Skip to content
Draft
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
})
}

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,

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
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