Skip to content

Commit

Permalink
Merge remote-tracking branch 'remotes/origin/master' into st_dh_email…
Browse files Browse the repository at this point in the history
…_routes

# Conflicts:
#	microsetta_private_api/admin/admin_impl.py
  • Loading branch information
dhakim87 committed Jul 9, 2020
2 parents 8cefb80 + 65ebac7 commit d6993b0
Show file tree
Hide file tree
Showing 22 changed files with 1,428 additions and 1,073 deletions.
13 changes: 5 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ Once the conda environment is created, activate it:

`conda activate microsetta-private-api`

Ensure that the `conda-forge` channel has been added to the conda install and run:

`conda install -c conda-forge python-dateutil pycountry coveralls pytest-cov`

Install connexion version 2.0 (which supports the OpenAPI Specification 3.0) as well as the Swagger UI:

`pip install connexion[swagger-ui]`
Expand All @@ -37,12 +41,5 @@ In the activated conda environment, start the microservice using flask's built-i
which will start the server on http://localhost:8082 . Note that this usage is suitable for
**development ONLY**--real use of the service would require a production-level server.

The Swagger UI should now be available at http://localhost:8082/api/ui .

Currently the `/account` `get` interface (only) requires oath2 authentication, but this is currently mocked to return
the same token info no matter what bearer token value is passed in. This dummy-authenticated method can be tested from
the command line using the following command to examine an account record added by `repo_test_scratch.py`

`curl -H 'Authorization: Bearer dummy' http://localhost:8082/api/accounts/aaaaaaaa-bbbb-cccc-dddd-eeeeffffffff`

The Swagger UI should also be available at http://localhost:8082/api/ui .

22 changes: 20 additions & 2 deletions microsetta_private_api/admin/admin_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@

import flask
from flask import jsonify
import datetime

from microsetta_private_api.admin.email_templates import EmailMessage
from microsetta_private_api.config_manager import SERVER_CONFIG
from microsetta_private_api.model.log_event import LogEvent
from microsetta_private_api.exceptions import RepoException
from microsetta_private_api.repo.account_repo import AccountRepo
from microsetta_private_api.repo.event_log_repo import EventLogRepo
from microsetta_private_api.repo.transaction import Transaction
from microsetta_private_api.repo.admin_repo import AdminRepo
from microsetta_private_api.util.email import SendEmail
from microsetta_private_api.util.redirects import build_login_redirect
from werkzeug.exceptions import Unauthorized
from werkzeug.exceptions import Unauthorized, BadRequest


def search_barcode(token_info, sample_barcode):
Expand Down Expand Up @@ -122,13 +124,29 @@ def create_project(body, token_info):

project_name = body['project_name']
is_microsetta = body['is_microsetta']
bank_samples = body['bank_samples']
plating_start_date = body.get('plating_start_date')

if plating_start_date is not None:
try:
plating_start_date = datetime.datetime.strptime(
plating_start_date, "%Y-%m-%d")
except ValueError:
raise BadRequest(
"plating start date '{0}' is not a valid date in YYYY-MM-DD "
"format".format(plating_start_date))

if len(project_name) == 0:
return jsonify(code=400, message="No project name provided"), 400

if not bank_samples and plating_start_date is not None:
raise RepoException("Plating start date cannot be set for"
" unbanked projects")

with Transaction() as t:
admin_repo = AdminRepo(t)
admin_repo.create_project(project_name, is_microsetta)
admin_repo.create_project(project_name, is_microsetta, bank_samples,
plating_start_date)
t.commit()

return {}, 201
Expand Down
18 changes: 12 additions & 6 deletions microsetta_private_api/admin/tests/test_admin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from unittest import TestCase
from datetime import date

from werkzeug.exceptions import Unauthorized, NotFound

Expand Down Expand Up @@ -129,19 +130,24 @@ def test_create_project(self):
"WHERE project = 'doesnotexist'")
self.assertEqual(len(cur.fetchall()), 0)

