Skip to content

Commit e49362c

Browse files
committed
Merge remote-tracking branch 'origin/VED-26-permissions-api' into VED-382-backend-configurable-disease-mapping
# Conflicts: # backend/src/clients.py # backend/src/fhir_controller.py # backend/src/fhir_repository.py # backend/src/fhir_service.py # backend/src/mappings.py # backend/src/models/utils/permissions.py # backend/tests/test_fhir_controller.py # backend/tests/test_fhir_controller_authorization.py # backend/tests/test_fhir_repository.py # backend/tests/test_fhir_service.py # backend/tests/utils/test_permissions.py
2 parents 128e61e + 0a7748a commit e49362c

31 files changed

+593
-738
lines changed

backend/poetry.lock

Lines changed: 37 additions & 48 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ python = "~3.11"
1212
boto3 = "~1.38.42"
1313
boto3-stubs-lite = {extras = ["dynamodb"], version = "~1.38.42"}
1414
aws-lambda-typing = "~2.20.0"
15+
redis = "^4.6.0"
1516
moto = "^5.1.6"
1617
requests = "~2.32.4"
1718
responses = "~0.25.7"

backend/src/authorization.py

Lines changed: 8 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from models.errors import UnauthorizedError
77

8-
PERMISSIONS_HEADER = "Permissions"
8+
99
AUTHENTICATION_HEADER = "AuthenticationType"
1010

1111

@@ -14,40 +14,16 @@ class UnknownPermission(RuntimeError):
1414
"""Error when the parsed value can't be converted to Permissions enum."""
1515

1616

17-
class EndpointOperation(Enum):
18-
"""The kind of operation.
19-
This maps one-to-one to each endpoint. Authorization class decides whether there are sufficient permissions or not.
20-
The caller is responsible for passing the correct operation.
21-
"""
22-
READ = 0,
23-
CREATE = 1,
24-
UPDATE = 2,
25-
DELETE = 3,
26-
SEARCH = 4,
27-
28-
2917
class AuthType(str, Enum):
3018
"""This backend supports all three types of authentication.
3119
An Apigee App should specify AuthenticationType in its custom attribute.
3220
Each Apigee app can only have one type of authentication which is enforced by onboarding process.
3321
See: https://digital.nhs.uk/developer/guides-and-documentation/security-and-authorisation"""
3422
APP_RESTRICTED = "ApplicationRestricted",
35-
NHS_LOGIN = "NnsLogin",
23+
NHS_LOGIN = "NhsLogin",
3624
CIS2 = "Cis2",
3725

3826

39-
class Permission(str, Enum):
40-
"""Permission name for each operation that can be done to an Immunization Resource
41-
An Apigee App should specify a set of these as a comma-separated custom attribute.
42-
Permission works the same way as 'scope' but, in this case, they're called permission to distinguish them from
43-
OAuth2 scopes"""
44-
READ = "immunization:read"
45-
CREATE = "immunization:create"
46-
UPDATE = "immunization:update"
47-
DELETE = "immunization:delete"
48-
SEARCH = "immunization:search"
49-
50-
5127
class Authorization:
5228
""" Authorize the call based on the endpoint and the authentication type.
5329
This class uses the passed headers from Apigee to decide the type of authentication (Application Restricted,
@@ -56,52 +32,13 @@ class Authorization:
5632
UnknownPermission is due to proxy bad configuration, and should result in 500. Any invalid value, either
5733
insufficient permissions or bad string, will result in UnauthorizedError if it comes from user.
5834
"""
59-
60-
def authorize(self, operation: EndpointOperation, aws_event: dict):
35+
36+
def authorize(self, aws_event: dict):
6137
auth_type = self._parse_auth_type(aws_event["headers"])
62-
if auth_type == AuthType.APP_RESTRICTED:
63-
self._app_restricted(operation, aws_event)
64-
if auth_type == AuthType.CIS2:
65-
self._cis2(operation, aws_event)
66-
# TODO(NhsLogin_AMB-1923) add NHSLogin
67-
else:
68-
UnauthorizedError()
69-
70-
_app_restricted_map = {
71-
EndpointOperation.READ: {Permission.READ},
72-
EndpointOperation.CREATE: {Permission.CREATE},
73-
EndpointOperation.UPDATE: {Permission.UPDATE, Permission.CREATE},
74-
EndpointOperation.DELETE: {Permission.DELETE},
75-
EndpointOperation.SEARCH: {Permission.SEARCH},
76-
}
77-
78-
def _app_restricted(self, operation: EndpointOperation, aws_event: dict) -> None:
79-
allowed = self._parse_permissions(aws_event["headers"])
80-
requested = self._app_restricted_map[operation]
81-
if not requested.issubset(allowed):
38+
39+
if auth_type not in {AuthType.APP_RESTRICTED, AuthType.CIS2, AuthType.NHS_LOGIN}:
8240
raise UnauthorizedError()
8341

