Skip to content

Commit 2b76e5d

Browse files
secret handling
1 parent be5a733 commit 2b76e5d

File tree

9 files changed

+149
-6
lines changed

9 files changed

+149
-6
lines changed

src/eligibility_signposting_api/config/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from yarl import URL
77

8+
from eligibility_signposting_api.processors.hashing_service import HashSecretName
89
from eligibility_signposting_api.repos.campaign_repo import BucketName
910
from eligibility_signposting_api.repos.person_repo import TableName
1011

@@ -22,6 +23,7 @@ def config() -> dict[str, Any]:
2223
person_table_name = TableName(os.getenv("PERSON_TABLE_NAME", "test_eligibility_datastore"))
2324
rules_bucket_name = BucketName(os.getenv("RULES_BUCKET_NAME", "test-rules-bucket"))
2425
audit_bucket_name = BucketName(os.getenv("AUDIT_BUCKET_NAME", "test-audit-bucket"))
26+
hashing_secret_name = HashSecretName(os.getenv("HASHING_SECRET_NAME", "test_secret"))
2527
aws_default_region = AwsRegion(os.getenv("AWS_DEFAULT_REGION", "eu-west-1"))
2628
enable_xray_patching = bool(os.getenv("ENABLE_XRAY_PATCHING", "false"))
2729
kinesis_audit_stream_to_s3 = AwsKinesisFirehoseStreamName(
@@ -42,6 +44,8 @@ def config() -> dict[str, Any]:
4244
"firehose_endpoint": None,
4345
"kinesis_audit_stream_to_s3": kinesis_audit_stream_to_s3,
4446
"enable_xray_patching": enable_xray_patching,
47+
"secretsmanager_endpoint": None,
48+
"hashing_secret_name": hashing_secret_name,
4549
"log_level": log_level,
4650
}
4751

@@ -58,5 +62,7 @@ def config() -> dict[str, Any]:
5862
"firehose_endpoint": URL(os.getenv("FIREHOSE_ENDPOINT", local_stack_endpoint)),
5963
"kinesis_audit_stream_to_s3": kinesis_audit_stream_to_s3,
6064
"enable_xray_patching": enable_xray_patching,
65+
"secretsmanager_endpoint": URL(os.getenv("SECRET_MANAGER_ENDPOINT", local_stack_endpoint)),
66+
"hashing_secret_name": hashing_secret_name,
6167
"log_level": log_level,
6268
}

src/eligibility_signposting_api/processors/__init__.py

Whitespace-only changes.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import hashlib
2+
from typing import Annotated, NewType
3+
4+
from wireup import service, Inject
5+
6+
from eligibility_signposting_api.repos.secret_repo import SecretRepo
7+
8+
HashSecretName = NewType("HashSecretName", str)
9+
10+
11+
def _hash(nhs_number: str, secret_value: str) -> str:
12+
"""Internal helper to hash NHS number with a given secret value."""
13+
combined = f"{nhs_number}{secret_value}"
14+
return hashlib.sha256(combined.encode("utf-8")).hexdigest()
15+
16+
17+
@service
18+
class HashingService:
19+
def __init__(
20+
self,
21+
secret_repo: Annotated[SecretRepo, Inject()],
22+
hash_secret_name: Annotated[HashSecretName, Inject(param="hashing_secret_name")],
23+
) -> None:
24+
super().__init__()
25+
self.secret_repo = secret_repo
26+
self.hash_secret_name = hash_secret_name
27+
28+
def hash_with_current_secret(self, nhs_number: str) -> str:
29+
secret_value = self.secret_repo.get_secret_current(self.hash_secret_name)["AWSCURRENT"]
30+
return _hash(nhs_number, secret_value)
31+
32+
def hash_with_previous_secret(self, nhs_number: str) -> str:
33+
secret_value = self.secret_repo.get_secret_previous(self.hash_secret_name)["AWSPREVIOUS"]
34+
return _hash(nhs_number, secret_value)
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from .campaign_repo import CampaignRepo
22
from .exceptions import NotFoundError
33
from .person_repo import PersonRepo
4+
from .secret_repo import SecretRepo
45

