Skip to content

Commit 481a2c1

Browse files
authored
[NDR-283] Bypass requirement for Bearer token in FHIR endpoint when using PDM (#856)
1 parent c3395c2 commit 481a2c1

9 files changed

+242
-45
lines changed

lambdas/handlers/fhir_document_reference_search_handler.py

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from utils.decorators.validate_patient_id import validate_patient_id_fhir
1616
from utils.exceptions import AuthorisationException, OidcApiException
1717
from utils.lambda_exceptions import DocumentRefSearchException, SearchPatientException
18+
from utils.lambda_handler_utils import extract_bearer_token
1819
from utils.lambda_response import ApiGatewayResponse
1920
from utils.request_context import request_context
2021

@@ -46,15 +47,15 @@ def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
4647
"""
4748
logger.info("Received request to search for document references")
4849

49-
bearer_token = extract_bearer_token(event)
50+
bearer_token = extract_bearer_token(event, context)
5051
selected_role_id = event.get("headers", {}).get(HEADER_CIS2_USER_ID, "")
5152

5253
nhs_number, search_filters = parse_query_parameters(
5354
event.get("queryStringParameters", {})
5455
)
5556
request_context.patient_nhs_no = nhs_number
5657

57-
if selected_role_id:
58+
if selected_role_id and bearer_token:
5859
validate_user_access(bearer_token, selected_role_id, nhs_number)
5960

6061
service = DocumentReferenceSearchService()
@@ -151,16 +152,3 @@ def validate_user_access(
151152
search_patient_service.handle_search_patient_request(nhs_number, False)
152153
except SearchPatientException as e:
153154
raise DocumentRefSearchException(e.status_code, e.error)
154-
155-
156-
def extract_bearer_token(event):
157-
"""Extract and validate bearer token from event"""
158-
headers = event.get("headers", {})
159-
if not headers:
160-
logger.warning("No headers found in request")
161-
raise DocumentRefSearchException(401, LambdaError.DocumentReferenceUnauthorised)
162-
bearer_token = headers.get("Authorization", None)
163-
if not bearer_token or not bearer_token.startswith("Bearer "):
164-
logger.warning("No bearer token found in request")
165-
raise DocumentRefSearchException(401, LambdaError.DocumentReferenceUnauthorised)
166-
return bearer_token

lambdas/handlers/get_fhir_document_reference_handler.py

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from enums.lambda_error import LambdaError
2+
from enums.mtls import MtlsCommonNames
23
from oauthlib.oauth2 import WebApplicationClient
34
from services.base.ssm_service import SSMService
45
from services.dynamic_configuration_service import DynamicConfigurationService
@@ -8,6 +9,8 @@
89
from utils.audit_logging_setup import LoggingService
910
from utils.decorators.ensure_env_var import ensure_environment_variables
1011
from utils.decorators.handle_lambda_exceptions import handle_lambda_exceptions_fhir
12+
from utils.lambda_handler_utils import extract_bearer_token
13+
from utils.lambda_header_utils import validate_common_name_in_mtls
1114
from utils.decorators.set_audit_arg import set_request_context_for_logging
1215
from utils.exceptions import AuthorisationException, OidcApiException
1316
from utils.lambda_exceptions import (
@@ -34,16 +37,17 @@
3437
)
3538
def lambda_handler(event, context):
3639
try:
37-
bearer_token = extract_bearer_token(event)
40+
bearer_token = extract_bearer_token(event, context)
3841
selected_role_id = event.get("headers", {}).get("cis2-urid", None)
42+
3943
document_id, snomed_code = extract_document_parameters(event)
4044

4145
get_document_service = GetFhirDocumentReferenceService()
4246
document_reference = get_document_service.handle_get_document_reference_request(
4347
snomed_code, document_id
4448
)
4549

46-
if selected_role_id:
50+
if selected_role_id and bearer_token:
4751
verify_user_authorisation(
4852
bearer_token, selected_role_id, document_reference.nhs_number
4953
)
@@ -72,17 +76,6 @@ def lambda_handler(event, context):
7276
).create_api_gateway_response()
7377

7478

75-
def extract_bearer_token(event):
76-
"""Extract and validate bearer token from event"""
77-
bearer_token = event.get("headers", {}).get("Authorization", None)
78-
if not bearer_token or not bearer_token.startswith("Bearer "):
79-
logger.warning("No bearer token found in request")
80-
raise GetFhirDocumentReferenceException(
81-
401, LambdaError.DocumentReferenceUnauthorised
82-
)
83-
return bearer_token
84-
85-
8679
def extract_document_parameters(event):
8780
"""Extract document ID and SNOMED code from path parameters"""
8881
path_params = event.get("pathParameters", {}).get("id", None)

lambdas/tests/e2e/api/fhir/test_retrieve_document_fhir_api.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,7 @@ def build_pdm_record(nhs_number="9912003071", data=None, doc_status=None, size=N
3333
def get_document_reference(record_id):
3434
"""Helper to perform GET request for DocumentReference."""
3535
url = f"https://{MTLS_ENDPOINT}/DocumentReference/{PDM_SNOMED}~{record_id}"
36-
print("url:", url)
3736
headers = {
38-
"Authorization": "Bearer 123",
3937
"X-Correlation-Id": "1234",
4038
}
4139
session = create_mtls_session()

lambdas/tests/e2e/api/fhir/test_upload_document_fhir_api.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ def upload_document(session, payload):
6868
"""Helper to upload DocumentReference."""
6969
url = f"https://{MTLS_ENDPOINT}/DocumentReference"
7070
headers = {
71-
"Authorization": "Bearer 123",
7271
"X-Correlation-Id": "1234",
7372
}
7473
return session.post(url, headers=headers, data=payload)
@@ -78,7 +77,6 @@ def retrieve_document_with_retry(session, doc_id, condition):
7877
"""Poll until condition is met on DocumentReference retrieval."""
7978
retrieve_url = f"https://{MTLS_ENDPOINT}/DocumentReference/{doc_id}"
8079
headers = {
81-
"Authorization": "Bearer 123",
8280
"X-Correlation-Id": "1234",
8381
}
8482
return fetch_with_retry_mtls(session, retrieve_url, headers, condition)

lambdas/tests/unit/handlers/test_fhir_document_reference_search_handler.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
import pytest
55
from enums.lambda_error import LambdaError
66
from handlers.fhir_document_reference_search_handler import (
7-
extract_bearer_token,
87
lambda_handler,
98
parse_query_parameters,
109
validate_user_access,
1110
)
1211
from utils.exceptions import AuthorisationException, OidcApiException
1312
from utils.lambda_exceptions import DocumentRefSearchException
13+
from utils.lambda_handler_utils import extract_bearer_token
1414

1515

1616
@pytest.fixture
@@ -349,27 +349,30 @@ def test_parse_query_parameters():
349349
assert filters == {}
350350

351351

352-
def test_extract_bearer_token_valid():
352+
def test_extract_bearer_token_valid(context):
353+
context.function_name = "SearchDocumentReferencesFHIR"
353354
event = {"headers": {"Authorization": "Bearer valid-token"}}
354355

355-
token = extract_bearer_token(event)
356+
token = extract_bearer_token(event, context)
356357
assert token == "Bearer valid-token"
357358

358359

359-
def test_extract_bearer_token_invalid_format():
360+
def test_extract_bearer_token_invalid_format(context):
361+
context.function_name = "SearchDocumentReferencesFHIR"
360362
event = {"headers": {"Authorization": "Token valid-token"}}
361363

362364
with pytest.raises(DocumentRefSearchException) as e:
363-
extract_bearer_token(event)
365+
extract_bearer_token(event, context)
364366

365367
assert e.value.status_code == 401
366368

367369

368-
def test_extract_bearer_token_missing():
370+
def test_extract_bearer_token_missing(context):
371+
context.function_name = "SearchDocumentReferencesFHIR"
369372
event = {"headers": {}}
370373

371374
with pytest.raises(DocumentRefSearchException) as e:
372-
extract_bearer_token(event)
375+
extract_bearer_token(event, context)
373376

374377
assert e.value.status_code == 401
375378

lambdas/tests/unit/handlers/test_get_fhir_document_reference_handler.py

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from enums.lambda_error import LambdaError
66
from enums.snomed_codes import SnomedCodes
77
from handlers.get_fhir_document_reference_handler import (
8-
extract_bearer_token,
98
extract_document_parameters,
109
get_id_and_snomed_from_path_parameters,
1110
lambda_handler,
@@ -15,6 +14,7 @@
1514
from tests.unit.conftest import TEST_UUID
1615
from tests.unit.helpers.data.dynamo.dynamo_responses import MOCK_SEARCH_RESPONSE
1716
from utils.exceptions import OidcApiException
17+
from utils.lambda_handler_utils import extract_bearer_token
1818
from utils.lambda_exceptions import (
1919
GetFhirDocumentReferenceException,
2020
SearchPatientException,
@@ -30,6 +30,33 @@
3030
},
3131
"pathParameters": {"id": f"{SNOMED_CODE}~{TEST_UUID}"},
3232
"body": None,
33+
"requestContext": {},
34+
}
35+
36+
MOCK_MTLS_VALID_EVENT = {
37+
"httpMethod": "GET",
38+
"headers": {},
39+
"pathParameters": {"id": f"{SNOMED_CODE}~{TEST_UUID}"},
40+
"body": None,
41+
"requestContext": {
42+
"accountId": "123456789012",
43+
"apiId": "abc123",
44+
"domainName": "api.example.com",
45+
"identity": {
46+
"sourceIp": "1.2.3.4",
47+
"userAgent": "curl/7.64.1",
48+
"clientCert": {
49+
"clientCertPem": "-----BEGIN CERTIFICATE-----...",
50+
"subjectDN": "CN=ndrclient.main.int.pdm.national.nhs.uk,O=NHS,C=UK",
51+
"issuerDN": "CN=NHS Root CA,O=NHS,C=UK",
52+
"serialNumber": "12:34:56",
53+
"validity": {
54+
"notBefore": "May 10 00:00:00 2024 GMT",
55+
"notAfter": "May 10 00:00:00 2025 GMT",
56+
},
57+
},
58+
},
59+
},
3360
}
3461

3562
MOCK_INVALID_EVENT_ID_MALFORMED = deepcopy(MOCK_CIS2_VALID_EVENT)
@@ -144,15 +171,45 @@ def test_lambda_handler_happy_path_with_application_login(
144171
)
145172

146173

147-
def test_extract_bearer_token():
148-
token = extract_bearer_token(MOCK_CIS2_VALID_EVENT)
174+
def test_lambda_handler_happy_path_with_mtls_pdm_login(
175+
set_env,
176+
mock_document_service,
177+
mock_search_patient_service,
178+
context,
179+
):
180+
mock_document_service.create_document_reference_fhir_response.return_value = (
181+
"test_document_reference"
182+
)
183+
184+
response = lambda_handler(MOCK_MTLS_VALID_EVENT, context)
185+
186+
assert response["statusCode"] == 200
187+
assert response["body"] == "test_document_reference"
188+
# Verify correct method calls
189+
mock_document_service.handle_get_document_reference_request.assert_called_once_with(
190+
SNOMED_CODE, TEST_UUID
191+
)
192+
mock_document_service.create_document_reference_fhir_response.assert_called_once_with(
193+
MOCK_DOCUMENT_REFERENCE
194+
)
195+
196+
197+
def test_extract_bearer_token(context):
198+
context.function_name = "GetDocumentReference"
199+
token = extract_bearer_token(MOCK_CIS2_VALID_EVENT, context)
149200
assert token == f"Bearer {TEST_UUID}"
150201

151202

152-
def test_extract_missing_bearer_token():
203+
def test_extract_bearer_token_when_pdm(context):
204+
token = extract_bearer_token(MOCK_MTLS_VALID_EVENT, context)
205+
assert token is None
206+
207+
208+
def test_extract_missing_bearer_token(context):
209+
context.function_name = "GetDocumentReference"
153210
event_without_auth = {"headers": {}}
154211
with pytest.raises(GetFhirDocumentReferenceException) as e:
155-
extract_bearer_token(event_without_auth)
212+
extract_bearer_token(event_without_auth, context)
156213
assert e.value.status_code == 401
157214
assert e.value.error == LambdaError.DocumentReferenceUnauthorised
158215

@@ -163,6 +220,12 @@ def test_extract_document_parameters_valid():
163220
assert snomed_code == SNOMED_CODE
164221

165222

223+
def test_extract_document_parameters_valid_pdm():
224+
document_id, snomed_code = extract_document_parameters(MOCK_MTLS_VALID_EVENT)
225+
assert document_id == TEST_UUID
226+
assert snomed_code == SNOMED_CODE
227+
228+
166229
def test_extract_document_parameters_invalid():
167230
with pytest.raises(GetFhirDocumentReferenceException) as e:
168231
extract_document_parameters(MOCK_INVALID_EVENT_ID_MALFORMED)
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import pytest
2+
from enums.lambda_error import LambdaError
3+
from tests.unit.conftest import TEST_UUID
4+
5+
from enums.snomed_codes import SnomedCodes
6+
from utils.lambda_handler_utils import extract_bearer_token
7+
from utils.lambda_exceptions import (
8+
GetFhirDocumentReferenceException,
9+
DocumentRefSearchException,
10+
DocumentRefException,
11+
)
12+
13+
LG_SNOMED_CODE = SnomedCodes.LLOYD_GEORGE.value.code
14+
PDM_SNOMED_CODE = SnomedCodes.PATIENT_DATA.value.code
15+
16+
MOCK_CIS2_VALID_EVENT = {
17+
"httpMethod": "GET",
18+
"headers": {
19+
"Authorization": f"Bearer {TEST_UUID}",
20+
"cis2-urid": TEST_UUID,
21+
},
22+
"pathParameters": {"id": f"{LG_SNOMED_CODE}~{TEST_UUID}"},
23+
"body": None,
24+
"requestContext": {},
25+
}
26+
27+
MOCK_MTLS_VALID_EVENT = {
28+
"httpMethod": "GET",
29+
"headers": {},
30+
"pathParameters": {"id": f"{PDM_SNOMED_CODE}~{TEST_UUID}"},
31+
"body": None,
32+
"requestContext": {
33+
"accountId": "123456789012",
34+
"apiId": "abc123",
35+
"domainName": "api.example.com",
36+
"identity": {
37+
"sourceIp": "1.2.3.4",
38+
"userAgent": "curl/7.64.1",
39+
"clientCert": {
40+
"clientCertPem": "-----BEGIN CERTIFICATE-----...",
41+
"subjectDN": "CN=ndrclient.main.int.pdm.national.nhs.uk,O=NHS,C=UK",
42+
"issuerDN": "CN=NHS Root CA,O=NHS,C=UK",
43+
"serialNumber": "12:34:56",
44+
"validity": {
45+
"notBefore": "May 10 00:00:00 2024 GMT",
46+
"notAfter": "May 10 00:00:00 2025 GMT",
47+
},
48+
},
49+
},
50+
},
51+
}
52+
53+
54+
@pytest.mark.parametrize(
55+
"function_name, mock_event",
56+
[
57+
("GetDocumentReference", MOCK_CIS2_VALID_EVENT),
58+
("SearchDocumentReferencesFHIR", MOCK_CIS2_VALID_EVENT),
59+
("foobar", MOCK_CIS2_VALID_EVENT),
60+
],
61+
)
62+
def test_extract_bearer_token_happy_paths(context, function_name, mock_event):
63+
context.function_name = function_name
64+
token = extract_bearer_token(mock_event, context)
65+
assert token == f"Bearer {TEST_UUID}"
66+
67+
68+
def test_extract_bearer_token_when_pdm(context):
69+
token = extract_bearer_token(MOCK_MTLS_VALID_EVENT, context)
70+
assert token is None
71+
72+
73+
@pytest.mark.parametrize(
74+
"function_name, error_type, headers",
75+
[
76+
(
77+
"GetDocumentReference",
78+
GetFhirDocumentReferenceException,
79+
{"headers": {}},
80+
),
81+
(
82+
"SearchDocumentReferencesFHIR",
83+
DocumentRefSearchException,
84+
{"headers": {}},
85+
),
86+
("foobar", DocumentRefException, {"headers": {}}),
87+
(
88+
"GetDocumentReference",
89+
GetFhirDocumentReferenceException,
90+
{"headers": {"Authorization": "foo bar"}},
91+
),
92+
(
93+
"SearchDocumentReferencesFHIR",
94+
DocumentRefSearchException,
95+
{"headers": {"Authorization": "foo bar"}},
96+
),
97+
(
98+
"foobar",
99+
DocumentRefException,
100+
{"headers": {"Authorization": "foo bar"}},
101+
),
102+
],
103+
)
104+
def test_extract_problem_bearer_token(context, function_name, error_type, headers):
105+
context.function_name = function_name
106+
event_without_auth = headers
107+
with pytest.raises(error_type) as e:
108+
extract_bearer_token(event_without_auth, context)
109+
assert e.value.status_code == 401
110+
assert e.value.error == LambdaError.DocumentReferenceUnauthorised

0 commit comments

Comments
 (0)