Skip to content

Commit 064f5c1

Browse files
ELI-578 nhsd app id (temp consumer identifier) - campaign mapping (#504)
* ELI-578 consumer_id - campaign_config mapping * ELI-578 added dummy consumer id * ELI-578 local stack configuration * ELI-578 wip test * ELI-578 unit test fixed * ELI-578 consumer_id error validation * ELI-578 intergation testing * ELI-578 lambda * ELI-578 integration test * ELI-578 Integration test to check if consumer has campaign mappings * ELI-578 Unit tests * added test : customer requesting for campaign that is not mapped * consumer with no campaign mapping is valid * ELI-578 consumer_id - campaign_config mapping * ELI-578 added dummy consumer id * ELI-578 local stack configuration * ELI-578 wip test * ELI-578 unit test fixed * ELI-578 consumer_id error validation * ELI-578 intergation testing * ELI-578 lambda * ELI-578 integration test * ELI-578 Integration test to check if consumer has campaign mappings * ELI-578 Unit tests * added test : customer requesting for campaign that is not mapped * consumer with no campaign mapping is valid * revert * linting * removed unused code * added CONSUMER_ID_NOT_PROVIDED_ERROR back * more test cases * hardcodes values are converted to fixtures * fixtures * fixtures * linting * more scenarios * added consumer-id header to the requests in unit tests * linting * Added vacc request placeholder in tests. * fixed linting. * added sample consumer-mapping file * test_valid_response_when_consumer_has_a_valid_campaign_config_mapping parameterise are categorised * multiple campaigns for same target * multiple campaigns for same target * consumer config structure modification * lint fix * consumer mapping - schema * Terraform consumer mapping bucket * Terraform consumer mapping bucket policy * more linting * fix s3 * fix s3 * revert to S3ReadAccess * iam permissions * more tests * name correction * one more test scenario * one more test scenario * added comments * test case - check best status is picked from the iterations * test cases fixed * fix lambda test case * fix lambda test case * fix latest testcases without consumer mapping * removed unsued conftest * renamed conftests * fixed comment * no reference to CONSUMER_ID in constant in tests * integration tests * integration tests * checkov suggestions * checkov suggestions * Revert "checkov suggestions" This reverts commit 37c919d. * Revert "checkov suggestions" This reverts commit d681b59. * revert - checkov suggestions * checkov skips * checkov skips * renamed "Campaign" to CampaignConfigId" * "CampaignConfigID" is the campaign id in consumer mapping --------- Co-authored-by: ayeshalshukri1-nhs <112615598+ayeshalshukri1-nhs@users.noreply.github.com>
1 parent 726e423 commit 064f5c1

23 files changed

Lines changed: 1442 additions & 138 deletions

File tree

infrastructure/modules/lambda/lambda.tf

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,15 @@ resource "aws_lambda_function" "eligibility_signposting_lambda" {
1717

1818
environment {
1919
variables = {
20-
PERSON_TABLE_NAME = var.eligibility_status_table_name,
21-
RULES_BUCKET_NAME = var.eligibility_rules_bucket_name,
22-
KINESIS_AUDIT_STREAM_TO_S3 = var.kinesis_audit_stream_to_s3_name
23-
ENV = var.environment
24-
LOG_LEVEL = var.log_level
25-
ENABLE_XRAY_PATCHING = var.enable_xray_patching
26-
API_DOMAIN_NAME = var.api_domain_name
27-
HASHING_SECRET_NAME = var.hashing_secret_name
20+
PERSON_TABLE_NAME = var.eligibility_status_table_name,
21+
RULES_BUCKET_NAME = var.eligibility_rules_bucket_name,
22+
CONSUMER_MAPPING_BUCKET_NAME = var.eligibility_consumer_mappings_bucket_name,
23+
KINESIS_AUDIT_STREAM_TO_S3 = var.kinesis_audit_stream_to_s3_name
24+
ENV = var.environment
25+
LOG_LEVEL = var.log_level
26+
ENABLE_XRAY_PATCHING = var.enable_xray_patching
27+
API_DOMAIN_NAME = var.api_domain_name
28+
HASHING_SECRET_NAME = var.hashing_secret_name
2829
}
2930
}
3031

infrastructure/modules/lambda/variables.tf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ variable "eligibility_rules_bucket_name" {
4444
type = string
4545
}
4646

47+
variable "eligibility_consumer_mappings_bucket_name" {
48+
description = "consumer mappings bucket name"
49+
type = string
50+
}
51+
4752
variable "eligibility_status_table_name" {
4853
description = "eligibility datastore table name"
4954
type = string

infrastructure/stacks/api-layer/iam_policies.tf

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,60 @@ data "aws_iam_policy_document" "rules_s3_bucket_policy" {
104104
}
105105
}
106106

107+
# Policy doc for S3 Consumer Mappings bucket
108+
data "aws_iam_policy_document" "s3_consumer_mapping_bucket_policy" {
109+
statement {
110+
sid = "AllowSSLRequestsOnly"
111+
actions = [
112+
"s3:GetObject",
113+
"s3:ListBucket",
114+
]
115+
resources = [
116+
module.s3_consumer_mappings_bucket.storage_bucket_arn,
117+
"${module.s3_consumer_mappings_bucket.storage_bucket_arn}/*",
118+
]
119+
condition {
120+
test = "Bool"
121+
values = ["true"]
122+
variable = "aws:SecureTransport"
123+
}
124+
}
125+
}
126+
127+
# ensure only secure transport is allowed
128+
129+
resource "aws_s3_bucket_policy" "consumer_mapping_s3_bucket" {
130+
bucket = module.s3_consumer_mappings_bucket.storage_bucket_id
131+
policy = data.aws_iam_policy_document.consumer_mapping_s3_bucket_policy.json
132+
}
133+
134+
data "aws_iam_policy_document" "consumer_mapping_s3_bucket_policy" {
135+
statement {
136+
sid = "AllowSslRequestsOnly"
137+
actions = [
138+
"s3:*",
139+
]
140+
effect = "Deny"
141+
resources = [
142+
module.s3_consumer_mappings_bucket.storage_bucket_arn,
143+
"${module.s3_consumer_mappings_bucket.storage_bucket_arn}/*",
144+
]
145+
principals {
146+
type = "*"
147+
identifiers = ["*"]
148+
}
149+
condition {
150+
test = "Bool"
151+
values = [
152+
"false",
153+
]
154+
155+
variable = "aws:SecureTransport"
156+
}
157+
}
158+
}
159+
160+
# audit bucket
107161
resource "aws_s3_bucket_policy" "audit_s3_bucket" {
108162
bucket = module.s3_audit_bucket.storage_bucket_id
109163
policy = data.aws_iam_policy_document.audit_s3_bucket_policy.json
@@ -136,12 +190,18 @@ data "aws_iam_policy_document" "audit_s3_bucket_policy" {
136190
}
137191

138192
# Attach s3 read policy to Lambda role
139-
resource "aws_iam_role_policy" "lambda_s3_read_policy" {
193+
resource "aws_iam_role_policy" "lambda_s3_rules_read_policy" {
140194
name = "S3ReadAccess"
141195
role = aws_iam_role.eligibility_lambda_role.id
142196
policy = data.aws_iam_policy_document.s3_rules_bucket_policy.json
143197
}
144198

199+
resource "aws_iam_role_policy" "lambda_s3_mapping_read_policy" {
200+
name = "S3ConsumerMappingReadAccess"
201+
role = aws_iam_role.eligibility_lambda_role.id
202+
policy = data.aws_iam_policy_document.s3_consumer_mapping_bucket_policy.json
203+
}
204+
145205
# Attach s3 write policy to kinesis firehose role
146206
resource "aws_iam_role_policy" "kinesis_firehose_s3_write_policy" {
147207
name = "S3WriteAccess"
@@ -290,6 +350,41 @@ resource "aws_kms_key_policy" "s3_rules_kms_key" {
290350
policy = data.aws_iam_policy_document.s3_rules_kms_key_policy.json
291351
}
292352

353+
data "aws_iam_policy_document" "s3_consumer_mapping_kms_key_policy" {
354+
#checkov:skip=CKV_AWS_111: Root user needs full KMS key management
355+
#checkov:skip=CKV_AWS_356: Root user needs full KMS key management
356+
#checkov:skip=CKV_AWS_109: Root user needs full KMS key management
357+
statement {
358+
sid = "EnableIamUserPermissions"
359+
effect = "Allow"
360+
principals {
361+
type = "AWS"
362+
identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"]
363+
}
364+
actions = ["kms:*"]
365+
resources = ["*"]
366+
}
367+
368+
#checkov:skip=CKV_AWS_111: Permission boundary enforces restrictions for this policy
369+
#checkov:skip=CKV_AWS_356: Permission boundary enforces resource-level controls
370+
#checkov:skip=CKV_AWS_109: Permission boundary governs write-access constraints
371+
statement {
372+
sid = "AllowLambdaDecrypt"
373+
effect = "Allow"
374+
principals {
375+
type = "AWS"
376+
identifiers = [aws_iam_role.eligibility_lambda_role.arn]
377+
}
378+
actions = ["kms:Decrypt"]
379+
resources = ["*"]
380+
}
381+
}
382+
383+
resource "aws_kms_key_policy" "s3_consumer_mapping_kms_key" {
384+
key_id = module.s3_consumer_mappings_bucket.storage_bucket_kms_key_id
385+
policy = data.aws_iam_policy_document.s3_consumer_mapping_kms_key_policy.json
386+
}
387+
293388
resource "aws_iam_role_policy" "splunk_firehose_policy" {
294389
#checkov:skip=CKV_AWS_290: Firehose requires write access to dynamic log streams without static constraints
295390
#checkov:skip=CKV_AWS_355: Firehose logging requires wildcard resource for CloudWatch log groups/streams

infrastructure/stacks/api-layer/lambda.tf

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,27 +11,28 @@ data "aws_subnet" "private_subnets" {
1111
}
1212

1313
module "eligibility_signposting_lambda_function" {
14-
source = "../../modules/lambda"
15-
eligibility_lambda_role_arn = aws_iam_role.eligibility_lambda_role.arn
16-
eligibility_lambda_role_name = aws_iam_role.eligibility_lambda_role.name
17-
workspace = local.workspace
18-
environment = var.environment
19-
runtime = "python3.13"
20-
lambda_func_name = "${terraform.workspace == "default" ? "" : "${terraform.workspace}-"}eligibility_signposting_api"
14+
source = "../../modules/lambda"
15+
eligibility_lambda_role_arn = aws_iam_role.eligibility_lambda_role.arn
16+
eligibility_lambda_role_name = aws_iam_role.eligibility_lambda_role.name
17+
workspace = local.workspace
18+
environment = var.environment
19+
runtime = "python3.13"
20+
lambda_func_name = "${terraform.workspace == "default" ? "" : "${terraform.workspace}-"}eligibility_signposting_api"
2121
security_group_ids = [data.aws_security_group.main_sg.id]
22-
vpc_intra_subnets = [for v in data.aws_subnet.private_subnets : v.id]
23-
file_name = "../../../dist/lambda.zip"
24-
handler = "eligibility_signposting_api.app.lambda_handler"
25-
eligibility_rules_bucket_name = module.s3_rules_bucket.storage_bucket_name
26-
eligibility_status_table_name = module.eligibility_status_table.table_name
27-
kinesis_audit_stream_to_s3_name = module.eligibility_audit_firehose_delivery_stream.firehose_stream_name
28-
hashing_secret_name = module.secrets_manager.aws_hashing_secret_name
29-
lambda_insights_extension_version = 38
30-
log_level = "INFO"
31-
enable_xray_patching = "true"
32-
stack_name = local.stack_name
33-
provisioned_concurrency_count = 5
34-
api_domain_name = local.api_domain_name
22+
vpc_intra_subnets = [for v in data.aws_subnet.private_subnets : v.id]
23+
file_name = "../../../dist/lambda.zip"
24+
handler = "eligibility_signposting_api.app.lambda_handler"
25+
eligibility_rules_bucket_name = module.s3_rules_bucket.storage_bucket_name
26+
eligibility_consumer_mappings_bucket_name = module.s3_consumer_mappings_bucket.storage_bucket_name
27+
eligibility_status_table_name = module.eligibility_status_table.table_name
28+
kinesis_audit_stream_to_s3_name = module.eligibility_audit_firehose_delivery_stream.firehose_stream_name
29+
hashing_secret_name = module.secrets_manager.aws_hashing_secret_name
30+
lambda_insights_extension_version = 38
31+
log_level = "INFO"
32+
enable_xray_patching = "true"
33+
stack_name = local.stack_name
34+
provisioned_concurrency_count = 5
35+
api_domain_name = local.api_domain_name
3536
}
3637

3738
# -----------------------------------------------------------------------------

infrastructure/stacks/api-layer/s3_buckets.tf

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ module "s3_rules_bucket" {
77
workspace = terraform.workspace
88
}
99

10+
module "s3_consumer_mappings_bucket" {
11+
source = "../../modules/s3"
12+
bucket_name = "eli-consumer-map"
13+
environment = var.environment
14+
project_name = var.project_name
15+
stack_name = local.stack_name
16+
workspace = terraform.workspace
17+
}
18+
1019
module "s3_audit_bucket" {
1120
source = "../../modules/s3"
1221
bucket_name = "eli-audit"

src/eligibility_signposting_api/common/api_error_response.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,11 @@ def log_and_generate_response(
135135
fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED,
136136
fhir_display_message="Access has been denied to process this request.",
137137
)
138+
139+
CONSUMER_ID_NOT_PROVIDED_ERROR = APIErrorResponse(
140+
status_code=HTTPStatus.FORBIDDEN,
141+
fhir_issue_code=FHIRIssueCode.FORBIDDEN,
142+
fhir_issue_severity=FHIRIssueSeverity.ERROR,
143+
fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED,
144+
fhir_display_message="Access has been denied to process this request.",
145+
)

src/eligibility_signposting_api/common/request_validator.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77
from flask.typing import ResponseReturnValue
88

99
from eligibility_signposting_api.common.api_error_response import (
10+
CONSUMER_ID_NOT_PROVIDED_ERROR,
1011
INVALID_CATEGORY_ERROR,
1112
INVALID_CONDITION_FORMAT_ERROR,
1213
INVALID_INCLUDE_ACTIONS_ERROR,
1314
NHS_NUMBER_ERROR,
1415
)
15-
from eligibility_signposting_api.config.constants import NHS_NUMBER_HEADER
16+
from eligibility_signposting_api.config.constants import CONSUMER_ID, NHS_NUMBER_HEADER
1617

1718
logger = logging.getLogger(__name__)
1819

@@ -50,6 +51,13 @@ def validate_request_params() -> Callable:
5051
def decorator(func: Callable) -> Callable:
5152
@wraps(func)
5253
def wrapper(*args, **kwargs) -> ResponseReturnValue: # noqa:ANN002,ANN003
54+
consumer_id = request.headers.get(CONSUMER_ID)
55+
if not consumer_id:
56+
message = "You are not authorised to request"
57+
return CONSUMER_ID_NOT_PROVIDED_ERROR.log_and_generate_response(
58+
log_message=message, diagnostics=message
59+
)
60+
5361
path_nhs_number = str(kwargs.get("nhs_number")) if kwargs.get("nhs_number") else None
5462

5563
if not path_nhs_number:

src/eligibility_signposting_api/config/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
def config() -> dict[str, Any]:
2323
person_table_name = TableName(os.getenv("PERSON_TABLE_NAME", "test_eligibility_datastore"))
2424
rules_bucket_name = BucketName(os.getenv("RULES_BUCKET_NAME", "test-rules-bucket"))
25+
consumer_mapping_bucket_name = BucketName(os.getenv("CONSUMER_MAPPING_BUCKET_NAME", "test-consumer-mapping-bucket"))
2526
audit_bucket_name = BucketName(os.getenv("AUDIT_BUCKET_NAME", "test-audit-bucket"))
2627
hashing_secret_name = HashSecretName(os.getenv("HASHING_SECRET_NAME", "test_secret"))
2728
aws_default_region = AwsRegion(os.getenv("AWS_DEFAULT_REGION", "eu-west-1"))
@@ -41,6 +42,7 @@ def config() -> dict[str, Any]:
4142
"s3_endpoint": None,
4243
"rules_bucket_name": rules_bucket_name,
4344
"audit_bucket_name": audit_bucket_name,
45+
"consumer_mapping_bucket_name": consumer_mapping_bucket_name,
4446
"firehose_endpoint": None,
4547
"kinesis_audit_stream_to_s3": kinesis_audit_stream_to_s3,
4648
"enable_xray_patching": enable_xray_patching,
@@ -59,6 +61,7 @@ def config() -> dict[str, Any]:
5961
"s3_endpoint": URL(os.getenv("S3_ENDPOINT", local_stack_endpoint)),
6062
"rules_bucket_name": rules_bucket_name,
6163
"audit_bucket_name": audit_bucket_name,
64+
"consumer_mapping_bucket_name": consumer_mapping_bucket_name,
6265
"firehose_endpoint": URL(os.getenv("FIREHOSE_ENDPOINT", local_stack_endpoint)),
6366
"kinesis_audit_stream_to_s3": kinesis_audit_stream_to_s3,
6467
"enable_xray_patching": enable_xray_patching,

src/eligibility_signposting_api/config/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
URL_PREFIX = "patient-check"
44
RULE_STOP_DEFAULT = False
55
NHS_NUMBER_HEADER = "nhs-login-nhs-number"
6+
CONSUMER_ID = "nhsd-application-id" # "Nhsd-Application-Id"
67
ALLOWED_CONDITIONS = Literal["COVID", "FLU", "MMR", "RSV"]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from typing import NewType
2+
3+
from pydantic import BaseModel, Field, RootModel
4+
5+
from eligibility_signposting_api.model.campaign_config import CampaignID
6+
7+
ConsumerId = NewType("ConsumerId", str)
8+
9+
10+
class ConsumerCampaign(BaseModel):
11+
campaign_config_id: CampaignID = Field(alias="CampaignConfigID")
12+
description: str | None = Field(default=None, alias="Description")
13+
14+
15+
class ConsumerMapping(RootModel[dict[ConsumerId, list[ConsumerCampaign]]]):
16+
def get(self, key: ConsumerId, default: list[ConsumerCampaign] | None = None) -> list[ConsumerCampaign] | None:
17+
return self.root.get(key, default)

0 commit comments

Comments
 (0)