Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2b76e5d
secret handling
Karthikeyannhs Nov 25, 2025
304b89f
Fixed some integration tests.
ayeshalshukri1-nhs Nov 26, 2025
8c3941c
Fixed integration test hashing serivce.
ayeshalshukri1-nhs Nov 26, 2025
5d95435
Stub for hashing service and secret repo.
ayeshalshukri1-nhs Nov 26, 2025
864060a
Fixed integration tests.
ayeshalshukri1-nhs Nov 27, 2025
413471a
Added logic for current and previous hash checks.
ayeshalshukri1-nhs Nov 27, 2025
df1e43d
Added global const for secret
ayeshalshukri1-nhs Nov 27, 2025
7ff5b3f
added previous secret testing.
ayeshalshukri1-nhs Nov 27, 2025
bf06476
Update logic with nhs fallback.
ayeshalshukri1-nhs Nov 28, 2025
78ab8a8
Fixed linting.
ayeshalshukri1-nhs Nov 28, 2025
b0e283a
pyright errors fixed.
ayeshalshukri1-nhs Nov 28, 2025
86f54ce
more info on error message.
ayeshalshukri1-nhs Nov 28, 2025
8b1f6ca
Added extra tests for person repo function.
ayeshalshukri1-nhs Nov 28, 2025
22419f3
Added tests and linting.
ayeshalshukri1-nhs Nov 28, 2025
54b1c6d
Added hashing service tests.
ayeshalshukri1-nhs Nov 28, 2025
9fef927
Added testing and linting.
ayeshalshukri1-nhs Nov 28, 2025
197c4e2
refined tests.
ayeshalshukri1-nhs Nov 28, 2025
24d50ef
Updated logic as per PR feedback.
ayeshalshukri1-nhs Dec 1, 2025
7195bba
Refactored integration tests hashing service factory.
ayeshalshukri1-nhs Dec 2, 2025
fa673ab
Refactored persisted person factory.
ayeshalshukri1-nhs Dec 2, 2025
b5078bc
Added parma test for scenarios.
ayeshalshukri1-nhs Dec 2, 2025
4f5b3bc
Updated scenarios for key tests.
ayeshalshukri1-nhs Dec 3, 2025
29d88e9
updated description.
ayeshalshukri1-nhs Dec 3, 2025
fd58f83
linting.
ayeshalshukri1-nhs Dec 3, 2025
5a93bfd
Added docstring for secret key scenario tests
robbailiff2 Dec 3, 2025
6eabe99
Added more person repo test scenarios and updated docstring
robbailiff2 Dec 4, 2025
8185d0f
Merge branch 'main' into feature/eli-540-lambda-to-support-hashed-nhsnum
robbailiff2 Dec 4, 2025
a9ea05b
Merge branch 'main' into feature/eli-540-lambda-to-support-hashed-nhsnum
ayeshalshukri1-nhs Dec 8, 2025
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
6 changes: 6 additions & 0 deletions src/eligibility_signposting_api/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from yarl import URL

from eligibility_signposting_api.processors.hashing_service import HashSecretName
from eligibility_signposting_api.repos.campaign_repo import BucketName
from eligibility_signposting_api.repos.person_repo import TableName

Expand All @@ -22,6 +23,7 @@ def config() -> dict[str, Any]:
person_table_name = TableName(os.getenv("PERSON_TABLE_NAME", "test_eligibility_datastore"))
rules_bucket_name = BucketName(os.getenv("RULES_BUCKET_NAME", "test-rules-bucket"))
audit_bucket_name = BucketName(os.getenv("AUDIT_BUCKET_NAME", "test-audit-bucket"))
hashing_secret_name = HashSecretName(os.getenv("HASHING_SECRET_NAME", "test_secret"))
aws_default_region = AwsRegion(os.getenv("AWS_DEFAULT_REGION", "eu-west-1"))
enable_xray_patching = bool(os.getenv("ENABLE_XRAY_PATCHING", "false"))
kinesis_audit_stream_to_s3 = AwsKinesisFirehoseStreamName(
Expand All @@ -42,6 +44,8 @@ def config() -> dict[str, Any]:
"firehose_endpoint": None,
"kinesis_audit_stream_to_s3": kinesis_audit_stream_to_s3,
"enable_xray_patching": enable_xray_patching,
"secretsmanager_endpoint": None,
"hashing_secret_name": hashing_secret_name,
"log_level": log_level,
}

Expand All @@ -58,5 +62,7 @@ def config() -> dict[str, Any]:
"firehose_endpoint": URL(os.getenv("FIREHOSE_ENDPOINT", local_stack_endpoint)),
"kinesis_audit_stream_to_s3": kinesis_audit_stream_to_s3,
"enable_xray_patching": enable_xray_patching,
"secretsmanager_endpoint": URL(os.getenv("SECRET_MANAGER_ENDPOINT", local_stack_endpoint)),
"hashing_secret_name": hashing_secret_name,
"log_level": log_level,
}
Empty file.
42 changes: 42 additions & 0 deletions src/eligibility_signposting_api/processors/hashing_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import hashlib
import hmac
from typing import Annotated, NewType

from wireup import Inject, service

from eligibility_signposting_api.repos.secret_repo import SecretRepo

HashSecretName = NewType("HashSecretName", str)


def _hash(nhs_number: str, secret_value: str | None) -> str | None:
if not secret_value:
return None

nhs_str = str(nhs_number)

return hmac.new(
secret_value.encode("utf-8"),
nhs_str.encode("utf-8"),
hashlib.sha512,
).hexdigest()