5-
__all__ = ["CampaignRepo", "NotFoundError", "PersonRepo"]
6+
__all__ = ["CampaignRepo", "NotFoundError", "PersonRepo", "SecretRepo"]

src/eligibility_signposting_api/repos/factory.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,17 @@ def firehose_client_factory(
4343
) -> BaseClient:
4444
endpoint_url = str(firehose_endpoint) if firehose_endpoint is not None else None
4545
return session.client("firehose", endpoint_url=endpoint_url)
46+
47+
48+
@service(qualifier="secretsmanager")
49+
def secretsmanager_client_factory(
50+
session: Session,
51+
secretsmanager_endpoint: Annotated[URL, Inject(param="secretsmanager_endpoint")],
52+
aws_default_region: Annotated[AwsRegion, Inject(param="aws_default_region")],
53+
) -> BaseClient:
54+
endpoint_url = str(secretsmanager_endpoint) if secretsmanager_endpoint is not None else None
55+
return session.client(
56+
service_name="secretsmanager",
57+
endpoint_url=endpoint_url,
58+
region_name=aws_default_region,
59+
)

src/eligibility_signposting_api/repos/person_repo.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from eligibility_signposting_api.model.eligibility_status import NHSNumber
99
from eligibility_signposting_api.model.person import Person
10+
from eligibility_signposting_api.processors.hashing_service import HashingService
1011
from eligibility_signposting_api.repos.exceptions import NotFoundError
1112

