Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
09ffcff
ELI-578 consumer_id - campaign_config mapping
Karthikeyannhs Dec 16, 2025
dfcff79
ELI-578 added dummy consumer id
Karthikeyannhs Dec 18, 2025
18736fa
ELI-578 local stack configuration
Karthikeyannhs Dec 18, 2025
b9fe63b
ELI-578 wip test
Karthikeyannhs Dec 18, 2025
bcd1d8d
ELI-578 unit test fixed
Karthikeyannhs Dec 18, 2025
60b4954
ELI-578 consumer_id error validation
Karthikeyannhs Dec 19, 2025
d840389
ELI-578 intergation testing
Karthikeyannhs Dec 19, 2025
b7561a0
ELI-578 lambda
Karthikeyannhs Dec 19, 2025
aefbe33
ELI-578 integration test
Karthikeyannhs Dec 19, 2025
8f4e28a
ELI-578 Integration test to check if consumer has campaign mappings
Karthikeyannhs Dec 22, 2025
4afb122
ELI-578 Unit tests
Karthikeyannhs Dec 22, 2025
2dacd5e
added test : customer requesting for campaign that is not mapped
Karthikeyannhs Jan 2, 2026
72f6d0c
consumer with no campaign mapping is valid
Karthikeyannhs Jan 5, 2026
bcee4b1
ELI-578 consumer_id - campaign_config mapping
Karthikeyannhs Dec 16, 2025
6029398
ELI-578 added dummy consumer id
Karthikeyannhs Dec 18, 2025
714433d
ELI-578 local stack configuration
Karthikeyannhs Dec 18, 2025
fcc68be
ELI-578 wip test
Karthikeyannhs Dec 18, 2025
510df8d
ELI-578 unit test fixed
Karthikeyannhs Dec 18, 2025
91a2a09
ELI-578 consumer_id error validation
Karthikeyannhs Dec 19, 2025
4089abc
ELI-578 intergation testing
Karthikeyannhs Dec 19, 2025
1279f2d
ELI-578 lambda
Karthikeyannhs Dec 19, 2025
425f6e7
ELI-578 integration test
Karthikeyannhs Dec 19, 2025
3b58fc9
ELI-578 Integration test to check if consumer has campaign mappings
Karthikeyannhs Dec 22, 2025
7812be0
ELI-578 Unit tests
Karthikeyannhs Dec 22, 2025
569dc5f
added test : customer requesting for campaign that is not mapped
Karthikeyannhs Jan 2, 2026
fbb902d
consumer with no campaign mapping is valid
Karthikeyannhs Jan 5, 2026
d30b4df
revert
Karthikeyannhs Jan 5, 2026
6591256
Merge remote-tracking branch 'origin/ELI-578/consumer-campaign-config…
Karthikeyannhs Jan 5, 2026
bca6215
linting
Karthikeyannhs Jan 5, 2026
5160d98
Merge branch 'main' into ELI-578/consumer-campaign-config-mappings
ayeshalshukri1-nhs Jan 6, 2026
92dd24e
removed unused code
Karthikeyannhs Jan 6, 2026
f25e29e
added CONSUMER_ID_NOT_PROVIDED_ERROR back
Karthikeyannhs Jan 6, 2026
80704fe
more test cases
Karthikeyannhs Jan 6, 2026
5407820
hardcodes values are converted to fixtures
Karthikeyannhs Jan 6, 2026
ca0aef8
fixtures
Karthikeyannhs Jan 6, 2026
3f30502
fixtures
Karthikeyannhs Jan 6, 2026
7751ae8
linting
Karthikeyannhs Jan 6, 2026
b6b9242
Merge branch 'main' into ELI-578/consumer-campaign-config-mappings
ayeshalshukri1-nhs Jan 7, 2026
4fea2dc
more scenarios
Karthikeyannhs Jan 7, 2026
34827d1
added consumer-id header to the requests in unit tests
Karthikeyannhs Jan 7, 2026
3d1b970
linting
Karthikeyannhs Jan 7, 2026
a14db73
Added vacc request placeholder in tests.
ayeshalshukri1-nhs Jan 7, 2026
1b10f85
fixed linting.
ayeshalshukri1-nhs Jan 7, 2026
45522f8
added sample consumer-mapping file
Karthikeyannhs Jan 12, 2026
c507ee2
Merge branch 'main' into ELI-578/consumer-campaign-config-mappings
Karthikeyannhs Jan 12, 2026
926edc8
test_valid_response_when_consumer_has_a_valid_campaign_config_mapping…
Karthikeyannhs Jan 12, 2026
cbbae6f
multiple campaigns for same target
Karthikeyannhs Jan 12, 2026
f525d01
multiple campaigns for same target
Karthikeyannhs Jan 12, 2026
fa80df6
consumer config structure modification
Karthikeyannhs Jan 13, 2026
dbef36c
Merge branch 'main' into ELI-578/consumer-campaign-config-mappings
Karthikeyannhs Jan 13, 2026
82ee079
lint fix
Karthikeyannhs Jan 13, 2026
2045e08
consumer mapping - schema
Karthikeyannhs Jan 13, 2026
5fff049
Terraform consumer mapping bucket
Karthikeyannhs Jan 13, 2026
bcadb02
Terraform consumer mapping bucket policy
Karthikeyannhs Jan 13, 2026
6eaca30
more linting
Karthikeyannhs Jan 13, 2026
a1ad018
fix s3
Karthikeyannhs Jan 13, 2026
6c70813
Merge branch 'main' into ELI-578/consumer-campaign-config-mappings
Karthikeyannhs Jan 13, 2026
3d6b127
fix s3
Karthikeyannhs Jan 13, 2026
8afefa9
revert to S3ReadAccess
Karthikeyannhs Jan 13, 2026
34073c6
iam permissions
Karthikeyannhs Jan 13, 2026
bd8b5c2
more tests
Karthikeyannhs Jan 14, 2026
0e6c784
name correction
Karthikeyannhs Jan 14, 2026
fe9a2da
one more test scenario
Karthikeyannhs Jan 14, 2026
e7d8862
one more test scenario
Karthikeyannhs Jan 14, 2026
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
17 changes: 9 additions & 8 deletions infrastructure/modules/lambda/lambda.tf
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ resource "aws_lambda_function" "eligibility_signposting_lambda" {

environment {
variables = {
PERSON_TABLE_NAME = var.eligibility_status_table_name,
RULES_BUCKET_NAME = var.eligibility_rules_bucket_name,
KINESIS_AUDIT_STREAM_TO_S3 = var.kinesis_audit_stream_to_s3_name
ENV = var.environment
LOG_LEVEL = var.log_level
ENABLE_XRAY_PATCHING = var.enable_xray_patching
API_DOMAIN_NAME = var.api_domain_name
HASHING_SECRET_NAME = var.hashing_secret_name
PERSON_TABLE_NAME = var.eligibility_status_table_name,
RULES_BUCKET_NAME = var.eligibility_rules_bucket_name,
CONSUMER_MAPPING_BUCKET_NAME = var.eligibility_consumer_mappings_bucket_name,
KINESIS_AUDIT_STREAM_TO_S3 = var.kinesis_audit_stream_to_s3_name
ENV = var.environment
LOG_LEVEL = var.log_level
ENABLE_XRAY_PATCHING = var.enable_xray_patching
API_DOMAIN_NAME = var.api_domain_name
HASHING_SECRET_NAME = var.hashing_secret_name
}
}

Expand Down
5 changes: 5 additions & 0 deletions infrastructure/modules/lambda/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ variable "eligibility_rules_bucket_name" {
type = string
}

variable "eligibility_consumer_mappings_bucket_name" {
description = "consumer mappings bucket name"
type = string
}

variable "eligibility_status_table_name" {
description = "eligibility datastore table name"
type = string
Expand Down
94 changes: 93 additions & 1 deletion infrastructure/stacks/api-layer/iam_policies.tf
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,60 @@
}
}

