Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 .github/workflows/cicd-1-pull-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions infrastructure/terraform/components/callbacks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| <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-applicationId map used for payload signing | `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_component"></a> [component](#input\_component) | The variable encapsulating the name of this component | `string` | `"callbacks"` | no |
Expand Down
2 changes: 2 additions & 0 deletions infrastructure/terraform/components/callbacks/locals.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ resource "aws_pipes_pipe" "main" {
input_template = <<EOF
{
"type": <$.type>,
"transformedPayload": <$.transformedPayload>
"transformedPayload": <$.transformedPayload>,
"headers": <$.headers>
}
EOF
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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]
}
}
6 changes: 6 additions & 0 deletions infrastructure/terraform/components/callbacks/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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-applicationId map used for payload signing"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lambdas/client-transform-filter-lambda/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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",
Expand Down Expand Up @@ -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" }),
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,30 @@ 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) => [
{
SubscriptionType: "MessageStatus",
SubscriptionId: "00000000-0000-0000-0000-000000000001",
ClientId: clientId,
Targets: [],
Targets: [stubTarget],
MessageStatuses: [
"DELIVERED",
"FAILED",
Expand All @@ -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: [
Expand All @@ -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: [
Expand All @@ -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(),
Expand Down Expand Up @@ -96,6 +115,7 @@ describe("Lambda handler", () => {
createObservabilityService: () =>
new ObservabilityService(mockLogger, mockMetrics, mockMetricsLogger),
createConfigLoaderService: makeStubConfigLoaderService,
createApplicationsMapService: makeStubApplicationsMapService,
});

beforeEach(() => {
Expand Down Expand Up @@ -348,6 +368,7 @@ describe("Lambda handler", () => {
const faultyHandler = createHandler({
createObservabilityService: () => faultyObservability,
createConfigLoaderService: makeStubConfigLoaderService,
createApplicationsMapService: makeStubApplicationsMapService,
});

const sqsMessage: SQSRecord = {
Expand Down Expand Up @@ -527,6 +548,7 @@ describe("createHandler default wiring", () => {
[],
state.mockObservabilityInstance,
expect.any(Object),
expect.any(Object),
);
expect(result).toEqual(["ok"]);

Expand Down
Original file line number Diff line number Diff line change
@@ -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"),
);
});
});
Loading
Loading