84-
def _cis2(self, operation: EndpointOperation, aws_event: dict) -> None:
85-
# Cis2 works exactly the same as ApplicationRestricted
86-
self._app_restricted(operation, aws_event)
87-
88-
@staticmethod
89-
def _parse_permissions(headers) -> Set[Permission]:
90-
"""Given headers return a set of Permissions. Raises UnknownPermission"""
91-
92-
content = headers.get(PERMISSIONS_HEADER, "")
93-
# comma-separate the Permissions header then trim and finally convert to lowercase
94-
parsed = [str.strip(str.lower(s)) for s in content.split(",")]
95-
96-
permissions = set()
97-
for s in parsed:
98-
try:
99-
permissions.add(Permission(s))
100-
except ValueError:
101-
raise UnknownPermission()
102-
103-
return permissions
104-
10542
@staticmethod
10643
def _parse_auth_type(headers) -> AuthType:
10744
try:
@@ -112,14 +49,13 @@ def _parse_auth_type(headers) -> AuthType:
11249
# we raise UnknownPermission in case of an error and not UnauthorizedError
11350
raise UnknownPermission()
11451

115-
116-
def authorize(operation: EndpointOperation):
52+
def authorize():
11753
def decorator(func):
11854
auth = Authorization()
11955

12056
@wraps(func)
12157
def wrapper(controller_instance, a):
122-
auth.authorize(operation, a)
58+
auth.authorize(a)
12359
return func(controller_instance, a)
12460

12561
return wrapper

backend/src/clients.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919

2020
logging.basicConfig(level="INFO")
2121
logger = logging.getLogger()
22-
logger.setLevel("INFO")
2322
logger.info(f"Connecting to Redis at {REDIS_HOST}:{REDIS_PORT}")
2423

25-
redis_client = redis.StrictRedis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
24+
redis_client = redis.StrictRedis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)

backend/src/create_imms_handler.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pprint
44
import uuid
55

6-
from authorization import Permission
6+
77
from fhir_controller import FhirController, make_controller
88
from local_lambda import load_string
99
from models.errors import Severity, Code, create_operation_outcome
@@ -42,7 +42,6 @@ def create_immunization(event, controller: FhirController):
4242
"headers": {
4343
"Content-Type": "application/x-www-form-urlencoded",
4444
"AuthenticationType": "ApplicationRestricted",
45-
"Permissions": (",".join([Permission.CREATE])),
4645
},
4746
}
4847

backend/src/delete_imms_handler.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pprint
44
import uuid
55

6-
from authorization import Permission
6+
77
from fhir_controller import FhirController, make_controller
88
from models.errors import Severity, Code, create_operation_outcome
99
from log_structure import function_info
@@ -40,8 +40,7 @@ def delete_immunization(event, controller: FhirController):
4040
"pathParameters": {"id": args.id},
4141
"headers": {
4242
"Content-Type": "application/x-www-form-urlencoded",
43-
"AuthenticationType": "ApplicationRestricted",
44-
"Permissions": (",".join([Permission.DELETE])),
43+
"AuthenticationType": "ApplicationRestricted"
4544
},
4645
}
4746
pprint.pprint(delete_imms_handler(event, {}))

backend/src/fhir_controller.py

Lines changed: 24 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from fhir.resources.R4B.immunization import Immunization
1515
from boto3 import client as boto3_client
1616

