Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor: organize application as a package #140

Merged
merged 19 commits into from
Sep 7, 2022
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
da446ac
refactor: move db and User into database module
angela-tran Aug 27, 2022
258f958
refactor: move Database into db package
angela-tran Aug 27, 2022
e246508
refactor: move db and User into a models module inside db package
angela-tran Aug 27, 2022
24fb1ec
refactor: convert setup script into click CLI command
angela-tran Aug 27, 2022
d475f6e
refactor: convert teardown script into click CLI command
angela-tran Aug 27, 2022
7a857d9
refactor: verify module uses current_app instead of actual app object
angela-tran Aug 30, 2022
a23143b
feat: make package importable through setup.py
angela-tran Aug 30, 2022
2d65a63
ci: add variable for use in run-tests workflow
angela-tran Aug 31, 2022
75dea8e
docs: correct some details about init script
angela-tran Sep 1, 2022
7de32c1
chore: remove unnecessary parameters leftover from refactoring
angela-tran Sep 2, 2022
5458a42
refactor: move init_app to module initialization
angela-tran Sep 2, 2022
1385b77
refactor: move database commands into single file
angela-tran Sep 2, 2022
ae836f0
chore(metadata): flesh out package metadata in setup script
angela-tran Sep 2, 2022
a898926
refactor: move check_user logic into Verify
angela-tran Sep 7, 2022
8481506
refactor(tests): move ported tests into more appropriate file
angela-tran Sep 7, 2022
2a0669a
chore: switch to using absolute imports
angela-tran Sep 7, 2022
4d3bdb1
feat(container): install package into app container
angela-tran Sep 7, 2022
39aaa7b
chore(metadata): remove unneeded specification of minor Python versions
angela-tran Sep 7, 2022
fd1c915
chore: fix some docstrings for database commands
angela-tran Sep 7, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
FLASK_APP=eligibility_server/app.py
ELIGIBILITY_SERVER_SETTINGS=../config/sample.py
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ jobs:

