Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
903c26c
Return subscription and target ids from tranform lambda
mjewildnhs Mar 23, 2026
93c047b
Terraform changes
mjewildnhs Mar 23, 2026
bc7a267
Fix webhook raw payload logging
mjewildnhs Mar 23, 2026
b7c03b4
Remove unused clients var
mjewildnhs Mar 23, 2026
37b6a72
Log headers in mock lambda
mjewildnhs Mar 24, 2026
ceca908
Create clients using terraform/tool
mjewildnhs Mar 24, 2026
f120379
Config driven IT subscriptions
mjewildnhs Mar 24, 2026
b9643b3
Tool to add application ids
mjewildnhs Mar 24, 2026
05263ef
Target DLQ terminology fixes
mjewildnhs Mar 24, 2026
7296777
fixup! Config driven IT subscriptions
mjewildnhs Mar 24, 2026
17f648e
Fix duplicate logging in webhook and add test for the logging that re…
mjewildnhs Mar 24, 2026
8f98575
Fix signing secret
mjewildnhs Mar 24, 2026
99c27f6
Assert api key correctly
mjewildnhs Mar 24, 2026
6d72254
fixup! Assert api key correctly
mjewildnhs Mar 24, 2026
a0198e8
Attempt terraform re-apply
mjewildnhs Mar 25, 2026
69d8c9a
fixup! Attempt terraform re-apply
mjewildnhs Mar 25, 2026
8cb566a
2nd mock client
mjewildnhs Mar 25, 2026
724a2f8
Feedback: remove unncessary signature input_transformer
mjewildnhs Mar 25, 2026
9e03a52
fixup! Attempt terraform re-apply
mjewildnhs Mar 25, 2026
6c8e74b
fixup! Attempt terraform re-apply
mjewildnhs Mar 25, 2026
d029cb2
Revert "2nd mock client"
mjewildnhs Mar 25, 2026
c5709e4
Feedback: improve application map call
mjewildnhs Mar 25, 2026
55ba575
fixup! Attempt terraform re-apply
mjewildnhs Mar 25, 2026
7f45666
Rename mock-client -> mock-it-client
mjewildnhs Mar 25, 2026
02262d0
fixup! Attempt terraform re-apply
mjewildnhs Mar 25, 2026
bb50c8a
fixup! Terraform changes
mjewildnhs Mar 25, 2026
b8155b4
fixup! Create clients using terraform/tool
mjewildnhs Mar 25, 2026
c940c63
Make AWS_ACCOUNT_ID mandatory in sync-client-config script
mjewildnhs Mar 25, 2026
18d302f
Revert "fixup! Attempt terraform re-apply"
mjewildnhs Mar 25, 2026
0aa1306
Revert github action changes for attempt terraform re-apply
mjewildnhs Mar 25, 2026
06063da
Revert integration.sh terraform changes
mjewildnhs Mar 25, 2026
e42b1db
Move mock client subscription json location
mjewildnhs Mar 25, 2026
e48737f
Use config json for mock terraform clients
mjewildnhs Mar 25, 2026
8ad15bf
Upload the config back to S3
mjewildnhs Mar 25, 2026
12903fb
Load the application-id map with the correct mock client
mjewildnhs Mar 25, 2026
aeb82e9
Pull the integration test vars down from client config
mjewildnhs Mar 25, 2026
36fd5a6
fixup! Move mock client subscription json location
mjewildnhs Mar 25, 2026
c8dd303
fixup! Move mock client subscription json location
mjewildnhs Mar 25, 2026
408507b
SSM put tool
mjewildnhs Mar 25, 2026
986b4ab
Fix integration script jq commands
mjewildnhs Mar 25, 2026
0744e32
fixup! Move mock client subscription json location
mjewildnhs Mar 25, 2026
9ce6a37
Tidy up terraform
mjewildnhs Mar 25, 2026
c7909a3
Remove unncessary missing api key handling
mjewildnhs Mar 25, 2026
fa2b05c
Rename client mock subscription file
mjewildnhs Mar 25, 2026
d5c7e6c
Improve concurrency of dlq redrive test
mjewildnhs Mar 25, 2026
6c41ab4
Fix hardcoded region in integration script
mjewildnhs Mar 25, 2026
f4dadbe
Add lookback buffer to log search in ITs to fix issue between local c…
mjewildnhs Mar 25, 2026
50b07cf
Remove superfluous test
mjewildnhs Mar 25, 2026
aab9364
Delete global setup/teardown from ITs that is no longer needed
mjewildnhs Mar 25, 2026
313ba66
Force destroy config bucket if mock web hook deployed
mjewildnhs Mar 26, 2026
ec282bd
Variable to force destroy the config bucket
mjewildnhs Mar 26, 2026
3c3abd3
Rename deploy_mock_webhook var
mjewildnhs Mar 26, 2026
14b74c7
Feedback: resolve coverage issue in mock
mjewildnhs Mar 26, 2026
7bf9fb8
Clarify seed terraform
mjewildnhs Mar 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ The Client Callbacks infrastructure processes message and channel status events,
- **Callbacks Event Bus**: Domain-specific EventBridge bus for webhook orchestration
- **API Destination Target Rules**: Per-client rules invoking HTTPS endpoints with client-specific authentication
- **Client Config Storage**: S3 bucket storing client subscription configurations (status filters, webhook endpoints)
- **Per-Client DLQs**: SQS Dead Letter Queues for failed webhook deliveries (one per client)
- **Per-Client Target DLQs**: SQS Dead Letter Queues for failed webhook deliveries (one per client target)