17-
from authorization import Authorization, EndpointOperation, UnknownPermission
17+
from authorization import Authorization, UnknownPermission
1818
from cache import Cache
1919
from fhir_repository import ImmunizationRepository, create_table
2020
from fhir_service import FhirService, UpdateOutcome, get_service_url
@@ -35,6 +35,7 @@
3535
)
3636
from models.utils.generic_utils import check_keys_in_sources
3737
from models.utils.permissions import get_supplier_permissions
38+
from models.utils.permission_checker import VaccinePermissionChecker
3839
from pds_service import PdsService
3940
from parameter_parser import process_params, process_search_params, create_query_string
4041
import urllib.parse
@@ -79,7 +80,7 @@ def __init__(
7980
def get_immunization_by_identifier(self, aws_event) -> dict:
8081
try:
8182
if aws_event.get("headers"):
82-
if response := self.authorize_request(EndpointOperation.SEARCH, aws_event):
83+
if response := self.authorize_request(aws_event):
8384
return response
8485
query_params = aws_event.get("queryStringParameters", {})
8586
else:
@@ -120,7 +121,7 @@ def get_immunization_by_identifier(self, aws_event) -> dict:
120121
return self.create_response(403, unauthorized.to_operation_outcome())
121122

122123
def get_immunization_by_id(self, aws_event) -> dict:
123-
if response := self.authorize_request(EndpointOperation.READ, aws_event):
124+
if response := self.authorize_request(aws_event):
124125
return response
125126

126127
imms_id = aws_event["pathParameters"]["id"]
@@ -165,7 +166,7 @@ def get_immunization_by_id(self, aws_event) -> dict:
165166
def create_immunization(self, aws_event):
166167
try:
167168
if aws_event.get("headers"):
168-
if response := self.authorize_request(EndpointOperation.CREATE, aws_event):
169+
if response := self.authorize_request(aws_event):
169170
return response
170171
else:
171172
raise UnauthorizedError()
@@ -209,7 +210,7 @@ def create_immunization(self, aws_event):
209210
def update_immunization(self, aws_event):
210211
try:
211212
if aws_event.get("headers"):
212-
if response := self.authorize_request(EndpointOperation.UPDATE, aws_event):
213+
if response := self.authorize_request(aws_event):
213214
return response
214215
imms_id = aws_event["pathParameters"]["id"]
215216
else:
@@ -271,9 +272,8 @@ def update_immunization(self, aws_event):
271272

272273
# Check vaccine type permissions on the existing record - start
273274
try:
274-
vax_type_perms = self._parse_vaccine_permissions_controller(imms_vax_type_perms)
275-
vax_type_perm = self._vaccine_permission(existing_record["VaccineType"], "update")
276-
self._check_permission(vax_type_perm, vax_type_perms)
275+
checker = VaccinePermissionChecker(imms_vax_type_perms)
276+
checker.validate(existing_record["VaccineType"], "update")
277277
except UnauthorizedVaxOnRecordError as unauthorized:
278278
return self.create_response(403, unauthorized.to_operation_outcome())
279279
# Check vaccine type permissions on the existing record - end
@@ -372,7 +372,7 @@ def update_immunization(self, aws_event):
372372
def delete_immunization(self, aws_event):
373373
try:
374374
if aws_event.get("headers"):
375-
if response := self.authorize_request(EndpointOperation.DELETE, aws_event):
375+
if response := self.authorize_request(aws_event):
376376
return response
377377
imms_id = aws_event["pathParameters"]["id"]
378378
else:
@@ -403,7 +403,7 @@ def delete_immunization(self, aws_event):
403403
return self.create_response(403, unauthorized.to_operation_outcome())
404404

405405
def search_immunizations(self, aws_event: APIGatewayProxyEventV1) -> dict:
406-
if response := self.authorize_request(EndpointOperation.SEARCH, aws_event):
406+
if response := self.authorize_request(aws_event):
407407
return response
408408

409409
try:
@@ -428,8 +428,11 @@ def search_immunizations(self, aws_event: APIGatewayProxyEventV1) -> dict:
428428
return self.create_response(403, unauthorized.to_operation_outcome())
429429
# Check vaxx type permissions on the existing record - start
430430
try:
431-
vax_type_perms = self._parse_vaccine_permissions_controller(imms_vax_type_perms)
432-
vax_type_perm = self._new_vaccine_request(search_params.immunization_targets, "search", vax_type_perms)
431+
checker = VaccinePermissionChecker(imms_vax_type_perms)
432+
vax_type_perms = checker.expanded_permissions
433+
operation_code = VaccinePermissionChecker.mapped_operations.get("search")
434+
vax_type_perm = [ vaccine_type for vaccine_type in search_params.immunization_targets
435+
if f"{vaccine_type.lower()}.{operation_code}" in vax_type_perms ]
433436
if not vax_type_perm:
434437
raise UnauthorizedVaxError
435438
except UnauthorizedVaxError as unauthorized:
@@ -540,19 +543,19 @@ def _create_bad_request(self, message):
540543
)
541544
return self.create_response(400, error)
542545

