,
- "subscriptions": <$.subscriptions>,
- "signatures": <$.signatures>
+ "subscriptions": <$.subscriptions>
}
EOF
}
diff --git a/infrastructure/terraform/components/callbacks/pre.sh b/infrastructure/terraform/components/callbacks/pre.sh
index aa5d3dda..cac3b745 100755
--- a/infrastructure/terraform/components/callbacks/pre.sh
+++ b/infrastructure/terraform/components/callbacks/pre.sh
@@ -6,17 +6,21 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=_paths.sh
source "${script_dir}/_paths.sh"
-# Resolve deploy_mock_clients from tfvars; base_path/group/region/environment are in scope from terraform.sh
+# Resolve deploy_mock_clients and deploy_perf_runner from tfvars; base_path/group/region/environment are in scope from terraform.sh
deploy_mock_clients="false"
+deploy_perf_runner="false"
for _tfvar_file in \
"${base_path}/etc/group_${group}.tfvars" \
"${base_path}/etc/env_${region}_${environment}.tfvars"; do
if [ -f "${_tfvar_file}" ]; then
_val=$(grep -E '^\s*deploy_mock_clients\s*=' "${_tfvar_file}" | tail -1 | sed 's/.*=\s*//;s/\s*$//')
[ -n "${_val}" ] && deploy_mock_clients="${_val}"
+ _val=$(grep -E '^\s*deploy_perf_runner\s*=' "${_tfvar_file}" | tail -1 | sed 's/.*=\s*//;s/\s*$//')
+ [ -n "${_val}" ] && deploy_perf_runner="${_val}"
fi
done
echo "deploy_mock_clients resolved to: ${deploy_mock_clients}"
+echo "deploy_perf_runner resolved to: ${deploy_perf_runner}"
pnpm install --frozen-lockfile
@@ -25,15 +29,13 @@ pnpm run generate-dependencies
"${script_dir}/sync-client-config.sh"
if [ "${deploy_mock_clients}" == "true" ]; then
- shopt -s nullglob
- existing_configs=("${clients_dir}"/*.json)
- shopt -u nullglob
- if [ "${#existing_configs[@]}" -eq 0 ]; then
- cp "${bounded_context_root}/tests/integration/fixtures/subscriptions/"*.json "${clients_dir}/"
- echo "Copied mock client subscription config fixtures into clients dir"
- else
- echo "Client configs already present from S3 sync; skipping fixture copy"
- fi
+ cp "${bounded_context_root}/tests/integration/fixtures/subscriptions/"*.json "${clients_dir}/"
+ echo "Copied mock client subscription config fixtures into clients dir"
+fi
+
+if [ "${deploy_perf_runner}" == "true" ]; then
+ cp "${bounded_context_root}/tests/performance/fixtures/subscriptions/"*.json "${clients_dir}/"
+ echo "Copied perf client subscription config fixtures into clients dir"
fi
pnpm run --recursive --if-present lambda-build
diff --git a/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf b/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf
index 8bf25c83..9943affd 100644
--- a/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf
+++ b/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf
@@ -55,7 +55,7 @@ data "aws_iam_policy_document" "client_config_bucket" {
]
resources = [
- module.client_config_bucket.arn,
+ local.client_config_bucket_arn,
]
}
@@ -73,7 +73,7 @@ data "aws_iam_policy_document" "client_config_bucket" {
]
resources = [
- "${module.client_config_bucket.arn}/*",
+ "${local.client_config_bucket_arn}/*",
]
}
@@ -91,8 +91,8 @@ data "aws_iam_policy_document" "client_config_bucket" {
]
resources = [
- module.client_config_bucket.arn,
- "${module.client_config_bucket.arn}/*"
+ local.client_config_bucket_arn,
+ "${local.client_config_bucket_arn}/*"
]
condition {
diff --git a/infrastructure/terraform/components/callbacks/s3_bucket_mtls_test_certs.tf b/infrastructure/terraform/components/callbacks/s3_bucket_mtls_test_certs.tf
new file mode 100644
index 00000000..e1bd377e
--- /dev/null
+++ b/infrastructure/terraform/components/callbacks/s3_bucket_mtls_test_certs.tf
@@ -0,0 +1,193 @@
+module "mtls_test_certs_bucket" {
+ count = var.deploy_mock_clients ? 1 : 0
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-s3bucket.zip"
+
+ name = "mtls-test-certs"
+
+ aws_account_id = var.aws_account_id
+ component = var.component
+ environment = var.environment
+ project = var.project
+ region = var.region
+
+ default_tags = merge(
+ local.default_tags,
+ {
+ Description = "mTLS test certificate material for non-production callback delivery"
+ }
+ )
+
+ kms_key_arn = module.kms.key_arn
+ force_destroy = var.s3_enable_force_destroy
+ versioning = false
+ object_ownership = "BucketOwnerPreferred"
+ bucket_key_enabled = true
+
+ policy_documents = [
+ data.aws_iam_policy_document.mtls_test_certs_bucket[0].json
+ ]
+}
+
+data "aws_iam_policy_document" "mtls_test_certs_bucket" {
+ count = var.deploy_mock_clients ? 1 : 0
+
+ statement {
+ sid = "DenyInsecureTransport"
+ effect = "Deny"
+
+ principals {
+ type = "*"
+ identifiers = ["*"]
+ }
+
+ actions = [
+ "s3:*",
+ ]
+
+ resources = [
+ "arn:aws:s3:::${var.project}-${var.aws_account_id}-${var.region}-${var.environment}-${var.component}-mtls-test-certs",
+ "arn:aws:s3:::${var.project}-${var.aws_account_id}-${var.region}-${var.environment}-${var.component}-mtls-test-certs/*"
+ ]
+
+ condition {
+ test = "Bool"
+ variable = "aws:SecureTransport"
+ values = ["false"]
+ }
+ }
+}
+
+locals {
+ mtls_test_certs_s3_prefix = "callbacks/mtls-test"
+ mtls_test_cert_s3_key = "${local.mtls_test_certs_s3_prefix}/client-bundle.pem"
+ mtls_test_ca_s3_key = "${local.mtls_test_certs_s3_prefix}/ca.pem"
+}
+
+# --- TLS provider: generate test CA, client, and server certificates ---
+
+resource "tls_private_key" "test_ca" {
+ count = var.deploy_mock_clients ? 1 : 0
+ algorithm = "ECDSA"
+ ecdsa_curve = "P256"
+}
+
+resource "tls_self_signed_cert" "test_ca" {
+ count = var.deploy_mock_clients ? 1 : 0
+ private_key_pem = tls_private_key.test_ca[0].private_key_pem
+ is_ca_certificate = true
+ validity_period_hours = 87600
+
+ subject {
+ common_name = "NHS Notify Test CA"
+ organization = "NHS Notify"
+ country = "GB"
+ }
+
+ allowed_uses = [
+ "cert_signing",
+ ]
+}
+
+resource "tls_private_key" "test_client" {
+ count = var.deploy_mock_clients ? 1 : 0
+ algorithm = "ECDSA"
+ ecdsa_curve = "P256"
+}
+
+resource "tls_cert_request" "test_client" {
+ count = var.deploy_mock_clients ? 1 : 0
+ private_key_pem = tls_private_key.test_client[0].private_key_pem
+
+ subject {
+ common_name = "NHS Notify Callbacks Test Client"
+ organization = "NHS Notify"
+ country = "GB"
+ }
+}
+
+resource "tls_locally_signed_cert" "test_client" {
+ count = var.deploy_mock_clients ? 1 : 0
+ cert_request_pem = tls_cert_request.test_client[0].cert_request_pem
+ ca_private_key_pem = tls_private_key.test_ca[0].private_key_pem
+ ca_cert_pem = tls_self_signed_cert.test_ca[0].cert_pem
+ validity_period_hours = 87600
+
+ allowed_uses = [
+ "digital_signature",
+ "client_auth",
+ ]
+}
+
+resource "tls_private_key" "mock_server" {
+ count = var.deploy_mock_clients ? 1 : 0
+ algorithm = "ECDSA"
+ ecdsa_curve = "P256"
+}
+
+resource "tls_cert_request" "mock_server" {
+ count = var.deploy_mock_clients ? 1 : 0
+ private_key_pem = tls_private_key.mock_server[0].private_key_pem
+
+ subject {
+ common_name = "NHS Notify Mock Webhook Server"
+ organization = "NHS Notify"
+ country = "GB"
+ }
+
+ dns_names = ["*.eu-west-2.elb.amazonaws.com"]
+}
+
+resource "tls_locally_signed_cert" "mock_server" {
+ count = var.deploy_mock_clients ? 1 : 0
+ cert_request_pem = tls_cert_request.mock_server[0].cert_request_pem
+ ca_private_key_pem = tls_private_key.test_ca[0].private_key_pem
+ ca_cert_pem = tls_self_signed_cert.test_ca[0].cert_pem
+ validity_period_hours = 87600
+
+ allowed_uses = [
+ "digital_signature",
+ "key_encipherment",
+ "server_auth",
+ ]
+}
+
+# --- S3 objects: Lambda reads certs from S3 at runtime ---
+
+resource "aws_s3_object" "mtls_test_client_bundle" {
+ count = var.deploy_mock_clients ? 1 : 0
+ bucket = module.mtls_test_certs_bucket[0].id
+ key = local.mtls_test_cert_s3_key # gitleaks:allow
+ content = "${tls_locally_signed_cert.test_client[0].cert_pem}${tls_private_key.test_client[0].private_key_pem}"
+
+ server_side_encryption = "aws:kms"
+ content_type = "application/x-pem-file"
+}
+
+resource "aws_s3_object" "mtls_test_ca" {
+ count = var.deploy_mock_clients ? 1 : 0
+ bucket = module.mtls_test_certs_bucket[0].id
+ key = local.mtls_test_ca_s3_key # gitleaks:allow
+ content = tls_self_signed_cert.test_ca[0].cert_pem
+
+ server_side_encryption = "aws:kms"
+ content_type = "application/x-pem-file"
+}
+
+# Compute the base64-encoded SHA-256 hash of the mock server's SPKI (Subject Public Key Info) DER.
+# Used by cert-pinning clients to verify the server certificate during mTLS handshake.
+data "external" "mock_server_spki_hash" {
+ count = var.deploy_mock_clients ? 1 : 0
+ program = ["bash", "-c", <<-EOT
+ HASH=$(jq -r '.pem' \
+ | openssl pkey -pubin -outform DER 2>/dev/null \
+ | openssl dgst -sha256 -binary \
+ | base64 \
+ | tr -d '\n')
+ printf '{"hash":"%s"}' "$HASH"
+ EOT
+ ]
+
+ query = {
+ pem = tls_private_key.mock_server[0].public_key_pem
+ }
+}
diff --git a/infrastructure/terraform/components/callbacks/variables.tf b/infrastructure/terraform/components/callbacks/variables.tf
index 74a72d24..9c71492d 100644
--- a/infrastructure/terraform/components/callbacks/variables.tf
+++ b/infrastructure/terraform/components/callbacks/variables.tf
@@ -155,6 +155,12 @@ variable "deploy_mock_clients" {
default = false
}
+variable "deploy_perf_runner" {
+ type = bool
+ description = "Flag to deploy the perf-runner lambda for performance testing (test/dev environments only)"
+ default = false
+}
+
variable "enable_xray_tracing" {
type = bool
description = "Enable AWS X-Ray active tracing for Lambda functions"
@@ -177,3 +183,15 @@ variable "s3_enable_force_destroy" {
description = "Whether to enable force destroy for the S3 buckets created in this module"
default = false
}
+
+variable "mtls_cert_secret_arn" {
+ type = string
+ description = "Secrets Manager ARN for the shared mTLS client certificate (production)"
+ default = ""
+}
+
+variable "elasticache_data_storage_maximum_gb" {
+ type = number
+ description = "Maximum data storage in GB for the ElastiCache Serverless delivery state cache"
+ default = 1
+}
diff --git a/infrastructure/terraform/components/callbacks/versions.tf b/infrastructure/terraform/components/callbacks/versions.tf
index 55552749..d91998a2 100644
--- a/infrastructure/terraform/components/callbacks/versions.tf
+++ b/infrastructure/terraform/components/callbacks/versions.tf
@@ -4,10 +4,18 @@ terraform {
source = "hashicorp/aws"
version = "6.13"
}
+ external = {
+ source = "hashicorp/external"
+ version = "~> 2.0"
+ }
random = {
source = "hashicorp/random"
version = "~> 3.0"
}
+ tls = {
+ source = "hashicorp/tls"
+ version = "~> 4.0"
+ }
}
required_version = ">= 1.10.1"
diff --git a/infrastructure/terraform/modules/client-delivery/README.md b/infrastructure/terraform/modules/client-delivery/README.md
new file mode 100644
index 00000000..0a4965e7
--- /dev/null
+++ b/infrastructure/terraform/modules/client-delivery/README.md
@@ -0,0 +1,61 @@
+
+
+
+
+## Requirements
+
+No requirements.
+## Inputs
+
+| Name | Description | Type | Default | Required |
+|------|-------------|------|---------|:--------:|
+| [applications\_map\_parameter\_name](#input\_applications\_map\_parameter\_name) | SSM Parameter Store path for the clientId-to-applicationData map | `string` | n/a | yes |
+| [aws\_account\_id](#input\_aws\_account\_id) | Account ID | `string` | n/a | yes |
+| [client\_bus\_name](#input\_client\_bus\_name) | EventBridge bus name for subscription rules | `string` | n/a | yes |
+| [client\_config\_bucket](#input\_client\_config\_bucket) | S3 bucket name containing client subscription configuration | `string` | n/a | yes |
+| [client\_config\_bucket\_arn](#input\_client\_config\_bucket\_arn) | S3 bucket ARN containing client subscription configuration | `string` | n/a | yes |
+| [client\_id](#input\_client\_id) | Unique identifier for this client | `string` | n/a | yes |
+| [component](#input\_component) | Component name | `string` | n/a | yes |
+| [elasticache\_cache\_name](#input\_elasticache\_cache\_name) | ElastiCache cache name for SigV4 token presigning | `string` | `""` | no |
+| [elasticache\_endpoint](#input\_elasticache\_endpoint) | ElastiCache Serverless endpoint URL | `string` | `""` | no |
+| [elasticache\_iam\_username](#input\_elasticache\_iam\_username) | IAM username for ElastiCache authentication | `string` | `""` | no |
+| [enable\_xray\_tracing](#input\_enable\_xray\_tracing) | Enable AWS X-Ray active tracing for the Lambda function | `bool` | `false` | no |
+| [environment](#input\_environment) | The name of the tfscaffold environment | `string` | n/a | yes |
+| [force\_lambda\_code\_deploy](#input\_force\_lambda\_code\_deploy) | Force Lambda code redeployment even when commit tag matches | `bool` | `false` | no |
+| [group](#input\_group) | The name of the tfscaffold group | `string` | `null` | no |
+| [kms\_key\_arn](#input\_kms\_key\_arn) | KMS Key ARN for encryption at rest | `string` | n/a | yes |
+| [lambda\_batch\_size](#input\_lambda\_batch\_size) | Number of SQS messages per Lambda invocation | `number` | `10` | no |
+| [lambda\_code\_base\_path](#input\_lambda\_code\_base\_path) | Base path to Lambda source code directories | `string` | n/a | yes |
+| [lambda\_memory](#input\_lambda\_memory) | Lambda memory allocation in MB | `number` | `256` | no |
+| [lambda\_s3\_bucket](#input\_lambda\_s3\_bucket) | S3 bucket for Lambda function artefacts | `string` | n/a | yes |
+| [lambda\_security\_group\_id](#input\_lambda\_security\_group\_id) | Security group ID for the Lambda function | `string` | `""` | no |
+| [lambda\_timeout](#input\_lambda\_timeout) | Lambda timeout in seconds | `number` | `30` | no |
+| [log\_destination\_arn](#input\_log\_destination\_arn) | Firehose destination ARN for log forwarding | `string` | `""` | no |
+| [log\_level](#input\_log\_level) | Log level for the Lambda function | `string` | `"INFO"` | no |
+| [log\_retention\_in\_days](#input\_log\_retention\_in\_days) | CloudWatch log retention period in days | `number` | `0` | no |
+| [log\_subscription\_role\_arn](#input\_log\_subscription\_role\_arn) | IAM role ARN for CloudWatch log subscription | `string` | `""` | no |
+| [max\_retry\_duration\_seconds](#input\_max\_retry\_duration\_seconds) | Maximum retry window before messages are sent to DLQ | `number` | `7200` | no |
+| [mtls\_cert\_secret\_arn](#input\_mtls\_cert\_secret\_arn) | Secrets Manager ARN for the mTLS client certificate | `string` | `""` | no |
+| [mtls\_test\_ca\_s3\_key](#input\_mtls\_test\_ca\_s3\_key) | S3 key for dev CA certificate PEM bundle used for server verification | `string` | `""` | no |
+| [mtls\_test\_cert\_s3\_bucket](#input\_mtls\_test\_cert\_s3\_bucket) | S3 bucket for dev mTLS test certificates | `string` | `""` | no |
+| [mtls\_test\_cert\_s3\_key](#input\_mtls\_test\_cert\_s3\_key) | S3 key for dev mTLS test certificate bundle | `string` | `""` | no |
+| [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes |
+| [region](#input\_region) | AWS Region | `string` | n/a | yes |
+| [sqs\_max\_receive\_count](#input\_sqs\_max\_receive\_count) | Safety-net maximum receive count before a message moves to DLQ. Supplements the time-based retry window for cases where the Lambda fails before reaching the window check. | `number` | `100` | no |
+| [sqs\_visibility\_timeout\_seconds](#input\_sqs\_visibility\_timeout\_seconds) | Visibility timeout for the per-client delivery queue | `number` | `60` | no |
+| [subscription\_targets](#input\_subscription\_targets) | Flattened subscription-target fanout map keyed by subscription-target composite key | map(object({
subscription_id = string
target_id = string
})) | n/a | yes |
+| [subscriptions](#input\_subscriptions) | Subscription definitions for this client, keyed by subscription\_id | map(object({
subscription_id = string
target_ids = list(string)
})) | n/a | yes |
+| [vpc\_subnet\_ids](#input\_vpc\_subnet\_ids) | VPC subnet IDs for Lambda execution | `list(string)` | `[]` | no |
+## Modules
+
+| Name | Source | Version |
+|------|--------|---------|
+| [dlq\_delivery](#module\_dlq\_delivery) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-sqs.zip | n/a |
+| [https\_client\_lambda](#module\_https\_client\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-lambda.zip | n/a |
+| [sqs\_delivery](#module\_sqs\_delivery) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.9/terraform-sqs.zip | n/a |
+## Outputs
+
+No outputs.
+
+
+
diff --git a/infrastructure/terraform/modules/client-delivery/cloudwatch_event_rule_per_subscription.tf b/infrastructure/terraform/modules/client-delivery/cloudwatch_event_rule_per_subscription.tf
new file mode 100644
index 00000000..63da2089
--- /dev/null
+++ b/infrastructure/terraform/modules/client-delivery/cloudwatch_event_rule_per_subscription.tf
@@ -0,0 +1,103 @@
+resource "aws_cloudwatch_event_rule" "per_subscription" {
+ for_each = var.subscriptions
+
+ name = "${local.csi}-${each.key}"
+ description = "Client Callbacks event rule for client ${var.client_id} subscription ${each.key}"
+ event_bus_name = var.client_bus_name
+
+ event_pattern = jsonencode({
+ "detail" : {
+ "subscriptions" : [each.value.subscription_id]
+ }
+ })
+
+ tags = merge(local.default_tags, {
+ SubscriptionId = each.value.subscription_id
+ })
+}
+
+resource "aws_cloudwatch_event_target" "per_subscription_target" {
+ for_each = var.subscription_targets
+
+ rule = aws_cloudwatch_event_rule.per_subscription[each.value.subscription_id].name
+ arn = module.sqs_delivery.sqs_queue_arn
+ target_id = "${local.csi}-${each.value.target_id}"
+ event_bus_name = var.client_bus_name
+ role_arn = aws_iam_role.eventbridge_sqs_target.arn
+
+ sqs_target {
+ message_group_id = null
+ }
+
+ input_transformer {
+ input_paths = {
+ payload = "$.detail.payload"
+ }
+
+ input_template = "{\"payload\": , \"subscriptionId\": \"${each.value.subscription_id}\", \"targetId\": \"${each.value.target_id}\"}"
+ }
+
+ dead_letter_config {
+ arn = module.dlq_delivery.sqs_queue_arn
+ }
+
+ retry_policy {
+ maximum_retry_attempts = 0
+ maximum_event_age_in_seconds = 60
+ }
+}
+
+resource "aws_iam_role" "eventbridge_sqs_target" {
+ name = "${local.client_prefix}-eb-sqs-role"
+ description = "Role for EventBridge to send messages to per-client SQS queue"
+ assume_role_policy = data.aws_iam_policy_document.eventbridge_sqs_assume.json
+
+ tags = local.default_tags
+}
+
+data "aws_iam_policy_document" "eventbridge_sqs_assume" {
+ statement {
+ actions = ["sts:AssumeRole"]
+
+ principals {
+ type = "Service"
+ identifiers = ["events.amazonaws.com"]
+ }
+ }
+}
+
+resource "aws_iam_role_policy" "eventbridge_sqs_send" {
+ name = "sqs-send"
+ role = aws_iam_role.eventbridge_sqs_target.id
+ policy = data.aws_iam_policy_document.eventbridge_sqs_send.json
+}
+
+data "aws_iam_policy_document" "eventbridge_sqs_send" {
+ statement {
+ sid = "AllowSQSSendMessage"
+ effect = "Allow"
+
+ actions = [
+ "sqs:SendMessage",
+ ]
+
+ resources = [
+ module.sqs_delivery.sqs_queue_arn,
+ module.dlq_delivery.sqs_queue_arn,
+ ]
+ }
+
+ statement {
+ sid = "AllowKMSForSQS"
+ effect = "Allow"
+
+ actions = [
+ "kms:Decrypt",
+ "kms:GenerateDataKey",
+ ]
+
+ resources = [
+ var.kms_key_arn,
+ ]
+ }
+}
diff --git a/infrastructure/terraform/modules/client-delivery/iam_role_sqs_target.tf b/infrastructure/terraform/modules/client-delivery/iam_role_sqs_target.tf
new file mode 100644
index 00000000..55162684
--- /dev/null
+++ b/infrastructure/terraform/modules/client-delivery/iam_role_sqs_target.tf
@@ -0,0 +1,151 @@
+data "aws_iam_policy_document" "https_client_lambda" {
+ statement {
+ sid = "KMSPermissions"
+ effect = "Allow"
+
+ actions = [
+ "kms:Decrypt",
+ "kms:GenerateDataKey",
+ ]
+
+ resources = [
+ var.kms_key_arn,
+ ]
+ }
+
+ statement {
+ sid = "SQSDeliveryQueueConsume"
+ effect = "Allow"
+
+ actions = [
+ "sqs:ReceiveMessage",
+ "sqs:DeleteMessage",
+ "sqs:GetQueueAttributes",
+ "sqs:ChangeMessageVisibility",
+ ]
+
+ resources = [
+ module.sqs_delivery.sqs_queue_arn,
+ ]
+ }
+
+ statement {
+ sid = "SQSDLQSend"
+ effect = "Allow"
+
+ actions = [
+ "sqs:SendMessage",
+ ]
+
+ resources = [
+ module.dlq_delivery.sqs_queue_arn,
+ ]
+ }
+
+ statement {
+ sid = "SSMGetApplicationsMap"
+ effect = "Allow"
+
+ actions = [
+ "ssm:GetParameter",
+ ]
+
+ resources = [
+ "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter${var.applications_map_parameter_name}",
+ ]
+ }
+
+ statement {
+ sid = "S3ClientConfigReadAccess"
+ effect = "Allow"
+
+ actions = [
+ "s3:GetObject",
+ ]
+
+ resources = [
+ "${var.client_config_bucket_arn}/client_subscriptions/*",
+ ]
+ }
+
+ statement {
+ sid = "S3ClientConfigListAccess"
+ effect = "Allow"
+
+ actions = [
+ "s3:ListBucket",
+ ]
+
+ resources = [
+ var.client_config_bucket_arn,
+ ]
+ }
+
+ dynamic "statement" {
+ for_each = var.lambda_security_group_id != "" ? [1] : []
+ content {
+ sid = "VPCNetworkInterfacePermissions"
+ effect = "Allow"
+
+ actions = [
+ "ec2:CreateNetworkInterface",
+ "ec2:DeleteNetworkInterface",
+ "ec2:DescribeNetworkInterfaces",
+ ]
+
+ resources = [
+ "*",
+ ]
+ }
+ }
+
+ dynamic "statement" {
+ for_each = var.mtls_cert_secret_arn != "" ? [1] : []
+ content {
+ sid = "SecretsManagerMTLSCert"
+ effect = "Allow"
+
+ actions = [
+ "secretsmanager:GetSecretValue",
+ ]
+
+ resources = [
+ var.mtls_cert_secret_arn,
+ ]
+ }
+ }
+
+ dynamic "statement" {
+ for_each = var.mtls_test_cert_s3_bucket != "" ? [1] : []
+ content {
+ sid = "S3MTLSTestCertReadAccess"
+ effect = "Allow"
+
+ actions = [
+ "s3:GetObject",
+ ]
+
+ resources = [
+ "arn:aws:s3:::${var.mtls_test_cert_s3_bucket}/${var.mtls_test_cert_s3_key}",
+ "arn:aws:s3:::${var.mtls_test_cert_s3_bucket}/${var.mtls_test_ca_s3_key}",
+ ]
+ }
+ }
+
+ dynamic "statement" {
+ for_each = var.elasticache_endpoint != "" ? [1] : []
+ content {
+ sid = "ElastiCacheConnect"
+ effect = "Allow"
+
+ actions = [
+ "elasticache:Connect",
+ ]
+
+ resources = [
+ "arn:aws:elasticache:${var.region}:${var.aws_account_id}:serverlesscache:${var.elasticache_cache_name}",
+ "arn:aws:elasticache:${var.region}:${var.aws_account_id}:user:${var.elasticache_iam_username}",
+ ]
+ }
+ }
+}
diff --git a/infrastructure/terraform/modules/client-delivery/locals.tf b/infrastructure/terraform/modules/client-delivery/locals.tf
new file mode 100644
index 00000000..6ca35137
--- /dev/null
+++ b/infrastructure/terraform/modules/client-delivery/locals.tf
@@ -0,0 +1,21 @@
+locals {
+ csi = replace(
+ format(
+ "%s-%s-%s",
+ var.project,
+ var.environment,
+ var.component,
+ ),
+ "_",
+ "",
+ )
+
+ client_prefix = "${local.csi}-${var.client_id}"
+
+ default_tags = {
+ Project = var.project
+ Environment = var.environment
+ Component = var.component
+ Client = var.client_id
+ }
+}
diff --git a/infrastructure/terraform/modules/client-delivery/module_dlq_per_client.tf b/infrastructure/terraform/modules/client-delivery/module_dlq_per_client.tf
new file mode 100644
index 00000000..84c410dd
--- /dev/null
+++ b/infrastructure/terraform/modules/client-delivery/module_dlq_per_client.tf
@@ -0,0 +1,43 @@
+module "dlq_delivery" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-sqs.zip"
+
+ aws_account_id = var.aws_account_id
+ component = var.component
+ environment = var.environment
+ project = var.project
+ region = var.region
+ name = "${var.client_id}-delivery-dlq"
+
+ sqs_kms_key_arn = var.kms_key_arn
+
+ create_dlq = false
+}
+
+resource "aws_cloudwatch_metric_alarm" "dlq_depth" {
+ alarm_name = "${local.client_prefix}-dlq-depth"
+ alarm_description = join(" ", [
+ "RELIABILITY: Messages are in DLQ for client ${var.client_id}.",
+ "Failed callback deliveries require operator attention.",
+ ])
+
+ comparison_operator = "GreaterThanThreshold"
+ evaluation_periods = 1
+ metric_name = "ApproximateNumberOfMessagesVisible"
+ namespace = "AWS/SQS"
+ period = 300
+ statistic = "Sum"
+ threshold = 0
+ actions_enabled = true
+ treat_missing_data = "notBreaching"
+
+ dimensions = {
+ QueueName = "${local.client_prefix}-delivery-dlq-queue"
+ }
+
+ tags = merge(
+ local.default_tags,
+ {
+ Name = "${local.client_prefix}-dlq-depth"
+ },
+ )
+}
diff --git a/infrastructure/terraform/modules/client-delivery/module_https_client_lambda.tf b/infrastructure/terraform/modules/client-delivery/module_https_client_lambda.tf
new file mode 100644
index 00000000..1260d471
--- /dev/null
+++ b/infrastructure/terraform/modules/client-delivery/module_https_client_lambda.tf
@@ -0,0 +1,71 @@
+module "https_client_lambda" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-lambda.zip"
+
+ function_name = "https-client-${var.client_id}"
+ description = "HTTPS delivery Lambda for client ${var.client_id}"
+
+ aws_account_id = var.aws_account_id
+ component = var.component
+ environment = var.environment
+ project = var.project
+ region = var.region
+ group = var.group
+
+ log_retention_in_days = var.log_retention_in_days
+ kms_key_arn = var.kms_key_arn
+
+ iam_policy_document = {
+ body = data.aws_iam_policy_document.https_client_lambda.json
+ }
+
+ function_s3_bucket = var.lambda_s3_bucket
+ function_code_base_path = var.lambda_code_base_path
+ function_code_dir = "https-client-lambda/dist"
+ function_include_common = true
+ handler_function_name = "handler"
+ runtime = "nodejs22.x"
+ memory = var.lambda_memory
+ timeout = var.lambda_timeout
+ log_level = var.log_level
+
+ force_lambda_code_deploy = var.force_lambda_code_deploy
+ enable_lambda_insights = false
+ enable_xray_tracing = var.enable_xray_tracing
+
+ log_destination_arn = var.log_destination_arn
+ log_subscription_role_arn = var.log_subscription_role_arn
+
+ lambda_env_vars = {
+ APPLICATIONS_MAP_PARAMETER = var.applications_map_parameter_name
+ CLIENT_ID = var.client_id
+ CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS = "60"
+ CLIENT_SUBSCRIPTION_CONFIG_BUCKET = var.client_config_bucket
+ CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "client_subscriptions/"
+ DLQ_URL = module.dlq_delivery.sqs_queue_url
+ ELASTICACHE_CACHE_NAME = var.elasticache_cache_name
+ ELASTICACHE_ENDPOINT = var.elasticache_endpoint
+ ELASTICACHE_IAM_USERNAME = var.elasticache_iam_username
+ ENVIRONMENT = var.environment
+ MAX_RETRY_DURATION_SECONDS = tostring(var.max_retry_duration_seconds)
+ METRICS_NAMESPACE = "nhs-notify-client-callbacks"
+ MTLS_CERT_SECRET_ARN = var.mtls_cert_secret_arn
+ MTLS_TEST_CA_S3_KEY = var.mtls_test_ca_s3_key # gitleaks:allow
+ MTLS_TEST_CERT_S3_BUCKET = var.mtls_test_cert_s3_bucket
+ MTLS_TEST_CERT_S3_KEY = var.mtls_test_cert_s3_key # gitleaks:allow
+ QUEUE_URL = module.sqs_delivery.sqs_queue_url
+ }
+
+ vpc_config = var.lambda_security_group_id != "" ? {
+ subnet_ids = var.vpc_subnet_ids
+ security_group_ids = [var.lambda_security_group_id]
+ } : null
+}
+
+resource "aws_lambda_event_source_mapping" "sqs_delivery" {
+ event_source_arn = module.sqs_delivery.sqs_queue_arn
+ function_name = module.https_client_lambda.function_arn
+ batch_size = var.lambda_batch_size
+ enabled = true
+
+ function_response_types = ["ReportBatchItemFailures"]
+}
diff --git a/infrastructure/terraform/modules/client-delivery/module_sqs_per_client.tf b/infrastructure/terraform/modules/client-delivery/module_sqs_per_client.tf
new file mode 100644
index 00000000..0fad559b
--- /dev/null
+++ b/infrastructure/terraform/modules/client-delivery/module_sqs_per_client.tf
@@ -0,0 +1,47 @@
+module "sqs_delivery" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.9/terraform-sqs.zip"
+
+ aws_account_id = var.aws_account_id
+ component = var.component
+ environment = var.environment
+ project = var.project
+ region = var.region
+ name = "${var.client_id}-delivery"
+
+ sqs_kms_key_arn = var.kms_key_arn
+
+ visibility_timeout_seconds = var.sqs_visibility_timeout_seconds
+ max_receive_count = var.sqs_max_receive_count
+
+ create_dlq = false
+
+ sqs_policy_overload = data.aws_iam_policy_document.sqs_delivery.json
+}
+
+resource "aws_sqs_queue_redrive_policy" "delivery" {
+ queue_url = module.sqs_delivery.sqs_queue_url
+ redrive_policy = jsonencode({
+ deadLetterTargetArn = module.dlq_delivery.sqs_queue_arn
+ maxReceiveCount = var.sqs_max_receive_count
+ })
+}
+
+data "aws_iam_policy_document" "sqs_delivery" {
+ statement {
+ sid = "AllowEventBridgeToSendMessage"
+ effect = "Allow"
+
+ principals {
+ type = "Service"
+ identifiers = ["events.amazonaws.com"]
+ }
+
+ actions = [
+ "sqs:SendMessage",
+ ]
+
+ resources = [
+ "arn:aws:sqs:${var.region}:${var.aws_account_id}:${local.csi}-${var.client_id}-delivery-queue",
+ ]
+ }
+}
diff --git a/infrastructure/terraform/modules/client-delivery/variables.tf b/infrastructure/terraform/modules/client-delivery/variables.tf
new file mode 100644
index 00000000..643e163e
--- /dev/null
+++ b/infrastructure/terraform/modules/client-delivery/variables.tf
@@ -0,0 +1,212 @@
+variable "project" {
+ type = string
+ description = "The name of the tfscaffold project"
+}
+
+variable "environment" {
+ type = string
+ description = "The name of the tfscaffold environment"
+}
+
+variable "component" {
+ type = string
+ description = "Component name"
+}
+
+variable "aws_account_id" {
+ type = string
+ description = "Account ID"
+}
+
+variable "region" {
+ type = string
+ description = "AWS Region"
+}
+
+variable "group" {
+ type = string
+ description = "The name of the tfscaffold group"
+ default = null
+}
+
+variable "client_id" {
+ type = string
+ description = "Unique identifier for this client"
+}
+
+variable "kms_key_arn" {
+ type = string
+ description = "KMS Key ARN for encryption at rest"
+}
+
+variable "client_bus_name" {
+ type = string
+ description = "EventBridge bus name for subscription rules"
+}
+
+variable "subscriptions" {
+ type = map(object({
+ subscription_id = string
+ target_ids = list(string)
+ }))
+ description = "Subscription definitions for this client, keyed by subscription_id"
+}
+
+variable "subscription_targets" {
+ type = map(object({
+ subscription_id = string
+ target_id = string
+ }))
+ description = "Flattened subscription-target fanout map keyed by subscription-target composite key"
+}
+
+variable "client_config_bucket" {
+ type = string
+ description = "S3 bucket name containing client subscription configuration"
+}
+
+variable "client_config_bucket_arn" {
+ type = string
+ description = "S3 bucket ARN containing client subscription configuration"
+}
+
+variable "applications_map_parameter_name" {
+ type = string
+ description = "SSM Parameter Store path for the clientId-to-applicationData map"
+}
+
+variable "lambda_s3_bucket" {
+ type = string
+ description = "S3 bucket for Lambda function artefacts"
+}
+
+variable "lambda_code_base_path" {
+ type = string
+ description = "Base path to Lambda source code directories"
+}
+
+variable "force_lambda_code_deploy" {
+ type = bool
+ description = "Force Lambda code redeployment even when commit tag matches"
+ default = false
+}
+
+variable "log_level" {
+ type = string
+ description = "Log level for the Lambda function"
+ default = "INFO"
+}
+
+variable "log_retention_in_days" {
+ type = number
+ description = "CloudWatch log retention period in days"
+ default = 0
+}
+
+variable "log_destination_arn" {
+ type = string
+ description = "Firehose destination ARN for log forwarding"
+ default = ""
+}
+
+variable "log_subscription_role_arn" {
+ type = string
+ description = "IAM role ARN for CloudWatch log subscription"
+ default = ""
+}
+
+variable "lambda_batch_size" {
+ type = number
+ description = "Number of SQS messages per Lambda invocation"
+ default = 10
+}
+
+variable "lambda_memory" {
+ type = number
+ description = "Lambda memory allocation in MB"
+ default = 256
+}
+
+variable "lambda_timeout" {
+ type = number
+ description = "Lambda timeout in seconds"
+ default = 30
+}
+
+variable "max_retry_duration_seconds" {
+ type = number
+ description = "Maximum retry window before messages are sent to DLQ"
+ default = 7200
+}
+
+variable "sqs_visibility_timeout_seconds" {
+ type = number
+ description = "Visibility timeout for the per-client delivery queue"
+ default = 60
+}
+
+variable "sqs_max_receive_count" {
+ type = number
+ description = "Safety-net maximum receive count before a message moves to DLQ. Supplements the time-based retry window for cases where the Lambda fails before reaching the window check."
+ default = 100
+}
+
+variable "enable_xray_tracing" {
+ type = bool
+ description = "Enable AWS X-Ray active tracing for the Lambda function"
+ default = false
+}
+
+variable "mtls_cert_secret_arn" {
+ type = string
+ description = "Secrets Manager ARN for the mTLS client certificate"
+ default = ""
+}
+
+variable "mtls_test_cert_s3_bucket" {
+ type = string
+ description = "S3 bucket for dev mTLS test certificates"
+ default = ""
+}
+
+variable "mtls_test_cert_s3_key" {
+ type = string
+ description = "S3 key for dev mTLS test certificate bundle"
+ default = ""
+}
+
+variable "mtls_test_ca_s3_key" {
+ type = string
+ description = "S3 key for dev CA certificate PEM bundle used for server verification"
+ default = ""
+}
+
+variable "elasticache_endpoint" {
+ type = string
+ description = "ElastiCache Serverless endpoint URL"
+ default = ""
+}
+
+variable "elasticache_cache_name" {
+ type = string
+ description = "ElastiCache cache name for SigV4 token presigning"
+ default = ""
+}
+
+variable "elasticache_iam_username" {
+ type = string
+ description = "IAM username for ElastiCache authentication"
+ default = ""
+}
+
+variable "vpc_subnet_ids" {
+ type = list(string)
+ description = "VPC subnet IDs for Lambda execution"
+ default = []
+}
+
+variable "lambda_security_group_id" {
+ type = string
+ description = "Security group ID for the Lambda function"
+ default = ""
+}
diff --git a/infrastructure/terraform/modules/client-destination/README.md b/infrastructure/terraform/modules/client-destination/README.md
deleted file mode 100644
index 11b689c3..00000000
--- a/infrastructure/terraform/modules/client-destination/README.md
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-## Requirements
-
-No requirements.
-## Inputs
-
-| Name | Description | Type | Default | Required |
-|------|-------------|------|---------|:--------:|
-| [aws\_account\_id](#input\_aws\_account\_id) | Account ID | `string` | n/a | yes |
-| [client\_bus\_name](#input\_client\_bus\_name) | EventBus name where you create the rule | `string` | n/a | yes |
-| [component](#input\_component) | Component name | `string` | n/a | yes |
-| [environment](#input\_environment) | The name of the tfscaffold environment | `string` | n/a | yes |
-| [kms\_key\_arn](#input\_kms\_key\_arn) | KMS Key ARN | `string` | n/a | yes |
-| [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes |
-| [region](#input\_region) | AWS Region | `string` | n/a | yes |
-| [subscription\_targets](#input\_subscription\_targets) | Flattened subscription-target fanout map keyed by subscription-target composite key | map(object({
subscription_id = string
target_id = string
})) | n/a | yes |
-| [subscriptions](#input\_subscriptions) | Flattened subscription definitions keyed by subscription\_id | map(object({
client_id = string
subscription_id = string
target_ids = list(string)
})) | n/a | yes |
-| [targets](#input\_targets) | Flattened target definitions keyed by target\_id | map(object({
client_id = string
target_id = string
invocation_endpoint = string
invocation_rate_limit_per_second = number
http_method = string
header_name = string
header_value = string
})) | n/a | yes |
-## Modules
-
-| Name | Source | Version |
-|------|--------|---------|
-| [target\_dlq](#module\_target\_dlq) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-sqs.zip | n/a |
-## Outputs
-
-No outputs.
-
-
-
diff --git a/infrastructure/terraform/modules/client-destination/cloudwatch_event_api_destination_this.tf b/infrastructure/terraform/modules/client-destination/cloudwatch_event_api_destination_this.tf
deleted file mode 100644
index 4bec92cc..00000000
--- a/infrastructure/terraform/modules/client-destination/cloudwatch_event_api_destination_this.tf
+++ /dev/null
@@ -1,10 +0,0 @@
-resource "aws_cloudwatch_event_api_destination" "per_target" {
- for_each = var.targets
-
- name = "${local.csi}-${each.key}"
- description = "API Destination for ${each.key}"
- invocation_endpoint = each.value.invocation_endpoint
- http_method = each.value.http_method
- invocation_rate_limit_per_second = each.value.invocation_rate_limit_per_second
- connection_arn = aws_cloudwatch_event_connection.per_target[each.key].arn
-}
diff --git a/infrastructure/terraform/modules/client-destination/cloudwatch_event_connection_main.tf b/infrastructure/terraform/modules/client-destination/cloudwatch_event_connection_main.tf
deleted file mode 100644
index 7546d666..00000000
--- a/infrastructure/terraform/modules/client-destination/cloudwatch_event_connection_main.tf
+++ /dev/null
@@ -1,14 +0,0 @@
-resource "aws_cloudwatch_event_connection" "per_target" {
- for_each = var.targets
-
- name = "${local.csi}-${each.key}"
- description = "Event Connection which would be used by API Destination ${each.key}"
- authorization_type = "API_KEY"
-
- auth_parameters {
- api_key {
- key = each.value.header_name
- value = each.value.header_value
- }
- }
-}
diff --git a/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf b/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf
deleted file mode 100644
index bdf7ea47..00000000
--- a/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf
+++ /dev/null
@@ -1,46 +0,0 @@
-resource "aws_cloudwatch_event_rule" "per_subscription" {
- for_each = var.subscriptions
-
- name = "${local.csi}-${each.key}"
- description = "Client Callbacks event rule for subscription ${each.key}"
- event_bus_name = var.client_bus_name
-
- event_pattern = jsonencode({
- "detail" : {
- "subscriptions" : [each.value.subscription_id]
- }
- })
-}
-
-resource "aws_cloudwatch_event_target" "per_subscription_target" {
- for_each = var.subscription_targets
-
- rule = aws_cloudwatch_event_rule.per_subscription[each.value.subscription_id].name
- arn = aws_cloudwatch_event_api_destination.per_target[each.value.target_id].arn
- target_id = "${local.csi}-${each.value.target_id}"
- role_arn = aws_iam_role.api_target_role.arn
- event_bus_name = var.client_bus_name
-
- dead_letter_config {
- arn = module.target_dlq[each.value.target_id].sqs_queue_arn
- }
-
- input_transformer {
- input_paths = {
- data = "$.detail.payload.data"
- }
-
- input_template = "{\"data\": }"
- }
-
- http_target {
- header_parameters = {
- "x-hmac-sha256-signature" = "$.detail.signatures.${replace(each.value.target_id, "-", "_")}"
- }
- }
-
- retry_policy {
- maximum_retry_attempts = 3
- maximum_event_age_in_seconds = 3600
- }
-}
diff --git a/infrastructure/terraform/modules/client-destination/iam_role_api_target_role.tf b/infrastructure/terraform/modules/client-destination/iam_role_api_target_role.tf
deleted file mode 100644
index 1158a2b2..00000000
--- a/infrastructure/terraform/modules/client-destination/iam_role_api_target_role.tf
+++ /dev/null
@@ -1,83 +0,0 @@
-resource "aws_iam_role" "api_target_role" {
- name = "${local.csi}-api-target-target-role"
- description = "Role for client target rule"
- assume_role_policy = data.aws_iam_policy_document.api_target_role_assume_role_policy.json
-}
-
-data "aws_iam_policy_document" "api_target_role_assume_role_policy" {
- statement {
- actions = [
- "sts:AssumeRole"
- ]
-
- principals {
- type = "Service"
- identifiers = ["events.amazonaws.com"]
- }
- }
-}
-
-resource "aws_iam_role_policy_attachment" "api_target_role" {
- role = aws_iam_role.api_target_role.id
- policy_arn = aws_iam_policy.api_target_role.arn
-}
-
-resource "aws_iam_policy" "api_target_role" {
- name = "${local.csi}-api-target-role-policy"
- description = "IAM Policy for the client target role"
- path = "/"
- policy = data.aws_iam_policy_document.api_target_role.json
-}
-
-data "aws_iam_policy_document" "api_target_role" {
- dynamic "statement" {
- for_each = length(aws_cloudwatch_event_api_destination.per_target) > 0 ? [1] : []
- content {
- sid = "AllowAPIDestinationAccess"
- effect = "Allow"
-
- actions = [
- "events:InvokeApiDestination",
- ]
-
- resources = [
- for destination in aws_cloudwatch_event_api_destination.per_target :
- destination.arn
- ]
- }
- }
-
- dynamic "statement" {
- for_each = length(module.target_dlq) > 0 ? [1] : []
- content {
- sid = "AllowSQSSendMessageForDLQ"
- effect = "Allow"
-
- actions = [
- "sqs:SendMessage",
- ]
-
- resources = [
- for dlq in module.target_dlq :
- dlq.sqs_queue_arn
- ]
- }
- }
-
- statement {
- sid = "AllowKMSForDLQ"
- effect = "Allow"
-
- actions = [
- "kms:ReEncrypt*",
- "kms:GenerateDataKey*",
- "kms:Encrypt",
- "kms:DescribeKey",
- "kms:Decrypt"
- ]
-
- resources = [
- var.kms_key_arn,
- ]
- }
-}
diff --git a/infrastructure/terraform/modules/client-destination/locals.tf b/infrastructure/terraform/modules/client-destination/locals.tf
deleted file mode 100644
index fe672990..00000000
--- a/infrastructure/terraform/modules/client-destination/locals.tf
+++ /dev/null
@@ -1,12 +0,0 @@
-locals {
- csi = replace(
- format(
- "%s-%s-%s",
- var.project,
- var.environment,
- var.component,
- ),
- "_",
- "",
- )
-}
diff --git a/infrastructure/terraform/modules/client-destination/module_target_dlq.tf b/infrastructure/terraform/modules/client-destination/module_target_dlq.tf
deleted file mode 100644
index 36c4c277..00000000
--- a/infrastructure/terraform/modules/client-destination/module_target_dlq.tf
+++ /dev/null
@@ -1,41 +0,0 @@
-module "target_dlq" {
- source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-sqs.zip"
- for_each = var.targets
-
- aws_account_id = var.aws_account_id
- component = var.component
- environment = var.environment
- project = var.project
- region = var.region
- name = "${each.key}-dlq"
-
- sqs_kms_key_arn = var.kms_key_arn
-
- visibility_timeout_seconds = 60
-
- create_dlq = false
-
- sqs_policy_overload = data.aws_iam_policy_document.target_dlq[each.key].json
-}
-
-data "aws_iam_policy_document" "target_dlq" {
- for_each = var.targets
-
- statement {
- sid = "AllowEventBridgeToSendMessage"
- effect = "Allow"
-
- principals {
- type = "Service"
- identifiers = ["events.amazonaws.com"]
- }
-
- actions = [
- "sqs:SendMessage"
- ]
-
- resources = [
- "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-${var.component}-${each.key}-dlq-queue"
- ]
- }
-}
diff --git a/infrastructure/terraform/modules/client-destination/variables.tf b/infrastructure/terraform/modules/client-destination/variables.tf
deleted file mode 100644
index 2b9a0ceb..00000000
--- a/infrastructure/terraform/modules/client-destination/variables.tf
+++ /dev/null
@@ -1,67 +0,0 @@
-variable "project" {
- type = string
- description = "The name of the tfscaffold project"
-}
-
-variable "environment" {
- type = string
- description = "The name of the tfscaffold environment"
-}
-
-variable "component" {
- type = string
- description = "Component name"
-}
-
-variable "aws_account_id" {
- type = string
- description = "Account ID"
-}
-
-variable "region" {
- type = string
- description = "AWS Region"
-}
-
-variable "targets" {
- type = map(object({
- client_id = string
- target_id = string
- invocation_endpoint = string
- invocation_rate_limit_per_second = number
- http_method = string
- header_name = string
- header_value = string
- }))
-
- description = "Flattened target definitions keyed by target_id"
-}
-
-variable "subscriptions" {
- type = map(object({
- client_id = string
- subscription_id = string
- target_ids = list(string)
- }))
-
- description = "Flattened subscription definitions keyed by subscription_id"
-}
-
-variable "subscription_targets" {
- type = map(object({
- subscription_id = string
- target_id = string
- }))
-
- description = "Flattened subscription-target fanout map keyed by subscription-target composite key"
-}
-
-variable "client_bus_name" {
- type = string
- description = "EventBus name where you create the rule"
-}
-
-variable "kms_key_arn" {
- type = string
- description = "KMS Key ARN"
-}
diff --git a/knip.ts b/knip.ts
index 3dd626cb..f8612f70 100644
--- a/knip.ts
+++ b/knip.ts
@@ -18,7 +18,7 @@ const config: KnipConfig = {
// ESLint peer deps – referenced indirectly through plugin configs
"@stylistic/eslint-plugin",
"@typescript-eslint/parser",
- // Used in lambdas' lambda-build npm script via pnpm exec
+ // Used in lambdas' lambda-build script via pnpm exec
"esbuild",
// Used in scripts/tests/unit.sh (shell script, not scanned by knip)
"lcov-result-merger",
@@ -32,9 +32,21 @@ const config: KnipConfig = {
// Resolved transitively through tsconfig.base.json → @tsconfig/node22
ignoreDependencies: ["@tsconfig/node22"],
},
+ "lambdas/https-client-lambda": {
+ ignoreDependencies: ["@tsconfig/node22"],
+ },
"lambdas/mock-webhook-lambda": {
ignoreDependencies: ["@tsconfig/node22"],
},
+ "lambdas/perf-runner-lambda": {
+ ignoreDependencies: ["@tsconfig/node22", "@types/aws-lambda"],
+ },
+ "src/config-cache": {
+ ignoreDependencies: ["@tsconfig/node22"],
+ },
+ "src/config-subscription-cache": {
+ ignoreDependencies: ["@tsconfig/node22"],
+ },
"src/logger": {
ignoreDependencies: ["@tsconfig/node22"],
},
@@ -42,6 +54,7 @@ const config: KnipConfig = {
ignoreDependencies: ["@tsconfig/node22"],
},
"tests/integration": {
+ entry: ["helpers/**/*.ts"],
ignoreDependencies: [
"@tsconfig/node22",
// Used in helpers/sqs.ts and helpers/cloudwatch.ts; flagged because
@@ -49,9 +62,6 @@ const config: KnipConfig = {
"async-wait-until",
],
},
- "tests/performance": {
- ignoreDependencies: ["@tsconfig/node22"],
- },
"tests/test-support": {
ignoreDependencies: ["@tsconfig/node22"],
},
diff --git a/lambdas/client-transform-filter-lambda/package.json b/lambdas/client-transform-filter-lambda/package.json
index 266911da..668250b4 100644
--- a/lambdas/client-transform-filter-lambda/package.json
+++ b/lambdas/client-transform-filter-lambda/package.json
@@ -1,7 +1,7 @@
{
"dependencies": {
"@aws-sdk/client-s3": "catalog:aws",
- "@aws-sdk/client-ssm": "catalog:aws",
+ "@nhs-notify-client-callbacks/config-subscription-cache": "workspace:*",
"@nhs-notify-client-callbacks/logger": "workspace:*",
"@nhs-notify-client-callbacks/models": "workspace:*",
"aws-embedded-metrics": "catalog:app",
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts
index b46c49f8..b234a244 100644
--- a/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts
@@ -15,26 +15,12 @@ jest.mock("@aws-sdk/client-s3", () => {
};
});
-const mockSsmSend = jest.fn();
-jest.mock("@aws-sdk/client-ssm", () => {
- const actual = jest.requireActual("@aws-sdk/client-ssm");
- return {
- ...actual,
- SSMClient: jest.fn().mockImplementation(() => ({
- send: mockSsmSend,
- })),
- };
-});
-
-// Set environment variables before importing the handler/module under test so that
-// services constructed at module import time (e.g. applicationsMapService) see
-// the correct configuration.
+// Set environment variables before importing the handler/module under test.
process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "test-bucket";
process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "client_subscriptions/";
process.env.CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS = "60";
process.env.METRICS_NAMESPACE = "test-namespace";
process.env.ENVIRONMENT = "test";
-process.env.APPLICATIONS_MAP_PARAMETER = "/test/applications-map";
jest.mock("aws-embedded-metrics", () => ({
createMetricsLogger: jest.fn(() => ({
@@ -50,12 +36,11 @@ jest.mock("aws-embedded-metrics", () => ({
}));
import { GetObjectCommand, NoSuchKey } from "@aws-sdk/client-s3";
-import { GetParameterCommand } from "@aws-sdk/client-ssm";
import type { SQSRecord } from "aws-lambda";
import { EventTypes } from "@nhs-notify-client-callbacks/models";
import { createMessageStatusConfig } from "__tests__/helpers/client-subscription-fixtures";
import { createS3Client } from "services/config-loader-service";
-import { applicationsMapService, configLoaderService, handler } from "..";
+import { configLoaderService, handler } from "..";
const makeSqsRecord = (body: object): SQSRecord => ({
messageId: "sqs-id",
@@ -104,18 +89,8 @@ const validMessageStatusEvent = (clientId: string, messageStatus: string) => ({
});
describe("Lambda handler with S3 subscription filtering", () => {
- const applicationsMap = JSON.stringify({
- "client-1": "app-id-1",
- "client-a": "app-id-a",
- "client-b": "app-id-b",
- "client-no-config": "app-id-no-config",
- });
-
beforeEach(() => {
mockSend.mockClear();
- mockSsmSend.mockClear();
- applicationsMapService.reset();
- mockSsmSend.mockResolvedValue({ Parameter: { Value: applicationsMap } });
// Reset loader and clear cache for clean state between tests
configLoaderService.reset(
createS3Client({ AWS_ENDPOINT_URL: "http://localhost:4566" }),
@@ -129,7 +104,6 @@ describe("Lambda handler with S3 subscription filtering", () => {
delete process.env.CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS;
delete process.env.METRICS_NAMESPACE;
delete process.env.ENVIRONMENT;
- delete process.env.APPLICATIONS_MAP_PARAMETER;
});
it("passes event through when client config matches subscription", async () => {
@@ -148,12 +122,8 @@ describe("Lambda handler with S3 subscription filtering", () => {
expect(result).toHaveLength(1);
expect(mockSend).toHaveBeenCalledTimes(1);
expect(mockSend.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand);
- expect(mockSsmSend).toHaveBeenCalledTimes(1);
- expect(mockSsmSend.mock.calls[0][0]).toBeInstanceOf(GetParameterCommand);
expect(result[0]).toHaveProperty("payload");
expect(result[0]).toHaveProperty("subscriptions");
- expect(result[0]).toHaveProperty("signatures");
- expect(Object.values(result[0].signatures)[0]).toMatch(/^[0-9a-f]+$/);
});
it("filters out event when status is not in subscription", async () => {
@@ -251,25 +221,4 @@ describe("Lambda handler with S3 subscription filtering", () => {
// S3 fetched once per distinct client (client-a and client-b), not once per event
expect(mockSend).toHaveBeenCalledTimes(2);
});
-
- it("filters out event when no applicationId found in SSM map", async () => {
- mockSend.mockResolvedValue({
- Body: {
- transformToString: jest
- .fn()
- .mockResolvedValue(
- JSON.stringify(createValidConfig("client-unknown")),
- ),
- },
- });
- mockSsmSend.mockResolvedValue({
- Parameter: { Value: JSON.stringify({}) },
- });
-
- const result = await handler([
- makeSqsRecord(validMessageStatusEvent("client-unknown", "DELIVERED")),
- ]);
-
- expect(result).toHaveLength(0);
- });
});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts
index 14b10096..168d128d 100644
--- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts
@@ -10,7 +10,6 @@ import type {
import type { Logger } from "services/logger";
import type { CallbackMetrics } from "services/metrics";
import type { ConfigLoader } from "services/config-loader";
-import type { ApplicationsMapService } from "services/ssm-applications-map";
import { ObservabilityService } from "services/observability";
import { ConfigLoaderService } from "services/config-loader-service";
import {
@@ -71,15 +70,6 @@ const makeStubConfigLoaderService = (): ConfigLoaderService => {
return { getLoader: () => loader } as unknown as ConfigLoaderService;
};
-const makeStubApplicationsMapService = (): ApplicationsMapService =>
- ({
- getApplicationId: jest
- .fn()
- .mockImplementation(
- async (clientId: string) => `test-app-id-${clientId}`,
- ),
- }) as unknown as ApplicationsMapService;
-
describe("Lambda handler", () => {
const mockLogger = {
info: jest.fn(),
@@ -109,7 +99,6 @@ describe("Lambda handler", () => {
createObservabilityService: () =>
new ObservabilityService(mockLogger, mockMetrics, mockMetricsLogger),
createConfigLoaderService: makeStubConfigLoaderService,
- createApplicationsMapService: makeStubApplicationsMapService,
});
beforeEach(() => {
@@ -173,7 +162,6 @@ describe("Lambda handler", () => {
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("payload");
expect(result[0]).toHaveProperty("subscriptions");
- expect(result[0]).toHaveProperty("signatures");
const dataItem = result[0].payload.data[0];
expect(dataItem.type).toBe("MessageStatus");
expect((dataItem.attributes as MessageStatusAttributes).messageStatus).toBe(
@@ -203,7 +191,6 @@ describe("Lambda handler", () => {
new ObservabilityService(mockLogger, mockMetrics, mockMetricsLogger),
createConfigLoaderService: () =>
({ getLoader: () => customConfigLoader }) as ConfigLoaderService,
- createApplicationsMapService: makeStubApplicationsMapService,
});
const sqsMessage: SQSRecord = {
@@ -234,65 +221,6 @@ describe("Lambda handler", () => {
);
});
- it("should throw when any target is missing an apiKey", async () => {
- const customConfigLoader = {
- loadClientConfig: jest.fn().mockResolvedValue(
- createClientSubscriptionConfig("client-abc-123", {
- subscriptions: [
- createMessageStatusSubscription(["DELIVERED"], {
- targetIds: ["target-no-key", DEFAULT_TARGET_ID],
- }),
- ],
- targets: [
- createTarget({
- targetId: "target-no-key",
- apiKey: undefined as unknown as {
- headerName: string;
- headerValue: string;
- },
- }),
- createTarget({
- targetId: DEFAULT_TARGET_ID,
- apiKey: {
- headerName: "x-api-key",
- headerValue: "valid-key",
- },
- }),
- ],
- }),
- ),
- } as unknown as ConfigLoader;
-
- const handlerWithMixedTargets = createHandler({
- createObservabilityService: () =>
- new ObservabilityService(mockLogger, mockMetrics, mockMetricsLogger),
- createConfigLoaderService: () =>
- ({ getLoader: () => customConfigLoader }) as ConfigLoaderService,
- createApplicationsMapService: makeStubApplicationsMapService,
- });
-
- const sqsMessage: SQSRecord = {
- messageId: "sqs-msg-id-mixed",
- receiptHandle: "receipt-handle-mixed",
- body: JSON.stringify(validMessageStatusEvent),
- attributes: {
- ApproximateReceiveCount: "1",
- SentTimestamp: "1519211230",
- SenderId: "ABCDEFGHIJ",
- ApproximateFirstReceiveTimestamp: "1519211230",
- },
- messageAttributes: {},
- md5OfBody: "mock-md5",
- eventSource: "aws:sqs",
- eventSourceARN: "arn:aws:sqs:eu-west-2:123456789:mock-queue",
- awsRegion: "eu-west-2",
- };
-
- await expect(handlerWithMixedTargets([sqsMessage])).rejects.toThrow(
- "Missing apiKey for target target-no-key",
- );
- });
-
it("should handle batch of SQS messages from EventBridge Pipes", async () => {
const sqsMessages: SQSRecord[] = [
{
@@ -414,7 +342,6 @@ describe("Lambda handler", () => {
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("payload");
expect(result[0]).toHaveProperty("subscriptions");
- expect(result[0]).toHaveProperty("signatures");
const dataItem = result[0].payload.data[0];
expect(dataItem.type).toBe("ChannelStatus");
expect((dataItem.attributes as ChannelStatusAttributes).channelStatus).toBe(
@@ -481,7 +408,6 @@ describe("Lambda handler", () => {
const faultyHandler = createHandler({
createObservabilityService: () => faultyObservability,
createConfigLoaderService: makeStubConfigLoaderService,
- createApplicationsMapService: makeStubApplicationsMapService,
});
const sqsMessage: SQSRecord = {
@@ -662,7 +588,6 @@ describe("createHandler default wiring", () => {
[],
state.mockObservabilityInstance,
expect.any(Object),
- expect.any(Object),
);
expect(result).toEqual(["ok"]);
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts
deleted file mode 100644
index 6199b92c..00000000
--- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models";
-import {
- createClientSubscriptionConfig,
- createMessageStatusSubscription,
-} from "__tests__/helpers/client-subscription-fixtures";
-import { ConfigCache } from "services/config-cache";
-
-const createConfig = (): ClientSubscriptionConfiguration =>
- createClientSubscriptionConfig("client-1", {
- subscriptions: [
- createMessageStatusSubscription(["DELIVERED"], { targetIds: [] }),
- ],
- });
-
-describe("ConfigCache", () => {
- it("stores and retrieves configuration", () => {
- const cache = new ConfigCache(60_000);
- const config = createConfig();
-
- cache.set("client-1", config);
-
- expect(cache.get("client-1")).toEqual(config);
- });
-
- it("returns undefined for non-existent key", () => {
- const cache = new ConfigCache(60_000);
- const result = cache.get("non-existent");
-
- expect(result).toBeUndefined();
- });
-
- it("returns undefined for expired entries", () => {
- jest.useFakeTimers();
- jest.setSystemTime(new Date("2025-01-01T10:00:00Z"));
-
- const cache = new ConfigCache(1000); // 1 second TTL
- const config = createConfig();
-
- cache.set("client-1", config);
- expect(cache.get("client-1")).toEqual(config);
-
- jest.advanceTimersByTime(1001);
-
- const result = cache.get("client-1");
-
- expect(result).toBeUndefined();
-
- jest.useRealTimers();
- });
-
- it("clears all entries", () => {
- const cache = new ConfigCache(60_000);
- const config = createConfig();
-
- cache.set("client-1", config);
- cache.set("client-2", config);
-
- expect(cache.get("client-1")).toEqual(config);
- expect(cache.get("client-2")).toEqual(config);
-
- cache.clear();
-
- expect(cache.get("client-1")).toBeUndefined();
- expect(cache.get("client-2")).toBeUndefined();
- });
-});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader-service.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader-service.test.ts
index a5741d2b..c907bb3f 100644
--- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader-service.test.ts
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader-service.test.ts
@@ -1,4 +1,5 @@
import { S3Client } from "@aws-sdk/client-s3";
+import { ConfigSubscriptionCache } from "@nhs-notify-client-callbacks/config-subscription-cache";
import { ConfigLoader } from "services/config-loader";
import {
ConfigLoaderService,
@@ -8,6 +9,7 @@ import {
const mockS3Client = jest.mocked(S3Client);
const mockConfigLoader = jest.mocked(ConfigLoader);
+const mockConfigSubscriptionCache = jest.mocked(ConfigSubscriptionCache);
jest.mock("@aws-sdk/client-s3", () => ({
S3Client: jest.fn(),
@@ -17,12 +19,19 @@ jest.mock("services/config-loader", () => ({
ConfigLoader: jest.fn(),
}));
+jest.mock("@nhs-notify-client-callbacks/config-subscription-cache", () => ({
+ ConfigSubscriptionCache: jest.fn().mockImplementation(() => ({
+ reset: jest.fn(),
+ })),
+}));
+
describe("ConfigLoaderService", () => {
const originalBucket = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET;
const originalPrefix = process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX;
beforeEach(() => {
mockConfigLoader.mockClear();
+ mockConfigSubscriptionCache.mockClear();
process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "test-bucket";
});
@@ -60,7 +69,7 @@ describe("ConfigLoaderService", () => {
delete process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX;
const service = new ConfigLoaderService();
service.getLoader();
- expect(mockConfigLoader).toHaveBeenCalledWith(
+ expect(mockConfigSubscriptionCache).toHaveBeenCalledWith(
expect.objectContaining({ keyPrefix: "client_subscriptions/" }),
);
});
@@ -69,7 +78,7 @@ describe("ConfigLoaderService", () => {
process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "custom_prefix/";
const service = new ConfigLoaderService();
service.getLoader();
- expect(mockConfigLoader).toHaveBeenCalledWith(
+ expect(mockConfigSubscriptionCache).toHaveBeenCalledWith(
expect.objectContaining({ keyPrefix: "custom_prefix/" }),
);
});
@@ -90,7 +99,6 @@ describe("ConfigLoaderService", () => {
});
const service = new ConfigLoaderService();
service.reset(customClient);
- // Should not throw and the loader should be available immediately
expect(() => service.getLoader()).not.toThrow();
});
@@ -101,7 +109,7 @@ describe("ConfigLoaderService", () => {
});
const service = new ConfigLoaderService();
service.reset(customClient);
- expect(mockConfigLoader).toHaveBeenCalledWith(
+ expect(mockConfigSubscriptionCache).toHaveBeenCalledWith(
expect.objectContaining({ keyPrefix: "custom_prefix/" }),
);
});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts
index 495164fb..c9fecdce 100644
--- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts
@@ -1,6 +1,6 @@
import { GetObjectCommand, NoSuchKey, S3Client } from "@aws-sdk/client-s3";
import { createMessageStatusConfig } from "__tests__/helpers/client-subscription-fixtures";
-import { ConfigCache } from "services/config-cache";
+import { ConfigSubscriptionCache } from "@nhs-notify-client-callbacks/config-subscription-cache";
import { ConfigLoader } from "services/config-loader";
import { ConfigValidationError } from "services/validators/config-validator";
@@ -13,6 +13,15 @@ jest.mock("services/logger", () => ({
},
}));
+jest.mock("@nhs-notify-client-callbacks/logger", () => ({
+ logger: {
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
const mockBody = (json: string) => ({
transformToString: jest.fn().mockResolvedValue(json),
});
@@ -20,13 +29,15 @@ const mockBody = (json: string) => ({
const createValidConfig = (clientId: string) =>
createMessageStatusConfig(["DELIVERED"], clientId);
-const createLoader = (send: jest.Mock) =>
- new ConfigLoader({
+const createLoader = (send: jest.Mock) => {
+ const cache = new ConfigSubscriptionCache({
+ s3Client: { send } as unknown as S3Client,
bucketName: "bucket",
keyPrefix: "client_subscriptions/",
- s3Client: { send } as unknown as S3Client,
- cache: new ConfigCache(60_000),
+ ttlMs: 60_000,
});
+ return new ConfigLoader(cache);
+};
describe("ConfigLoader", () => {
it("loads and validates client configuration from S3", async () => {
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.component.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.component.test.ts
index 81af7f04..487e6130 100644
--- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.component.test.ts
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.component.test.ts
@@ -1,8 +1,17 @@
import { S3Client } from "@aws-sdk/client-s3";
import { createMessageStatusConfig } from "__tests__/helpers/client-subscription-fixtures";
-import { ConfigCache } from "services/config-cache";
+import { ConfigSubscriptionCache } from "@nhs-notify-client-callbacks/config-subscription-cache";
import { ConfigLoader } from "services/config-loader";
+jest.mock("@nhs-notify-client-callbacks/logger", () => ({
+ logger: {
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
const makeConfig = (messageStatuses: string[]) =>
createMessageStatusConfig(messageStatuses as never);
@@ -28,12 +37,13 @@ describe("config update component", () => {
},
});
- const loader = new ConfigLoader({
+ const cache = new ConfigSubscriptionCache({
+ s3Client: { send } as unknown as S3Client,
bucketName: "bucket",
keyPrefix: "client_subscriptions/",
- s3Client: { send } as unknown as S3Client,
- cache: new ConfigCache(1000),
+ ttlMs: 1000,
});
+ const loader = new ConfigLoader(cache);
const first = await loader.loadClientConfig("client-1");
const firstMessage = first?.subscriptions.find(
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/payload-signer.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/payload-signer.test.ts
deleted file mode 100644
index e1785d55..00000000
--- a/lambdas/client-transform-filter-lambda/src/__tests__/services/payload-signer.test.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { createHmac } from "node:crypto";
-import type { ClientCallbackPayload } from "@nhs-notify-client-callbacks/models";
-import { signPayload } from "services/payload-signer";
-
-const makePayload = (id = "msg-1") =>
- ({ data: [{ id }] }) as unknown as ClientCallbackPayload;
-
-describe("signPayload", () => {
- it("produces the expected HMAC-SHA256 hex string", () => {
- const payload = makePayload();
- const applicationId = "app-id-1";
- const apiKey = "api-key-1";
-
- const expected = createHmac("sha256", `${applicationId}.${apiKey}`)
- .update(JSON.stringify(payload))
- .digest("hex");
-
- expect(signPayload(payload, applicationId, apiKey)).toBe(expected);
- });
-
- it("returns a non-empty hex string", () => {
- const result = signPayload(makePayload(), "app-id", "api-key");
- expect(result).toMatch(/^[0-9a-f]+$/);
- });
-
- it("produces different signatures for different payloads", () => {
- const apiKey = "key";
- const appId = "app";
- expect(signPayload(makePayload("msg-1"), appId, apiKey)).not.toBe(
- signPayload(makePayload("msg-2"), appId, apiKey),
- );
- });
-
- it("produces different signatures for different applicationIds", () => {
- const payload = makePayload();
- const apiKey = "key";
- expect(signPayload(payload, "app-1", apiKey)).not.toBe(
- signPayload(payload, "app-2", apiKey),
- );
- });
-
- it("produces different signatures for different apiKeys", () => {
- const payload = makePayload();
- const appId = "app";
- expect(signPayload(payload, appId, "key-1")).not.toBe(
- signPayload(payload, appId, "key-2"),
- );
- });
-});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/ssm-applications-map.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/ssm-applications-map.test.ts
deleted file mode 100644
index 7123009a..00000000
--- a/lambdas/client-transform-filter-lambda/src/__tests__/services/ssm-applications-map.test.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
-import {
- ApplicationsMapService,
- createSsmClient,
- resolveCacheTtlMs,
-} from "services/ssm-applications-map";
-
-jest.mock("services/logger", () => ({
- logger: {
- debug: jest.fn(),
- info: jest.fn(),
- warn: jest.fn(),
- error: jest.fn(),
- },
-}));
-
-const makeSsmClient = (value: string | undefined) =>
- ({
- send: jest
- .fn()
- .mockResolvedValue(
- value === undefined ? {} : { Parameter: { Value: value } },
- ),
- }) as unknown as SSMClient;
-
-describe("ApplicationsMapService", () => {
- beforeEach(() => {
- jest.useFakeTimers();
- });
-
- afterEach(() => {
- jest.useRealTimers();
- });
-
- it("returns the applicationId for a known clientId", async () => {
- const ssmClient = makeSsmClient(
- JSON.stringify({ "client-1": "app-id-1", "client-2": "app-id-2" }),
- );
- const service = new ApplicationsMapService(ssmClient, "/test/param");
-
- expect(await service.getApplicationId("client-1")).toBe("app-id-1");
- });
-
- it("returns undefined for an unknown clientId", async () => {
- const ssmClient = makeSsmClient(JSON.stringify({ "client-1": "app-id-1" }));
- const service = new ApplicationsMapService(ssmClient, "/test/param");
-
- expect(await service.getApplicationId("unknown")).toBeUndefined();
- });
-
- it("loads from SSM and sends GetParameterCommand with WithDecryption", async () => {
- const ssmClient = makeSsmClient(JSON.stringify({ "client-1": "app-id-1" }));
- const service = new ApplicationsMapService(ssmClient, "/test/param");
-
- await service.getApplicationId("client-1");
-
- expect(ssmClient.send).toHaveBeenCalledTimes(1);
- expect((ssmClient.send as jest.Mock).mock.calls[0][0]).toBeInstanceOf(
- GetParameterCommand,
- );
- });
-
- it("caches the map and does not call SSM again within TTL", async () => {
- const ssmClient = makeSsmClient(JSON.stringify({ "client-1": "app-id-1" }));
- const service = new ApplicationsMapService(ssmClient, "/test/param", 5000);
-
- await service.getApplicationId("client-1");
- await service.getApplicationId("client-1");
-
- expect(ssmClient.send).toHaveBeenCalledTimes(1);
- });
-
- it("reloads from SSM after TTL expires", async () => {
- const ssmClient = makeSsmClient(JSON.stringify({ "client-1": "app-id-1" }));
- const service = new ApplicationsMapService(ssmClient, "/test/param", 5000);
-
- await service.getApplicationId("client-1");
- jest.advanceTimersByTime(6000);
- await service.getApplicationId("client-1");
-
- expect(ssmClient.send).toHaveBeenCalledTimes(2);
- });
-
- it("throws when SSM parameter is missing", async () => {
- const ssmClient = makeSsmClient(undefined);
- const service = new ApplicationsMapService(ssmClient, "/test/param");
-
- await expect(service.getApplicationId("client-1")).rejects.toThrow(
- "SSM parameter '/test/param' not found or has no value",
- );
- });
-
- it("throws when APPLICATIONS_MAP_PARAMETER is not set", async () => {
- const ssmClient = makeSsmClient(JSON.stringify({ "client-1": "app-id-1" }));
- const service = new ApplicationsMapService(ssmClient, undefined);
-
- await expect(service.getApplicationId("client-1")).rejects.toThrow(
- "APPLICATIONS_MAP_PARAMETER is required",
- );
- });
-
- it("throws when SSM parameter has empty value", async () => {
- const ssmClient = {
- send: jest.fn().mockResolvedValue({ Parameter: { Value: "" } }),
- } as unknown as SSMClient;
- const service = new ApplicationsMapService(ssmClient, "/test/param");
-
- await expect(service.getApplicationId("client-1")).rejects.toThrow(
- "SSM parameter '/test/param' not found or has no value",
- );
- });
-
- it("throws when SSM parameter contains invalid JSON", async () => {
- const ssmClient = makeSsmClient("not valid json");
- const service = new ApplicationsMapService(ssmClient, "/test/param");
-
- await expect(service.getApplicationId("client-1")).rejects.toThrow(
- "SSM parameter '/test/param' contains invalid JSON",
- );
- });
-
- it("reset clears the cache and forces reload on next call", async () => {
- const ssmClient = makeSsmClient(JSON.stringify({ "client-1": "app-id-1" }));
- const service = new ApplicationsMapService(ssmClient, "/test/param", 5000);
-
- await service.getApplicationId("client-1");
- service.reset();
- await service.getApplicationId("client-1");
-
- expect(ssmClient.send).toHaveBeenCalledTimes(2);
- });
-});
-
-describe("resolveCacheTtlMs", () => {
- it("returns configured value in ms", () => {
- expect(
- resolveCacheTtlMs({ APPLICATIONS_MAP_CACHE_TTL_SECONDS: "30" }),
- ).toBe(30_000);
- });
-
- it("returns default when env var is absent", () => {
- expect(resolveCacheTtlMs({})).toBe(60_000);
- });
-
- it("returns default when env var is not a valid number", () => {
- expect(
- resolveCacheTtlMs({ APPLICATIONS_MAP_CACHE_TTL_SECONDS: "invalid" }),
- ).toBe(60_000);
- });
-});
-
-describe("createSsmClient", () => {
- it("returns an SSMClient instance", () => {
- expect(createSsmClient({})).toBeInstanceOf(SSMClient);
- });
-});
diff --git a/lambdas/client-transform-filter-lambda/src/handler.ts b/lambdas/client-transform-filter-lambda/src/handler.ts
index 0d1f20b6..be05991c 100644
--- a/lambdas/client-transform-filter-lambda/src/handler.ts
+++ b/lambdas/client-transform-filter-lambda/src/handler.ts
@@ -7,13 +7,11 @@ import type {
} from "@nhs-notify-client-callbacks/models";
import { validateStatusPublishEvent } from "services/validators/event-validator";
import { transformEvent } from "services/transformers/event-transformer";
-import { extractCorrelationId, logger } from "services/logger";
+import { extractCorrelationId } from "services/logger";
import { ValidationError, getEventError } from "services/error-handler";
import type { ObservabilityService } from "services/observability";
import type { ConfigLoader } from "services/config-loader";
import { evaluateSubscriptionFilters } from "services/subscription-filter";
-import type { ApplicationsMapService } from "services/ssm-applications-map";
-import { signPayload } from "services/payload-signer";
const BATCH_CONCURRENCY = Number(process.env.BATCH_CONCURRENCY) || 10;
const MESSAGE_ROOT_URI = process.env.MESSAGE_ROOT_URI ?? "";
@@ -27,20 +25,9 @@ type FilteredEvent = UnsignedEvent & {
targetIds: string[];
};
-type SignedEvent = {
- transformedEvent: TransformedEvent;
- deliveryContext: {
- correlationId: string;
- eventType: string;
- clientId: string;
- messageId: string;
- };
-};
-
export interface TransformedEvent {
payload: ClientCallbackPayload;
subscriptions: string[];
- signatures: Record;
}
class BatchStats {
@@ -140,79 +127,6 @@ function processSingleEvent(
type ClientConfigMap = Map;
-async function signBatch(
- filteredEvents: FilteredEvent[],
- applicationsMapService: ApplicationsMapService,
- configByClientId: ClientConfigMap,
- stats: BatchStats,
- observability: ObservabilityService,
-): Promise {
- const results = await pMap(
- filteredEvents,
- async (event): Promise => {
- const { clientId } = event.data;
- const correlationId = extractCorrelationId(event) ?? event.id;
-
- const applicationId =
- await applicationsMapService.getApplicationId(clientId);
- if (!applicationId) {
- stats.recordFiltered();
- logger.warn(
- "No applicationId found in SSM map - event will not be delivered",
- { clientId, correlationId },
- );
- return undefined;
- }
-
- const clientConfig = configByClientId.get(clientId);
- const targetsById = new Map(
- (clientConfig?.targets ?? []).map((t) => [t.targetId, t]),
- );
-
- const signaturesByTarget = new Map();
-
- for (const targetId of event.targetIds) {
- const target = targetsById.get(targetId);
- const apiKey = target?.apiKey?.headerValue;
- if (!apiKey) {
- throw new ValidationError(
- `Missing apiKey for target ${targetId}`,
- correlationId,
- );
- }
- const signature = signPayload(
- event.transformedPayload,
- applicationId,
- apiKey,
- );
- signaturesByTarget.set(targetId.replaceAll("-", "_"), signature);
- observability.recordCallbackSigned(
- event.transformedPayload,
- correlationId,
- clientId,
- signature,
- );
- }
-
- return {
- transformedEvent: {
- payload: event.transformedPayload,
- subscriptions: event.subscriptionIds,
- signatures: Object.fromEntries(signaturesByTarget),
- },
- deliveryContext: {
- correlationId,
- eventType: event.type,
- clientId,
- messageId: event.data.messageId,
- },
- };
- },
- { concurrency: BATCH_CONCURRENCY },
- );
- return results.filter((e): e is SignedEvent => e !== undefined);
-}
-
async function loadClientConfigs(
events: UnsignedEvent[],
configLoader: ConfigLoader,
@@ -304,7 +218,6 @@ export async function processEvents(
event: SQSRecord[],
observability: ObservabilityService,
configLoader: ConfigLoader,
- applicationsMapService: ApplicationsMapService,
): Promise {
const startTime = Date.now();
const stats = new BatchStats();
@@ -324,20 +237,21 @@ export async function processEvents(
stats,
);
- const signedEvents = await signBatch(
- filteredEvents,
- applicationsMapService,
- configByClientId,
- stats,
- observability,
- );
-
- for (const signedEvent of signedEvents) {
- observability.recordDeliveryInitiated(signedEvent.deliveryContext);
- }
+ const deliverableEvents: TransformedEvent[] = filteredEvents.map(
+ (filteredEvent) => {
+ const correlationId = extractCorrelationId(filteredEvent);
+ observability.recordDeliveryInitiated({
+ correlationId,
+ eventType: filteredEvent.type,
+ clientId: filteredEvent.data.clientId,
+ messageId: filteredEvent.data.messageId,
+ });
- const deliverableEvents = signedEvents.map(
- (signedEvent) => signedEvent.transformedEvent,
+ return {
+ payload: filteredEvent.transformedPayload,
+ subscriptions: filteredEvent.subscriptionIds,
+ };
+ },
);
const processingTime = Date.now() - startTime;
diff --git a/lambdas/client-transform-filter-lambda/src/index.ts b/lambdas/client-transform-filter-lambda/src/index.ts
index 9d631bfe..5ef8e197 100644
--- a/lambdas/client-transform-filter-lambda/src/index.ts
+++ b/lambdas/client-transform-filter-lambda/src/index.ts
@@ -3,17 +3,13 @@ import { Logger } from "services/logger";
import { CallbackMetrics, createMetricLogger } from "services/metrics";
import { ObservabilityService } from "services/observability";
import { ConfigLoaderService } from "services/config-loader-service";
-import { ApplicationsMapService } from "services/ssm-applications-map";
import { type TransformedEvent, processEvents } from "handler";
export const configLoaderService = new ConfigLoaderService();
-export const applicationsMapService = new ApplicationsMapService();
-
export interface HandlerDependencies {
createObservabilityService?: () => ObservabilityService;
createConfigLoaderService?: () => ConfigLoaderService;
- createApplicationsMapService?: () => ApplicationsMapService;
}
function createDefaultObservabilityService(): ObservabilityService {
@@ -28,10 +24,6 @@ function createDefaultConfigLoaderService(): ConfigLoaderService {
return configLoaderService;
}
-function createDefaultApplicationsMapService(): ApplicationsMapService {
- return applicationsMapService;
-}
-
export function createHandler(
dependencies: Partial = {},
): (event: SQSRecord[]) => Promise {
@@ -41,19 +33,10 @@ export function createHandler(
const configLoader = (
dependencies.createConfigLoaderService ?? createDefaultConfigLoaderService
)();
- const applicationsMap = (
- dependencies.createApplicationsMapService ??
- createDefaultApplicationsMapService
- )();
return async (event: SQSRecord[]): Promise => {
const observability = createObservabilityService();
- return processEvents(
- event,
- observability,
- configLoader.getLoader(),
- applicationsMap,
- );
+ return processEvents(event, observability, configLoader.getLoader());
};
}
diff --git a/lambdas/client-transform-filter-lambda/src/services/config-loader-service.ts b/lambdas/client-transform-filter-lambda/src/services/config-loader-service.ts
index b0af71b0..b5542d01 100644
--- a/lambdas/client-transform-filter-lambda/src/services/config-loader-service.ts
+++ b/lambdas/client-transform-filter-lambda/src/services/config-loader-service.ts
@@ -1,5 +1,5 @@
import { S3Client } from "@aws-sdk/client-s3";
-import { ConfigCache } from "services/config-cache";
+import { ConfigSubscriptionCache } from "@nhs-notify-client-callbacks/config-subscription-cache";
import { ConfigLoader } from "services/config-loader";
const DEFAULT_CACHE_TTL_SECONDS = 60;
@@ -26,52 +26,49 @@ export const createS3Client = (
};
export class ConfigLoaderService {
- private readonly cache: ConfigCache;
-
private loader: ConfigLoader | undefined;
+ private cache: ConfigSubscriptionCache | undefined;
+
+ private readonly ttlMs: number;
+
constructor(cacheTtlMs: number = resolveCacheTtlMs()) {
- this.cache = new ConfigCache(cacheTtlMs);
+ this.ttlMs = cacheTtlMs;
}
getLoader(): ConfigLoader {
- const bucketName = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET;
- if (!bucketName) {
- throw new Error("CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required");
- }
-
if (this.loader) {
return this.loader;
}
- this.loader = new ConfigLoader({
- bucketName,
- keyPrefix:
- process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX ??
- "client_subscriptions/",
- s3Client: createS3Client(),
- cache: this.cache,
- });
-
+ this.cache = this.createCache(createS3Client());
+ this.loader = new ConfigLoader(this.cache);
return this.loader;
}
reset(s3Client?: S3Client): void {
+ this.cache?.reset();
this.loader = undefined;
- this.cache.clear();
+ this.cache = undefined;
if (s3Client) {
- const bucketName = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET;
- if (!bucketName) {
- throw new Error("CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required");
- }
- this.loader = new ConfigLoader({
- bucketName,
- keyPrefix:
- process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX ??
- "client_subscriptions/",
- s3Client,
- cache: this.cache,
- });
+ this.cache = this.createCache(s3Client);
+ this.loader = new ConfigLoader(this.cache);
+ }
+ }
+
+ private createCache(s3Client: S3Client): ConfigSubscriptionCache {
+ const bucketName = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET;
+ if (!bucketName) {
+ throw new Error("CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required");
}
+
+ return new ConfigSubscriptionCache({
+ s3Client,
+ bucketName,
+ keyPrefix:
+ process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX ??
+ "client_subscriptions/",
+ ttlMs: this.ttlMs,
+ });
}
}
diff --git a/lambdas/client-transform-filter-lambda/src/services/config-loader.ts b/lambdas/client-transform-filter-lambda/src/services/config-loader.ts
index 2d5b388f..0b272774 100644
--- a/lambdas/client-transform-filter-lambda/src/services/config-loader.ts
+++ b/lambdas/client-transform-filter-lambda/src/services/config-loader.ts
@@ -1,82 +1,21 @@
-import { GetObjectCommand, NoSuchKey, S3Client } from "@aws-sdk/client-s3";
+import type { ConfigSubscriptionCache } from "@nhs-notify-client-callbacks/config-subscription-cache";
import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models";
-import { ConfigCache } from "services/config-cache";
import { logger } from "services/logger";
import { wrapUnknownError } from "services/error-handler";
-import {
- ConfigValidationError,
- validateClientConfig,
-} from "services/validators/config-validator";
-
-type ConfigLoaderOptions = {
- bucketName: string;
- keyPrefix: string;
- s3Client: S3Client;
- cache: ConfigCache;
-};
-
-function throwAsConfigError(error: unknown, clientId: string): never {
- if (error instanceof ConfigValidationError) {
- logger.error("Config validation failed with schema violations", {
- clientId,
- validationErrors: error.issues,
- });
- throw error;
- }
-
- const { message } = wrapUnknownError(error);
- logger.error("Failed to load config from S3", { clientId });
- throw new ConfigValidationError([{ path: "config", message }]);
-}
+import { ConfigValidationError } from "services/validators/config-validator";
export class ConfigLoader {
- constructor(private readonly options: ConfigLoaderOptions) {}
+ constructor(private readonly cache: ConfigSubscriptionCache) {}
async loadClientConfig(
clientId: string,
): Promise {
- const cached = this.options.cache.get(clientId);
- if (cached) {
- logger.debug("Config loaded from cache", { clientId, cacheHit: true });
- return cached;
- }
-
- logger.debug("Config not in cache, fetching from S3", {
- clientId,
- cacheHit: false,
- });
-
try {
- const response = await this.options.s3Client.send(
- new GetObjectCommand({
- Bucket: this.options.bucketName,
- Key: `${this.options.keyPrefix}${clientId}.json`,
- }),
- );
-
- if (!response.Body) {
- throw new Error("S3 response body was empty");
- }
-
- const rawConfig = await response.Body.transformToString();
- const parsedConfig = JSON.parse(rawConfig) as unknown;
- const validated = validateClientConfig(parsedConfig);
- this.options.cache.set(clientId, validated);
- logger.info("Config loaded successfully from S3", {
- clientId,
- subscriptionCount: validated.subscriptions.length,
- });
- return validated;
+ return await this.cache.loadClientConfig(clientId);
} catch (error) {
- if (error instanceof NoSuchKey) {
- logger.info(
- "No config found in S3 for client - events will be filtered out",
- { clientId },
- );
- return undefined;
- }
- throwAsConfigError(error, clientId);
- return undefined;
+ const { message } = wrapUnknownError(error);
+ logger.error("Failed to load config", { clientId });
+ throw new ConfigValidationError([{ path: "config", message }]);
}
}
}
diff --git a/lambdas/client-transform-filter-lambda/src/services/observability.ts b/lambdas/client-transform-filter-lambda/src/services/observability.ts
index 4cfbf469..efd55eea 100644
--- a/lambdas/client-transform-filter-lambda/src/services/observability.ts
+++ b/lambdas/client-transform-filter-lambda/src/services/observability.ts
@@ -1,9 +1,6 @@
import type { MetricsLogger } from "aws-embedded-metrics";
import type { ClientCallbackPayload } from "@nhs-notify-client-callbacks/models";
-import {
- logCallbackGenerated,
- logCallbackSigned,
-} from "services/callback-logger";
+import { logCallbackGenerated } from "services/callback-logger";
import type { Logger } from "services/logger";
import { logLifecycleEvent } from "services/logger";
import type { CallbackMetrics } from "services/metrics";
@@ -95,15 +92,6 @@ export class ObservabilityService {
this.metrics.emitTransformationSuccess();
}
- recordCallbackSigned(
- payload: ClientCallbackPayload,
- correlationId: string | undefined,
- clientId: string,
- signature: string,
- ): void {
- logCallbackSigned(this.logger, payload, correlationId, clientId, signature);
- }
-
createChild(context: {
correlationId?: string;
eventType: string;
diff --git a/lambdas/client-transform-filter-lambda/src/services/ssm-applications-map.ts b/lambdas/client-transform-filter-lambda/src/services/ssm-applications-map.ts
deleted file mode 100644
index 87cead24..00000000
--- a/lambdas/client-transform-filter-lambda/src/services/ssm-applications-map.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
-import { logger } from "services/logger";
-
-const DEFAULT_CACHE_TTL_SECONDS = 60;
-
-export const createSsmClient = (
- env: NodeJS.ProcessEnv = process.env,
-): SSMClient => {
- const endpoint = env.AWS_ENDPOINT_URL;
- return new SSMClient({ endpoint });
-};
-
-export const resolveCacheTtlMs = (
- env: NodeJS.ProcessEnv = process.env,
-): number => {
- const ttlSeconds = Number.parseInt(
- env.APPLICATIONS_MAP_CACHE_TTL_SECONDS ?? `${DEFAULT_CACHE_TTL_SECONDS}`,
- 10,
- );
- return (
- (Number.isFinite(ttlSeconds) ? ttlSeconds : DEFAULT_CACHE_TTL_SECONDS) *
- 1000
- );
-};
-
-export class ApplicationsMapService {
- private cachedMap: Map | undefined;
-
- private cacheExpiresAt = 0;
-
- constructor(
- private readonly ssmClient: SSMClient = createSsmClient(),
- private readonly parameterName: string | undefined = process.env
- .APPLICATIONS_MAP_PARAMETER,
- private readonly cacheTtlMs: number = resolveCacheTtlMs(),
- ) {}
-
- async getApplicationId(clientId: string): Promise {
- const map = await this.getMap();
- return map.get(clientId);
- }
-
- private async getMap(): Promise