diff --git a/api/consumer/readDocumentReference/read_document_reference.py b/api/consumer/readDocumentReference/read_document_reference.py index a3b95ca9b..27515d2eb 100644 --- a/api/consumer/readDocumentReference/read_document_reference.py +++ b/api/consumer/readDocumentReference/read_document_reference.py @@ -45,12 +45,18 @@ def handler( diagnostics="The requested DocumentReference could not be found" ) - if result.type not in metadata.pointer_types: + allowed_types = ( + metadata.nrl_permissions_policy.types + if metadata.nrl_permissions_policy + else metadata.pointer_types + ) + + if result.type not in allowed_types: logger.log( LogReference.CONREAD002, ods_code=metadata.ods_code, type=result.type, - pointer_types=metadata.pointer_types, + pointer_types=allowed_types, ) return SpineErrorResponse.ACCESS_DENIED( diagnostics="The requested DocumentReference is not of a type that this organisation is allowed to access" diff --git a/api/consumer/readDocumentReference/tests/test_read_document_reference_consumer.py b/api/consumer/readDocumentReference/tests/test_read_document_reference_consumer.py index 27a4b1c62..64763d822 100644 --- a/api/consumer/readDocumentReference/tests/test_read_document_reference_consumer.py +++ b/api/consumer/readDocumentReference/tests/test_read_document_reference_consumer.py @@ -1,8 +1,10 @@ import json +from unittest.mock import patch from moto import mock_aws from api.consumer.readDocumentReference.read_document_reference import handler +from nrlf.core.constants import CLIENT_RP_DETAILS, V2Headers from nrlf.core.dynamodb.repository import DocumentPointer, DocumentPointerRepository from nrlf.tests.data import load_document_reference from nrlf.tests.dynamodb import mock_repository @@ -41,6 +43,47 @@ def test_read_document_reference_happy_path( assert parsed_body == doc_ref.model_dump(exclude_none=True) +@mock_aws +@mock_repository +@patch("nrlf.core.decorators.get_pointer_permissions_v2") +def test_read_document_reference_happy_path_v2( + get_pointer_permissions_mock, repository: DocumentPointerRepository +): + doc_ref = load_document_reference("Y05868-736253002-Valid") + doc_pointer = DocumentPointer.from_document_reference(doc_ref) + repository.create(doc_pointer) + + v2_headers = create_headers( + additional_headers={ + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", + } + ) + v2_headers.pop(CLIENT_RP_DETAILS) + + get_pointer_permissions_mock.return_value = { + "access_controls": [], + "types": ["http://snomed.info/sct|736253002"], + } + + event = create_test_api_gateway_event( + headers=v2_headers, + path_parameters={"id": doc_pointer.id}, + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "200", + "headers": default_response_headers(), + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body == doc_ref.model_dump(exclude_none=True) + + @mock_aws @mock_repository def test_read_document_reference_not_found(repository: DocumentPointerRepository): @@ -159,6 +202,65 @@ def test_read_document_reference_unauthorised_for_type( } +@mock_aws +@mock_repository +@patch("nrlf.core.decorators.get_pointer_permissions_v2") +def test_read_document_reference_unauthorised_for_type_v2( + get_pointer_permissions_mock, repository: DocumentPointerRepository +): + doc_ref = load_document_reference("Y05868-736253002-Valid") + doc_pointer = DocumentPointer.from_document_reference(doc_ref) + repository.create(doc_pointer) + + headers = create_headers( + additional_headers={ + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", + } + ) + headers.pop("nhsd-client-rp-details") + + get_pointer_permissions_mock.return_value = { + "access_controls": [], + "types": ["http://snomed.info/sct|736373009"], + } + + event = create_test_api_gateway_event( + headers=headers, + path_parameters={"id": doc_pointer.id}, + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "403", + "headers": default_response_headers(), + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "forbidden", + "details": { + "coding": [ + { + "code": "ACCESS DENIED", + "display": "Access has been denied to process this request", + "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + } + ] + }, + "diagnostics": "The requested DocumentReference is not of a type that this organisation is allowed to access", + } + ], + } + + @mock_aws @mock_repository def test_document_reference_invalid_json(repository: DocumentPointerRepository): diff --git a/api/consumer/searchDocumentReference/search_document_reference.py b/api/consumer/searchDocumentReference/search_document_reference.py index d87fbebb0..0252296f1 100644 --- a/api/consumer/searchDocumentReference/search_document_reference.py +++ b/api/consumer/searchDocumentReference/search_document_reference.py @@ -50,11 +50,17 @@ def handler( base_url = f"https://{config.ENVIRONMENT}.api.service.nhs.uk/" self_link = f"{base_url}record-locator/consumer/FHIR/R4/DocumentReference?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|{params.nhs_number}" - if not validate_type(params.type, metadata.pointer_types): + allowed_types = ( + metadata.nrl_permissions_policy.types + if metadata.nrl_permissions_policy + else metadata.pointer_types + ) + + if not validate_type(params.type, allowed_types): logger.log( LogReference.CONSEARCH002, type=params.type, - pointer_types=metadata.pointer_types, + pointer_types=allowed_types, ) return SpineErrorResponse.INVALID_CODE_SYSTEM( diagnostics="Invalid query parameter (The provided type does not match the allowed types for this organisation)", @@ -80,7 +86,7 @@ def handler( if custodian_id: self_link += f"&custodian:identifier=https://fhir.nhs.uk/Id/ods-organization-code|{custodian_id}" - pointer_types = [params.type.root] if params.type else metadata.pointer_types + pointer_types = [params.type.root] if params.type else allowed_types if params.type: self_link += f"&type={params.type.root}" diff --git a/api/consumer/searchDocumentReference/tests/test_search_document_reference_consumer.py b/api/consumer/searchDocumentReference/tests/test_search_document_reference_consumer.py index d65214402..30d4408c4 100644 --- a/api/consumer/searchDocumentReference/tests/test_search_document_reference_consumer.py +++ b/api/consumer/searchDocumentReference/tests/test_search_document_reference_consumer.py @@ -7,9 +7,11 @@ from nrlf.consumer.fhir.r4.model import CodeableConcept, Identifier from nrlf.core.constants import ( CATEGORY_ATTRIBUTES, + CLIENT_RP_DETAILS, TYPE_ATTRIBUTES, Categories, PointerTypes, + V2Headers, ) from nrlf.core.dynamodb.repository import DocumentPointer, DocumentPointerRepository from nrlf.tests.data import load_document_reference @@ -63,6 +65,60 @@ def test_search_document_reference_happy_path( } +@mock_aws +@mock_repository +@patch("nrlf.core.decorators.get_pointer_permissions_v2") +def test_search_document_reference_happy_path_v2( + get_pointer_permissions_mock, repository: DocumentPointerRepository +): + doc_ref = load_document_reference("Y05868-736253002-Valid") + doc_pointer = DocumentPointer.from_document_reference(doc_ref) + repository.create(doc_pointer) + + v2_headers = create_headers( + additional_headers={ + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", + } + ) + v2_headers.pop(CLIENT_RP_DETAILS) + + get_pointer_permissions_mock.return_value = { + "access_controls": [], + "types": ["http://snomed.info/sct|736253002"], + } + + event = create_test_api_gateway_event( + headers=v2_headers, + query_string_parameters={ + "subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|6700028191", + }, + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "200", + "headers": default_response_headers(), + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body == { + "resourceType": "Bundle", + "type": "searchset", + "link": [ + { + "relation": "self", + "url": "https://pytest.api.service.nhs.uk/record-locator/consumer/FHIR/R4/DocumentReference?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|6700028191", + } + ], + "total": 1, + "entry": [{"resource": doc_ref.model_dump(exclude_none=True)}], + } + + @mock_aws @mock_repository def test_search_document_reference_accession_number_in_pointer( @@ -680,6 +736,65 @@ def test_search_document_reference_invalid_type( } +@mock_aws +@mock_repository +@patch("nrlf.core.decorators.get_pointer_permissions_v2") +def test_search_document_reference_invalid_type_v2( + get_pointer_permissions_mock, repository: DocumentPointerRepository +): + v2_headers = create_headers( + additional_headers={ + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", + } + ) + v2_headers.pop(CLIENT_RP_DETAILS) + + get_pointer_permissions_mock.return_value = { + "access_controls": [], + "types": ["http://snomed.info/sct|736253002"], + } + + event = create_test_api_gateway_event( + headers=v2_headers, + query_string_parameters={ + "subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|6700028191", + "type": "http://snomed.info/sct|861421000000109", + }, + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "400", + "headers": default_response_headers(), + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "code-invalid", + "details": { + "coding": [ + { + "code": "INVALID_CODE_SYSTEM", + "display": "Invalid code system", + "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + } + ] + }, + "diagnostics": "Invalid query parameter (The provided type does not match the allowed types for this organisation)", + "expression": ["type"], + } + ], + } + + @mock_aws @mock_repository def test_search_document_reference_invalid_category( diff --git a/api/consumer/searchPostDocumentReference/search_post_document_reference.py b/api/consumer/searchPostDocumentReference/search_post_document_reference.py index 44bd33e61..25382e060 100644 --- a/api/consumer/searchPostDocumentReference/search_post_document_reference.py +++ b/api/consumer/searchPostDocumentReference/search_post_document_reference.py @@ -50,11 +50,17 @@ def handler( base_url = f"https://{config.ENVIRONMENT}.api.service.nhs.uk/" self_link = f"{base_url}record-locator/consumer/FHIR/R4/DocumentReference?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|{body.nhs_number}" - if not validate_type(body.type, metadata.pointer_types): + allowed_types = ( + metadata.nrl_permissions_policy.types + if metadata.nrl_permissions_policy + else metadata.pointer_types + ) + + if not validate_type(body.type, allowed_types): logger.log( LogReference.CONPOSTSEARCH002, type=body.type, - pointer_types=metadata.pointer_types, + pointer_types=allowed_types, ) return SpineErrorResponse.INVALID_CODE_SYSTEM( diagnostics="The provided type does not match the allowed types for this organisation", @@ -80,7 +86,7 @@ def handler( if custodian_id: self_link += f"&custodian:identifier=https://fhir.nhs.uk/Id/ods-organization-code|{custodian_id}" - pointer_types = [body.type.root] if body.type else metadata.pointer_types + pointer_types = [body.type.root] if body.type else allowed_types if body.type: self_link += f"&type={body.type.root}" diff --git a/api/consumer/searchPostDocumentReference/tests/test_search_post_document_reference_consumer.py b/api/consumer/searchPostDocumentReference/tests/test_search_post_document_reference_consumer.py index 359b75c7f..fc90d68c6 100644 --- a/api/consumer/searchPostDocumentReference/tests/test_search_post_document_reference_consumer.py +++ b/api/consumer/searchPostDocumentReference/tests/test_search_post_document_reference_consumer.py @@ -8,9 +8,11 @@ ) from nrlf.core.constants import ( CATEGORY_ATTRIBUTES, + CLIENT_RP_DETAILS, TYPE_ATTRIBUTES, Categories, PointerTypes, + V2Headers, ) from nrlf.core.dynamodb.repository import DocumentPointer, DocumentPointerRepository from nrlf.tests.data import load_document_reference @@ -65,6 +67,63 @@ def test_search_post_document_reference_happy_path( } +@mock_aws +@mock_repository +@patch("nrlf.core.decorators.get_pointer_permissions_v2") +def test_search_post_document_reference_happy_path_v2( + get_pointer_permissions_mock, + repository: DocumentPointerRepository, +): + doc_ref = load_document_reference("Y05868-736253002-Valid") + doc_pointer = DocumentPointer.from_document_reference(doc_ref) + repository.create(doc_pointer) + + v2_headers = create_headers( + additional_headers={ + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", + } + ) + v2_headers.pop(CLIENT_RP_DETAILS) + + get_pointer_permissions_mock.return_value = { + "access_controls": [], + "types": ["http://snomed.info/sct|736253002"], + } + + event = create_test_api_gateway_event( + headers=v2_headers, + body=json.dumps( + { + "subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|6700028191", + } + ), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "200", + "headers": default_response_headers(), + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body == { + "resourceType": "Bundle", + "type": "searchset", + "link": [ + { + "relation": "self", + "url": "https://pytest.api.service.nhs.uk/record-locator/consumer/FHIR/R4/DocumentReference?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|6700028191", + } + ], + "total": 1, + "entry": [{"resource": doc_ref.model_dump(exclude_none=True)}], + } + + @mock_aws @mock_repository def test_search_post_document_reference_happy_path_with_custodian( @@ -434,6 +493,68 @@ def test_search_post_document_reference_invalid_type( } +@mock_aws +@mock_repository +@patch("nrlf.core.decorators.get_pointer_permissions_v2") +def test_search_post_document_reference_invalid_type_v2( + get_pointer_permissions_mock, + repository: DocumentPointerRepository, +): + v2_headers = create_headers( + additional_headers={ + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", + } + ) + v2_headers.pop(CLIENT_RP_DETAILS) + + get_pointer_permissions_mock.return_value = { + "access_controls": [], + "types": ["http://snomed.info/sct|736253002"], + } + + event = create_test_api_gateway_event( + headers=v2_headers, + body=json.dumps( + { + "subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|6700028191", + "type": "https://fhir.nhs.uk/CodeSystem/Document-Type|invalid", + } + ), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "400", + "headers": default_response_headers(), + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "code-invalid", + "details": { + "coding": [ + { + "code": "INVALID_CODE_SYSTEM", + "display": "Invalid code system", + "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + } + ] + }, + "diagnostics": "The provided type does not match the allowed types for this organisation", + "expression": ["type"], + } + ], + } + + @mock_aws @mock_repository def test_search_document_reference_invalid_category( diff --git a/api/producer/createDocumentReference/create_document_reference.py b/api/producer/createDocumentReference/create_document_reference.py index f69ac65e9..940e642b2 100644 --- a/api/producer/createDocumentReference/create_document_reference.py +++ b/api/producer/createDocumentReference/create_document_reference.py @@ -76,12 +76,18 @@ def _check_permissions( expression="custodian.identifier.value", ) - if core_model.type not in metadata.pointer_types: + allowed_types = ( + metadata.nrl_permissions_policy.types + if metadata.nrl_permissions_policy + else metadata.pointer_types + ) + + if core_model.type not in allowed_types: logger.log( LogReference.PROCREATE005, ods_code=metadata.ods_code, type=core_model.type, - pointer_types=metadata.pointer_types, + pointer_types=allowed_types, ) return SpineErrorResponse.AUTHOR_CREDENTIALS_ERROR( diagnostics="The type of the provided DocumentReference is not in the list of allowed types for this organisation", diff --git a/api/producer/createDocumentReference/tests/test_create_document_reference.py b/api/producer/createDocumentReference/tests/test_create_document_reference.py index 00871bd7c..13d422486 100644 --- a/api/producer/createDocumentReference/tests/test_create_document_reference.py +++ b/api/producer/createDocumentReference/tests/test_create_document_reference.py @@ -10,7 +10,7 @@ _set_create_time_fields, handler, ) -from nrlf.core.constants import SNOMED_SYSTEM_URL +from nrlf.core.constants import CLIENT_RP_DETAILS, SNOMED_SYSTEM_URL, V2Headers from nrlf.core.dynamodb.repository import DocumentPointer, DocumentPointerRepository from nrlf.producer.fhir.r4.model import ( DocumentReferenceRelatesTo, @@ -731,6 +731,128 @@ def test_create_document_reference_pointer_type_not_allowed( } +@mock_aws +@mock_repository +@freeze_time("2024-03-21T12:34:56.789") +@freeze_uuid("00000000-0000-0000-0000-000000000001") +@patch("nrlf.core.decorators.get_pointer_permissions_v2") +def test_create_document_reference_happy_path_v2( + get_pointer_permissions_mock, repository: DocumentPointerRepository +): + doc_ref_data = load_document_reference_data("Y05868-736253002-Valid") + + v2_headers = create_headers( + additional_headers={ + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", + } + ) + v2_headers.pop(CLIENT_RP_DETAILS) + + get_pointer_permissions_mock.return_value = { + "access_controls": [], + "types": ["http://snomed.info/sct|736253002"], + } + + event = create_test_api_gateway_event( + headers=v2_headers, + body=doc_ref_data, + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "201", + "headers": { + "Location": "/DocumentReference/Y05868-00000000-0000-0000-0000-000000000001", + **default_response_headers(), + }, + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "information", + "code": "informational", + "details": { + "coding": [ + { + "code": "RESOURCE_CREATED", + "display": "Resource created", + "system": "https://fhir.nhs.uk/ValueSet/NRL-ResponseCode", + } + ], + }, + "diagnostics": "The document has been created", + } + ], + } + + +@mock_aws +@mock_repository +@patch("nrlf.core.decorators.get_pointer_permissions_v2") +def test_create_document_reference_pointer_type_not_allowed_v2( + get_pointer_permissions_mock, repository: DocumentPointerRepository +): + doc_ref = load_document_reference("Y05868-736253002-Valid") + + headers = create_headers( + additional_headers={ + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", + } + ) + headers.pop("nhsd-client-rp-details") + + # Return a type that does not match the document's type + get_pointer_permissions_mock.return_value = { + "access_controls": [], + "types": ["http://snomed.info/sct|736373009"], + } + + event = create_test_api_gateway_event( + headers=headers, + body=doc_ref.model_dump_json(exclude_none=True), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "403", + "headers": default_response_headers(), + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + + assert parsed_body == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "forbidden", + "details": { + "coding": [ + { + "code": "AUTHOR_CREDENTIALS_ERROR", + "display": "Author credentials error", + "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + } + ] + }, + "diagnostics": "The type of the provided DocumentReference is not in the list of allowed types for this organisation", + "expression": ["type.coding[0].code"], + } + ], + } + + def test_create_document_reference_invalid_category_type(): doc_ref = load_document_reference("Y05868-736253002-Valid") diff --git a/api/producer/searchDocumentReference/search_document_reference.py b/api/producer/searchDocumentReference/search_document_reference.py index 1641d6adb..7fa1e19bb 100644 --- a/api/producer/searchDocumentReference/search_document_reference.py +++ b/api/producer/searchDocumentReference/search_document_reference.py @@ -52,11 +52,17 @@ def handler( expression="subject:identifier", ) - if not validate_type(params.type, metadata.pointer_types): + allowed_types = ( + metadata.nrl_permissions_policy.types + if metadata.nrl_permissions_policy + else metadata.pointer_types + ) + + if not validate_type(params.type, allowed_types): logger.log( LogReference.PROSEARCH002, type=params.type, - pointer_types=metadata.pointer_types, + pointer_types=allowed_types, ) return SpineErrorResponse.INVALID_CODE_SYSTEM( diagnostics="Invalid query parameter (The provided type does not match the allowed types for this organisation)", @@ -74,7 +80,7 @@ def handler( expression="category", ) - pointer_types = [params.type.root] if params.type else metadata.pointer_types + pointer_types = [params.type.root] if params.type else allowed_types bundle = {"resourceType": "Bundle", "type": "searchset", "total": 0, "entry": []} logger.log( diff --git a/api/producer/searchDocumentReference/tests/test_search_document_reference_producer.py b/api/producer/searchDocumentReference/tests/test_search_document_reference_producer.py index 5f80a8d2e..9af6f27ca 100644 --- a/api/producer/searchDocumentReference/tests/test_search_document_reference_producer.py +++ b/api/producer/searchDocumentReference/tests/test_search_document_reference_producer.py @@ -6,9 +6,11 @@ from api.producer.searchDocumentReference.search_document_reference import handler from nrlf.core.constants import ( CATEGORY_ATTRIBUTES, + CLIENT_RP_DETAILS, TYPE_ATTRIBUTES, Categories, PointerTypes, + V2Headers, ) from nrlf.core.dynamodb.repository import DocumentPointer, DocumentPointerRepository from nrlf.tests.data import load_document_reference @@ -55,6 +57,55 @@ def test_search_document_reference_happy_path( } +@mock_aws +@mock_repository +@patch("nrlf.core.decorators.get_pointer_permissions_v2") +def test_search_document_reference_happy_path_v2( + get_pointer_permissions_mock, + repository: DocumentPointerRepository, +): + doc_ref = load_document_reference("Y05868-736253002-Valid") + doc_pointer = DocumentPointer.from_document_reference(doc_ref) + repository.create(doc_pointer) + + v2_headers = create_headers( + additional_headers={ + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", + } + ) + v2_headers.pop(CLIENT_RP_DETAILS) + + get_pointer_permissions_mock.return_value = { + "access_controls": [], + "types": ["http://snomed.info/sct|736253002"], + } + + event = create_test_api_gateway_event( + headers=v2_headers, + query_string_parameters={ + "subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|6700028191", + }, + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "200", + "headers": default_response_headers(), + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body == { + "resourceType": "Bundle", + "type": "searchset", + "total": 1, + "entry": [{"resource": doc_ref.model_dump(exclude_none=True)}], + } + + @mock_aws @mock_repository def test_search_document_reference_no_results( @@ -462,6 +513,60 @@ def test_search_document_reference_filters_by_pointer_types( } +@mock_aws +@mock_repository +@patch("nrlf.core.decorators.get_pointer_permissions_v2") +def test_search_document_reference_filters_by_pointer_types_v2( + get_pointer_permissions_mock, + repository: DocumentPointerRepository, +): + allowed_doc_ref = load_document_reference("Y05868-736253002-Valid") + allowed_pointer = DocumentPointer.from_document_reference(allowed_doc_ref) + repository.create(allowed_pointer) + + disallowed_doc_ref = load_document_reference("Y05868-736253002-Valid") + assert disallowed_doc_ref.type and disallowed_doc_ref.type.coding + disallowed_doc_ref.type.coding[0].code = "861421000000109" + disallowed_doc_ref.type.coding[0].system = "http://snomed.info/sct" + disallowed_pointer = DocumentPointer.from_document_reference(disallowed_doc_ref) + disallowed_pointer.id = "Y05868-disallowed-type" + disallowed_pointer.type = "http://snomed.info/sct|861421000000109" + repository.create(disallowed_pointer) + + v2_headers = create_headers( + additional_headers={ + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", + } + ) + v2_headers.pop(CLIENT_RP_DETAILS) + + get_pointer_permissions_mock.return_value = { + "access_controls": [], + "types": ["http://snomed.info/sct|736253002"], + } + + event = create_test_api_gateway_event( + headers=v2_headers, + query_string_parameters={ + "subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|6700028191", + }, + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "200", + "headers": default_response_headers(), + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body["total"] == 1 + assert parsed_body["entry"][0]["resource"]["id"] == allowed_doc_ref.id + + @mock_aws @mock_repository @patch("api.producer.searchDocumentReference.search_document_reference.logger") diff --git a/api/producer/searchPostDocumentReference/search_post_document_reference.py b/api/producer/searchPostDocumentReference/search_post_document_reference.py index f8a5bd250..8ecb4e476 100644 --- a/api/producer/searchPostDocumentReference/search_post_document_reference.py +++ b/api/producer/searchPostDocumentReference/search_post_document_reference.py @@ -46,11 +46,17 @@ def handler( expression="subject:identifier", ) - if not validate_type(body.type, metadata.pointer_types): + allowed_types = ( + metadata.nrl_permissions_policy.types + if metadata.nrl_permissions_policy + else metadata.pointer_types + ) + + if not validate_type(body.type, allowed_types): logger.log( LogReference.PROPOSTSEARCH002, type=body.type, - pointer_types=metadata.pointer_types, + pointer_types=allowed_types, ) return SpineErrorResponse.INVALID_CODE_SYSTEM( diagnostics="The provided type does not match the allowed types for this organisation", @@ -68,7 +74,7 @@ def handler( expression="category", ) - pointer_types = [body.type.root] if body.type else metadata.pointer_types + pointer_types = [body.type.root] if body.type else allowed_types bundle = {"resourceType": "Bundle", "type": "searchset", "total": 0, "entry": []} logger.log( diff --git a/api/producer/searchPostDocumentReference/tests/test_search_post_document_reference_producer.py b/api/producer/searchPostDocumentReference/tests/test_search_post_document_reference_producer.py index ed8df5915..2c17515b1 100644 --- a/api/producer/searchPostDocumentReference/tests/test_search_post_document_reference_producer.py +++ b/api/producer/searchPostDocumentReference/tests/test_search_post_document_reference_producer.py @@ -8,9 +8,11 @@ ) from nrlf.core.constants import ( CATEGORY_ATTRIBUTES, + CLIENT_RP_DETAILS, TYPE_ATTRIBUTES, Categories, PointerTypes, + V2Headers, ) from nrlf.core.dynamodb.repository import DocumentPointer, DocumentPointerRepository from nrlf.tests.data import load_document_reference @@ -59,6 +61,57 @@ def test_search_document_reference_happy_path( } +@mock_aws +@mock_repository +@patch("nrlf.core.decorators.get_pointer_permissions_v2") +def test_search_post_document_reference_happy_path_v2( + get_pointer_permissions_mock, + repository: DocumentPointerRepository, +): + doc_ref = load_document_reference("Y05868-736253002-Valid") + doc_pointer = DocumentPointer.from_document_reference(doc_ref) + repository.create(doc_pointer) + + v2_headers = create_headers( + additional_headers={ + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", + } + ) + v2_headers.pop(CLIENT_RP_DETAILS) + + get_pointer_permissions_mock.return_value = { + "access_controls": [], + "types": ["http://snomed.info/sct|736253002"], + } + + event = create_test_api_gateway_event( + headers=v2_headers, + body=json.dumps( + { + "subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|6700028191", + } + ), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "200", + "headers": default_response_headers(), + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body == { + "resourceType": "Bundle", + "type": "searchset", + "total": 1, + "entry": [{"resource": doc_ref.model_dump(exclude_none=True)}], + } + + @mock_aws @mock_repository def test_search_document_reference_no_results( @@ -479,6 +532,62 @@ def test_search_document_reference_filters_by_pointer_types( } +@mock_aws +@mock_repository +@patch("nrlf.core.decorators.get_pointer_permissions_v2") +def test_search_post_document_reference_filters_by_pointer_types_v2( + get_pointer_permissions_mock, + repository: DocumentPointerRepository, +): + allowed_doc_ref = load_document_reference("Y05868-736253002-Valid") + allowed_pointer = DocumentPointer.from_document_reference(allowed_doc_ref) + repository.create(allowed_pointer) + + disallowed_doc_ref = load_document_reference("Y05868-736253002-Valid") + assert disallowed_doc_ref.type and disallowed_doc_ref.type.coding + disallowed_doc_ref.type.coding[0].code = "861421000000109" + disallowed_doc_ref.type.coding[0].system = "http://snomed.info/sct" + disallowed_pointer = DocumentPointer.from_document_reference(disallowed_doc_ref) + disallowed_pointer.id = "Y05868-disallowed-type" + disallowed_pointer.type = "http://snomed.info/sct|861421000000109" + repository.create(disallowed_pointer) + + v2_headers = create_headers( + additional_headers={ + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", + } + ) + v2_headers.pop(CLIENT_RP_DETAILS) + + get_pointer_permissions_mock.return_value = { + "access_controls": [], + "types": ["http://snomed.info/sct|736253002"], + } + + event = create_test_api_gateway_event( + headers=v2_headers, + body=json.dumps( + { + "subject:identifier": "https://fhir.nhs.uk/Id/nhs-number|6700028191", + } + ), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "200", + "headers": default_response_headers(), + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body["total"] == 1 + assert parsed_body["entry"][0]["resource"]["id"] == allowed_doc_ref.id + + @mock_aws @mock_repository @patch("api.producer.searchPostDocumentReference.search_post_document_reference.logger") diff --git a/api/producer/upsertDocumentReference/tests/test_upsert_document_reference.py b/api/producer/upsertDocumentReference/tests/test_upsert_document_reference.py index 082257f52..326dd581d 100644 --- a/api/producer/upsertDocumentReference/tests/test_upsert_document_reference.py +++ b/api/producer/upsertDocumentReference/tests/test_upsert_document_reference.py @@ -9,7 +9,11 @@ _set_upsert_time_fields, handler, ) -from nrlf.core.constants import PERMISSION_SUPERSEDE_IGNORE_DELETE_FAIL +from nrlf.core.constants import ( + CLIENT_RP_DETAILS, + PERMISSION_SUPERSEDE_IGNORE_DELETE_FAIL, + V2Headers, +) from nrlf.core.dynamodb.repository import DocumentPointer, DocumentPointerRepository from nrlf.producer.fhir.r4.model import ( DocumentReferenceRelatesTo, @@ -86,6 +90,67 @@ def test_upsert_document_reference_happy_path( } +@mock_aws +@mock_repository +@freeze_time("2024-03-21T12:34:56.789") +@patch("nrlf.core.decorators.get_pointer_permissions_v2") +def test_upsert_document_reference_happy_path_v2( + get_pointer_permissions_mock, repository: DocumentPointerRepository +): + doc_ref_data = load_document_reference_data("Y05868-736253002-Valid") + + v2_headers = create_headers( + additional_headers={ + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", + } + ) + v2_headers.pop(CLIENT_RP_DETAILS) + + get_pointer_permissions_mock.return_value = { + "access_controls": [], + "types": ["http://snomed.info/sct|736253002"], + } + + event = create_test_api_gateway_event( + headers=v2_headers, + body=doc_ref_data, + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "201", + "headers": { + "Location": "/DocumentReference/Y05868-99999-99999-999999", + **default_response_headers(), + }, + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + assert parsed_body == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "information", + "code": "informational", + "details": { + "coding": [ + { + "code": "RESOURCE_CREATED", + "display": "Resource created", + "system": "https://fhir.nhs.uk/ValueSet/NRL-ResponseCode", + } + ], + }, + "diagnostics": "The document has been created", + } + ], + } + + @mock_aws @mock_repository @freeze_time("2024-03-21T12:34:56.789") @@ -747,6 +812,65 @@ def test_upsert_document_reference_pointer_type_not_allowed( } +@mock_aws +@mock_repository +@patch("nrlf.core.decorators.get_pointer_permissions_v2") +def test_upsert_document_reference_pointer_type_not_allowed_v2( + get_pointer_permissions_mock, repository: DocumentPointerRepository +): + doc_ref = load_document_reference("Y05868-736253002-Valid") + + v2_headers = create_headers( + additional_headers={ + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", + } + ) + v2_headers.pop(CLIENT_RP_DETAILS) + + get_pointer_permissions_mock.return_value = { + "access_controls": [], + "types": ["http://snomed.info/sct|99999999999"], + } + + event = create_test_api_gateway_event( + headers=v2_headers, + body=doc_ref.model_dump_json(exclude_none=True), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "403", + "headers": default_response_headers(), + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + + assert parsed_body == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "forbidden", + "details": { + "coding": [ + { + "code": "AUTHOR_CREDENTIALS_ERROR", + "display": "Author credentials error", + "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + } + ] + }, + "diagnostics": "The type of the provided DocumentReference is not in the list of allowed types for this organisation", + "expression": ["type.coding[0].code"], + } + ], + } + + def test_upsert_document_reference_no_relatesto_target(): doc_ref = load_document_reference("Y05868-736253002-Valid") doc_ref.relatesTo = [ diff --git a/api/producer/upsertDocumentReference/upsert_document_reference.py b/api/producer/upsertDocumentReference/upsert_document_reference.py index 788f4854f..3e5f31fc5 100644 --- a/api/producer/upsertDocumentReference/upsert_document_reference.py +++ b/api/producer/upsertDocumentReference/upsert_document_reference.py @@ -74,12 +74,18 @@ def _check_permissions( expression="custodian.identifier.value", ) - if core_model.type not in metadata.pointer_types: + allowed_types = ( + metadata.nrl_permissions_policy.types + if metadata.nrl_permissions_policy + else metadata.pointer_types + ) + + if core_model.type not in allowed_types: logger.log( LogReference.PROUPSERT005, ods_code=metadata.ods_code, type=core_model.type, - pointer_types=metadata.pointer_types, + pointer_types=allowed_types, ) return SpineErrorResponse.AUTHOR_CREDENTIALS_ERROR( diagnostics="The type of the provided DocumentReference is not in the list of allowed types for this organisation", diff --git a/layer/nrlf/core/constants.py b/layer/nrlf/core/constants.py index f7af5b627..9ef41b69c 100644 --- a/layer/nrlf/core/constants.py +++ b/layer/nrlf/core/constants.py @@ -39,11 +39,31 @@ class Source(Enum): } CLIENT_RP_DETAILS = "nhsd-client-rp-details" CONNECTION_METADATA = "nhsd-connection-metadata" + + +class V2Headers(str, Enum): + NHSD_END_USER_ORGANISATION_ODS = "nhsd-end-user-organisation-ods" + NHSD_NRL_APP_ID = "nhsd-nrl-app-id" + + PERMISSION_AUDIT_DATES_FROM_PAYLOAD = "audit-dates-from-payload" PERMISSION_SUPERSEDE_IGNORE_DELETE_FAIL = "supersede-ignore-delete-fail" PERMISSION_ALLOW_ALL_POINTER_TYPES = "allow-all-pointer-types" +class AccessControls(Enum): + ALLOW_FULL_ACCESS = "allow_full_access" + ALLOW_ALL_TYPES = "allow_all_types" + ALLOW_ALL_SUPPLIER_INTERACTIONS = "allow_all_supplier_interactions" + ALLOW_PRODUCE_FOR_ANY_AUTHOR = "allow_produce_for_any_author" + ALLOW_PRODUCE_FOR_ANY_CUSTODIAN = "allow_produce_for_any_custodian" + ALLOW_OVERRIDE_CREATION_DATETIME = "allow_override_creation_datetime" + + @staticmethod + def list(): + return [control.value for control in AccessControls] + + NHSD_REQUEST_ID_HEADER = "NHSD-Request-Id" NHSD_CORRELATION_ID_HEADER = "NHSD-Correlation-Id" X_REQUEST_ID_HEADER = "X-Request-Id" diff --git a/layer/nrlf/core/decorators.py b/layer/nrlf/core/decorators.py index 72bfba3fe..e925a1a93 100644 --- a/layer/nrlf/core/decorators.py +++ b/layer/nrlf/core/decorators.py @@ -25,11 +25,13 @@ PERMISSION_ALLOW_ALL_POINTER_TYPES, X_CORRELATION_ID_HEADER, X_REQUEST_ID_HEADER, + AccessControls, PointerTypes, ) from nrlf.core.dynamodb.repository import DocumentPointerRepository from nrlf.core.errors import OperationOutcomeError, ParseError from nrlf.core.logger import LogReference, logger +from nrlf.core.model import PermissionsPolicy from nrlf.core.request import parse_body, parse_headers, parse_params, parse_path from nrlf.core.response import Response @@ -159,11 +161,24 @@ def _load_v2_connection_metadata(headers: Dict[str, str], path: str): logger.log(LogReference.HANDLER004e) pointer_permissions = get_pointer_permissions_v2(metadata, path) - metadata.pointer_types = pointer_permissions.get("types", []) + metadata.nrl_permissions_policy = PermissionsPolicy.model_validate( + pointer_permissions + ) + + if ( + AccessControls.ALLOW_ALL_TYPES.value + in metadata.nrl_permissions_policy.access_controls + ): + metadata.nrl_permissions_policy.types = PointerTypes.list() logger.log( - LogReference.HANDLER004f, pointer_types=metadata.pointer_types - ) # TODO: log other permissions as they're added + LogReference.HANDLER004f, + permissions_policy=( + metadata.nrl_permissions_policy.model_dump() + if metadata.nrl_permissions_policy + else None + ), + ) return metadata @@ -297,11 +312,16 @@ def wrapper(event: APIGatewayProxyEvent, context: LambdaContext, **kwargs): logger.log(LogReference.HANDLER001, config=config.model_dump()) metadata = load_connection_metadata(event.headers, config, event.path) - if metadata.pointer_types == []: + allowed_types = ( + metadata.nrl_permissions_policy.types + if metadata.nrl_permissions_policy + else metadata.pointer_types + ) + if allowed_types == []: logger.log( LogReference.HANDLER005, ods_code=metadata.ods_code, - pointer_types=metadata.pointer_types, + pointer_types=allowed_types, ) raise OperationOutcomeError( status_code="403", diff --git a/layer/nrlf/core/model.py b/layer/nrlf/core/model.py index d0138bb23..bc9af2e8d 100644 --- a/layer/nrlf/core/model.py +++ b/layer/nrlf/core/model.py @@ -48,7 +48,15 @@ class ClientRpDetails(BaseModel): developer_app_id: StrictStr = Field(alias="developer.app.id") -# expand with other permissions types: pointer_types, etc +class PermissionsPolicy(BaseModel): + access_controls: list[str] = Field(default_factory=list) + types: list[str] = Field(default_factory=list) + categories: list[str] = Field(default_factory=list) + interactions: list[str] = Field(default_factory=list) + produce_for_authors: list[str] = Field(default_factory=list) + produce_for_custodians: list[str] = Field(default_factory=list) + + class ConnectionMetadata(BaseModel): pointer_types: list[str] = Field(alias="nrl.pointer-types", default_factory=list) ods_code: str = Field(alias="nrl.ods-code") @@ -56,3 +64,4 @@ class ConnectionMetadata(BaseModel): nrl_app_id: str = Field(alias="nrl.app-id") is_test_event: bool = Field(alias="nrl.test-event", default=False) client_rp_details: ClientRpDetails + nrl_permissions_policy: PermissionsPolicy | None = None diff --git a/layer/nrlf/core/request.py b/layer/nrlf/core/request.py index df60da007..d433c6676 100644 --- a/layer/nrlf/core/request.py +++ b/layer/nrlf/core/request.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ValidationError from nrlf.core.codes import SpineErrorConcept -from nrlf.core.constants import CLIENT_RP_DETAILS, CONNECTION_METADATA +from nrlf.core.constants import CLIENT_RP_DETAILS, CONNECTION_METADATA, V2Headers from nrlf.core.errors import OperationOutcomeError, ParseError from nrlf.core.json_duplicate_checker import check_duplicate_keys from nrlf.core.logger import LogReference, logger @@ -15,7 +15,7 @@ def _fetch_ods_app_id_headers(headers: dict[str, str]): case_insensitive_headers = {key.lower(): value for key, value in headers.items()} - ods_code = case_insensitive_headers.get("nhsd-end-user-organisation-ods") + ods_code = case_insensitive_headers.get(V2Headers.NHSD_END_USER_ORGANISATION_ODS) if not ods_code or len(ods_code.strip()) == 0: logger.log( @@ -23,7 +23,7 @@ def _fetch_ods_app_id_headers(headers: dict[str, str]): headers_names=list(case_insensitive_headers.keys()), ) - nrl_app_id = case_insensitive_headers.get("nhsd-nrl-app-id") + nrl_app_id = case_insensitive_headers.get(V2Headers.NHSD_NRL_APP_ID) if not nrl_app_id or len(nrl_app_id.strip()) == 0: logger.log( LogReference.HANDLER003b, diff --git a/layer/nrlf/core/tests/test_decorators.py b/layer/nrlf/core/tests/test_decorators.py index 9c20ec53f..4e23d5e92 100644 --- a/layer/nrlf/core/tests/test_decorators.py +++ b/layer/nrlf/core/tests/test_decorators.py @@ -12,7 +12,9 @@ from nrlf.core.constants import ( PERMISSION_ALLOW_ALL_POINTER_TYPES, X_REQUEST_ID_HEADER, + AccessControls, PointerTypes, + V2Headers, ) from nrlf.core.decorators import ( deprecated, @@ -818,8 +820,8 @@ def test_request_load_connection_with_missing_headers_gets_v2_permissions( ): headers = create_headers( additional_headers={ - "nhsd-end-user-organisation-ods": "Y05868", - "nhsd-nrl-app-id": "Y05868-TestApp-12345678", + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", } ) for header_name in headers_missing_from_request: @@ -834,6 +836,76 @@ def test_request_load_connection_with_missing_headers_gets_v2_permissions( assert expected_metadata.nrl_app_id == "Y05868-TestApp-12345678" +def _create_v2_headers() -> dict: + """Create headers that trigger the v2 permissions model (missing nhsd-client-rp-details).""" + headers = create_headers( + additional_headers={ + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", + } + ) + headers.pop("nhsd-client-rp-details") + return headers + + +def test_load_v2_connection_metadata_allow_all_types(mocker: MockerFixture): + mocker.patch( + "nrlf.core.decorators.get_pointer_permissions_v2", + return_value={ + "access_controls": [AccessControls.ALLOW_ALL_TYPES.value], + "types": [], + }, + ) + + metadata = load_connection_metadata( + headers=_create_v2_headers(), + config=Config(), + path="/producer/DocumentReference", + ) + + assert metadata.nrl_permissions_policy.types == PointerTypes.list() + + +def test_load_v2_connection_metadata_specific_types(mocker: MockerFixture): + specific_types = [ + "http://snomed.info/sct|736253002", + "http://snomed.info/sct|735324008", + ] + mocker.patch( + "nrlf.core.decorators.get_pointer_permissions_v2", + return_value={ + "access_controls": [], + "types": specific_types, + }, + ) + + metadata = load_connection_metadata( + headers=_create_v2_headers(), + config=Config(), + path="/producer/DocumentReference", + ) + + assert metadata.nrl_permissions_policy.types == specific_types + + +def test_load_v2_connection_metadata_missing_access_controls(mocker: MockerFixture): + specific_types = ["http://snomed.info/sct|736253002"] + mocker.patch( + "nrlf.core.decorators.get_pointer_permissions_v2", + return_value={ + "types": specific_types, + }, + ) + + metadata = load_connection_metadata( + headers=_create_v2_headers(), + config=Config(), + path="/producer/DocumentReference", + ) + + assert metadata.nrl_permissions_policy.types == specific_types + + def test_request_handler_with_custom_repository(mocker: MockerFixture): repository_mock = mocker.Mock() diff --git a/scripts/get_s3_permissions.py b/scripts/get_s3_permissions.py index bbd08a66b..3f4d4f525 100644 --- a/scripts/get_s3_permissions.py +++ b/scripts/get_s3_permissions.py @@ -6,7 +6,7 @@ import fire from aws_session_assume import get_boto_session -from nrlf.core.constants import PointerTypes +from nrlf.core.constants import AccessControls, PointerTypes def get_file_folders(s3_client, bucket_name, prefix=""): @@ -49,10 +49,10 @@ def add_test_files(folder, file_name, local_path): json.dump(PointerTypes.list(), f) -def _write_permission_file(folder_path, ods_code, pointer_types): +def _write_permission_file(folder_path, ods_code, pointer_types, access_controls=None): folder_path.mkdir(parents=True, exist_ok=True) with open(folder_path / f"{ods_code}.json", "w") as f: - json.dump({"types": pointer_types}, f) + json.dump({"access_controls": access_controls or [], "types": pointer_types}, f) def add_feature_test_files(local_path): @@ -68,22 +68,39 @@ def add_feature_test_files(local_path): "z00z-y11y-x22x", "RX898", [PointerTypes.MENTAL_HEALTH_PLAN.value], + [], ), # http://snomed.info/sct|736253002 + ( + "z00z-y11y-x22x", + "4LLTYP35C", + [], + [AccessControls.ALLOW_ALL_TYPES.value], + ), ], "producer": [ ( "z00z-y11y-x22x", "RX898", [PointerTypes.EOL_CARE_PLAN.value], + [], ), # http://snomed.info/sct|736373009 + ( + "z00z-y11y-x22x", + "4LLTYP35P", + [], + [AccessControls.ALLOW_ALL_TYPES.value], + ), ], } [ _write_permission_file( - Path.joinpath(local_path, actor_type, app_id), ods_code, pointer_types + Path.joinpath(local_path, actor_type, app_id), + ods_code, + pointer_types, + access_controls, ) for actor_type, entries in permissions.items() - for app_id, ods_code, pointer_types in entries + for app_id, ods_code, pointer_types, access_controls in entries ] diff --git a/terraform/account-wide-infrastructure/modules/permissions-store-bucket/s3.tf b/terraform/account-wide-infrastructure/modules/permissions-store-bucket/s3.tf index 2c7f43d1e..80c9515ca 100644 --- a/terraform/account-wide-infrastructure/modules/permissions-store-bucket/s3.tf +++ b/terraform/account-wide-infrastructure/modules/permissions-store-bucket/s3.tf @@ -60,7 +60,7 @@ resource "aws_s3_bucket_versioning" "authorization-store" { status = "Enabled" } } -# Need to pull these into state if they already exist + resource "aws_s3_object" "consumer-object" { bucket = aws_s3_bucket.authorization-store.id key = "consumer/" diff --git a/tests/features/consumer/v2-permissions-by-pointer-type.feature b/tests/features/consumer/v2-permissions-by-pointer-type.feature index 7c5fe6bbd..347d4ccec 100644 --- a/tests/features/consumer/v2-permissions-by-pointer-type.feature +++ b/tests/features/consumer/v2-permissions-by-pointer-type.feature @@ -210,3 +210,79 @@ Feature: Consumer v2 permissions by pointer type - Success and Failure Scenarios "expression": ["type"] } """ + + Scenario: V2 permissions with access all pointer types retrieves expected document references - searchPostDocumentReference + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + And a DocumentReference resource exists with values: + | property | value | + | id | X26-5900056201-SearchMultipleType1 | + | subject | 9000000378 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc-1.pdf | + | custodian | X26 | + | author | X26 | + And a DocumentReference resource exists with values: + | property | value | + | id | X26-5900056201-SearchMultipleType2 | + | subject | 9000000378 | + | status | current | + | type | 1382601000000107 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc-2.pdf | + | custodian | X26 | + | author | X26 | + And a DocumentReference resource exists with values: + | property | value | + | id | X26-5900056201-SearchMultipleType3 | + | subject | 9000000378 | + | status | current | + | type | 736373009 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc-3.pdf | + | custodian | X26 | + | author | X26 | + When consumer v2 '4LLTYP35C' searches for DocumentReferences using POST with request body: + | key | value | + | subject | 9000000378 | + Then the response status code is 200 + And the response is a searchset Bundle + And the Bundle has a total of 3 + And the Bundle has 3 entries + And the Bundle contains an DocumentReference with values + | property | value | + | id | X26-5900056201-SearchMultipleType1 | + | subject | 9000000378 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc-1.pdf | + | custodian | X26 | + | author | X26 | + And the Bundle contains an DocumentReference with values + | property | value | + | id | X26-5900056201-SearchMultipleType2 | + | subject | 9000000378 | + | status | current | + | type | 1382601000000107 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc-2.pdf | + | custodian | X26 | + | author | X26 | + And the Bundle contains an DocumentReference with values + | property | value | + | id | X26-5900056201-SearchMultipleType3 | + | subject | 9000000378 | + | status | current | + | type | 736373009 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc-3.pdf | + | custodian | X26 | + | author | X26 | diff --git a/tests/features/producer/v2-permissions-by-pointer-type.feature b/tests/features/producer/v2-permissions-by-pointer-type.feature index 673be9d48..83c429146 100644 --- a/tests/features/producer/v2-permissions-by-pointer-type.feature +++ b/tests/features/producer/v2-permissions-by-pointer-type.feature @@ -118,7 +118,7 @@ Feature: Producer v2 permissions by pointer type - Success and Failure Scenarios """ And the resource with id 'RX898-111-DeleteDocRefTest1' does not exist - Scenario: V2 Permissions with no access for pointer type - searchDocumentReference + Scenario: V2 Permissions search results are scoped to allowed pointer types - searchDocumentReference Given a DocumentReference resource exists with values: | property | value | | id | RX898-1111111111-SearchNHSDocRefTest1 | @@ -131,16 +131,16 @@ Feature: Producer v2 permissions by pointer type - Success and Failure Scenarios | custodian | RX898 | | author | X26 | And a DocumentReference resource exists with values: - | property | value | - | id | SG4-1111111111-SearchNHSDocRefTest3 | - | subject | 9999999999 | - | status | current | - | type | 1363501000000100 | - | category | 734163000 | - | contentType | application/pdf | - | url | https://example.org/my-doc.pdf | - | custodian | SG4 | - | author | X26 | + | property | value | + | id | RX898-1111111111-SearchNHSDocRefTest2 | + | subject | 9999999999 | + | status | current | + | type | 1363501000000100 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc.pdf | + | custodian | RX898 | + | author | X26 | When producer v2 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9999999999 | @@ -159,7 +159,60 @@ Feature: Producer v2 permissions by pointer type - Success and Failure Scenarios | url | https://example.org/my-doc.pdf | | custodian | RX898 | | author | X26 | - And the Bundle does not contain a DocumentReference with ID 'SG4-1111111111-SearchNHSDocRefTest3' + And the Bundle does not contain a DocumentReference with ID 'RX898-1111111111-SearchNHSDocRefTest2' + + Scenario: V2 Permissions search results for allow_all_types - searchDocumentReference + Given a DocumentReference resource exists with values: + | property | value | + | id | RX898-1111111111-SearchNHSDocRefTest1 | + | subject | 9999999999 | + | status | current | + | type | 736373009 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc.pdf | + | custodian | 4LLTYP35P | + | author | X26 | + And a DocumentReference resource exists with values: + | property | value | + | id | RX898-1111111111-SearchNHSDocRefTest2 | + | subject | 9999999999 | + | status | current | + | type | 1363501000000100 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc.pdf | + | custodian | 4LLTYP35P | + | author | X26 | + When producer v2 '4LLTYP35P' searches for DocumentReferences with parameters: + | parameter | value | + | subject | 9999999999 | + Then the response status code is 200 + And the response is a searchset Bundle + And the Bundle has a total of 2 + And the Bundle has 2 entries + And the Bundle contains an DocumentReference with values: + | property | value | + | id | RX898-1111111111-SearchNHSDocRefTest1 | + | subject | 9999999999 | + | status | current | + | type | 736373009 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc.pdf | + | custodian | 4LLTYP35P | + | author | X26 | + And the Bundle contains an DocumentReference with values: + | property | value | + | id | RX898-1111111111-SearchNHSDocRefTest2 | + | subject | 9999999999 | + | status | current | + | type | 1363501000000100 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc.pdf | + | custodian | 4LLTYP35P | + | author | X26 | Scenario: V2 Permissions with no access for org - searchDocumentReference Given a DocumentReference resource exists with values: diff --git a/tests/utilities/api_clients.py b/tests/utilities/api_clients.py index 85cf41d36..5d0136519 100644 --- a/tests/utilities/api_clients.py +++ b/tests/utilities/api_clients.py @@ -6,7 +6,12 @@ from pydantic import BaseModel from requests import Response -from nrlf.core.constants import Categories, PointerTypes +from nrlf.core.constants import ( + NHSD_CORRELATION_ID_HEADER, + Categories, + PointerTypes, + V2Headers, +) from nrlf.core.model import ConnectionMetadata logger = logging.getLogger(__name__) @@ -81,9 +86,9 @@ def __init__(self, config: ClientConfig, use_v2: bool = False): if use_v2: self.request_headers.update( { - "NHSD-End-User-Organisation-ODS": self.config.connection_metadata.ods_code, - "NHSD-NRL-App-Id": self.config.connection_metadata.nrl_app_id, - "NHSD-Correlation-Id": "test-correlation-id", + V2Headers.NHSD_END_USER_ORGANISATION_ODS: self.config.connection_metadata.ods_code, + V2Headers.NHSD_NRL_APP_ID: self.config.connection_metadata.nrl_app_id, + NHSD_CORRELATION_ID_HEADER: "test-correlation-id", } ) else: @@ -232,9 +237,9 @@ def __init__(self, config: ClientConfig, use_v2: bool = False): if use_v2: self.request_headers.update( { - "NHSD-End-User-Organisation-ODS": self.config.connection_metadata.ods_code, - "NHSD-NRL-App-Id": self.config.connection_metadata.nrl_app_id, - "NHSD-Correlation-Id": "test-correlation-id", + V2Headers.NHSD_END_USER_ORGANISATION_ODS: self.config.connection_metadata.ods_code, + V2Headers.NHSD_NRL_APP_ID: self.config.connection_metadata.nrl_app_id, + NHSD_CORRELATION_ID_HEADER: "test-correlation-id", } ) else: