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: 2 additions & 0 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ RUN if [ -n "${DOCKER_GID}" ]; then \
fi && \
usermod -aG docker vscode; \
fi

RUN apt-get update && apt-get install -y --no-install-recommends git-secrets && rm -rf /var/lib/apt/lists/*
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ _site/
.jekyll-metadata
vendor
.trivy_out/
*.tgz
3 changes: 3 additions & 0 deletions .trivyignore.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,6 @@ vulnerabilities:
- id: CVE-2026-32141
statement: flatted
expired_at: 2026-06-01
- id: CVE-2026-33036
statement: fast-xml-parser vulnerability accepted as risk - dependency of aws-sdk/client-dynamodb and redocly
expired_at: 2026-04-01
230 changes: 230 additions & 0 deletions packages/cdkConstructs/src/constructs/RestApiGateway.ts
Original file line number Diff line number Diff line change
@@ -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<IManagedPolicy>
}

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
})
}
Comment on lines +88 to +95
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

forwardCsocLogs can be set to true while csocApiGatewayDestination is an empty string (as in tests). That will synthesize a SubscriptionFilter with an invalid/empty DestinationArn and fail deployment. Consider making csocApiGatewayDestination optional unless forwardCsocLogs is true, and throw an explicit error when forwardCsocLogs is true but the destination ARN is missing/blank.

Copilot uses AI. Check for mistakes.

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
})
Comment on lines +104 to +111
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

In the mTLS branch, the truststore deployment LogGroup is created with scope (the constructor arg) rather than this. That makes the LogGroup a sibling of the construct instead of a child, and can cause duplicate construct ID collisions (e.g., if multiple RestApiGateway instances are added to the same stack, they would all try to create LambdaLogGroup under the stack scope). Create this LogGroup under this (and consider using a more specific id to avoid clashes).

Copilot uses AI. Check for mistakes.
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,

Check warning on line 193 in packages/cdkConstructs/src/constructs/RestApiGateway.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unnecessary use of boolean literals in conditional expression.

See more on https://sonarcloud.io/project/issues?id=NHSDigital_eps-cdk-utils&issues=AZyfdqZ5MA34uwVC8cuJ&open=AZyfdqZ5MA34uwVC8cuJ&pullRequest=547
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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {IResource, LambdaIntegration} from "aws-cdk-lib/aws-apigateway"
import {IRole} from "aws-cdk-lib/aws-iam"
import {IFunction} from "aws-cdk-lib/aws-lambda"

Check warning on line 3 in packages/cdkConstructs/src/constructs/RestApiGateway/LambdaEndpoint.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'/__w/eps-cdk-utils/eps-cdk-utils/node_modules/aws-cdk-lib/aws-lambda/index.js' imported multiple times.

See more on https://sonarcloud.io/project/issues?id=NHSDigital_eps-cdk-utils&issues=AZ0BzH-y6BZSWFp2OuCJ&open=AZ0BzH-y6BZSWFp2OuCJ&pullRequest=547
import {HttpMethod} from "aws-cdk-lib/aws-lambda"

Check warning on line 4 in packages/cdkConstructs/src/constructs/RestApiGateway/LambdaEndpoint.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'/__w/eps-cdk-utils/eps-cdk-utils/node_modules/aws-cdk-lib/aws-lambda/index.js' imported multiple times.

See more on https://sonarcloud.io/project/issues?id=NHSDigital_eps-cdk-utils&issues=AZ0BzH-y6BZSWFp2OuCK&open=AZ0BzH-y6BZSWFp2OuCK&pullRequest=547
import {Construct} from "constructs"

export interface LambdaFunctionHolder {
readonly function: IFunction
}

export interface LambdaEndpointProps {
parentResource: IResource
readonly resourceName: string
readonly method: HttpMethod
Comment on lines +4 to +14
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

LambdaEndpointProps.method is typed as aws-lambda's HttpMethod, but this construct is for API Gateway REST methods (IResource.addMethod takes a string and REST APIs support methods like ANY). Using the Lambda type unnecessarily couples to aws-lambda and restricts valid REST API methods. Prefer typing this as string (or a local union that includes ANY) to match API Gateway semantics.

Suggested change
import {HttpMethod} from "aws-cdk-lib/aws-lambda"
import {Construct} from "constructs"
export interface LambdaFunctionHolder {
readonly function: IFunction
}
export interface LambdaEndpointProps {
parentResource: IResource
readonly resourceName: string
readonly method: HttpMethod
import {Construct} from "constructs"
export interface LambdaFunctionHolder {
readonly function: IFunction
}
export type RestApiHttpMethod =
| "GET"
| "POST"
| "PUT"
| "DELETE"
| "PATCH"
| "HEAD"
| "OPTIONS"
| "ANY"
export interface LambdaEndpointProps {
parentResource: IResource
readonly resourceName: string
readonly method: RestApiHttpMethod

Copilot uses AI. Check for mistakes.
restApiGatewayRole: IRole
lambdaFunction: LambdaFunctionHolder
}

export class LambdaEndpoint extends Construct {
resource: IResource

public constructor(scope: Construct, id: string, props: LambdaEndpointProps) {
super(scope, id)

const resource = props.parentResource.addResource(props.resourceName)
resource.addMethod(props.method, new LambdaIntegration(props.lambdaFunction.function, {
credentialsRole: props.restApiGatewayRole
}))

this.resource = resource
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}))
}
3 changes: 3 additions & 0 deletions packages/cdkConstructs/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
// Export all constructs
export * from "./constructs/TypescriptLambdaFunction.js"
export * from "./constructs/RestApiGateway.js"
export * from "./constructs/RestApiGateway/accessLogFormat.js"
export * from "./constructs/RestApiGateway/LambdaEndpoint.js"
export * from "./constructs/PythonLambdaFunction.js"
export * from "./apps/createApp.js"
export * from "./config/index.js"
Expand Down
Loading
Loading