Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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) {
Comment thread
tstephen-nhs marked this conversation as resolved.
super(scope, id)

// Imports
const cloudWatchLogsKmsKey = Key.fromKeyArn(
Comment thread
tstephen-nhs marked this conversation as resolved.
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 thread
tstephen-nhs marked this conversation as resolved.

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 thread
tstephen-nhs marked this conversation as resolved.
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 thread
tstephen-nhs marked this conversation as resolved.
Outdated
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