Skip to content
This repository was archived by the owner on Oct 23, 2023. It is now read-only.

Commit 3245e6e

Browse files
committed
update ga4gh with fixes
1 parent a340551 commit 3245e6e

6 files changed

Lines changed: 197 additions & 215 deletions

File tree

beacon_api/api/exceptions.py

Lines changed: 39 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -10,37 +10,35 @@
1010
from ..conf import CONFIG_INFO
1111

1212

13-
class BeaconError(Exception):
14-
"""BeaconError Exception specific class.
13+
def process_exception_data(request, host, error_code, error):
14+
"""Return request data as dictionary.
1515
1616
Generates custom exception messages based on request parameters.
1717
"""
18-
19-
def __init__(self, request, host, error_code, error):
20-
"""Return request data as dictionary."""
21-
self.data = {'beaconId': '.'.join(reversed(host.split('.'))),
22-
"apiVersion": __apiVersion__,
23-
'exists': None,
24-
'error': {'errorCode': error_code,
25-
'errorMessage': error},
26-
'alleleRequest': {'referenceName': request.get("referenceName", None),
27-
'referenceBases': request.get("referenceBases", None),
28-
'includeDatasetResponses': request.get("includeDatasetResponses", "NONE"),
29-
'assemblyId': request.get("assemblyId", None)},
30-
# showing empty datasetsAlleRsponse as no datasets found
31-
# A null/None would represent no data while empty array represents
32-
# none found or error and corresponds with exists null/None
33-
'datasetAlleleResponses': []}
34-
# include datasetIds only if they are specified
35-
# as per specification if they don't exist all datatsets will be queried
36-
# Only one of `alternateBases` or `variantType` is required, validated by schema
37-
oneof_fields = ["alternateBases", "variantType", "start", "end", "startMin", "startMax",
38-
"endMin", "endMax", "datasetIds"]
39-
self.data['alleleRequest'].update({k: request.get(k) for k in oneof_fields if k in request})
40-
return self.data
41-
42-
43-
class BeaconBadRequest(BeaconError):
18+
data = {'beaconId': '.'.join(reversed(host.split('.'))),
19+
"apiVersion": __apiVersion__,
20+
'exists': None,
21+
'error': {'errorCode': error_code,
22+
'errorMessage': error},
23+
'alleleRequest': {'referenceName': request.get("referenceName", None),
24+
'referenceBases': request.get("referenceBases", None),
25+
'includeDatasetResponses': request.get("includeDatasetResponses", "NONE"),
26+
'assemblyId': request.get("assemblyId", None)},
27+
# showing empty datasetsAlleRsponse as no datasets found
28+
# A null/None would represent no data while empty array represents
29+
# none found or error and corresponds with exists null/None
30+
'datasetAlleleResponses': []}
31+
# include datasetIds only if they are specified
32+
# as per specification if they don't exist all datatsets will be queried
33+
# Only one of `alternateBases` or `variantType` is required, validated by schema
34+
oneof_fields = ["alternateBases", "variantType", "start", "end", "startMin", "startMax",
35+
"endMin", "endMax", "datasetIds"]
36+
data['alleleRequest'].update({k: request.get(k) for k in oneof_fields if k in request})
37+
38+
return data
39+
40+
41+
class BeaconBadRequest(web.HTTPBadRequest):
4442
"""Exception returns with 400 code and a custom error message.
4543
4644
The method is called if one of the required parameters are missing or invalid.
@@ -49,13 +47,12 @@ class BeaconBadRequest(BeaconError):
4947

5048
def __init__(self, request, host, error):
5149
"""Return custom bad request exception."""
52-
data = super().__init__(request, host, 400, error)
53-
54-
LOG.error(f'400 ERROR MESSAGE: {error}')
55-
raise web.HTTPBadRequest(content_type="application/json", text=json.dumps(data))
50+
data = process_exception_data(request, host, 400, error)
51+
super().__init__(text=json.dumps(data), content_type="application/json")
52+
LOG.error(f'401 ERROR MESSAGE: {error}')
5653

5754