543-
def authorize_request(self, operation: EndpointOperation, aws_event: dict) -> Optional[dict]:
546+
547+
def authorize_request(self, aws_event: dict) -> Optional[dict]:
544548
try:
545-
self.authorizer.authorize(operation, aws_event)
549+
self.authorizer.authorize(aws_event)
546550
except UnauthorizedError as e:
547551
return self.create_response(403, e.to_operation_outcome())
548552
except UnknownPermission:
549-
# TODO: I think when AuthenticationType is not present, then we don't get below message. Double check again
550553
id_error = create_operation_outcome(
551-
resource_id=str(uuid.uuid4()),
552-
severity=Severity.error,
553-
code=Code.server_error,
554-
diagnostics="application includes invalid authorization values",
555-
)
554+
resource_id=str(uuid.uuid4()),
555+
severity=Severity.error,
556+
code=Code.server_error,
557+
diagnostics="Application includes invalid authorization values",
558+
)
556559
return self.create_response(500, id_error)
557560

558561
def fetch_identifier_system_and_element(self, event: dict):
@@ -633,6 +636,7 @@ def check_vaccine_type_permissions(self, aws_event):
633636
if len(supplier_system) == 0:
634637
raise UnauthorizedSystemError()
635638
imms_vax_type_perms = get_supplier_permissions(supplier_system)
639+
print(f" update imms = {imms_vax_type_perms}")
636640
if len(imms_vax_type_perms) == 0:
637641
raise UnauthorizedVaxError()
638642
# Return the values needed for later use
@@ -661,50 +665,6 @@ def create_response(status_code, body=None, headers=None):
661665
**({"body": body} if body else {}),
662666
}
663667

664-
@staticmethod
665-
def _sendack(payload, file_name, message_id, created_at_formatted_string, local_id, operation_requested):
666-
payload["file_key"] = file_name
667-
payload["row_id"] = message_id
668-
payload["created_at_formatted_string"] = created_at_formatted_string
669-
payload["local_id"] = local_id
670-
payload["operation_requested"] = operation_requested
671-
sqs_client.send_message(QueueUrl=queue_url, MessageBody=json.dumps(payload), MessageGroupId=file_name)
672-
673-
@staticmethod
674-
def _vaccine_permission(vaccine_type, operation) -> set:
675-
vaccine_permission = set()
676-
if isinstance(vaccine_type, list):
677-
for x in vaccine_type:
678-
vaccine_permission.add(str.lower(f"{x}:{operation}"))
679-
return vaccine_permission
680-
else:
681-
vaccine_permission.add(str.lower(f"{vaccine_type}:{operation}"))
682-
return vaccine_permission
683-
684-
@staticmethod
685-
def _parse_vaccine_permissions_controller(imms_vax_type_perms) -> set:
686-
return {str(s).strip().lower() for s in imms_vax_type_perms}
687-
688-
@staticmethod
689-
def _check_permission(requested: set, allowed: set) -> set:
690-
if not requested.issubset(allowed):
691-
raise UnauthorizedVaxOnRecordError()
692-
else:
693-
return None
694-
695-
@staticmethod
696-
def _new_vaccine_request(vaccine_type, operation, vaccine_type_permissions: None) -> Optional[list]:
697-
vaccine_permission = list()
698-
if isinstance(vaccine_type, list):
699-
for x in vaccine_type:
700-
vaccs_prms = set()
701-
vaccs_prms.add(str.lower(f"{x}:{operation}"))
702-
if vaccs_prms.issubset(vaccine_type_permissions):
703-
vaccine_permission.append(x)
704-
return vaccine_permission
705-
else:
706-
return vaccine_permission
707-
708668
@staticmethod
709669
def _identify_supplier_system(aws_event):
710670
supplier_system = aws_event["headers"]["SupplierSystem"]

0 commit comments

Comments
 (0)