diff --git a/.github/actions/acceptance-tests/action.yaml b/.github/actions/acceptance-tests/action.yaml
index 7fe3c28a..6f6a44ba 100644
--- a/.github/actions/acceptance-tests/action.yaml
+++ b/.github/actions/acceptance-tests/action.yaml
@@ -49,6 +49,7 @@ runs:
shell: bash
env:
PROJECT: nhs
- COMPONENT: ${{ inputs.targetComponent }}
+ COMPONENT: cb
+ CLIENT_COMPONENT: cbc
run: |
make test-${{ inputs.testType }}
diff --git a/.github/workflows/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml
index aa5a82bf..a52772a5 100644
--- a/.github/workflows/cicd-1-pull-request.yaml
+++ b/.github/workflows/cicd-1-pull-request.yaml
@@ -173,6 +173,20 @@ jobs:
--overrideProjectName "nhs" \
--overrideRoleName "nhs-main-acct-client-callbacks-github-deploy" \
--overrides "branch_name=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}"
+ - name: Trigger callback-clients dynamic environment creation
+ shell: bash
+ run: |
+ .github/scripts/dispatch_internal_repo_workflow.sh \
+ --infraRepoName "$(echo ${{ github.repository }} | cut -d'/' -f2)" \
+ --releaseVersion "${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" \
+ --targetWorkflow "dispatch-deploy-dynamic-env.yaml" \
+ --targetEnvironment "pr${{ needs.metadata.outputs.pr_number }}" \
+ --targetComponent "callback-clients" \
+ --targetAccountGroup "nhs-notify-client-callbacks-dev" \
+ --terraformAction "apply" \
+ --overrideProjectName "nhs" \
+ --overrideRoleName "nhs-main-acct-client-callbacks-github-deploy" \
+ --overrides "branch_name=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}"
acceptance-stage: # Recommended maximum execution time is 10 minutes
name: "Acceptance stage"
needs: [metadata, build-stage, pr-create-dynamic-environment]
diff --git a/.github/workflows/pr_closed.yml b/.github/workflows/pr_closed.yml
index 42e61428..5a7cc82f 100644
--- a/.github/workflows/pr_closed.yml
+++ b/.github/workflows/pr_closed.yml
@@ -46,7 +46,7 @@ jobs:
strategy:
max-parallel: 1
matrix:
- component: [callbacks]
+ component: [callbacks, callback-clients]
steps:
- name: Checkout repository
diff --git a/.github/workflows/pr_destroy_dynamic_env.yml b/.github/workflows/pr_destroy_dynamic_env.yml
index 67abd292..ccc425b8 100644
--- a/.github/workflows/pr_destroy_dynamic_env.yml
+++ b/.github/workflows/pr_destroy_dynamic_env.yml
@@ -19,7 +19,23 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - name: Trigger dynamic environment destroy
+ - name: Trigger callback-clients dynamic environment destroy
+ env:
+ APP_PEM_FILE: ${{ secrets.APP_PEM_FILE }}
+ APP_CLIENT_ID: ${{ secrets.APP_CLIENT_ID }}
+ shell: bash
+ run: |
+ .github/scripts/dispatch_internal_repo_workflow.sh \
+ --infraRepoName "$(echo ${{ github.repository }} | cut -d'/' -f2)" \
+ --releaseVersion "${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" \
+ --targetWorkflow "dispatch-deploy-dynamic-env.yaml" \
+ --targetEnvironment "pr${{ github.event.number }}" \
+ --targetComponent "callback-clients" \
+ --targetAccountGroup "nhs-notify-client-callbacks-dev" \
+ --terraformAction "destroy" \
+ --overrideProjectName "nhs" \
+ --overrideRoleName "nhs-main-acct-client-callbacks-github-deploy"
+ - name: Trigger callbacks dynamic environment destroy
env:
APP_PEM_FILE: ${{ secrets.APP_PEM_FILE }}
APP_CLIENT_ID: ${{ secrets.APP_CLIENT_ID }}
@@ -34,4 +50,4 @@ jobs:
--targetAccountGroup "nhs-notify-client-callbacks-dev" \
--terraformAction "destroy" \
--overrideProjectName "nhs" \
- --overrideRoleName "nhs-main-acct-client-callbacks-github-deploy" \
+ --overrideRoleName "nhs-main-acct-client-callbacks-github-deploy"
diff --git a/.github/workflows/release_created.yml b/.github/workflows/release_created.yml
index 329282ae..827eac3e 100644
--- a/.github/workflows/release_created.yml
+++ b/.github/workflows/release_created.yml
@@ -22,7 +22,7 @@ jobs:
strategy:
max-parallel: 1
matrix:
- component: [callbacks]
+ component: [callbacks, callback-clients]
steps:
- name: Checkout repository
diff --git a/.tool-versions b/.tool-versions
index 4ca52a65..f098ca89 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -5,7 +5,7 @@ nodejs 24.14.1
pnpm 10.33.0
pre-commit 3.6.0
ruby 3.3.6
-terraform 1.10.1
+terraform 1.14.3
terraform-docs 0.19.0
#trivy 0.61.0 - TODO - Re-visit Trivy usage https://nhsd-jira.digital.nhs.uk/browse/CCM-15549
vale 3.6.0
diff --git a/infrastructure/terraform/components/callback-clients/.tool-versions b/infrastructure/terraform/components/callback-clients/.tool-versions
new file mode 100644
index 00000000..52428ded
--- /dev/null
+++ b/infrastructure/terraform/components/callback-clients/.tool-versions
@@ -0,0 +1 @@
+terraform 1.14.3
diff --git a/infrastructure/terraform/components/callback-clients/README.md b/infrastructure/terraform/components/callback-clients/README.md
new file mode 100644
index 00000000..72830609
--- /dev/null
+++ b/infrastructure/terraform/components/callback-clients/README.md
@@ -0,0 +1,52 @@
+
+
+
+
+## Requirements
+
+| Name | Version |
+|------|---------|
+| [terraform](#requirement\_terraform) | >= 1.10.1 |
+| [aws](#requirement\_aws) | 6.13 |
+| [external](#requirement\_external) | ~> 2.0 |
+| [random](#requirement\_random) | ~> 3.0 |
+| [tls](#requirement\_tls) | ~> 4.0 |
+## Inputs
+
+| Name | Description | Type | Default | Required |
+|------|-------------|------|---------|:--------:|
+| [applications\_map\_s3\_bucket](#input\_applications\_map\_s3\_bucket) | S3 bucket for the applications map | `string` | n/a | yes |
+| [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes |
+| [client\_config\_s3\_bucket](#input\_client\_config\_s3\_bucket) | S3 bucket for client subscription configuration | `string` | n/a | yes |
+| [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no |
+| [deploy\_mock\_clients](#input\_deploy\_mock\_clients) | Flag to deploy mock webhook lambda for integration testing | `bool` | `false` | no |
+| [enable\_xray\_tracing](#input\_enable\_xray\_tracing) | Enable AWS X-Ray active tracing for Lambda functions | `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) | If the lambda package in s3 has the same commit id tag as the terraform build branch, the lambda will not update automatically. Set to True if making changes to Lambda code from on the same commit for example during development | `bool` | `false` | no |
+| [group](#input\_group) | The group variables are being inherited from (often synonmous with account short-name) | `string` | n/a | yes |
+| [log\_level](#input\_log\_level) | The log level to be used in lambda functions within the component | `string` | `"INFO"` | no |
+| [log\_retention\_in\_days](#input\_log\_retention\_in\_days) | The retention period in days for the Cloudwatch Logs events to be retained, default of 0 is indefinite | `number` | `0` | no |
+| [mtls\_ca\_s3\_key](#input\_mtls\_ca\_s3\_key) | S3 key for the CA certificate PEM bundle used for server verification | `string` | `""` | no |
+| [mtls\_cert\_s3\_bucket](#input\_mtls\_cert\_s3\_bucket) | S3 bucket containing the mTLS client certificate bundle | `string` | `""` | no |
+| [mtls\_cert\_s3\_key](#input\_mtls\_cert\_s3\_key) | S3 key for the mTLS client certificate PEM bundle | `string` | `""` | no |
+| [parent\_acct\_environment](#input\_parent\_acct\_environment) | Name of the environment responsible for the acct resources used, affects things like DNS zone. Useful for named dev environments | `string` | `"main"` | no |
+| [parent\_callbacks\_environment](#input\_parent\_callbacks\_environment) | The name of the environment which deployed the parent callbacks component. Used to identify the appropriate state file. | `string` | `"main"` | no |
+| [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes |
+| [region](#input\_region) | The AWS Region | `string` | n/a | yes |
+| [s3\_enable\_force\_destroy](#input\_s3\_enable\_force\_destroy) | Whether to enable force destroy for the S3 buckets created in this module | `bool` | `false` | no |
+| [token\_bucket\_burst\_capacity](#input\_token\_bucket\_burst\_capacity) | Token bucket burst capacity used by the rate limiter | `number` | `2250` | no |
+## Modules
+
+| Name | Source | Version |
+|------|--------|---------|
+| [client\_delivery](#module\_client\_delivery) | ../../modules/client-delivery | n/a |
+| [mock\_webhook\_lambda](#module\_mock\_webhook\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-lambda.zip | n/a |
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [applications\_map\_s3](#output\_applications\_map\_s3) | S3 location of the client-to-application map |
+| [mock\_webhook\_alb\_dns](#output\_mock\_webhook\_alb\_dns) | DNS name of the mock webhook ALB |
+
+
+
diff --git a/infrastructure/terraform/components/callback-clients/locals.tf b/infrastructure/terraform/components/callback-clients/locals.tf
new file mode 100644
index 00000000..6fd06208
--- /dev/null
+++ b/infrastructure/terraform/components/callback-clients/locals.tf
@@ -0,0 +1,62 @@
+locals {
+ bc_name = "client-callbacks"
+ aws_lambda_functions_dir_path = "../../../../lambdas"
+ log_destination_arn = local.callbacks.log_destination_arn
+ log_subscription_role_arn = local.callbacks.log_subscription_role_arn
+
+ clients_dir_path = "${path.module}/../../modules/clients"
+
+ config_clients = merge([
+ for filename in fileset(local.clients_dir_path, "*.json") : {
+ (replace(filename, ".json", "")) = jsondecode(file("${local.clients_dir_path}/${filename}"))
+ }
+ ]...)
+
+ mock_server_spki_hash = var.deploy_mock_clients ? data.external.mock_server_spki_hash[0].result.hash : ""
+
+ enriched_mock_config_clients = var.deploy_mock_clients ? {
+ for client_id, client in local.config_clients :
+ client_id => merge(client, {
+ targets = [
+ for target in try(client.targets, []) :
+ merge(target, {
+ invocationEndpoint = "https://${aws_lb.mock_webhook_mtls[0].dns_name}/${target.targetId}"
+ apiKey = merge(target.apiKey, { headerValue = random_password.mock_webhook_api_key[0].result })
+ delivery = merge(try(target.delivery, {}), {
+ mtls = merge(try(target.delivery.mtls, {}), {
+ certPinning = merge(try(target.delivery.mtls.certPinning, {}), try(target.delivery.mtls.certPinning.enabled, false) ? {
+ spkiHash = local.mock_server_spki_hash
+ } : {})
+ })
+ })
+ })
+ ]
+ })
+ } : local.config_clients
+
+ client_subscriptions = {
+ for client_id, data in local.config_clients :
+ client_id => {
+ for subscription in try(data.subscriptions, []) :
+ subscription.subscriptionId => {
+ subscription_id = subscription.subscriptionId
+ target_ids = try(subscription.targetIds, [])
+ }
+ }
+ }
+
+ client_subscription_targets = {
+ for client_id, data in local.config_clients :
+ client_id => merge([
+ for subscription in try(data.subscriptions, []) : {
+ for target_id in try(subscription.targetIds, []) :
+ "${subscription.subscriptionId}-${target_id}" => {
+ subscription_id = subscription.subscriptionId
+ target_id = target_id
+ }
+ }
+ ]...)
+ }
+
+ applications_map_s3_key = "${var.environment}/applications-map.json"
+}
diff --git a/infrastructure/terraform/components/callback-clients/locals_remote_state.tf b/infrastructure/terraform/components/callback-clients/locals_remote_state.tf
new file mode 100644
index 00000000..876c2f6c
--- /dev/null
+++ b/infrastructure/terraform/components/callback-clients/locals_remote_state.tf
@@ -0,0 +1,40 @@
+locals {
+ callbacks = data.terraform_remote_state.callbacks.outputs
+ acct = data.terraform_remote_state.acct.outputs
+}
+
+data "terraform_remote_state" "callbacks" {
+ backend = "s3"
+
+ config = {
+ bucket = local.terraform_state_bucket
+
+ key = format(
+ "%s/%s/%s/%s/callbacks.tfstate",
+ var.project,
+ var.aws_account_id,
+ "eu-west-2",
+ var.parent_callbacks_environment
+ )
+
+ region = "eu-west-2"
+ }
+}
+
+data "terraform_remote_state" "acct" {
+ backend = "s3"
+
+ config = {
+ bucket = local.terraform_state_bucket
+
+ key = format(
+ "%s/%s/%s/%s/acct.tfstate",
+ var.project,
+ var.aws_account_id,
+ "eu-west-2",
+ var.parent_acct_environment
+ )
+
+ region = "eu-west-2"
+ }
+}
diff --git a/infrastructure/terraform/components/callback-clients/locals_tfscaffold.tf b/infrastructure/terraform/components/callback-clients/locals_tfscaffold.tf
new file mode 100644
index 00000000..5c28416c
--- /dev/null
+++ b/infrastructure/terraform/components/callback-clients/locals_tfscaffold.tf
@@ -0,0 +1,46 @@
+locals {
+ component = "cbc"
+
+ terraform_state_bucket = format(
+ "%s-tfscaffold-%s-%s",
+ var.project,
+ var.aws_account_id,
+ var.region,
+ )
+
+ csi = replace(
+ format(
+ "%s-%s-%s",
+ var.project,
+ var.environment,
+ local.component,
+ ),
+ "_",
+ "",
+ )
+
+ # CSI for use in resources with a global namespace, i.e. S3 Buckets
+ csi_global = replace(
+ format(
+ "%s-%s-%s-%s-%s",
+ var.project,
+ var.aws_account_id,
+ var.region,
+ var.environment,
+ local.component,
+ ),
+ "_",
+ "",
+ )
+
+ default_tags = merge(
+ var.default_tags,
+ {
+ Project = var.project
+ Environment = var.environment
+ Component = local.component
+ Group = var.group
+ Name = local.csi
+ },
+ )
+}
diff --git a/infrastructure/terraform/components/callbacks/module_client_delivery.tf b/infrastructure/terraform/components/callback-clients/module_client_delivery.tf
similarity index 52%
rename from infrastructure/terraform/components/callbacks/module_client_delivery.tf
rename to infrastructure/terraform/components/callback-clients/module_client_delivery.tf
index cce31bd5..7868338f 100644
--- a/infrastructure/terraform/components/callbacks/module_client_delivery.tf
+++ b/infrastructure/terraform/components/callback-clients/module_client_delivery.tf
@@ -5,23 +5,25 @@ module "client_delivery" {
project = var.project
aws_account_id = var.aws_account_id
region = var.region
- component = var.component
+ component = local.component
environment = var.environment
group = var.group
client_id = each.key
- client_bus_name = aws_cloudwatch_event_bus.main.name
- kms_key_arn = module.kms.key_arn
+ client_bus_name = local.callbacks.eventbus_name.name
+ kms_key_arn = local.callbacks.kms_key_arn
subscriptions = local.client_subscriptions[each.key]
subscription_targets = local.client_subscription_targets[each.key]
- client_config_bucket = module.client_config_bucket.bucket
- client_config_bucket_arn = module.client_config_bucket.arn
+ client_config_bucket = var.client_config_s3_bucket
+ client_config_bucket_arn = local.callbacks.client_config_bucket.arn
+ client_config_key_prefix = "${var.environment}/client_subscriptions/"
- applications_map_parameter_name = local.applications_map_parameter_name
+ applications_map_s3_bucket = var.applications_map_s3_bucket
+ applications_map_s3_key = local.applications_map_s3_key
- lambda_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"]
+ lambda_s3_bucket = local.callbacks.lambda_s3_bucket
lambda_code_base_path = local.aws_lambda_functions_dir_path
force_lambda_code_deploy = var.force_lambda_code_deploy
@@ -30,11 +32,11 @@ module "client_delivery" {
enable_xray_tracing = var.enable_xray_tracing
log_destination_arn = local.log_destination_arn
- log_subscription_role_arn = local.acct.log_subscription_role_arn
+ log_subscription_role_arn = local.log_subscription_role_arn
- elasticache_endpoint = aws_elasticache_serverless_cache.delivery_state.endpoint[0].address
- elasticache_cache_name = aws_elasticache_serverless_cache.delivery_state.name
- elasticache_iam_username = "${var.project}-${var.environment}-${var.component}-elasticache-user"
+ elasticache_endpoint = local.callbacks.elasticache.endpoint
+ elasticache_cache_name = local.callbacks.elasticache.cache_name
+ elasticache_iam_username = local.callbacks.elasticache.iam_username
mtls_cert_s3_bucket = local.mtls_cert_s3_bucket
mtls_cert_s3_key = local.mtls_cert_s3_key # gitleaks:allow
@@ -42,6 +44,6 @@ module "client_delivery" {
token_bucket_burst_capacity = var.token_bucket_burst_capacity
- vpc_subnet_ids = try(local.acct.private_subnets[local.bc_name], [])
- lambda_security_group_id = aws_security_group.https_client_lambda.id
+ vpc_subnet_ids = local.callbacks.vpc_subnet_ids
+ lambda_security_group_id = local.callbacks.security_group_id
}
diff --git a/infrastructure/terraform/components/callbacks/module_mock_webhook_alb_mtls.tf b/infrastructure/terraform/components/callback-clients/module_mock_webhook_alb_mtls.tf
similarity index 92%
rename from infrastructure/terraform/components/callbacks/module_mock_webhook_alb_mtls.tf
rename to infrastructure/terraform/components/callback-clients/module_mock_webhook_alb_mtls.tf
index eb8b6776..bf3c2698 100644
--- a/infrastructure/terraform/components/callbacks/module_mock_webhook_alb_mtls.tf
+++ b/infrastructure/terraform/components/callback-clients/module_mock_webhook_alb_mtls.tf
@@ -15,7 +15,7 @@ resource "aws_security_group" "mock_webhook_alb" {
resource "aws_vpc_security_group_ingress_rule" "mock_webhook_alb_https" {
count = var.deploy_mock_clients ? 1 : 0
security_group_id = aws_security_group.mock_webhook_alb[0].id
- referenced_security_group_id = aws_security_group.https_client_lambda.id
+ referenced_security_group_id = local.callbacks.security_group_id
from_port = 443
to_port = 443
ip_protocol = "tcp"
@@ -41,17 +41,17 @@ resource "aws_acm_certificate" "mock_webhook_server" {
resource "aws_lb" "mock_webhook_mtls" {
count = var.deploy_mock_clients ? 1 : 0
- name = substr("${local.csi}-mock-mtls", 0, 32)
+ name = "${local.csi}-mock-mtls"
internal = true
load_balancer_type = "application"
security_groups = [aws_security_group.mock_webhook_alb[0].id]
- subnets = try(local.acct.private_subnets[local.bc_name], [])
+ subnets = local.callbacks.vpc_subnet_ids
tags = local.default_tags
}
resource "aws_lb_target_group" "mock_webhook_mtls" {
count = var.deploy_mock_clients ? 1 : 0
- name = substr("${local.csi}-mock-mtls", 0, 32)
+ name = "${local.csi}-mock-mtls"
target_type = "lambda"
tags = local.default_tags
}
diff --git a/infrastructure/terraform/components/callbacks/module_mock_webhook_lambda.tf b/infrastructure/terraform/components/callback-clients/module_mock_webhook_lambda.tf
similarity index 86%
rename from infrastructure/terraform/components/callbacks/module_mock_webhook_lambda.tf
rename to infrastructure/terraform/components/callback-clients/module_mock_webhook_lambda.tf
index 467dc1c6..786d6fe9 100644
--- a/infrastructure/terraform/components/callbacks/module_mock_webhook_lambda.tf
+++ b/infrastructure/terraform/components/callback-clients/module_mock_webhook_lambda.tf
@@ -6,20 +6,20 @@ module "mock_webhook_lambda" {
description = "Mock webhook endpoint for integration testing - logs received callbacks to CloudWatch"
aws_account_id = var.aws_account_id
- component = var.component
+ component = local.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 = module.kms.key_arn
+ kms_key_arn = local.callbacks.kms_key_arn
iam_policy_document = {
body = data.aws_iam_policy_document.mock_webhook_lambda[0].json
}
- function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"]
+ function_s3_bucket = local.callbacks.lambda_s3_bucket
function_code_base_path = local.aws_lambda_functions_dir_path
function_code_dir = "mock-webhook-lambda/dist"
function_include_common = true
@@ -33,7 +33,7 @@ module "mock_webhook_lambda" {
enable_lambda_insights = false
log_destination_arn = local.log_destination_arn
- log_subscription_role_arn = local.acct.log_subscription_role_arn
+ log_subscription_role_arn = local.log_subscription_role_arn
lambda_env_vars = {
LOG_LEVEL = var.log_level
@@ -60,7 +60,7 @@ data "aws_iam_policy_document" "mock_webhook_lambda" {
]
resources = [
- module.kms.key_arn,
+ local.callbacks.kms_key_arn,
]
}
}
diff --git a/infrastructure/terraform/components/callback-clients/outputs.tf b/infrastructure/terraform/components/callback-clients/outputs.tf
new file mode 100644
index 00000000..2651b9c5
--- /dev/null
+++ b/infrastructure/terraform/components/callback-clients/outputs.tf
@@ -0,0 +1,12 @@
+output "mock_webhook_alb_dns" {
+ description = "DNS name of the mock webhook ALB"
+ value = var.deploy_mock_clients ? aws_lb.mock_webhook_mtls[0].dns_name : null
+}
+
+output "applications_map_s3" {
+ description = "S3 location of the client-to-application map"
+ value = {
+ bucket = var.applications_map_s3_bucket
+ key = local.applications_map_s3_key
+ }
+}
diff --git a/infrastructure/terraform/components/callback-clients/pre.sh b/infrastructure/terraform/components/callback-clients/pre.sh
new file mode 100644
index 00000000..918f9bb1
--- /dev/null
+++ b/infrastructure/terraform/components/callback-clients/pre.sh
@@ -0,0 +1,44 @@
+# This script is run before Terraform apply for the callback-clients component.
+# It installs dependencies, syncs client config from S3 into modules/clients/,
+# copies mock/perf fixtures when needed, and builds lambda workspaces.
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd "${script_dir}/../../../.." && pwd)"
+clients_dir="${repo_root}/infrastructure/terraform/modules/clients"
+
+_real_script="$(readlink -f "${BASH_SOURCE[0]}")"
+bounded_context_root="$(cd "$(dirname "${_real_script}")/../../../.." && pwd)"
+
+# Resolve deploy_mock_clients and deploy_perf_runner from tfvars
+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
+
+pnpm run generate-dependencies
+
+"${script_dir}/../callbacks/sync-client-config.sh"
+
+if [ "${deploy_mock_clients}" == "true" ]; then
+ 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/callback-clients/provider_aws.tf b/infrastructure/terraform/components/callback-clients/provider_aws.tf
new file mode 100644
index 00000000..d694811e
--- /dev/null
+++ b/infrastructure/terraform/components/callback-clients/provider_aws.tf
@@ -0,0 +1,24 @@
+provider "aws" {
+ region = var.region
+
+ allowed_account_ids = [
+ var.aws_account_id,
+ ]
+
+ default_tags {
+ tags = local.default_tags
+ }
+}
+
+provider "aws" {
+ alias = "us-east-1"
+ region = "us-east-1"
+
+ default_tags {
+ tags = local.default_tags
+ }
+
+ allowed_account_ids = [
+ var.aws_account_id,
+ ]
+}
diff --git a/infrastructure/terraform/components/callbacks/s3_bucket_mtls_test_certs.tf b/infrastructure/terraform/components/callback-clients/s3_bucket_mtls_test_certs.tf
similarity index 67%
rename from infrastructure/terraform/components/callbacks/s3_bucket_mtls_test_certs.tf
rename to infrastructure/terraform/components/callback-clients/s3_bucket_mtls_test_certs.tf
index c29806cd..3594b016 100644
--- a/infrastructure/terraform/components/callbacks/s3_bucket_mtls_test_certs.tf
+++ b/infrastructure/terraform/components/callback-clients/s3_bucket_mtls_test_certs.tf
@@ -1,67 +1,8 @@
-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"
- mtls_cert_s3_bucket = var.deploy_mock_clients ? module.mtls_test_certs_bucket[0].bucket : var.mtls_cert_s3_bucket
+ mtls_cert_s3_bucket = var.mtls_cert_s3_bucket
mtls_cert_s3_key = var.deploy_mock_clients ? local.mtls_test_cert_s3_key : var.mtls_cert_s3_key # gitleaks:allow
mtls_ca_s3_key = var.deploy_mock_clients ? local.mtls_test_ca_s3_key : var.mtls_ca_s3_key # gitleaks:allow
}
@@ -158,22 +99,22 @@ resource "tls_locally_signed_cert" "mock_server" {
resource "aws_s3_object" "mtls_test_client_bundle" {
count = var.deploy_mock_clients ? 1 : 0
- bucket = module.mtls_test_certs_bucket[0].id
+ bucket = var.mtls_cert_s3_bucket
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"
+ kms_key_id = local.callbacks.kms_key_arn
+ 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
+ bucket = var.mtls_cert_s3_bucket
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"
+ kms_key_id = local.callbacks.kms_key_arn
+ content_type = "application/x-pem-file"
}
# Compute the base64-encoded SHA-256 hash of the mock server's SPKI (Subject Public Key Info) DER.
diff --git a/infrastructure/terraform/components/callback-clients/s3_client_config_objects.tf b/infrastructure/terraform/components/callback-clients/s3_client_config_objects.tf
new file mode 100644
index 00000000..afca6106
--- /dev/null
+++ b/infrastructure/terraform/components/callback-clients/s3_client_config_objects.tf
@@ -0,0 +1,9 @@
+resource "aws_s3_object" "mock_client_config" {
+ for_each = var.deploy_mock_clients ? local.enriched_mock_config_clients : {}
+
+ bucket = var.client_config_s3_bucket
+ key = "${var.environment}/client_subscriptions/${each.key}.json"
+ content = jsonencode(each.value)
+ content_type = "application/json"
+ kms_key_id = local.callbacks.kms_key_arn
+}
diff --git a/infrastructure/terraform/components/callback-clients/s3_object_applications_map.tf b/infrastructure/terraform/components/callback-clients/s3_object_applications_map.tf
new file mode 100644
index 00000000..ee0428e8
--- /dev/null
+++ b/infrastructure/terraform/components/callback-clients/s3_object_applications_map.tf
@@ -0,0 +1,9 @@
+resource "aws_s3_object" "applications_map" {
+ count = var.deploy_mock_clients ? 1 : 0
+
+ bucket = var.applications_map_s3_bucket
+ key = local.applications_map_s3_key
+ content = jsonencode({ for client_id, client in local.config_clients : client_id => try(client.applicationId, client_id) })
+ content_type = "application/json"
+ kms_key_id = local.callbacks.kms_key_arn
+}
diff --git a/infrastructure/terraform/components/callback-clients/variables.tf b/infrastructure/terraform/components/callback-clients/variables.tf
new file mode 100644
index 00000000..e7f72997
--- /dev/null
+++ b/infrastructure/terraform/components/callback-clients/variables.tf
@@ -0,0 +1,124 @@
+##
+# Basic Required Variables for tfscaffold Components
+##
+
+variable "project" {
+ type = string
+ description = "The name of the tfscaffold project"
+}
+
+variable "environment" {
+ type = string
+ description = "The name of the tfscaffold environment"
+}
+
+variable "aws_account_id" {
+ type = string
+ description = "The AWS Account ID (numeric)"
+}
+
+variable "region" {
+ type = string
+ description = "The AWS Region"
+}
+
+variable "group" {
+ type = string
+ description = "The group variables are being inherited from (often synonmous with account short-name)"
+}
+
+##
+# tfscaffold variables specific to this component
+##
+
+variable "default_tags" {
+ type = map(string)
+ description = "A map of default tags to apply to all taggable resources within the component"
+ default = {}
+}
+
+variable "parent_acct_environment" {
+ type = string
+ description = "Name of the environment responsible for the acct resources used, affects things like DNS zone. Useful for named dev environments"
+ default = "main"
+}
+
+variable "parent_callbacks_environment" {
+ type = string
+ description = "The name of the environment which deployed the parent callbacks component. Used to identify the appropriate state file."
+ default = "main"
+}
+
+##
+# Variables specific to the component
+##
+
+variable "log_retention_in_days" {
+ type = number
+ description = "The retention period in days for the Cloudwatch Logs events to be retained, default of 0 is indefinite"
+ default = 0
+}
+
+variable "log_level" {
+ type = string
+ description = "The log level to be used in lambda functions within the component"
+ default = "INFO"
+}
+
+variable "force_lambda_code_deploy" {
+ type = bool
+ description = "If the lambda package in s3 has the same commit id tag as the terraform build branch, the lambda will not update automatically. Set to True if making changes to Lambda code from on the same commit for example during development"
+ default = false
+}
+
+variable "deploy_mock_clients" {
+ type = bool
+ description = "Flag to deploy mock webhook lambda for integration testing"
+ default = false
+}
+
+variable "enable_xray_tracing" {
+ type = bool
+ description = "Enable AWS X-Ray active tracing for Lambda functions"
+ default = false
+}
+
+variable "token_bucket_burst_capacity" {
+ type = number
+ description = "Token bucket burst capacity used by the rate limiter"
+ default = 2250
+}
+
+variable "s3_enable_force_destroy" {
+ type = bool
+ description = "Whether to enable force destroy for the S3 buckets created in this module"
+ default = false
+}
+
+variable "mtls_cert_s3_bucket" {
+ type = string
+ description = "S3 bucket containing the mTLS client certificate bundle"
+ default = ""
+}
+
+variable "mtls_cert_s3_key" {
+ type = string
+ description = "S3 key for the mTLS client certificate PEM bundle"
+ default = ""
+}
+
+variable "mtls_ca_s3_key" {
+ type = string
+ description = "S3 key for the CA certificate PEM bundle used for server verification"
+ default = ""
+}
+
+variable "applications_map_s3_bucket" {
+ type = string
+ description = "S3 bucket for the applications map"
+}
+
+variable "client_config_s3_bucket" {
+ type = string
+ description = "S3 bucket for client subscription configuration"
+}
diff --git a/infrastructure/terraform/components/callback-clients/versions.tf b/infrastructure/terraform/components/callback-clients/versions.tf
new file mode 100644
index 00000000..d91998a2
--- /dev/null
+++ b/infrastructure/terraform/components/callback-clients/versions.tf
@@ -0,0 +1,22 @@
+terraform {
+ required_providers {
+ aws = {
+ 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/components/callbacks/.tool-versions b/infrastructure/terraform/components/callbacks/.tool-versions
index 3dd74c72..52428ded 100644
--- a/infrastructure/terraform/components/callbacks/.tool-versions
+++ b/infrastructure/terraform/components/callbacks/.tool-versions
@@ -1 +1 @@
-terraform 1.10.1
+terraform 1.14.3
diff --git a/infrastructure/terraform/components/callbacks/README.md b/infrastructure/terraform/components/callbacks/README.md
index 9889ab22..fdb941d9 100644
--- a/infrastructure/terraform/components/callbacks/README.md
+++ b/infrastructure/terraform/components/callbacks/README.md
@@ -8,19 +8,16 @@
|------|---------|
| [terraform](#requirement\_terraform) | >= 1.10.1 |
| [aws](#requirement\_aws) | 6.13 |
-| [external](#requirement\_external) | ~> 2.0 |
| [random](#requirement\_random) | ~> 3.0 |
-| [tls](#requirement\_tls) | ~> 4.0 |
## 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, where applicationData is currently only the applicationId | `string` | `null` | no |
| [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes |
-| [component](#input\_component) | The variable encapsulating the name of this component | `string` | `"callbacks"` | no |
+| [client\_config\_s3\_bucket](#input\_client\_config\_s3\_bucket) | S3 bucket for client subscription configuration | `string` | n/a | yes |
| [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no |
-| [deploy\_mock\_clients](#input\_deploy\_mock\_clients) | Flag to deploy mock webhook lambda for integration testing (test/dev environments only) | `bool` | `false` | no |
-| [deploy\_perf\_runner](#input\_deploy\_perf\_runner) | Flag to deploy the perf-runner lambda for performance testing (test/dev environments only) | `bool` | `false` | no |
+| [deploy\_mock\_clients](#input\_deploy\_mock\_clients) | Flag indicating whether mock clients are deployed in callback-clients component (used by perf runner for log group references) | `bool` | `false` | no |
+| [deploy\_perf\_runner](#input\_deploy\_perf\_runner) | Flag to deploy the perf-runner lambda for performance testing | `bool` | `false` | no |
| [elasticache\_data\_storage\_maximum\_gb](#input\_elasticache\_data\_storage\_maximum\_gb) | Maximum data storage in GB for the ElastiCache Serverless delivery state cache | `number` | `1` | no |
| [enable\_event\_anomaly\_detection](#input\_enable\_event\_anomaly\_detection) | Enable CloudWatch anomaly detection alarm for inbound event queue message reception | `bool` | `true` | no |
| [enable\_xray\_tracing](#input\_enable\_xray\_tracing) | Enable AWS X-Ray active tracing for Lambda functions | `bool` | `false` | no |
@@ -34,9 +31,6 @@
| [log\_level](#input\_log\_level) | The log level to be used in lambda functions within the component. Any log with a lower severity than the configured value will not be logged: https://docs.python.org/3/library/logging.html#levels | `string` | `"INFO"` | no |
| [log\_retention\_in\_days](#input\_log\_retention\_in\_days) | The retention period in days for the Cloudwatch Logs events to be retained, default of 0 is indefinite | `number` | `0` | no |
| [message\_root\_uri](#input\_message\_root\_uri) | The root URI used for constructing message links in callback payloads | `string` | n/a | yes |
-| [mtls\_ca\_s3\_key](#input\_mtls\_ca\_s3\_key) | S3 key for the CA certificate PEM bundle used for server verification | `string` | `""` | no |
-| [mtls\_cert\_s3\_bucket](#input\_mtls\_cert\_s3\_bucket) | S3 bucket containing the mTLS client certificate bundle | `string` | `""` | no |
-| [mtls\_cert\_s3\_key](#input\_mtls\_cert\_s3\_key) | S3 key for the mTLS client certificate PEM bundle | `string` | `""` | no |
| [parent\_acct\_environment](#input\_parent\_acct\_environment) | Name of the environment responsible for the acct resources used, affects things like DNS zone. Useful for named dev environments | `string` | `"main"` | no |
| [pipe\_event\_patterns](#input\_pipe\_event\_patterns) | value | `list(string)` | `[]` | no |
| [pipe\_log\_level](#input\_pipe\_log\_level) | Log level for the EventBridge Pipe. | `string` | `"ERROR"` | no |
@@ -44,28 +38,30 @@
| [pipe\_sqs\_max\_batch\_window](#input\_pipe\_sqs\_max\_batch\_window) | n/a | `number` | `2` | no |
| [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes |
| [region](#input\_region) | The AWS Region | `string` | n/a | yes |
-| [s3\_enable\_force\_destroy](#input\_s3\_enable\_force\_destroy) | Whether to enable force destroy for the S3 buckets created in this module | `bool` | `false` | no |
| [sqs\_inbound\_event\_max\_receive\_count](#input\_sqs\_inbound\_event\_max\_receive\_count) | n/a | `number` | `3` | no |
| [sqs\_inbound\_event\_visibility\_timeout\_seconds](#input\_sqs\_inbound\_event\_visibility\_timeout\_seconds) | n/a | `number` | `60` | no |
-| [token\_bucket\_burst\_capacity](#input\_token\_bucket\_burst\_capacity) | Token bucket burst capacity used by the rate limiter | `number` | `2250` | no |
## Modules
| Name | Source | Version |
|------|--------|---------|
-| [client\_config\_bucket](#module\_client\_config\_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-s3bucket.zip | n/a |
-| [client\_delivery](#module\_client\_delivery) | ../../modules/client-delivery | n/a |
| [client\_transform\_filter\_lambda](#module\_client\_transform\_filter\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-lambda.zip | n/a |
| [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-kms.zip | n/a |
-| [mock\_webhook\_lambda](#module\_mock\_webhook\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-lambda.zip | n/a |
-| [mtls\_test\_certs\_bucket](#module\_mtls\_test\_certs\_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-s3bucket.zip | n/a |
| [perf\_runner\_lambda](#module\_perf\_runner\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-lambda.zip | n/a |
| [sqs\_inbound\_event](#module\_sqs\_inbound\_event) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-sqs.zip | n/a |
## Outputs
| Name | Description |
|------|-------------|
+| [client\_config\_bucket](#output\_client\_config\_bucket) | S3 bucket for client subscription configuration |
| [deployment](#output\_deployment) | Deployment details used for post-deployment scripts |
-| [mock\_webhook\_lambda\_log\_group\_name](#output\_mock\_webhook\_lambda\_log\_group\_name) | CloudWatch log group name for mock webhook lambda (for integration test queries) |
+| [elasticache](#output\_elasticache) | ElastiCache delivery state details for cross-component access |
+| [eventbus\_name](#output\_eventbus\_name) | Name of the EventBridge event bus for callback events |
+| [kms\_key\_arn](#output\_kms\_key\_arn) | ARN of the KMS key used for encryption in the callbacks component |
+| [lambda\_s3\_bucket](#output\_lambda\_s3\_bucket) | S3 bucket ID for Lambda function artefacts |
+| [log\_destination\_arn](#output\_log\_destination\_arn) | Firehose destination ARN for log forwarding |
+| [log\_subscription\_role\_arn](#output\_log\_subscription\_role\_arn) | IAM role ARN for CloudWatch log subscription |
+| [security\_group\_id](#output\_security\_group\_id) | Security group ID for per-client HTTPS delivery Lambda functions |
+| [vpc\_subnet\_ids](#output\_vpc\_subnet\_ids) | Private subnet IDs for Lambda VPC configuration |
diff --git a/infrastructure/terraform/components/callbacks/_paths.sh b/infrastructure/terraform/components/callbacks/_paths.sh
deleted file mode 100644
index 9b9aba00..00000000
--- a/infrastructure/terraform/components/callbacks/_paths.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-_paths_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-repo_root="$(cd "${_paths_dir}/../../../.." && pwd)"
-clients_dir="${repo_root}/infrastructure/terraform/modules/clients"
-
-# Follow symlinks to find the real nhs-notify-client-callbacks root
-# (repo_root resolves to the workspace root, which differs in CI where the component is symlinked in)
-_real_script="$(readlink -f "${BASH_SOURCE[0]}")"
-bounded_context_root="$(cd "$(dirname "${_real_script}")/../../../.." && pwd)"
diff --git a/infrastructure/terraform/components/callbacks/locals.tf b/infrastructure/terraform/components/callbacks/locals.tf
index 68129a5b..0f4ce15d 100644
--- a/infrastructure/terraform/components/callbacks/locals.tf
+++ b/infrastructure/terraform/components/callbacks/locals.tf
@@ -4,67 +4,5 @@ locals {
log_destination_arn = "arn:aws:firehose:${var.region}:${var.aws_account_id}:deliverystream/nhs-main-obs-splunk-logs-firehose"
root_domain_name = "${var.environment}.${local.acct.route53_zone_names["client-callbacks"]}" # e.g. [main|dev|abxy0].smsnudge.[dev|nonprod|prod].nhsnotify.national.nhs.uk
root_domain_id = local.acct.route53_zone_ids["client-callbacks"]
-
- clients_dir_path = "${path.module}/../../modules/clients"
-
- config_clients = merge([
- for filename in fileset(local.clients_dir_path, "*.json") : {
- (replace(filename, ".json", "")) = jsondecode(file("${local.clients_dir_path}/${filename}"))
- }
- ]...)
-
- # SPKI hash of the mock webhook server certificate for cert-pinning enrichment.
- # Computed via external data source because Terraform cannot SHA-256 hash raw binary (DER) data natively.
- mock_server_spki_hash = var.deploy_mock_clients ? data.external.mock_server_spki_hash[0].result.hash : ""
-
- # When deploying mock clients, replace sentinel placeholder values with the mock webhook URL and API key.
- # Only used for S3 object content — must not be used as a for_each source (contains apply-time values).
- enriched_mock_config_clients = var.deploy_mock_clients ? {
- for client_id, client in local.config_clients :
- client_id => merge(client, {
- targets = [
- for target in try(client.targets, []) :
- merge(target, {
- invocationEndpoint = "https://${aws_lb.mock_webhook_mtls[0].dns_name}/${target.targetId}"
- apiKey = merge(target.apiKey, { headerValue = random_password.mock_webhook_api_key[0].result })
- delivery = merge(try(target.delivery, {}), {
- mtls = merge(try(target.delivery.mtls, {}), {
- certPinning = merge(try(target.delivery.mtls.certPinning, {}), try(target.delivery.mtls.certPinning.enabled, false) ? {
- spkiHash = local.mock_server_spki_hash
- } : {})
- })
- })
- })
- ]
- })
- } : local.config_clients
-
-
- client_subscriptions = {
- for client_id, data in local.config_clients :
- client_id => {
- for subscription in try(data.subscriptions, []) :
- subscription.subscriptionId => {
- subscription_id = subscription.subscriptionId
- target_ids = try(subscription.targetIds, [])
- }
- }
- }
-
- client_subscription_targets = {
- for client_id, data in local.config_clients :
- client_id => merge([
- for subscription in try(data.subscriptions, []) : {
- for target_id in try(subscription.targetIds, []) :
- "${subscription.subscriptionId}-${target_id}" => {
- subscription_id = subscription.subscriptionId
- target_id = target_id
- }
- }
- ]...)
- }
-
- applications_map_parameter_name = coalesce(var.applications_map_parameter_name, "/${var.project}/${var.environment}/${var.component}/applications-map")
-
- client_config_bucket_arn = "arn:aws:s3:::${var.project}-${var.aws_account_id}-${var.region}-${var.environment}-${var.component}-subscription-config"
+ client_config_bucket_arn = "arn:aws:s3:::${var.client_config_s3_bucket}"
}
diff --git a/infrastructure/terraform/components/callbacks/locals_tfscaffold.tf b/infrastructure/terraform/components/callbacks/locals_tfscaffold.tf
index b7cf3217..4d68787a 100644
--- a/infrastructure/terraform/components/callbacks/locals_tfscaffold.tf
+++ b/infrastructure/terraform/components/callbacks/locals_tfscaffold.tf
@@ -1,4 +1,6 @@
locals {
+ component = "cb"
+
terraform_state_bucket = format(
"%s-tfscaffold-%s-%s",
var.project,
@@ -11,7 +13,7 @@ locals {
"%s-%s-%s",
var.project,
var.environment,
- var.component,
+ local.component,
),
"_",
"",
@@ -25,7 +27,7 @@ locals {
var.aws_account_id,
var.region,
var.environment,
- var.component,
+ local.component,
),
"_",
"",
@@ -36,7 +38,7 @@ locals {
{
Project = var.project
Environment = var.environment
- Component = var.component
+ Component = local.component
Group = var.group
Name = local.csi
},
diff --git a/infrastructure/terraform/components/callbacks/module_kms.tf b/infrastructure/terraform/components/callbacks/module_kms.tf
index 327b5641..1cf7b03a 100644
--- a/infrastructure/terraform/components/callbacks/module_kms.tf
+++ b/infrastructure/terraform/components/callbacks/module_kms.tf
@@ -2,7 +2,7 @@ module "kms" {
source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-kms.zip"
aws_account_id = var.aws_account_id
- component = var.component
+ component = local.component
environment = var.environment
project = var.project
region = var.region
@@ -64,9 +64,10 @@ data "aws_iam_policy_document" "kms" {
test = "ArnLike"
variable = "kms:EncryptionContext:aws:sqs:arn"
values = [
- "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-callbacks-inbound-event-queue",
- "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-callbacks-inbound-event-dlq",
- "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-callbacks-*-dlq-queue" #wildcard here so that DLQs for clients can also use this key
+ "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-cb-inbound-event-queue",
+ "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-cb-inbound-event-dlq",
+ "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-cbc-*-delivery-queue",
+ "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-cbc-*-delivery-dlq-queue",
]
}
}
diff --git a/infrastructure/terraform/components/callbacks/module_perf_runner_lambda.tf b/infrastructure/terraform/components/callbacks/module_perf_runner_lambda.tf
index 7a77c40c..1af65e48 100644
--- a/infrastructure/terraform/components/callbacks/module_perf_runner_lambda.tf
+++ b/infrastructure/terraform/components/callbacks/module_perf_runner_lambda.tf
@@ -6,7 +6,7 @@ module "perf_runner_lambda" {
description = "Lambda function that executes performance tests against the client callbacks pipeline from within AWS"
aws_account_id = var.aws_account_id
- component = var.component
+ component = local.component
environment = var.environment
project = var.project
region = var.region
@@ -39,11 +39,12 @@ module "perf_runner_lambda" {
ENVIRONMENT = var.environment
INBOUND_QUEUE_URL = module.sqs_inbound_event.sqs_queue_url
TRANSFORM_FILTER_LOG_GROUP = module.client_transform_filter_lambda.cloudwatch_log_group_name
- DELIVERY_LOG_GROUP_PREFIX = "/aws/lambda/${local.csi}-https-client-"
- MOCK_WEBHOOK_LOG_GROUP = var.deploy_mock_clients ? module.mock_webhook_lambda[0].cloudwatch_log_group_name : ""
+ DELIVERY_LOG_GROUP_PREFIX = "/aws/lambda/${var.project}-${var.environment}-cbc-https-client-"
+ DELIVERY_QUEUE_URL_PREFIX = "https://sqs.${var.region}.amazonaws.com/${var.aws_account_id}/${var.project}-${var.environment}-cbc-"
+ MOCK_WEBHOOK_LOG_GROUP = var.deploy_mock_clients ? "/aws/lambda/${var.project}-${var.environment}-cbc-mock-webhook" : ""
ELASTICACHE_ENDPOINT = aws_elasticache_serverless_cache.delivery_state.endpoint[0].address
ELASTICACHE_CACHE_NAME = aws_elasticache_serverless_cache.delivery_state.name
- ELASTICACHE_IAM_USERNAME = "${var.project}-${var.environment}-${var.component}-elasticache-user"
+ ELASTICACHE_IAM_USERNAME = "${var.project}-${var.environment}-${local.component}-elasticache-user"
}
vpc_config = {
@@ -114,7 +115,7 @@ data "aws_iam_policy_document" "perf_runner_lambda" {
"arn:aws:logs:${var.region}:${var.aws_account_id}:log-group:/aws/lambda/${local.csi}-https-client-*",
],
var.deploy_mock_clients ? [
- "arn:aws:logs:${var.region}:${var.aws_account_id}:log-group:${module.mock_webhook_lambda[0].cloudwatch_log_group_name}:*",
+ "arn:aws:logs:${var.region}:${var.aws_account_id}:log-group:/aws/lambda/${var.project}-${var.environment}-cbc-mock-webhook:*",
] : [],
)
}
diff --git a/infrastructure/terraform/components/callbacks/module_sqs_inbound_event.tf b/infrastructure/terraform/components/callbacks/module_sqs_inbound_event.tf
index 2e3080fe..2a15e357 100644
--- a/infrastructure/terraform/components/callbacks/module_sqs_inbound_event.tf
+++ b/infrastructure/terraform/components/callbacks/module_sqs_inbound_event.tf
@@ -2,7 +2,7 @@ module "sqs_inbound_event" {
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
+ component = local.component
environment = var.environment
project = var.project
region = var.region
@@ -33,7 +33,7 @@ data "aws_iam_policy_document" "sqs_inbound_event" {
]
resources = [
- "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-${var.component}-inbound-event-queue"
+ "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-${local.component}-inbound-event-queue"
]
condition {
diff --git a/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf b/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf
index 2b75ddd5..7a327712 100644
--- a/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf
+++ b/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf
@@ -5,7 +5,7 @@ module "client_transform_filter_lambda" {
description = "Lambda function that transforms and filters events coming to through the eventpipe"
aws_account_id = var.aws_account_id
- component = var.component
+ component = local.component
environment = var.environment
project = var.project
region = var.region
@@ -38,8 +38,8 @@ module "client_transform_filter_lambda" {
lambda_env_vars = {
ENVIRONMENT = var.environment
METRICS_NAMESPACE = "nhs-notify-client-callbacks"
- CLIENT_SUBSCRIPTION_CONFIG_BUCKET = module.client_config_bucket.bucket
- CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "client_subscriptions/"
+ CLIENT_SUBSCRIPTION_CONFIG_BUCKET = var.client_config_s3_bucket
+ CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "${var.environment}/client_subscriptions/"
CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS = "60"
MESSAGE_ROOT_URI = var.message_root_uri
}
diff --git a/infrastructure/terraform/components/callbacks/outputs.tf b/infrastructure/terraform/components/callbacks/outputs.tf
index 1ca00df8..257a4084 100644
--- a/infrastructure/terraform/components/callbacks/outputs.tf
+++ b/infrastructure/terraform/components/callbacks/outputs.tf
@@ -10,15 +10,73 @@ output "deployment" {
project = var.project
environment = var.environment
group = var.group
- component = var.component
+ component = local.component
}
}
##
-# Mock Webhook Lambda Outputs (test/dev environments only).
+# EventBridge Event Bus Outputs
##
-output "mock_webhook_lambda_log_group_name" {
- description = "CloudWatch log group name for mock webhook lambda (for integration test queries)"
- value = var.deploy_mock_clients ? module.mock_webhook_lambda[0].cloudwatch_log_group_name : null
+output "eventbus_name" {
+ description = "Name of the EventBridge event bus for callback events"
+ value = {
+ name = aws_cloudwatch_event_bus.main.name
+ arn = aws_cloudwatch_event_bus.main.arn
+ }
+}
+
+##
+# KMS
+##
+
+output "kms_key_arn" {
+ description = "ARN of the KMS key used for encryption in the callbacks component"
+ value = module.kms.key_arn
+}
+
+##
+# Shared infrastructure for callback-clients component
+##
+
+output "security_group_id" {
+ description = "Security group ID for per-client HTTPS delivery Lambda functions"
+ value = aws_security_group.https_client_lambda.id
+}
+
+output "elasticache" {
+ description = "ElastiCache delivery state details for cross-component access"
+ value = {
+ endpoint = aws_elasticache_serverless_cache.delivery_state.endpoint[0].address
+ cache_name = aws_elasticache_serverless_cache.delivery_state.name
+ iam_username = aws_elasticache_user.delivery_state_iam.user_name
+ }
+}
+
+output "client_config_bucket" {
+ description = "S3 bucket for client subscription configuration"
+ value = {
+ bucket = var.client_config_s3_bucket
+ arn = local.client_config_bucket_arn
+ }
+}
+
+output "vpc_subnet_ids" {
+ description = "Private subnet IDs for Lambda VPC configuration"
+ value = try(local.acct.private_subnets[local.bc_name], [])
+}
+
+output "lambda_s3_bucket" {
+ description = "S3 bucket ID for Lambda function artefacts"
+ value = local.acct.s3_buckets["lambda_function_artefacts"]["id"]
+}
+
+output "log_destination_arn" {
+ description = "Firehose destination ARN for log forwarding"
+ value = local.log_destination_arn
+}
+
+output "log_subscription_role_arn" {
+ description = "IAM role ARN for CloudWatch log subscription"
+ value = local.acct.log_subscription_role_arn
}
diff --git a/infrastructure/terraform/components/callbacks/pre.sh b/infrastructure/terraform/components/callbacks/pre.sh
index 39eb0817..aa04f710 100755
--- a/infrastructure/terraform/components/callbacks/pre.sh
+++ b/infrastructure/terraform/components/callbacks/pre.sh
@@ -1,41 +1,12 @@
-# This script is run before the Terraform apply command.
-# It ensures dependencies are installed, generates local client config files
-# for terraform from S3-held subscriptions, and builds lambda workspaces.
+# This script is run before Terraform apply for the callbacks component.
+# It installs dependencies, generates any required build artefacts,
+# and builds lambda workspaces.
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-# shellcheck source=_paths.sh
-source "${script_dir}/_paths.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}"
+repo_root="$(cd "${script_dir}/../../../.." && pwd)"
pnpm install --frozen-lockfile
pnpm run generate-dependencies
-"${script_dir}/sync-client-config.sh"
-
-if [ "${deploy_mock_clients}" == "true" ]; then
- 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
deleted file mode 100644
index 9943affd..00000000
--- a/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf
+++ /dev/null
@@ -1,104 +0,0 @@
-resource "aws_s3_object" "mock_client_config" {
- for_each = var.deploy_mock_clients ? toset(keys(local.config_clients)) : toset([])
-
- bucket = module.client_config_bucket.id
- key = "client_subscriptions/${local.config_clients[each.key].clientId}.json"
- content = jsonencode(local.enriched_mock_config_clients[each.key])
-
- kms_key_id = module.kms.key_arn
- server_side_encryption = "aws:kms"
-
- content_type = "application/json"
-}
-
-module "client_config_bucket" {
- source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-s3bucket.zip"
-
- name = "subscription-config"
-
- 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 = "Client subscription configuration storage"
- }
- )
-
- kms_key_arn = module.kms.key_arn
- force_destroy = var.s3_enable_force_destroy
- versioning = true
- object_ownership = "BucketOwnerPreferred"
- bucket_key_enabled = true
-
- policy_documents = [
- data.aws_iam_policy_document.client_config_bucket.json
- ]
-}
-
-data "aws_iam_policy_document" "client_config_bucket" {
- statement {
- sid = "AllowLambdaListAccess"
- effect = "Allow"
-
- principals {
- type = "AWS"
- identifiers = [module.client_transform_filter_lambda.iam_role_arn]
- }
-
- actions = [
- "s3:ListBucket",
- ]
-
- resources = [
- local.client_config_bucket_arn,
- ]
- }
-
- statement {
- sid = "AllowLambdaReadAccess"
- effect = "Allow"
-
- principals {
- type = "AWS"
- identifiers = [module.client_transform_filter_lambda.iam_role_arn]
- }
-
- actions = [
- "s3:GetObject",
- ]
-
- resources = [
- "${local.client_config_bucket_arn}/*",
- ]
- }
-
- statement {
- sid = "DenyInsecureTransport"
- effect = "Deny"
-
- principals {
- type = "*"
- identifiers = ["*"]
- }
-
- actions = [
- "s3:*",
- ]
-
- resources = [
- local.client_config_bucket_arn,
- "${local.client_config_bucket_arn}/*"
- ]
-
- condition {
- test = "Bool"
- variable = "aws:SecureTransport"
- values = ["false"]
- }
- }
-}
diff --git a/infrastructure/terraform/components/callbacks/ssm_parameter_applications_map.tf b/infrastructure/terraform/components/callbacks/ssm_parameter_applications_map.tf
deleted file mode 100644
index 567647d1..00000000
--- a/infrastructure/terraform/components/callbacks/ssm_parameter_applications_map.tf
+++ /dev/null
@@ -1,19 +0,0 @@
-resource "random_password" "mock_application_id" {
- for_each = var.deploy_mock_clients ? toset(keys(local.config_clients)) : toset([])
- length = 24
- special = false
-}
-
-resource "aws_ssm_parameter" "applications_map" {
- name = local.applications_map_parameter_name
- type = "SecureString"
- key_id = module.kms.key_arn
-
- value = var.deploy_mock_clients ? jsonencode({
- for id in keys(local.config_clients) : local.config_clients[id].clientId => random_password.mock_application_id[id].result
- }) : jsonencode({})
-
- lifecycle {
- ignore_changes = [value]
- }
-}
diff --git a/infrastructure/terraform/components/callbacks/sync-client-config.sh b/infrastructure/terraform/components/callbacks/sync-client-config.sh
index 2c2a3ecb..653c137b 100755
--- a/infrastructure/terraform/components/callbacks/sync-client-config.sh
+++ b/infrastructure/terraform/components/callbacks/sync-client-config.sh
@@ -7,8 +7,8 @@
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-# shellcheck source=_paths.sh
-source "${script_dir}/_paths.sh"
+repo_root="$(cd "${script_dir}/../../../.." && pwd)"
+clients_dir="${repo_root}/infrastructure/terraform/modules/clients"
: "${ENVIRONMENT:?ENVIRONMENT must be set}"
: "${AWS_REGION:?AWS_REGION must be set}"
@@ -18,9 +18,9 @@ cd "${repo_root}"
rm -f "${clients_dir}"/*.json
-bucket_name="nhs-${AWS_ACCOUNT_ID}-${AWS_REGION}-${ENVIRONMENT}-callbacks-subscription-config"
+bucket_name="nhs-${AWS_ACCOUNT_ID}-${AWS_REGION}-main-acct-clie-client-configs"
-s3_prefix="client_subscriptions/"
+s3_prefix="${ENVIRONMENT}/client_subscriptions/"
echo "Seeding client configs from s3://${bucket_name}/${s3_prefix} for ${ENVIRONMENT}/${AWS_REGION}"
diff --git a/infrastructure/terraform/components/callbacks/variables.tf b/infrastructure/terraform/components/callbacks/variables.tf
index 68e4eafd..89583ae6 100644
--- a/infrastructure/terraform/components/callbacks/variables.tf
+++ b/infrastructure/terraform/components/callbacks/variables.tf
@@ -35,11 +35,7 @@ variable "group" {
# a default within its declaration in this file, because the variables
# purpose is as an identifier unique to this component, rather
# then to the environment from where all other variables come.
-variable "component" {
- type = string
- description = "The variable encapsulating the name of this component"
- default = "callbacks"
-}
+
variable "default_tags" {
type = map(string)
@@ -151,13 +147,13 @@ variable "event_anomaly_band_width" {
variable "deploy_mock_clients" {
type = bool
- description = "Flag to deploy mock webhook lambda for integration testing (test/dev environments only)"
+ description = "Flag indicating whether mock clients are deployed in callback-clients component (used by perf runner for log group references)"
default = false
}
variable "deploy_perf_runner" {
type = bool
- description = "Flag to deploy the perf-runner lambda for performance testing (test/dev environments only)"
+ description = "Flag to deploy the perf-runner lambda for performance testing"
default = false
}
@@ -167,49 +163,20 @@ variable "enable_xray_tracing" {
default = false
}
-variable "message_root_uri" {
+variable "client_config_s3_bucket" {
type = string
- description = "The root URI used for constructing message links in callback payloads"
+ description = "S3 bucket for client subscription configuration"
}
-variable "applications_map_parameter_name" {
- type = string
- default = null
- description = "SSM Parameter Store path for the clientId-to-applicationData map, where applicationData is currently only the applicationId"
-}
-
-variable "s3_enable_force_destroy" {
- type = bool
- description = "Whether to enable force destroy for the S3 buckets created in this module"
- default = false
-}
-
-variable "mtls_cert_s3_bucket" {
+variable "message_root_uri" {
type = string
- description = "S3 bucket containing the mTLS client certificate bundle"
- default = ""
+ description = "The root URI used for constructing message links in callback payloads"
}
-variable "mtls_cert_s3_key" {
- type = string
- description = "S3 key for the mTLS client certificate PEM bundle"
- default = ""
-}
-variable "mtls_ca_s3_key" {
- type = string
- description = "S3 key for the CA certificate PEM bundle used for server verification"
- default = ""
-}
variable "elasticache_data_storage_maximum_gb" {
type = number
description = "Maximum data storage in GB for the ElastiCache Serverless delivery state cache"
default = 1
}
-
-variable "token_bucket_burst_capacity" {
- type = number
- description = "Token bucket burst capacity used by the rate limiter"
- default = 2250
-}
diff --git a/infrastructure/terraform/components/callbacks/versions.tf b/infrastructure/terraform/components/callbacks/versions.tf
index d91998a2..55552749 100644
--- a/infrastructure/terraform/components/callbacks/versions.tf
+++ b/infrastructure/terraform/components/callbacks/versions.tf
@@ -4,18 +4,10 @@ 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
index 22b98e26..7e76436e 100644
--- a/infrastructure/terraform/modules/client-delivery/README.md
+++ b/infrastructure/terraform/modules/client-delivery/README.md
@@ -9,11 +9,13 @@ No requirements.
| 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 |
+| [applications\_map\_s3\_bucket](#input\_applications\_map\_s3\_bucket) | S3 bucket containing the applications map JSON | `string` | n/a | yes |
+| [applications\_map\_s3\_key](#input\_applications\_map\_s3\_key) | S3 key for the applications map JSON file | `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\_config\_key\_prefix](#input\_client\_config\_key\_prefix) | S3 key prefix for client subscription config objects | `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 |
diff --git a/infrastructure/terraform/modules/client-delivery/iam_role_sqs_target.tf b/infrastructure/terraform/modules/client-delivery/iam_role_sqs_target.tf
index 2f8e3c28..2e0e25db 100644
--- a/infrastructure/terraform/modules/client-delivery/iam_role_sqs_target.tf
+++ b/infrastructure/terraform/modules/client-delivery/iam_role_sqs_target.tf
@@ -43,15 +43,15 @@ data "aws_iam_policy_document" "https_client_lambda" {
}
statement {
- sid = "SSMGetApplicationsMap"
+ sid = "S3GetApplicationsMap"
effect = "Allow"
actions = [
- "ssm:GetParameter",
+ "s3:GetObject",
]
resources = [
- "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter${var.applications_map_parameter_name}",
+ "arn:aws:s3:::${var.applications_map_s3_bucket}/${var.applications_map_s3_key}",
]
}
@@ -64,7 +64,7 @@ data "aws_iam_policy_document" "https_client_lambda" {
]
resources = [
- "${var.client_config_bucket_arn}/client_subscriptions/*",
+ "${var.client_config_bucket_arn}/${var.client_config_key_prefix}*",
]
}
diff --git a/infrastructure/terraform/modules/client-delivery/module_https_client_lambda.tf b/infrastructure/terraform/modules/client-delivery/module_https_client_lambda.tf
index a1bb48f2..6dfabecb 100644
--- a/infrastructure/terraform/modules/client-delivery/module_https_client_lambda.tf
+++ b/infrastructure/terraform/modules/client-delivery/module_https_client_lambda.tf
@@ -36,11 +36,12 @@ module "https_client_lambda" {
log_subscription_role_arn = var.log_subscription_role_arn
lambda_env_vars = {
- APPLICATIONS_MAP_PARAMETER = var.applications_map_parameter_name
+ APPLICATIONS_MAP_S3_BUCKET = var.applications_map_s3_bucket
+ APPLICATIONS_MAP_S3_KEY = var.applications_map_s3_key
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/"
+ CLIENT_SUBSCRIPTION_CONFIG_PREFIX = var.client_config_key_prefix
DLQ_URL = module.dlq_delivery.sqs_queue_url
ELASTICACHE_CACHE_NAME = var.elasticache_cache_name
ELASTICACHE_ENDPOINT = var.elasticache_endpoint
diff --git a/infrastructure/terraform/modules/client-delivery/variables.tf b/infrastructure/terraform/modules/client-delivery/variables.tf
index 46f66f45..612bcd6f 100644
--- a/infrastructure/terraform/modules/client-delivery/variables.tf
+++ b/infrastructure/terraform/modules/client-delivery/variables.tf
@@ -70,9 +70,19 @@ variable "client_config_bucket_arn" {
description = "S3 bucket ARN containing client subscription configuration"
}
-variable "applications_map_parameter_name" {
+variable "client_config_key_prefix" {
type = string
- description = "SSM Parameter Store path for the clientId-to-applicationData map"
+ description = "S3 key prefix for client subscription config objects"
+}
+
+variable "applications_map_s3_bucket" {
+ type = string
+ description = "S3 bucket containing the applications map JSON"
+}
+
+variable "applications_map_s3_key" {
+ type = string
+ description = "S3 key for the applications map JSON file"
}
variable "lambda_s3_bucket" {
diff --git a/lambdas/https-client-lambda/package.json b/lambdas/https-client-lambda/package.json
index 88b36769..8082b859 100644
--- a/lambdas/https-client-lambda/package.json
+++ b/lambdas/https-client-lambda/package.json
@@ -3,7 +3,6 @@
"@aws-crypto/sha256-js": "catalog:aws",
"@aws-sdk/client-s3": "catalog:aws",
"@aws-sdk/client-sqs": "catalog:aws",
- "@aws-sdk/client-ssm": "catalog:aws",
"@aws-sdk/credential-providers": "catalog:aws",
"@smithy/signature-v4": "catalog:aws",
"@nhs-notify-client-callbacks/config-subscription-cache": "workspace:*",
@@ -16,6 +15,7 @@
"p-map": "catalog:app"
},
"devDependencies": {
+ "@smithy/types": "catalog:aws",
"@tsconfig/node22": "catalog:tools",
"@types/aws-lambda": "catalog:tools",
"@types/jest": "catalog:test",
diff --git a/lambdas/https-client-lambda/src/__tests__/applications-map.test.ts b/lambdas/https-client-lambda/src/__tests__/applications-map.test.ts
new file mode 100644
index 00000000..3a7c8e92
--- /dev/null
+++ b/lambdas/https-client-lambda/src/__tests__/applications-map.test.ts
@@ -0,0 +1,119 @@
+import { GetObjectCommand } from "@aws-sdk/client-s3";
+import { Readable } from "node:stream";
+import type { SdkStream } from "@smithy/types";
+
+import { getApplicationId, resetCache } from "services/applications-map";
+
+const mockSend = jest.fn();
+jest.mock("@aws-sdk/client-s3", () => {
+ const actual = jest.requireActual("@aws-sdk/client-s3");
+ return {
+ ...actual,
+ S3Client: jest.fn().mockImplementation(() => ({
+ send: (...args: unknown[]) => mockSend(...args),
+ })),
+ };
+});
+
+jest.mock("@nhs-notify-client-callbacks/logger", () => ({
+ logger: {
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ debug: jest.fn(),
+ },
+}));
+
+function createS3Body(content: string): { Body: SdkStream } {
+ const stream = Readable.from([content]) as SdkStream;
+ stream.transformToString = jest.fn().mockResolvedValue(content);
+ return { Body: stream };
+}
+
+process.env.APPLICATIONS_MAP_S3_BUCKET = "test-bucket";
+process.env.APPLICATIONS_MAP_S3_KEY = "dev/applications-map.json";
+
+describe("getApplicationId", () => {
+ beforeEach(() => {
+ mockSend.mockReset();
+ resetCache();
+ });
+
+ it("returns correct applicationId for a known clientId", async () => {
+ mockSend.mockResolvedValue(
+ createS3Body(
+ JSON.stringify({
+ "client-1": "app-id-1",
+ "client-2": "app-id-2",
+ }),
+ ),
+ );
+
+ const result = await getApplicationId("client-1");
+
+ expect(result).toBe("app-id-1");
+ expect(mockSend).toHaveBeenCalledTimes(1);
+ expect(mockSend.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand);
+ });
+
+ it("throws for unknown clientId", async () => {
+ mockSend.mockResolvedValue(
+ createS3Body(JSON.stringify({ "client-1": "app-id-1" })),
+ );
+
+ await expect(getApplicationId("unknown")).rejects.toThrow(
+ "No applicationId found for clientId 'unknown' in applications map",
+ );
+ });
+
+ it("surfaces S3 SDK errors", async () => {
+ mockSend.mockRejectedValue(new Error("S3 unavailable"));
+
+ await expect(getApplicationId("client-1")).rejects.toThrow(
+ "S3 unavailable",
+ );
+ });
+
+ it("throws when env vars are not set", async () => {
+ const savedBucket = process.env.APPLICATIONS_MAP_S3_BUCKET;
+ const savedKey = process.env.APPLICATIONS_MAP_S3_KEY;
+ delete process.env.APPLICATIONS_MAP_S3_BUCKET;
+ delete process.env.APPLICATIONS_MAP_S3_KEY;
+
+ resetCache();
+
+ await expect(getApplicationId("client-1")).rejects.toThrow(
+ "APPLICATIONS_MAP_S3_BUCKET and APPLICATIONS_MAP_S3_KEY are required",
+ );
+
+ process.env.APPLICATIONS_MAP_S3_BUCKET = savedBucket;
+ process.env.APPLICATIONS_MAP_S3_KEY = savedKey;
+ });
+
+ it("throws when S3 object body is empty", async () => {
+ const stream = Readable.from([]) as SdkStream;
+ stream.transformToString = jest.fn().mockResolvedValue("");
+ mockSend.mockResolvedValue({ Body: stream });
+
+ await expect(getApplicationId("client-1")).rejects.toThrow("is empty");
+ });
+
+ it("throws when S3 object contains invalid JSON", async () => {
+ mockSend.mockResolvedValue(createS3Body("not-json"));
+
+ await expect(getApplicationId("client-1")).rejects.toThrow(
+ "contains invalid JSON",
+ );
+ });
+
+ it("caches the applications map between calls", async () => {
+ mockSend.mockResolvedValue(
+ createS3Body(JSON.stringify({ "client-1": "app-id-1" })),
+ );
+
+ await getApplicationId("client-1");
+ await getApplicationId("client-1");
+
+ expect(mockSend).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/lambdas/https-client-lambda/src/__tests__/handler.test.ts b/lambdas/https-client-lambda/src/__tests__/handler.test.ts
index 5e4dade3..1d46d6eb 100644
--- a/lambdas/https-client-lambda/src/__tests__/handler.test.ts
+++ b/lambdas/https-client-lambda/src/__tests__/handler.test.ts
@@ -20,7 +20,7 @@ jest.mock("services/config-loader", () => ({
}));
const mockGetApplicationId = jest.fn();
-jest.mock("services/ssm-applications-map", () => ({
+jest.mock("services/applications-map", () => ({
getApplicationId: (...args: unknown[]) => mockGetApplicationId(...args),
}));
diff --git a/lambdas/https-client-lambda/src/__tests__/ssm-applications-map.test.ts b/lambdas/https-client-lambda/src/__tests__/ssm-applications-map.test.ts
deleted file mode 100644
index 059023d1..00000000
--- a/lambdas/https-client-lambda/src/__tests__/ssm-applications-map.test.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import { GetParameterCommand } from "@aws-sdk/client-ssm";
-
-import { getApplicationId, resetCache } from "services/ssm-applications-map";
-
-const mockSend = jest.fn();
-jest.mock("@aws-sdk/client-ssm", () => {
- const actual = jest.requireActual("@aws-sdk/client-ssm");
- return {
- ...actual,
- SSMClient: jest.fn().mockImplementation(() => ({
- send: (...args: unknown[]) => mockSend(...args),
- })),
- };
-});
-
-jest.mock("@nhs-notify-client-callbacks/logger", () => ({
- logger: {
- info: jest.fn(),
- warn: jest.fn(),
- error: jest.fn(),
- debug: jest.fn(),
- },
-}));
-
-process.env.APPLICATIONS_MAP_PARAMETER = "/test/applications-map";
-
-describe("getApplicationId", () => {
- beforeEach(() => {
- mockSend.mockReset();
- resetCache();
- });
-
- it("returns correct applicationId for a known clientId", async () => {
- mockSend.mockResolvedValue({
- Parameter: {
- Value: JSON.stringify({
- "client-1": "app-id-1",
- "client-2": "app-id-2",
- }),
- },
- });
-
- const result = await getApplicationId("client-1");
-
- expect(result).toBe("app-id-1");
- expect(mockSend).toHaveBeenCalledTimes(1);
- expect(mockSend.mock.calls[0][0]).toBeInstanceOf(GetParameterCommand);
- });
-
- it("throws for unknown clientId", async () => {
- mockSend.mockResolvedValue({
- Parameter: {
- Value: JSON.stringify({ "client-1": "app-id-1" }),
- },
- });
-
- await expect(getApplicationId("unknown")).rejects.toThrow(
- "No applicationId found for clientId 'unknown' in SSM map",
- );
- });
-
- it("surfaces SSM SDK errors", async () => {
- mockSend.mockRejectedValue(new Error("SSM unavailable"));
-
- await expect(getApplicationId("client-1")).rejects.toThrow(
- "SSM unavailable",
- );
- });
-
- it("throws when APPLICATIONS_MAP_PARAMETER is not set", async () => {
- let getFn: typeof getApplicationId;
- const saved = process.env.APPLICATIONS_MAP_PARAMETER;
- delete process.env.APPLICATIONS_MAP_PARAMETER;
-
- jest.isolateModules(() => {
- // eslint-disable-next-line @typescript-eslint/no-require-imports -- jest.isolateModules requires synchronous require
- getFn = require("services/ssm-applications-map").getApplicationId;
- });
-
- await expect(getFn!("client-1")).rejects.toThrow(
- "APPLICATIONS_MAP_PARAMETER is required",
- );
-
- process.env.APPLICATIONS_MAP_PARAMETER = saved;
- });
-
- it("throws when SSM parameter value is empty", async () => {
- mockSend.mockResolvedValue({ Parameter: { Value: undefined } });
-
- await expect(getApplicationId("client-1")).rejects.toThrow(
- "not found or has no value",
- );
- });
-
- it("throws when SSM parameter contains invalid JSON", async () => {
- mockSend.mockResolvedValue({
- Parameter: { Value: "not-json" },
- });
-
- await expect(getApplicationId("client-1")).rejects.toThrow(
- "contains invalid JSON",
- );
- });
-
- it("caches the applications map between calls", async () => {
- mockSend.mockResolvedValue({
- Parameter: {
- Value: JSON.stringify({ "client-1": "app-id-1" }),
- },
- });
-
- await getApplicationId("client-1");
- await getApplicationId("client-1");
-
- expect(mockSend).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/lambdas/https-client-lambda/src/handler.ts b/lambdas/https-client-lambda/src/handler.ts
index 395815ad..f80d9928 100644
--- a/lambdas/https-client-lambda/src/handler.ts
+++ b/lambdas/https-client-lambda/src/handler.ts
@@ -3,7 +3,7 @@ import type { ClientCallbackPayload } from "@nhs-notify-client-callbacks/models"
import pMap from "p-map";
import { logger } from "@nhs-notify-client-callbacks/logger";
import { loadTargetConfig } from "services/config-loader";
-import { getApplicationId } from "services/ssm-applications-map";
+import { getApplicationId } from "services/applications-map";
import { signPayload } from "services/payload-signer";
import { buildAgent } from "services/delivery/tls-agent-factory";
import {
@@ -273,6 +273,7 @@ async function processTargetBatch(
try {
const outcome = await deliverRecord(
record,
+ // eslint-disable-next-line security/detect-object-injection
admittedMessages[index],
target,
applicationId,
@@ -280,6 +281,7 @@ async function processTargetBatch(
);
return { record, success: outcome.success, dlq: outcome.dlq };
} catch (error) {
+ // eslint-disable-next-line security/detect-object-injection
const correlationId = extractCorrelationId(admittedMessages[index]);
logger.error("Failed to process record", {
messageId: record.messageId,
diff --git a/lambdas/https-client-lambda/src/services/applications-map.ts b/lambdas/https-client-lambda/src/services/applications-map.ts
new file mode 100644
index 00000000..9a86d86c
--- /dev/null
+++ b/lambdas/https-client-lambda/src/services/applications-map.ts
@@ -0,0 +1,73 @@
+import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
+import { logger } from "@nhs-notify-client-callbacks/logger";
+
+const s3Client = new S3Client({});
+
+const DEFAULT_CACHE_TTL_MS = 300_000; // 5 minutes
+
+let cachedMap: Map | undefined;
+let cacheExpiresAt = 0;
+
+async function loadMap(): Promise