Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .github/workflows/preview-env.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,11 @@ jobs:
APIM_API_KEY_NAME=$API_KEY, \
APIM_MTLS_CERT_NAME=$MTLS_CERT, \
APIM_MTLS_KEY_NAME=$MTLS_KEY, \
APIM_TOKEN_URL=$MOCK_URL/apim, \
PDM_BUNDLE_URL=$MOCK_URL/pdm, \
APIM_KEY_ID=DEV-1, \
APIM_TOKEN_URL=$MOCK_URL/apim/oauth2/token, \
PDM_BUNDLE_URL=$MOCK_URL/apim/check_auth, \
MNS_EVENT_URL=$MOCK_URL/mns, \
CLIENT_TIMEOUT=1m, \
JWKS_SECRET_NAME=$JWKS_SECRET}" || true
wait_for_lambda_ready
aws lambda update-function-code --function-name "$FN" \
Expand All @@ -176,11 +178,13 @@ jobs:
--environment "Variables={APIM_TOKEN_EXPIRY_THRESHOLD=$EXPIRY_THRESHOLD, \
APIM_PRIVATE_KEY_NAME=$PRIVATE_KEY, \
APIM_API_KEY_NAME=$API_KEY, \
APIM_KEY_ID=DEV-1, \
APIM_MTLS_CERT_NAME=$MTLS_CERT, \
APIM_MTLS_KEY_NAME=$MTLS_KEY, \
APIM_TOKEN_URL=$MOCK_URL/apim, \
PDM_BUNDLE_URL=$MOCK_URL/pdm, \
APIM_TOKEN_URL=$MOCK_URL/apim/oauth2/token, \
PDM_BUNDLE_URL=$MOCK_URL/apim/check_auth, \
MNS_EVENT_URL=$MOCK_URL/mns, \
CLIENT_TIMEOUT=1m, \
JWKS_SECRET_NAME=$JWKS_SECRET}" \
--publish
wait_for_lambda_ready
Expand Down
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@
"gitlens.ai.enabled": false,
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"python.testing.pytestArgs": [
"pathology-api",
"mocks"
],
"git.enableCommitSigning": true,
"sonarlint.connectedMode.project": {
"connectionId": "nhsdigital",
Expand Down
1 change: 0 additions & 1 deletion pathology-api/lambda_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
from pathology_api.logging import get_logger

_logger = get_logger(__name__)

app = APIGatewayHttpResolver()

type _ExceptionHandler[T: Exception] = Callable[[T], Response[str]]
Expand Down
190 changes: 126 additions & 64 deletions pathology-api/poetry.lock

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions pathology-api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ readme = "README.md"
requires-python = ">3.13,<4.0.0"
dependencies = [
"aws-lambda-powertools (>=3.24.0,<4.0.0)",
"pydantic (>=2.12.5,<3.0.0)"
"pydantic (>=2.12.5,<3.0.0)",
"pyjwt[crypto] (>=2.11.0,<3.0.0)",
"requests>=2.31.0",
"boto3 (>=1.42.64,<2.0.0)"
]

[tool.poetry]
Expand Down Expand Up @@ -47,7 +50,6 @@ dev = [
"pytest-cov (>=7.0.0,<8.0.0)",
"pytest-html (>=4.1.1,<5.0.0)",
"pact-python>=2.0.0",
"requests>=2.31.0",
"schemathesis>=4.4.1",
"types-requests (>=2.32.4.20250913,<3.0.0.0)",
"types-pyyaml (>=6.0.12.20250915,<7.0.0.0)",
Expand Down
155 changes: 155 additions & 0 deletions pathology-api/src/pathology_api/apim.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import functools
import uuid
from collections.abc import Callable
from datetime import datetime, timedelta, timezone
from typing import Any, TypedDict

import jwt
import requests

from pathology_api.http import RequestMethod, SessionManager
from pathology_api.logging import get_logger

_logger = get_logger(__name__)


class ApimAuthenticationException(Exception):
pass


class ApimAuthenticator:
class __AccessToken(TypedDict):
value: str
expiry: datetime

def __init__(
self,
private_key: str,
key_id: str,
api_key: str,
token_validity_threshold: timedelta,
token_endpoint: str,
session_manager: SessionManager,
):
self._private_key = private_key
self._key_id = key_id
self._api_key = api_key
self._token_validity_threshold = token_validity_threshold
self._token_endpoint = token_endpoint
self._session_manager = session_manager

self.__access_token: ApimAuthenticator.__AccessToken | None = None

def auth[**P, S](self, func: RequestMethod[P, S]) -> Callable[P, S]:
"""
Decorate a given function with APIM authentication. This authentication will be
provided via a `requests.Session` object.
"""

@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
@self._session_manager.with_session
def with_session(
session: requests.Session, access_token: ApimAuthenticator.__AccessToken
) -> S:
session.headers.update(
{"Authorization": f"Bearer {access_token['value']}"}
)
return func(session, *args, **kwargs)

# If there isn't an access token yet, or the token will expire within the
# token validity threshold, reauthenticate.
if (
self.__access_token is None
or self.__access_token["expiry"] - datetime.now(tz=timezone.utc)
< self._token_validity_threshold
):
_logger.debug("Authenticating with APIM...")
self.__access_token = self._authenticate()

return with_session(self.__access_token)

return wrapper

def _create_client_assertion(self) -> str:
_logger.debug("Creating client assertion JWT for APIM authentication")
claims = {
"sub": self._api_key,
"iss": self._api_key,
"jti": str(uuid.uuid4()),
"aud": self._token_endpoint,
"exp": int(
(datetime.now(tz=timezone.utc) + timedelta(seconds=30)).timestamp()
),
}
_logger.debug(
"Created client assertion. jti: %s, exp: %s, aud: %s",
claims["jti"],
claims["exp"],
claims["aud"],
)

try:
client_assertion = jwt.encode(
claims,
self._private_key,
algorithm="RS512",
headers={"kid": self._key_id},
)

_logger.debug("Created client assertion. kid: %s", self._key_id)

return client_assertion
except BaseException:
_logger.exception("Failed to create client assertion JWT")
raise

def _authenticate(self) -> __AccessToken:
@self._session_manager.with_session
def with_session(session: requests.Session) -> ApimAuthenticator.__AccessToken:
client_assertion = self._create_client_assertion()

_logger.debug("Sending token request with created session.")

try:
response = session.post(
self._token_endpoint,
data={
"grant_type": "client_credentials",
"client_assertion_type": "urn:ietf:params:oauth"
":client-assertion-type:jwt-bearer",
"client_assertion": client_assertion,
},
)
except BaseException:
_logger.exception("Failed to send authentication request to APIM")
raise

_logger.debug(
"Response received from APIM token endpoint. Status code: %s",
response.status_code,
)

if response.status_code != 200:
raise ApimAuthenticationException(
f"Failed to authenticate with APIM. "
f"Status code: {response.status_code}"
f", Response: {response.text}"
)

response_data = response.json()
_logger.debug(
"APIM authentication successful. Expiry: %s",
response_data["expires_in"],
)

return {
"value": response_data["access_token"],
"expiry": datetime.now(tz=timezone.utc)
+ timedelta(seconds=response_data["expires_in"]),
}

_logger.debug(
"Sending authentication request to APIM: %s", self._token_endpoint
)
return with_session()
66 changes: 66 additions & 0 deletions pathology-api/src/pathology_api/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import os
import re
from dataclasses import dataclass
from datetime import timedelta
from enum import StrEnum
from typing import cast


class ConfigError(Exception):
pass


class DurationUnit(StrEnum):
SECONDS = "s"
MINUTES = "m"


@dataclass(frozen=True)
class Duration:
unit: DurationUnit
value: int

@property
def timedelta(self) -> timedelta:
match self.unit:
case DurationUnit.SECONDS:
return timedelta(seconds=self.value)
case DurationUnit.MINUTES:
return timedelta(minutes=self.value)


def get_optional_environment_variable[T](name: str, _type: type[T]) -> T | None:
value = os.getenv(name)

if _type is Duration and value is not None:
parsed = re.fullmatch(r"(?P<value>\d+)(?P<unit>[sm])", value)
if parsed is None:
raise ConfigError(f"Invalid duration value: {value!r}")

raw_value = parsed.group("value")
raw_unit = parsed.group("unit")

if not raw_value or not raw_value.isdigit():
raise ConfigError(f"Invalid duration value: {value!r}")

return cast(
"T",
Duration(
unit=DurationUnit(raw_unit),
value=int(raw_value),
),
)
elif value is not None:
if not isinstance(value, _type):
raise ConfigError(f"Environment variable {name!r} is not of type {_type!r}")

return value
else:
return None


def get_environment_variable[T](name: str, _type: type[T]) -> T:
value = get_optional_environment_variable(name=name, _type=_type)
if value is None:
raise ConfigError(f"Environment variable {name!r} is not set")
return value
63 changes: 63 additions & 0 deletions pathology-api/src/pathology_api/handler.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,64 @@
import uuid
from collections.abc import Callable

import requests
from aws_lambda_powertools.utilities import parameters

from pathology_api.apim import ApimAuthenticator
from pathology_api.config import (
Duration,
get_environment_variable,
get_optional_environment_variable,
)
from pathology_api.exception import ValidationError
from pathology_api.fhir.r4.elements import Meta
from pathology_api.fhir.r4.resources import Bundle, Composition
from pathology_api.http import ClientCertificate, SessionManager
from pathology_api.logging import get_logger

_logger = get_logger(__name__)

CLIENT_TIMEOUT = get_environment_variable("CLIENT_TIMEOUT", Duration)

CLIENT_CERTIFICATE_NAME = get_optional_environment_variable("APIM_MTLS_CERT_NAME", str)
CLIENT_KEY_NAME = get_optional_environment_variable("APIM_MTLS_KEY_NAME", str)

APIM_TOKEN_URL = get_environment_variable("APIM_TOKEN_URL", str)
APIM_PRIVATE_KEY_NAME = get_environment_variable("APIM_PRIVATE_KEY_NAME", str)
APIM_API_KEY_NAME = get_environment_variable("APIM_API_KEY_NAME", str)
APIM_TOKEN_EXPIRY_THRESHOLD = get_environment_variable(
"APIM_TOKEN_EXPIRY_THRESHOLD", Duration
)
APIM_KEY_ID = get_environment_variable("APIM_KEY_ID", str)

PDM_URL = get_environment_variable("PDM_BUNDLE_URL", str)

if CLIENT_CERTIFICATE_NAME and CLIENT_KEY_NAME:
certificate = parameters.get_secret(CLIENT_CERTIFICATE_NAME)
key = parameters.get_secret(CLIENT_KEY_NAME)

CLIENT_CERTIFICATE: ClientCertificate | None = {
"certificate": certificate,
"key": key,
}
else:
CLIENT_CERTIFICATE = None


session_manager = SessionManager(
client_timeout=CLIENT_TIMEOUT.timedelta,
client_certificate=CLIENT_CERTIFICATE,
)

apim_authenticator = ApimAuthenticator(
private_key=parameters.get_secret(APIM_PRIVATE_KEY_NAME),
key_id=APIM_KEY_ID,
api_key=parameters.get_secret(APIM_API_KEY_NAME),
token_endpoint=APIM_TOKEN_URL,
token_validity_threshold=APIM_TOKEN_EXPIRY_THRESHOLD.timedelta,
session_manager=session_manager,
)


def _validate_composition(bundle: Bundle) -> None:
compositions = bundle.find_resources(t=Composition)
Expand Down Expand Up @@ -48,4 +99,16 @@ def handle_request(bundle: Bundle) -> Bundle:
)
_logger.debug("Return bundle: %s", return_bundle)

auth_response = _send_request(PDM_URL)
_logger.debug(
"Result of auth request. status_code=%s data=%s",
auth_response.status_code,
auth_response.text,
)

return return_bundle


@apim_authenticator.auth
def _send_request(session: requests.Session, url: str) -> requests.Response:
return session.post(url)
Loading
Loading