1213
logger = logging.getLogger(__name__)
@@ -32,17 +33,26 @@ class PersonRepo:
3233
This data is held in a handful of records in a single Dynamodb table.
3334
"""
3435

35-
def __init__(self, table: Annotated[Any, Inject(qualifier="person_table")]) -> None:
36+
def __init__(self, table: Annotated[Any, Inject(qualifier="person_table")],
37+
hashing_service: Annotated[HashingService, Inject()]) -> None:
3638
super().__init__()
3739
self.table = table
40+
self._hashing_service = hashing_service
3841

3942
def get_eligibility_data(self, nhs_number: NHSNumber) -> Person:
40-
response = self.table.query(KeyConditionExpression=Key("NHS_NUMBER").eq(nhs_number))
43+
nhs_hash = self._hashing_service.hash_with_current_secret(nhs_number)
44+
response = self.table.query(KeyConditionExpression=Key("NHS_NUMBER").eq(nhs_hash))
4145

4246
if not (items := response.get("Items")) or not next(
4347
(item for item in items if item.get("ATTRIBUTE_TYPE") == "PERSON"), None
4448
):
45-
message = f"Person not found with nhs_number {nhs_number}"
46-
raise NotFoundError(message)
49+
nhs_hash = self._hashing_service.hash_with_previous_secret(nhs_number)
50+
response = self.table.query(KeyConditionExpression=Key("NHS_NUMBER").eq(nhs_hash))
51+
52+
if not (items := response.get("Items")) or not next(
53+
(item for item in items if item.get("ATTRIBUTE_TYPE") == "PERSON"), None
54+
):
55+
message = f"Person not found with nhs_number {nhs_number}"
56+
raise NotFoundError(message)
4757

4858
return Person(data=items)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import logging
2+
from typing import Annotated, Any, NewType
3+
4+
from botocore.client import BaseClient
5+
from botocore.exceptions import ClientError
6+
from wireup import Inject, service
7+
8+
logger = logging.getLogger(__name__)
9+
10+
SecretName = NewType("SecretName", str)
11+
12+
13+
@service
14+
class SecretRepo:
15+
def __init__(
16+
self,
17+
secret_manager: Annotated[BaseClient, Inject(qualifier="secretsmanager")]
18+
) -> None:
19+
super().__init__()
20+
self.secret_manager = secret_manager
21+
22+
def _get_secret_by_stage(self, secret_name: str, stage: str) -> dict[str, str]:
23+
"""Internal helper to fetch a secret by version stage."""
24+
try:
25+
response = self.secret_manager.get_secret_value(
26+
SecretId=secret_name,
27+
VersionStage=stage,
28+
)
29+
return {stage: response["SecretString"]}
30+
except ClientError as e:
31+
logger.error("Failed to get secret %s at stage %s: %s", secret_name, stage, e)
32+
raise
33+
34+
def get_secret_current(self, secret_name: str) -> dict[str, str]:
35+
"""Fetch the AWSCURRENT version of the secret."""
36+
return self._get_secret_by_stage(secret_name, "AWSCURRENT")
37+
38+
def get_secret_previous(self, secret_name: str) -> dict[str, str]:
39+
"""Fetch the AWSPREVIOUS version of the secret."""
40+
return self._get_secret_by_stage(secret_name, "AWSPREVIOUS")
41+

tests/integration/conftest.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,40 @@ def s3_client(boto3_session: Session, localstack: URL) -> BaseClient:
114114
def firehose_client(boto3_session: Session, localstack: URL) -> BaseClient:
115115
return boto3_session.client("firehose", endpoint_url=str(localstack))
116116

117+
@pytest.fixture(scope="session")
118+
def secretsmanager_client(boto3_session: Session, localstack: URL) -> BaseClient:
119+
"""
120+
Provides a boto3 Secrets Manager client bound to LocalStack.
121+
Seeds a test secret for use in integration tests.
122+
"""
123+
client:BaseClient = boto3_session.client(
124+
service_name="secretsmanager",
125+
endpoint_url=str(localstack),
126+
region_name="eu-west-1"
127+
)
128+
129+
secret_name = "test_secret"
130+
secret_value = "test_value_old"
131+
132+
try:
133+
client.create_secret(
134+
Name=secret_name,
135+
SecretString=secret_value,
136+
)
137+
except client.exceptions.ResourceExistsException:
138+
client.put_secret_value(
139+
SecretId=secret_name,
140+
SecretString=secret_value,
141+
)
142+
143+
secret_name = "test_secret"
144+
secret_value = "test_value"
145+
146+
client.put_secret_value(
147+
SecretId=secret_name,
148+
SecretString=secret_value,
149+
)
150+
return client
117151

118152
@pytest.fixture(scope="session")
119153
def iam_role(iam_client: BaseClient) -> Generator[str]:
@@ -209,6 +243,7 @@ def flask_function(lambda_client: BaseClient, iam_role: str, lambda_zip: Path) -
209243
"DYNAMODB_ENDPOINT": os.getenv("LOCALSTACK_INTERNAL_ENDPOINT", "http://localstack:4566/"),
210244
"S3_ENDPOINT": os.getenv("LOCALSTACK_INTERNAL_ENDPOINT", "http://localstack:4566/"),
211245
"FIREHOSE_ENDPOINT": os.getenv("LOCALSTACK_INTERNAL_ENDPOINT", "http://localstack:4566/"),
246+
"SECRET_MANAGER_ENDPOINT": os.getenv("LOCALSTACK_INTERNAL_ENDPOINT", "http://localstack:4566/"),
212247
"AWS_REGION": AWS_REGION,
213248
"LOG_LEVEL": "DEBUG",
214249
}

tests/integration/in_process/test_eligibility_endpoint.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from http import HTTPStatus
22

3+
from botocore.client import BaseClient
34
from brunns.matchers.data import json_matching as is_json_that
45
from brunns.matchers.werkzeug import is_werkzeug_response as is_response
56
from flask.testing import FlaskClient
@@ -23,7 +24,8 @@ def test_nhs_number_given(
2324
self,
2425
client: FlaskClient,
2526
persisted_person: NHSNumber,
26-
campaign_config: CampaignConfig, # noqa: ARG002
27+
campaign_config: CampaignConfig,
28+
secretsmanager_client: BaseClient # noqa: ARG002
2729
):
2830
# Given
2931
headers = {"nhs-login-nhs-number": str(persisted_person)}

0 commit comments

Comments
 (0)