Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
02b0af2
refactor: api gateway from cpt for reuse
tstephen-nhs Mar 18, 2026
455b657
Merge branch 'main' into aea-6254-cdk-api-gateway
tstephen-nhs Mar 18, 2026
26fbada
feat: add state machine construct inc. api gateway endpoint
tstephen-nhs Mar 18, 2026
db95a9f
chore: fix 'any' use
tstephen-nhs Mar 18, 2026
d3594ca
Merge branch 'main' into aea-6254-cdk-api-gateway
tstephen-nhs Mar 24, 2026
f3a9207
Add a construct that creates a batch of SSM parameters, thier outputs…
wildjames Mar 24, 2026
c7dbb39
Hack around sonar complaint
wildjames Mar 24, 2026
f7e421d
Add some validation around parameters being unique, and unit tests wr…
wildjames Mar 24, 2026
2afccf1
Fallback to non optional descriptions and names
wildjames Mar 24, 2026
dc6f2d3
Merge in aea-6256-cdk-statemachine
wildjames Mar 24, 2026
bb83c29
fix: enforce expected role
tstephen-nhs Mar 25, 2026
74d0de2
fix: make LogGroup child of API gateway
tstephen-nhs Mar 25, 2026
c7d714d
chore: add sonarqube plugin for 'clean as you code'
tstephen-nhs Mar 25, 2026
f2e9325
fix: protect against enabling csoc with no destination
tstephen-nhs Mar 25, 2026
0ac46b1
chore: clean imports
tstephen-nhs Mar 25, 2026
b500519
chore: ignore SQ rather than add extra var declaration
tstephen-nhs Mar 25, 2026
734254b
docs: JS doc
tstephen-nhs Mar 26, 2026
e26e480
docs: example of ApiGateway use
tstephen-nhs Mar 26, 2026
50ebe0a
Merge branch 'aea-6254-cdk-api-gateway' into aea-6258-ssm-parameters
wildjames Mar 26, 2026
bd610d0
refactor: centralise constants
tstephen-nhs Mar 26, 2026
4912c82
Add jsdocs to the construct
wildjames Mar 26, 2026
a784142
Allow a fallback value on get env var
wildjames Mar 26, 2026
7b17511
Allow default values in env vars. Tests
wildjames Mar 26, 2026
503d44c
Merge branch 'main' into aea-6254-cdk-api-gateway
tstephen-nhs Mar 27, 2026
0ae7177
revert git secrets install
tstephen-nhs Mar 27, 2026
2b92465
fix: use postCreate to avoid git-secrets failing on second and subseq…
tstephen-nhs Mar 27, 2026
70e7f20
docs: add copilot instructions and teach it to write JSDoc
tstephen-nhs Mar 27, 2026
a5a01ac
Merge branch 'main' into aea-6254-cdk-api-gateway
tstephen-nhs Mar 27, 2026
3176a52
chore: adopt latest get-repo-config.yml
tstephen-nhs Mar 27, 2026
87cba70
chore: strip comment from devcontainer.json
tstephen-nhs Mar 27, 2026
dcab80b
Merge in latest from Tims branch
wildjames Mar 27, 2026
1f2297d
Resolve conflicts
wildjames Mar 27, 2026
f5a4f8d
Run linter
wildjames Mar 27, 2026
d15b6f7
Go away sonar
wildjames Mar 27, 2026
92376db
Address comments
wildjames Mar 27, 2026
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/*
Comment thread
tstephen-nhs marked this conversation as resolved.
Outdated
3 changes: 2 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"timonwong.shellcheck",
"github.vscode-github-actions",
"dbaeumer.vscode-eslint",
"vitest.explorer"
"vitest.explorer",
"sonarsource.sonarlint-vscode"
],
"settings": {
"cSpell.words": [
Expand Down
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
1,924 changes: 351 additions & 1,573 deletions package-lock.json

Large diffs are not rendered by default.

25 changes: 20 additions & 5 deletions packages/cdkConstructs/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,36 @@ import {CloudFormationClient, DescribeStacksCommand} from "@aws-sdk/client-cloud
import {S3Client, HeadObjectCommand} from "@aws-sdk/client-s3"
import {StandardStackProps} from "../apps/createApp"

export function getConfigFromEnvVar(varName: string, prefix: string = "CDK_CONFIG_"): string {
export function getConfigFromEnvVar(
varName: string,
prefix: string = "CDK_CONFIG_",
defaultValue: string | undefined = undefined
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I know the idiomatic way to do this would be to use an options object, but that would break downstream things and for this I don't want to mess about with that if we can avoid it...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
defaultValue: string | undefined = undefined
defaultValue: string | undefined

explicitly initialising to undefined feels a bit excessive.
There a couple of these

): string {
const value = process.env[prefix + varName]
if (!value) {
if (defaultValue !== undefined) {
return defaultValue
}
throw new Error(`Environment variable ${prefix}${varName} is not set`)
}
Comment thread
wildjames marked this conversation as resolved.
return value
}

export function getBooleanConfigFromEnvVar(varName: string, prefix: string = "CDK_CONFIG_"): boolean {
const value = getConfigFromEnvVar(varName, prefix)
export function getBooleanConfigFromEnvVar(
varName: string,
prefix: string = "CDK_CONFIG_",
defaultValue: string | undefined = undefined
): boolean {
const value = getConfigFromEnvVar(varName, prefix, defaultValue)
return value.toLowerCase().trim() === "true"
}

export function getNumberConfigFromEnvVar(varName: string, prefix: string = "CDK_CONFIG_"): number {
const value = getConfigFromEnvVar(varName, prefix)
export function getNumberConfigFromEnvVar(
varName: string,
prefix: string = "CDK_CONFIG_",
defaultValue: string | undefined = undefined
): number {
const value = getConfigFromEnvVar(varName, prefix, defaultValue)
return Number(value)
}

Expand Down
234 changes: 234 additions & 0 deletions packages/cdkConstructs/src/constructs/RestApiGateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
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)

if (props.forwardCsocLogs && props.csocApiGatewayDestination === "") {
throw new Error("csocApiGatewayDestination must be provided when forwardCsocLogs is true")
}

// 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(this, "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, // NOSONAR
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,31 @@
import {IResource, LambdaIntegration} from "aws-cdk-lib/aws-apigateway"
import {IRole} from "aws-cdk-lib/aws-iam"
import {HttpMethod, IFunction} 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
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,61 @@
import {IResource, PassthroughBehavior, StepFunctionsIntegration} from "aws-cdk-lib/aws-apigateway"
import {IRole} from "aws-cdk-lib/aws-iam"
import {HttpMethod} from "aws-cdk-lib/aws-lambda"
import {Construct} from "constructs"
import {stateMachineRequestTemplate} from "./templates/stateMachineRequest.js"
import {stateMachine200ResponseTemplate, stateMachineErrorResponseTemplate} from "./templates/stateMachineResponses.js"
import {ExpressStateMachine} from "../StateMachine.js"

export interface StateMachineEndpointProps {
parentResource: IResource
readonly resourceName: string
readonly method: HttpMethod
restApiGatewayRole: IRole
stateMachine: ExpressStateMachine
}

export class StateMachineEndpoint extends Construct {
resource: IResource

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

const requestTemplate = stateMachineRequestTemplate(props.stateMachine.stateMachine.stateMachineArn)

const resource = props.parentResource.addResource(props.resourceName)
resource.addMethod(props.method, StepFunctionsIntegration.startExecution(props.stateMachine.stateMachine, {
credentialsRole: props.restApiGatewayRole,
passthroughBehavior: PassthroughBehavior.WHEN_NO_MATCH,
requestTemplates: {
"application/json": requestTemplate,
"application/fhir+json": requestTemplate
},
integrationResponses: [
{
statusCode: "200",
responseTemplates: {
"application/json": stateMachine200ResponseTemplate
}
},
{
statusCode: "400",
selectionPattern: "^4\\d{2}.*",

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`String.raw` should be used to avoid escaping `\`.

See more on https://sonarcloud.io/project/issues?id=NHSDigital_eps-cdk-utils&issues=AZ0gj4ls1zUD9oIYNKDp&open=AZ0gj4ls1zUD9oIYNKDp&pullRequest=619
responseTemplates: {
"application/json": stateMachineErrorResponseTemplate("400")
}
},
{
statusCode: "500",
selectionPattern: "^5\\d{2}.*",

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`String.raw` should be used to avoid escaping `\`.

See more on https://sonarcloud.io/project/issues?id=NHSDigital_eps-cdk-utils&issues=AZ0gj4ls1zUD9oIYNKDq&open=AZ0gj4ls1zUD9oIYNKDq&pullRequest=619
responseTemplates: {
"application/json": stateMachineErrorResponseTemplate("500")
}
}
]
}), {
methodResponses: []
})

this.resource = resource
}
}
Loading
Loading