### Event Flow

Expand Down
4 changes: 2 additions & 2 deletions infrastructure/terraform/components/callbacks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@
|------|-------------|------|---------|:--------:|
| <a name="input_applications_map_parameter_name"></a> [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 |
| <a name="input_aws_account_id"></a> [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes |
| <a name="input_clients"></a> [clients](#input\_clients) | n/a | <pre>list(object({<br/> connection_name = string<br/> destination_name = string<br/> invocation_endpoint = string<br/> invocation_rate_limit_per_second = optional(number, 10)<br/> http_method = optional(string, "POST")<br/> header_name = optional(string, "x-api-key")<br/> header_value = string<br/> client_detail = list(string)<br/> }))</pre> | `[]` | no |
| <a name="input_client_config_bucket_force_destroy"></a> [client\_config\_bucket\_force\_destroy](#input\_client\_config\_bucket\_force\_destroy) | Force-delete all objects and versions from the client config bucket during destroy | `bool` | `false` | no |
| <a name="input_component"></a> [component](#input\_component) | The variable encapsulating the name of this component | `string` | `"callbacks"` | no |
| <a name="input_default_tags"></a> [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no |
| <a name="input_deploy_mock_webhook"></a> [deploy\_mock\_webhook](#input\_deploy\_mock\_webhook) | Flag to deploy mock webhook lambda for integration testing (test/dev environments only) | `bool` | `false` | no |
| <a name="input_deploy_mock_client_subscriptions"></a> [deploy\_mock\_client\_subscriptions](#input\_deploy\_mock\_client\_subscriptions) | Flag to deploy mock webhook lambda for integration testing (test/dev environments only) | `bool` | `false` | no |
| <a name="input_enable_event_anomaly_detection"></a> [enable\_event\_anomaly\_detection](#input\_enable\_event\_anomaly\_detection) | Enable CloudWatch anomaly detection alarm for inbound event queue message reception | `bool` | `true` | no |
| <a name="input_environment"></a> [environment](#input\_environment) | The name of the tfscaffold environment | `string` | n/a | yes |
| <a name="input_event_anomaly_band_width"></a> [event\_anomaly\_band\_width](#input\_event\_anomaly\_band\_width) | The width of the anomaly detection band. Higher values (e.g. 4-6) reduce sensitivity and noise, lower values (e.g. 2-3) increase sensitivity. Recommended: 2-4. | `number` | `3` | no |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
resource "aws_cloudwatch_metric_alarm" "client_dlq_depth" {
for_each = toset(keys(local.all_clients))
for_each = toset(keys(local.config_clients))

alarm_name = "${local.csi}-${each.key}-dlq-depth"
alarm_description = join(" ", [
Expand Down
101 changes: 81 additions & 20 deletions infrastructure/terraform/components/callbacks/locals.tf
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,90 @@ locals {
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_by_name = {
for client in var.clients :
client.connection_name => client
}

# Automatic test client when mock webhook is deployed
mock_client = var.deploy_mock_webhook ? {
"mock-client" = {
connection_name = "mock-client"
destination_name = "test-destination"
invocation_endpoint = aws_lambda_function_url.mock_webhook[0].function_url
invocation_rate_limit_per_second = 10
http_method = "POST"
header_name = "x-api-key"
header_value = random_password.mock_webhook_api_key[0].result
client_detail = [
"uk.nhs.notify.message.status.PUBLISHED.v1",
"uk.nhs.notify.channel.status.PUBLISHED.v1"
clients_dir_path = "${path.module}/../../modules/clients"
mock_client_file = "mock-it-client.json"
mock_client_key = replace(local.mock_client_file, ".json", "")
mock_client_path = "${path.module}/../../../../tests/integration/fixtures/${local.mock_client_file}"

config_files_from_s3 = fileset(local.clients_dir_path, "*.json")
config_files = var.deploy_mock_client_subscriptions ? setunion(
local.config_files_from_s3,
toset([local.mock_client_file])
) : local.config_files_from_s3

config_clients = merge([
for filename in local.config_files : {
# The mock file may be absent on first apply (bucket/key not created yet),
# but present on subsequent applies after sync from S3
(replace(filename, ".json", "")) = jsondecode(file(
filename == local.mock_client_file && !fileexists("${local.clients_dir_path}/${filename}")
? local.mock_client_path
: "${local.clients_dir_path}/${filename}"
))
}
]...)

mock_client_id = var.deploy_mock_client_subscriptions ? local.config_clients[local.mock_client_key].clientId : ""

# Enriched mock client config with live Lambda URL and API key (for S3 upload)
mock_client_config = var.deploy_mock_client_subscriptions ? merge(
local.config_clients[local.mock_client_key],
{
targets = [
for target in try(local.config_clients[local.mock_client_key].targets, []) :
merge(target, {
invocationEndpoint = aws_lambda_function_url.mock_webhook[0].function_url
apiKey = merge(target.apiKey, { headerValue = random_password.mock_webhook_api_key[0].result })
})
]
}
} : {}
) : null

raw_targets = merge([
for client_id, data in local.config_clients : {
for target in try(data.targets, []) : target.targetId => {
client_id = client_id
target_id = target.targetId
invocation_endpoint = target.invocationEndpoint
invocation_rate_limit_per_second = target.invocationRateLimit
http_method = target.invocationMethod
header_name = target.apiKey.headerName
header_value = target.apiKey.headerValue
}
}
]...)

# Override mock targets with live Lambda URL when deployed
config_targets = var.deploy_mock_client_subscriptions ? merge(
local.raw_targets,
{ for target_id, target in local.raw_targets :
target_id => merge(target, {
invocation_endpoint = aws_lambda_function_url.mock_webhook[0].function_url
header_value = random_password.mock_webhook_api_key[0].result
})
if target.client_id == local.mock_client_key
}
) : local.raw_targets

all_clients = merge(local.clients_by_name, local.mock_client)
config_subscriptions = merge([
for client_id, data in local.config_clients : {
for subscription in try(data.subscriptions, []) : subscription.subscriptionId => {
client_id = client_id
subscription_id = subscription.subscriptionId
target_ids = try(subscription.targetIds, [])
}
}
]...)

subscription_targets = merge([
for subscription_id, subscription in local.config_subscriptions : {
for target_id in subscription.target_ids :
"${subscription_id}-${target_id}" => {
subscription_id = subscription_id
target_id = target_id
}
}
]...)

applications_map_parameter_name = coalesce(var.applications_map_parameter_name, "/${var.project}/${var.environment}/${var.component}/applications-map")
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
module "client_destination" {
source = "../../modules/client-destination"
for_each = local.all_clients
source = "../../modules/client-destination"

project = var.project
aws_account_id = var.aws_account_id
Expand All @@ -11,16 +10,8 @@ module "client_destination" {

kms_key_arn = module.kms.key_arn

connection_name = each.value.connection_name
destination_name = each.value.destination_name
invocation_endpoint = each.value.invocation_endpoint
invocation_rate_limit_per_second = each.value.invocation_rate_limit_per_second
http_method = each.value.http_method
header_name = each.value.header_name
header_value = each.value.header_value
client_detail = each.value.client_detail



targets = local.config_targets
subscriptions = local.config_subscriptions
subscription_targets = local.subscription_targets

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
module "mock_webhook_lambda" {
count = var.deploy_mock_webhook ? 1 : 0
count = var.deploy_mock_client_subscriptions ? 1 : 0
source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip"

function_name = "mock-webhook"
Expand Down Expand Up @@ -42,13 +42,13 @@ module "mock_webhook_lambda" {
}

resource "random_password" "mock_webhook_api_key" {
count = var.deploy_mock_webhook ? 1 : 0
count = var.deploy_mock_client_subscriptions ? 1 : 0
length = 32
special = false
}

data "aws_iam_policy_document" "mock_webhook_lambda" {
count = var.deploy_mock_webhook ? 1 : 0
count = var.deploy_mock_client_subscriptions ? 1 : 0

statement {
sid = "KMSPermissions"
Expand All @@ -67,7 +67,7 @@ data "aws_iam_policy_document" "mock_webhook_lambda" {

# Lambda Function URL for mock webhook (test/dev only)
resource "aws_lambda_function_url" "mock_webhook" {
count = var.deploy_mock_webhook ? 1 : 0
count = var.deploy_mock_client_subscriptions ? 1 : 0
function_name = module.mock_webhook_lambda[0].function_name
authorization_type = "NONE" # Public endpoint for testing

Expand All @@ -80,7 +80,7 @@ resource "aws_lambda_function_url" "mock_webhook" {
}

resource "aws_lambda_permission" "mock_webhook_function_url" {
count = var.deploy_mock_webhook ? 1 : 0
count = var.deploy_mock_client_subscriptions ? 1 : 0
statement_id_prefix = "FunctionURLAllowPublicAccess"
action = "lambda:InvokeFunctionUrl"
function_name = module.mock_webhook_lambda[0].function_name
Expand All @@ -89,7 +89,7 @@ resource "aws_lambda_permission" "mock_webhook_function_url" {
}

resource "aws_lambda_permission" "mock_webhook_function_invoke" {
count = var.deploy_mock_webhook ? 1 : 0
count = var.deploy_mock_client_subscriptions ? 1 : 0
statement_id_prefix = "FunctionURLAllowInvokeAction"
action = "lambda:InvokeFunction"
function_name = module.mock_webhook_lambda[0].function_name
Expand Down
4 changes: 2 additions & 2 deletions infrastructure/terraform/components/callbacks/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ output "deployment" {

output "mock_webhook_lambda_log_group_name" {
description = "CloudWatch log group name for mock webhook lambda (for integration test queries)"
value = var.deploy_mock_webhook ? module.mock_webhook_lambda[0].cloudwatch_log_group_name : null
value = var.deploy_mock_client_subscriptions ? module.mock_webhook_lambda[0].cloudwatch_log_group_name : null
}

output "mock_webhook_url" {
description = "URL endpoint for mock webhook (for TEST_WEBHOOK_URL environment variable)"
value = var.deploy_mock_webhook ? aws_lambda_function_url.mock_webhook[0].function_url : null
value = var.deploy_mock_client_subscriptions ? aws_lambda_function_url.mock_webhook[0].function_url : null
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ resource "aws_pipes_pipe" "main" {

input_template = <<EOF
{
"type": <$.type>,
"transformedPayload": <$.transformedPayload>,
"headers": <$.headers>
"payload": <$.payload>,
"subscriptions": <$.subscriptions>,
"signatures": <$.signatures>
}
EOF
}
Expand Down
10 changes: 7 additions & 3 deletions infrastructure/terraform/components/callbacks/pre.sh
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
# # This script is run before the Terraform apply command.
# # It ensures all Node.js dependencies are installed, generates any required dependencies,
# # and builds all Lambda functions in the workspace before Terraform provisions infrastructure.
# 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.

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

npm ci

npm run generate-dependencies --workspaces --if-present

"${script_dir}/sync-client-config.sh"

npm run lambda-build --workspaces --if-present
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
resource "aws_s3_object" "mock_client_config" {
count = var.deploy_mock_client_subscriptions ? 1 : 0

bucket = module.client_config_bucket.id
key = "client_subscriptions/mock-it-client.json"
content = jsonencode(local.mock_client_config)

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.6/terraform-s3bucket.zip"

Expand All @@ -17,7 +30,7 @@ module "client_config_bucket" {
)

kms_key_arn = module.kms.key_arn
force_destroy = false
force_destroy = var.client_config_bucket_force_destroy
versioning = true
object_ownership = "BucketOwnerPreferred"
bucket_key_enabled = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ resource "aws_ssm_parameter" "applications_map" {
type = "SecureString"
key_id = module.kms.key_arn

value = var.deploy_mock_webhook ? jsonencode({
"mock-client" = "mock-application-id"
value = var.deploy_mock_client_subscriptions ? jsonencode({
(local.mock_client_id) = "mock-application-id"
}) : jsonencode({})

lifecycle {
Expand Down
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if this script could just copy mock-client.json in - this would simplify some of the terraform in locals.tf
However there isn't currently a mechanism to set env vars accessible to the pre.sh.
We can set tfvars (like var.deploy_mock_client_subscriptions)
but these aren't visible outside of terraform.

Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env bash

# This script seeds local client subscription JSON files from the config bucket
# before Terraform evaluates locals/fileset (see local.config_clients in locals.tf).
# It handles the S3 bucket not existing on first apply.
# Deployment Lifecycle:
# - Sync client JSON files from S3 into "modules/clients" before Terraform runs.
# - Terraform then reads the local fileset from "modules/clients" as input.
# - On later deployments, files are refreshed from bucket state each run.
#
# Deployment Lifecycle dev/test environment:
# - Special handling is needed to seed test data and have it take effect on the first apply, as the bucket and files won't exist yet
# - Terraform merges in the local mock fixture config into local.config_clients
# - Terraform uploads mock-it-client.json to the config bucket
# - On subsequent deployments, that mock config is synced from S3 and handled
# through the normal fileset path.

set -euo pipefail

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
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}"
: "${AWS_ACCOUNT_ID:?AWS_ACCOUNT_ID must be set}"

cd "${repo_root}"

rm -f "${clients_dir}"/*.json

bucket_name="nhs-${AWS_ACCOUNT_ID}-${AWS_REGION}-${ENVIRONMENT}-callbacks-subscription-config"

s3_prefix="client_subscriptions/"

echo "Seeding client configs from s3://${bucket_name}/${s3_prefix} for ${ENVIRONMENT}/${AWS_REGION}"

if ! sync_output=$(aws s3 sync "s3://${bucket_name}/${s3_prefix}" "${clients_dir}/" \
--region "${AWS_REGION}" \
--exclude "*" \
--include "*.json" \
--only-show-errors 2>&1); then
if [[ "${sync_output}" == *"NoSuchBucket"* ]]; then
# Expected on first apply before Terraform creates the bucket.
echo "Client config bucket not found yet; skipping sync for first run"
else
echo "Failed to sync client config from S3" >&2
echo "${sync_output}" >&2
exit 1
fi
fi

# Ensure an empty directory produces a zero-length array rather than a literal "*.json" entry.
shopt -s nullglob
seeded_files=("${clients_dir}"/*.json)
seeded_count="${#seeded_files[@]}"
shopt -u nullglob

echo "Seeded ${seeded_count} client config file(s)"
22 changes: 7 additions & 15 deletions infrastructure/terraform/components/callbacks/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -87,21 +87,7 @@ variable "pipe_event_patterns" {
default = []
}

variable "clients" {
type = list(object({
connection_name = string
destination_name = string
invocation_endpoint = string
invocation_rate_limit_per_second = optional(number, 10)
http_method = optional(string, "POST")
header_name = optional(string, "x-api-key")
header_value = string
client_detail = list(string)
}))

default = []

}

variable "pipe_log_level" {
type = string
Expand Down Expand Up @@ -163,12 +149,18 @@ variable "event_anomaly_band_width" {
}
}

variable "deploy_mock_webhook" {
variable "deploy_mock_client_subscriptions" {
type = bool
description = "Flag to deploy mock webhook lambda for integration testing (test/dev environments only)"
default = false
}

variable "client_config_bucket_force_destroy" {
type = bool
description = "Force-delete all objects and versions from the client config bucket during destroy"
default = false
}

variable "message_root_uri" {
type = string
description = "The root URI used for constructing message links in callback payloads"
Expand Down
Loading
Loading