58-
class BeaconUnauthorised(BeaconError):
55+
class BeaconUnauthorised(web.HTTPUnauthorized):
5956
"""HTTP Exception returns with 401 code with a custom error message.
6057
6158
The method is called if the user is not registered or if the token from the authentication has expired.
@@ -64,17 +61,17 @@ class BeaconUnauthorised(BeaconError):
6461

6562
def __init__(self, request, host, error, error_message):
6663
"""Return custom unauthorized exception."""
67-
data = super().__init__(request, host, 401, error)
64+
data = process_exception_data(request, host, 401, error)
6865
headers_401 = {"WWW-Authenticate": f"Bearer realm=\"{CONFIG_INFO.url}\"\n\
6966
error=\"{error}\"\n\
7067
error_description=\"{error_message}\""}
68+
super().__init__(content_type="application/json", text=json.dumps(data),
69+
# we use auth scheme Bearer by default
70+
headers=headers_401)
7171
LOG.error(f'401 ERROR MESSAGE: {error}')
72-
raise web.HTTPUnauthorized(content_type="application/json", text=json.dumps(data),
73-
# we use auth scheme Bearer by default
74-
headers=headers_401)
7572

7673

77-
class BeaconForbidden(BeaconError):
74+
class BeaconForbidden(web.HTTPForbidden):
7875
"""HTTP Exception returns with 403 code with the error message.
7976
8077
`'Resource not granted for authenticated user or resource protected for all users.'`.
@@ -84,13 +81,12 @@ class BeaconForbidden(BeaconError):
8481

8582
def __init__(self, request, host, error):
8683
"""Return custom forbidden exception."""
87-
data = super().__init__(request, host, 403, error)
88-
84+
data = process_exception_data(request, host, 403, error)
85+
super().__init__(content_type="application/json", text=json.dumps(data))
8986
LOG.error(f'403 ERROR MESSAGE: {error}')
90-
raise web.HTTPForbidden(content_type="application/json", text=json.dumps(data))
9187

9288

93-
class BeaconServerError(BeaconError):
89+
class BeaconServerError(web.HTTPInternalServerError):
9490
"""HTTP Exception returns with 500 code with the error message.
9591
9692
The 500 error is not specified by the Beacon API, thus as simple error would do.
@@ -100,6 +96,5 @@ def __init__(self, error):
10096
"""Return custom forbidden exception."""
10197
data = {'errorCode': 500,
10298
'errorMessage': error}
103-
99+
super().__init__(content_type="application/json", text=json.dumps(data))
104100
LOG.error(f'500 ERROR MESSAGE: {error}')
105-
raise web.HTTPInternalServerError(content_type="application/json", text=json.dumps(data))

beacon_api/permissions/ga4gh.py

Lines changed: 43 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
.. code-block:: javascript
88
99
{
10-
"scope": "ga4gh_passport_v1",
10+
"scope": "openid ga4gh_passport_v1",
1111
...
1212
}
1313
@@ -87,13 +87,25 @@
8787
import aiohttp
8888

8989
from authlib.jose import jwt
90-
from authlib.jose.errors import MissingClaimError, InvalidClaimError, ExpiredTokenError, InvalidTokenError
9190

9291
from ..api.exceptions import BeaconServerError
9392
from ..utils.logging import LOG
9493
from ..conf import OAUTH2_CONFIG
9594

9695

96+
async def check_ga4gh_token(decoded_data, token, bona_fide_status, dataset_permissions):
97+
"""Check the token for GA4GH claims."""
98+
if 'scope' in decoded_data:
99+
ga4gh_scopes = ['openid', 'ga4gh_passport_v1']
100+
token_scopes = decoded_data.get('scope').split(' ')
101+
LOG.info(f'GA4H Required scopes: {ga4gh_scopes}')
102+
LOG.info(f'Token scopes: {token_scopes}')
103+
LOG.info(f'Bona fide before: {bona_fide_status}')
104+
LOG.info(f'Permissions before: {dataset_permissions}')
105+
if all(scope in token_scopes for scope in ga4gh_scopes):
106+
dataset_permissions, bona_fide_status = await get_ga4gh_permissions(token)
107+
108+
97109
async def decode_passport(encoded_passport):
98110
"""Return decoded header and payload from encoded passport JWT.
99111
@@ -148,10 +160,11 @@ async def get_ga4gh_permissions(token):
148160
# Decode passport
149161
header, payload = await decode_passport(encoded_passport)
150162
# Sort passports that carry dataset permissions
151-
if payload.get('ga4gh_visa_v1', {}).get('type') == 'ControlledAccessGrants':
163+
pass_type = payload.get('ga4gh_visa_v1', {}).get('type')
164+
if pass_type == 'ControlledAccessGrants':
152165
dataset_passports.append((encoded_passport, header))
153166
# Sort passports that MAY carry bona fide status information
154-
if payload.get('ga4gh_visa_v1', {}).get('type') in ['AcceptedTermsAndPolicies', 'ResearcherStatus']:
167+
if pass_type in ['AcceptedTermsAndPolicies', 'ResearcherStatus']:
155168
bona_fide_passports.append((encoded_passport, header, payload))
156169