- name: Test with pytest
run: |
python setup.py
flask init-db
coverage run -m pytest
coverage report -m
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ __pycache__/
.env
config/*
!config/sample.py
eligibility_server.egg-info
2 changes: 1 addition & 1 deletion bin/init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ set -eux

# run database migrations

python setup.py
flask init-db
9 changes: 4 additions & 5 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,7 @@ Once you clone the repository locally, open it within VS Code, which will prompt
2. Start the `eligibility-server` Flask app and database with `F5`
3. Now you can run tests from the container.

Starting the Dev Container will run `bin/init.sh`, which runs `setup.py` and starts the Flask app. The `setup.py` script creates the database and imports and saves users
based on the configured settings.
Starting the Dev Container will run `bin/init.sh`, which runs a command to initialize the database. More specifically, it creates the database and imports and saves users based on the configured settings.

## Run tests

Expand All @@ -80,16 +79,16 @@ The test suite runs against every pull request via a GitHub Action.

In testing the database, you may need to teardown the database and restart a database from scratch.

The teardown script removes all users and drops the database. To tear down the database, run:
The command below will remove all users and drop the database:

```bash
python teardown.py
flask drop-db
machikoyasuda marked this conversation as resolved.
Show resolved Hide resolved
```

To set up the database with a new import file or other configuration variables, after making any new environment variable changes, run:

```bash
python setup.py
flask init-db
```

## Run and develop the Documentation
Expand Down
15 changes: 2 additions & 13 deletions eligibility_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@

from flask import Flask, jsonify, make_response
from flask_restful import Api
from flask_sqlalchemy import SQLAlchemy
from flask.logging import default_handler

from . import db
from .verify import Verify
from .keypair import get_server_public_key
thekaveman marked this conversation as resolved.
Show resolved Hide resolved

Expand Down Expand Up @@ -67,18 +67,7 @@ def internal_server_error(error):
api = Api(app)
api.add_resource(Verify, "/verify")

db = SQLAlchemy(app)


class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
sub = db.Column(db.String, unique=True, nullable=False)
name = db.Column(db.String, unique=True, nullable=False)
types = db.Column(db.String, unique=False, nullable=False)

def __repr__(self):
return "<User %r>" % self.sub

db.init_app(app)
angela-tran marked this conversation as resolved.
Show resolved Hide resolved

if __name__ == "__main__":
app.run(host=app.config["HOST"], debug=app.config["DEBUG_MODE"], port="8000") # nosec
17 changes: 17 additions & 0 deletions eligibility_server/db/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import logging

from flask_sqlalchemy import SQLAlchemy

logger = logging.getLogger(__name__)


db = SQLAlchemy()


def init_app(app):
db.init_app(app)

from .setup import init_db_command, drop_db_command

app.cli.add_command(init_db_command)
app.cli.add_command(drop_db_command)
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
"""
Simple hard-coded server database.
"""

import ast

from . import app
import logging

from .models import User
thekaveman marked this conversation as resolved.
Show resolved Hide resolved


logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -44,7 +41,7 @@ def check_user(self, sub: str, name: str, types: str) -> list:
sub = self._hash.hash_input(sub)
name = self._hash.hash_input(name)

existing_user = app.User.query.filter_by(sub=sub, name=name).first()
existing_user = User.query.filter_by(sub=sub, name=name).first()
if existing_user:
existing_user_types = ast.literal_eval(existing_user.types)
else:
Expand Down
11 changes: 11 additions & 0 deletions eligibility_server/db/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from . import db
thekaveman marked this conversation as resolved.
Show resolved Hide resolved


class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
sub = db.Column(db.String, unique=True, nullable=False)
name = db.Column(db.String, unique=True, nullable=False)
types = db.Column(db.String, unique=False, nullable=False)

def __repr__(self):
return "<User %r>" % self.sub
95 changes: 95 additions & 0 deletions eligibility_server/db/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import csv
import json

import click
thekaveman marked this conversation as resolved.
Show resolved Hide resolved
from flask import current_app
from flask_sqlalchemy import inspect

from .models import db, User


@click.command("init-db")
def init_db_command():
with current_app.app_context():
inspector = inspect(db.engine)

if inspector.get_table_names():
click.echo("Tables already exist.")
if User.query.count() == 0:
import_users()
else:
click.echo("User table already has data.")
else:
click.echo("Creating table...")
db.create_all()
click.echo("Table created.")

import_users()


def import_users():
"""
Imports user data to be added to database and saves user to database

Users can be imported from either a JSON file or CSV file, as configured
with settings from environment variables. CSV files take extra setting
configurations: CSV_DELIMITER, CSV_NEWLINE, CSV_QUOTING, CSV_QUOTECHAR
"""

file_path = current_app.config["IMPORT_FILE_PATH"]
click.echo(f"Importing users from {file_path}")

file_format = file_path.split(".")[-1]

if file_format == "json":
with open(file_path) as file:
data = json.load(file)["users"]
for user in data:
save_users(user, data[user][0], str(data[user][1]))
elif file_format == "csv":
with open(file_path, newline=current_app.config["CSV_NEWLINE"], encoding="utf-8") as file:
data = csv.reader(
file,
delimiter=current_app.config["CSV_DELIMITER"],
quoting=int(current_app.config["CSV_QUOTING"]),
quotechar=current_app.config["CSV_QUOTECHAR"],
)
for user in data:
save_users(user[0], user[1], user[2])
else:
click.echo(f"Warning: File format is not supported: {file_format}")

click.echo(f"Users added: {User.query.count()}")


def save_users(sub: str, name: str, types: str):
"""
Add users to the database User table

@param sub - User's ID, not to be confused with Database row ID
@param name - User's name
@param types - Types of eligibilities, in a stringified list
"""

item = User(sub=sub, name=name, types=types)
db.session.add(item)
db.session.commit()


@click.command("drop-db")
def drop_db_command():
with current_app.app_context():
inspector = inspect(db.engine)

if inspector.get_table_names():
try:
click.echo(f"Users to be deleted: {User.query.count()}")
User.query.delete()
db.session.commit()
except Exception as e:
click.echo("Failed to query for Users", e)

db.drop_all()
click.echo("Database dropped.")
else:
click.echo("Database does not exist.")
31 changes: 16 additions & 15 deletions eligibility_server/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
import re
import time

from flask import abort
from flask import abort, current_app
angela-tran marked this conversation as resolved.
Show resolved Hide resolved
from flask_restful import Resource, reqparse
from jwcrypto import jwe, jws, jwt

from . import app, keypair
from .database import Database
from . import keypair
from .db.database import Database
thekaveman marked this conversation as resolved.
Show resolved Hide resolved
from .hash import Hash

thekaveman marked this conversation as resolved.
Show resolved Hide resolved

Expand All @@ -26,27 +26,28 @@ def __init__(self):
self.client_public_key = keypair.get_client_public_key()
self.server_private_key = keypair.get_server_private_key()

if app.app.config["INPUT_HASH_ALGO"] != "":
hash = Hash(app.app.config["INPUT_HASH_ALGO"])
if current_app.config["INPUT_HASH_ALGO"] != "":
thekaveman marked this conversation as resolved.
Show resolved Hide resolved
hash = Hash(current_app.config["INPUT_HASH_ALGO"])
self._db = Database(hash=hash)
else:
self._db = Database()

def _check_headers(self):
"""Ensure correct request headers."""
req_parser = reqparse.RequestParser()
req_parser.add_argument(app.app.config["TOKEN_HEADER"], location="headers", required=True)
req_parser.add_argument(app.app.config["AUTH_HEADER"], location="headers", required=True)
req_parser.add_argument(current_app.config["TOKEN_HEADER"], location="headers", required=True)
req_parser.add_argument(current_app.config["AUTH_HEADER"], location="headers", required=True)
headers = req_parser.parse_args()

# verify auth_header's value
if headers.get(app.app.config["AUTH_HEADER"]) == app.app.config["AUTH_TOKEN"]:
if headers.get(current_app.config["AUTH_HEADER"]) == current_app.config["AUTH_TOKEN"]:
return headers
else:
return False

def _get_token(self, headers):
"""Get the token from request headers"""
token = headers.get(app.app.config["TOKEN_HEADER"], "").split(" ")
token = headers.get(current_app.config["TOKEN_HEADER"], "").split(" ")
if len(token) == 2:
return token[1]
elif len(token) == 1:
Expand All @@ -59,12 +60,12 @@ def _get_token_payload(self, token):
"""Decode a token (JWE(JWS))."""
try:
# decrypt
decrypted_token = jwe.JWE(algs=[app.app.config["JWE_ENCRYPTION_ALG"], app.app.config["JWE_CEK_ENC"]])
decrypted_token = jwe.JWE(algs=[current_app.config["JWE_ENCRYPTION_ALG"], current_app.config["JWE_CEK_ENC"]])
decrypted_token.deserialize(token, key=self.server_private_key)
decrypted_payload = str(decrypted_token.payload, "utf-8")
# verify signature
signed_token = jws.JWS()
signed_token.deserialize(decrypted_payload, key=self.client_public_key, alg=app.app.config["JWS_SIGNING_ALG"])
signed_token.deserialize(decrypted_payload, key=self.client_public_key, alg=current_app.config["JWS_SIGNING_ALG"])
# return final payload
payload = str(signed_token.payload, "utf-8")
return json.loads(payload)
Expand All @@ -75,12 +76,12 @@ def _get_token_payload(self, token):
def _make_token(self, payload):
"""Wrap payload in a signed and encrypted JWT for response."""
# sign the payload with server's private key
header = {"typ": "JWS", "alg": app.app.config["JWS_SIGNING_ALG"]}
header = {"typ": "JWS", "alg": current_app.config["JWS_SIGNING_ALG"]}
signed_token = jwt.JWT(header=header, claims=payload)
signed_token.make_signed_token(self.server_private_key)
signed_payload = signed_token.serialize()
# encrypt the signed payload with client's public key
header = {"typ": "JWE", "alg": app.app.config["JWE_ENCRYPTION_ALG"], "enc": app.app.config["JWE_CEK_ENC"]}
header = {"typ": "JWE", "alg": current_app.config["JWE_ENCRYPTION_ALG"], "enc": current_app.config["JWE_CEK_ENC"]}
encrypted_token = jwt.JWT(header=header, claims=signed_payload)
encrypted_token.make_encrypted_token(self.client_public_key)
return encrypted_token.serialize()
Expand All @@ -91,11 +92,11 @@ def _get_response(self, token_payload):
sub, name, eligibility = token_payload["sub"], token_payload["name"], list(token_payload["eligibility"])
resp_payload = dict(
jti=token_payload["jti"],
iss=app.app.config["APP_NAME"],
iss=current_app.config["APP_NAME"],
iat=int(datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).timestamp()),
)
# sub format check
if re.match(app.app.config["SUB_FORMAT_REGEX"], sub):
if re.match(current_app.config["SUB_FORMAT_REGEX"], sub):
# eligibility check against db
resp_payload["eligibility"] = self._db.check_user(sub, name, eligibility)
code = 200
Expand Down
Loading