From df8fcae4795872c23bee6c12298ef73ac788bbe3 Mon Sep 17 00:00:00 2001 From: Daniel Lorigan Date: Tue, 2 Apr 2024 18:41:20 -0700 Subject: [PATCH 01/10] Add scope filter to limit user access Limit user access to requests for their patient resource and resources that reference their patient resource, based on the id found in their token. --- jwt_proxy/api.py | 40 +++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/jwt_proxy/api.py b/jwt_proxy/api.py index 4675554..e945a21 100644 --- a/jwt_proxy/api.py +++ b/jwt_proxy/api.py @@ -2,12 +2,35 @@ import jwt import requests import json +import re from jwt_proxy.audit import audit_HAPI_change blueprint = Blueprint('auth', __name__) SUPPORTED_METHODS = ('GET', 'POST', 'PUT', 'DELETE', 'OPTIONS') +# TODO: to be pulled into its own module and loaded per config +def scope_filter(req, token): + user_id = token.get("sub") + pattern = rf"(.*?\|)?{user_id}" + # Search params + params = req.args + id_param_value = params.get("_identifier", params.get("subject.identifier")) + if id_param_value is not None and re.search(pattern, id_param_value): + return True + # Search body + data = request.get_data() + if data: + try: + parsed_data = json.loads(data) + except (ValueError, TypeError): + return False + reference_string = parsed_data.get('subject', {}).get('reference') + if reference_string is not None and re.search(pattern, reference_string): + return True + return False + + def proxy_request(req, upstream_url, user_info=None): """Forward request to given url""" @@ -67,13 +90,16 @@ def validate_jwt(relative_path): ) except jwt.exceptions.ExpiredSignatureError: return jsonify(message="token expired"), 401 - - response_content = proxy_request( - req=request, - upstream_url=f"{current_app.config['UPSTREAM_SERVER']}/{relative_path}", - user_info=decoded_token.get("email") or decoded_token.get("preferred_username"), - ) - return response_content + + # TODO: call new function here to dynamically load a filter call dependent on config; hardwired for now + if scope_filter(req, decoded_token): + response_content = proxy_request( + req=request, + upstream_url=f"{current_app.config['UPSTREAM_SERVER']}/{relative_path}", + user_info=decoded_token.get("email") or decoded_token.get("preferred_username"), + ) + return response_content + return jsonify(message="invalid request"), 400 @blueprint.route("/fhir/.well-known/smart-configuration") From e316db6eab004dc72dce1be0fc44399ab3f6f6a4 Mon Sep 17 00:00:00 2001 From: Daniel Lorigan Date: Wed, 3 Apr 2024 00:00:11 -0700 Subject: [PATCH 02/10] Add scope filter function --- jwt_proxy/api.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/jwt_proxy/api.py b/jwt_proxy/api.py index e945a21..468f968 100644 --- a/jwt_proxy/api.py +++ b/jwt_proxy/api.py @@ -15,19 +15,19 @@ def scope_filter(req, token): pattern = rf"(.*?\|)?{user_id}" # Search params params = req.args - id_param_value = params.get("_identifier", params.get("subject.identifier")) + id_param_value = params.get("identifier", params.get("_identifier", params.get("subject.identifier"))) if id_param_value is not None and re.search(pattern, id_param_value): - return True + return "params" # Search body - data = request.get_data() - if data: + if req.is_json: try: + data = req.get_json() parsed_data = json.loads(data) except (ValueError, TypeError): - return False + return "Error False" reference_string = parsed_data.get('subject', {}).get('reference') if reference_string is not None and re.search(pattern, reference_string): - return True + return "Body" return False From 79e3933534610ad9eba54380e433495b782431ab Mon Sep 17 00:00:00 2001 From: Daniel Lorigan Date: Wed, 3 Apr 2024 00:02:10 -0700 Subject: [PATCH 03/10] Remove temp returns --- jwt_proxy/api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jwt_proxy/api.py b/jwt_proxy/api.py index 468f968..d080179 100644 --- a/jwt_proxy/api.py +++ b/jwt_proxy/api.py @@ -17,17 +17,17 @@ def scope_filter(req, token): params = req.args id_param_value = params.get("identifier", params.get("_identifier", params.get("subject.identifier"))) if id_param_value is not None and re.search(pattern, id_param_value): - return "params" + return True # Search body if req.is_json: try: data = req.get_json() parsed_data = json.loads(data) except (ValueError, TypeError): - return "Error False" + return False reference_string = parsed_data.get('subject', {}).get('reference') if reference_string is not None and re.search(pattern, reference_string): - return "Body" + return True return False From 1512e58ecf7b003add78b401a27adab69f31d006 Mon Sep 17 00:00:00 2001 From: Daniel Lorigan Date: Wed, 3 Apr 2024 12:13:24 -0700 Subject: [PATCH 04/10] Fix scope_filter arg --- jwt_proxy/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jwt_proxy/api.py b/jwt_proxy/api.py index d080179..555ef4e 100644 --- a/jwt_proxy/api.py +++ b/jwt_proxy/api.py @@ -92,7 +92,7 @@ def validate_jwt(relative_path): return jsonify(message="token expired"), 401 # TODO: call new function here to dynamically load a filter call dependent on config; hardwired for now - if scope_filter(req, decoded_token): + if scope_filter(request, decoded_token): response_content = proxy_request( req=request, upstream_url=f"{current_app.config['UPSTREAM_SERVER']}/{relative_path}", From fc099f9b49cba6e5a2557efd8a1f06a2653cbc14 Mon Sep 17 00:00:00 2001 From: Daniel Lorigan Date: Wed, 3 Apr 2024 14:17:17 -0700 Subject: [PATCH 05/10] Add ltt-specific id system and resource types; fix body check --- jwt_proxy/api.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/jwt_proxy/api.py b/jwt_proxy/api.py index 555ef4e..3c35282 100644 --- a/jwt_proxy/api.py +++ b/jwt_proxy/api.py @@ -11,21 +11,25 @@ # TODO: to be pulled into its own module and loaded per config def scope_filter(req, token): + # Check path + resource_pattern = rf"(Patient|DocumentReference)^" + if not re.search(resource_pattern, req.path): + return False + user_id = token.get("sub") - pattern = rf"(.*?\|)?{user_id}" - # Search params + pattern = rf"(https:\/\/keycloak\.ltt\.cirg\.uw\.edu\|)?{user_id}" + # Search params for keycloak id params = req.args id_param_value = params.get("identifier", params.get("_identifier", params.get("subject.identifier"))) if id_param_value is not None and re.search(pattern, id_param_value): return True - # Search body + # Search body for keycloak id if req.is_json: try: - data = req.get_json() - parsed_data = json.loads(data) + body = req.get_json() except (ValueError, TypeError): return False - reference_string = parsed_data.get('subject', {}).get('reference') + reference_string = body.get('subject', {}).get('reference') if reference_string is not None and re.search(pattern, reference_string): return True return False From 3677e87b88aa74271d45004f60ed3242672f4c7d Mon Sep 17 00:00:00 2001 From: Daniel Lorigan Date: Wed, 3 Apr 2024 14:27:15 -0700 Subject: [PATCH 06/10] Update api.py --- jwt_proxy/api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jwt_proxy/api.py b/jwt_proxy/api.py index 3c35282..57417bd 100644 --- a/jwt_proxy/api.py +++ b/jwt_proxy/api.py @@ -12,9 +12,9 @@ # TODO: to be pulled into its own module and loaded per config def scope_filter(req, token): # Check path - resource_pattern = rf"(Patient|DocumentReference)^" - if not re.search(resource_pattern, req.path): - return False + # resource_pattern = rf"(Patient|DocumentReference)^" + # if not re.search(resource_pattern, req.path): + # return False user_id = token.get("sub") pattern = rf"(https:\/\/keycloak\.ltt\.cirg\.uw\.edu\|)?{user_id}" From 752c89e85c8a5a78c1ffc31e184e90356df5d21b Mon Sep 17 00:00:00 2001 From: Daniel Lorigan Date: Wed, 3 Apr 2024 14:31:54 -0700 Subject: [PATCH 07/10] Fix resource type match --- jwt_proxy/api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jwt_proxy/api.py b/jwt_proxy/api.py index 57417bd..ac4c816 100644 --- a/jwt_proxy/api.py +++ b/jwt_proxy/api.py @@ -12,9 +12,9 @@ # TODO: to be pulled into its own module and loaded per config def scope_filter(req, token): # Check path - # resource_pattern = rf"(Patient|DocumentReference)^" - # if not re.search(resource_pattern, req.path): - # return False + resource_pattern = rf"(Patient|DocumentReference)$" + if not re.search(resource_pattern, req.path): + return False user_id = token.get("sub") pattern = rf"(https:\/\/keycloak\.ltt\.cirg\.uw\.edu\|)?{user_id}" From e6dd0bd2098469e3f5942c0050cb1ca37abd8fb8 Mon Sep 17 00:00:00 2001 From: Daniel Lorigan Date: Wed, 3 Apr 2024 14:43:50 -0700 Subject: [PATCH 08/10] Update api.py resource type check --- jwt_proxy/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jwt_proxy/api.py b/jwt_proxy/api.py index ac4c816..63f43fa 100644 --- a/jwt_proxy/api.py +++ b/jwt_proxy/api.py @@ -13,7 +13,7 @@ def scope_filter(req, token): # Check path resource_pattern = rf"(Patient|DocumentReference)$" - if not re.search(resource_pattern, req.path): + if re.search(resource_pattern, req.path) is None: return False user_id = token.get("sub") From 9d5a155c60c03c946f47f4c4c0cc78ec24edf10b Mon Sep 17 00:00:00 2001 From: Daniel Lorigan Date: Wed, 3 Apr 2024 15:20:50 -0700 Subject: [PATCH 09/10] Tighten up checks to limit to resource types and ltt kc identifier system --- jwt_proxy/api.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/jwt_proxy/api.py b/jwt_proxy/api.py index 63f43fa..c9f4494 100644 --- a/jwt_proxy/api.py +++ b/jwt_proxy/api.py @@ -17,11 +17,11 @@ def scope_filter(req, token): return False user_id = token.get("sub") - pattern = rf"(https:\/\/keycloak\.ltt\.cirg\.uw\.edu\|)?{user_id}" + identifier_pattern = rf"(https(:|%3[Aa])(\/|%2[Ff]){2}keycloak\.ltt\.cirg\.uw\.edu(%7[Cc]|\|))?{user_id}" # Search params for keycloak id params = req.args id_param_value = params.get("identifier", params.get("_identifier", params.get("subject.identifier"))) - if id_param_value is not None and re.search(pattern, id_param_value): + if id_param_value is not None and re.search(identifier_pattern, id_param_value): return True # Search body for keycloak id if req.is_json: @@ -29,9 +29,11 @@ def scope_filter(req, token): body = req.get_json() except (ValueError, TypeError): return False - reference_string = body.get('subject', {}).get('reference') - if reference_string is not None and re.search(pattern, reference_string): - return True + resource_type = body.get("resourceType") + if resource_type == "DocumentReference": + reference_string = body.get("subject", {}).get("reference") + if reference_string is not None and re.search(identifier_pattern, reference_string): + return True return False From 3d201f1574037c8cdb5f22c12d0130ca382d3371 Mon Sep 17 00:00:00 2001 From: Daniel Lorigan Date: Wed, 3 Apr 2024 21:45:34 -0700 Subject: [PATCH 10/10] Address feedback Guard clause prior to proxy Response code change to 403 on failure json handling improvement --- jwt_proxy/api.py | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/jwt_proxy/api.py b/jwt_proxy/api.py index c9f4494..08b48bb 100644 --- a/jwt_proxy/api.py +++ b/jwt_proxy/api.py @@ -18,22 +18,20 @@ def scope_filter(req, token): user_id = token.get("sub") identifier_pattern = rf"(https(:|%3[Aa])(\/|%2[Ff]){2}keycloak\.ltt\.cirg\.uw\.edu(%7[Cc]|\|))?{user_id}" - # Search params for keycloak id + + # Search params for identifier-like param containing keycloak id params = req.args id_param_value = params.get("identifier", params.get("_identifier", params.get("subject.identifier"))) if id_param_value is not None and re.search(identifier_pattern, id_param_value): return True - # Search body for keycloak id - if req.is_json: - try: - body = req.get_json() - except (ValueError, TypeError): - return False - resource_type = body.get("resourceType") - if resource_type == "DocumentReference": - reference_string = body.get("subject", {}).get("reference") - if reference_string is not None and re.search(identifier_pattern, reference_string): - return True + + # Search body for subject.reference containing keycloak id + body = req.get_json() # Propegates a 400 BadRequest on failure + resource_type = body.get("resourceType") + if resource_type == "DocumentReference": + reference_string = body.get("subject", {}).get("reference") + if reference_string is not None and re.search(identifier_pattern, reference_string): + return True return False @@ -98,14 +96,16 @@ def validate_jwt(relative_path): return jsonify(message="token expired"), 401 # TODO: call new function here to dynamically load a filter call dependent on config; hardwired for now - if scope_filter(request, decoded_token): - response_content = proxy_request( - req=request, - upstream_url=f"{current_app.config['UPSTREAM_SERVER']}/{relative_path}", - user_info=decoded_token.get("email") or decoded_token.get("preferred_username"), - ) - return response_content - return jsonify(message="invalid request"), 400 + scope_filter_ok = scope_filter(request, decoded_token) + if not scope_filter_ok: + return jsonify(message="Forbidden"), 403 + + response_content = proxy_request( + req=request, + upstream_url=f"{current_app.config['UPSTREAM_SERVER']}/{relative_path}", + user_info=decoded_token.get("email") or decoded_token.get("preferred_username"), + ) + return response_content @blueprint.route("/fhir/.well-known/smart-configuration")