157170
# Parse dataset passports to extract dataset permissions and validate them
@@ -227,20 +240,8 @@ async def validate_passport(passport):
227240
decoded_passport.validate()
228241
# Return decoded and validated payload contents
229242
return decoded_passport
230-
except MissingClaimError as e:
231-
LOG.error(f'Missing claim(s): {e}')
232-
pass
233-
except ExpiredTokenError as e:
234-
LOG.error(f'Expired signature: {e}')
235-
pass
236-
except InvalidClaimError as e:
237-
LOG.error(f'Token info not corresponding with claim: {e}')
238-
pass
239-
except InvalidTokenError as e:
240-
LOG.error(f'Invalid authorization token: {e}')
241-
pass
242-
243-
return None
243+
except Exception as e:
244+
LOG.error(f"Something went wrong when processing JWT tokens: {e}")
244245

245246

246247
async def get_ga4gh_controlled(passports):
@@ -274,35 +275,31 @@ async def get_ga4gh_bona_fide(passports):
274275
# Check for the `type` of visa to determine if to look for `terms` or `status`
275276
#
276277
# CHECK FOR TERMS
277-
if passport[2].get('ga4gh_visa_v1', {}).get('type') == 'AcceptedTermsAndPolicies':
278-
# Check if the visa contains a bona fide value
279-
if passport[2].get('ga4gh_visa_v1', {}).get('value') == OAUTH2_CONFIG.bona_fide_value:
280-
# This passport has the correct type and value, next step is to validate it
281-
#
282-
# Decode passport and validate its contents
283-
# If the validation passes, terms will be set to True
284-
# If the validation fails, an exception will be raised
285-
# (and ignored since it's not fatal), and terms will remain False
286-
await validate_passport(passport)
287-
# The token is validated, therefore the terms are accepted
288-
terms = True
278+
passport_type = passport[2].get('ga4gh_visa_v1', {}).get('type')
279+
passport_value = passport[2].get('ga4gh_visa_v1', {}).get('value')
280+
if passport_type in 'AcceptedTermsAndPolicies' and passport_value == OAUTH2_CONFIG.bona_fide_value:
281+
# This passport has the correct type and value, next step is to validate it
282+
#
283+
# Decode passport and validate its contents
284+
# If the validation passes, terms will be set to True
285+
# If the validation fails, an exception will be raised
286+
# (and ignored since it's not fatal), and terms will remain False
287+
await validate_passport(passport)
288+
# The token is validated, therefore the terms are accepted
289+
terms = True
289290
#
290291
# CHECK FOR STATUS
291-
if passport[2].get('ga4gh_visa_v1', {}).get('type') == 'ResearcherStatus':
292+
if passport_value == OAUTH2_CONFIG.bona_fide_value and passport_type == 'ResearcherStatus':
292293
# Check if the visa contains a bona fide value
293-
if passport[2].get('ga4gh_visa_v1', {}).get('value') == OAUTH2_CONFIG.bona_fide_value:
294-
# This passport has the correct type and value, next step is to validate it
295-
#
296-
# Decode passport and validate its contents
297-
# If the validation passes, status will be set to True
298-
# If the validation fails, an exception will be raised
299-
# (and ignored since it's not fatal), and status will remain False
300-
await validate_passport(passport)
301-
# The token is validated, therefore the status is accepted
302-
status = True
303-
304-
if terms and status:
305-
# User has agreed to terms and has been recognized by a peer, return True for Bona Fide status
306-
return True
294+
# This passport has the correct type and value, next step is to validate it
295+
#
296+
# Decode passport and validate its contents
297+
# If the validation passes, status will be set to True
298+
# If the validation fails, an exception will be raised
299+
# (and ignored since it's not fatal), and status will remain False
300+
await validate_passport(passport)
301+
# The token is validated, therefore the status is accepted
302+
status = True
307303