admin_repo.create_project('doesnotexist', True)
cur.execute("SELECT project, is_microsetta "
admin_repo.create_project('doesnotexist', True, False)
cur.execute("SELECT project, is_microsetta, bank_samples, "
"plating_start_date "
"FROM barcodes.project "
"WHERE project = 'doesnotexist'")
obs = cur.fetchall()
self.assertEqual(obs, [('doesnotexist', True), ])
self.assertEqual(obs, [('doesnotexist', True, False, None), ])

admin_repo.create_project('doesnotexist2', False)
cur.execute("SELECT project, is_microsetta "
plating_start_date = date(2020, 7, 31)
admin_repo.create_project('doesnotexist2', False, True,
plating_start_date)
cur.execute("SELECT project, is_microsetta, bank_samples, "
"plating_start_date "
"FROM barcodes.project "
"WHERE project = 'doesnotexist2'")
obs = cur.fetchall()
self.assertEqual(obs, [('doesnotexist2', False), ])
self.assertEqual(obs, [('doesnotexist2', False, True,
plating_start_date), ])

def test_create_kits(self):
with Transaction() as t:
Expand Down
52 changes: 52 additions & 0 deletions microsetta_private_api/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from ._account import (
find_accounts_for_login, register_account, claim_legacy_acct,
read_account, update_account, check_email_match, verify_authrocket,
)
from ._consent import (
render_consent_doc,
)
from ._source import (
create_source, read_source, update_source, delete_source,
read_sources, create_human_source_from_consent
)
from ._survey import (
read_survey_template, read_survey_templates, read_answered_survey,
read_answered_surveys, submit_answered_survey,
read_answered_survey_associations,
)
from ._sample import (
read_sample_association, associate_sample, read_sample_associations,
update_sample_association, dissociate_answered_survey,
dissociate_sample, read_kit, associate_answered_survey
)

__all__ = [
'find_accounts_for_login',
'register_account',
'claim_legacy_acct',
'read_account',
'update_account',
'check_email_match',
'render_consent_doc',
'create_source',
'read_source',
'update_source',
'delete_source',
'read_sources',
'create_human_source_from_consent',
'read_survey_template',
'read_survey_templates',
'read_answered_survey',
'read_answered_surveys',
'read_answered_survey_associations',
'read_sample_association',
'associate_sample',
'read_sample_associations',
'update_sample_association',
'dissociate_answered_survey',
'dissociate_sample',
'read_kit',
'associate_answered_survey',
'submit_answered_survey',
'verify_authrocket',
]
181 changes: 181 additions & 0 deletions microsetta_private_api/api/_account.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import uuid

import jwt
from flask import jsonify
from jwt import InvalidTokenError

from werkzeug.exceptions import Unauthorized, Forbidden, NotFound

from microsetta_private_api.api.literals import AUTHROCKET_PUB_KEY, \
INVALID_TOKEN_MSG, JWT_ISS_CLAIM_KEY, JWT_SUB_CLAIM_KEY, \
JWT_EMAIL_CLAIM_KEY, ACCT_NOT_FOUND_MSG
from microsetta_private_api.model.account import Account, AuthorizationMatch
from microsetta_private_api.model.address import Address
from microsetta_private_api.repo.account_repo import AccountRepo
from microsetta_private_api.repo.kit_repo import KitRepo
from microsetta_private_api.repo.transaction import Transaction


def find_accounts_for_login(token_info):
# Note: Returns an array of accounts accessible by token_info because
# we'll use that functionality when we add in administrator accounts.
with Transaction() as t:
acct_repo = AccountRepo(t)
acct = acct_repo.find_linked_account(
token_info[JWT_ISS_CLAIM_KEY],
token_info[JWT_SUB_CLAIM_KEY])

if acct is None:
return jsonify([]), 200

return jsonify([acct.to_api()]), 200


def claim_legacy_acct(token_info):
# If there exists a legacy account for the email in the token, which the
# user represented by the token does not already own but can claim, this
# claims the legacy account for the user and returns a 200 code with json
# list containing the object for the claimed account. Otherwise, this
# returns an empty json list. This function can also trigger a 422 from the
# repo layer in the case of inconsistent account data.

email = token_info[JWT_EMAIL_CLAIM_KEY]
auth_iss = token_info[JWT_ISS_CLAIM_KEY]
auth_sub = token_info[JWT_SUB_CLAIM_KEY]

with Transaction() as t:
acct_repo = AccountRepo(t)
acct = acct_repo.claim_legacy_account(email, auth_iss, auth_sub)
t.commit()

if acct is None:
return jsonify([]), 200

return jsonify([acct.to_api()]), 200


def register_account(body, token_info):
# First register with AuthRocket, then come here to make the account
new_acct_id = str(uuid.uuid4())
body["id"] = new_acct_id
account_obj = Account.from_dict(body, token_info[JWT_ISS_CLAIM_KEY],
token_info[JWT_SUB_CLAIM_KEY])

with Transaction() as t:
kit_repo = KitRepo(t)
kit = kit_repo.get_kit_all_samples(body['kit_name'])
if kit is None:
return jsonify(code=404, message="Kit name not found"), 404

acct_repo = AccountRepo(t)
acct_repo.create_account(account_obj)
new_acct = acct_repo.get_account(new_acct_id)
t.commit()

response = jsonify(new_acct.to_api())
response.status_code = 201
response.headers['Location'] = '/api/accounts/%s' % new_acct_id
return response


def read_account(account_id, token_info):
acc = _validate_account_access(token_info, account_id)
return jsonify(acc.to_api()), 200


def check_email_match(account_id, token_info):
acc = _validate_account_access(token_info, account_id)

match_status = acc.account_matches_auth(
token_info[JWT_EMAIL_CLAIM_KEY], token_info[JWT_ISS_CLAIM_KEY],
token_info[JWT_SUB_CLAIM_KEY])

if match_status == AuthorizationMatch.AUTH_ONLY_MATCH:
result = {'email_match': False}
elif match_status == AuthorizationMatch.FULL_MATCH:
result = {'email_match': True}
else:
raise ValueError("Unexpected authorization match value")

return jsonify(result), 200


def update_account(account_id, body, token_info):
acc = _validate_account_access(token_info, account_id)

with Transaction() as t:
acct_repo = AccountRepo(t)
acc.first_name = body['first_name']
acc.last_name = body['last_name']
acc.email = body['email']
acc.address = Address(
body['address']['street'],
body['address']['city'],
body['address']['state'],
body['address']['post_code'],
body['address']['country_code']
)

# 422 handling is done inside acct_repo
acct_repo.update_account(acc)
t.commit()

return jsonify(acc.to_api()), 200


def verify_authrocket(token):
email_verification_key = 'email_verified'

try:
token_info = jwt.decode(token,
AUTHROCKET_PUB_KEY,
algorithms=["RS256"],
verify=True,
issuer="https://authrocket.com")
except InvalidTokenError as e:
raise(Unauthorized(INVALID_TOKEN_MSG, e))

if JWT_ISS_CLAIM_KEY not in token_info or \
JWT_SUB_CLAIM_KEY not in token_info or \
JWT_EMAIL_CLAIM_KEY not in token_info:
# token is malformed--no soup for you
raise Unauthorized(INVALID_TOKEN_MSG)

# if the user's email is not yet verified, they are forbidden to
# access their account even regardless of whether they have
# authenticated with authrocket
if email_verification_key not in token_info or \
token_info[email_verification_key] is not True:
raise Forbidden("Email is not verified")

return token_info


def _validate_account_access(token_info, account_id):
with Transaction() as t:
account_repo = AccountRepo(t)
token_associated_account = account_repo.find_linked_account(
token_info['iss'],
token_info['sub'])
account = account_repo.get_account(account_id)
if account is None:
raise NotFound(ACCT_NOT_FOUND_MSG)
else:
# Whether or not the token_info is associated with an admin acct
token_authenticates_admin = \
token_associated_account is not None and \
token_associated_account.account_type == 'admin'

# Enum of how closely token info matches requested account_id
auth_match = account.account_matches_auth(
token_info[JWT_EMAIL_CLAIM_KEY],
token_info[JWT_ISS_CLAIM_KEY],
token_info[JWT_SUB_CLAIM_KEY])

# If token doesn't match requested account id, and doesn't grant
# admin access to the system, deny.
if auth_match == AuthorizationMatch.NO_MATCH and \
not token_authenticates_admin:
raise Unauthorized()

return account
22 changes: 22 additions & 0 deletions microsetta_private_api/api/_consent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from flask import render_template, jsonify

from microsetta_private_api import localization
from microsetta_private_api.api._account import \
_validate_account_access


def render_consent_doc(account_id, language_tag, consent_post_url, token_info):
_validate_account_access(token_info, account_id)

# NB: Do NOT need to explicitly pass account_id into template for
# integration into form submission URL because form submit URL builds on
# the base of the URL that called it (which includes account_id)

localization_info = localization.LANG_SUPPORT[language_tag]
consent_html = render_template(
"new_participant.jinja2",
tl=localization_info[localization.NEW_PARTICIPANT_KEY],
lang_tag=language_tag,
post_url=consent_post_url
)
return jsonify({"consent_html": consent_html}), 200

0 comments on commit d6993b0

Please sign in to comment.