diff --git a/package-lock.json b/package-lock.json index 3029dd6f..f3be4c05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,7 @@ "requires": true, "packages": { "": { + "name": "eps-cdk-utils", "license": "MIT", "workspaces": [ "packages/cdkConstructs", @@ -54,6 +55,7 @@ "semver" ], "license": "Apache-2.0", + "peer": true, "dependencies": { "jsonschema": "~1.4.1", "semver": "^7.7.3" @@ -1142,6 +1144,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -2168,9 +2171,9 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4098,6 +4101,7 @@ "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -4161,6 +4165,7 @@ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", @@ -4824,6 +4829,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5487,9 +5493,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -5516,6 +5522,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5798,7 +5805,8 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz", "integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/convert-source-map": { "version": "2.0.0", @@ -5966,6 +5974,7 @@ "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -6030,6 +6039,7 @@ "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -6139,9 +6149,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6885,6 +6895,7 @@ "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", @@ -8997,9 +9008,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -9100,6 +9111,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9215,6 +9227,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -9301,6 +9314,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9444,6 +9458,7 @@ "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -9535,6 +9550,7 @@ "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", diff --git a/packages/cdkConstructs/src/constructs/RestApiGateway/StateMachineEndpoint.ts b/packages/cdkConstructs/src/constructs/RestApiGateway/StateMachineEndpoint.ts new file mode 100644 index 00000000..3a0b7929 --- /dev/null +++ b/packages/cdkConstructs/src/constructs/RestApiGateway/StateMachineEndpoint.ts @@ -0,0 +1,74 @@ +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" + +/** Parameters used to create an API endpoint backed by a Step Functions Express workflow. */ +export interface StateMachineEndpointProps { + /** Parent API resource under which the state machine endpoint is added. */ + parentResource: IResource + /** Path segment used to create the child API resource. */ + readonly resourceName: string + /** HTTP verb bound to the Step Functions integration. */ + readonly method: HttpMethod + /** Invocation role used by API Gateway when starting workflow executions. */ + restApiGatewayRole: IRole + /** State machine wrapper construct providing the target workflow ARN and integration target. */ + stateMachine: ExpressStateMachine +} + +/** Adds an API Gateway resource/method that starts an Express Step Functions execution. */ +export class StateMachineEndpoint extends Construct { + /** API resource created by this construct. */ + resource: IResource + + /** Wires request and response mapping templates for JSON and FHIR payload flows. */ + 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: String.raw`^4\d{2}.*`, + responseTemplates: { + "application/json": stateMachineErrorResponseTemplate("400") + } + }, + { + statusCode: "500", + selectionPattern: String.raw`^5\d{2}.*`, + responseTemplates: { + "application/json": stateMachineErrorResponseTemplate("500") + } + } + ] + }), { + methodResponses: [ + { statusCode: "200" }, + { statusCode: "400" }, + { statusCode: "500" } + ] + }) + + this.resource = resource + } +} diff --git a/packages/cdkConstructs/src/constructs/RestApiGateway/templates/stateMachineRequest.ts b/packages/cdkConstructs/src/constructs/RestApiGateway/templates/stateMachineRequest.ts new file mode 100644 index 00000000..87ca7357 --- /dev/null +++ b/packages/cdkConstructs/src/constructs/RestApiGateway/templates/stateMachineRequest.ts @@ -0,0 +1,60 @@ +/* eslint-disable max-len */ +/** + * @returns API Gateway request mapping template for StartExecution payloads. + */ +export const stateMachineRequestTemplate = (stateMachineArn: string) => { + return `## Velocity Template used for API Gateway request mapping template +## "@@" is used here as a placeholder for '"' to avoid using escape characters. + +#set($includeHeaders = true) +#set($includeQueryString = true) +#set($includePath = true) +#set($requestContext = '') + +#set($inputString = '') +#set($allParams = $input.params()) +#set($allParams.header.apigw-request-id = $context.requestId) +{ + "stateMachineArn": "${stateMachineArn}", + #set($inputString = "$inputString,@@body@@: $input.body") + #if ($includeHeaders) + #set($inputString = "$inputString, @@headers@@:{") + #foreach($paramName in $allParams.header.keySet()) + #set($inputString = "$inputString @@$paramName@@: @@$util.escapeJavaScript($allParams.header.get($paramName))@@") + #if($foreach.hasNext) + #set($inputString = "$inputString,") + #end + #end + #set($inputString = "$inputString }") + #end + #if ($includeQueryString) + #set($inputString = "$inputString, @@queryStringParameters@@:{") + #foreach($paramName in $allParams.querystring.keySet()) + #set($inputString = "$inputString @@$paramName@@: @@$util.escapeJavaScript($allParams.querystring.get($paramName))@@") + #if($foreach.hasNext) + #set($inputString = "$inputString,") + #end + #end + #set($inputString = "$inputString }") + #end + #if ($includePath) + #set($inputString = "$inputString, @@pathParameters@@:{") + #foreach($paramName in $allParams.path.keySet()) + #set($inputString = "$inputString @@$paramName@@: @@$util.escapeJavaScript($allParams.path.get($paramName))@@") + #if($foreach.hasNext) + #set($inputString = "$inputString,") + #end + #end + #set($inputString = "$inputString }") + #end + ## Check if the request context should be included as part of the execution input + #if($requestContext && !$requestContext.empty) + #set($inputString = "$inputString,") + #set($inputString = "$inputString @@requestContext@@: $requestContext") + #end + #set($inputString = "$inputString}") + #set($inputString = $inputString.replaceAll("@@",'"')) + #set($len = $inputString.length() - 1) + "input": "{$util.escapeJavaScript($inputString.substring(1,$len))}" +}` +} diff --git a/packages/cdkConstructs/src/constructs/RestApiGateway/templates/stateMachineResponses.ts b/packages/cdkConstructs/src/constructs/RestApiGateway/templates/stateMachineResponses.ts new file mode 100644 index 00000000..91e2d534 --- /dev/null +++ b/packages/cdkConstructs/src/constructs/RestApiGateway/templates/stateMachineResponses.ts @@ -0,0 +1,64 @@ +/* eslint-disable max-len */ +/** VTL response template that unwraps successful workflow output and forwards status and headers. */ +export const stateMachine200ResponseTemplate = `#set($payload = $util.parseJson($input.path('$.output'))) +#set($context.responseOverride.status = $payload.Payload.statusCode) +#set($allHeaders = $payload.Payload.headers) +#foreach($headerName in $allHeaders.keySet()) + #set($context.responseOverride.header[$headerName] = $allHeaders.get($headerName)) +#end +$payload.Payload.body` + +interface ErrorMap { + [key: string]: { + code: string + severity: string + diagnostics: string + codingCode: string + codingDisplay: string + } +} + +const getOperationOutcome = (status: string) => { + const errorMap: ErrorMap = { + 400: { + code: "value", + severity: "error", + diagnostics: "Invalid request.", + codingCode: "BAD_REQUEST", + codingDisplay: "400: The Server was unable to process the request" + }, + 500: { + code: "exception", + severity: "fatal", + diagnostics: "Unknown Error.", + codingCode: "SERVER_ERROR", + codingDisplay: "500: The Server has encountered an error processing the request." + } + } + + return JSON.stringify({ + ResourceType: "OperationOutcome", + issue: [ + { + code: errorMap[status].code, + severity: errorMap[status].severity, + diagnostics: errorMap[status].diagnostics, + details: { + coding: [ + { + system: "https://fhir.nhs.uk/CodeSystem/http-error-codes", + code: errorMap[status].codingCode, + display: errorMap[status].codingDisplay + } + ] + } + } + ] + }) +} + +/** + * @returns VTL response template that maps workflow failures to FHIR OperationOutcome payloads. + */ +export const stateMachineErrorResponseTemplate = (status: string) => `#set($context.responseOverride.header["Content-Type"] ="application/fhir+json") +${getOperationOutcome(status)}` diff --git a/packages/cdkConstructs/src/constructs/StateMachine.ts b/packages/cdkConstructs/src/constructs/StateMachine.ts new file mode 100644 index 00000000..821c4813 --- /dev/null +++ b/packages/cdkConstructs/src/constructs/StateMachine.ts @@ -0,0 +1,208 @@ +import {RemovalPolicy} from "aws-cdk-lib" +import { + IManagedPolicy, + IRole, + ManagedPolicy, + PolicyStatement, + Role, + ServicePrincipal +} from "aws-cdk-lib/aws-iam" +import {Stream} from "aws-cdk-lib/aws-kinesis" +import {IKey, Key} from "aws-cdk-lib/aws-kms" +import {CfnLogGroup, CfnSubscriptionFilter, LogGroup} from "aws-cdk-lib/aws-logs" +import { + DefinitionBody, + IChainable, + LogLevel, + QueryLanguage, + StateMachine, + StateMachineType +} from "aws-cdk-lib/aws-stepfunctions" +import {Construct} from "constructs" +import {CfnDeliveryStream} from "aws-cdk-lib/aws-kinesisfirehose" +import {ACCOUNT_RESOURCES, LAMBDA_RESOURCES} from "../constants" + +/** + * Configuration for provisioning an Express Step Functions state machine + * with logging and optional Splunk forwarding. + */ +export interface StateMachineProps { + /** Stack name, used as prefix for resource naming and DNS records. */ + readonly stackName: string + /** Friendly state machine name used for both AWS resource and log naming. */ + readonly stateMachineName: string + /** Workflow definition chain rendered as the state machine definition body. */ + readonly definition: IChainable + /** Extra managed policies merged into the execution role when required. */ + readonly additionalPolicies?: Array + /** Retention period applied to the workflow CloudWatch log group. */ + readonly logRetentionInDays: number + /** + * Optional KMS key for encrypting CloudWatch Logs. + * Defaults to the shared account-level KMS key via CloudFormation import. + */ + readonly cloudWatchLogsKmsKey?: IKey + /** + * Optional IAM policy allowing CloudWatch to use the KMS key for encrypting logs. + * Defaults to the shared account-level policy via CloudFormation import. + */ + readonly cloudwatchEncryptionKMSPolicy?: IManagedPolicy + /** + * Optional Kinesis Firehose delivery stream for forwarding logs to Splunk. + * When not provided, falls back to a Kinesis Stream via CloudFormation import. + */ + readonly splunkDeliveryStream?: CfnDeliveryStream + /** + * Optional IAM role used by the Splunk subscription filter. + * Defaults to the shared role via CloudFormation import. + */ + readonly splunkSubscriptionFilterRole?: IRole + /** + * Whether to create a subscription filter to forward logs to Splunk. + * Defaults to true. + */ + readonly addSplunkSubscriptionFilter?: boolean +} + +/** Creates an Express Step Functions workflow with CloudWatch logging and invoke permissions. */ +export class ExpressStateMachine extends Construct { + /** Managed policy that grants permission to start this workflow. */ + public readonly executionPolicy: ManagedPolicy + + /** Created Step Functions state machine resource. */ + public readonly stateMachine: StateMachine + + /** + * Provisions an Express Step Functions workflow with logging, tracing, and invoke permissions. + * @example + * ```ts + * const sm = new ExpressStateMachine(this, "MyWorkflow", { + * stackName: "my-service", + * stateMachineName: "my-service-workflow", + * definition: new Pass(this, "Start"), + * logRetentionInDays: 30, + * additionalPolicies: [myLambdaInvokePolicy] + * }) + * // Attach the generated execution policy to an API Gateway role + * apiGatewayRole.addManagedPolicy(sm.executionPolicy) + * ``` + */ + public constructor(scope: Construct, id: string, props: StateMachineProps) { + super(scope, id) + + const { + cloudWatchLogsKmsKey = Key.fromKeyArn( + this, "CloudWatchLogsKmsKey", ACCOUNT_RESOURCES.CloudwatchLogsKmsKeyArn), + cloudwatchEncryptionKMSPolicy = ManagedPolicy.fromManagedPolicyArn( + this, "cloudwatchEncryptionKMSPolicy", ACCOUNT_RESOURCES.CloudwatchEncryptionKMSPolicyArn), + splunkDeliveryStream, + splunkSubscriptionFilterRole = Role.fromRoleArn( + this, "splunkSubscriptionFilterRole", LAMBDA_RESOURCES.SplunkSubscriptionFilterRole), + addSplunkSubscriptionFilter = true + } = props + + const logGroup = new LogGroup(this, "StateMachineLogGroup", { + encryptionKey: cloudWatchLogsKmsKey, + logGroupName: `/aws/stepfunctions/${props.stateMachineName}`, + retention: props.logRetentionInDays, + removalPolicy: RemovalPolicy.DESTROY + }) + + const cfnLogGroup = logGroup.node.defaultChild as CfnLogGroup + cfnLogGroup.cfnOptions.metadata = { + guard: { + SuppressedRules: [ + "CW_LOGGROUP_RETENTION_PERIOD_CHECK" + ] + } + } + + if (addSplunkSubscriptionFilter) { + if (splunkDeliveryStream) { + new CfnSubscriptionFilter(this, "StateMachineLogsSplunkSubscriptionFilter", { + destinationArn: splunkDeliveryStream.attrArn, + filterPattern: "", + logGroupName: logGroup.logGroupName, + roleArn: splunkSubscriptionFilterRole.roleArn + }) + } else { + const splunkDeliveryStreamImport = Stream.fromStreamArn( + this, "SplunkDeliveryStream", LAMBDA_RESOURCES.SplunkDeliveryStream) + new CfnSubscriptionFilter(this, "StateMachineLogsSplunkSubscriptionFilter", { + destinationArn: splunkDeliveryStreamImport.streamArn, + filterPattern: "", + logGroupName: logGroup.logGroupName, + roleArn: splunkSubscriptionFilterRole.roleArn + }) + } + } + + const putLogsManagedPolicy = new ManagedPolicy(this, "StateMachinePutLogsManagedPolicy", { + description: `write to ${props.stateMachineName} logs`, + statements: [ + new PolicyStatement({ + actions: [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + resources: [ + logGroup.logGroupArn, + `${logGroup.logGroupArn}:log-stream:*` + ] + }), + new PolicyStatement({ + actions: [ + "logs:DescribeLogGroups", + "logs:ListLogDeliveries", + "logs:CreateLogDelivery", + "logs:GetLogDelivery", + "logs:UpdateLogDelivery", + "logs:DeleteLogDelivery", + "logs:PutResourcePolicy", + "logs:DescribeResourcePolicies" + ], + resources: ["*"] + }) + ] + }) + + const role = new Role(this, "StateMachineRole", { + assumedBy: new ServicePrincipal("states.amazonaws.com"), + managedPolicies: [ + putLogsManagedPolicy, + cloudwatchEncryptionKMSPolicy, + ...(props.additionalPolicies ?? []) + ] + }).withoutPolicyUpdates() + + const stateMachine = new StateMachine(this, "StateMachine", { + stateMachineName: props.stateMachineName, + stateMachineType: StateMachineType.EXPRESS, + queryLanguage: QueryLanguage.JSONATA, + definitionBody: DefinitionBody.fromChainable(props.definition), + role: role, + logs: { + destination: logGroup, + level: LogLevel.ALL, + includeExecutionData: true + }, + tracingEnabled: true + }) + + const executionManagedPolicy = new ManagedPolicy(this, "ExecuteStateMachineManagedPolicy", { + description: `execute state machine ${props.stateMachineName}`, + statements: [ + new PolicyStatement({ + actions: [ + "states:StartSyncExecution", + "states:StartExecution" + ], + resources: [stateMachine.stateMachineArn] + }) + ] + }) + + this.executionPolicy = executionManagedPolicy + this.stateMachine = stateMachine + } +} diff --git a/packages/cdkConstructs/src/constructs/StateMachine/CatchAllErrorPass.ts b/packages/cdkConstructs/src/constructs/StateMachine/CatchAllErrorPass.ts new file mode 100644 index 00000000..3111be63 --- /dev/null +++ b/packages/cdkConstructs/src/constructs/StateMachine/CatchAllErrorPass.ts @@ -0,0 +1,53 @@ +import {Pass} from "aws-cdk-lib/aws-stepfunctions" +import {Construct} from "constructs" + +const errorOperationOutcome = `{% $string( + { + "ResourceType": "OperationOutcome", + "meta": { + "lastUpdated": $now() + }, + "issue": [ + { + "code": "exception", + "severity": "fatal", + "diagnostics": "Unknown Error.", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/CodeSystem/http-error-codes", + "code": "SERVER_ERROR", + "display": "500: The Server has encountered an error processing the request." + } + ] + } + } + ] + } +) %}` + +/** Produces a fixed 500 FHIR OperationOutcome payload for unhandled workflow failures. */ +export class CatchAllErrorPass extends Construct { + /** Pass state returned by this construct for chaining in state machine definitions. */ + public readonly state: Pass + + /** Creates a terminal-style error response payload without exposing internal exception detail. */ + public constructor(scope: Construct, id: string) { + super(scope, id) + + const state = new Pass(this, "Catch All Error", { + outputs: { + Payload: { + statusCode: 500, + headers: { + "Content-Type": "application/fhir+json", + "Cache-Control": "no-cache" + }, + body: errorOperationOutcome + } + } + }) + + this.state = state + } +} diff --git a/packages/cdkConstructs/src/index.ts b/packages/cdkConstructs/src/index.ts index a964a253..0430b531 100644 --- a/packages/cdkConstructs/src/index.ts +++ b/packages/cdkConstructs/src/index.ts @@ -1,8 +1,11 @@ // Export all constructs +export * from "./constructs/StateMachine.js" +export * from "./constructs/StateMachine/CatchAllErrorPass.js" 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/RestApiGateway/StateMachineEndpoint.js" export * from "./constructs/PythonLambdaFunction.js" export * from "./apps/createApp.js" export * from "./config/index.js" diff --git a/packages/cdkConstructs/tests/constructs/RestApiGateway/StateMachineEndpoint.test.ts b/packages/cdkConstructs/tests/constructs/RestApiGateway/StateMachineEndpoint.test.ts new file mode 100644 index 00000000..88a7c30e --- /dev/null +++ b/packages/cdkConstructs/tests/constructs/RestApiGateway/StateMachineEndpoint.test.ts @@ -0,0 +1,119 @@ +import {App, Stack} from "aws-cdk-lib" +import {RestApi} from "aws-cdk-lib/aws-apigateway" +import {Role, ServicePrincipal} from "aws-cdk-lib/aws-iam" +import {Template, Match} from "aws-cdk-lib/assertions" +import { + describe, + test, + beforeAll, + expect +} from "vitest" +import {HttpMethod} from "aws-cdk-lib/aws-lambda" +import {Pass} from "aws-cdk-lib/aws-stepfunctions" + +import {StateMachineEndpoint} from "../../../src/constructs/RestApiGateway/StateMachineEndpoint.js" +import {ExpressStateMachine} from "../../../src/constructs/StateMachine.js" +import {stateMachineRequestTemplate} from "../../../src/constructs/RestApiGateway/templates/stateMachineRequest.js" +import { + stateMachine200ResponseTemplate, + stateMachineErrorResponseTemplate +} from "../../../src/constructs/RestApiGateway/templates/stateMachineResponses.js" + +describe("StateMachineEndpoint construct", () => { + let stack: Stack + let template: Template + let construct: StateMachineEndpoint + + beforeAll(() => { + const app = new App() + stack = new Stack(app, "StateMachineEndpointStack") + + const api = new RestApi(stack, "TestApi") + + const credentialsRole = new Role(stack, "ApiGwRole", { + assumedBy: new ServicePrincipal("apigateway.amazonaws.com") + }) + + const dummyState = new Pass(stack, "DummyState") + const expressStateMachine = new ExpressStateMachine(stack, "TestStateMachine", { + stackName: "test-stack", + stateMachineName: "test-state-machine", + definition: dummyState, + logRetentionInDays: 30 + }) + + construct = new StateMachineEndpoint(stack, "TestStateMachineEndpoint", { + parentResource: api.root, + resourceName: "clinical-view", + method: HttpMethod.GET, + restApiGatewayRole: credentialsRole, + stateMachine: expressStateMachine + }) + + template = Template.fromStack(stack) + }) + + test("creates an API Gateway resource with the correct path part", () => { + template.hasResourceProperties("AWS::ApiGateway::Resource", { + PathPart: "clinical-view" + }) + }) + + test("creates a GET method on the resource", () => { + template.hasResourceProperties("AWS::ApiGateway::Method", { + HttpMethod: "GET" + }) + }) + + test("uses Step Functions integration with correct integration responses", () => { + template.hasResourceProperties("AWS::ApiGateway::Method", { + Integration: Match.objectLike({ + Type: "AWS", + IntegrationHttpMethod: "POST", + IntegrationResponses: Match.arrayWith([ + Match.objectLike({StatusCode: "200"}), + Match.objectLike({StatusCode: "400", SelectionPattern: String.raw`^4\d{2}.*`}), + Match.objectLike({StatusCode: "500", SelectionPattern: String.raw`^5\d{2}.*`}) + ]) + }) + }) + }) + + test("exposes the resource as a public property", () => { + expect(construct.resource).toBeDefined() + }) +}) + +describe("stateMachineRequestTemplate helper", () => { + test("returns a string containing the provided ARN", () => { + const arn = "arn:aws:states:eu-west-2:123456789012:stateMachine:test" + const result = stateMachineRequestTemplate(arn) + expect(result).toContain(arn) + expect(result).toContain("stateMachineArn") + }) + + test("includes header, queryString, and path parameter blocks", () => { + const result = stateMachineRequestTemplate("arn:test") + expect(result).toContain("includeHeaders") + expect(result).toContain("includeQueryString") + expect(result).toContain("includePath") + }) +}) + +describe("stateMachineResponseTemplates helpers", () => { + test("200 template references Payload.statusCode", () => { + expect(stateMachine200ResponseTemplate).toContain("Payload.statusCode") + expect(stateMachine200ResponseTemplate).toContain("Payload.body") + }) + + test("error template for 400 includes BAD_REQUEST coding", () => { + const result = stateMachineErrorResponseTemplate("400") + expect(result).toContain("BAD_REQUEST") + expect(result).toContain("application/fhir+json") + }) + + test("error template for 500 includes SERVER_ERROR coding", () => { + const result = stateMachineErrorResponseTemplate("500") + expect(result).toContain("SERVER_ERROR") + }) +}) diff --git a/packages/cdkConstructs/tests/constructs/stateMachineConstruct.test.ts b/packages/cdkConstructs/tests/constructs/stateMachineConstruct.test.ts new file mode 100644 index 00000000..3d4d6387 --- /dev/null +++ b/packages/cdkConstructs/tests/constructs/stateMachineConstruct.test.ts @@ -0,0 +1,165 @@ +import {App, Stack} from "aws-cdk-lib" +import {Template, Match} from "aws-cdk-lib/assertions" +import { + describe, + test, + beforeAll, + expect +} from "vitest" +import {Pass} from "aws-cdk-lib/aws-stepfunctions" + +import {ExpressStateMachine} from "../../src/constructs/StateMachine.js" +import {CatchAllErrorPass} from "../../src/constructs/StateMachine/CatchAllErrorPass.js" + +describe("ExpressStateMachine construct", () => { + let stack: Stack + let app: App + let template: Template + let construct: ExpressStateMachine + + beforeAll(() => { + app = new App() + stack = new Stack(app, "StateMachineStack") + + const dummyState = new Pass(stack, "DummyState") + + construct = new ExpressStateMachine(stack, "TestStateMachine", { + stackName: "test-stack", + stateMachineName: "test-state-machine", + definition: dummyState, + logRetentionInDays: 30 + }) + + template = Template.fromStack(stack) + }) + + test("creates CloudWatch log group with correct name and KMS key", () => { + template.hasResourceProperties("AWS::Logs::LogGroup", { + LogGroupName: "/aws/stepfunctions/test-state-machine", + KmsKeyId: {"Fn::ImportValue": "account-resources:CloudwatchLogsKmsKeyArn"}, + RetentionInDays: 30 + }) + }) + + test("creates Splunk subscription filter by default", () => { + template.hasResourceProperties("AWS::Logs::SubscriptionFilter", { + FilterPattern: "", + RoleArn: {"Fn::ImportValue": "lambda-resources:SplunkSubscriptionFilterRole"} + }) + }) + + test("creates IAM role for state machine with correct service principal", () => { + template.hasResourceProperties("AWS::IAM::Role", { + AssumeRolePolicyDocument: { + Statement: [{ + Action: "sts:AssumeRole", + Effect: "Allow", + Principal: {Service: "states.amazonaws.com"} + }] + } + }) + }) + + test("creates Express state machine with tracing and logging", () => { + template.hasResourceProperties("AWS::StepFunctions::StateMachine", { + StateMachineName: "test-state-machine", + StateMachineType: "EXPRESS", + TracingConfiguration: {Enabled: true}, + LoggingConfiguration: { + IncludeExecutionData: true, + Level: "ALL" + } + }) + }) + + test("creates execution managed policy with StartSyncExecution permission", () => { + template.hasResourceProperties("AWS::IAM::ManagedPolicy", { + Description: "execute state machine test-state-machine", + PolicyDocument: { + Statement: [Match.objectLike({ + Action: Match.arrayWith(["states:StartSyncExecution", "states:StartExecution"]), + Effect: "Allow" + })] + } + }) + }) + + test("creates put-logs managed policy allowing wildcard on log delivery actions", () => { + template.hasResourceProperties("AWS::IAM::ManagedPolicy", { + Description: "write to test-state-machine logs", + PolicyDocument: { + Statement: [ + Match.objectLike({ + Action: Match.arrayWith(["logs:CreateLogStream", "logs:PutLogEvents"]), + Effect: "Allow" + }), + Match.objectLike({ + Action: Match.arrayWith(["logs:DescribeLogGroups", "logs:CreateLogDelivery"]), + Effect: "Allow", + Resource: "*" + }) + ] + } + }) + }) + + test("exposes executionPolicy and stateMachine as public properties", () => { + expect(construct.executionPolicy).toBeDefined() + expect(construct.stateMachine).toBeDefined() + }) +}) + +describe("ExpressStateMachine with Splunk disabled", () => { + let template: Template + let construct: ExpressStateMachine + + beforeAll(() => { + const app = new App() + const stack = new Stack(app, "StateMachineNoSplunkStack") + const dummyState = new Pass(stack, "DummyState") + + construct = new ExpressStateMachine(stack, "TestStateMachine", { + stackName: "test-stack", + stateMachineName: "test-state-machine", + definition: dummyState, + logRetentionInDays: 30, + addSplunkSubscriptionFilter: false + }) + + template = Template.fromStack(stack) + }) + + test("does not create a subscription filter when addSplunkSubscriptionFilter is false", () => { + const filters = template.findResources("AWS::Logs::SubscriptionFilter") + expect(Object.keys(filters).length).toBe(0) + }) + + test("exposes executionPolicy and stateMachine as public properties", () => { + expect(construct.executionPolicy).toBeDefined() + expect(construct.stateMachine).toBeDefined() + }) +}) + +describe("CatchAllErrorPass construct", () => { + let stack: Stack + let template: Template + let construct: CatchAllErrorPass + + beforeAll(() => { + const app = new App() + stack = new Stack(app, "CatchAllErrorStack") + construct = new CatchAllErrorPass(stack, "TestCatchAllError") + template = Template.fromStack(stack) + }) + + test("exposes a state property", () => { + expect(construct.state).toBeDefined() + }) + + test("creates a Pass state in the stack", () => { + template.resourceCountIs("AWS::StepFunctions::StateMachine", 0) + // CatchAllErrorPass creates a Pass state; verify it is present as a construct child + const passState = construct.node.tryFindChild("Catch All Error") + expect(passState).toBeDefined() + }) +})