From e0f10270b75a6c88a50c91a3d154eed98a38b1c4 Mon Sep 17 00:00:00 2001 From: Sandy Forrester Date: Thu, 26 Feb 2026 14:46:01 +0000 Subject: [PATCH 01/14] NRL-1949 Add consumer & producer objects to auth store --- .../modules/permissions-store-bucket/s3.tf | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 ab7fe77aa..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,3 +60,13 @@ resource "aws_s3_bucket_versioning" "authorization-store" { status = "Enabled" } } + +resource "aws_s3_object" "consumer-object" { + bucket = aws_s3_bucket.authorization-store.id + key = "consumer/" +} + +resource "aws_s3_object" "producer-object" { + bucket = aws_s3_bucket.authorization-store.id + key = "producer/" +} From 7eeb57f3d103bcf2c9f1c89d0d8b2f0667c6097a Mon Sep 17 00:00:00 2001 From: Sandy Forrester Date: Thu, 5 Mar 2026 15:26:30 +0000 Subject: [PATCH 02/14] NRL-1949 Add V2 consumer pointer type feature tests --- scripts/get_s3_permissions.py | 24 ++ .../readDocumentReference-failure.feature | 12 +- .../readDocumentReference-success.feature | 6 +- .../searchDocumentReference-failure.feature | 22 +- .../searchDocumentReference-success.feature | 22 +- ...earchPostDocumentReference-failure.feature | 20 +- ...earchPostDocumentReference-success.feature | 16 +- .../features/consumer/status-success.feature | 1 - .../v2-permissions-by-pointer-type.feature | 215 ++++++++++++++++++ tests/features/steps/2_request.py | 24 +- tests/features/utils/api_client.py | 9 +- tests/utilities/api_clients.py | 32 +++ 12 files changed, 342 insertions(+), 61 deletions(-) delete mode 100644 tests/features/consumer/status-success.feature create mode 100644 tests/features/consumer/v2-permissions-by-pointer-type.feature diff --git a/scripts/get_s3_permissions.py b/scripts/get_s3_permissions.py index 626ded1d6..2d5f52e20 100644 --- a/scripts/get_s3_permissions.py +++ b/scripts/get_s3_permissions.py @@ -49,6 +49,29 @@ def add_test_files(folder, file_name, local_path): json.dump(PointerTypes.list(), f) +def add_feature_test_files(local_path): + """Bake in v2 permissions for the feature test application so that the + v2 permissions model can be proven via feature tests without + requiring a dynamic layer rebuild between test setup and test execution. + """ + + # TODO NEED TO ADD PRODUCER PERMS + print("Adding feature test v2 permissions to temporary directory...") + feature_test_app_id = "z00z-y11y-x22x" + consumer_entries = [ + ( + "RX898", + [PointerTypes.MENTAL_HEALTH_PLAN.value], + ), # http://snomed.info/sct|736253002 + ] + for ods_code, pointer_types in consumer_entries: + folder_path = Path.joinpath(local_path, "consumer", feature_test_app_id) + folder_path.mkdir(parents=True, exist_ok=True) + file_path = folder_path / f"{ods_code}.json" + with open(file_path, "w") as f: + json.dump({"types": pointer_types}, f) + + def download_files(s3_client, bucket_name, local_path, file_names, folders): print(f"Downloading {len(file_names)} S3 files to temporary directory...") local_path = Path(local_path) @@ -65,6 +88,7 @@ def download_files(s3_client, bucket_name, local_path, file_names, folders): s3_client.download_file(bucket_name, file_name, str(file_path)) add_test_files("K6PerformanceTest", "Y05868.json", local_path) + add_feature_test_files(local_path) def main(use_shared_resources: str, env: str, workspace: str, path_to_store: str): diff --git a/tests/features/consumer/readDocumentReference-failure.feature b/tests/features/consumer/readDocumentReference-failure.feature index f6ef1e0f4..dd37af613 100644 --- a/tests/features/consumer/readDocumentReference-failure.feature +++ b/tests/features/consumer/readDocumentReference-failure.feature @@ -6,7 +6,7 @@ Feature: Consumer - readDocumentReference - Failure Scenarios And the organisation 'RX898' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When consumer 'RX898' reads a DocumentReference with ID 'X26-000000000-000000000' + When consumer v1 'RX898' reads a DocumentReference with ID 'X26-000000000-000000000' Then the response status code is 404 And the response is an OperationOutcome with 1 issue And the OperationOutcome contains the issue: @@ -33,7 +33,7 @@ Feature: Consumer - readDocumentReference - Failure Scenarios And the organisation 'RX898' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When consumer 'RX898' reads a DocumentReference with ID 'X26`DROP TABLE 'pointers';--Something-000000000-000000000' + When consumer v1 'RX898' reads a DocumentReference with ID 'X26`DROP TABLE 'pointers';--Something-000000000-000000000' Then the response status code is 404 And the response is an OperationOutcome with 1 issue And the OperationOutcome contains the issue: @@ -58,7 +58,7 @@ Feature: Consumer - readDocumentReference - Failure Scenarios Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API And the organisation 'RX898' is authorised to access pointer types: | system | value | - When consumer 'RX898' reads a DocumentReference with ID 'X26-000000000-000000000' + When consumer v1 'RX898' reads a DocumentReference with ID 'X26-000000000-000000000' Then the response status code is 403 And the response is an OperationOutcome with 1 issue And the OperationOutcome contains the issue: @@ -95,7 +95,7 @@ Feature: Consumer - readDocumentReference - Failure Scenarios | url | https://example.org/my-doc.pdf | | custodian | 02V | | author | 02V | - When consumer 'RX898' reads a DocumentReference with ID '02V-1111111111-ReadDocRefNoAuthForType' + When consumer v1 'RX898' reads a DocumentReference with ID '02V-1111111111-ReadDocRefNoAuthForType' Then the response status code is 403 And the response is an OperationOutcome with 1 issue And the OperationOutcome contains the issue: @@ -120,7 +120,7 @@ Feature: Consumer - readDocumentReference - Failure Scenarios Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API And the organisation 'RX898' is authorised to access pointer types: | system | value | - When consumer 'RX898' reads a DocumentReference with ID 'X26-000000000-000000000' + When consumer v1 'RX898' reads a DocumentReference with ID 'X26-000000000-000000000' Then the response status code is 403 And the response is an OperationOutcome with 1 issue And the OperationOutcome contains the issue: @@ -157,7 +157,7 @@ Feature: Consumer - readDocumentReference - Failure Scenarios | url | https://example.org/my-doc.pdf | | custodian | 02V | | author | 02V | - When consumer 'RX898' reads a DocumentReference with ID '02V-1111111111-ReadDocRefNoAuthForTypeS3' + When consumer v1 'RX898' reads a DocumentReference with ID '02V-1111111111-ReadDocRefNoAuthForTypeS3' Then the response status code is 403 And the response is an OperationOutcome with 1 issue And the OperationOutcome contains the issue: diff --git a/tests/features/consumer/readDocumentReference-success.feature b/tests/features/consumer/readDocumentReference-success.feature index 9a3c81d55..5de6b44cd 100644 --- a/tests/features/consumer/readDocumentReference-success.feature +++ b/tests/features/consumer/readDocumentReference-success.feature @@ -16,7 +16,7 @@ Feature: Consumer - readDocumentReference - Success Scenarios | url | https://example.org/my-doc.pdf | | custodian | RX898 | | author | RX898 | - When consumer 'RX898' reads a DocumentReference with ID 'RX898-9999999999-ReadDocRefSameCustodian' + When consumer v1 'RX898' reads a DocumentReference with ID 'RX898-9999999999-ReadDocRefSameCustodian' Then the response status code is 200 And the response is a DocumentReference with JSON value: """ @@ -133,7 +133,7 @@ Feature: Consumer - readDocumentReference - Success Scenarios | url | https://example.org/my-doc.pdf | | custodian | X26 | | author | RX898 | - When consumer 'RX898' reads a DocumentReference with ID 'X26-9999999999-ReadDocRefDiffCustodian' + When consumer v1 'RX898' reads a DocumentReference with ID 'X26-9999999999-ReadDocRefDiffCustodian' Then the response status code is 200 And the response is a DocumentReference with JSON value: """ @@ -250,5 +250,5 @@ Feature: Consumer - readDocumentReference - Success Scenarios | url | https://example.org/my-doc.pdf | | custodian | RX898\|001 | | author | RX898 | - When consumer 'RX898' reads a DocumentReference with ID 'RX898%7C001-1234567890-ReadDocRefUrlEncoded' + When consumer v1 'RX898' reads a DocumentReference with ID 'RX898%7C001-1234567890-ReadDocRefUrlEncoded' Then the response status code is 200 diff --git a/tests/features/consumer/searchDocumentReference-failure.feature b/tests/features/consumer/searchDocumentReference-failure.feature index 6113b714c..48d0e7882 100644 --- a/tests/features/consumer/searchDocumentReference-failure.feature +++ b/tests/features/consumer/searchDocumentReference-failure.feature @@ -5,7 +5,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios And the organisation 'RX898' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | | extra | parameter | @@ -33,7 +33,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios And the organisation 'RX898' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | Then the response status code is 400 And the response is an OperationOutcome with 1 issue @@ -59,7 +59,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios And the organisation 'RX898' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | | type | http://incorrect.info/sct\|736253002 | @@ -87,7 +87,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios And the organisation 'RX898' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | | type | http://snomed.info/sct\|887701000000100 | @@ -115,7 +115,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios And the organisation 'RX898' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 123 | Then the response status code is 400 @@ -141,7 +141,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API And the organisation 'RX898' is authorised to access pointer types: | system | value | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | Then the response status code is 403 @@ -166,7 +166,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API And the organisation 'RX898' is authorised to access pointer types: | system | value | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | | category | http://snomed.info/sct\|734163000 | @@ -204,7 +204,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios | url | https://example.org/my-doc.pdf | | custodian | 8FW23 | | author | 8FW23 | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | | category | http://snomed.info/sct\|1102421000000108 | @@ -227,7 +227,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios | url | https://example.org/my-doc.pdf | | custodian | 8FW23 | | author | 8FW23 | - When consumer 'Z26' searches for DocumentReferences with parameters: + When consumer v1 'Z26' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | | type | 736253002 | @@ -254,7 +254,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios And the organisation 'RX898' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | | category | http://incorrect.info/sct\|736253002 | @@ -282,7 +282,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios And the organisation 'RX898' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | | category | http://snomed.info/sct\|734163000,http://snomed.info/sct\|invalid | diff --git a/tests/features/consumer/searchDocumentReference-success.feature b/tests/features/consumer/searchDocumentReference-success.feature index a35fa3273..41ec35367 100644 --- a/tests/features/consumer/searchDocumentReference-success.feature +++ b/tests/features/consumer/searchDocumentReference-success.feature @@ -16,7 +16,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc.pdf | | custodian | 02V | | author | 02V | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | Then the response status code is 200 @@ -53,7 +53,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | custodian | 02V | | author | 02V | | identifier | 02V.123456789 | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | Then the response status code is 200 @@ -90,7 +90,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc.pdf | | custodian | 02V | | author | 02V | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | | custodian | 02V | @@ -138,7 +138,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc.pdf | | custodian | DK94 | | author | DK94 | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | | custodian | 02V | @@ -186,7 +186,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc.pdf | | custodian | 02V | | author | 02V | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | | custodian | 02V | @@ -245,7 +245,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc-3.pdf | | custodian | 02V | | author | 02V | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | Then the response status code is 200 @@ -299,7 +299,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | custodian | DK94 | | author | N64 | When producer 'DK94' requests to delete DocumentReference with id 'DK94-111-DeleteDocRefTest1' - And consumer 'RX898' searches for DocumentReferences with parameters: + And consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | | custodian | RX898 | @@ -348,7 +348,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc-3.pdf | | custodian | 02V | | author | 02V | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | | category | http://snomed.info/sct\|734163000 | @@ -409,7 +409,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc-3.pdf | | custodian | 02V | | author | 02V | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | | category | http://snomed.info/sct\|1102421000000108 | @@ -450,7 +450,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc-3.pdf | | custodian | 02V | | author | 02V | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | | _summary | count | @@ -510,7 +510,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc-4.pdf | | custodian | 02V | | author | 02V | - When consumer 'RX898' searches for DocumentReferences with parameters: + When consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | | category | http://snomed.info/sct\|734163000,http://snomed.info/sct\|823651000000106 | diff --git a/tests/features/consumer/searchPostDocumentReference-failure.feature b/tests/features/consumer/searchPostDocumentReference-failure.feature index 2aa60a5b1..53fd41d39 100644 --- a/tests/features/consumer/searchPostDocumentReference-failure.feature +++ b/tests/features/consumer/searchPostDocumentReference-failure.feature @@ -5,7 +5,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios And the organisation 'RX898' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When consumer 'RX898' searches for DocumentReferences using POST with request body: + When consumer v1 'RX898' searches for DocumentReferences using POST with request body: | key | value | | subject | 9278693472 | | extra | parameter | @@ -33,7 +33,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios And the organisation 'RX898' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When consumer 'RX898' searches for DocumentReferences using POST with request body: + When consumer v1 'RX898' searches for DocumentReferences using POST with request body: | key | value | Then the response status code is 400 And the response is an OperationOutcome with 1 issue @@ -59,7 +59,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios And the organisation 'RX898' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When consumer 'RX898' searches for DocumentReferences using POST with request body: + When consumer v1 'RX898' searches for DocumentReferences using POST with request body: | key | value | | subject | 9278693472 | | type | http://incorrect.info/sct\|736253002 | @@ -87,7 +87,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios And the organisation 'RX898' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When consumer 'RX898' searches for DocumentReferences using POST with request body: + When consumer v1 'RX898' searches for DocumentReferences using POST with request body: | key | value | | subject | 9278693472 | | type | http://snomed.info/sct\|887701000000100 | @@ -115,7 +115,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios And the organisation 'RX898' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When consumer 'RX898' searches for DocumentReferences using POST with request body: + When consumer v1 'RX898' searches for DocumentReferences using POST with request body: | key | value | | subject | 123 | Then the response status code is 400 @@ -141,7 +141,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API And the organisation 'RX898' is authorised to access pointer types: | system | value | - When consumer 'RX898' searches for DocumentReferences using POST with request body: + When consumer v1 'RX898' searches for DocumentReferences using POST with request body: | key | value | | subject | 9278693472 | Then the response status code is 403 @@ -175,7 +175,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios | url | https://example.org/my-doc.pdf | | custodian | 8FW23 | | author | 8FW23 | - When consumer 'X26' searches for DocumentReferences using POST with request body: + When consumer v1 'X26' searches for DocumentReferences using POST with request body: | key | value | | subject | 9278693472 | | type | 736253002 | @@ -199,7 +199,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios Scenario: Search rejects request if the organisation has no registered pointer types Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API - When consumer 'RX898' searches for DocumentReferences using POST with request body: + When consumer v1 'RX898' searches for DocumentReferences using POST with request body: | key | value | | subject | 9278693472 | Then the response status code is 403 @@ -225,7 +225,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios And the organisation 'RX898' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When consumer 'RX898' searches for DocumentReferences using POST with request body: + When consumer v1 'RX898' searches for DocumentReferences using POST with request body: | key | value | | subject | 9278693472 | | category | http://incorrect.info/sct\|736253002 | @@ -253,7 +253,7 @@ Feature: Consumer - searchDocumentReference - Failure Scenarios And the organisation 'RX898' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When consumer 'RX898' searches for DocumentReferences using POST with request body: + When consumer v1 'RX898' searches for DocumentReferences using POST with request body: | key | value | | subject | 9278693472 | | category | http://snomed.info/sct\|734163000,http://snomed.info/sct\|invalid | diff --git a/tests/features/consumer/searchPostDocumentReference-success.feature b/tests/features/consumer/searchPostDocumentReference-success.feature index c8fcecaa5..e601b1e82 100644 --- a/tests/features/consumer/searchPostDocumentReference-success.feature +++ b/tests/features/consumer/searchPostDocumentReference-success.feature @@ -16,7 +16,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc.pdf | | custodian | 8FW23 | | author | 8FW23 | - When consumer 'RX898' searches for DocumentReferences using POST with request body: + When consumer v1 'RX898' searches for DocumentReferences using POST with request body: | key | value | | subject | 9278693472 | Then the response status code is 200 @@ -52,7 +52,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc.pdf | | custodian | RX898 | | author | RX898 | - When consumer 'RX898' searches for DocumentReferences using POST with request body: + When consumer v1 'RX898' searches for DocumentReferences using POST with request body: | key | value | | subject | 9278693472 | | custodian | RX898 | @@ -111,7 +111,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc-3.pdf | | custodian | X26 | | author | X26 | - When consumer 'RX898' searches for DocumentReferences using POST with request body: + When consumer v1 'RX898' searches for DocumentReferences using POST with request body: | key | value | | subject | 9278693472 | Then the response status code is 200 @@ -180,7 +180,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc-3.pdf | | custodian | X26 | | author | X26 | - When consumer 'RX898' searches for DocumentReferences using POST with request body: + When consumer v1 'RX898' searches for DocumentReferences using POST with request body: | key | value | | subject | 9278693472 | Then the response status code is 200 @@ -233,7 +233,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | custodian | DK94 | | author | N64 | When producer 'DK94' requests to delete DocumentReference with id 'DK94-111-DeleteDocRefTest1' - And consumer 'RX898' searches for DocumentReferences using POST with request body: + And consumer v1 'RX898' searches for DocumentReferences using POST with request body: | key | value | | subject | 9278693472 | | custodian | RX898 | @@ -282,7 +282,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc-3.pdf | | custodian | x26 | | author | x26 | - When consumer 'RX898' searches for DocumentReferences using POST with request body: + When consumer v1 'RX898' searches for DocumentReferences using POST with request body: | key | value | | subject | 9278693472 | | category | http://snomed.info/sct\|734163000 | @@ -353,7 +353,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc-3.pdf | | custodian | x26 | | author | x26 | - When consumer 'RX898' searches for DocumentReferences using POST with request body: + When consumer v1 'RX898' searches for DocumentReferences using POST with request body: | key | value | | subject | 9278693472 | | _summary | count | @@ -412,7 +412,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc-4.pdf | | custodian | X26 | | author | X26 | - When consumer 'RX898' searches for DocumentReferences using POST with request body: + When consumer v1 'RX898' searches for DocumentReferences using POST with request body: | key | value | | subject | 9278693472 | | category | http://snomed.info/sct\|734163000,http://snomed.info/sct\|823651000000106 | diff --git a/tests/features/consumer/status-success.feature b/tests/features/consumer/status-success.feature deleted file mode 100644 index c4f2cc781..000000000 --- a/tests/features/consumer/status-success.feature +++ /dev/null @@ -1 +0,0 @@ -# Status returns 200 diff --git a/tests/features/consumer/v2-permissions-by-pointer-type.feature b/tests/features/consumer/v2-permissions-by-pointer-type.feature new file mode 100644 index 000000000..829078493 --- /dev/null +++ b/tests/features/consumer/v2-permissions-by-pointer-type.feature @@ -0,0 +1,215 @@ +Feature: Consumer readDocumentReference - v2 Permissions Model - Success and Failure Scenarios + For the v2 permissions model, permissions are resolved from a JSON file stored in the + nrlf_permissions Lambda layer at the path + {actorType}/{app_id}/{ods_code}.json, which must contain a "types" array. + Permissions for the feature test application (ID 'z00z-y11y-x22x') and + ODS code 'RX898' are baked into the layer by `scripts/get_s3_permissions.py` + at build time, so no dynamic seeding step is required for + success scenarios. + + Background: + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + + Scenario: V2 Permissions with access for pointer type - readDocumentReference + Given a DocumentReference resource exists with values: + | property | value | + | id | RX898-9999999999-ReadDocRefV2SameCustodian | + | subject | 9999999999 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc.pdf | + | custodian | RX898 | + | author | RX898 | + When consumer v2 'RX898' reads a DocumentReference with ID 'RX898-9999999999-ReadDocRefV2SameCustodian' + Then the response status code is 200 + And the response is a DocumentReference with JSON value: + """ + { + "resourceType": "DocumentReference", + "id": "RX898-9999999999-ReadDocRefV2SameCustodian", + "status": "current", + "type": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "736253002", + "display": "Mental health crisis plan" + } + ] + }, + "category": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "734163000", + "display": "Care plan" + } + ] + } + ], + "subject": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999" + } + }, + "custodian": { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "RX898" + } + }, + "author": [ + { + "identifier": { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": "RX898" + } + } + ], + "content": [ + { + "attachment": { + "contentType": "application/pdf", + "url": "https://example.org/my-doc.pdf" + }, + "format": { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLFormatCode", + "code": "urn:nhs-ic:unstructured", + "display": "Unstructured Document" + }, + "extension": [ + { + "url": "https://fhir.nhs.uk/England/StructureDefinition/Extension-England-ContentStability", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLContentStability", + "code": "static", + "display": "Static" + } + ] + } + }, + { + "url": "https://fhir.nhs.uk/England/StructureDefinition/Extension-England-NRLRetrievalMechanism", + "valueCodeableConcept": { + "coding": [ + { + "system": "https://fhir.nhs.uk/England/CodeSystem/England-NRLRetrievalMechanism", + "code": "Direct", + "display": "Direct" + } + ] + } + } + ] + } + ], + "context": { + "practiceSetting": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "788007007", + "display": "General practice service" + } + ] + } + } + } + """ + + Scenario: V2 permissions with access for pointer type 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-1111111111-SearchMultipleRefTest1 | + | subject | 9278693472 | + | 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-1111111111-SearchMultipleRefTest2 | + | subject | 9278693472 | + | status | current | + | type | 736253002 | + | 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-1111111111-SearchMultipleRefTestDifferentType | + | subject | 9278693472 | + | status | current | + | type | 887701000000100 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc-3.pdf | + | custodian | X26 | + | author | X26 | + When consumer v2 'RX898' searches for DocumentReferences using POST with request body: + | key | value | + | subject | 9278693472 | + 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 | X26-1111111111-SearchMultipleRefTest1 | + | subject | 9278693472 | + | 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-1111111111-SearchMultipleRefTest2 | + | subject | 9278693472 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc-2.pdf | + | custodian | X26 | + | author | X26 | + And the Bundle does not contain a DocumentReference with ID 'X26-1111111111-SearchMultipleRefTestDifferentType' + + Scenario: V2 permissions with no access for pointer type - searchDocumentReference + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + When consumer v2 'RX898' searches for DocumentReferences with parameters: + | parameter | value | + | subject | 9278693472 | + | type | http://snomed.info/sct\|887701000000100 | + Then the response status code is 400 + And the response is an OperationOutcome with 1 issue + And the OperationOutcome contains the issue: + """ + { + "severity": "error", + "code": "code-invalid", + "details": { + "coding": [{ + "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + "code": "INVALID_CODE_SYSTEM", + "display": "Invalid code system" + }] + }, + "diagnostics": "Invalid query parameter (The provided type does not match the allowed types for this organisation)", + "expression": ["type"] + } + """ diff --git a/tests/features/steps/2_request.py b/tests/features/steps/2_request.py index 304228fec..7ac95e7d5 100644 --- a/tests/features/steps/2_request.py +++ b/tests/features/steps/2_request.py @@ -24,9 +24,11 @@ def consumer_count_document_references_step(context: Context, ods_code: str): context.response = client.count(items) -@when("consumer '{ods_code}' searches for DocumentReferences with parameters") -def consumer_search_document_reference_step(context: Context, ods_code: str): - client = consumer_client_from_context(context, ods_code) +@when("consumer {version} '{ods_code}' searches for DocumentReferences with parameters") +def consumer_search_document_reference_step( + context: Context, version: str, ods_code: str +): + client = consumer_client_from_context(context, ods_code, v2=(version == "v2")) if not context.table: raise ValueError("No search query table provided") @@ -46,10 +48,12 @@ def consumer_search_document_reference_step(context: Context, ods_code: str): @when( - "consumer '{ods_code}' searches for DocumentReferences using POST with request body" + "consumer {version} '{ods_code}' searches for DocumentReferences using POST with request body" ) -def consumer_search_post_document_reference_step(context: Context, ods_code: str): - client = consumer_client_from_context(context, ods_code) +def consumer_search_post_document_reference_step( + context: Context, version: str, ods_code: str +): + client = consumer_client_from_context(context, ods_code, v2=(version == "v2")) if not context.table: raise ValueError("No search query table provided") @@ -68,11 +72,13 @@ def consumer_search_post_document_reference_step(context: Context, ods_code: str ) -@when("consumer '{ods_code}' reads a DocumentReference with ID '{doc_ref_id}'") +@when( + "consumer {version} '{ods_code}' reads a DocumentReference with ID '{doc_ref_id}'" +) def consumer_read_document_reference_step( - context: Context, ods_code: str, doc_ref_id: str + context: Context, version: str, ods_code: str, doc_ref_id: str ): - client = consumer_client_from_context(context, ods_code) + client = consumer_client_from_context(context, ods_code, v2=(version == "v2")) context.response = client.read(doc_ref_id) diff --git a/tests/features/utils/api_client.py b/tests/features/utils/api_client.py index 4c8f8a8cc..ad468767c 100644 --- a/tests/features/utils/api_client.py +++ b/tests/features/utils/api_client.py @@ -4,6 +4,7 @@ from tests.utilities.api_clients import ( ClientConfig, ConsumerTestClient, + ConsumerV2TestClient, ProducerTestClient, ) @@ -33,9 +34,13 @@ def _config_from_context(context: Context, ods_code: str): ) -def consumer_client_from_context(context: Context, ods_code: str): +def consumer_client_from_context(context: Context, ods_code: str, v2: bool = False): client_config = _config_from_context(context, ods_code) - return ConsumerTestClient(config=client_config) + return ( + ConsumerV2TestClient(config=client_config) + if v2 + else ConsumerTestClient(config=client_config) + ) def producer_client_from_context(context: Context, ods_code: str): diff --git a/tests/utilities/api_clients.py b/tests/utilities/api_clients.py index bb522cbe3..8c42e99ab 100644 --- a/tests/utilities/api_clients.py +++ b/tests/utilities/api_clients.py @@ -209,6 +209,38 @@ def read_capability_statement(self) -> Response: ) +class ConsumerV2TestClient(ConsumerTestClient): + """ + Consumer test client that uses the v2 permissions model. + + Sends NHSD-End-User-Organisation-ODS and NHSD-NRL-App-Id instead of + NHSD-Connection-Metadata / NHSD-Client-RP-Details, triggering the + _use_v2_permissions_model path in the request handler. + """ + + def __init__(self, config: ClientConfig): + self.config = config + self.api_url = f"{self.config.base_url}consumer{self.config.api_path}" + + self.request_headers = { + "Authorization": f"Bearer {self.config.auth_token}", + "X-Request-Id": "test-request-id", + } + + if self.config.client_cert: + ods_code = self.config.connection_metadata.ods_code + app_id = self.config.connection_metadata.nrl_app_id + self.request_headers.update( + { + "NHSD-End-User-Organisation-ODS": ods_code, + "NHSD-NRL-App-Id": app_id, + "NHSD-Correlation-Id": "test-correlation-id", + } + ) + + self.request_headers.update(self.config.custom_headers) + + class ProducerTestClient: def __init__(self, config: ClientConfig): self.config = config From ac060ab373b4c7060c06669e4861eb131bab5c42 Mon Sep 17 00:00:00 2001 From: Sandy Forrester Date: Thu, 5 Mar 2026 15:31:20 +0000 Subject: [PATCH 03/14] NRL-1949 Add todo --- .../modules/permissions-store-bucket/s3.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 80c9515ca..2c7f43d1e 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/" From e5d4edd316ec67c1d22d6b05a164a69c6ba42433 Mon Sep 17 00:00:00 2001 From: Sandy Forrester Date: Thu, 5 Mar 2026 17:30:03 +0000 Subject: [PATCH 04/14] NRL-1949 Add producer v2 WIP --- scripts/get_s3_permissions.py | 14 ++++- .../v2-permissions-by-pointer-type.feature | 2 +- .../createDocumentReference-failure.feature | 24 ++++----- .../createDocumentReference-success.feature | 12 ++--- .../v2-permissions-by-pointer-type.feature | 54 +++++++++++++++++++ tests/features/steps/2_request.py | 6 +-- tests/features/utils/api_client.py | 9 +++- tests/utilities/api_clients.py | 33 ++++++++++++ 8 files changed, 129 insertions(+), 25 deletions(-) create mode 100644 tests/features/producer/v2-permissions-by-pointer-type.feature diff --git a/scripts/get_s3_permissions.py b/scripts/get_s3_permissions.py index 2d5f52e20..84ed11471 100644 --- a/scripts/get_s3_permissions.py +++ b/scripts/get_s3_permissions.py @@ -49,13 +49,13 @@ def add_test_files(folder, file_name, local_path): json.dump(PointerTypes.list(), f) +# TODO MAKE THIS NEATER def add_feature_test_files(local_path): """Bake in v2 permissions for the feature test application so that the v2 permissions model can be proven via feature tests without requiring a dynamic layer rebuild between test setup and test execution. """ - # TODO NEED TO ADD PRODUCER PERMS print("Adding feature test v2 permissions to temporary directory...") feature_test_app_id = "z00z-y11y-x22x" consumer_entries = [ @@ -64,12 +64,24 @@ def add_feature_test_files(local_path): [PointerTypes.MENTAL_HEALTH_PLAN.value], ), # http://snomed.info/sct|736253002 ] + producer_entries = [ + ( + "RX898", + [PointerTypes.MENTAL_HEALTH_PLAN.value], + ), # http://snomed.info/sct|736253002 + ] for ods_code, pointer_types in consumer_entries: folder_path = Path.joinpath(local_path, "consumer", feature_test_app_id) folder_path.mkdir(parents=True, exist_ok=True) file_path = folder_path / f"{ods_code}.json" with open(file_path, "w") as f: json.dump({"types": pointer_types}, f) + for ods_code, pointer_types in producer_entries: + folder_path = Path.joinpath(local_path, "producer", feature_test_app_id) + folder_path.mkdir(parents=True, exist_ok=True) + file_path = folder_path / f"{ods_code}.json" + with open(file_path, "w") as f: + json.dump({"types": pointer_types}, f) def download_files(s3_client, bucket_name, local_path, file_names, folders): diff --git a/tests/features/consumer/v2-permissions-by-pointer-type.feature b/tests/features/consumer/v2-permissions-by-pointer-type.feature index 829078493..3ac0e9510 100644 --- a/tests/features/consumer/v2-permissions-by-pointer-type.feature +++ b/tests/features/consumer/v2-permissions-by-pointer-type.feature @@ -1,4 +1,4 @@ -Feature: Consumer readDocumentReference - v2 Permissions Model - Success and Failure Scenarios +Feature: Consumer v2 permissions by pointer type - Success and Failure Scenarios For the v2 permissions model, permissions are resolved from a JSON file stored in the nrlf_permissions Lambda layer at the path {actorType}/{app_id}/{ods_code}.json, which must contain a "types" array. diff --git a/tests/features/producer/createDocumentReference-failure.feature b/tests/features/producer/createDocumentReference-failure.feature index ff1d2bfda..905bea341 100644 --- a/tests/features/producer/createDocumentReference-failure.feature +++ b/tests/features/producer/createDocumentReference-failure.feature @@ -16,7 +16,7 @@ Feature: Producer - createDocumentReference - Failure Scenarios And the organisation 'ANGY1' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When producer 'ANGY1' creates a DocumentReference with values: + When producer v1 'ANGY1' creates a DocumentReference with values: | property | value | | subject | 9999999999 | | status | current | @@ -53,7 +53,7 @@ Feature: Producer - createDocumentReference - Failure Scenarios And the organisation 'ANGY1' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When producer 'ANGY1' creates a DocumentReference with values: + When producer v1 'ANGY1' creates a DocumentReference with values: | property | value | | subject | 1234567890 | | status | current | @@ -212,7 +212,7 @@ Feature: Producer - createDocumentReference - Failure Scenarios | url | https://example.org/my-doc.pdf | | custodian | N0TANGY | | author | HAR1 | - When producer 'ANGY1' creates a DocumentReference with values: + When producer v1 'ANGY1' creates a DocumentReference with values: | property | value | | subject | 9278693472 | | status | current | @@ -263,7 +263,7 @@ Feature: Producer - createDocumentReference - Failure Scenarios Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API And the organisation 'ANGY1' is authorised to access pointer types: | system | value | - When producer 'ANGY1' creates a DocumentReference with values: + When producer v1 'ANGY1' creates a DocumentReference with values: | property | value | | subject | 9999999999 | | status | current | @@ -298,7 +298,7 @@ Feature: Producer - createDocumentReference - Failure Scenarios | system | value | | http://snomed.info/sct | 1363501000000100 | | http://snomed.info/sct | 736253002 | - When producer 'X26' creates a DocumentReference with values: + When producer v1 'X26' creates a DocumentReference with values: | property | value | | subject | 9999999999 | | type | 736253002 | @@ -335,7 +335,7 @@ Feature: Producer - createDocumentReference - Failure Scenarios And the organisation 'ANGY1' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When producer 'ANGY1' creates a DocumentReference with values: + When producer v1 'ANGY1' creates a DocumentReference with values: | property | value | | subject | 9999999999 | | status | current | @@ -373,7 +373,7 @@ Feature: Producer - createDocumentReference - Failure Scenarios | system | value | | http://snomed.info/sct | 1363501000000100 | | http://snomed.info/sct | 736253002 | - When producer 'X26' creates a DocumentReference with values: + When producer v1 'X26' creates a DocumentReference with values: | property | value | | subject | 9999999999 | | status | current | @@ -544,7 +544,7 @@ Feature: Producer - createDocumentReference - Failure Scenarios And the organisation 'ANGY1' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When producer 'ANGY1' creates a DocumentReference with values: + When producer v1 'ANGY1' creates a DocumentReference with values: | property | value | | subject | 9278693472 | | status | current | @@ -581,7 +581,7 @@ Feature: Producer - createDocumentReference - Failure Scenarios And the organisation 'ANGY1' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When producer 'ANGY1' creates a DocumentReference with values: + When producer v1 'ANGY1' creates a DocumentReference with values: | property | value | | subject | 9999999999 | | status | current | @@ -620,7 +620,7 @@ Feature: Producer - createDocumentReference - Failure Scenarios | system | value | | https://nicip.nhs.uk | MAULR | | https://nicip.nhs.uk | MAXIB | - When producer 'ANGY1' creates a DocumentReference with values: + When producer v1 'ANGY1' creates a DocumentReference with values: | property | value | | subject | 9999999999 | | status | current | @@ -665,7 +665,7 @@ Feature: Producer - createDocumentReference - Failure Scenarios | system | value | | http://snomed.info/sct | 1363501000000100 | | http://snomed.info/sct | 736253002 | - When producer 'X26' creates a DocumentReference with values: + When producer v1 'X26' creates a DocumentReference with values: | property | value | | subject | 9999999999 | | status | current | @@ -814,7 +814,7 @@ Feature: Producer - createDocumentReference - Failure Scenarios And the organisation 'ANGY1' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When producer 'ANGY1' creates a DocumentReference with values: + When producer v1 'ANGY1' creates a DocumentReference with values: | property | value | | subject | 9999999999 | | status | current | diff --git a/tests/features/producer/createDocumentReference-success.feature b/tests/features/producer/createDocumentReference-success.feature index e7ea87d8c..03f4f65c7 100644 --- a/tests/features/producer/createDocumentReference-success.feature +++ b/tests/features/producer/createDocumentReference-success.feature @@ -5,7 +5,7 @@ Feature: Producer - createDocumentReference - Success Scenarios And the organisation 'ANGY1' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When producer 'ANGY1' creates a DocumentReference with values: + When producer v1 'ANGY1' creates a DocumentReference with values: | property | value | | subject | 9278693472 | | status | current | @@ -110,7 +110,7 @@ Feature: Producer - createDocumentReference - Success Scenarios | custodian | ANGY1 | | author | HAR1 | | practiceSetting | 788002001 | - When producer 'ANGY1' creates a DocumentReference with values: + When producer v1 'ANGY1' creates a DocumentReference with values: | property | value | | subject | 9278693472 | | status | current | @@ -161,7 +161,7 @@ Feature: Producer - createDocumentReference - Success Scenarios And the organisation 'ANGY1' is authorised to access pointer types: | system | value | | http://snomed.info/sct | | - When producer 'ANGY1' creates a DocumentReference with values: + When producer v1 'ANGY1' creates a DocumentReference with values: | property | value | | subject | 9278693472 | | status | current | @@ -239,7 +239,7 @@ Feature: Producer - createDocumentReference - Success Scenarios | system | value | | https://nicip.nhs.uk | MAULR | | https://nicip.nhs.uk | MAXIB | - When producer 'ANGY1' creates a DocumentReference with values: + When producer v1 'ANGY1' creates a DocumentReference with values: | property | value | | subject | 9278693472 | | status | current | @@ -282,7 +282,7 @@ Feature: Producer - createDocumentReference - Success Scenarios | author | HAR1 | | url | https://example.org/my-doc.pdf | | practiceSetting | 788002001 | - When producer 'ANGY1' creates a DocumentReference with values: + When producer v1 'ANGY1' creates a DocumentReference with values: | property | value | | subject | 9278693472 | | status | current | @@ -330,7 +330,7 @@ Feature: Producer - createDocumentReference - Success Scenarios And the organisation 'BARS1' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 749001000000101 | - When producer 'BARS1' creates a DocumentReference with values: + When producer v1 'BARS1' creates a DocumentReference with values: | property | value | | subject | 9278693472 | | status | current | diff --git a/tests/features/producer/v2-permissions-by-pointer-type.feature b/tests/features/producer/v2-permissions-by-pointer-type.feature new file mode 100644 index 000000000..0f17a8151 --- /dev/null +++ b/tests/features/producer/v2-permissions-by-pointer-type.feature @@ -0,0 +1,54 @@ +Feature: Producer v2 permissions by pointer type - Success and Failure Scenarios + For the v2 permissions model, permissions are resolved from a JSON file stored in the + nrlf_permissions Lambda layer at the path + {actorType}/{app_id}/{ods_code}.json, which must contain a "types" array. + Permissions for the feature test application (ID 'z00z-y11y-x22x') and + ODS code 'RX898' are baked into the layer by `scripts/get_s3_permissions.py` + at build time, so no dynamic seeding step is required for + success scenarios. + + Background: + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + + Scenario: Successfully create a Document Pointer (care plan) + When producer v2 'RX898' creates a DocumentReference with values: + | property | value | + | subject | 9278693472 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | custodian | RX898 | + | author | HAR1 | + | url | https://example.org/my-doc.pdf | + | practiceSetting | 788002001 | + Then the response status code is 201 + And the response is an OperationOutcome with 1 issue + And the OperationOutcome contains the issue: + """ + { + "severity": "information", + "code": "informational", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/ValueSet/NRL-ResponseCode", + "code": "RESOURCE_CREATED", + "display": "Resource created" + } + ] + }, + "diagnostics": "The document has been created" + } + """ + And the response has a Location header + And the Location header starts with '/DocumentReference/RX898-' + And the resource in the Location header exists with values: + | property | value | + | subject | 9278693472 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | custodian | RX898 | + | author | HAR1 | + | url | https://example.org/my-doc.pdf | + | practiceSetting | 788002001 | diff --git a/tests/features/steps/2_request.py b/tests/features/steps/2_request.py index 7ac95e7d5..f37699991 100644 --- a/tests/features/steps/2_request.py +++ b/tests/features/steps/2_request.py @@ -82,9 +82,9 @@ def consumer_read_document_reference_step( context.response = client.read(doc_ref_id) -@when("producer '{ods_code}' creates a DocumentReference with values") -def create_post_document_reference_step(context: Context, ods_code: str): - client = producer_client_from_context(context, ods_code) +@when("producer {version} '{ods_code}' creates a DocumentReference with values") +def create_post_document_reference_step(context: Context, version: str, ods_code: str): + client = producer_client_from_context(context, ods_code, v2=(version == "v2")) if not context.table: raise ValueError("No document reference data table provided") diff --git a/tests/features/utils/api_client.py b/tests/features/utils/api_client.py index ad468767c..3e90966a2 100644 --- a/tests/features/utils/api_client.py +++ b/tests/features/utils/api_client.py @@ -6,6 +6,7 @@ ConsumerTestClient, ConsumerV2TestClient, ProducerTestClient, + ProducerV2TestClient, ) @@ -43,6 +44,10 @@ def consumer_client_from_context(context: Context, ods_code: str, v2: bool = Fal ) -def producer_client_from_context(context: Context, ods_code: str): +def producer_client_from_context(context: Context, ods_code: str, v2: bool = False): client_config = _config_from_context(context, ods_code) - return ProducerTestClient(config=client_config) + return ( + ProducerV2TestClient(config=client_config) + if v2 + else ProducerTestClient(config=client_config) + ) diff --git a/tests/utilities/api_clients.py b/tests/utilities/api_clients.py index 8c42e99ab..4f3b10218 100644 --- a/tests/utilities/api_clients.py +++ b/tests/utilities/api_clients.py @@ -241,6 +241,7 @@ def __init__(self, config: ClientConfig): self.request_headers.update(self.config.custom_headers) +# TODO MAKE THIS NEATER class ProducerTestClient: def __init__(self, config: ClientConfig): self.config = config @@ -417,3 +418,35 @@ def head( headers=headers, cert=self.config.client_cert, ) + + +class ProducerV2TestClient(ProducerTestClient): + """ + Producer test client that uses the v2 permissions model. + + Sends NHSD-End-User-Organisation-ODS and NHSD-NRL-App-Id instead of + NHSD-Connection-Metadata / NHSD-Client-RP-Details, triggering the + _use_v2_permissions_model path in the request handler. + """ + + def __init__(self, config: ClientConfig): + self.config = config + self.api_url = f"{self.config.base_url}producer{self.config.api_path}" + + self.request_headers = { + "Authorization": f"Bearer {self.config.auth_token}", + "X-Request-Id": "test-request-id", + } + + if self.config.client_cert: + ods_code = self.config.connection_metadata.ods_code + app_id = self.config.connection_metadata.nrl_app_id + self.request_headers.update( + { + "NHSD-End-User-Organisation-ODS": ods_code, + "NHSD-NRL-App-Id": app_id, + "NHSD-Correlation-Id": "test-correlation-id", + } + ) + + self.request_headers.update(self.config.custom_headers) From db6588348dba28709feda58b7817ad74896a12e1 Mon Sep 17 00:00:00 2001 From: Sandy Forrester Date: Fri, 6 Mar 2026 12:24:08 +0000 Subject: [PATCH 05/14] NRL-1949 V2 Producer tests for pointer type --- .../searchDocumentReference-success.feature | 2 +- ...earchPostDocumentReference-success.feature | 2 +- .../deleteDocumentReference-failure.feature | 2 +- .../deleteDocumentReference-success.feature | 4 +- .../searchDocumentReference-failure.feature | 0 .../searchDocumentReference-success.feature | 4 +- ...earchPostDocumentReference-failure.feature | 0 ...earchPostDocumentReference-success.feature | 0 .../v2-permissions-by-pointer-type.feature | 79 ++++++++++++++++++- tests/features/steps/2_request.py | 16 ++-- 10 files changed, 95 insertions(+), 14 deletions(-) delete mode 100644 tests/features/producer/searchDocumentReference-failure.feature delete mode 100644 tests/features/producer/searchPostDocumentReference-failure.feature delete mode 100644 tests/features/producer/searchPostDocumentReference-success.feature diff --git a/tests/features/consumer/searchDocumentReference-success.feature b/tests/features/consumer/searchDocumentReference-success.feature index 41ec35367..b3d9c4f7b 100644 --- a/tests/features/consumer/searchDocumentReference-success.feature +++ b/tests/features/consumer/searchDocumentReference-success.feature @@ -298,7 +298,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc.pdf | | custodian | DK94 | | author | N64 | - When producer 'DK94' requests to delete DocumentReference with id 'DK94-111-DeleteDocRefTest1' + When producer v1 'DK94' requests to delete DocumentReference with id 'DK94-111-DeleteDocRefTest1' And consumer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | diff --git a/tests/features/consumer/searchPostDocumentReference-success.feature b/tests/features/consumer/searchPostDocumentReference-success.feature index e601b1e82..af09f03d6 100644 --- a/tests/features/consumer/searchPostDocumentReference-success.feature +++ b/tests/features/consumer/searchPostDocumentReference-success.feature @@ -232,7 +232,7 @@ Feature: Consumer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc.pdf | | custodian | DK94 | | author | N64 | - When producer 'DK94' requests to delete DocumentReference with id 'DK94-111-DeleteDocRefTest1' + When producer v1 'DK94' requests to delete DocumentReference with id 'DK94-111-DeleteDocRefTest1' And consumer v1 'RX898' searches for DocumentReferences using POST with request body: | key | value | | subject | 9278693472 | diff --git a/tests/features/producer/deleteDocumentReference-failure.feature b/tests/features/producer/deleteDocumentReference-failure.feature index 52fb6c3fc..105398075 100644 --- a/tests/features/producer/deleteDocumentReference-failure.feature +++ b/tests/features/producer/deleteDocumentReference-failure.feature @@ -19,7 +19,7 @@ Feature: Producer - deleteDocumentReference - Failure Scenarios | url | https://example.org/my-doc.pdf | | custodian | OC84 | | author | N64 | - When producer 'DK94' requests to delete DocumentReference with id 'OC84-111-DeleteTest-NotYourPointer' + When producer v1 'DK94' requests to delete DocumentReference with id 'OC84-111-DeleteTest-NotYourPointer' Then the response status code is 403 And the response is an OperationOutcome with 1 issue And the OperationOutcome contains the issue: diff --git a/tests/features/producer/deleteDocumentReference-success.feature b/tests/features/producer/deleteDocumentReference-success.feature index 440de277b..f1543a8e5 100644 --- a/tests/features/producer/deleteDocumentReference-success.feature +++ b/tests/features/producer/deleteDocumentReference-success.feature @@ -18,7 +18,7 @@ Feature: Producer - deleteDocumentReference - Success Scenarios | url | https://example.org/my-doc.pdf | | custodian | DK94 | | author | N64 | - When producer 'DK94' requests to delete DocumentReference with id 'DK94-111-DeleteDocRefTest1' + When producer v1 'DK94' requests to delete DocumentReference with id 'DK94-111-DeleteDocRefTest1' Then the response status code is 200 And the response is an OperationOutcome with 1 issue And the OperationOutcome contains the issue: @@ -47,7 +47,7 @@ Feature: Producer - deleteDocumentReference - Success Scenarios And the organisation 'DK94' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When producer 'DK94' requests to delete DocumentReference with id 'DK94-000-NoPointerHere' + When producer v1 'DK94' requests to delete DocumentReference with id 'DK94-000-NoPointerHere' Then the response status code is 200 And the response is an OperationOutcome with 1 issue And the OperationOutcome contains the issue: diff --git a/tests/features/producer/searchDocumentReference-failure.feature b/tests/features/producer/searchDocumentReference-failure.feature deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/features/producer/searchDocumentReference-success.feature b/tests/features/producer/searchDocumentReference-success.feature index 2fe5e31df..c8823bce5 100644 --- a/tests/features/producer/searchDocumentReference-success.feature +++ b/tests/features/producer/searchDocumentReference-success.feature @@ -27,7 +27,7 @@ Feature: Producer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc.pdf | | custodian | RX898 | | author | X26 | - When producer 'RX898' searches for DocumentReferences with parameters: + When producer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | subject | 9278693472 | Then the response status code is 200 @@ -86,7 +86,7 @@ Feature: Producer - searchDocumentReference - Success Scenarios | url | https://example.org/my-doc.pdf | | custodian | RX898 | | author | X26 | - When producer 'RX898' searches for DocumentReferences with parameters: + When producer v1 'RX898' searches for DocumentReferences with parameters: | parameter | value | | pointer_type | 736253002 | | subject | 9999999999 | diff --git a/tests/features/producer/searchPostDocumentReference-failure.feature b/tests/features/producer/searchPostDocumentReference-failure.feature deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/features/producer/searchPostDocumentReference-success.feature b/tests/features/producer/searchPostDocumentReference-success.feature deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/features/producer/v2-permissions-by-pointer-type.feature b/tests/features/producer/v2-permissions-by-pointer-type.feature index 0f17a8151..cb2f51beb 100644 --- a/tests/features/producer/v2-permissions-by-pointer-type.feature +++ b/tests/features/producer/v2-permissions-by-pointer-type.feature @@ -10,7 +10,7 @@ Feature: Producer v2 permissions by pointer type - Success and Failure Scenarios Background: Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API - Scenario: Successfully create a Document Pointer (care plan) + Scenario: V2 Permissions with access for pointer type - createDocumentReference When producer v2 'RX898' creates a DocumentReference with values: | property | value | | subject | 9278693472 | @@ -52,3 +52,80 @@ Feature: Producer v2 permissions by pointer type - Success and Failure Scenarios | author | HAR1 | | url | https://example.org/my-doc.pdf | | practiceSetting | 788002001 | + + Scenario: V2 Permissions with access for pointer type - deleteDocumentReference + Given a DocumentReference resource exists with values + | property | value | + | id | RX898-111-DeleteDocRefTest1 | + | subject | 9278693472 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc.pdf | + | custodian | RX898 | + | author | RX898 | + When producer v2 'RX898' requests to delete DocumentReference with id 'RX898-111-DeleteDocRefTest1' + Then the response status code is 200 + And the response is an OperationOutcome with 1 issue + And the OperationOutcome contains the issue: + """ + { + "severity": "information", + "code": "informational", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/ValueSet/NRL-ResponseCode", + "code": "RESOURCE_DELETED", + "display": "Resource deleted" + } + ] + }, + "diagnostics": "The requested DocumentReference has been deleted" + } + """ + And the resource with id 'DK94-111-DeleteDocRefTest1' does not exist + + Scenario: V2 Permissions with no access for pointer type - searchDocumentReference + Given a DocumentReference resource exists with values: + | property | value | + | id | RX898-1111111111-SearchNHSDocRefTest1 | + | subject | 9999999999 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc.pdf | + | 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 | + When producer v2 'RX898' 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 1 + And the Bundle has 1 entry + And the Bundle contains an DocumentReference with values + | property | value | + | id | RX898-1111111111-SearchNHSDocRefTest1 | + | subject | 9999999999 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | contentType | application/pdf | + | url | https://example.org/my-doc.pdf | + | custodian | RX898 | + | author | X26 | + And the Bundle does not contain a DocumentReference with ID 'SG4-1111111111-SearchNHSDocRefTest3' diff --git a/tests/features/steps/2_request.py b/tests/features/steps/2_request.py index f37699991..733988d83 100644 --- a/tests/features/steps/2_request.py +++ b/tests/features/steps/2_request.py @@ -221,10 +221,12 @@ def update_put_document_reference_step( @when( - "producer '{ods_code}' requests to delete DocumentReference with id '{doc_ref_id}'" + "producer {version} '{ods_code}' requests to delete DocumentReference with id '{doc_ref_id}'" ) -def delete_document_reference_step(context: Context, ods_code: str, doc_ref_id: str): - client = producer_client_from_context(context, ods_code) +def delete_document_reference_step( + context: Context, version: str, ods_code: str, doc_ref_id: str +): + client = producer_client_from_context(context, ods_code, v2=(version == "v2")) context.response = client.delete(doc_ref_id) @@ -236,9 +238,11 @@ def producer_read_document_reference_step( context.response = client.read(doc_ref_id) -@when("producer '{ods_code}' searches for DocumentReferences with parameters") -def producer_search_document_reference_step(context: Context, ods_code: str): - client = producer_client_from_context(context, ods_code) +@when("producer {version} '{ods_code}' searches for DocumentReferences with parameters") +def producer_search_document_reference_step( + context: Context, version: str, ods_code: str +): + client = producer_client_from_context(context, ods_code, v2=(version == "v2")) if not context.table: raise ValueError("No search query table provided") From dddea426a9c656b1bff1a2f25749d2ef75e4d3be Mon Sep 17 00:00:00 2001 From: Sandy Forrester Date: Fri, 6 Mar 2026 14:42:00 +0000 Subject: [PATCH 06/14] NRL-1949 Tidy up client classes --- tests/features/utils/api_client.py | 14 +-- tests/utilities/api_clients.py | 131 +++++++++-------------------- 2 files changed, 44 insertions(+), 101 deletions(-) diff --git a/tests/features/utils/api_client.py b/tests/features/utils/api_client.py index 3e90966a2..3b00c5938 100644 --- a/tests/features/utils/api_client.py +++ b/tests/features/utils/api_client.py @@ -4,9 +4,7 @@ from tests.utilities.api_clients import ( ClientConfig, ConsumerTestClient, - ConsumerV2TestClient, ProducerTestClient, - ProducerV2TestClient, ) @@ -37,17 +35,9 @@ def _config_from_context(context: Context, ods_code: str): def consumer_client_from_context(context: Context, ods_code: str, v2: bool = False): client_config = _config_from_context(context, ods_code) - return ( - ConsumerV2TestClient(config=client_config) - if v2 - else ConsumerTestClient(config=client_config) - ) + return ConsumerTestClient(config=client_config, use_v2=v2) def producer_client_from_context(context: Context, ods_code: str, v2: bool = False): client_config = _config_from_context(context, ods_code) - return ( - ProducerV2TestClient(config=client_config) - if v2 - else ProducerTestClient(config=client_config) - ) + return ProducerTestClient(config=client_config, use_v2=v2) diff --git a/tests/utilities/api_clients.py b/tests/utilities/api_clients.py index 4f3b10218..85cf41d36 100644 --- a/tests/utilities/api_clients.py +++ b/tests/utilities/api_clients.py @@ -68,7 +68,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: class ConsumerTestClient: - def __init__(self, config: ClientConfig): + def __init__(self, config: ClientConfig, use_v2: bool = False): self.config = config self.api_url = f"{self.config.base_url}consumer{self.config.api_path}" @@ -78,17 +78,26 @@ def __init__(self, config: ClientConfig): } if self.config.client_cert: - connection_metadata = self.config.connection_metadata.model_dump( - by_alias=True - ) - client_rp_details = connection_metadata.pop("client_rp_details") - self.request_headers.update( - { - "NHSD-Connection-Metadata": json.dumps(connection_metadata), - "NHSD-Client-RP-Details": json.dumps(client_rp_details), - "NHSD-Correlation-Id": "test-correlation-id", - } - ) + 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", + } + ) + else: + connection_metadata = self.config.connection_metadata.model_dump( + by_alias=True + ) + client_rp_details = connection_metadata.pop("client_rp_details") + self.request_headers.update( + { + "NHSD-Connection-Metadata": json.dumps(connection_metadata), + "NHSD-Client-RP-Details": json.dumps(client_rp_details), + "NHSD-Correlation-Id": "test-correlation-id", + } + ) self.request_headers.update(self.config.custom_headers) @@ -209,41 +218,8 @@ def read_capability_statement(self) -> Response: ) -class ConsumerV2TestClient(ConsumerTestClient): - """ - Consumer test client that uses the v2 permissions model. - - Sends NHSD-End-User-Organisation-ODS and NHSD-NRL-App-Id instead of - NHSD-Connection-Metadata / NHSD-Client-RP-Details, triggering the - _use_v2_permissions_model path in the request handler. - """ - - def __init__(self, config: ClientConfig): - self.config = config - self.api_url = f"{self.config.base_url}consumer{self.config.api_path}" - - self.request_headers = { - "Authorization": f"Bearer {self.config.auth_token}", - "X-Request-Id": "test-request-id", - } - - if self.config.client_cert: - ods_code = self.config.connection_metadata.ods_code - app_id = self.config.connection_metadata.nrl_app_id - self.request_headers.update( - { - "NHSD-End-User-Organisation-ODS": ods_code, - "NHSD-NRL-App-Id": app_id, - "NHSD-Correlation-Id": "test-correlation-id", - } - ) - - self.request_headers.update(self.config.custom_headers) - - -# TODO MAKE THIS NEATER class ProducerTestClient: - def __init__(self, config: ClientConfig): + def __init__(self, config: ClientConfig, use_v2: bool = False): self.config = config self.api_url = f"{self.config.base_url}producer{self.config.api_path}" @@ -253,17 +229,26 @@ def __init__(self, config: ClientConfig): } if self.config.client_cert: - connection_metadata = self.config.connection_metadata.model_dump( - by_alias=True - ) - client_rp_details = connection_metadata.pop("client_rp_details") - self.request_headers.update( - { - "NHSD-Connection-Metadata": json.dumps(connection_metadata), - "NHSD-Client-RP-Details": json.dumps(client_rp_details), - "NHSD-Correlation-Id": "test-correlation-id", - } - ) + 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", + } + ) + else: + connection_metadata = self.config.connection_metadata.model_dump( + by_alias=True + ) + client_rp_details = connection_metadata.pop("client_rp_details") + self.request_headers.update( + { + "NHSD-Connection-Metadata": json.dumps(connection_metadata), + "NHSD-Client-RP-Details": json.dumps(client_rp_details), + "NHSD-Correlation-Id": "test-correlation-id", + } + ) self.request_headers.update(self.config.custom_headers) @@ -418,35 +403,3 @@ def head( headers=headers, cert=self.config.client_cert, ) - - -class ProducerV2TestClient(ProducerTestClient): - """ - Producer test client that uses the v2 permissions model. - - Sends NHSD-End-User-Organisation-ODS and NHSD-NRL-App-Id instead of - NHSD-Connection-Metadata / NHSD-Client-RP-Details, triggering the - _use_v2_permissions_model path in the request handler. - """ - - def __init__(self, config: ClientConfig): - self.config = config - self.api_url = f"{self.config.base_url}producer{self.config.api_path}" - - self.request_headers = { - "Authorization": f"Bearer {self.config.auth_token}", - "X-Request-Id": "test-request-id", - } - - if self.config.client_cert: - ods_code = self.config.connection_metadata.ods_code - app_id = self.config.connection_metadata.nrl_app_id - self.request_headers.update( - { - "NHSD-End-User-Organisation-ODS": ods_code, - "NHSD-NRL-App-Id": app_id, - "NHSD-Correlation-Id": "test-correlation-id", - } - ) - - self.request_headers.update(self.config.custom_headers) From 833e404b8dd5e2a3edadcb712db5dd08ea59a0c6 Mon Sep 17 00:00:00 2001 From: Sandy Forrester Date: Fri, 6 Mar 2026 15:39:24 +0000 Subject: [PATCH 07/14] NRL-1949 Tidy up write permissions --- scripts/get_s3_permissions.py | 53 ++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/scripts/get_s3_permissions.py b/scripts/get_s3_permissions.py index 84ed11471..68e82c5de 100644 --- a/scripts/get_s3_permissions.py +++ b/scripts/get_s3_permissions.py @@ -49,7 +49,12 @@ def add_test_files(folder, file_name, local_path): json.dump(PointerTypes.list(), f) -# TODO MAKE THIS NEATER +def _write_permission_file(folder_path, ods_code, pointer_types): + 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) + + def add_feature_test_files(local_path): """Bake in v2 permissions for the feature test application so that the v2 permissions model can be proven via feature tests without @@ -57,31 +62,29 @@ def add_feature_test_files(local_path): """ print("Adding feature test v2 permissions to temporary directory...") - feature_test_app_id = "z00z-y11y-x22x" - consumer_entries = [ - ( - "RX898", - [PointerTypes.MENTAL_HEALTH_PLAN.value], - ), # http://snomed.info/sct|736253002 + permissions = { + "consumer": [ + ( + "z00z-y11y-x22x", + "RX898", + [PointerTypes.MENTAL_HEALTH_PLAN.value], + ), # http://snomed.info/sct|736253002 + ], + "producer": [ + ( + "z00z-y11y-x22x", + "RX898", + [PointerTypes.MENTAL_HEALTH_PLAN.value], + ), # http://snomed.info/sct|736253002 + ], + } + [ + _write_permission_file( + Path.joinpath(local_path, actor_type, app_id), ods_code, pointer_types + ) + for actor_type, entries in permissions.items() + for app_id, ods_code, pointer_types in entries ] - producer_entries = [ - ( - "RX898", - [PointerTypes.MENTAL_HEALTH_PLAN.value], - ), # http://snomed.info/sct|736253002 - ] - for ods_code, pointer_types in consumer_entries: - folder_path = Path.joinpath(local_path, "consumer", feature_test_app_id) - folder_path.mkdir(parents=True, exist_ok=True) - file_path = folder_path / f"{ods_code}.json" - with open(file_path, "w") as f: - json.dump({"types": pointer_types}, f) - for ods_code, pointer_types in producer_entries: - folder_path = Path.joinpath(local_path, "producer", feature_test_app_id) - folder_path.mkdir(parents=True, exist_ok=True) - file_path = folder_path / f"{ods_code}.json" - with open(file_path, "w") as f: - json.dump({"types": pointer_types}, f) def download_files(s3_client, bucket_name, local_path, file_names, folders): From 791f3a3441979fc72ee12a6eff7937f234de789c Mon Sep 17 00:00:00 2001 From: Sandy Forrester Date: Fri, 6 Mar 2026 15:59:13 +0000 Subject: [PATCH 08/14] NRL-1949 Amend comments --- .../consumer/v2-permissions-by-pointer-type.feature | 7 ++----- .../producer/v2-permissions-by-pointer-type.feature | 7 ++----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/tests/features/consumer/v2-permissions-by-pointer-type.feature b/tests/features/consumer/v2-permissions-by-pointer-type.feature index 3ac0e9510..7c5fe6bbd 100644 --- a/tests/features/consumer/v2-permissions-by-pointer-type.feature +++ b/tests/features/consumer/v2-permissions-by-pointer-type.feature @@ -1,10 +1,7 @@ Feature: Consumer v2 permissions by pointer type - Success and Failure Scenarios For the v2 permissions model, permissions are resolved from a JSON file stored in the - nrlf_permissions Lambda layer at the path - {actorType}/{app_id}/{ods_code}.json, which must contain a "types" array. - Permissions for the feature test application (ID 'z00z-y11y-x22x') and - ODS code 'RX898' are baked into the layer by `scripts/get_s3_permissions.py` - at build time, so no dynamic seeding step is required for + nrlf_permissions Lambda layer. Permissions for the feature tests are baked into the layer + by `scripts/get_s3_permissions.py` at build time, so no dynamic seeding step is required for success scenarios. Background: diff --git a/tests/features/producer/v2-permissions-by-pointer-type.feature b/tests/features/producer/v2-permissions-by-pointer-type.feature index cb2f51beb..080cea943 100644 --- a/tests/features/producer/v2-permissions-by-pointer-type.feature +++ b/tests/features/producer/v2-permissions-by-pointer-type.feature @@ -1,10 +1,7 @@ Feature: Producer v2 permissions by pointer type - Success and Failure Scenarios For the v2 permissions model, permissions are resolved from a JSON file stored in the - nrlf_permissions Lambda layer at the path - {actorType}/{app_id}/{ods_code}.json, which must contain a "types" array. - Permissions for the feature test application (ID 'z00z-y11y-x22x') and - ODS code 'RX898' are baked into the layer by `scripts/get_s3_permissions.py` - at build time, so no dynamic seeding step is required for + nrlf_permissions Lambda layer. Permissions for the feature tests are baked into the layer by + `scripts/get_s3_permissions.py` at build time, so no dynamic seeding step is required for success scenarios. Background: From 2caf29abf55363df601f13e18b1a7a1390f5956b Mon Sep 17 00:00:00 2001 From: Sandy Forrester Date: Mon, 9 Mar 2026 12:19:47 +0000 Subject: [PATCH 09/14] NRL-1949 Address pr comments --- scripts/get_s3_permissions.py | 4 +- .../v2-permissions-by-pointer-type.feature | 79 +++++++++++++++++-- 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/scripts/get_s3_permissions.py b/scripts/get_s3_permissions.py index 68e82c5de..bbd08a66b 100644 --- a/scripts/get_s3_permissions.py +++ b/scripts/get_s3_permissions.py @@ -74,8 +74,8 @@ def add_feature_test_files(local_path): ( "z00z-y11y-x22x", "RX898", - [PointerTypes.MENTAL_HEALTH_PLAN.value], - ), # http://snomed.info/sct|736253002 + [PointerTypes.EOL_CARE_PLAN.value], + ), # http://snomed.info/sct|736373009 ], } [ diff --git a/tests/features/producer/v2-permissions-by-pointer-type.feature b/tests/features/producer/v2-permissions-by-pointer-type.feature index 080cea943..673be9d48 100644 --- a/tests/features/producer/v2-permissions-by-pointer-type.feature +++ b/tests/features/producer/v2-permissions-by-pointer-type.feature @@ -12,7 +12,7 @@ Feature: Producer v2 permissions by pointer type - Success and Failure Scenarios | property | value | | subject | 9278693472 | | status | current | - | type | 736253002 | + | type | 736373009 | | category | 734163000 | | custodian | RX898 | | author | HAR1 | @@ -40,6 +40,18 @@ Feature: Producer v2 permissions by pointer type - Success and Failure Scenarios And the response has a Location header And the Location header starts with '/DocumentReference/RX898-' And the resource in the Location header exists with values: + | property | value | + | subject | 9278693472 | + | status | current | + | type | 736373009 | + | category | 734163000 | + | custodian | RX898 | + | author | HAR1 | + | url | https://example.org/my-doc.pdf | + | practiceSetting | 788002001 | + + Scenario: V2 Permissions with no access for pointer type - createDocumentReference + When producer v2 'RX898' creates a DocumentReference with values: | property | value | | subject | 9278693472 | | status | current | @@ -49,6 +61,28 @@ Feature: Producer v2 permissions by pointer type - Success and Failure Scenarios | author | HAR1 | | url | https://example.org/my-doc.pdf | | practiceSetting | 788002001 | + Then the response status code is 403 + And the response is an OperationOutcome with 1 issue + And the OperationOutcome contains the issue: + """ + { + "severity": "error", + "code": "forbidden", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + "code": "AUTHOR_CREDENTIALS_ERROR", + "display": "Author credentials error" + } + ] + }, + "diagnostics": "The type of the provided DocumentReference is not in the list of allowed types for this organisation", + "expression": [ + "type.coding[0].code" + ] + } + """ Scenario: V2 Permissions with access for pointer type - deleteDocumentReference Given a DocumentReference resource exists with values @@ -56,7 +90,7 @@ Feature: Producer v2 permissions by pointer type - Success and Failure Scenarios | id | RX898-111-DeleteDocRefTest1 | | subject | 9278693472 | | status | current | - | type | 736253002 | + | type | 736373009 | | category | 734163000 | | contentType | application/pdf | | url | https://example.org/my-doc.pdf | @@ -82,7 +116,7 @@ Feature: Producer v2 permissions by pointer type - Success and Failure Scenarios "diagnostics": "The requested DocumentReference has been deleted" } """ - And the resource with id 'DK94-111-DeleteDocRefTest1' does not exist + And the resource with id 'RX898-111-DeleteDocRefTest1' does not exist Scenario: V2 Permissions with no access for pointer type - searchDocumentReference Given a DocumentReference resource exists with values: @@ -90,7 +124,7 @@ Feature: Producer v2 permissions by pointer type - Success and Failure Scenarios | id | RX898-1111111111-SearchNHSDocRefTest1 | | subject | 9999999999 | | status | current | - | type | 736253002 | + | type | 736373009 | | category | 734163000 | | contentType | application/pdf | | url | https://example.org/my-doc.pdf | @@ -119,10 +153,45 @@ Feature: Producer v2 permissions by pointer type - Success and Failure Scenarios | id | RX898-1111111111-SearchNHSDocRefTest1 | | subject | 9999999999 | | status | current | - | type | 736253002 | + | type | 736373009 | | category | 734163000 | | contentType | application/pdf | | url | https://example.org/my-doc.pdf | | custodian | RX898 | | author | X26 | And the Bundle does not contain a DocumentReference with ID 'SG4-1111111111-SearchNHSDocRefTest3' + + Scenario: V2 Permissions with no access for org - 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 | RX898 | + | author | X26 | + When producer v2 'N00RG1' searches for DocumentReferences with parameters: + | parameter | value | + | subject | 9999999999 | + Then the response status code is 403 + And the response is an OperationOutcome with 1 issue + And the OperationOutcome contains the issue: + """ + { + "severity": "error", + "code": "forbidden", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + "code": "ACCESS DENIED", + "display": "Access has been denied to process this request" + } + ] + }, + "diagnostics": "Your organisation 'N00RG1' does not have permission to access this resource. Contact the onboarding team." + } + """ From 81ada466d04710671761e33d4fc8c19ea25b9bb0 Mon Sep 17 00:00:00 2001 From: Sandy Forrester Date: Wed, 11 Mar 2026 11:12:50 +0000 Subject: [PATCH 10/14] NRL-1949 Add v2 perm policy to metadata & update producer endpoints --- .../create_document_reference.py | 10 +- .../tests/test_create_document_reference.py | 122 ++++++++++++++++++ .../search_document_reference.py | 12 +- ...test_search_document_reference_producer.py | 103 +++++++++++++++ .../search_post_document_reference.py | 12 +- ...search_post_document_reference_producer.py | 107 +++++++++++++++ .../tests/test_upsert_document_reference.py | 120 +++++++++++++++++ .../upsert_document_reference.py | 10 +- layer/nrlf/core/decorators.py | 26 +++- layer/nrlf/core/model.py | 11 +- layer/nrlf/core/tests/test_decorators.py | 70 ++++++++++ .../modules/permissions-store-bucket/s3.tf | 2 +- 12 files changed, 588 insertions(+), 17 deletions(-) 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..fa3f74865 100644 --- a/api/producer/createDocumentReference/tests/test_create_document_reference.py +++ b/api/producer/createDocumentReference/tests/test_create_document_reference.py @@ -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={ + "nhsd-end-user-organisation-ods": "Y05868", + "nhsd-nrl-app-id": "Y05868-TestApp-12345678", + } + ) + v2_headers.pop("nhsd-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={ + "nhsd-end-user-organisation-ods": "Y05868", + "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..b6fe27c5c 100644 --- a/api/producer/searchDocumentReference/tests/test_search_document_reference_producer.py +++ b/api/producer/searchDocumentReference/tests/test_search_document_reference_producer.py @@ -55,6 +55,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={ + "nhsd-end-user-organisation-ods": "Y05868", + "nhsd-nrl-app-id": "Y05868-TestApp-12345678", + } + ) + v2_headers.pop("nhsd-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 +511,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={ + "nhsd-end-user-organisation-ods": "Y05868", + "nhsd-nrl-app-id": "Y05868-TestApp-12345678", + } + ) + v2_headers.pop("nhsd-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..d45490449 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 @@ -59,6 +59,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={ + "nhsd-end-user-organisation-ods": "Y05868", + "nhsd-nrl-app-id": "Y05868-TestApp-12345678", + } + ) + v2_headers.pop("nhsd-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 +530,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={ + "nhsd-end-user-organisation-ods": "Y05868", + "nhsd-nrl-app-id": "Y05868-TestApp-12345678", + } + ) + v2_headers.pop("nhsd-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..40c990573 100644 --- a/api/producer/upsertDocumentReference/tests/test_upsert_document_reference.py +++ b/api/producer/upsertDocumentReference/tests/test_upsert_document_reference.py @@ -86,6 +86,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={ + "nhsd-end-user-organisation-ods": "Y05868", + "nhsd-nrl-app-id": "Y05868-TestApp-12345678", + } + ) + v2_headers.pop("nhsd-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 +808,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={ + "nhsd-end-user-organisation-ods": "Y05868", + "nhsd-nrl-app-id": "Y05868-TestApp-12345678", + } + ) + v2_headers.pop("nhsd-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/decorators.py b/layer/nrlf/core/decorators.py index 72bfba3fe..cdedabf5e 100644 --- a/layer/nrlf/core/decorators.py +++ b/layer/nrlf/core/decorators.py @@ -30,6 +30,7 @@ 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 +160,21 @@ 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 "allow_all_types" 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 +308,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/tests/test_decorators.py b/layer/nrlf/core/tests/test_decorators.py index 9c20ec53f..442471476 100644 --- a/layer/nrlf/core/tests/test_decorators.py +++ b/layer/nrlf/core/tests/test_decorators.py @@ -834,6 +834,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={ + "nhsd-end-user-organisation-ods": "Y05868", + "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": ["allow_all_types"], + "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/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/" From b128293871e30476b22073359dfe12dabd49c73b Mon Sep 17 00:00:00 2001 From: Sandy Forrester Date: Wed, 11 Mar 2026 12:33:22 +0000 Subject: [PATCH 11/14] NRL-1949 Add Update consumer endpoints with v2 permission policy --- .../read_document_reference.py | 10 +- .../test_read_document_reference_consumer.py | 101 +++++++++++++++ .../search_document_reference.py | 12 +- ...test_search_document_reference_consumer.py | 113 +++++++++++++++++ .../search_post_document_reference.py | 12 +- ...search_post_document_reference_consumer.py | 119 ++++++++++++++++++ 6 files changed, 359 insertions(+), 8 deletions(-) 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..6b100453a 100644 --- a/api/consumer/readDocumentReference/tests/test_read_document_reference_consumer.py +++ b/api/consumer/readDocumentReference/tests/test_read_document_reference_consumer.py @@ -1,4 +1,5 @@ import json +from unittest.mock import patch from moto import mock_aws @@ -41,6 +42,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={ + "nhsd-end-user-organisation-ods": "Y05868", + "nhsd-nrl-app-id": "Y05868-TestApp-12345678", + } + ) + v2_headers.pop("nhsd-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 +201,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={ + "nhsd-end-user-organisation-ods": "Y05868", + "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..e57016e55 100644 --- a/api/consumer/searchDocumentReference/tests/test_search_document_reference_consumer.py +++ b/api/consumer/searchDocumentReference/tests/test_search_document_reference_consumer.py @@ -63,6 +63,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={ + "nhsd-end-user-organisation-ods": "Y05868", + "nhsd-nrl-app-id": "Y05868-TestApp-12345678", + } + ) + v2_headers.pop("nhsd-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 +734,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={ + "nhsd-end-user-organisation-ods": "Y05868", + "nhsd-nrl-app-id": "Y05868-TestApp-12345678", + } + ) + v2_headers.pop("nhsd-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..6e4fd1798 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 @@ -65,6 +65,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={ + "nhsd-end-user-organisation-ods": "Y05868", + "nhsd-nrl-app-id": "Y05868-TestApp-12345678", + } + ) + v2_headers.pop("nhsd-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 +491,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={ + "nhsd-end-user-organisation-ods": "Y05868", + "nhsd-nrl-app-id": "Y05868-TestApp-12345678", + } + ) + v2_headers.pop("nhsd-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( From b8820d0afa470798db072c9a9a248a5644d8bac4 Mon Sep 17 00:00:00 2001 From: Sandy Forrester Date: Wed, 11 Mar 2026 16:08:00 +0000 Subject: [PATCH 12/14] NRL-1986 Add allow_all_types consumer test and v2 enums --- .../test_read_document_reference_consumer.py | 11 +-- ...test_search_document_reference_consumer.py | 14 ++-- ...search_post_document_reference_consumer.py | 14 ++-- .../tests/test_create_document_reference.py | 12 +-- ...test_search_document_reference_producer.py | 14 ++-- ...search_post_document_reference_producer.py | 14 ++-- .../tests/test_upsert_document_reference.py | 18 +++-- layer/nrlf/core/constants.py | 20 +++++ layer/nrlf/core/request.py | 6 +- layer/nrlf/core/tests/test_decorators.py | 9 ++- scripts/get_s3_permissions.py | 21 +++-- .../v2-permissions-by-pointer-type.feature | 76 +++++++++++++++++++ .../v2-permissions-by-pointer-type.feature | 24 +++--- tests/utilities/api_clients.py | 6 +- 14 files changed, 190 insertions(+), 69 deletions(-) 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 6b100453a..64763d822 100644 --- a/api/consumer/readDocumentReference/tests/test_read_document_reference_consumer.py +++ b/api/consumer/readDocumentReference/tests/test_read_document_reference_consumer.py @@ -4,6 +4,7 @@ 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 @@ -54,11 +55,11 @@ def test_read_document_reference_happy_path_v2( v2_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", } ) - v2_headers.pop("nhsd-client-rp-details") + v2_headers.pop(CLIENT_RP_DETAILS) get_pointer_permissions_mock.return_value = { "access_controls": [], @@ -213,8 +214,8 @@ def test_read_document_reference_unauthorised_for_type_v2( 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", } ) headers.pop("nhsd-client-rp-details") 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 e57016e55..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 @@ -75,11 +77,11 @@ def test_search_document_reference_happy_path_v2( v2_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", } ) - v2_headers.pop("nhsd-client-rp-details") + v2_headers.pop(CLIENT_RP_DETAILS) get_pointer_permissions_mock.return_value = { "access_controls": [], @@ -742,11 +744,11 @@ def test_search_document_reference_invalid_type_v2( ): v2_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", } ) - v2_headers.pop("nhsd-client-rp-details") + v2_headers.pop(CLIENT_RP_DETAILS) get_pointer_permissions_mock.return_value = { "access_controls": [], 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 6e4fd1798..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 @@ -78,11 +80,11 @@ def test_search_post_document_reference_happy_path_v2( v2_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", } ) - v2_headers.pop("nhsd-client-rp-details") + v2_headers.pop(CLIENT_RP_DETAILS) get_pointer_permissions_mock.return_value = { "access_controls": [], @@ -500,11 +502,11 @@ def test_search_post_document_reference_invalid_type_v2( ): v2_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", } ) - v2_headers.pop("nhsd-client-rp-details") + v2_headers.pop(CLIENT_RP_DETAILS) get_pointer_permissions_mock.return_value = { "access_controls": [], diff --git a/api/producer/createDocumentReference/tests/test_create_document_reference.py b/api/producer/createDocumentReference/tests/test_create_document_reference.py index fa3f74865..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, @@ -743,11 +743,11 @@ def test_create_document_reference_happy_path_v2( v2_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", } ) - v2_headers.pop("nhsd-client-rp-details") + v2_headers.pop(CLIENT_RP_DETAILS) get_pointer_permissions_mock.return_value = { "access_controls": [], @@ -803,8 +803,8 @@ def test_create_document_reference_pointer_type_not_allowed_v2( 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", } ) headers.pop("nhsd-client-rp-details") 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 b6fe27c5c..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 @@ -68,11 +70,11 @@ def test_search_document_reference_happy_path_v2( v2_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", } ) - v2_headers.pop("nhsd-client-rp-details") + v2_headers.pop(CLIENT_RP_DETAILS) get_pointer_permissions_mock.return_value = { "access_controls": [], @@ -533,11 +535,11 @@ def test_search_document_reference_filters_by_pointer_types_v2( v2_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", } ) - v2_headers.pop("nhsd-client-rp-details") + v2_headers.pop(CLIENT_RP_DETAILS) get_pointer_permissions_mock.return_value = { "access_controls": [], 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 d45490449..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 @@ -72,11 +74,11 @@ def test_search_post_document_reference_happy_path_v2( v2_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", } ) - v2_headers.pop("nhsd-client-rp-details") + v2_headers.pop(CLIENT_RP_DETAILS) get_pointer_permissions_mock.return_value = { "access_controls": [], @@ -552,11 +554,11 @@ def test_search_post_document_reference_filters_by_pointer_types_v2( v2_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", } ) - v2_headers.pop("nhsd-client-rp-details") + v2_headers.pop(CLIENT_RP_DETAILS) get_pointer_permissions_mock.return_value = { "access_controls": [], diff --git a/api/producer/upsertDocumentReference/tests/test_upsert_document_reference.py b/api/producer/upsertDocumentReference/tests/test_upsert_document_reference.py index 40c990573..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, @@ -97,11 +101,11 @@ def test_upsert_document_reference_happy_path_v2( v2_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", } ) - v2_headers.pop("nhsd-client-rp-details") + v2_headers.pop(CLIENT_RP_DETAILS) get_pointer_permissions_mock.return_value = { "access_controls": [], @@ -818,11 +822,11 @@ def test_upsert_document_reference_pointer_type_not_allowed_v2( v2_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", } ) - v2_headers.pop("nhsd-client-rp-details") + v2_headers.pop(CLIENT_RP_DETAILS) get_pointer_permissions_mock.return_value = { "access_controls": [], 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/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 442471476..8ba0152c1 100644 --- a/layer/nrlf/core/tests/test_decorators.py +++ b/layer/nrlf/core/tests/test_decorators.py @@ -13,6 +13,7 @@ PERMISSION_ALLOW_ALL_POINTER_TYPES, X_REQUEST_ID_HEADER, PointerTypes, + V2Headers, ) from nrlf.core.decorators import ( deprecated, @@ -818,8 +819,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: @@ -838,8 +839,8 @@ def _create_v2_headers() -> dict: """Create headers that trigger the v2 permissions model (missing nhsd-client-rp-details).""" 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", } ) headers.pop("nhsd-client-rp-details") diff --git a/scripts/get_s3_permissions.py b/scripts/get_s3_permissions.py index bbd08a66b..136b75ce7 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,33 @@ def add_feature_test_files(local_path): "z00z-y11y-x22x", "RX898", [PointerTypes.MENTAL_HEALTH_PLAN.value], + [], ), # http://snomed.info/sct|736253002 + ( + "z00z-y11y-x22x", + "4LLTYP35", + [], + [AccessControls.ALLOW_ALL_TYPES.value], + ), ], "producer": [ ( "z00z-y11y-x22x", "RX898", [PointerTypes.EOL_CARE_PLAN.value], + [], ), # http://snomed.info/sct|736373009 ], } [ _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/tests/features/consumer/v2-permissions-by-pointer-type.feature b/tests/features/consumer/v2-permissions-by-pointer-type.feature index 7c5fe6bbd..f7f47fca9 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 '4LLTYP35' 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..93b109a94 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,7 @@ 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 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..a1de37756 100644 --- a/tests/utilities/api_clients.py +++ b/tests/utilities/api_clients.py @@ -6,7 +6,7 @@ from pydantic import BaseModel from requests import Response -from nrlf.core.constants import Categories, PointerTypes +from nrlf.core.constants import Categories, PointerTypes, V2Headers from nrlf.core.model import ConnectionMetadata logger = logging.getLogger(__name__) @@ -81,8 +81,8 @@ 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, + 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": "test-correlation-id", } ) From 6eb0ae6c217d981461fe6e7eea2b4aa0db7bf31f Mon Sep 17 00:00:00 2001 From: Sandy Forrester Date: Thu, 12 Mar 2026 11:05:10 +0000 Subject: [PATCH 13/14] NRL-1986 Add allow_all_types producer feature test --- scripts/get_s3_permissions.py | 8 ++- .../v2-permissions-by-pointer-type.feature | 2 +- .../v2-permissions-by-pointer-type.feature | 53 +++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/scripts/get_s3_permissions.py b/scripts/get_s3_permissions.py index 136b75ce7..3f4d4f525 100644 --- a/scripts/get_s3_permissions.py +++ b/scripts/get_s3_permissions.py @@ -72,7 +72,7 @@ def add_feature_test_files(local_path): ), # http://snomed.info/sct|736253002 ( "z00z-y11y-x22x", - "4LLTYP35", + "4LLTYP35C", [], [AccessControls.ALLOW_ALL_TYPES.value], ), @@ -84,6 +84,12 @@ def add_feature_test_files(local_path): [PointerTypes.EOL_CARE_PLAN.value], [], ), # http://snomed.info/sct|736373009 + ( + "z00z-y11y-x22x", + "4LLTYP35P", + [], + [AccessControls.ALLOW_ALL_TYPES.value], + ), ], } [ diff --git a/tests/features/consumer/v2-permissions-by-pointer-type.feature b/tests/features/consumer/v2-permissions-by-pointer-type.feature index f7f47fca9..347d4ccec 100644 --- a/tests/features/consumer/v2-permissions-by-pointer-type.feature +++ b/tests/features/consumer/v2-permissions-by-pointer-type.feature @@ -246,7 +246,7 @@ Feature: Consumer v2 permissions by pointer type - Success and Failure Scenarios | url | https://example.org/my-doc-3.pdf | | custodian | X26 | | author | X26 | - When consumer v2 '4LLTYP35' searches for DocumentReferences using POST with request body: + When consumer v2 '4LLTYP35C' searches for DocumentReferences using POST with request body: | key | value | | subject | 9000000378 | Then the response status code is 200 diff --git a/tests/features/producer/v2-permissions-by-pointer-type.feature b/tests/features/producer/v2-permissions-by-pointer-type.feature index 93b109a94..83c429146 100644 --- a/tests/features/producer/v2-permissions-by-pointer-type.feature +++ b/tests/features/producer/v2-permissions-by-pointer-type.feature @@ -161,6 +161,59 @@ Feature: Producer v2 permissions by pointer type - Success and Failure Scenarios | author | X26 | 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: | property | value | From 7ad3bb993c67425c8fa0cbf55420397e6e2cde4c Mon Sep 17 00:00:00 2001 From: Sandy Forrester Date: Thu, 12 Mar 2026 12:17:23 +0000 Subject: [PATCH 14/14] NRL-1986 Address PR comments --- layer/nrlf/core/decorators.py | 6 +++++- layer/nrlf/core/tests/test_decorators.py | 3 ++- tests/utilities/api_clients.py | 15 ++++++++++----- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/layer/nrlf/core/decorators.py b/layer/nrlf/core/decorators.py index cdedabf5e..e925a1a93 100644 --- a/layer/nrlf/core/decorators.py +++ b/layer/nrlf/core/decorators.py @@ -25,6 +25,7 @@ PERMISSION_ALLOW_ALL_POINTER_TYPES, X_CORRELATION_ID_HEADER, X_REQUEST_ID_HEADER, + AccessControls, PointerTypes, ) from nrlf.core.dynamodb.repository import DocumentPointerRepository @@ -164,7 +165,10 @@ def _load_v2_connection_metadata(headers: Dict[str, str], path: str): pointer_permissions ) - if "allow_all_types" in metadata.nrl_permissions_policy.access_controls: + if ( + AccessControls.ALLOW_ALL_TYPES.value + in metadata.nrl_permissions_policy.access_controls + ): metadata.nrl_permissions_policy.types = PointerTypes.list() logger.log( diff --git a/layer/nrlf/core/tests/test_decorators.py b/layer/nrlf/core/tests/test_decorators.py index 8ba0152c1..4e23d5e92 100644 --- a/layer/nrlf/core/tests/test_decorators.py +++ b/layer/nrlf/core/tests/test_decorators.py @@ -12,6 +12,7 @@ from nrlf.core.constants import ( PERMISSION_ALLOW_ALL_POINTER_TYPES, X_REQUEST_ID_HEADER, + AccessControls, PointerTypes, V2Headers, ) @@ -851,7 +852,7 @@ def test_load_v2_connection_metadata_allow_all_types(mocker: MockerFixture): mocker.patch( "nrlf.core.decorators.get_pointer_permissions_v2", return_value={ - "access_controls": ["allow_all_types"], + "access_controls": [AccessControls.ALLOW_ALL_TYPES.value], "types": [], }, ) diff --git a/tests/utilities/api_clients.py b/tests/utilities/api_clients.py index a1de37756..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, V2Headers +from nrlf.core.constants import ( + NHSD_CORRELATION_ID_HEADER, + Categories, + PointerTypes, + V2Headers, +) from nrlf.core.model import ConnectionMetadata logger = logging.getLogger(__name__) @@ -83,7 +88,7 @@ def __init__(self, config: ClientConfig, use_v2: bool = False): { 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": "test-correlation-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: