From ab3946294b4e627d33ddc447c495639131633c37 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:35:00 +0000 Subject: [PATCH 1/4] feat: RestApiGateway construct --- package-lock.json | 28 +- .../src/constructs/RestApiGateway.ts | 230 +++++++++++++ .../RestApiGateway/accessLogFormat.ts | 38 +++ packages/cdkConstructs/src/index.ts | 2 + .../tests/constructs/RestApiGateway.test.ts | 310 ++++++++++++++++++ 5 files changed, 601 insertions(+), 7 deletions(-) create mode 100644 packages/cdkConstructs/src/constructs/RestApiGateway.ts create mode 100644 packages/cdkConstructs/src/constructs/RestApiGateway/accessLogFormat.ts create mode 100644 packages/cdkConstructs/tests/constructs/RestApiGateway.test.ts diff --git a/package-lock.json b/package-lock.json index 14933855..ae634d9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -120,6 +120,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -633,9 +634,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", - "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", "dev": true, "license": "MIT", "optional": true, @@ -645,9 +646,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "dev": true, "license": "MIT", "optional": true, @@ -2389,6 +2390,7 @@ "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2452,6 +2454,7 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -3082,6 +3085,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3765,6 +3769,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -4047,7 +4052,8 @@ "version": "10.4.4", "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.4.4.tgz", "integrity": "sha512-lP0qC1oViYf1cutHo9/KQ8QL637f/W29tDmv/6sy35F5zs+MD9f66nbAAIjicwc7fwyuF3rkg6PhZh4sfvWIpA==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/convert-source-map": { "version": "2.0.0", @@ -4291,6 +4297,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5182,6 +5189,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -7581,6 +7589,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7713,6 +7722,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -7801,6 +7811,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7962,6 +7973,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8070,6 +8082,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8083,6 +8096,7 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", diff --git a/packages/cdkConstructs/src/constructs/RestApiGateway.ts b/packages/cdkConstructs/src/constructs/RestApiGateway.ts new file mode 100644 index 00000000..6870360f --- /dev/null +++ b/packages/cdkConstructs/src/constructs/RestApiGateway.ts @@ -0,0 +1,230 @@ +import {Fn, RemovalPolicy} from "aws-cdk-lib" +import { + CfnStage, + EndpointType, + LogGroupLogDestination, + MethodLoggingLevel, + MTLSConfig, + RestApi, + SecurityPolicy +} from "aws-cdk-lib/aws-apigateway" +import { + IManagedPolicy, + IRole, + ManagedPolicy, + PolicyStatement, + Role, + ServicePrincipal +} from "aws-cdk-lib/aws-iam" +import {Stream} from "aws-cdk-lib/aws-kinesis" +import {Key} from "aws-cdk-lib/aws-kms" +import {CfnSubscriptionFilter, LogGroup} from "aws-cdk-lib/aws-logs" +import {Construct} from "constructs" +import {accessLogFormat} from "./RestApiGateway/accessLogFormat.js" +import {Certificate, CertificateValidation} from "aws-cdk-lib/aws-certificatemanager" +import {Bucket} from "aws-cdk-lib/aws-s3" +import {BucketDeployment, Source} from "aws-cdk-lib/aws-s3-deployment" +import {ARecord, HostedZone, RecordTarget} from "aws-cdk-lib/aws-route53" +import {ApiGateway as ApiGatewayTarget} from "aws-cdk-lib/aws-route53-targets" +import {NagSuppressions} from "cdk-nag" + +export interface RestApiGatewayProps { + readonly stackName: string + readonly logRetentionInDays: number + readonly mutualTlsTrustStoreKey: string | undefined + readonly forwardCsocLogs: boolean + readonly csocApiGatewayDestination: string + readonly executionPolicies: Array +} + +export class RestApiGateway extends Construct { + public readonly api: RestApi + public readonly role: IRole + + public constructor(scope: Construct, id: string, props: RestApiGatewayProps) { + super(scope, id) + + // Imports + const cloudWatchLogsKmsKey = Key.fromKeyArn( + this, "cloudWatchLogsKmsKey", Fn.importValue("account-resources:CloudwatchLogsKmsKeyArn")) + + const splunkDeliveryStream = Stream.fromStreamArn( + this, "SplunkDeliveryStream", Fn.importValue("lambda-resources:SplunkDeliveryStream")) + + const splunkSubscriptionFilterRole = Role.fromRoleArn( + this, "splunkSubscriptionFilterRole", Fn.importValue("lambda-resources:SplunkSubscriptionFilterRole")) + + const trustStoreBucket = Bucket.fromBucketArn( + this, "TrustStoreBucket", Fn.importValue("account-resources:TrustStoreBucket")) + + const trustStoreDeploymentBucket = Bucket.fromBucketArn( + this, "TrustStoreDeploymentBucket", Fn.importValue("account-resources:TrustStoreDeploymentBucket")) + + const trustStoreBucketKmsKey = Key.fromKeyArn( + this, "TrustStoreBucketKmsKey", Fn.importValue("account-resources:TrustStoreBucketKMSKey")) + + const epsDomainName: string = Fn.importValue("eps-route53-resources:EPS-domain") + const hostedZone = HostedZone.fromHostedZoneAttributes(this, "HostedZone", { + hostedZoneId: Fn.importValue("eps-route53-resources:EPS-ZoneID"), + zoneName: epsDomainName + }) + const serviceDomainName = `${props.stackName}.${epsDomainName}` + + // Resources + const logGroup = new LogGroup(this, "ApiGatewayAccessLogGroup", { + encryptionKey: cloudWatchLogsKmsKey, + logGroupName: `/aws/apigateway/${props.stackName}-apigw`, + retention: props.logRetentionInDays, + removalPolicy: RemovalPolicy.DESTROY + }) + + new CfnSubscriptionFilter(this, "ApiGatewayAccessLogsSplunkSubscriptionFilter", { + destinationArn: splunkDeliveryStream.streamArn, + filterPattern: "", + logGroupName: logGroup.logGroupName, + roleArn: splunkSubscriptionFilterRole.roleArn + }) + + if (props.forwardCsocLogs) { + new CfnSubscriptionFilter(this, "ApiGatewayAccessLogsCSOCSubscriptionFilter", { + destinationArn: props.csocApiGatewayDestination, + filterPattern: "", + logGroupName: logGroup.logGroupName, + roleArn: splunkSubscriptionFilterRole.roleArn + }) + } + + const certificate = new Certificate(this, "Certificate", { + domainName: serviceDomainName, + validation: CertificateValidation.fromDns(hostedZone) + }) + + let mtlsConfig: MTLSConfig | undefined + + if (props.mutualTlsTrustStoreKey) { + const trustStoreKeyPrefix = `cpt-api/${props.stackName}-truststore` + const logGroup = new LogGroup(scope, "LambdaLogGroup", { + encryptionKey: cloudWatchLogsKmsKey, + logGroupName: `/aws/lambda/${props.stackName}-truststore-deployment`, + retention: props.logRetentionInDays, + removalPolicy: RemovalPolicy.DESTROY + }) + const trustStoreDeploymentPolicy = new ManagedPolicy(this, "TrustStoreDeploymentPolicy", { + statements: [ + new PolicyStatement({ + actions: [ + "s3:ListBucket" + ], + resources: [ + trustStoreBucket.bucketArn, + trustStoreDeploymentBucket.bucketArn + ] + }), + new PolicyStatement({ + actions: [ + "s3:GetObject" + ], + resources: [trustStoreBucket.arnForObjects(props.mutualTlsTrustStoreKey)] + }), + new PolicyStatement({ + actions: [ + "s3:DeleteObject", + "s3:PutObject" + ], + resources: [ + trustStoreDeploymentBucket.arnForObjects(trustStoreKeyPrefix + "/" + props.mutualTlsTrustStoreKey) + ] + }), + new PolicyStatement({ + actions: [ + "kms:Decrypt", + "kms:Encrypt", + "kms:GenerateDataKey" + ], + resources: [trustStoreBucketKmsKey.keyArn] + }), + new PolicyStatement({ + actions: [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + resources: [ + logGroup.logGroupArn, + `${logGroup.logGroupArn}:log-stream:*` + ] + }) + ] + }) + NagSuppressions.addResourceSuppressions(trustStoreDeploymentPolicy, [ + { + id: "AwsSolutions-IAM5", + // eslint-disable-next-line max-len + reason: "Suppress error for not having wildcards in permissions. This is a fine as we need to have permissions on all log streams under path" + } + ]) + const trustStoreDeploymentRole = new Role(this, "TrustStoreDeploymentRole", { + assumedBy: new ServicePrincipal("lambda.amazonaws.com"), + managedPolicies: [trustStoreDeploymentPolicy] + }).withoutPolicyUpdates() + const deployment = new BucketDeployment(this, "TrustStoreDeployment", { + sources: [Source.bucket(trustStoreBucket, props.mutualTlsTrustStoreKey)], + destinationBucket: trustStoreDeploymentBucket, + destinationKeyPrefix: trustStoreKeyPrefix, + extract: false, + retainOnDelete: false, + role: trustStoreDeploymentRole, + logGroup: logGroup + }) + mtlsConfig = { + bucket: deployment.deployedBucket, + key: trustStoreKeyPrefix + "/" + props.mutualTlsTrustStoreKey + } + } + + const apiGateway = new RestApi(this, "ApiGateway", { + restApiName: `${props.stackName}-apigw`, + domainName: { + domainName: serviceDomainName, + certificate: certificate, + securityPolicy: SecurityPolicy.TLS_1_2, + endpointType: EndpointType.REGIONAL, + mtls: mtlsConfig + }, + disableExecuteApiEndpoint: mtlsConfig ? true : false, + endpointConfiguration: { + types: [EndpointType.REGIONAL] + }, + deploy: true, + deployOptions: { + accessLogDestination: new LogGroupLogDestination(logGroup), + accessLogFormat: accessLogFormat(), + loggingLevel: MethodLoggingLevel.INFO, + metricsEnabled: true + } + }) + + const role = new Role(this, "ApiGatewayRole", { + assumedBy: new ServicePrincipal("apigateway.amazonaws.com"), + managedPolicies: props.executionPolicies + }).withoutPolicyUpdates() + + new ARecord(this, "ARecord", { + recordName: props.stackName, + target: RecordTarget.fromAlias(new ApiGatewayTarget(apiGateway)), + zone: hostedZone + }) + + const cfnStage = apiGateway.deploymentStage.node.defaultChild as CfnStage + cfnStage.cfnOptions.metadata = { + guard: { + SuppressedRules: [ + "API_GW_CACHE_ENABLED_AND_ENCRYPTED" + ] + } + } + + // Outputs + this.api = apiGateway + this.role = role + } +} diff --git a/packages/cdkConstructs/src/constructs/RestApiGateway/accessLogFormat.ts b/packages/cdkConstructs/src/constructs/RestApiGateway/accessLogFormat.ts new file mode 100644 index 00000000..1504faf7 --- /dev/null +++ b/packages/cdkConstructs/src/constructs/RestApiGateway/accessLogFormat.ts @@ -0,0 +1,38 @@ +import {AccessLogFormat} from "aws-cdk-lib/aws-apigateway" + +export const accessLogFormat = () => { + return AccessLogFormat.custom(JSON.stringify({ + requestId: "$context.requestId", + ip: "$context.identity.sourceIp", + caller: "$context.identity.caller", + user: "$context.identity.user", + requestTime: "$context.requestTime", + httpMethod: "$context.httpMethod", + resourcePath: "$context.resourcePath", + status: "$context.status", + protocol: "$context.protocol", + responseLength: "$context.responseLength", + accountId: "$context.accountId", + apiId: "$context.apiId", + stage: "$context.stage", + api_key: "$context.identity.apiKey", + identity: { + sourceIp: "$context.identity.sourceIp", + userAgent: "$context.identity.userAgent", + clientCert: { + subjectDN: "$context.identity.clientCert.subjectDN", + issuerDN: "$context.identity.clientCert.issuerDN", + serialNumber: "$context.identity.clientCert.serialNumber", + validityNotBefore: "$context.identity.clientCert.validity.notBefore", + validityNotAfter: "$context.identity.clientCert.validity.notAfter" + } + }, + integration:{ + error: "$context.integration.error", + integrationStatus: "$context.integration.integrationStatus", + latency: "$context.integration.latency", + requestId: "$context.integration.requestId", + status: "$context.integration.status" + } + })) +} diff --git a/packages/cdkConstructs/src/index.ts b/packages/cdkConstructs/src/index.ts index d5ab521c..88bf2f22 100644 --- a/packages/cdkConstructs/src/index.ts +++ b/packages/cdkConstructs/src/index.ts @@ -1,2 +1,4 @@ // Export all constructs export * from "./constructs/TypescriptLambdaFunction.js" +export * from "./constructs/RestApiGateway.js" +export * from "./constructs/RestApiGateway/accessLogFormat.js" diff --git a/packages/cdkConstructs/tests/constructs/RestApiGateway.test.ts b/packages/cdkConstructs/tests/constructs/RestApiGateway.test.ts new file mode 100644 index 00000000..f16cdca2 --- /dev/null +++ b/packages/cdkConstructs/tests/constructs/RestApiGateway.test.ts @@ -0,0 +1,310 @@ +import {App, Stack} from "aws-cdk-lib" +import {Template, Match} from "aws-cdk-lib/assertions" +import {ManagedPolicy, PolicyStatement} from "aws-cdk-lib/aws-iam" +import { + describe, + test, + beforeAll, + expect +} from "vitest" + +import {RestApiGateway} from "../../src/constructs/RestApiGateway.js" + +describe("RestApiGateway without mTLS", () => { + let stack: Stack + let app: App + let template: Template + + beforeAll(() => { + app = new App() + stack = new Stack(app, "RestApiGatewayStack") + + const testPolicy = new ManagedPolicy(stack, "TestPolicy", { + description: "test execution policy", + statements: [ + new PolicyStatement({ + actions: ["lambda:InvokeFunction"], + resources: ["*"] + }) + ] + }) + + const apiGateway = new RestApiGateway(stack, "TestApiGateway", { + stackName: "test-stack", + logRetentionInDays: 30, + mutualTlsTrustStoreKey: undefined, + forwardCsocLogs: false, + csocApiGatewayDestination: "", + executionPolicies: [testPolicy] + }) + + // Add a dummy method to satisfy API Gateway validation + apiGateway.api.root.addMethod("GET") + + template = Template.fromStack(stack) + }) + + test("creates CloudWatch log group with correct properties", () => { + template.hasResourceProperties("AWS::Logs::LogGroup", { + LogGroupName: "/aws/apigateway/test-stack-apigw", + KmsKeyId: {"Fn::ImportValue": "account-resources:CloudwatchLogsKmsKeyArn"}, + RetentionInDays: 30 + }) + }) + + test("creates Splunk subscription filter", () => { + template.hasResourceProperties("AWS::Logs::SubscriptionFilter", { + FilterPattern: "", + RoleArn: {"Fn::ImportValue": "lambda-resources:SplunkSubscriptionFilterRole"}, + DestinationArn: {"Fn::ImportValue": "lambda-resources:SplunkDeliveryStream"} + }) + }) + + test("does not create CSOC subscription filter", () => { + const filters = template.findResources("AWS::Logs::SubscriptionFilter") + const filterCount = Object.keys(filters).length + expect(filterCount).toBe(1) + }) + + test("creates ACM certificate", () => { + template.hasResourceProperties("AWS::CertificateManager::Certificate", { + DomainName: { + "Fn::Join": ["", [ + "test-stack.", + {"Fn::ImportValue": "eps-route53-resources:EPS-domain"} + ]] + }, + DomainValidationOptions: [{ + DomainName: { + "Fn::Join": ["", [ + "test-stack.", + {"Fn::ImportValue": "eps-route53-resources:EPS-domain"} + ]] + }, + HostedZoneId: {"Fn::ImportValue": "eps-route53-resources:EPS-ZoneID"} + }], + ValidationMethod: "DNS" + }) + }) + + test("creates REST API Gateway with correct configuration", () => { + template.hasResourceProperties("AWS::ApiGateway::RestApi", { + Name: "test-stack-apigw", + EndpointConfiguration: { + Types: ["REGIONAL"] + }, + DisableExecuteApiEndpoint: false + }) + }) + + test("creates API Gateway domain name with TLS 1.2", () => { + template.hasResourceProperties("AWS::ApiGateway::DomainName", { + DomainName: { + "Fn::Join": ["", [ + "test-stack.", + {"Fn::ImportValue": "eps-route53-resources:EPS-domain"} + ]] + }, + EndpointConfiguration: { + Types: ["REGIONAL"] + }, + SecurityPolicy: "TLS_1_2" + }) + }) + + test("creates deployment with logging and metrics enabled", () => { + template.hasResourceProperties("AWS::ApiGateway::Stage", { + MethodSettings: [{ + LoggingLevel: "INFO", + MetricsEnabled: true, + DataTraceEnabled: false, + HttpMethod: "*", + ResourcePath: "/*" + }], + AccessLogSetting: Match.objectLike({ + Format: Match.stringLikeRegexp("requestId") + }) + }) + }) + + test("creates IAM role for API Gateway execution", () => { + template.hasResourceProperties("AWS::IAM::Role", { + AssumeRolePolicyDocument: { + Statement: [{ + Action: "sts:AssumeRole", + Effect: "Allow", + Principal: { + Service: "apigateway.amazonaws.com" + } + }], + Version: "2012-10-17" + } + }) + }) + + test("creates Route53 A record", () => { + template.hasResourceProperties("AWS::Route53::RecordSet", { + Name: { + "Fn::Join": ["", [ + "test-stack.", + {"Fn::ImportValue": "eps-route53-resources:EPS-domain"}, + "." + ]] + }, + Type: "A" + }) + }) + + test("sets guard metadata on stage", () => { + const stages = template.findResources("AWS::ApiGateway::Stage") + const stageKeys = Object.keys(stages) + expect(stageKeys.length).toBeGreaterThan(0) + + const stage = stages[stageKeys[0]] + expect(stage.Metadata).toBeDefined() + expect(stage.Metadata.guard).toBeDefined() + expect(stage.Metadata.guard.SuppressedRules).toContain("API_GW_CACHE_ENABLED_AND_ENCRYPTED") + }) +}) + +describe("RestApiGateway with CSOC logs", () => { + let stack: Stack + let app: App + let template: Template + + beforeAll(() => { + app = new App() + stack = new Stack(app, "RestApiGatewayStack") + + const testPolicy = new ManagedPolicy(stack, "TestPolicy", { + description: "test execution policy", + statements: [ + new PolicyStatement({ + actions: ["lambda:InvokeFunction"], + resources: ["*"] + }) + ] + }) + + const apiGateway = new RestApiGateway(stack, "TestApiGateway", { + stackName: "test-stack", + logRetentionInDays: 30, + mutualTlsTrustStoreKey: undefined, + forwardCsocLogs: true, + csocApiGatewayDestination: "arn:aws:logs:eu-west-2:123456789012:destination:csoc-destination", + executionPolicies: [testPolicy] + }) + + // Add a dummy method to satisfy API Gateway validation + apiGateway.api.root.addMethod("GET") + + template = Template.fromStack(stack) + }) + + test("creates both Splunk and CSOC subscription filters", () => { + const filters = template.findResources("AWS::Logs::SubscriptionFilter") + const filterCount = Object.keys(filters).length + expect(filterCount).toBe(2) + }) + + test("creates CSOC subscription filter with correct destination", () => { + template.hasResourceProperties("AWS::Logs::SubscriptionFilter", { + FilterPattern: "", + DestinationArn: "arn:aws:logs:eu-west-2:123456789012:destination:csoc-destination" + }) + }) +}) + +describe("RestApiGateway with mTLS", () => { + let stack: Stack + let app: App + let template: Template + + beforeAll(() => { + app = new App() + stack = new Stack(app, "RestApiGatewayStack") + + const testPolicy = new ManagedPolicy(stack, "TestPolicy", { + description: "test execution policy", + statements: [ + new PolicyStatement({ + actions: ["lambda:InvokeFunction"], + resources: ["*"] + }) + ] + }) + + const apiGateway = new RestApiGateway(stack, "TestApiGateway", { + stackName: "test-stack", + logRetentionInDays: 30, + mutualTlsTrustStoreKey: "truststore.pem", + forwardCsocLogs: false, + csocApiGatewayDestination: "", + executionPolicies: [testPolicy] + }) + + // Add a dummy method to satisfy API Gateway validation + apiGateway.api.root.addMethod("GET") + + template = Template.fromStack(stack) + }) + + test("creates trust store deployment log group", () => { + template.hasResourceProperties("AWS::Logs::LogGroup", { + LogGroupName: "/aws/lambda/test-stack-truststore-deployment", + KmsKeyId: {"Fn::ImportValue": "account-resources:CloudwatchLogsKmsKeyArn"}, + RetentionInDays: 30 + }) + }) + + test("creates trust store deployment policy with S3 permissions", () => { + const policies = template.findResources("AWS::IAM::ManagedPolicy") + const trustStorePolicy = Object.values(policies).find((p: any) => + p.Properties?.PolicyDocument?.Statement?.some((s: any) => + s.Action?.includes("s3:ListBucket") + ) + ) + expect(trustStorePolicy).toBeDefined() + const statements = (trustStorePolicy as any).Properties.PolicyDocument.Statement + expect(statements.some((s: any) => s.Action?.includes("s3:ListBucket"))).toBe(true) + expect(statements.some((s: any) => s.Action?.includes("s3:GetObject"))).toBe(true) + expect(statements.some((s: any) => s.Action?.includes("kms:Decrypt"))).toBe(true) + expect(statements.some((s: any) => s.Action?.includes("logs:CreateLogStream"))).toBe(true) + }) + + test("creates trust store deployment role", () => { + template.hasResourceProperties("AWS::IAM::Role", { + AssumeRolePolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: "sts:AssumeRole", + Effect: "Allow", + Principal: { + Service: "lambda.amazonaws.com" + } + }) + ]), + Version: "2012-10-17" + } + }) + }) + + test("creates bucket deployment custom resource", () => { + const customResources = template.findResources("Custom::CDKBucketDeployment") + expect(Object.keys(customResources).length).toBeGreaterThan(0) + }) + + test("disables execute-api endpoint when mTLS is enabled", () => { + template.hasResourceProperties("AWS::ApiGateway::RestApi", { + Name: "test-stack-apigw", + DisableExecuteApiEndpoint: true + }) + }) + + test("configures mTLS on domain name", () => { + const domainNames = template.findResources("AWS::ApiGateway::DomainName") + const domainName = Object.values(domainNames)[0] as any + expect(domainName.Properties.MutualTlsAuthentication).toBeDefined() + expect(domainName.Properties.MutualTlsAuthentication.TruststoreUri).toBeDefined() + }) +}) From a14aa66a5a22c15b9c026cc03a30c140921f9bab Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:14:36 +0000 Subject: [PATCH 2/4] chore: trivy ignore minimatch and rollup --- .trivyignore.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.trivyignore.yaml b/.trivyignore.yaml index 7a699b25..82329a8d 100644 --- a/.trivyignore.yaml +++ b/.trivyignore.yaml @@ -50,3 +50,12 @@ vulnerabilities: - id: CVE-2026-26960 statement: tar node module expired_at: 2026-06-01 + - id: CVE-2026-27903 + statement: minimatch node module + expired_at: 2026-06-01 + - id: CVE-2026-27904 + statement: minimatch node module + expired_at: 2026-06-01 + - id: CVE-2026-27606 + statement: rollup node module + expired_at: 2026-06-01 From c44181a69744827ef81bf22dd309ad9d977717b8 Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:30:53 +0000 Subject: [PATCH 3/4] chore: satisfy SQ --- .../cdkConstructs/tests/constructs/RestApiGateway.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cdkConstructs/tests/constructs/RestApiGateway.test.ts b/packages/cdkConstructs/tests/constructs/RestApiGateway.test.ts index a9b424f6..b6bd576e 100644 --- a/packages/cdkConstructs/tests/constructs/RestApiGateway.test.ts +++ b/packages/cdkConstructs/tests/constructs/RestApiGateway.test.ts @@ -24,7 +24,7 @@ describe("RestApiGateway without mTLS", () => { statements: [ new PolicyStatement({ actions: ["lambda:InvokeFunction"], - resources: ["*"] + resources: ["arn:aws:lambda:eu-west-2:123456789012:function:test-function"] }) ] }) @@ -181,7 +181,7 @@ describe("RestApiGateway with CSOC logs", () => { statements: [ new PolicyStatement({ actions: ["lambda:InvokeFunction"], - resources: ["*"] + resources: ["arn:aws:lambda:eu-west-2:123456789012:function:test-function"] }) ] }) @@ -229,7 +229,7 @@ describe("RestApiGateway with mTLS", () => { statements: [ new PolicyStatement({ actions: ["lambda:InvokeFunction"], - resources: ["*"] + resources: ["arn:aws:lambda:eu-west-2:123456789012:function:test-function"] }) ] }) From 493164c9103d437c0e67d9c6c2e2de97cdf61a7f Mon Sep 17 00:00:00 2001 From: tstephen-nhs <231503406+tstephen-nhs@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:03:28 +0000 Subject: [PATCH 4/4] chore: exclude vitest cfg from SQ --- sonar-project.properties | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sonar-project.properties b/sonar-project.properties index 8054cb01..d631f11b 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -2,6 +2,10 @@ sonar.organization=nhsdigital sonar.projectKey=NHSDigital_eps-cdk-utils sonar.host.url=https://sonarcloud.io +sonar.exclusions=\ + packages/serviceSearchClient/vitest.config.ts,\ + packages/enrichPrescriptions/vitest.config.ts + sonar.coverage.exclusions=\ **/*.test.*,\ **/jest.config.ts,scripts/*,\