diff --git a/packages/cdkConstructs/src/constructs/RestApiGateway.ts b/packages/cdkConstructs/src/constructs/RestApiGateway.ts index 16918400..0b7df284 100644 --- a/packages/cdkConstructs/src/constructs/RestApiGateway.ts +++ b/packages/cdkConstructs/src/constructs/RestApiGateway.ts @@ -44,6 +44,11 @@ export interface RestApiGatewayProps { readonly logRetentionInDays: number /** Truststore object key to enable mTLS; leave undefined to disable mTLS or when enableServiceDomain is false. */ readonly mutualTlsTrustStoreKey: string | undefined + /** Required with mutualTlsTrustStoreKey. Service name, used as prefix for trust store key */ + readonly serviceName: string | undefined + /** Optional stack UUID. If set, included in the mTLS trust store key prefix to prevent collisions + * when deploying multiple stacks with the same name, avoiding AWS API Gateway mTLS key caching issues. */ + readonly trustStoreUuuid: string | undefined /** Enables creation of a second subscription filter to forward logs to CSOC. */ readonly forwardCsocLogs: boolean /** Destination ARN used by the optional CSOC subscription filter. */ @@ -56,6 +61,16 @@ export interface RestApiGatewayProps { readonly enableServiceDomain?: boolean } +const getTrustStoreKeyPrefix = (stackName: string, + serviceName: string | undefined, + trustStoreUuuid: string | undefined) => { + if (trustStoreUuuid) { + return `${serviceName}/${stackName}-${trustStoreUuuid}-truststore` + } else { + return `${serviceName}/${stackName}-truststore` + } +} + /** Creates a regional REST API with standard logging, DNS, and optional mTLS/CSOC integration. */ export class RestApiGateway extends Construct { /** Created API Gateway instance. */ @@ -69,9 +84,11 @@ export class RestApiGateway extends Construct { * @example * ```ts * const api = new RestApiGateway(this, "MyApi", { - * stackName: "my-service", + * stackName: "v1.3", * logRetentionInDays: 30, * mutualTlsTrustStoreKey: "truststore.pem", + * serviceName: "my-service", + * trustStoreUuuid: "abc123", * forwardCsocLogs: true, * csocApiGatewayDestination: "arn:aws:logs:eu-west-2:123456789012:destination:csoc", * executionPolicies: [myLambdaInvokePolicy], @@ -93,6 +110,10 @@ export class RestApiGateway extends Construct { throw new Error("mutualTlsTrustStoreKey should not be provided when enableServiceDomain is false") } + if (props.mutualTlsTrustStoreKey && !props.serviceName) { + throw new Error("serviceName must be provided when mTLS is set") + } + // Imports const cloudWatchLogsKmsKey = Key.fromKeyArn( this, "cloudWatchLogsKmsKey", ACCOUNT_RESOURCES.CloudwatchLogsKmsKeyArn) @@ -158,7 +179,11 @@ export class RestApiGateway extends Construct { let mtlsConfig: MTLSConfig | undefined if (enableServiceDomain && props.mutualTlsTrustStoreKey) { - const trustStoreKeyPrefix = `cpt-api/${props.stackName}-truststore` + const trustStoreKeyPrefix = getTrustStoreKeyPrefix( + props.stackName, + props.serviceName, + props.trustStoreUuuid + ) const logGroup = new LogGroup(this, "LambdaLogGroup", { encryptionKey: cloudWatchLogsKmsKey, logGroupName: `/aws/lambda/${props.stackName}-truststore-deployment`, diff --git a/packages/cdkConstructs/tests/constructs/RestApiGateway.test.ts b/packages/cdkConstructs/tests/constructs/RestApiGateway.test.ts index 02b1538a..a0e96499 100644 --- a/packages/cdkConstructs/tests/constructs/RestApiGateway.test.ts +++ b/packages/cdkConstructs/tests/constructs/RestApiGateway.test.ts @@ -253,6 +253,7 @@ describe("RestApiGateway with mTLS", () => { stackName: "test-stack", logRetentionInDays: 30, mutualTlsTrustStoreKey: "truststore.pem", + serviceName: "cpt-api", forwardCsocLogs: false, csocApiGatewayDestination: "", executionPolicies: [testPolicy], @@ -321,6 +322,12 @@ describe("RestApiGateway with mTLS", () => { expect(Object.keys(customResources).length).toBeGreaterThan(0) }) + test("uses serviceName in trust store deployment key prefix", () => { + template.hasResourceProperties("Custom::CDKBucketDeployment", { + DestinationBucketKeyPrefix: "cpt-api/test-stack-truststore" + }) + }) + test("disables execute-api endpoint when mTLS is enabled", () => { template.hasResourceProperties("AWS::ApiGateway::RestApi", { Name: "test-stack-apigw", @@ -344,6 +351,80 @@ describe("RestApiGateway with mTLS", () => { }) }) +describe("RestApiGateway with mTLS and trustStoreUuuid", () => { + test("uses trustStoreUuuid in trust store deployment key prefix", () => { + interface ManagedPolicyResource { + Properties?: { + PolicyDocument?: { + Statement?: Array<{ + Action?: Array + Resource?: unknown | Array + }> + } + } + } + + const app = new App() + const stack = new Stack(app, "RestApiGatewayStackWithUuid") + + const testPolicy = new ManagedPolicy(stack, "TestPolicy", { + description: "test execution policy", + statements: [ + new PolicyStatement({ + actions: ["lambda:InvokeFunction"], + resources: ["arn:aws:lambda:eu-west-2:123456789012:function:test-function"] + }) + ] + }) + + const apiGateway = new RestApiGateway(stack, "TestApiGateway", { + stackName: "test-stack", + logRetentionInDays: 30, + mutualTlsTrustStoreKey: "truststore.pem", + serviceName: "cpt-api", + trustStoreUuuid: "f47ac10b-58cc-4372-a567-0e02b2c3d479", + forwardCsocLogs: false, + csocApiGatewayDestination: "", + executionPolicies: [testPolicy], + enableServiceDomain: true + }) + + apiGateway.api.root.addMethod("GET") + + const template = Template.fromStack(stack) + template.hasResourceProperties("Custom::CDKBucketDeployment", { + DestinationBucketKeyPrefix: "cpt-api/test-stack-f47ac10b-58cc-4372-a567-0e02b2c3d479-truststore" + }) + + const policies = template.findResources("AWS::IAM::ManagedPolicy") + const expectedTrustStoreObjectPath = + "cpt-api/test-stack-f47ac10b-58cc-4372-a567-0e02b2c3d479-truststore/truststore.pem" + + const hasExpectedTrustStorePath = Object.values(policies).some((policy) => { + const statements = (policy as ManagedPolicyResource).Properties?.PolicyDocument?.Statement ?? [] + return statements.some((statement) => { + if (!statement.Action?.includes("s3:PutObject")) { + return false + } + + const resources = Array.isArray(statement.Resource) + ? statement.Resource + : (statement.Resource ? [statement.Resource] : []) + + return resources.some((resource) => { + if (typeof resource === "string") { + return resource.includes(expectedTrustStoreObjectPath) + } + + return JSON.stringify(resource).includes(expectedTrustStoreObjectPath) + }) + }) + }) + + expect(hasExpectedTrustStorePath).toBe(true) + }) +}) + describe("RestApiGateway validation errors", () => { test("throws when forwardCsocLogs is true and csocApiGatewayDestination is empty string", () => { const app = new App() @@ -385,12 +466,37 @@ describe("RestApiGateway validation errors", () => { stackName: "test-stack", logRetentionInDays: 30, mutualTlsTrustStoreKey: "truststore.pem", + serviceName: "cpt-api", forwardCsocLogs: false, csocApiGatewayDestination: "", executionPolicies: [testPolicy], enableServiceDomain: false })).toThrow("mutualTlsTrustStoreKey should not be provided when enableServiceDomain is false") }) + + test("throws when mutualTlsTrustStoreKey is set and serviceName is missing", () => { + const app = new App() + const stack = new Stack(app, "ValidationStack3") + const testPolicy = new ManagedPolicy(stack, "TestPolicy", { + description: "test execution policy", + statements: [ + new PolicyStatement({ + actions: ["lambda:InvokeFunction"], + resources: ["arn:aws:lambda:eu-west-2:123456789012:function:test-function"] + }) + ] + }) + + expect(() => new RestApiGateway(stack, "TestApiGateway", { + stackName: "test-stack", + logRetentionInDays: 30, + mutualTlsTrustStoreKey: "truststore.pem", + forwardCsocLogs: false, + csocApiGatewayDestination: "", + executionPolicies: [testPolicy], + enableServiceDomain: true + })).toThrow("serviceName must be provided when mTLS is set") + }) }) describe("RestApiGateway enableServiceDomain default behaviour", () => {