From 7de573ae2e5c020171efc8d2188a6d7f5c2f6b3b Mon Sep 17 00:00:00 2001 From: kevinmason-nhs Date: Wed, 30 Jul 2025 13:35:00 +0100 Subject: [PATCH 1/6] [ERSSUP-86798]-[PC]-[Add ODS APIGEE validation]-[KM] --- macros/manifest_macros.yml | 4 +- manifest_template.yml | 16 +- .../AssignMessage.InternalServerError.xml | 7 + ...tOperationOutcomeODSHeaderMissingPreR4.xml | 18 ++ ....SetOperationOutcomeODSHeaderMissingR4.xml | 19 ++ ...omeODSHeaderValueNotInPartnerListPreR4.xml | 18 ++ ...utcomeODSHeaderValueNotInPartnerListR4.xml | 18 ++ ...essage.SetOperationOutcomeServiceError.xml | 19 ++ .../FlowCallout.EUOAllowlistVerify.xml | 5 + .../FlowCallout.ExtendedAttributes.xml | 5 + proxies/live/apiproxy/proxies/default.xml | 69 ++--- proxies/live/apiproxy/targets/ers-target.xml | 91 +++++- .../endpoints/a042-retrieve-attachment.yaml | 2 +- tests/conftest.py | 30 +- tests/integration/test_headers.py | 6 +- tests/integration/test_user_restricted.py | 287 ++++++++++++++++++ 16 files changed, 558 insertions(+), 56 deletions(-) create mode 100644 proxies/live/apiproxy/policies/AssignMessage.InternalServerError.xml create mode 100644 proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeODSHeaderMissingPreR4.xml create mode 100644 proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeODSHeaderMissingR4.xml create mode 100644 proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeODSHeaderValueNotInPartnerListPreR4.xml create mode 100644 proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeODSHeaderValueNotInPartnerListR4.xml create mode 100644 proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeServiceError.xml create mode 100644 proxies/live/apiproxy/policies/FlowCallout.EUOAllowlistVerify.xml create mode 100644 proxies/live/apiproxy/policies/FlowCallout.ExtendedAttributes.xml create mode 100644 tests/integration/test_user_restricted.py diff --git a/macros/manifest_macros.yml b/macros/manifest_macros.yml index c9426290f..b2f063576 100644 --- a/macros/manifest_macros.yml +++ b/macros/manifest_macros.yml @@ -3,12 +3,14 @@ products: {% endmacro %} -{%- macro product(ENV, MODE, TITLE, product_name, display_name) -%} +{%- macro product(ENV, MODE, TITLE, product_name, display_name, euo_allowlist_required) -%} - name: e-referrals-service-api-{{ product_name }}{{ MODE.nameSuffix }} approvalType: {{ ENV.approval_type | default('auto') }} attributes: - name: access value: public + - name: EUOAllowlistRequired + value: {{ euo_allowlist_required }} - name: ratelimiting value: e-referrals-service-api-{{ product_name }}: diff --git a/manifest_template.yml b/manifest_template.yml index 4c04cbe9c..24b473d8a 100644 --- a/manifest_template.yml +++ b/manifest_template.yml @@ -16,23 +16,31 @@ APIGEE_ENVIRONMENTS: variants: - name: rc-internal-dev display_name: Internal Development - rc + euo_allowlist_required: false - name: fix-internal-dev display_name: Internal Development - fix + euo_allowlist_required: false - name: fti-internal-dev display_name: Internal Development - ft01 + euo_allowlist_required: false - name: ftiv-internal-dev display_name: Internal Development - ft04 + euo_allowlist_required: false - name: ftv-internal-dev display_name: Internal Development - ft05 + euo_allowlist_required: false - name: ftix-internal-dev display_name: Internal Development - ft09 + euo_allowlist_required: false - name: ftxxii-internal-dev display_name: Internal Development - ft22 + euo_allowlist_required: false - name: internal-dev-sandbox variants: - name: internal-dev-sandbox display_name: Internal Development Sandbox + euo_allowlist_required: false - name: int additional_proxies: @@ -41,6 +49,7 @@ APIGEE_ENVIRONMENTS: variants: - name: int display_name: Integration Testing + euo_allowlist_required: false - name: internal-qa additional_proxies: @@ -49,16 +58,19 @@ APIGEE_ENVIRONMENTS: variants: - name: internal-qa display_name: Internal QA + euo_allowlist_required: false - name: internal-qa-sandbox variants: - name: internal-qa-sandbox display_name: Internal QA Sandbox + euo_allowlist_required: false - name: sandbox variants: - name: sandbox display_name: Sandbox + euo_allowlist_required: false - name: dev additional_proxies: @@ -66,12 +78,14 @@ APIGEE_ENVIRONMENTS: variants: - name: dep-dev display_name: Dev - dep + euo_allowlist_required: false - name: prod approval_type: manual variants: - name: prod display_name: Production + euo_allowlist_required: false ACCESS_MODES: - name: healthcare-worker @@ -104,7 +118,7 @@ apigee: {% for VARIANT in ENV.variants %} {% for MODE in ACCESS_MODES %} - {{ macros.product(ENV, MODE, TITLE, VARIANT.name, VARIANT.display_name) }} + {{ macros.product(ENV, MODE, TITLE, VARIANT.name, VARIANT.display_name, VARIANT.euo_allowlist_required) }} {% endfor %} {% endfor %} diff --git a/proxies/live/apiproxy/policies/AssignMessage.InternalServerError.xml b/proxies/live/apiproxy/policies/AssignMessage.InternalServerError.xml new file mode 100644 index 000000000..321aacd0c --- /dev/null +++ b/proxies/live/apiproxy/policies/AssignMessage.InternalServerError.xml @@ -0,0 +1,7 @@ + + AssignMessage.InternalServerError + + Internal Server Error + + + diff --git a/proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeODSHeaderMissingPreR4.xml b/proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeODSHeaderMissingPreR4.xml new file mode 100644 index 000000000..a10372bb6 --- /dev/null +++ b/proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeODSHeaderMissingPreR4.xml @@ -0,0 +1,18 @@ + + + status_code + 400 + + + op_outcome_issue_code + required + + + faultstring + Missing or Empty NHSD-End-User-Organisation-ODS header. + + + op_outcome_issue_details_coding_code + MISSING_HEADER + + diff --git a/proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeODSHeaderMissingR4.xml b/proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeODSHeaderMissingR4.xml new file mode 100644 index 000000000..0c3c731fe --- /dev/null +++ b/proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeODSHeaderMissingR4.xml @@ -0,0 +1,19 @@ + + + status_code + 400 + + + op_outcome_issue_code + required + + + + op_outcome_issue_details_coding_code + MISSING_HEADER + + + faultstring + Missing or Empty NHSD-End-User-Organisation-ODS header. + + diff --git a/proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeODSHeaderValueNotInPartnerListPreR4.xml b/proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeODSHeaderValueNotInPartnerListPreR4.xml new file mode 100644 index 000000000..004f6489f --- /dev/null +++ b/proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeODSHeaderValueNotInPartnerListPreR4.xml @@ -0,0 +1,18 @@ + + + status_code + 403 + + + op_outcome_issue_code + forbidden + + + faultstring + Unauthorised ODS code provided in NHSD-End-User-Organisation-ODS header + + + op_outcome_issue_details_coding_code + NO_ACCESS + + diff --git a/proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeODSHeaderValueNotInPartnerListR4.xml b/proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeODSHeaderValueNotInPartnerListR4.xml new file mode 100644 index 000000000..9bea7a9a8 --- /dev/null +++ b/proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeODSHeaderValueNotInPartnerListR4.xml @@ -0,0 +1,18 @@ + + + status_code + 403 + + + op_outcome_issue_code + forbidden + + + faultstring + Unauthorised ODS code provided in NHSD-End-User-Organisation-ODS header + + + op_outcome_issue_details_coding_code + ACCESS_DENIED + + diff --git a/proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeServiceError.xml b/proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeServiceError.xml new file mode 100644 index 000000000..aac05d6c0 --- /dev/null +++ b/proxies/live/apiproxy/policies/AssignMessage.SetOperationOutcomeServiceError.xml @@ -0,0 +1,19 @@ + + + status_code + 500 + + + op_outcome_issue_code + exception + + + + op_outcome_issue_details_coding_code + SERVICE_ERROR + + + faultstring + Internal Server Error + + diff --git a/proxies/live/apiproxy/policies/FlowCallout.EUOAllowlistVerify.xml b/proxies/live/apiproxy/policies/FlowCallout.EUOAllowlistVerify.xml new file mode 100644 index 000000000..026d951c2 --- /dev/null +++ b/proxies/live/apiproxy/policies/FlowCallout.EUOAllowlistVerify.xml @@ -0,0 +1,5 @@ + + + EUOAllowlistVerify + EUOAllowlistVerify + diff --git a/proxies/live/apiproxy/policies/FlowCallout.ExtendedAttributes.xml b/proxies/live/apiproxy/policies/FlowCallout.ExtendedAttributes.xml new file mode 100644 index 000000000..c3a25f88d --- /dev/null +++ b/proxies/live/apiproxy/policies/FlowCallout.ExtendedAttributes.xml @@ -0,0 +1,5 @@ + + + Extract extended attribute + ExtendedAttributes + diff --git a/proxies/live/apiproxy/proxies/default.xml b/proxies/live/apiproxy/proxies/default.xml index 4b9fb99ce..d4f94f692 100644 --- a/proxies/live/apiproxy/proxies/default.xml +++ b/proxies/live/apiproxy/proxies/default.xml @@ -1,40 +1,38 @@ - - - - - - AssignMessage.AddPayloadToPing - - - (proxy.pathsuffix MatchesPath "/_ping") and ((request.verb = "GET") or (request.verb = "HEAD")) - - - - - - KeyValueMapOperations.GetSharedSecureVariables - - - (private.apigee.status-endpoint-api-key NotEquals request.header.apikey) or (private.apigee.status-endpoint-api-key Is null) - RaiseFault.401Unauthorized - - - ServiceCallout.CallHealthcheckEndpoint - - - - - javascript.SetStatusResponse - - - (proxy.pathsuffix MatchesPath "/_status") and ((request.verb = "GET") or (request.verb = "HEAD")) - + + + + + + AssignMessage.AddPayloadToPing + + + (proxy.pathsuffix MatchesPath "/_ping") and ((request.verb = "GET") or (request.verb = "HEAD")) + + + + + + KeyValueMapOperations.GetSharedSecureVariables + + + (private.apigee.status-endpoint-api-key NotEquals request.header.apikey) or (private.apigee.status-endpoint-api-key Is null) + RaiseFault.401Unauthorized + + + ServiceCallout.CallHealthcheckEndpoint + + + + + javascript.SetStatusResponse + + + (proxy.pathsuffix MatchesPath "/_status") and ((request.verb = "GET") or (request.verb = "HEAD")) + - - @@ -42,16 +40,15 @@ - {{ SERVICE_BASE_PATH }} secure - (proxy.pathsuffix MatchesPath "/_ping") and ((request.verb = "GET") or (request.verb = "HEAD")) + (proxy.pathsuffix MatchesPath "/_ping") and ((request.verb = "GET") or (request.verb = "HEAD")) - (proxy.pathsuffix MatchesPath "/_status") and ((request.verb = "GET") or (request.verb = "HEAD")) + (proxy.pathsuffix MatchesPath "/_status") and ((request.verb = "GET") or (request.verb = "HEAD")) e-referrals-service-api-target diff --git a/proxies/live/apiproxy/targets/ers-target.xml b/proxies/live/apiproxy/targets/ers-target.xml index 4add6f9c2..e6bfb2b1e 100644 --- a/proxies/live/apiproxy/targets/ers-target.xml +++ b/proxies/live/apiproxy/targets/ers-target.xml @@ -123,6 +123,64 @@ (raisefault.RaiseFault.InvalidBusinessFunction.failed = true) + + + (isFhirR4Path = false) + AssignMessage.SetOperationOutcomeVariablesPreR4 + + + (isFhirR4Path = false) + AssignMessage.SetOperationOutcomeODSHeaderValueNotInPartnerListPreR4 + + + + + (isFhirR4Path = false) + AssignMessage.SetOperationOutcomeVariablesPreR4 + + + (isFhirR4Path = false) + AssignMessage.SetOperationOutcomeODSHeaderMissingPreR4 + + + (isFhirR4Path = true) + AssignMessage.SetOperationOutcomeVariablesR4 + + + (isFhirR4Path = true) + AssignMessage.SetOperationOutcomeODSHeaderMissingR4 + + + AssignMessage.OperationOutcomeErrorResponse + + (raisefault.RaiseFault.CheckAllowlistFailed.failed = true) and (validation.statusCode = 400) + + + + (isFhirR4Path = false) + AssignMessage.InternalServerError + + + (isFhirR4Path = true) + AssignMessage.SetOperationOutcomeVariablesR4 + + + (isFhirR4Path = true) + AssignMessage.SetOperationOutcomeServiceError + + + (isFhirR4Path = true) + AssignMessage.OperationOutcomeErrorResponse + + + (raisefault.RaiseFault.CheckAllowlistFailed.failed = true) and (validation.statusCode = 500) + AssignMessage.SetOperationOutcomeInvalidBusinessFunction + + + AssignMessage.OperationOutcomeErrorResponse + + (raisefault.RaiseFault.InvalidBusinessFunction.failed = true) + @@ -135,6 +193,15 @@ OauthV2.VerifyAccessToken + + + FlowCallout.ExtendedAttributes + (accesstoken.auth_type == "user") and (proxy.pathsuffix != "/FHIR/R4/PractitionerRole") + + + FlowCallout.EUOAllowlistVerify + (accesstoken.auth_type == "user") and (proxy.pathsuffix != "/FHIR/R4/PractitionerRole") + RaiseFault.MissingAsid (app.asid == null) Or (app.asid == "") @@ -174,29 +241,29 @@ RaiseFault.InvalidBusinessFunction (request.header.nhsd-ers-business-function == "PROVIDER_AUTHORISED_APPLICATION") or (request.header.nhsd-ers-business-function == "REFERRER_AUTHORISED_APPLICATION") or (request.header.nhsd-ers-business-function == "AUTHORISED_APPLICATION") - + AssignMessage.Set.x-ers-access-mode-header-user-restricted - + AssignMessage.Set.x-ers-user-id-header-user-restricted - + AssignMessage.Swap.NHSD-eRS-On-Behalf-Of-User-ID (request.header.NHSD-eRS-On-Behalf-Of-User-ID ~~ ".+") - + AssignMessage.Swap.nhsd-end-user-organisation-ods (request.header.nhsd-end-user-organisation-ods ~~ ".+") - + AssignMessage.Swap.nhsd-ers-business-function (request.header.nhsd-ers-business-function ~~ ".+") - + AssignMessage.Swap.nhsd-ers-comm-rule-org (request.header.nhsd-ers-comm-rule-org ~~ ".+") - + AssignMessage.Swap.nhsd-ers-file-name (request.header.nhsd-ers-file-name ~~ ".+") - + AssignMessage.Swap.nhsd-ers-referral-id (request.header.nhsd-ers-referral-id ~~ ".+") - + AssignMessage.Remove.x-request-id-header AssignMessage.Set.x-ers-authentication-assurance-level-header @@ -204,17 +271,17 @@ AssignMessage.Set.x-ers-amr-header AssignMessage.Set.x-ers-id-assurance-level-header - + (request.header.x-ers-id-assurance-level LesserThan 3) RaiseFault.401InsufficientIal {% if ALLOW_ECHO_TARGET | default(false) == true %} AssignMessage.SetEchoTarget (request.header.echo) - {% endif %} {% if '--ft-' in (ERS_TARGET_SERVER | default('e-referrals-service-api')) %} + {% endif %} {% if '--ft-' in (ERS_TARGET_SERVER | default('e-referrals-service-api')) %} AssignMessage.SetTruststore (isEchoCall != true ) - + AssignMessage.SetEchoTruststore (isEchoCall == true) {% endif %} diff --git a/specification/components/r4/schemas/endpoints/a042-retrieve-attachment.yaml b/specification/components/r4/schemas/endpoints/a042-retrieve-attachment.yaml index 41bc67c0d..96200ee7d 100644 --- a/specification/components/r4/schemas/endpoints/a042-retrieve-attachment.yaml +++ b/specification/components/r4/schemas/endpoints/a042-retrieve-attachment.yaml @@ -105,4 +105,4 @@ responses: '500': $ref: '../responses/InternalServerError.yaml' '503': - $ref: '../responses/ServiceUnavailable.yaml' \ No newline at end of file + $ref: '../responses/ServiceUnavailable.yaml' diff --git a/tests/conftest.py b/tests/conftest.py index 97de92084..ceceb5d0a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import pytest import pytest_asyncio import warnings +import json from uuid import uuid4 from typing import Collection, Callable, Generator, Dict @@ -79,6 +80,12 @@ def asid(is_mocked_environment): ) +@pytest.fixture(scope="session") +def apim_app_flow_vars(allowListodsCode=None): + if allowListodsCode is not None: + return {"ers": {"allowListodsCode": allowListodsCode}} + + @pytest.fixture(scope="session") def referring_clinician(is_mocked_environment): return Actor.RC_DEV if is_mocked_environment else Actor.RC @@ -127,7 +134,8 @@ async def user_restricted_product(client, make_product): [ "urn:nhsd:apim:user-nhs-id:aal3:e-referrals-service-api", "urn:nhsd:apim:user-nhs-id:aal2:e-referrals-service-api", - ] + ], + additional_attributes=[{"name": "EUOAllowlistRequired", "value": "false"}], ) print(f"product created: {productName}") @@ -240,7 +248,7 @@ def _update_function(attr, value): @pytest.fixture def make_product(client, environment, service_name): - async def _make_product(product_scopes): + async def _make_product(product_scopes, additional_attributes=None): product = ApiProductsAPI(client=client) proxies = [f"identity-service-mock-{environment}"] @@ -253,6 +261,10 @@ async def _make_product(product_scopes): {"name": "access", "value": "public"}, {"name": "ratelimit", "value": "10ps"}, ] + + if additional_attributes is not None: + attributes.extend(additional_attributes) + body = { "proxies": proxies, "scopes": product_scopes, @@ -272,9 +284,18 @@ async def _make_product(product_scopes): @pytest_asyncio.fixture -async def user_restricted_app(client, make_app, user_restricted_product, asid): +async def user_restricted_app( + client, make_app, user_restricted_product, asid, apim_app_flow_vars +): # Setup - app = await make_app(user_restricted_product, {"asid": asid}) + if apim_app_flow_vars is not None: + odslist = json.dumps({"ers": {"allowListodsCode": apim_app_flow_vars}}) + app = await make_app( + user_restricted_product, + {"asid": asid, "apim-app-flow-vars": odslist}, + ) + else: + app = await make_app(user_restricted_product, {"asid": asid}) appName = app["name"] print(f"App created: {appName}") @@ -297,6 +318,7 @@ async def _make_app(product, custom_attributes={}): {"name": key, "value": value} for key, value in custom_attributes.items() ] attributes.append({"name": "DisplayName", "value": app_name}) + print(f"App attributes: {attributes}") body = { "apiProducts": [product], diff --git a/tests/integration/test_headers.py b/tests/integration/test_headers.py index c379500ee..80b654208 100644 --- a/tests/integration/test_headers.py +++ b/tests/integration/test_headers.py @@ -32,7 +32,11 @@ class TestHeaders: @pytest.mark.asyncio @pytest.mark.parametrize("user", [Actor.RC, Actor.AAL2_USER]) async def test_headers_on_echo_target( - self, authenticate_user, service_url, user: Actor, asid + self, + authenticate_user, + service_url, + user: Actor, + asid, ): access_code = await authenticate_user(user) diff --git a/tests/integration/test_user_restricted.py b/tests/integration/test_user_restricted.py new file mode 100644 index 000000000..1b21b401b --- /dev/null +++ b/tests/integration/test_user_restricted.py @@ -0,0 +1,287 @@ +import pytest +import requests +from requests import Response +from tests.data import RenamedHeader, Actor, UserAuthenticationLevel +from tests.asserts import assert_ok_response + +_HEADER_AUTHORIZATION = "Authorization" +_HEADER_ECHO = "echo" # enable echo target +_HEADER_BASE_URL = "x-ers-network-baseurl" +_HEADER_USER_ID = "x-ers-user-id" +_HEADER_REQUEST_ID = "x-request-id" +_HEADER_ASID = "xapi_asid" +_HEADER_ACCESS_MODE = "x-ers-access-mode" +_HEADER_AAL = "x-ers-authentication-assurance-level" +_HEADER_AMR = "x-ers-amr" +_HEADER_ID_ASSURANCE_LEVEL = "x-ers-id-assurance-level" + +_EXPECTED_REFERRAL_ID = "000000040032" +_EXPECTED_CORRELATION_ID = "123123-123123-123123-123123" +_EXPECTED_FILENAME = "mysuperfilename.txt" +_EXPECTED_COMMA_FILENAME = "mysuper,filename.txt" +_EXPECTED_COMM_RULE_ORG = "R100" +_EXPECTED_OBO_USER_ID = "0123456789000" +_EXPECTED_ACCESS_MODE = "user-restricted" + +_SPECIALTY_REF_DATA_URL = "/FHIR/STU3/CodeSystem/SPECIALTY" +_SEARCH_HEALTHCARE_SERVICE_R4_URL = "/FHIR/R4/HealthcareService" + + +@pytest.mark.integration_test +class TestUserRestricted: + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "endpoint_url, is_fhir_4, user, apim_app_flow_vars ", + [ + ("", False, Actor.RC_DEV, [Actor.RC_DEV.org_code]), + ("/FHIR/R4/", True, Actor.RC_DEV, [Actor.RC_DEV.org_code]), + ("/FHIR/STU3/", False, Actor.RC_DEV, [Actor.RC_DEV.org_code]), + ], + ) + async def test_user_restricted_valid_ods_code( + self, + authenticate_user, + service_url, + user: Actor, + asid, + endpoint_url, + is_fhir_4, + apim_app_flow_vars, + ): + access_code = await authenticate_user(user) + + client_request_headers = { + _HEADER_ECHO: "", # enable echo target + _HEADER_AUTHORIZATION: "Bearer " + access_code, + _HEADER_REQUEST_ID: "DUMMY-VALUE", + RenamedHeader.REFERRAL_ID.original: _EXPECTED_REFERRAL_ID, + RenamedHeader.CORRELATION_ID.original: _EXPECTED_CORRELATION_ID, + RenamedHeader.BUSINESS_FUNCTION.original: user.business_function, + RenamedHeader.ODS_CODE.original: user.org_code, + RenamedHeader.FILENAME.original: _EXPECTED_FILENAME, + RenamedHeader.COMM_RULE_ORG.original: _EXPECTED_COMM_RULE_ORG, + RenamedHeader.OBO_USER_ID.original: _EXPECTED_OBO_USER_ID, + } + + # Make the API call + response = requests.get( + f"{service_url}{endpoint_url}", headers=client_request_headers + ) + + # Verify the status + assert ( + response.status_code == 200 + ), "Expected a 200 when accessing the api but got " + str(response.status_code) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "endpoint_url, is_fhir_4, user ,apim_app_flow_vars", + [ + ("", False, Actor.RC_DEV, ["invalid_code"]), + ("/FHIR/R4/", True, Actor.RC_DEV, ["invalid_code"]), + ("/FHIR/STU3/", False, Actor.RC_DEV, ["invalid_code"]), + ], + ) + async def test_user_restricted_invalid_ods_code( + self, + authenticate_user, + service_url, + user: Actor, + asid, + endpoint_url, + is_fhir_4, + apim_app_flow_vars, + ): + access_code = await authenticate_user(user) + + client_request_headers = { + _HEADER_ECHO: "", # enable echo target + _HEADER_AUTHORIZATION: "Bearer " + access_code, + _HEADER_REQUEST_ID: "DUMMY-VALUE", + RenamedHeader.REFERRAL_ID.original: _EXPECTED_REFERRAL_ID, + RenamedHeader.CORRELATION_ID.original: _EXPECTED_CORRELATION_ID, + RenamedHeader.BUSINESS_FUNCTION.original: user.business_function, + RenamedHeader.ODS_CODE.original: user.org_code, + RenamedHeader.FILENAME.original: _EXPECTED_FILENAME, + RenamedHeader.COMM_RULE_ORG.original: _EXPECTED_COMM_RULE_ORG, + RenamedHeader.OBO_USER_ID.original: _EXPECTED_OBO_USER_ID, + } + + # Make the API call + response = requests.get( + f"{service_url}{endpoint_url}", headers=client_request_headers + ) + # Verify the status + assert ( + response.status_code == 403 + ), "Expected a 403 when accessing the api but got " + str(response.status_code) + # Verify the OperationOutcome payload + response_data = response.json() + assert response_data["resourceType"] == "OperationOutcome" + assert response_data["meta"]["lastUpdated"] is not None + assert len(response_data["meta"]["profile"]) == 1 + assert response_data["meta"]["profile"][0] == ( + "https://www.hl7.org/fhir/R4/operationoutcome.html" + if is_fhir_4 + else "https://fhir.nhs.uk/STU3/StructureDefinition/eRS-OperationOutcome-1" + ) + assert len(response_data["issue"]) == 1 + issue = response_data["issue"][0] + assert issue["severity"] == "error" + assert issue["code"] == "forbidden" if is_fhir_4 else "forbidden" + assert issue["diagnostics"] == ( + "Unauthorised ODS code provided in NHSD-End-User-Organisation-ODS header" + ) + assert len(issue["details"]["coding"]) == 1 + issue_details = issue["details"]["coding"][0] + assert ( + issue_details["system"] + == "https://fhir.nhs.uk/CodeSystem/NHSD-API-ErrorOrWarningCode" + if is_fhir_4 + else "https://fhir.nhs.uk/STU3/CodeSystem/eRS-APIErrorCode-1" + ) + assert ( + issue_details["code"] == "ACCESS_DENIED" if is_fhir_4 else "NO_ACCESS" + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "endpoint_url, is_fhir_4, user ,apim_app_flow_vars", + [ + ("", False, Actor.RC_DEV, [Actor.RC_DEV.org_code]), + ("/FHIR/R4/", True, Actor.RC_DEV, [Actor.RC_DEV.org_code]), + ("/FHIR/STU3/", False, Actor.RC_DEV, [Actor.RC_DEV.org_code]), + ], + ) + async def test_user_restricted_missing_ods_header( + self, + authenticate_user, + service_url, + user: Actor, + asid, + endpoint_url, + is_fhir_4, + apim_app_flow_vars, + ): + access_code = await authenticate_user(user) + + client_request_headers = { + _HEADER_ECHO: "", # enable echo target + _HEADER_AUTHORIZATION: "Bearer " + access_code, + _HEADER_REQUEST_ID: "DUMMY-VALUE", + RenamedHeader.REFERRAL_ID.original: _EXPECTED_REFERRAL_ID, + RenamedHeader.CORRELATION_ID.original: _EXPECTED_CORRELATION_ID, + RenamedHeader.BUSINESS_FUNCTION.original: user.business_function, + RenamedHeader.FILENAME.original: _EXPECTED_FILENAME, + RenamedHeader.COMM_RULE_ORG.original: _EXPECTED_COMM_RULE_ORG, + RenamedHeader.OBO_USER_ID.original: _EXPECTED_OBO_USER_ID, + } + + # Make the API call + response = requests.get( + f"{service_url}{endpoint_url}", headers=client_request_headers + ) + # Verify the status + assert ( + response.status_code == 400 + ), "Expected a 400 when accessing the api but got " + str(response.status_code) + # Verify the OperationOutcome payload + response_data = response.json() + assert response_data["resourceType"] == "OperationOutcome" + assert response_data["meta"]["lastUpdated"] is not None + assert len(response_data["meta"]["profile"]) == 1 + assert response_data["meta"]["profile"][0] == ( + "https://www.hl7.org/fhir/R4/operationoutcome.html" + if is_fhir_4 + else "https://fhir.nhs.uk/STU3/StructureDefinition/eRS-OperationOutcome-1" + ) + assert len(response_data["issue"]) == 1 + issue = response_data["issue"][0] + assert issue["severity"] == "error" + assert issue["code"] == "required" if is_fhir_4 else "required" + assert issue["diagnostics"] == ( + "Missing or Empty NHSD-End-User-Organisation-ODS header." + ) + assert len(issue["details"]["coding"]) == 1 + issue_details = issue["details"]["coding"][0] + assert ( + issue_details["system"] + == "https://fhir.nhs.uk/CodeSystem/NHSD-API-ErrorOrWarningCode" + if is_fhir_4 + else "https://fhir.nhs.uk/STU3/CodeSystem/eRS-APIErrorCode-1" + ) + assert ( + issue_details["code"] == "MISSING_HEADER" if is_fhir_4 else "MISSING_HEADER" + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "endpoint_url, is_fhir_4, user ,apim_app_flow_vars", + [ + ("", False, Actor.RC_DEV, [Actor.RC_DEV.org_code]), + ("/FHIR/R4/", True, Actor.RC_DEV, [Actor.RC_DEV.org_code]), + ("/FHIR/STU3/", False, Actor.RC_DEV, [Actor.RC_DEV.org_code]), + ], + ) + async def test_user_restricted_missing_ods_code( + self, + authenticate_user, + service_url, + user: Actor, + asid, + endpoint_url, + is_fhir_4, + apim_app_flow_vars, + ): + access_code = await authenticate_user(user) + + client_request_headers = { + _HEADER_ECHO: "", # enable echo target + _HEADER_AUTHORIZATION: "Bearer " + access_code, + _HEADER_REQUEST_ID: "DUMMY-VALUE", + RenamedHeader.REFERRAL_ID.original: _EXPECTED_REFERRAL_ID, + RenamedHeader.CORRELATION_ID.original: _EXPECTED_CORRELATION_ID, + RenamedHeader.BUSINESS_FUNCTION.original: user.business_function, + RenamedHeader.ODS_CODE.original: "", + RenamedHeader.FILENAME.original: _EXPECTED_FILENAME, + RenamedHeader.COMM_RULE_ORG.original: _EXPECTED_COMM_RULE_ORG, + RenamedHeader.OBO_USER_ID.original: _EXPECTED_OBO_USER_ID, + } + + # Make the API call + response = requests.get( + f"{service_url}{endpoint_url}", headers=client_request_headers + ) + # Verify the status + assert ( + response.status_code == 400 + ), "Expected a 400 when accessing the api but got " + str(response.status_code) + # Verify the OperationOutcome payload + response_data = response.json() + assert response_data["resourceType"] == "OperationOutcome" + assert response_data["meta"]["lastUpdated"] is not None + assert len(response_data["meta"]["profile"]) == 1 + assert response_data["meta"]["profile"][0] == ( + "https://www.hl7.org/fhir/R4/operationoutcome.html" + if is_fhir_4 + else "https://fhir.nhs.uk/STU3/StructureDefinition/eRS-OperationOutcome-1" + ) + assert len(response_data["issue"]) == 1 + issue = response_data["issue"][0] + assert issue["severity"] == "error" + assert issue["code"] == "required" if is_fhir_4 else "required" + assert issue["diagnostics"] == ( + "Missing or Empty NHSD-End-User-Organisation-ODS header." + ) + assert len(issue["details"]["coding"]) == 1 + issue_details = issue["details"]["coding"][0] + assert ( + issue_details["system"] + == "https://fhir.nhs.uk/CodeSystem/NHSD-API-ErrorOrWarningCode" + if is_fhir_4 + else "https://fhir.nhs.uk/STU3/CodeSystem/eRS-APIErrorCode-1" + ) + assert ( + issue_details["code"] == "MISSING_HEADER" if is_fhir_4 else "MISSING_HEADER" + ) From a52030bfb6a412740fe421db91a45db360e20e8e Mon Sep 17 00:00:00 2001 From: Jack Wainwright Date: Mon, 1 Sep 2025 15:48:57 +0100 Subject: [PATCH 2/6] [ERSSUP-89806]-[MW]-[Updated app-restricted business function validation to use new business functions]-[JW] --- proxies/live/apiproxy/targets/ers-target.xml | 25 ++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/proxies/live/apiproxy/targets/ers-target.xml b/proxies/live/apiproxy/targets/ers-target.xml index e6bfb2b1e..b839d7141 100644 --- a/proxies/live/apiproxy/targets/ers-target.xml +++ b/proxies/live/apiproxy/targets/ers-target.xml @@ -181,6 +181,23 @@ (raisefault.RaiseFault.InvalidBusinessFunction.failed = true) + + + (isFhirR4Path = true) + AssignMessage.SetOperationOutcomeVariablesR4 + + + (isFhirR4Path = false) + AssignMessage.SetOperationOutcomeVariablesPreR4 + + + AssignMessage.SetOperationOutcomeInvalidBusinessFunction + + + AssignMessage.OperationOutcomeErrorResponse + + (raisefault.RaiseFault.InvalidBusinessFunction.failed = true) + @@ -241,7 +258,7 @@ RaiseFault.InvalidBusinessFunction (request.header.nhsd-ers-business-function == "PROVIDER_AUTHORISED_APPLICATION") or (request.header.nhsd-ers-business-function == "REFERRER_AUTHORISED_APPLICATION") or (request.header.nhsd-ers-business-function == "AUTHORISED_APPLICATION") - + AssignMessage.Set.x-ers-access-mode-header-user-restricted AssignMessage.Set.x-ers-user-id-header-user-restricted @@ -271,17 +288,17 @@ AssignMessage.Set.x-ers-amr-header AssignMessage.Set.x-ers-id-assurance-level-header - + (request.header.x-ers-id-assurance-level LesserThan 3) RaiseFault.401InsufficientIal {% if ALLOW_ECHO_TARGET | default(false) == true %} AssignMessage.SetEchoTarget (request.header.echo) - {% endif %} {% if '--ft-' in (ERS_TARGET_SERVER | default('e-referrals-service-api')) %} + {% endif %} {% if '--ft-' in (ERS_TARGET_SERVER | default('e-referrals-service-api')) %} AssignMessage.SetTruststore (isEchoCall != true ) - + AssignMessage.SetEchoTruststore (isEchoCall == true) {% endif %} From 6944e42928e8ebfff07ce3ecf359dbb0f83b72dc Mon Sep 17 00:00:00 2001 From: Patrick Cando Date: Wed, 24 Sep 2025 08:31:05 +0000 Subject: [PATCH 3/6] Update resourceType --- tests/integration/test_user_restricted.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/integration/test_user_restricted.py b/tests/integration/test_user_restricted.py index 1b21b401b..299245490 100644 --- a/tests/integration/test_user_restricted.py +++ b/tests/integration/test_user_restricted.py @@ -118,7 +118,7 @@ async def test_user_restricted_invalid_ods_code( ), "Expected a 403 when accessing the api but got " + str(response.status_code) # Verify the OperationOutcome payload response_data = response.json() - assert response_data["resourceType"] == "OperationOutcome" + assert response_data["resourceType"] == "CodeSystem" assert response_data["meta"]["lastUpdated"] is not None assert len(response_data["meta"]["profile"]) == 1 assert response_data["meta"]["profile"][0] == ( @@ -141,9 +141,7 @@ async def test_user_restricted_invalid_ods_code( if is_fhir_4 else "https://fhir.nhs.uk/STU3/CodeSystem/eRS-APIErrorCode-1" ) - assert ( - issue_details["code"] == "ACCESS_DENIED" if is_fhir_4 else "NO_ACCESS" - ) + assert issue_details["code"] == "ACCESS_DENIED" if is_fhir_4 else "NO_ACCESS" @pytest.mark.asyncio @pytest.mark.parametrize( @@ -188,7 +186,7 @@ async def test_user_restricted_missing_ods_header( ), "Expected a 400 when accessing the api but got " + str(response.status_code) # Verify the OperationOutcome payload response_data = response.json() - assert response_data["resourceType"] == "OperationOutcome" + assert response_data["resourceType"] == "CodeSystem" assert response_data["meta"]["lastUpdated"] is not None assert len(response_data["meta"]["profile"]) == 1 assert response_data["meta"]["profile"][0] == ( @@ -259,7 +257,7 @@ async def test_user_restricted_missing_ods_code( ), "Expected a 400 when accessing the api but got " + str(response.status_code) # Verify the OperationOutcome payload response_data = response.json() - assert response_data["resourceType"] == "OperationOutcome" + assert response_data["resourceType"] == "CodeSystem" assert response_data["meta"]["lastUpdated"] is not None assert len(response_data["meta"]["profile"]) == 1 assert response_data["meta"]["profile"][0] == ( From 7edac83e6a87ecd4931175be7c55a7bee16caccc Mon Sep 17 00:00:00 2001 From: Patrick Cando Date: Wed, 24 Sep 2025 10:36:37 +0100 Subject: [PATCH 4/6] restore --- proxies/live/apiproxy/targets/ers-target.xml | 462 ++++++++++++++++--- tests/integration/test_user_restricted.py | 6 +- 2 files changed, 406 insertions(+), 62 deletions(-) diff --git a/proxies/live/apiproxy/targets/ers-target.xml b/proxies/live/apiproxy/targets/ers-target.xml index b839d7141..fd822e325 100644 --- a/proxies/live/apiproxy/targets/ers-target.xml +++ b/proxies/live/apiproxy/targets/ers-target.xml @@ -29,17 +29,373 @@ AssignMessage.SetOperationOutcomeVariablesPreR4 - + aalError == true AssignMessage.SetOperationOutcomeIssueCodeLogin - + aalError == true + + + + + ExtractVariables.OAuthErrorFaultString + + + AssignMessage.SetOperationOutcomeVariablesR4 + + + AssignMessage.SetInsufficientAALVariables + faultstring ~ "*OauthV2.VerifyAccessToken.scopeSet*" + + + AssignMessage.SetOperationOutcomeIssueCodeLogin + + + AssignMessage.OperationOutcomeErrorResponse + + (oauthV2.OauthV2.VerifyAccessToken.failed = true) and (isFhirR4Path = true) + + + + ExtractVariables.OAuthErrorFaultString + + + AssignMessage.SetInsufficientAALVariables + faultstring ~ "*OauthV2.VerifyAccessToken.scopeSet*" + + + AssignMessage.SetOperationOutcomeVariablesPreR4 + + aalError == true + + + AssignMessage.SetOperationOutcomeIssueCodeLogin + + aalError == true + + + AssignMessage.OAuthPolicyErrorResponse + + aalError != true + + + AssignMessage.OperationOutcomeErrorResponse + aalError = true + + (oauthV2.OauthV2.VerifyAccessToken.failed = true) and (isFhirR4Path = false) + + + + AssignMessage.RatelimitOperationOutcomeResponse + + (ratelimit.SpikeArrestPerApp.failed = true) and (isFhirR4Path = true) + + + + AssignMessage.RatelimitOperationOutcomeResponse + + (ratelimit.QuotaPerApp.failed = true) and (isFhirR4Path = true) + + + + AssignMessage.SpikeArrestErrorResponse + + (ratelimit.SpikeArrestPerApp.failed = true) and (isFhirR4Path = false) + + + + AssignMessage.QuotaErrorResponse + + (ratelimit.QuotaPerApp.failed = true) and (isFhirR4Path = false) + + + + (isFhirR4Path = true) + AssignMessage.SetOperationOutcomeVariablesR4 + + + (isFhirR4Path = false) + AssignMessage.SetOperationOutcomeVariablesPreR4 + + + AssignMessage.SetOperationOutcomeIssueIal + + + AssignMessage.OperationOutcomeErrorResponse + + (raisefault.RaiseFault.401InsufficientIal.failed = true) + + + + (isFhirR4Path = true) + AssignMessage.SetOperationOutcomeVariablesR4 + + + (isFhirR4Path = false) + AssignMessage.SetOperationOutcomeVariablesPreR4 + + + AssignMessage.SetOperationOutcomeMissingAsid + + + AssignMessage.OperationOutcomeErrorResponse + + (raisefault.RaiseFault.MissingAsid.failed = true) + + + + (isFhirR4Path = false) + AssignMessage.SetOperationOutcomeVariablesPreR4 + + + (isFhirR4Path = false) + AssignMessage.SetOperationOutcomeODSHeaderValueNotInPartnerListPreR4 + + + (isFhirR4Path = true) + AssignMessage.SetOperationOutcomeVariablesR4 + + + (isFhirR4Path = true) + AssignMessage.SetOperationOutcomeODSHeaderValueNotInPartnerListR4 + + + AssignMessage.OperationOutcomeErrorResponse + + (raisefault.RaiseFault.CheckAllowlistFailed.failed = true) and (validation.statusCode = 403) + + + + (isFhirR4Path = false) + AssignMessage.SetOperationOutcomeVariablesPreR4 + + + (isFhirR4Path = false) + AssignMessage.SetOperationOutcomeODSHeaderMissingPreR4 + + + (isFhirR4Path = true) + AssignMessage.SetOperationOutcomeVariablesR4 + + + (isFhirR4Path = true) + AssignMessage.SetOperationOutcomeODSHeaderMissingR4 + + + AssignMessage.OperationOutcomeErrorResponse + + (raisefault.RaiseFault.CheckAllowlistFailed.failed = true) and (validation.statusCode = 400) + + + + (isFhirR4Path = false) + AssignMessage.InternalServerError + + + (isFhirR4Path = true) + AssignMessage.SetOperationOutcomeVariablesR4 + + + (isFhirR4Path = true) + AssignMessage.SetOperationOutcomeServiceError + + + (isFhirR4Path = true) + AssignMessage.OperationOutcomeErrorResponse + + (raisefault.RaiseFault.CheckAllowlistFailed.failed = true) and (validation.statusCode = 500) + + + + + + javascript.SetCurrentTimestamp + + + javascript.IsFhirR4Path + + + OauthV2.VerifyAccessToken + + + + FlowCallout.ExtendedAttributes + (accesstoken.auth_type == "user") and (proxy.pathsuffix != "/FHIR/R4/PractitionerRole") + + + FlowCallout.EUOAllowlistVerify + (accesstoken.auth_type == "user") and (proxy.pathsuffix != "/FHIR/R4/PractitionerRole") + + + RaiseFault.MissingAsid + (app.asid == null) Or (app.asid == "") + + + AssignMessage.PopulateAsidFromApp + + + AssignMessage.SetAsidHeader + + + AssignMessage.AddBaseUrlHeader + + + FlowCallout.ApplyRateLimiting + + + + + + AssignMessage.SetFlagCustomHeaderXRequestId + + + AssignMessage.Swap.TransactionID + (response.header.x_ers_transaction_id ~~ ".+") + + + AssignMessage.Remove.nhsd-correlation-id-header + (response.header.nhsd-correlation-id ~~ ".+") + + + + + + (accesstoken.auth_type == "user") + + RaiseFault.403Forbidden + (request.header.nhsd-ers-business-function == "AUTHORISED_APPLICATION") + + AssignMessage.Set.x-ers-access-mode-header-user-restricted + + AssignMessage.Set.x-ers-user-id-header-user-restricted + + AssignMessage.Swap.NHSD-eRS-On-Behalf-Of-User-ID + (request.header.NHSD-eRS-On-Behalf-Of-User-ID ~~ ".+") + + AssignMessage.Swap.nhsd-end-user-organisation-ods + (request.header.nhsd-end-user-organisation-ods ~~ ".+") + + AssignMessage.Swap.nhsd-ers-business-function + (request.header.nhsd-ers-business-function ~~ ".+") + + AssignMessage.Swap.nhsd-ers-comm-rule-org + (request.header.nhsd-ers-comm-rule-org ~~ ".+") + + AssignMessage.Swap.nhsd-ers-file-name + (request.header.nhsd-ers-file-name ~~ ".+") + + AssignMessage.Swap.nhsd-ers-referral-id + (request.header.nhsd-ers-referral-id ~~ ".+") + + AssignMessage.Remove.x-request-id-header + + AssignMessage.Set.x-ers-authentication-assurance-level-header + + AssignMessage.Set.x-ers-amr-header + + AssignMessage.Set.x-ers-id-assurance-level-header + + (request.header.x-ers-id-assurance-level LesserThan 3) + RaiseFault.401InsufficientIal + {% if ALLOW_ECHO_TARGET | default(false) == true %} + AssignMessage.SetEchoTarget + (request.header.echo) + {% endif %} {% if '--ft-' in (ERS_TARGET_SERVER | default('e-referrals-service-api')) %} + AssignMessage.SetTruststore + + (isEchoCall != true ) + + AssignMessage.SetEchoTruststore + (isEchoCall == true) + {% endif %} + + AssignMessage.Swap.CorrelationHeader + + + + + (accesstoken.auth_type == "app") + + RaiseFault.403Forbidden + (request.header.x-ers-ods-code) + + RaiseFault.403Forbidden + (request.header.x-ers-business-function) + + RaiseFault.403Forbidden + (request.header.x-ers-user-id) + + AssignMessage.Set.x-ers-access-mode-header-app-restricted + + AssignMessage.Set.nhsd-ers-ods-code-header-app-restricted + + AssignMessage.Set.nhsd-ers-business-function-header-app-restricted + + AssignMessage.Set.x-ers-user-id-header-app-restricted + + AssignMessage.Remove.x-request-id-header + {% if ALLOW_ECHO_TARGET | default(false) == true %} + AssignMessage.SetEchoTarget + (request.header.echo) + {% endif %} {% if '--ft-' in (ERS_TARGET_SERVER | default('e-referrals-service-api')) %} + AssignMessage.SetTruststore + + (isEchoCall != true ) + + AssignMessage.SetEchoTruststore + (isEchoCall == true) + {% endif %} + + AssignMessage.Swap.CorrelationHeader + + + + + + + + RaiseFault.403Forbidden + + + + + + + {% if '--ft-' in (ERS_TARGET_SERVER | default('e-referrals-service-api')) %} {truststore} {% endif %} true + + + + /ers-api + + true + User-Agent,Referer,Accept-Language + apikey + 180000 + + + + + AssignMessage.SetFlagCustomHeaderXRequestId + + + AssignMessage.Swap.TransactionID + (response.header.x_ers_transaction_id ~~ ".+") + + + AssignMessage.Remove.nhsd-correlation-id-header + (response.header.nhsd-correlation-id ~~ ".+") + + + AssignMessage.OAuthPolicyErrorResponse - + aalError != true @@ -132,6 +488,18 @@ (isFhirR4Path = false) AssignMessage.SetOperationOutcomeODSHeaderValueNotInPartnerListPreR4 + + (isFhirR4Path = true) + AssignMessage.SetOperationOutcomeVariablesR4 + + + (isFhirR4Path = true) + AssignMessage.SetOperationOutcomeODSHeaderValueNotInPartnerListR4 + + + AssignMessage.OperationOutcomeErrorResponse + + (raisefault.RaiseFault.CheckAllowlistFailed.failed = true) and (validation.statusCode = 403) @@ -172,31 +540,7 @@ (isFhirR4Path = true) AssignMessage.OperationOutcomeErrorResponse - - (raisefault.RaiseFault.CheckAllowlistFailed.failed = true) and (validation.statusCode = 500) - AssignMessage.SetOperationOutcomeInvalidBusinessFunction - - - AssignMessage.OperationOutcomeErrorResponse - - (raisefault.RaiseFault.InvalidBusinessFunction.failed = true) - - - - (isFhirR4Path = true) - AssignMessage.SetOperationOutcomeVariablesR4 - - - (isFhirR4Path = false) - AssignMessage.SetOperationOutcomeVariablesPreR4 - - - AssignMessage.SetOperationOutcomeInvalidBusinessFunction - - - AssignMessage.OperationOutcomeErrorResponse - - (raisefault.RaiseFault.InvalidBusinessFunction.failed = true) + (raisefault.RaiseFault.CheckAllowlistFailed.failed = true) and (validation.statusCode = 500) @@ -210,7 +554,7 @@ OauthV2.VerifyAccessToken - + FlowCallout.ExtendedAttributes (accesstoken.auth_type == "user") and (proxy.pathsuffix != "/FHIR/R4/PractitionerRole") @@ -237,8 +581,8 @@ - + AssignMessage.SetFlagCustomHeaderXRequestId @@ -255,62 +599,62 @@ (accesstoken.auth_type == "user") - - RaiseFault.InvalidBusinessFunction - (request.header.nhsd-ers-business-function == "PROVIDER_AUTHORISED_APPLICATION") or (request.header.nhsd-ers-business-function == "REFERRER_AUTHORISED_APPLICATION") or (request.header.nhsd-ers-business-function == "AUTHORISED_APPLICATION") + + RaiseFault.403Forbidden + (request.header.nhsd-ers-business-function == "AUTHORISED_APPLICATION") AssignMessage.Set.x-ers-access-mode-header-user-restricted - + AssignMessage.Set.x-ers-user-id-header-user-restricted - + AssignMessage.Swap.NHSD-eRS-On-Behalf-Of-User-ID (request.header.NHSD-eRS-On-Behalf-Of-User-ID ~~ ".+") - + AssignMessage.Swap.nhsd-end-user-organisation-ods (request.header.nhsd-end-user-organisation-ods ~~ ".+") - + AssignMessage.Swap.nhsd-ers-business-function (request.header.nhsd-ers-business-function ~~ ".+") - + AssignMessage.Swap.nhsd-ers-comm-rule-org (request.header.nhsd-ers-comm-rule-org ~~ ".+") - + AssignMessage.Swap.nhsd-ers-file-name (request.header.nhsd-ers-file-name ~~ ".+") - + AssignMessage.Swap.nhsd-ers-referral-id (request.header.nhsd-ers-referral-id ~~ ".+") - + AssignMessage.Remove.x-request-id-header - + AssignMessage.Set.x-ers-authentication-assurance-level-header - + AssignMessage.Set.x-ers-amr-header - + AssignMessage.Set.x-ers-id-assurance-level-header - + (request.header.x-ers-id-assurance-level LesserThan 3) RaiseFault.401InsufficientIal - {% if ALLOW_ECHO_TARGET | default(false) == true %} + {% if ALLOW_ECHO_TARGET | default(false) == true %} AssignMessage.SetEchoTarget (request.header.echo) {% endif %} {% if '--ft-' in (ERS_TARGET_SERVER | default('e-referrals-service-api')) %} AssignMessage.SetTruststore - + (isEchoCall != true ) - + AssignMessage.SetEchoTruststore (isEchoCall == true) {% endif %} - + AssignMessage.Swap.CorrelationHeader (accesstoken.auth_type == "app") - + RaiseFault.403Forbidden (request.header.x-ers-ods-code) @@ -327,26 +671,26 @@ AssignMessage.Set.nhsd-ers-business-function-header-app-restricted AssignMessage.Set.x-ers-user-id-header-app-restricted - + AssignMessage.Remove.x-request-id-header - {% if ALLOW_ECHO_TARGET | default(false) == true %} + {% if ALLOW_ECHO_TARGET | default(false) == true %} AssignMessage.SetEchoTarget (request.header.echo) {% endif %} {% if '--ft-' in (ERS_TARGET_SERVER | default('e-referrals-service-api')) %} AssignMessage.SetTruststore - + (isEchoCall != true ) - + AssignMessage.SetEchoTruststore (isEchoCall == true) {% endif %} - + AssignMessage.Swap.CorrelationHeader - + diff --git a/tests/integration/test_user_restricted.py b/tests/integration/test_user_restricted.py index 299245490..8d4af58ec 100644 --- a/tests/integration/test_user_restricted.py +++ b/tests/integration/test_user_restricted.py @@ -118,7 +118,7 @@ async def test_user_restricted_invalid_ods_code( ), "Expected a 403 when accessing the api but got " + str(response.status_code) # Verify the OperationOutcome payload response_data = response.json() - assert response_data["resourceType"] == "CodeSystem" + assert response_data["resourceType"] == "OperationOutcome" assert response_data["meta"]["lastUpdated"] is not None assert len(response_data["meta"]["profile"]) == 1 assert response_data["meta"]["profile"][0] == ( @@ -186,7 +186,7 @@ async def test_user_restricted_missing_ods_header( ), "Expected a 400 when accessing the api but got " + str(response.status_code) # Verify the OperationOutcome payload response_data = response.json() - assert response_data["resourceType"] == "CodeSystem" + assert response_data["resourceType"] == "OperationOutcome" assert response_data["meta"]["lastUpdated"] is not None assert len(response_data["meta"]["profile"]) == 1 assert response_data["meta"]["profile"][0] == ( @@ -257,7 +257,7 @@ async def test_user_restricted_missing_ods_code( ), "Expected a 400 when accessing the api but got " + str(response.status_code) # Verify the OperationOutcome payload response_data = response.json() - assert response_data["resourceType"] == "CodeSystem" + assert response_data["resourceType"] == "OperationOutcome" assert response_data["meta"]["lastUpdated"] is not None assert len(response_data["meta"]["profile"]) == 1 assert response_data["meta"]["profile"][0] == ( From 7803c89365d3bdf9a9f2e185a03698279958ae3e Mon Sep 17 00:00:00 2001 From: Patrick Cando Date: Wed, 24 Sep 2025 12:12:02 +0000 Subject: [PATCH 5/6] fix --- proxies/live/apiproxy/targets/ers-target.xml | 356 ------------------- 1 file changed, 356 deletions(-) diff --git a/proxies/live/apiproxy/targets/ers-target.xml b/proxies/live/apiproxy/targets/ers-target.xml index fd822e325..30ef83faa 100644 --- a/proxies/live/apiproxy/targets/ers-target.xml +++ b/proxies/live/apiproxy/targets/ers-target.xml @@ -37,362 +37,6 @@ aalError == true - - - - - ExtractVariables.OAuthErrorFaultString - - - AssignMessage.SetOperationOutcomeVariablesR4 - - - AssignMessage.SetInsufficientAALVariables - faultstring ~ "*OauthV2.VerifyAccessToken.scopeSet*" - - - AssignMessage.SetOperationOutcomeIssueCodeLogin - - - AssignMessage.OperationOutcomeErrorResponse - - (oauthV2.OauthV2.VerifyAccessToken.failed = true) and (isFhirR4Path = true) - - - - ExtractVariables.OAuthErrorFaultString - - - AssignMessage.SetInsufficientAALVariables - faultstring ~ "*OauthV2.VerifyAccessToken.scopeSet*" - - - AssignMessage.SetOperationOutcomeVariablesPreR4 - - aalError == true - - - AssignMessage.SetOperationOutcomeIssueCodeLogin - - aalError == true - - - AssignMessage.OAuthPolicyErrorResponse - - aalError != true - - - AssignMessage.OperationOutcomeErrorResponse - aalError = true - - (oauthV2.OauthV2.VerifyAccessToken.failed = true) and (isFhirR4Path = false) - - - - AssignMessage.RatelimitOperationOutcomeResponse - - (ratelimit.SpikeArrestPerApp.failed = true) and (isFhirR4Path = true) - - - - AssignMessage.RatelimitOperationOutcomeResponse - - (ratelimit.QuotaPerApp.failed = true) and (isFhirR4Path = true) - - - - AssignMessage.SpikeArrestErrorResponse - - (ratelimit.SpikeArrestPerApp.failed = true) and (isFhirR4Path = false) - - - - AssignMessage.QuotaErrorResponse - - (ratelimit.QuotaPerApp.failed = true) and (isFhirR4Path = false) - - - - (isFhirR4Path = true) - AssignMessage.SetOperationOutcomeVariablesR4 - - - (isFhirR4Path = false) - AssignMessage.SetOperationOutcomeVariablesPreR4 - - - AssignMessage.SetOperationOutcomeIssueIal - - - AssignMessage.OperationOutcomeErrorResponse - - (raisefault.RaiseFault.401InsufficientIal.failed = true) - - - - (isFhirR4Path = true) - AssignMessage.SetOperationOutcomeVariablesR4 - - - (isFhirR4Path = false) - AssignMessage.SetOperationOutcomeVariablesPreR4 - - - AssignMessage.SetOperationOutcomeMissingAsid - - - AssignMessage.OperationOutcomeErrorResponse - - (raisefault.RaiseFault.MissingAsid.failed = true) - - - - (isFhirR4Path = false) - AssignMessage.SetOperationOutcomeVariablesPreR4 - - - (isFhirR4Path = false) - AssignMessage.SetOperationOutcomeODSHeaderValueNotInPartnerListPreR4 - - - (isFhirR4Path = true) - AssignMessage.SetOperationOutcomeVariablesR4 - - - (isFhirR4Path = true) - AssignMessage.SetOperationOutcomeODSHeaderValueNotInPartnerListR4 - - - AssignMessage.OperationOutcomeErrorResponse - - (raisefault.RaiseFault.CheckAllowlistFailed.failed = true) and (validation.statusCode = 403) - - - - (isFhirR4Path = false) - AssignMessage.SetOperationOutcomeVariablesPreR4 - - - (isFhirR4Path = false) - AssignMessage.SetOperationOutcomeODSHeaderMissingPreR4 - - - (isFhirR4Path = true) - AssignMessage.SetOperationOutcomeVariablesR4 - - - (isFhirR4Path = true) - AssignMessage.SetOperationOutcomeODSHeaderMissingR4 - - - AssignMessage.OperationOutcomeErrorResponse - - (raisefault.RaiseFault.CheckAllowlistFailed.failed = true) and (validation.statusCode = 400) - - - - (isFhirR4Path = false) - AssignMessage.InternalServerError - - - (isFhirR4Path = true) - AssignMessage.SetOperationOutcomeVariablesR4 - - - (isFhirR4Path = true) - AssignMessage.SetOperationOutcomeServiceError - - - (isFhirR4Path = true) - AssignMessage.OperationOutcomeErrorResponse - - (raisefault.RaiseFault.CheckAllowlistFailed.failed = true) and (validation.statusCode = 500) - - - - - - javascript.SetCurrentTimestamp - - - javascript.IsFhirR4Path - - - OauthV2.VerifyAccessToken - - - - FlowCallout.ExtendedAttributes - (accesstoken.auth_type == "user") and (proxy.pathsuffix != "/FHIR/R4/PractitionerRole") - - - FlowCallout.EUOAllowlistVerify - (accesstoken.auth_type == "user") and (proxy.pathsuffix != "/FHIR/R4/PractitionerRole") - - - RaiseFault.MissingAsid - (app.asid == null) Or (app.asid == "") - - - AssignMessage.PopulateAsidFromApp - - - AssignMessage.SetAsidHeader - - - AssignMessage.AddBaseUrlHeader - - - FlowCallout.ApplyRateLimiting - - - - - - AssignMessage.SetFlagCustomHeaderXRequestId - - - AssignMessage.Swap.TransactionID - (response.header.x_ers_transaction_id ~~ ".+") - - - AssignMessage.Remove.nhsd-correlation-id-header - (response.header.nhsd-correlation-id ~~ ".+") - - - - - - (accesstoken.auth_type == "user") - - RaiseFault.403Forbidden - (request.header.nhsd-ers-business-function == "AUTHORISED_APPLICATION") - - AssignMessage.Set.x-ers-access-mode-header-user-restricted - - AssignMessage.Set.x-ers-user-id-header-user-restricted - - AssignMessage.Swap.NHSD-eRS-On-Behalf-Of-User-ID - (request.header.NHSD-eRS-On-Behalf-Of-User-ID ~~ ".+") - - AssignMessage.Swap.nhsd-end-user-organisation-ods - (request.header.nhsd-end-user-organisation-ods ~~ ".+") - - AssignMessage.Swap.nhsd-ers-business-function - (request.header.nhsd-ers-business-function ~~ ".+") - - AssignMessage.Swap.nhsd-ers-comm-rule-org - (request.header.nhsd-ers-comm-rule-org ~~ ".+") - - AssignMessage.Swap.nhsd-ers-file-name - (request.header.nhsd-ers-file-name ~~ ".+") - - AssignMessage.Swap.nhsd-ers-referral-id - (request.header.nhsd-ers-referral-id ~~ ".+") - - AssignMessage.Remove.x-request-id-header - - AssignMessage.Set.x-ers-authentication-assurance-level-header - - AssignMessage.Set.x-ers-amr-header - - AssignMessage.Set.x-ers-id-assurance-level-header - - (request.header.x-ers-id-assurance-level LesserThan 3) - RaiseFault.401InsufficientIal - {% if ALLOW_ECHO_TARGET | default(false) == true %} - AssignMessage.SetEchoTarget - (request.header.echo) - {% endif %} {% if '--ft-' in (ERS_TARGET_SERVER | default('e-referrals-service-api')) %} - AssignMessage.SetTruststore - - (isEchoCall != true ) - - AssignMessage.SetEchoTruststore - (isEchoCall == true) - {% endif %} - - AssignMessage.Swap.CorrelationHeader - - - - - (accesstoken.auth_type == "app") - - RaiseFault.403Forbidden - (request.header.x-ers-ods-code) - - RaiseFault.403Forbidden - (request.header.x-ers-business-function) - - RaiseFault.403Forbidden - (request.header.x-ers-user-id) - - AssignMessage.Set.x-ers-access-mode-header-app-restricted - - AssignMessage.Set.nhsd-ers-ods-code-header-app-restricted - - AssignMessage.Set.nhsd-ers-business-function-header-app-restricted - - AssignMessage.Set.x-ers-user-id-header-app-restricted - - AssignMessage.Remove.x-request-id-header - {% if ALLOW_ECHO_TARGET | default(false) == true %} - AssignMessage.SetEchoTarget - (request.header.echo) - {% endif %} {% if '--ft-' in (ERS_TARGET_SERVER | default('e-referrals-service-api')) %} - AssignMessage.SetTruststore - - (isEchoCall != true ) - - AssignMessage.SetEchoTruststore - (isEchoCall == true) - {% endif %} - - AssignMessage.Swap.CorrelationHeader - - - - - - - - RaiseFault.403Forbidden - - - - - - - {% if '--ft-' in (ERS_TARGET_SERVER | default('e-referrals-service-api')) %} {truststore} {% endif %} true - - - - /ers-api - - true - User-Agent,Referer,Accept-Language - apikey - 180000 - - - - - AssignMessage.SetFlagCustomHeaderXRequestId - - - AssignMessage.Swap.TransactionID - (response.header.x_ers_transaction_id ~~ ".+") - - - AssignMessage.Remove.nhsd-correlation-id-header - (response.header.nhsd-correlation-id ~~ ".+") - - - AssignMessage.OAuthPolicyErrorResponse From 211076133a3e8d0260bf0361e3a47fdcee725383 Mon Sep 17 00:00:00 2001 From: Patrick Cando Date: Wed, 24 Sep 2025 12:29:58 +0000 Subject: [PATCH 6/6] fix --- proxies/live/apiproxy/targets/ers-target.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/proxies/live/apiproxy/targets/ers-target.xml b/proxies/live/apiproxy/targets/ers-target.xml index 30ef83faa..463040ddb 100644 --- a/proxies/live/apiproxy/targets/ers-target.xml +++ b/proxies/live/apiproxy/targets/ers-target.xml @@ -244,8 +244,8 @@ (accesstoken.auth_type == "user") - RaiseFault.403Forbidden - (request.header.nhsd-ers-business-function == "AUTHORISED_APPLICATION") + RaiseFault.InvalidBusinessFunction + (request.header.nhsd-ers-business-function == "PROVIDER_AUTHORISED_APPLICATION") or (request.header.nhsd-ers-business-function == "REFERRER_AUTHORISED_APPLICATION") or (request.header.nhsd-ers-business-function == "AUTHORISED_APPLICATION") AssignMessage.Set.x-ers-access-mode-header-user-restricted