308-
return False
304+
# User has agreed to terms and has been recognized by a peer, return True for Bona Fide status
305+
return terms and status

beacon_api/utils/validate.py

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from aiocache.serializers import JsonSerializer
1414
from ..api.exceptions import BeaconUnauthorised, BeaconBadRequest, BeaconForbidden, BeaconServerError
1515
from ..conf import OAUTH2_CONFIG
16-
from ..permissions.ga4gh import get_ga4gh_permissions
16+
from ..permissions.ga4gh import check_ga4gh_token
1717
from jsonschema import Draft7Validator, validators
1818
from jsonschema.exceptions import ValidationError
1919

@@ -183,19 +183,13 @@ async def token_middleware(request, handler):
183183
# for now the permissions just reflects that the data can be decoded from token
184184
# the bona fide status is checked against ELIXIR AAI by default or the URL from config
185185
# the bona_fide_status is specific to ELIXIR Tokens
186-
#
186+
dataset_permissions, bona_fide_status = [], ''
187+
187188
# Retrieve GA4GH Passports from /userinfo and process them into dataset permissions and bona fide status
188189
bona_fide_status = False
189190
dataset_permissions = set()
190-
required_scopes = ['openid', 'ga4gh_passport_v1']
191-
token_scopes = decoded_data.get('scope').split(' ')
192-
LOG.info(f'Required scopes: {required_scopes}')
193-
LOG.info(f'Token scopes: {token_scopes}')
194-
LOG.info(f'Bona fide before: {bona_fide_status}')
195-
LOG.info(f'Permissions before: {dataset_permissions}')
196-
if all(scope in token_scopes for scope in required_scopes):
197-
dataset_permissions, bona_fide_status = await get_ga4gh_permissions(token)
198-
#
191+
check_ga4gh_token(decoded_data, token, bona_fide_status, dataset_permissions)
192+
199193
LOG.info(f'Bona fide after: {bona_fide_status}')
200194
LOG.info(f'Permissions after: {dataset_permissions}')
201195
controlled_datasets = set()

tests/test_app.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,14 @@ def generate_token(issuer):
3737
"iss": issuer,
3838
"aud": "audience",
3939
"exp": 9999999999,
40-
"sub": "smth@elixir-europe.org"
40+
"sub": "smth@smth.org"
4141
}
4242
token = jwt.encode(header, payload, pem).decode('utf-8')
4343
return token, pem
4444

4545

4646
def generate_bad_token():
47-
"""Mock ELIXIR AAI token."""
47+
"""Mock AAI token."""
4848
pem = {
4949
"kty": "oct",
5050
"kid": "018c0ae5-4d9b-471b-bfd6-eef314bc7037",
@@ -286,7 +286,7 @@ async def test_invalid_scheme_get_query(self):
286286
@asynctest.mock.patch('beacon_api.app.query_request_handler', side_effect=json.dumps(PARAMS))
287287
@unittest_run_loop
288288
async def test_valid_token_get_query(self, mock_handler, mock_object):
289-
"""Test valid token GET query endpoint, invalid scheme."""
289+
"""Test valid token GET query endpoint."""
290290
token = os.environ.get('TOKEN')
291291
resp = await self.client.request("POST", "/query",
292292
data=json.dumps(PARAMS),

0 commit comments

Comments
 (0)