diff --git a/.github/workflows/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml
index 4dcd5dcc..cd4d89ef 100644
--- a/.github/workflows/cicd-1-pull-request.yaml
+++ b/.github/workflows/cicd-1-pull-request.yaml
@@ -171,7 +171,7 @@ jobs:
--terraformAction "apply" \
--overrideProjectName "nhs" \
--overrideRoleName "nhs-main-acct-client-callbacks-github-deploy" \
- --overrides "branch_name=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}},deploy_mock_webhook=true"
+ --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/infrastructure/terraform/components/callbacks/README.md b/infrastructure/terraform/components/callbacks/README.md
index f9ef0bec..c4e6d262 100644
--- a/infrastructure/terraform/components/callbacks/README.md
+++ b/infrastructure/terraform/components/callbacks/README.md
@@ -13,6 +13,7 @@
| 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 |
| [clients](#input\_clients) | n/a |
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)
})) | `[]` | no |
| [component](#input\_component) | The variable encapsulating the name of this component | `string` | `"callbacks"` | no |
diff --git a/infrastructure/terraform/components/callbacks/locals.tf b/infrastructure/terraform/components/callbacks/locals.tf
index 4ac741f1..b9f7d4d8 100644
--- a/infrastructure/terraform/components/callbacks/locals.tf
+++ b/infrastructure/terraform/components/callbacks/locals.tf
@@ -27,4 +27,6 @@ locals {
} : {}
all_clients = merge(local.clients_by_name, local.mock_client)
+
+ applications_map_parameter_name = coalesce(var.applications_map_parameter_name, "/${var.project}/${var.environment}/${var.component}/applications-map")
}
diff --git a/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf b/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf
index a9064bdc..22155559 100644
--- a/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf
+++ b/infrastructure/terraform/components/callbacks/module_transform_filter_lambda.tf
@@ -42,6 +42,7 @@ module "client_transform_filter_lambda" {
CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "client_subscriptions/"
CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS = "60"
MESSAGE_ROOT_URI = var.message_root_uri
+ APPLICATIONS_MAP_PARAMETER = local.applications_map_parameter_name
}
}
@@ -86,6 +87,19 @@ data "aws_iam_policy_document" "client_transform_filter_lambda" {
]
}
+ statement {
+ sid = "SSMApplicationsMapRead"
+ effect = "Allow"
+
+ actions = [
+ "ssm:GetParameter",
+ ]
+
+ resources = [
+ "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter${local.applications_map_parameter_name}",
+ ]
+ }
+
statement {
sid = "CloudWatchMetrics"
effect = "Allow"
diff --git a/infrastructure/terraform/components/callbacks/pipes_pipe_main.tf b/infrastructure/terraform/components/callbacks/pipes_pipe_main.tf
index e3c284ef..6c088133 100644
--- a/infrastructure/terraform/components/callbacks/pipes_pipe_main.tf
+++ b/infrastructure/terraform/components/callbacks/pipes_pipe_main.tf
@@ -26,7 +26,8 @@ resource "aws_pipes_pipe" "main" {
input_template = <,
- "transformedPayload": <$.transformedPayload>
+ "transformedPayload": <$.transformedPayload>,
+ "headers": <$.headers>
}
EOF
}
diff --git a/infrastructure/terraform/components/callbacks/ssm_parameter_applications_map.tf b/infrastructure/terraform/components/callbacks/ssm_parameter_applications_map.tf
new file mode 100644
index 00000000..1e9b6925
--- /dev/null
+++ b/infrastructure/terraform/components/callbacks/ssm_parameter_applications_map.tf
@@ -0,0 +1,13 @@
+resource "aws_ssm_parameter" "applications_map" {
+ name = local.applications_map_parameter_name
+ type = "SecureString"
+ key_id = module.kms.key_arn
+
+ value = var.deploy_mock_webhook ? jsonencode({
+ "mock-client" = "mock-application-id"
+ }) : jsonencode({})
+
+ lifecycle {
+ ignore_changes = [value]
+ }
+}
diff --git a/infrastructure/terraform/components/callbacks/variables.tf b/infrastructure/terraform/components/callbacks/variables.tf
index 42ee7f13..b21d5697 100644
--- a/infrastructure/terraform/components/callbacks/variables.tf
+++ b/infrastructure/terraform/components/callbacks/variables.tf
@@ -179,3 +179,9 @@ variable "enable_debug_log_bucket" {
description = "Enable the debug log S3 bucket used for integration testing"
default = false
}
+
+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"
+}
diff --git a/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf b/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf
index 0b2bf042..4bce1003 100644
--- a/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf
+++ b/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf
@@ -22,6 +22,12 @@ resource "aws_cloudwatch_event_target" "main" {
arn = module.target_dlq.sqs_queue_arn
}
+ http_target {
+ header_parameters = {
+ "x-hmac-sha256-signature" = "$.detail.headers.x-hmac-sha256-signature"
+ }
+ }
+
retry_policy {
maximum_retry_attempts = 3
maximum_event_age_in_seconds = 3600
diff --git a/lambdas/client-transform-filter-lambda/package.json b/lambdas/client-transform-filter-lambda/package.json
index 82cd8dc5..ff3f544f 100644
--- a/lambdas/client-transform-filter-lambda/package.json
+++ b/lambdas/client-transform-filter-lambda/package.json
@@ -1,6 +1,7 @@
{
"dependencies": {
"@aws-sdk/client-s3": "^3.821.0",
+ "@aws-sdk/client-ssm": "^3.821.0",
"@nhs-notify-client-callbacks/logger": "*",
"@nhs-notify-client-callbacks/models": "*",
"aws-embedded-metrics": "^4.2.1",
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts
index b8851d59..dbacd922 100644
--- a/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts
@@ -15,6 +15,27 @@ jest.mock("@aws-sdk/client-s3", () => {
};
});
+const mockSsmSend = jest.fn();
+jest.mock("@aws-sdk/client-ssm", () => {
+ const actual = jest.requireActual("@aws-sdk/client-ssm");
+ return {
+ ...actual,
+ SSMClient: jest.fn().mockImplementation(() => ({
+ send: mockSsmSend,
+ })),
+ };
+});
+
+// Set environment variables before importing the handler/module under test so that
+// services constructed at module import time (e.g. applicationsMapService) see
+// the correct configuration.
+process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "test-bucket";
+process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "client_subscriptions/";
+process.env.CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS = "60";
+process.env.METRICS_NAMESPACE = "test-namespace";
+process.env.ENVIRONMENT = "test";
+process.env.APPLICATIONS_MAP_PARAMETER = "/test/applications-map";
+
jest.mock("aws-embedded-metrics", () => ({
createMetricsLogger: jest.fn(() => ({
setNamespace: jest.fn(),
@@ -29,10 +50,11 @@ jest.mock("aws-embedded-metrics", () => ({
}));
import { GetObjectCommand, NoSuchKey } from "@aws-sdk/client-s3";
+import { GetParameterCommand } from "@aws-sdk/client-ssm";
import type { SQSRecord } from "aws-lambda";
import { EventTypes } from "@nhs-notify-client-callbacks/models";
import { createS3Client } from "services/config-loader-service";
-import { configLoaderService, handler } from "..";
+import { applicationsMapService, configLoaderService, handler } from "..";
const makeSqsRecord = (body: object): SQSRecord => ({
messageId: "sqs-id",
@@ -100,16 +122,18 @@ const validMessageStatusEvent = (clientId: string, messageStatus: string) => ({
});
describe("Lambda handler with S3 subscription filtering", () => {
- beforeAll(() => {
- process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "test-bucket";
- process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "client_subscriptions/";
- process.env.CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS = "60";
- process.env.METRICS_NAMESPACE = "test-namespace";
- process.env.ENVIRONMENT = "test";
+ const applicationsMap = JSON.stringify({
+ "client-1": "app-id-1",
+ "client-a": "app-id-a",
+ "client-b": "app-id-b",
+ "client-no-config": "app-id-no-config",
});
beforeEach(() => {
mockSend.mockClear();
+ mockSsmSend.mockClear();
+ applicationsMapService.reset();
+ mockSsmSend.mockResolvedValue({ Parameter: { Value: applicationsMap } });
// Reset loader and clear cache for clean state between tests
configLoaderService.reset(
createS3Client({ AWS_ENDPOINT_URL: "http://localhost:4566" }),
@@ -123,6 +147,7 @@ describe("Lambda handler with S3 subscription filtering", () => {
delete process.env.CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS;
delete process.env.METRICS_NAMESPACE;
delete process.env.ENVIRONMENT;
+ delete process.env.APPLICATIONS_MAP_PARAMETER;
});
it("passes event through when client config matches subscription", async () => {
@@ -141,6 +166,9 @@ describe("Lambda handler with S3 subscription filtering", () => {
expect(result).toHaveLength(1);
expect(mockSend).toHaveBeenCalledTimes(1);
expect(mockSend.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand);
+ expect(mockSsmSend).toHaveBeenCalledTimes(1);
+ expect(mockSsmSend.mock.calls[0][0]).toBeInstanceOf(GetParameterCommand);
+ expect(result[0].headers["x-hmac-sha256-signature"]).toMatch(/^[0-9a-f]+$/);
});
it("filters out event when status is not in subscription", async () => {
@@ -236,4 +264,25 @@ describe("Lambda handler with S3 subscription filtering", () => {
// S3 fetched once per distinct client (client-a and client-b), not once per event
expect(mockSend).toHaveBeenCalledTimes(2);
});
+
+ it("filters out event when no applicationId found in SSM map", async () => {
+ mockSend.mockResolvedValue({
+ Body: {
+ transformToString: jest
+ .fn()
+ .mockResolvedValue(
+ JSON.stringify(createValidConfig("client-unknown")),
+ ),
+ },
+ });
+ mockSsmSend.mockResolvedValue({
+ Parameter: { Value: JSON.stringify({}) },
+ });
+
+ const result = await handler([
+ makeSqsRecord(validMessageStatusEvent("client-unknown", "DELIVERED")),
+ ]);
+
+ expect(result).toHaveLength(0);
+ });
});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts
index fc3317b6..b8d02995 100644
--- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts
@@ -10,12 +10,22 @@ import type {
import type { Logger } from "services/logger";
import type { CallbackMetrics } from "services/metrics";
import type { ConfigLoader } from "services/config-loader";
+import type { ApplicationsMapService } from "services/ssm-applications-map";
import { ObservabilityService } from "services/observability";
import { ConfigLoaderService } from "services/config-loader-service";
import { createHandler } from "..";
jest.mock("aws-embedded-metrics");
+const stubTarget = {
+ Type: "API",
+ TargetId: "00000000-0000-4000-8000-000000000001",
+ InvocationEndpoint: "https://example.com/webhook",
+ InvocationMethod: "POST",
+ InvocationRateLimit: 10,
+ APIKey: { HeaderName: "x-api-key", HeaderValue: "test-api-key" },
+};
+
const createPassthroughConfigLoader = (): ConfigLoader =>
({
loadClientConfig: jest.fn().mockImplementation(async (clientId: string) => [
@@ -23,7 +33,7 @@ const createPassthroughConfigLoader = (): ConfigLoader =>
SubscriptionType: "MessageStatus",
SubscriptionId: "00000000-0000-0000-0000-000000000001",
ClientId: clientId,
- Targets: [],
+ Targets: [stubTarget],
MessageStatuses: [
"DELIVERED",
"FAILED",
@@ -37,7 +47,7 @@ const createPassthroughConfigLoader = (): ConfigLoader =>
SubscriptionType: "ChannelStatus",
SubscriptionId: "00000000-0000-0000-0000-000000000002",
ClientId: clientId,
- Targets: [],
+ Targets: [stubTarget],
ChannelType: "NHSAPP",
ChannelStatuses: ["DELIVERED", "FAILED", "TECHNICAL_FAILURE"],
SupplierStatuses: [
@@ -50,7 +60,7 @@ const createPassthroughConfigLoader = (): ConfigLoader =>
SubscriptionType: "ChannelStatus",
SubscriptionId: "00000000-0000-0000-0000-000000000003",
ClientId: clientId,
- Targets: [],
+ Targets: [stubTarget],
ChannelType: "SMS",
ChannelStatuses: ["DELIVERED", "FAILED", "TECHNICAL_FAILURE"],
SupplierStatuses: [
@@ -67,6 +77,15 @@ const makeStubConfigLoaderService = (): ConfigLoaderService => {
return { getLoader: () => loader } as unknown as ConfigLoaderService;
};
+const makeStubApplicationsMapService = (): ApplicationsMapService =>
+ ({
+ getApplicationId: jest
+ .fn()
+ .mockImplementation(
+ async (clientId: string) => `test-app-id-${clientId}`,
+ ),
+ }) as unknown as ApplicationsMapService;
+
describe("Lambda handler", () => {
const mockLogger = {
info: jest.fn(),
@@ -96,6 +115,7 @@ describe("Lambda handler", () => {
createObservabilityService: () =>
new ObservabilityService(mockLogger, mockMetrics, mockMetricsLogger),
createConfigLoaderService: makeStubConfigLoaderService,
+ createApplicationsMapService: makeStubApplicationsMapService,
});
beforeEach(() => {
@@ -348,6 +368,7 @@ describe("Lambda handler", () => {
const faultyHandler = createHandler({
createObservabilityService: () => faultyObservability,
createConfigLoaderService: makeStubConfigLoaderService,
+ createApplicationsMapService: makeStubApplicationsMapService,
});
const sqsMessage: SQSRecord = {
@@ -527,6 +548,7 @@ describe("createHandler default wiring", () => {
[],
state.mockObservabilityInstance,
expect.any(Object),
+ expect.any(Object),
);
expect(result).toEqual(["ok"]);
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/payload-signer.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/payload-signer.test.ts
new file mode 100644
index 00000000..e1785d55
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/payload-signer.test.ts
@@ -0,0 +1,49 @@
+import { createHmac } from "node:crypto";
+import type { ClientCallbackPayload } from "@nhs-notify-client-callbacks/models";
+import { signPayload } from "services/payload-signer";
+
+const makePayload = (id = "msg-1") =>
+ ({ data: [{ id }] }) as unknown as ClientCallbackPayload;
+
+describe("signPayload", () => {
+ it("produces the expected HMAC-SHA256 hex string", () => {
+ const payload = makePayload();
+ const applicationId = "app-id-1";
+ const apiKey = "api-key-1";
+
+ const expected = createHmac("sha256", `${applicationId}.${apiKey}`)
+ .update(JSON.stringify(payload))
+ .digest("hex");
+
+ expect(signPayload(payload, applicationId, apiKey)).toBe(expected);
+ });
+
+ it("returns a non-empty hex string", () => {
+ const result = signPayload(makePayload(), "app-id", "api-key");
+ expect(result).toMatch(/^[0-9a-f]+$/);
+ });
+
+ it("produces different signatures for different payloads", () => {
+ const apiKey = "key";
+ const appId = "app";
+ expect(signPayload(makePayload("msg-1"), appId, apiKey)).not.toBe(
+ signPayload(makePayload("msg-2"), appId, apiKey),
+ );
+ });
+
+ it("produces different signatures for different applicationIds", () => {
+ const payload = makePayload();
+ const apiKey = "key";
+ expect(signPayload(payload, "app-1", apiKey)).not.toBe(
+ signPayload(payload, "app-2", apiKey),
+ );
+ });
+
+ it("produces different signatures for different apiKeys", () => {
+ const payload = makePayload();
+ const appId = "app";
+ expect(signPayload(payload, appId, "key-1")).not.toBe(
+ signPayload(payload, appId, "key-2"),
+ );
+ });
+});
diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/ssm-applications-map.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/ssm-applications-map.test.ts
new file mode 100644
index 00000000..7123009a
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/ssm-applications-map.test.ts
@@ -0,0 +1,156 @@
+import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
+import {
+ ApplicationsMapService,
+ createSsmClient,
+ resolveCacheTtlMs,
+} from "services/ssm-applications-map";
+
+jest.mock("services/logger", () => ({
+ logger: {
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
+const makeSsmClient = (value: string | undefined) =>
+ ({
+ send: jest
+ .fn()
+ .mockResolvedValue(
+ value === undefined ? {} : { Parameter: { Value: value } },
+ ),
+ }) as unknown as SSMClient;
+
+describe("ApplicationsMapService", () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it("returns the applicationId for a known clientId", async () => {
+ const ssmClient = makeSsmClient(
+ JSON.stringify({ "client-1": "app-id-1", "client-2": "app-id-2" }),
+ );
+ const service = new ApplicationsMapService(ssmClient, "/test/param");
+
+ expect(await service.getApplicationId("client-1")).toBe("app-id-1");
+ });
+
+ it("returns undefined for an unknown clientId", async () => {
+ const ssmClient = makeSsmClient(JSON.stringify({ "client-1": "app-id-1" }));
+ const service = new ApplicationsMapService(ssmClient, "/test/param");
+
+ expect(await service.getApplicationId("unknown")).toBeUndefined();
+ });
+
+ it("loads from SSM and sends GetParameterCommand with WithDecryption", async () => {
+ const ssmClient = makeSsmClient(JSON.stringify({ "client-1": "app-id-1" }));
+ const service = new ApplicationsMapService(ssmClient, "/test/param");
+
+ await service.getApplicationId("client-1");
+
+ expect(ssmClient.send).toHaveBeenCalledTimes(1);
+ expect((ssmClient.send as jest.Mock).mock.calls[0][0]).toBeInstanceOf(
+ GetParameterCommand,
+ );
+ });
+
+ it("caches the map and does not call SSM again within TTL", async () => {
+ const ssmClient = makeSsmClient(JSON.stringify({ "client-1": "app-id-1" }));
+ const service = new ApplicationsMapService(ssmClient, "/test/param", 5000);
+
+ await service.getApplicationId("client-1");
+ await service.getApplicationId("client-1");
+
+ expect(ssmClient.send).toHaveBeenCalledTimes(1);
+ });
+
+ it("reloads from SSM after TTL expires", async () => {
+ const ssmClient = makeSsmClient(JSON.stringify({ "client-1": "app-id-1" }));
+ const service = new ApplicationsMapService(ssmClient, "/test/param", 5000);
+
+ await service.getApplicationId("client-1");
+ jest.advanceTimersByTime(6000);
+ await service.getApplicationId("client-1");
+
+ expect(ssmClient.send).toHaveBeenCalledTimes(2);
+ });
+
+ it("throws when SSM parameter is missing", async () => {
+ const ssmClient = makeSsmClient(undefined);
+ const service = new ApplicationsMapService(ssmClient, "/test/param");
+
+ await expect(service.getApplicationId("client-1")).rejects.toThrow(
+ "SSM parameter '/test/param' not found or has no value",
+ );
+ });
+
+ it("throws when APPLICATIONS_MAP_PARAMETER is not set", async () => {
+ const ssmClient = makeSsmClient(JSON.stringify({ "client-1": "app-id-1" }));
+ const service = new ApplicationsMapService(ssmClient, undefined);
+
+ await expect(service.getApplicationId("client-1")).rejects.toThrow(
+ "APPLICATIONS_MAP_PARAMETER is required",
+ );
+ });
+
+ it("throws when SSM parameter has empty value", async () => {
+ const ssmClient = {
+ send: jest.fn().mockResolvedValue({ Parameter: { Value: "" } }),
+ } as unknown as SSMClient;
+ const service = new ApplicationsMapService(ssmClient, "/test/param");
+
+ await expect(service.getApplicationId("client-1")).rejects.toThrow(
+ "SSM parameter '/test/param' not found or has no value",
+ );
+ });
+
+ it("throws when SSM parameter contains invalid JSON", async () => {
+ const ssmClient = makeSsmClient("not valid json");
+ const service = new ApplicationsMapService(ssmClient, "/test/param");
+
+ await expect(service.getApplicationId("client-1")).rejects.toThrow(
+ "SSM parameter '/test/param' contains invalid JSON",
+ );
+ });
+
+ it("reset clears the cache and forces reload on next call", async () => {
+ const ssmClient = makeSsmClient(JSON.stringify({ "client-1": "app-id-1" }));
+ const service = new ApplicationsMapService(ssmClient, "/test/param", 5000);
+
+ await service.getApplicationId("client-1");
+ service.reset();
+ await service.getApplicationId("client-1");
+
+ expect(ssmClient.send).toHaveBeenCalledTimes(2);
+ });
+});
+
+describe("resolveCacheTtlMs", () => {
+ it("returns configured value in ms", () => {
+ expect(
+ resolveCacheTtlMs({ APPLICATIONS_MAP_CACHE_TTL_SECONDS: "30" }),
+ ).toBe(30_000);
+ });
+
+ it("returns default when env var is absent", () => {
+ expect(resolveCacheTtlMs({})).toBe(60_000);
+ });
+
+ it("returns default when env var is not a valid number", () => {
+ expect(
+ resolveCacheTtlMs({ APPLICATIONS_MAP_CACHE_TTL_SECONDS: "invalid" }),
+ ).toBe(60_000);
+ });
+});
+
+describe("createSsmClient", () => {
+ it("returns an SSMClient instance", () => {
+ expect(createSsmClient({})).toBeInstanceOf(SSMClient);
+ });
+});
diff --git a/lambdas/client-transform-filter-lambda/src/handler.ts b/lambdas/client-transform-filter-lambda/src/handler.ts
index 09e0dfb5..8f38f101 100644
--- a/lambdas/client-transform-filter-lambda/src/handler.ts
+++ b/lambdas/client-transform-filter-lambda/src/handler.ts
@@ -2,21 +2,29 @@ import type { SQSRecord } from "aws-lambda";
import pMap from "p-map";
import type {
ClientCallbackPayload,
+ ClientSubscriptionConfiguration,
StatusPublishEvent,
} from "@nhs-notify-client-callbacks/models";
import { validateStatusPublishEvent } from "services/validators/event-validator";
import { transformEvent } from "services/transformers/event-transformer";
-import { extractCorrelationId } from "services/logger";
+import { extractCorrelationId, logger } from "services/logger";
import { ValidationError, getEventError } from "services/error-handler";
import type { ObservabilityService } from "services/observability";
import type { ConfigLoader } from "services/config-loader";
import { evaluateSubscriptionFilters } from "services/subscription-filter";
+import type { ApplicationsMapService } from "services/ssm-applications-map";
+import { signPayload } from "services/payload-signer";
const BATCH_CONCURRENCY = Number(process.env.BATCH_CONCURRENCY) || 10;
const MESSAGE_ROOT_URI = process.env.MESSAGE_ROOT_URI ?? "";
+type UnsignedEvent = StatusPublishEvent & {
+ transformedPayload: ClientCallbackPayload;
+};
+
export interface TransformedEvent extends StatusPublishEvent {
transformedPayload: ClientCallbackPayload;
+ headers: { "x-hmac-sha256-signature": string };
}
class BatchStats {
@@ -83,7 +91,7 @@ function parseSqsMessageBody(
function processSingleEvent(
event: StatusPublishEvent,
observability: ObservabilityService,
-): TransformedEvent {
+): UnsignedEvent {
const correlationId = extractCorrelationId(event);
const eventType = event.type;
const { clientId, messageId } = event.data;
@@ -114,6 +122,54 @@ function processSingleEvent(
};
}
+type ClientConfigMap = Map;
+
+async function signBatch(
+ filteredEvents: UnsignedEvent[],
+ applicationsMapService: ApplicationsMapService,
+ configByClientId: ClientConfigMap,
+ stats: BatchStats,
+): Promise {
+ const results = await pMap(
+ filteredEvents,
+ async (event): Promise => {
+ const { clientId } = event.data;
+ const correlationId = extractCorrelationId(event);
+
+ const applicationId =
+ await applicationsMapService.getApplicationId(clientId);
+ if (!applicationId) {
+ stats.recordFiltered();
+ logger.warn(
+ "No applicationId found in SSM map - event will not be delivered",
+ { clientId, correlationId },
+ );
+ return undefined;
+ }
+
+ const clientConfig = configByClientId.get(clientId);
+ const apiKey = clientConfig?.[0]?.Targets?.[0]?.APIKey?.HeaderValue;
+ if (!apiKey) {
+ stats.recordFiltered();
+ logger.warn(
+ "No apiKey in client config - event will not be delivered",
+ { clientId, correlationId },
+ );
+ return undefined;
+ }
+
+ const signature = signPayload(
+ event.transformedPayload,
+ applicationId,
+ apiKey,
+ );
+ return { ...event, headers: { "x-hmac-sha256-signature": signature } };
+ },
+ { concurrency: BATCH_CONCURRENCY },
+ );
+ return results.filter((e): e is TransformedEvent => e !== undefined);
+}
+
function recordDeliveryInitiated(
transformedEvents: TransformedEvent[],
observability: ObservabilityService,
@@ -131,19 +187,12 @@ function recordDeliveryInitiated(
}
}
-async function filterBatch(
- transformedEvents: TransformedEvent[],
+async function loadClientConfigs(
+ events: UnsignedEvent[],
configLoader: ConfigLoader,
- observability: ObservabilityService,
- stats: BatchStats,
-): Promise {
- observability.recordFilteringStarted({ batchSize: transformedEvents.length });
-
- const uniqueClientIds = new Set(
- transformedEvents.map((e) => e.data.clientId),
- );
-
- const configEntries = await pMap(
+): Promise {
+ const uniqueClientIds = new Set(events.map((e) => e.data.clientId));
+ const entries = await pMap(
uniqueClientIds,
async (clientId) => {
const config = await configLoader.loadClientConfig(clientId);
@@ -151,10 +200,18 @@ async function filterBatch(
},
{ concurrency: BATCH_CONCURRENCY },
);
+ return new Map(entries);
+}
- const configByClientId = new Map(configEntries);
+async function filterBatch(
+ transformedEvents: UnsignedEvent[],
+ configByClientId: ClientConfigMap,
+ observability: ObservabilityService,
+ stats: BatchStats,
+): Promise {
+ observability.recordFilteringStarted({ batchSize: transformedEvents.length });
- const filtered: TransformedEvent[] = [];
+ const filtered: UnsignedEvent[] = [];
for (const event of transformedEvents) {
const { clientId } = event.data;
@@ -191,7 +248,7 @@ async function transformBatch(
sqsRecords: SQSRecord[],
observability: ObservabilityService,
stats: BatchStats,
-): Promise {
+): Promise {
return pMap(
sqsRecords,
(sqsRecord: SQSRecord) => {
@@ -217,6 +274,7 @@ export async function processEvents(
event: SQSRecord[],
observability: ObservabilityService,
configLoader: ConfigLoader,
+ applicationsMapService: ApplicationsMapService,
): Promise {
const startTime = Date.now();
const stats = new BatchStats();
@@ -224,13 +282,25 @@ export async function processEvents(
try {
const transformedEvents = await transformBatch(event, observability, stats);
- const filteredEvents = await filterBatch(
+ const configByClientId = await loadClientConfigs(
transformedEvents,
configLoader,
+ );
+
+ const filteredEvents = await filterBatch(
+ transformedEvents,
+ configByClientId,
observability,
stats,
);
+ const signedEvents = await signBatch(
+ filteredEvents,
+ applicationsMapService,
+ configByClientId,
+ stats,
+ );
+
const processingTime = Date.now() - startTime;
observability.logBatchProcessingCompleted({
...stats.toObject(),
@@ -238,10 +308,10 @@ export async function processEvents(
processingTimeMs: processingTime,
});
- recordDeliveryInitiated(filteredEvents, observability);
+ recordDeliveryInitiated(signedEvents, observability);
await observability.flush();
- return filteredEvents;
+ return signedEvents;
} catch (error) {
stats.recordFailure();
diff --git a/lambdas/client-transform-filter-lambda/src/index.ts b/lambdas/client-transform-filter-lambda/src/index.ts
index 87d4afdd..818ad742 100644
--- a/lambdas/client-transform-filter-lambda/src/index.ts
+++ b/lambdas/client-transform-filter-lambda/src/index.ts
@@ -3,13 +3,17 @@ import { Logger, flushLogs } from "services/logger";
import { CallbackMetrics, createMetricLogger } from "services/metrics";
import { ObservabilityService } from "services/observability";
import { ConfigLoaderService } from "services/config-loader-service";
+import { ApplicationsMapService } from "services/ssm-applications-map";
import { type TransformedEvent, processEvents } from "handler";
export const configLoaderService = new ConfigLoaderService();
+export const applicationsMapService = new ApplicationsMapService();
+
export interface HandlerDependencies {
createObservabilityService?: () => ObservabilityService;
createConfigLoaderService?: () => ConfigLoaderService;
+ createApplicationsMapService?: () => ApplicationsMapService;
}
function createDefaultObservabilityService(): ObservabilityService {
@@ -24,6 +28,10 @@ function createDefaultConfigLoaderService(): ConfigLoaderService {
return configLoaderService;
}
+function createDefaultApplicationsMapService(): ApplicationsMapService {
+ return applicationsMapService;
+}
+
export function createHandler(
dependencies: Partial = {},
): (event: SQSRecord[]) => Promise {
@@ -33,6 +41,10 @@ export function createHandler(
const configLoader = (
dependencies.createConfigLoaderService ?? createDefaultConfigLoaderService
)();
+ const applicationsMap = (
+ dependencies.createApplicationsMapService ??
+ createDefaultApplicationsMapService
+ )();
return async (event: SQSRecord[]): Promise => {
const observability = createObservabilityService();
@@ -40,6 +52,7 @@ export function createHandler(
event,
observability,
configLoader.getLoader(),
+ applicationsMap,
);
await flushLogs();
return result;
diff --git a/lambdas/client-transform-filter-lambda/src/services/payload-signer.ts b/lambdas/client-transform-filter-lambda/src/services/payload-signer.ts
new file mode 100644
index 00000000..cf69cac8
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/services/payload-signer.ts
@@ -0,0 +1,12 @@
+import { createHmac } from "node:crypto";
+import type { ClientCallbackPayload } from "@nhs-notify-client-callbacks/models";
+
+export function signPayload(
+ payload: ClientCallbackPayload,
+ applicationId: string,
+ apiKey: string,
+): string {
+ return createHmac("sha256", `${applicationId}.${apiKey}`)
+ .update(JSON.stringify(payload))
+ .digest("hex");
+}
diff --git a/lambdas/client-transform-filter-lambda/src/services/ssm-applications-map.ts b/lambdas/client-transform-filter-lambda/src/services/ssm-applications-map.ts
new file mode 100644
index 00000000..87cead24
--- /dev/null
+++ b/lambdas/client-transform-filter-lambda/src/services/ssm-applications-map.ts
@@ -0,0 +1,85 @@
+import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
+import { logger } from "services/logger";
+
+const DEFAULT_CACHE_TTL_SECONDS = 60;
+
+export const createSsmClient = (
+ env: NodeJS.ProcessEnv = process.env,
+): SSMClient => {
+ const endpoint = env.AWS_ENDPOINT_URL;
+ return new SSMClient({ endpoint });
+};
+
+export const resolveCacheTtlMs = (
+ env: NodeJS.ProcessEnv = process.env,
+): number => {
+ const ttlSeconds = Number.parseInt(
+ env.APPLICATIONS_MAP_CACHE_TTL_SECONDS ?? `${DEFAULT_CACHE_TTL_SECONDS}`,
+ 10,
+ );
+ return (
+ (Number.isFinite(ttlSeconds) ? ttlSeconds : DEFAULT_CACHE_TTL_SECONDS) *
+ 1000
+ );
+};
+
+export class ApplicationsMapService {
+ private cachedMap: Map | undefined;
+
+ private cacheExpiresAt = 0;
+
+ constructor(
+ private readonly ssmClient: SSMClient = createSsmClient(),
+ private readonly parameterName: string | undefined = process.env
+ .APPLICATIONS_MAP_PARAMETER,
+ private readonly cacheTtlMs: number = resolveCacheTtlMs(),
+ ) {}
+
+ async getApplicationId(clientId: string): Promise {
+ const map = await this.getMap();
+ return map.get(clientId);
+ }
+
+ private async getMap(): Promise