# Policy doc for S3 Consumer Mappings bucket
data "aws_iam_policy_document" "s3_consumer_mapping_bucket_policy" {
statement {
sid = "AllowSSLRequestsOnly"
actions = [
"s3:GetObject",
"s3:ListBucket",
]
resources = [
module.s3_consumer_mappings_bucket.storage_bucket_arn,
"${module.s3_consumer_mappings_bucket.storage_bucket_arn}/*",
]
condition {
test = "Bool"
values = ["true"]
variable = "aws:SecureTransport"
}
}
}

# ensure only secure transport is allowed

resource "aws_s3_bucket_policy" "consumer_mapping_s3_bucket" {
bucket = module.s3_consumer_mappings_bucket.storage_bucket_id
policy = data.aws_iam_policy_document.consumer_mapping_s3_bucket_policy.json
}

data "aws_iam_policy_document" "consumer_mapping_s3_bucket_policy" {
statement {
sid = "AllowSslRequestsOnly"
actions = [
"s3:*",
]
effect = "Deny"
resources = [
module.s3_consumer_mappings_bucket.storage_bucket_arn,
"${module.s3_consumer_mappings_bucket.storage_bucket_arn}/*",
]
principals {
type = "*"
identifiers = ["*"]
}
condition {
test = "Bool"
values = [
"false",
]

variable = "aws:SecureTransport"
}
}
}