@service
class HashingService:
def __init__(
self,
secret_repo: Annotated[SecretRepo, Inject()],
hash_secret_name: Annotated[HashSecretName, Inject(param="hashing_secret_name")],
) -> None:
super().__init__()
self.secret_repo = secret_repo
self.hash_secret_name = hash_secret_name

def hash_with_current_secret(self, nhs_number: str) -> str | None:
secret_value = self.secret_repo.get_secret_current(self.hash_secret_name).get("AWSCURRENT")
return _hash(nhs_number, secret_value)

def hash_with_previous_secret(self, nhs_number: str) -> str | None:
secret_value = self.secret_repo.get_secret_previous(self.hash_secret_name).get("AWSPREVIOUS")
return _hash(nhs_number, secret_value)
3 changes: 2 additions & 1 deletion src/eligibility_signposting_api/repos/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from .campaign_repo import CampaignRepo
from .exceptions import NotFoundError
from .person_repo import PersonRepo
from .secret_repo import SecretRepo

__all__ = ["CampaignRepo", "NotFoundError", "PersonRepo"]
__all__ = ["CampaignRepo", "NotFoundError", "PersonRepo", "SecretRepo"]
14 changes: 14 additions & 0 deletions src/eligibility_signposting_api/repos/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,17 @@ def firehose_client_factory(
) -> BaseClient:
endpoint_url = str(firehose_endpoint) if firehose_endpoint is not None else None
return session.client("firehose", endpoint_url=endpoint_url)


@service(qualifier="secretsmanager")
def secretsmanager_client_factory(
session: Session,
secretsmanager_endpoint: Annotated[URL, Inject(param="secretsmanager_endpoint")],
aws_default_region: Annotated[AwsRegion, Inject(param="aws_default_region")],
) -> BaseClient:
endpoint_url = str(secretsmanager_endpoint) if secretsmanager_endpoint is not None else None
return session.client(
service_name="secretsmanager",
endpoint_url=endpoint_url,
region_name=aws_default_region,
)
48 changes: 41 additions & 7 deletions src/eligibility_signposting_api/repos/person_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from eligibility_signposting_api.model.eligibility_status import NHSNumber
from eligibility_signposting_api.model.person import Person
from eligibility_signposting_api.processors.hashing_service import HashingService
from eligibility_signposting_api.repos.exceptions import NotFoundError

logger = logging.getLogger(__name__)
Expand All @@ -32,17 +33,50 @@ class PersonRepo:
This data is held in a handful of records in a single Dynamodb table.
"""

def __init__(self, table: Annotated[Any, Inject(qualifier="person_table")]) -> None:
def __init__(
self,
table: Annotated[Any, Inject(qualifier="person_table")],
hashing_service: Annotated[HashingService, Inject()],
) -> None:
super().__init__()
self.table = table
self._hashing_service = hashing_service

def get_person_record(self, nhs_hash: str | None) -> Any:
if nhs_hash:
response = self.table.query(KeyConditionExpression=Key("NHS_NUMBER").eq(nhs_hash))

items = response.get("Items", [])
has_person = any(item.get("ATTRIBUTE_TYPE") == "PERSON" for item in items)

if has_person:
return items

return None

def get_eligibility_data(self, nhs_number: NHSNumber) -> Person:
response = self.table.query(KeyConditionExpression=Key("NHS_NUMBER").eq(nhs_number))
# AWSCURRENT secret
nhs_hash = self._hashing_service.hash_with_current_secret(nhs_number)
items = self.get_person_record(nhs_hash)

if not items:
logger.error("No person record found for hashed nhs_number using secret AWSCURRENT")

# AWSPREVIOUS secret
nhs_hash = self._hashing_service.hash_with_previous_secret(nhs_number)

if not (items := response.get("Items")) or not next(
(item for item in items if item.get("ATTRIBUTE_TYPE") == "PERSON"), None
):
message = f"Person not found with nhs_number {nhs_number}"
raise NotFoundError(message)
if nhs_hash is not None:
items = self.get_person_record(nhs_hash)
if not items:
logger.error("No person record found for hashed nhs_number using secret AWSPREVIOUS")
message = "Person not found after checking AWSCURRENT and AWSPREVIOUS."
raise NotFoundError(message)
else:
# fallback not hashed NHS number
items = self.get_person_record(nhs_number)
if not items:
logger.error("No person record found for not hashed nhs_number")
message = "Person not found after checking AWSCURRENT, AWSPREVIOUS, and not hashed NHS numbers."
raise NotFoundError(message)

return Person(data=items)
36 changes: 36 additions & 0 deletions src/eligibility_signposting_api/repos/secret_repo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import logging
from typing import Annotated, NewType

from botocore.client import BaseClient
from botocore.exceptions import ClientError
from wireup import Inject, service

logger = logging.getLogger(__name__)

SecretName = NewType("SecretName", str)


@service
class SecretRepo:
def __init__(self, secret_manager: Annotated[BaseClient, Inject(qualifier="secretsmanager")]) -> None:
super().__init__()
self.secret_manager = secret_manager

def _get_secret_by_stage(self, secret_name: str, stage: str) -> dict[str, str]:
"""Internal helper to fetch a secret by version stage."""
try:
response = self.secret_manager.get_secret_value(
SecretId=secret_name,
VersionStage=stage,
)
return {stage: response["SecretString"]}

except ClientError:
logger.exception("Failed to get secret %s at stage %s", secret_name, stage)
return {}

def get_secret_current(self, secret_name: str) -> dict[str, str]:
return self._get_secret_by_stage(secret_name, "AWSCURRENT")

def get_secret_previous(self, secret_name: str) -> dict[str, str]:
return self._get_secret_by_stage(secret_name, "AWSPREVIOUS")
Loading
Loading