# audit bucket
resource "aws_s3_bucket_policy" "audit_s3_bucket" {
bucket = module.s3_audit_bucket.storage_bucket_id
policy = data.aws_iam_policy_document.audit_s3_bucket_policy.json
Expand Down Expand Up @@ -136,12 +190,18 @@
}

# Attach s3 read policy to Lambda role
resource "aws_iam_role_policy" "lambda_s3_read_policy" {
resource "aws_iam_role_policy" "lambda_s3_rules_read_policy" {
name = "S3ReadAccess"
role = aws_iam_role.eligibility_lambda_role.id
policy = data.aws_iam_policy_document.s3_rules_bucket_policy.json
}

resource "aws_iam_role_policy" "lambda_s3_mapping_read_policy" {
name = "S3ConsumerMappingReadAccess"
role = aws_iam_role.eligibility_lambda_role.id
policy = data.aws_iam_policy_document.s3_consumer_mapping_bucket_policy.json
}

# Attach s3 write policy to kinesis firehose role
resource "aws_iam_role_policy" "kinesis_firehose_s3_write_policy" {
name = "S3WriteAccess"
Expand Down Expand Up @@ -290,6 +350,38 @@
policy = data.aws_iam_policy_document.s3_rules_kms_key_policy.json
}

data "aws_iam_policy_document" "s3_consumer_mapping_kms_key_policy" {
#checkov:skip=CKV_AWS_111: Root user needs full KMS key management
#checkov:skip=CKV_AWS_356: Root user needs full KMS key management
#checkov:skip=CKV_AWS_109: Root user needs full KMS key management
statement {
sid = "EnableIamUserPermissions"
effect = "Allow"
principals {
type = "AWS"
identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"]
}
actions = ["kms:*"]
resources = ["*"]
}

statement {
sid = "AllowLambdaDecrypt"
effect = "Allow"
principals {
type = "AWS"
identifiers = [aws_iam_role.eligibility_lambda_role.arn]
}
actions = ["kms:Decrypt"]
resources = ["*"]
}
}
Comment on lines +353 to +378

Check warning

Code scanning / checkov

Ensure IAM policies does not allow permissions management / resource exposure without constraints Warning

Ensure IAM policies does not allow permissions management / resource exposure without constraints
Comment on lines +353 to +378

Check warning

Code scanning / checkov

Ensure no IAM policies documents allow "*" as a statement's resource for restrictable actions Warning

Ensure no IAM policies documents allow "*" as a statement's resource for restrictable actions
Comment on lines +353 to +378

Check warning

Code scanning / checkov

Ensure IAM policies does not allow write access without constraints Warning

Ensure IAM policies does not allow write access without constraints

resource "aws_kms_key_policy" "s3_consumer_mapping_kms_key" {
key_id = module.s3_consumer_mappings_bucket.storage_bucket_kms_key_id
policy = data.aws_iam_policy_document.s3_consumer_mapping_kms_key_policy.json
}

resource "aws_iam_role_policy" "splunk_firehose_policy" {
#checkov:skip=CKV_AWS_290: Firehose requires write access to dynamic log streams without static constraints
#checkov:skip=CKV_AWS_355: Firehose logging requires wildcard resource for CloudWatch log groups/streams
Expand Down
41 changes: 21 additions & 20 deletions infrastructure/stacks/api-layer/lambda.tf
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,28 @@ data "aws_subnet" "private_subnets" {
}

module "eligibility_signposting_lambda_function" {
source = "../../modules/lambda"
eligibility_lambda_role_arn = aws_iam_role.eligibility_lambda_role.arn
eligibility_lambda_role_name = aws_iam_role.eligibility_lambda_role.name
workspace = local.workspace
environment = var.environment
runtime = "python3.13"
lambda_func_name = "${terraform.workspace == "default" ? "" : "${terraform.workspace}-"}eligibility_signposting_api"
source = "../../modules/lambda"
eligibility_lambda_role_arn = aws_iam_role.eligibility_lambda_role.arn
eligibility_lambda_role_name = aws_iam_role.eligibility_lambda_role.name
workspace = local.workspace
environment = var.environment
runtime = "python3.13"
lambda_func_name = "${terraform.workspace == "default" ? "" : "${terraform.workspace}-"}eligibility_signposting_api"
security_group_ids = [data.aws_security_group.main_sg.id]
vpc_intra_subnets = [for v in data.aws_subnet.private_subnets : v.id]
file_name = "../../../dist/lambda.zip"
handler = "eligibility_signposting_api.app.lambda_handler"
eligibility_rules_bucket_name = module.s3_rules_bucket.storage_bucket_name
eligibility_status_table_name = module.eligibility_status_table.table_name
kinesis_audit_stream_to_s3_name = module.eligibility_audit_firehose_delivery_stream.firehose_stream_name
hashing_secret_name = module.secrets_manager.aws_hashing_secret_name
lambda_insights_extension_version = 38
log_level = "INFO"
enable_xray_patching = "true"
stack_name = local.stack_name
provisioned_concurrency_count = 5
api_domain_name = local.api_domain_name
vpc_intra_subnets = [for v in data.aws_subnet.private_subnets : v.id]
file_name = "../../../dist/lambda.zip"
handler = "eligibility_signposting_api.app.lambda_handler"
eligibility_rules_bucket_name = module.s3_rules_bucket.storage_bucket_name
eligibility_consumer_mappings_bucket_name = module.s3_consumer_mappings_bucket.storage_bucket_name
eligibility_status_table_name = module.eligibility_status_table.table_name
kinesis_audit_stream_to_s3_name = module.eligibility_audit_firehose_delivery_stream.firehose_stream_name
hashing_secret_name = module.secrets_manager.aws_hashing_secret_name
lambda_insights_extension_version = 38
log_level = "INFO"
enable_xray_patching = "true"
stack_name = local.stack_name
provisioned_concurrency_count = 5
api_domain_name = local.api_domain_name
}

# -----------------------------------------------------------------------------
Expand Down
9 changes: 9 additions & 0 deletions infrastructure/stacks/api-layer/s3_buckets.tf
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ module "s3_rules_bucket" {
workspace = terraform.workspace
}

module "s3_consumer_mappings_bucket" {
source = "../../modules/s3"
bucket_name = "eli-consumer-map"
environment = var.environment
project_name = var.project_name
stack_name = local.stack_name
workspace = terraform.workspace
}

module "s3_audit_bucket" {
source = "../../modules/s3"
bucket_name = "eli-audit"
Expand Down
8 changes: 8 additions & 0 deletions src/eligibility_signposting_api/common/api_error_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,11 @@ def log_and_generate_response(
fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED,
fhir_display_message="Access has been denied to process this request.",
)

CONSUMER_ID_NOT_PROVIDED_ERROR = APIErrorResponse(
status_code=HTTPStatus.FORBIDDEN,
fhir_issue_code=FHIRIssueCode.FORBIDDEN,
fhir_issue_severity=FHIRIssueSeverity.ERROR,
fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED,
fhir_display_message="Access has been denied to process this request.",
)
10 changes: 9 additions & 1 deletion src/eligibility_signposting_api/common/request_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
from flask.typing import ResponseReturnValue

from eligibility_signposting_api.common.api_error_response import (
CONSUMER_ID_NOT_PROVIDED_ERROR,
INVALID_CATEGORY_ERROR,
INVALID_CONDITION_FORMAT_ERROR,
INVALID_INCLUDE_ACTIONS_ERROR,
NHS_NUMBER_MISMATCH_ERROR,
)
from eligibility_signposting_api.config.constants import NHS_NUMBER_HEADER
from eligibility_signposting_api.config.constants import CONSUMER_ID, NHS_NUMBER_HEADER

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -56,6 +57,13 @@ def validate_request_params() -> Callable:
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs) -> ResponseReturnValue: # noqa:ANN002,ANN003
consumer_id = request.headers.get(CONSUMER_ID)
if not consumer_id:
message = "You are not authorised to request"
return CONSUMER_ID_NOT_PROVIDED_ERROR.log_and_generate_response(
log_message=message, diagnostics=message
)

path_nhs_number = str(kwargs.get("nhs_number"))
header_nhs_no = str(request.headers.get(NHS_NUMBER_HEADER))

Expand Down
3 changes: 3 additions & 0 deletions src/eligibility_signposting_api/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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"))
consumer_mapping_bucket_name = BucketName(os.getenv("CONSUMER_MAPPING_BUCKET_NAME", "test-consumer-mapping-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"))
Expand All @@ -41,6 +42,7 @@ def config() -> dict[str, Any]:
"s3_endpoint": None,
"rules_bucket_name": rules_bucket_name,
"audit_bucket_name": audit_bucket_name,
"consumer_mapping_bucket_name": consumer_mapping_bucket_name,
"firehose_endpoint": None,
"kinesis_audit_stream_to_s3": kinesis_audit_stream_to_s3,
"enable_xray_patching": enable_xray_patching,
Expand All @@ -59,6 +61,7 @@ def config() -> dict[str, Any]:
"s3_endpoint": URL(os.getenv("S3_ENDPOINT", local_stack_endpoint)),
"rules_bucket_name": rules_bucket_name,
"audit_bucket_name": audit_bucket_name,
"consumer_mapping_bucket_name": consumer_mapping_bucket_name,
"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,
Expand Down
1 change: 1 addition & 0 deletions src/eligibility_signposting_api/config/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
URL_PREFIX = "patient-check"
RULE_STOP_DEFAULT = False
NHS_NUMBER_HEADER = "nhs-login-nhs-number"
CONSUMER_ID = "consumer-id"
ALLOWED_CONDITIONS = Literal["COVID", "FLU", "MMR", "RSV"]
17 changes: 17 additions & 0 deletions src/eligibility_signposting_api/model/consumer_mapping.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from typing import NewType

from pydantic import BaseModel, Field, RootModel

from eligibility_signposting_api.model.campaign_config import CampaignID

ConsumerId = NewType("ConsumerId", str)


class ConsumerCampaign(BaseModel):
campaign: CampaignID = Field(alias="Campaign")
description: str | None = Field(default=None, alias="Description")


class ConsumerMapping(RootModel[dict[ConsumerId, list[ConsumerCampaign]]]):
def get(self, key: ConsumerId, default: list[ConsumerCampaign] | None = None) -> list[ConsumerCampaign] | None:
return self.root.get(key, default)
43 changes: 43 additions & 0 deletions src/eligibility_signposting_api/repos/consumer_mapping_repo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import json
from typing import Annotated, NewType

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

from eligibility_signposting_api.model.campaign_config import CampaignID
from eligibility_signposting_api.model.consumer_mapping import ConsumerId, ConsumerMapping

BucketName = NewType("BucketName", str)


@service
class ConsumerMappingRepo:
"""Repository class for Campaign Rules, which we can use to calculate a person's eligibility for vaccination.

These rules are stored as JSON files in AWS S3."""

def __init__(
self,
s3_client: Annotated[BaseClient, Inject(qualifier="s3")],
bucket_name: Annotated[BucketName, Inject(param="consumer_mapping_bucket_name")],
) -> None:
super().__init__()
self.s3_client = s3_client
self.bucket_name = bucket_name

def get_permitted_campaign_ids(self, consumer_id: ConsumerId) -> list[CampaignID] | None:
objects = self.s3_client.list_objects(Bucket=self.bucket_name).get("Contents")

if not objects:
return None

consumer_mappings_obj = objects[0]
response = self.s3_client.get_object(Bucket=self.bucket_name, Key=consumer_mappings_obj["Key"])
body = response["Body"].read()

mapping_result = ConsumerMapping.model_validate(json.loads(body)).get(consumer_id)

if mapping_result is None:
return None

return [item.campaign for item in mapping_result]
Loading
Loading