From 86649e0cc931d68724763c28b046393941342eaf Mon Sep 17 00:00:00 2001 From: Tukue Gebremariam Gebregergis Date: Mon, 25 May 2026 18:24:56 +0200 Subject: [PATCH 1/5] Implement platform product improvement backlog --- .github/workflows/platform-iac-ci.yml | 2 + CONTRIBUTING.md | 34 +++ Makefile | 6 +- README.md | 9 +- .../infra/orders-service-stack.ts | 35 +++ docs/onboarding/first-service.md | 61 +++++ docs/platform-product-progress.md | 6 +- lib/cdk-app-stack.ts | 195 ++------------ packages/platform-constructs/README.md | 19 ++ .../src/api-lambda-dynamo-service/index.ts | 245 ++++++++++++++++++ packages/platform-constructs/src/index.ts | 1 + test/api-lambda-dynamo-service.test.ts | 97 +++++++ test/orders-service-stack.test.ts | 46 ++++ tsconfig.json | 7 +- 14 files changed, 581 insertions(+), 182 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 applications/examples/orders-service/infra/orders-service-stack.ts create mode 100644 docs/onboarding/first-service.md create mode 100644 packages/platform-constructs/README.md create mode 100644 packages/platform-constructs/src/api-lambda-dynamo-service/index.ts create mode 100644 packages/platform-constructs/src/index.ts create mode 100644 test/api-lambda-dynamo-service.test.ts create mode 100644 test/orders-service-stack.test.ts diff --git a/.github/workflows/platform-iac-ci.yml b/.github/workflows/platform-iac-ci.yml index 45679c9..1095772 100644 --- a/.github/workflows/platform-iac-ci.yml +++ b/.github/workflows/platform-iac-ci.yml @@ -5,6 +5,8 @@ on: branches: [ main ] paths: - 'platform/**' + - 'packages/**' + - 'applications/examples/**' - 'lib/**' - 'bin/**' - 'test/**' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3d2ed5e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# Contributing + +## Platform API compatibility + +The platform construct APIs are treated as internal product contracts. + +- Additive optional props are minor-compatible changes. +- Changing defaults that affect deployed infrastructure requires a migration note. +- Removing props, changing required props, or replacing resources requires a deprecation cycle. + +## Deprecation policy + +Deprecated construct props and behavior remain available for at least one release train before removal. + +Every deprecation must include: + +- replacement guidance, +- migration steps, +- target removal release, +- test coverage for both old and new behavior during the deprecation window. + +## Pull request expectations + +Platform changes must include: + +- `npm run build` +- `npm test` +- `npm run synth` +- focused tests for new constructs, policies, or environment behavior +- documentation updates when the consumer contract changes + +## Ownership + +The platform team owns reusable constructs, configuration contracts, policy packs, and CI gates. Application teams own service-specific code and configuration that consumes those platform APIs. diff --git a/Makefile b/Makefile index f3d5e11..5cf55be 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ help: @echo "make build # Build TypeScript" @echo "make test # Run tests" @echo "make synth # CDK synth" - @echo "make platform-check # Build + synth + lint placeholder" + @echo "make platform-check # Build + test + synth" @echo "make platform-plan ENV=dev # Plan platform changes" @echo "make platform-apply ENV=dev # Apply platform changes" @echo "make app-bootstrap SERVICE=name # Bootstrap app from template" @@ -26,8 +26,8 @@ test: synth: npx cdk synth -platform-check: build synth - @echo "[platform-check] add checkov/tfsec/cdk-nag in CI" +platform-check: build test synth + @echo "[platform-check] build, tests, and synth completed" platform-plan: @echo "[platform-plan] ENV=$(ENV)" diff --git a/README.md b/README.md index ae21f40..b394bf6 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ graph TB │ ├── services/ # Platform services (Argo CD, Backstage, monitoring) │ └── environments/ # Per-environment composition (dev/stage/prod) ├── applications/ # Developer/App team owned +│ ├── examples/orders-service/ # Canonical CDK consumer of platform constructs │ ├── gitops/ # Kubernetes manifests + Kustomize overlays │ │ ├── base/ # Reference: Namespace, Deployment, Service │ │ └── overlays/ (dev/stage/prod) @@ -122,8 +123,10 @@ graph TB │ ├── template.yaml # Backstage scaffolder v1beta3 │ └── scaffold/ # Scaffold files (CI, observability, policy) ├── templates/service-catalog/ # Additional Backstage template (multi-language) +├── packages/platform-constructs/ # Reusable golden-path CDK constructs +│ └── src/api-lambda-dynamo-service/ # API Gateway + Lambda + DynamoDB construct ├── lib/ # CDK application source -│ ├── cdk-app-stack.ts # Main stack — all AWS resources +│ ├── cdk-app-stack.ts # Main stack composing platform constructs │ ├── function.ts # Lambda handler (Node.js 18) │ ├── platform-config.ts # Typed env config with validation │ └── security-guardrails.ts # ALB-WAF CDK Aspect validation @@ -143,6 +146,7 @@ graph TB │ ├── platform-product-operating-model.md │ ├── platform-engineering-consulting-profile.md │ ├── observability-as-a-service.md +│ ├── onboarding/first-service.md │ └── oaas-implementation-flow.md ├── catalog-info.yaml # Backstage entity registration ├── Makefile # DX: platform-check, app-deploy, policy-test, etc. @@ -307,7 +311,10 @@ kubeconform validation → Conftest OPA policy checks | `docs/platform-engineering-consulting-profile.md` | Portfolio framing: strategy → architecture → implementation → adoption | | `docs/observability-as-a-service.md` | OaaS maturity assessment + baseline implementation | | `docs/oaas-implementation-flow.md` | End-to-end OaaS flow with ownership model and definition of done | +| `docs/onboarding/first-service.md` | 30-minute first-service onboarding path using the golden-path construct | | `docs/code-review-resolution.md` | Audit trail of how review feedback was addressed | +| `packages/platform-constructs/README.md` | Reusable CDK construct contract and consumer example | +| `CONTRIBUTING.md` | Compatibility, deprecation, ownership, and PR expectations | | `PLATFORM_PRODUCT_SETUP.md` | Full platform transformation guide (905 lines) | | `applications/policy/README.md` | OPA policy bundle documentation | | `terraform/README.md` | HashiCorp Vault setup guide | diff --git a/applications/examples/orders-service/infra/orders-service-stack.ts b/applications/examples/orders-service/infra/orders-service-stack.ts new file mode 100644 index 0000000..6127750 --- /dev/null +++ b/applications/examples/orders-service/infra/orders-service-stack.ts @@ -0,0 +1,35 @@ +import * as cdk from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +import { ApiLambdaDynamoService } from '../../../../packages/platform-constructs/src'; +import { PlatformConfig } from '../../../../lib/platform-config'; + +export interface OrdersServiceStackProps extends cdk.StackProps { + readonly platformConfig: PlatformConfig; +} + +export class OrdersServiceStack extends cdk.Stack { + public readonly service: ApiLambdaDynamoService; + + constructor(scope: Construct, id: string, props: OrdersServiceStackProps) { + super(scope, id, props); + + this.service = new ApiLambdaDynamoService(this, 'OrdersApiService', { + serviceName: 'orders-api', + stageName: props.platformConfig.environment, + catalogEntityRef: 'component:default/orders-service', + recommendedPathTemplateName: 'recommended-path-service', + recommendedPathTemplatePath: 'backstage/templates/recommended-path-service/template.yaml', + handlerEntry: `${__dirname}/../../../../lib/function.ts`, + environment: { + SERVICE_NAME: 'orders-api', + }, + }); + + cdk.Tags.of(this).add('environment', props.platformConfig.environment); + cdk.Tags.of(this).add('project', props.platformConfig.project); + cdk.Tags.of(this).add('owner', props.platformConfig.owner); + cdk.Tags.of(this).add('cost-center', props.platformConfig.costCenter); + cdk.Tags.of(this).add('data-classification', props.platformConfig.dataClassification); + cdk.Tags.of(this).add('finops-managed', 'true'); + } +} diff --git a/docs/onboarding/first-service.md b/docs/onboarding/first-service.md new file mode 100644 index 0000000..a60c73e --- /dev/null +++ b/docs/onboarding/first-service.md @@ -0,0 +1,61 @@ +# First Service Onboarding + +This tutorial provisions a service through the platform golden path in less than 30 minutes. + +## Prerequisites + +- Node.js 20 +- AWS credentials for the target sandbox account +- CDK bootstrap completed for the target account and region + +## 1. Start from the canonical consumer + +Use `applications/examples/orders-service/infra/orders-service-stack.ts` as the reference implementation. It consumes `ApiLambdaDynamoService`, which provides: + +- IAM-authenticated API Gateway routes +- VPC-isolated Lambda with tracing, reserved concurrency, encrypted environment variables, and a retry queue +- KMS-encrypted DynamoDB with point-in-time recovery and the standard pagination index +- API and application log groups with retention defaults + +## 2. Configure the environment + +Set the platform environment explicitly: + +```bash +export PLATFORM_ENV=dev +``` + +Allowed values are `dev`, `stage`, and `prod`. + +## 3. Validate locally + +Run the same quality gates used in CI: + +```bash +npm run build +npm test +npm run synth +``` + +## 4. Register ownership + +Set the service catalog reference in the construct props: + +```ts +catalogEntityRef: 'component:default/orders-service' +``` + +The value must match the Backstage entity for the service. + +## 5. Promote through environments + +Create one stack instance per environment and keep promotion artifact-based: + +1. Merge to `main` after build, test, synth, and policy checks pass. +2. Deploy to `dev`. +3. Promote the same reviewed change to `stage`. +4. Promote to `prod` after smoke tests and approval. + +## Done + +The service is onboarded when it has a catalog entity, passes CI quality gates, emits logs and traces by default, and uses the platform construct instead of hand-rolled API/Lambda/DynamoDB resources. diff --git a/docs/platform-product-progress.md b/docs/platform-product-progress.md index 754f62c..d78119d 100644 --- a/docs/platform-product-progress.md +++ b/docs/platform-product-progress.md @@ -6,11 +6,11 @@ _Last updated: 2026-04-01_ | Workstream | Status | Progress | Notes | |---|---|---:|---| -| Repository product model (platform vs applications) | ✅ Complete | 100% | Baseline folder model and docs are in place. | +| Repository product model (platform vs applications) | ✅ Complete | 100% | Baseline folder model, platform construct package, and canonical consumer example are in place. | | Golden-path scaffolding (Backstage template) | ✅ Complete | 100% | Template + runnable repo structure added and parameterized org support enabled. | | Platform IaC CI guardrails | ✅ Complete | 100% | Build/synth/checkov workflow configured. | | App GitOps guardrails | ✅ Complete | 100% | kubeconform validation enabled and fail-fast behavior enforced. | -| Secure-by-default CDK sample hardening | ✅ Complete | 100% | KMS, VPC, Lambda retry queue, IAM auth, caching, encrypted logs implemented. | +| Secure-by-default CDK sample hardening | ✅ Complete | 100% | KMS, VPC, Lambda retry queue, IAM auth, caching, encrypted logs implemented through the reusable API/Lambda/Dynamo construct. | | Environment overlays (dev/stage/prod) | 🟡 In Progress | 40% | Structure exists; env-specific manifests and policy sets pending. | | Policy-as-code enforcement (OPA/Kyverno) | 🟡 In Progress | 60% | Conftest policy bundle and CI enforcement added for deployment security/image/resource guardrails. | | Observability productization | 🟡 In Progress | 60% | CloudWatch dashboard, alerts, and structured logging baseline implemented; Prometheus/Grafana/Loki/OTel deployments pending. | @@ -25,6 +25,7 @@ _Last updated: 2026-04-01_ - Developer command interface established through `Makefile` targets. - Backstage self-service template now executable from a real scaffold repo structure. - Sample GitOps base manifests added for validation and onboarding reference. +- First golden-path CDK construct added under `packages/platform-constructs` with `applications/examples/orders-service` as a canonical consumer. ## Current quarter priorities @@ -41,6 +42,7 @@ _Last updated: 2026-04-01_ - Encrypted SNS-backed alarm fan-out for Lambda and API failures/latency. - Structured Lambda JSON logging and correlation-ID propagation. - See `docs/observability-as-a-service.md` for assessment details and rollout recommendations. +- Productized the API/Lambda/DynamoDB baseline as `ApiLambdaDynamoService`, added a first-service onboarding guide, and defined contribution/deprecation expectations in `CONTRIBUTING.md`. ## Definition of done for next milestone diff --git a/lib/cdk-app-stack.ts b/lib/cdk-app-stack.ts index 1d3a759..4443b79 100644 --- a/lib/cdk-app-stack.ts +++ b/lib/cdk-app-stack.ts @@ -1,20 +1,11 @@ -import * as path from 'path'; import * as cdk from 'aws-cdk-lib'; -import { RemovalPolicy } from 'aws-cdk-lib'; -import * as apigateway from 'aws-cdk-lib/aws-apigateway'; import * as budgets from 'aws-cdk-lib/aws-budgets'; import * as ce from 'aws-cdk-lib/aws-ce'; import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; import * as cwActions from 'aws-cdk-lib/aws-cloudwatch-actions'; -import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; -import * as ec2 from 'aws-cdk-lib/aws-ec2'; -import * as kms from 'aws-cdk-lib/aws-kms'; -import * as lambda from 'aws-cdk-lib/aws-lambda'; -import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; -import * as logs from 'aws-cdk-lib/aws-logs'; import * as sns from 'aws-cdk-lib/aws-sns'; -import * as sqs from 'aws-cdk-lib/aws-sqs'; import { Construct } from 'constructs'; +import { ApiLambdaDynamoService } from '../packages/platform-constructs/src'; import { PlatformConfig } from './platform-config'; import { enforceAlbWafAssociations } from './security-guardrails'; @@ -38,158 +29,16 @@ export class CdkAppStack extends cdk.Stack { enforceAlbWafAssociations(this); - const encryptionKey = new kms.Key(this, 'PlatformDataKey', { - enableKeyRotation: true, - description: 'CMK for DynamoDB, Lambda environment, and API access logs', - removalPolicy: RemovalPolicy.RETAIN, + const service = new ApiLambdaDynamoService(this, 'DemoApiService', { + serviceName: 'demo-api', + stageName, + catalogEntityRef: 'component:default/infra-as-code-with-cdk', + recommendedPathTemplateName: 'recommended-path-service', + recommendedPathTemplatePath: 'backstage/templates/recommended-path-service/template.yaml', + handlerEntry: `${__dirname}/function.ts`, + itemsByCreatedAtIndexName, }); - const appVpc = new ec2.Vpc(this, 'AppVpc', { - maxAzs: 2, - natGateways: 1, - restrictDefaultSecurityGroup: false, - subnetConfiguration: [ - { - cidrMask: 24, - name: 'public', - subnetType: ec2.SubnetType.PUBLIC, - }, - { - cidrMask: 24, - name: 'private', - subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, - }, - ], - }); - - const table = new dynamodb.Table(this, 'Table', { - partitionKey: { - name: 'id', - type: dynamodb.AttributeType.STRING, - }, - removalPolicy: RemovalPolicy.DESTROY, - pointInTimeRecovery: true, - billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, - tableName: 'DemoTable', - encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED, - encryptionKey, - }); - - table.addGlobalSecondaryIndex({ - indexName: itemsByCreatedAtIndexName, - partitionKey: { - name: 'entityType', - type: dynamodb.AttributeType.STRING, - }, - sortKey: { - name: 'createdAt', - type: dynamodb.AttributeType.STRING, - }, - projectionType: dynamodb.ProjectionType.ALL, - }); - - const retryQueue = new sqs.Queue(this, 'RetryQueue', { - encryption: sqs.QueueEncryption.KMS, - encryptionMasterKey: encryptionKey, - retentionPeriod: cdk.Duration.days(14), - }); - - const backend = new NodejsFunction(this, 'function', { - entry: path.join(__dirname, 'function.ts'), - handler: 'handler', - runtime: lambda.Runtime.NODEJS_18_X, - environment: { - APP_LOG_LEVEL: 'INFO', - CATALOG_ENTITY_REF: 'component:default/infra-as-code-with-cdk', - DYNAMODB: table.tableName, - ITEMS_BY_CREATED_AT_INDEX: itemsByCreatedAtIndexName, - NODE_OPTIONS: '--enable-source-maps', - RECOMMENDED_PATH_CATALOG_PATH: 'catalog-info.yaml', - RECOMMENDED_PATH_TEMPLATE_NAME: 'recommended-path-service', - RECOMMENDED_PATH_TEMPLATE_PATH: 'backstage/templates/recommended-path-service/template.yaml', - SERVICE_NAME: 'demo-api', - STAGE: stageName, - }, - environmentEncryption: encryptionKey, - bundling: { - minify: true, - sourceMap: true, - externalModules: ['aws-sdk'], - target: 'node18', - }, - memorySize: 1024, - timeout: cdk.Duration.seconds(30), - tracing: lambda.Tracing.ACTIVE, - deadLetterQueueEnabled: true, - deadLetterQueue: retryQueue, - reservedConcurrentExecutions: 10, - vpc: appVpc, - vpcSubnets: { - subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, - }, - }); - - table.grantReadWriteData(backend); - - const apiAccessLogs = new logs.LogGroup(this, 'ApiGatewayAccessLogs', { - encryptionKey, - retention: logs.RetentionDays.TWO_YEARS, - removalPolicy: RemovalPolicy.RETAIN, - }); - - const lambdaAppLogs = new logs.LogGroup(this, 'LambdaApplicationLogs', { - logGroupName: `/aws/lambda/${backend.functionName}`, - encryptionKey, - retention: logs.RetentionDays.ONE_MONTH, - removalPolicy: RemovalPolicy.RETAIN, - }); - - const api = new apigateway.RestApi(this, 'RestAPI', { - restApiName: 'Demo API', - description: 'Demo API with Lambda and DynamoDB', - defaultMethodOptions: { - authorizationType: apigateway.AuthorizationType.IAM, - }, - defaultCorsPreflightOptions: { - allowOrigins: apigateway.Cors.ALL_ORIGINS, - allowMethods: apigateway.Cors.ALL_METHODS, - allowHeaders: [ - 'Content-Type', - 'X-Amz-Date', - 'Authorization', - 'X-Api-Key', - 'X-Amz-Security-Token', - ], - maxAge: cdk.Duration.days(1), - }, - deployOptions: { - accessLogDestination: new apigateway.LogGroupLogDestination(apiAccessLogs), - accessLogFormat: apigateway.AccessLogFormat.jsonWithStandardFields(), - loggingLevel: apigateway.MethodLoggingLevel.INFO, - dataTraceEnabled: true, - tracingEnabled: true, - cacheClusterEnabled: true, - cacheClusterSize: '0.5', - cachingEnabled: true, - }, - }); - - const integration = new apigateway.LambdaIntegration(backend); - api.root.addMethod('GET', integration); - - const health = api.root.addResource('health'); - health.addMethod('GET', integration); - - const items = api.root.addResource('items'); - items.addMethod('GET', integration); - items.addMethod('POST', integration); - - const platform = api.root.addResource('platform'); - platform.addMethod('GET', integration); - - const recommendedPath = platform.addResource('recommended-path'); - recommendedPath.addMethod('GET', integration); - const budgetNotifications = finOpsAlertEmail ? [ budgetNotificationWithEmail(finOpsAlertEmail, 'ACTUAL', 80), @@ -257,11 +106,11 @@ export class CdkAppStack extends cdk.Stack { const alarmTopic = new sns.Topic(this, 'ObservabilityAlarmTopic', { displayName: 'Platform Observability Alerts', - masterKey: encryptionKey, + masterKey: service.encryptionKey, }); const lambdaErrorsAlarm = new cloudwatch.Alarm(this, 'LambdaErrorsAlarm', { - metric: backend.metricErrors({ + metric: service.backend.metricErrors({ period: cdk.Duration.minutes(5), statistic: 'sum', }), @@ -273,7 +122,7 @@ export class CdkAppStack extends cdk.Stack { }); const lambdaDurationAlarm = new cloudwatch.Alarm(this, 'LambdaDurationP95Alarm', { - metric: backend.metricDuration({ + metric: service.backend.metricDuration({ period: cdk.Duration.minutes(5), statistic: 'p95', }), @@ -285,7 +134,7 @@ export class CdkAppStack extends cdk.Stack { }); const api5xxAlarm = new cloudwatch.Alarm(this, 'Api5xxAlarm', { - metric: api.metricServerError({ + metric: service.api.metricServerError({ period: cdk.Duration.minutes(5), statistic: 'sum', }), @@ -307,40 +156,40 @@ export class CdkAppStack extends cdk.Stack { observabilityDashboard.addWidgets( new cloudwatch.GraphWidget({ title: 'Lambda Invocations / Errors', - left: [backend.metricInvocations(), backend.metricErrors()], + left: [service.backend.metricInvocations(), service.backend.metricErrors()], width: 12, }), new cloudwatch.GraphWidget({ title: 'Lambda Duration (p50/p95)', left: [ - backend.metricDuration({ statistic: 'p50' }), - backend.metricDuration({ statistic: 'p95' }), + service.backend.metricDuration({ statistic: 'p50' }), + service.backend.metricDuration({ statistic: 'p95' }), ], width: 12, }), new cloudwatch.GraphWidget({ title: 'API Gateway Requests / 5XX', - left: [api.metricCount(), api.metricServerError()], + left: [service.api.metricCount(), service.api.metricServerError()], width: 12, }), new cloudwatch.GraphWidget({ title: 'API Gateway Latency (p50/p95)', left: [ - api.metricLatency({ statistic: 'p50' }), - api.metricLatency({ statistic: 'p95' }), + service.api.metricLatency({ statistic: 'p50' }), + service.api.metricLatency({ statistic: 'p95' }), ], width: 12, }), ); new cdk.CfnOutput(this, 'ApiUrl', { - value: api.url, + value: service.api.url, description: 'API Gateway URL', exportName: `${this.stackName}-api-url`, }); new cdk.CfnOutput(this, 'DynamoDBTableName', { - value: table.tableName, + value: service.table.tableName, description: 'DynamoDB table name', exportName: `${this.stackName}-table-name`, }); @@ -388,7 +237,7 @@ export class CdkAppStack extends cdk.Stack { }); new cdk.CfnOutput(this, 'LambdaApplicationLogGroupName', { - value: lambdaAppLogs.logGroupName, + value: service.lambdaApplicationLogs.logGroupName, description: 'Application log group name used by Lambda structured logs', exportName: `${this.stackName}-lambda-application-log-group-name`, }); diff --git a/packages/platform-constructs/README.md b/packages/platform-constructs/README.md new file mode 100644 index 0000000..8014b82 --- /dev/null +++ b/packages/platform-constructs/README.md @@ -0,0 +1,19 @@ +# Platform Constructs + +Reusable CDK constructs for the internal developer platform. + +## ApiLambdaDynamoService + +`ApiLambdaDynamoService` is the first golden-path L3 construct. It packages the standard API Gateway, Lambda, DynamoDB, logging, tracing, retry, and encryption baseline behind a small consumer API. + +```ts +new ApiLambdaDynamoService(this, 'OrdersApiService', { + serviceName: 'orders-api', + stageName: 'dev', + catalogEntityRef: 'component:default/orders-service', + recommendedPathTemplateName: 'recommended-path-service', + recommendedPathTemplatePath: 'backstage/templates/recommended-path-service/template.yaml', +}); +``` + +Use `applications/examples/orders-service/infra/orders-service-stack.ts` as the canonical consumer example. diff --git a/packages/platform-constructs/src/api-lambda-dynamo-service/index.ts b/packages/platform-constructs/src/api-lambda-dynamo-service/index.ts new file mode 100644 index 0000000..265c7e4 --- /dev/null +++ b/packages/platform-constructs/src/api-lambda-dynamo-service/index.ts @@ -0,0 +1,245 @@ +import * as path from 'path'; +import * as cdk from 'aws-cdk-lib'; +import { RemovalPolicy } from 'aws-cdk-lib'; +import * as apigateway from 'aws-cdk-lib/aws-apigateway'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as kms from 'aws-cdk-lib/aws-kms'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; +import * as logs from 'aws-cdk-lib/aws-logs'; +import * as sqs from 'aws-cdk-lib/aws-sqs'; +import { Construct } from 'constructs'; + +export interface ApiLambdaDynamoServiceProps { + readonly serviceName: string; + readonly stageName: string; + readonly catalogEntityRef: string; + readonly recommendedPathTemplateName: string; + readonly recommendedPathTemplatePath: string; + readonly handlerEntry?: string; + readonly itemsByCreatedAtIndexName?: string; + readonly vpc?: ec2.IVpc; + readonly encryptionKey?: kms.IKey; + readonly environment?: Record; + readonly alarms?: { + readonly enableP95LatencyAlarm?: boolean; + }; + readonly overrides?: { + readonly lambda?: Partial; + readonly apiGateway?: Partial; + readonly table?: Partial; + }; +} + +export interface ApiLambdaDynamoServiceLambdaOverrides { + readonly memorySize: number; + readonly reservedConcurrentExecutions: number; + readonly timeout: cdk.Duration; + readonly runtime: lambda.Runtime; +} + +export class ApiLambdaDynamoService extends Construct { + public readonly api: apigateway.RestApi; + public readonly backend: NodejsFunction; + public readonly table: dynamodb.Table; + public readonly apiAccessLogs: logs.LogGroup; + public readonly lambdaApplicationLogs: logs.LogGroup; + public readonly retryQueue: sqs.Queue; + public readonly encryptionKey: kms.IKey; + public readonly itemsByCreatedAtIndexName: string; + + constructor(scope: Construct, id: string, props: ApiLambdaDynamoServiceProps) { + super(scope, id); + + validateProps(props); + + this.itemsByCreatedAtIndexName = props.itemsByCreatedAtIndexName ?? 'ItemsByCreatedAtIndex'; + this.encryptionKey = + props.encryptionKey ?? + new kms.Key(this, 'ServiceDataKey', { + enableKeyRotation: true, + description: `CMK for ${props.serviceName} data, Lambda environment, and API access logs`, + removalPolicy: RemovalPolicy.RETAIN, + }); + + const vpc = + props.vpc ?? + new ec2.Vpc(this, 'ServiceVpc', { + maxAzs: 2, + natGateways: 1, + restrictDefaultSecurityGroup: false, + subnetConfiguration: [ + { + cidrMask: 24, + name: 'public', + subnetType: ec2.SubnetType.PUBLIC, + }, + { + cidrMask: 24, + name: 'private', + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + }, + ], + }); + + this.table = new dynamodb.Table(this, 'Table', { + partitionKey: { + name: 'id', + type: dynamodb.AttributeType.STRING, + }, + removalPolicy: RemovalPolicy.DESTROY, + pointInTimeRecovery: true, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + tableName: `${props.serviceName}-${props.stageName}`, + encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED, + encryptionKey: this.encryptionKey, + ...props.overrides?.table, + }); + + this.table.addGlobalSecondaryIndex({ + indexName: this.itemsByCreatedAtIndexName, + partitionKey: { + name: 'entityType', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'createdAt', + type: dynamodb.AttributeType.STRING, + }, + projectionType: dynamodb.ProjectionType.ALL, + }); + + this.retryQueue = new sqs.Queue(this, 'RetryQueue', { + encryption: sqs.QueueEncryption.KMS, + encryptionMasterKey: this.encryptionKey, + retentionPeriod: cdk.Duration.days(14), + }); + + this.backend = new NodejsFunction(this, 'Function', { + entry: props.handlerEntry ?? path.join(__dirname, '../../../../lib/function.ts'), + handler: 'handler', + runtime: props.overrides?.lambda?.runtime ?? lambda.Runtime.NODEJS_18_X, + environment: { + APP_LOG_LEVEL: 'INFO', + CATALOG_ENTITY_REF: props.catalogEntityRef, + DYNAMODB: this.table.tableName, + ITEMS_BY_CREATED_AT_INDEX: this.itemsByCreatedAtIndexName, + NODE_OPTIONS: '--enable-source-maps', + RECOMMENDED_PATH_CATALOG_PATH: 'catalog-info.yaml', + RECOMMENDED_PATH_TEMPLATE_NAME: props.recommendedPathTemplateName, + RECOMMENDED_PATH_TEMPLATE_PATH: props.recommendedPathTemplatePath, + SERVICE_NAME: props.serviceName, + STAGE: props.stageName, + ...props.environment, + }, + environmentEncryption: this.encryptionKey, + bundling: { + minify: true, + sourceMap: true, + externalModules: ['aws-sdk'], + target: 'node18', + }, + memorySize: props.overrides?.lambda?.memorySize ?? 1024, + timeout: props.overrides?.lambda?.timeout ?? cdk.Duration.seconds(30), + tracing: lambda.Tracing.ACTIVE, + deadLetterQueueEnabled: true, + deadLetterQueue: this.retryQueue, + reservedConcurrentExecutions: props.overrides?.lambda?.reservedConcurrentExecutions ?? 10, + vpc, + vpcSubnets: { + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + }, + }); + + this.table.grantReadWriteData(this.backend); + + this.apiAccessLogs = new logs.LogGroup(this, 'ApiGatewayAccessLogs', { + encryptionKey: this.encryptionKey, + retention: logs.RetentionDays.TWO_YEARS, + removalPolicy: RemovalPolicy.RETAIN, + }); + + this.lambdaApplicationLogs = new logs.LogGroup(this, 'LambdaApplicationLogs', { + logGroupName: `/aws/lambda/${this.backend.functionName}`, + encryptionKey: this.encryptionKey, + retention: logs.RetentionDays.ONE_MONTH, + removalPolicy: RemovalPolicy.RETAIN, + }); + + this.api = new apigateway.RestApi(this, 'RestAPI', { + restApiName: `${props.serviceName}-${props.stageName}`, + description: `${props.serviceName} API with Lambda and DynamoDB`, + defaultMethodOptions: { + authorizationType: apigateway.AuthorizationType.IAM, + }, + defaultCorsPreflightOptions: { + allowOrigins: apigateway.Cors.ALL_ORIGINS, + allowMethods: apigateway.Cors.ALL_METHODS, + allowHeaders: [ + 'Content-Type', + 'X-Amz-Date', + 'Authorization', + 'X-Api-Key', + 'X-Amz-Security-Token', + ], + maxAge: cdk.Duration.days(1), + }, + deployOptions: { + accessLogDestination: new apigateway.LogGroupLogDestination(this.apiAccessLogs), + accessLogFormat: apigateway.AccessLogFormat.jsonWithStandardFields(), + loggingLevel: apigateway.MethodLoggingLevel.INFO, + dataTraceEnabled: true, + tracingEnabled: true, + cacheClusterEnabled: true, + cacheClusterSize: '0.5', + cachingEnabled: true, + ...props.overrides?.apiGateway, + }, + }); + + const integration = new apigateway.LambdaIntegration(this.backend); + this.api.root.addMethod('GET', integration); + + const health = this.api.root.addResource('health'); + health.addMethod('GET', integration); + + const items = this.api.root.addResource('items'); + items.addMethod('GET', integration); + items.addMethod('POST', integration); + + const platform = this.api.root.addResource('platform'); + platform.addMethod('GET', integration); + + const recommendedPath = platform.addResource('recommended-path'); + recommendedPath.addMethod('GET', integration); + } +} + +function validateProps(props: ApiLambdaDynamoServiceProps): void { + if (!props.serviceName.trim()) { + throw new Error('ApiLambdaDynamoService serviceName must be a non-empty string.'); + } + + if (!/^[a-z][a-z0-9-]{1,38}[a-z0-9]$/.test(props.serviceName)) { + throw new Error( + 'ApiLambdaDynamoService serviceName must be 3-40 characters and use lowercase letters, numbers, and hyphens.', + ); + } + + if (!props.stageName.trim()) { + throw new Error('ApiLambdaDynamoService stageName must be a non-empty string.'); + } + + if (!props.catalogEntityRef.trim()) { + throw new Error('ApiLambdaDynamoService catalogEntityRef must be a non-empty string.'); + } + + if (!props.recommendedPathTemplateName.trim()) { + throw new Error('ApiLambdaDynamoService recommendedPathTemplateName must be a non-empty string.'); + } + + if (!props.recommendedPathTemplatePath.trim()) { + throw new Error('ApiLambdaDynamoService recommendedPathTemplatePath must be a non-empty string.'); + } +} diff --git a/packages/platform-constructs/src/index.ts b/packages/platform-constructs/src/index.ts new file mode 100644 index 0000000..b115499 --- /dev/null +++ b/packages/platform-constructs/src/index.ts @@ -0,0 +1 @@ +export * from './api-lambda-dynamo-service'; diff --git a/test/api-lambda-dynamo-service.test.ts b/test/api-lambda-dynamo-service.test.ts new file mode 100644 index 0000000..e298564 --- /dev/null +++ b/test/api-lambda-dynamo-service.test.ts @@ -0,0 +1,97 @@ +import * as cdk from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { ApiLambdaDynamoService } from '../packages/platform-constructs/src'; + +jest.mock('aws-cdk-lib/aws-lambda-nodejs', () => { + const lambda = jest.requireActual('aws-cdk-lib/aws-lambda'); + + return { + NodejsFunction: class NodejsFunction extends lambda.Function { + constructor(scope: any, id: string, props: Record) { + const { bundling, entry, ...lambdaProps } = props; + + super(scope, id, { + ...lambdaProps, + code: lambda.Code.fromInline('exports.handler = async () => ({ statusCode: 200, body: "{}" });'), + }); + } + }, + }; +}); + +describe('ApiLambdaDynamoService', () => { + it('creates a secure-by-default API, Lambda, DynamoDB, and retry queue baseline', () => { + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'ConstructConsumerStack'); + + new ApiLambdaDynamoService(stack, 'OrdersApiService', { + serviceName: 'orders-api', + stageName: 'dev', + catalogEntityRef: 'component:default/orders-service', + recommendedPathTemplateName: 'recommended-path-service', + recommendedPathTemplatePath: 'backstage/templates/recommended-path-service/template.yaml', + }); + + const template = Template.fromStack(stack); + + template.resourceCountIs('AWS::ApiGateway::RestApi', 1); + template.resourceCountIs('AWS::DynamoDB::Table', 1); + template.resourceCountIs('AWS::Lambda::Function', 1); + template.resourceCountIs('AWS::SQS::Queue', 1); + + template.hasResourceProperties('AWS::DynamoDB::Table', { + BillingMode: 'PAY_PER_REQUEST', + PointInTimeRecoverySpecification: { + PointInTimeRecoveryEnabled: true, + }, + SSESpecification: { + SSEEnabled: true, + SSEType: 'KMS', + }, + GlobalSecondaryIndexes: Match.arrayWith([ + Match.objectLike({ + IndexName: 'ItemsByCreatedAtIndex', + }), + ]), + }); + + template.hasResourceProperties('AWS::Lambda::Function', { + Runtime: 'nodejs18.x', + TracingConfig: { + Mode: 'Active', + }, + ReservedConcurrentExecutions: 10, + Environment: { + Variables: Match.objectLike({ + CATALOG_ENTITY_REF: 'component:default/orders-service', + ITEMS_BY_CREATED_AT_INDEX: 'ItemsByCreatedAtIndex', + RECOMMENDED_PATH_TEMPLATE_NAME: 'recommended-path-service', + SERVICE_NAME: 'orders-api', + STAGE: 'dev', + }), + }, + }); + + template.hasResourceProperties('AWS::ApiGateway::Method', { + AuthorizationType: 'AWS_IAM', + }); + }); + + it('fails fast on invalid service names', () => { + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'InvalidConsumerStack'); + + expect( + () => + new ApiLambdaDynamoService(stack, 'InvalidApiService', { + serviceName: 'Orders API', + stageName: 'dev', + catalogEntityRef: 'component:default/orders-service', + recommendedPathTemplateName: 'recommended-path-service', + recommendedPathTemplatePath: 'backstage/templates/recommended-path-service/template.yaml', + }), + ).toThrow( + 'ApiLambdaDynamoService serviceName must be 3-40 characters and use lowercase letters, numbers, and hyphens.', + ); + }); +}); diff --git a/test/orders-service-stack.test.ts b/test/orders-service-stack.test.ts new file mode 100644 index 0000000..56d3c25 --- /dev/null +++ b/test/orders-service-stack.test.ts @@ -0,0 +1,46 @@ +import * as cdk from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { OrdersServiceStack } from '../applications/examples/orders-service/infra/orders-service-stack'; +import { loadPlatformConfig } from '../lib/platform-config'; + +jest.mock('aws-cdk-lib/aws-lambda-nodejs', () => { + const lambda = jest.requireActual('aws-cdk-lib/aws-lambda'); + + return { + NodejsFunction: class NodejsFunction extends lambda.Function { + constructor(scope: any, id: string, props: Record) { + const { bundling, entry, ...lambdaProps } = props; + + super(scope, id, { + ...lambdaProps, + code: lambda.Code.fromInline('exports.handler = async () => ({ statusCode: 200, body: "{}" });'), + }); + } + }, + }; +}); + +describe('OrdersServiceStack', () => { + it('consumes the platform API/Lambda/Dynamo golden-path construct', () => { + const app = new cdk.App(); + const stack = new OrdersServiceStack(app, 'OrdersServiceDev', { + platformConfig: loadPlatformConfig('dev'), + }); + + const template = Template.fromStack(stack); + + template.resourceCountIs('AWS::ApiGateway::RestApi', 1); + template.resourceCountIs('AWS::DynamoDB::Table', 1); + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + CATALOG_ENTITY_REF: 'component:default/orders-service', + ITEMS_BY_CREATED_AT_INDEX: 'ItemsByCreatedAtIndex', + RECOMMENDED_PATH_TEMPLATE_NAME: 'recommended-path-service', + SERVICE_NAME: 'orders-api', + STAGE: 'dev', + }), + }, + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index e6ec6af..f6f442a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,13 +13,14 @@ "noUnusedParameters": false, "noImplicitReturns": true, "noFallthroughCasesInSwitch": false, - "inlineSourceMap": true, + "inlineSourceMap": true, "inlineSources": true, + "skipLibCheck": true, "experimentalDecorators": true, "strictPropertyInitialization": false, "types": ["node", "jest", "aws-lambda"], "outDir": "dist" }, "exclude": ["node_modules", "cdk.out", "dist"], - "include": ["bin/**/*", "lib/**/*"] -} + "include": ["bin/**/*", "lib/**/*", "packages/**/*", "applications/examples/**/*"] +} From 1d4b543e71ccc757b1d0ed30e5fde691edac7428 Mon Sep 17 00:00:00 2001 From: Tukue Gebremariam Gebregergis Date: Mon, 25 May 2026 18:38:28 +0200 Subject: [PATCH 2/5] Address platform construct security review --- packages/platform-constructs/README.md | 8 ++++ .../src/api-lambda-dynamo-service/index.ts | 39 ++++++++++++------- test/api-lambda-dynamo-service.test.ts | 23 +++++++++++ 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/packages/platform-constructs/README.md b/packages/platform-constructs/README.md index 8014b82..62701cf 100644 --- a/packages/platform-constructs/README.md +++ b/packages/platform-constructs/README.md @@ -16,4 +16,12 @@ new ApiLambdaDynamoService(this, 'OrdersApiService', { }); ``` +Cross-origin requests are disabled by default. If browser access is required, provide explicit origins: + +```ts +cors: { + allowOrigins: ['https://app.example.com'], +} +``` + Use `applications/examples/orders-service/infra/orders-service-stack.ts` as the canonical consumer example. diff --git a/packages/platform-constructs/src/api-lambda-dynamo-service/index.ts b/packages/platform-constructs/src/api-lambda-dynamo-service/index.ts index 265c7e4..25a96fa 100644 --- a/packages/platform-constructs/src/api-lambda-dynamo-service/index.ts +++ b/packages/platform-constructs/src/api-lambda-dynamo-service/index.ts @@ -22,6 +22,12 @@ export interface ApiLambdaDynamoServiceProps { readonly vpc?: ec2.IVpc; readonly encryptionKey?: kms.IKey; readonly environment?: Record; + readonly cors?: { + readonly allowOrigins: string[]; + readonly allowMethods?: string[]; + readonly allowHeaders?: string[]; + readonly maxAge?: cdk.Duration; + }; readonly alarms?: { readonly enableP95LatencyAlarm?: boolean; }; @@ -68,7 +74,7 @@ export class ApiLambdaDynamoService extends Construct { new ec2.Vpc(this, 'ServiceVpc', { maxAzs: 2, natGateways: 1, - restrictDefaultSecurityGroup: false, + restrictDefaultSecurityGroup: true, subnetConfiguration: [ { cidrMask: 24, @@ -143,7 +149,6 @@ export class ApiLambdaDynamoService extends Construct { memorySize: props.overrides?.lambda?.memorySize ?? 1024, timeout: props.overrides?.lambda?.timeout ?? cdk.Duration.seconds(30), tracing: lambda.Tracing.ACTIVE, - deadLetterQueueEnabled: true, deadLetterQueue: this.retryQueue, reservedConcurrentExecutions: props.overrides?.lambda?.reservedConcurrentExecutions ?? 10, vpc, @@ -173,18 +178,20 @@ export class ApiLambdaDynamoService extends Construct { defaultMethodOptions: { authorizationType: apigateway.AuthorizationType.IAM, }, - defaultCorsPreflightOptions: { - allowOrigins: apigateway.Cors.ALL_ORIGINS, - allowMethods: apigateway.Cors.ALL_METHODS, - allowHeaders: [ - 'Content-Type', - 'X-Amz-Date', - 'Authorization', - 'X-Api-Key', - 'X-Amz-Security-Token', - ], - maxAge: cdk.Duration.days(1), - }, + defaultCorsPreflightOptions: props.cors + ? { + allowOrigins: props.cors.allowOrigins, + allowMethods: props.cors.allowMethods ?? apigateway.Cors.ALL_METHODS, + allowHeaders: props.cors.allowHeaders ?? [ + 'Content-Type', + 'X-Amz-Date', + 'Authorization', + 'X-Api-Key', + 'X-Amz-Security-Token', + ], + maxAge: props.cors.maxAge ?? cdk.Duration.days(1), + } + : undefined, deployOptions: { accessLogDestination: new apigateway.LogGroupLogDestination(this.apiAccessLogs), accessLogFormat: apigateway.AccessLogFormat.jsonWithStandardFields(), @@ -242,4 +249,8 @@ function validateProps(props: ApiLambdaDynamoServiceProps): void { if (!props.recommendedPathTemplatePath.trim()) { throw new Error('ApiLambdaDynamoService recommendedPathTemplatePath must be a non-empty string.'); } + + if (props.cors?.allowOrigins.some((origin) => origin === '*')) { + throw new Error('ApiLambdaDynamoService cors.allowOrigins must not include wildcard origins.'); + } } diff --git a/test/api-lambda-dynamo-service.test.ts b/test/api-lambda-dynamo-service.test.ts index e298564..3ca7b6e 100644 --- a/test/api-lambda-dynamo-service.test.ts +++ b/test/api-lambda-dynamo-service.test.ts @@ -75,6 +75,10 @@ describe('ApiLambdaDynamoService', () => { template.hasResourceProperties('AWS::ApiGateway::Method', { AuthorizationType: 'AWS_IAM', }); + + template.resourceCountIs('Custom::VpcRestrictDefaultSG', 1); + + expect(JSON.stringify(template.toJSON())).not.toContain("'Access-Control-Allow-Origin':'*'"); }); it('fails fast on invalid service names', () => { @@ -94,4 +98,23 @@ describe('ApiLambdaDynamoService', () => { 'ApiLambdaDynamoService serviceName must be 3-40 characters and use lowercase letters, numbers, and hyphens.', ); }); + + it('rejects wildcard CORS origins', () => { + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'InvalidCorsStack'); + + expect( + () => + new ApiLambdaDynamoService(stack, 'InvalidCorsApiService', { + serviceName: 'orders-api', + stageName: 'dev', + catalogEntityRef: 'component:default/orders-service', + recommendedPathTemplateName: 'recommended-path-service', + recommendedPathTemplatePath: 'backstage/templates/recommended-path-service/template.yaml', + cors: { + allowOrigins: ['*'], + }, + }), + ).toThrow('ApiLambdaDynamoService cors.allowOrigins must not include wildcard origins.'); + }); }); From 455886a50a0f56a05cf090e98ff44b51e564633e Mon Sep 17 00:00:00 2001 From: Tukue Gebremariam Gebregergis Date: Mon, 25 May 2026 18:50:26 +0200 Subject: [PATCH 3/5] Fix CI assertions for restricted VPC provider --- .github/workflows/platform-iac-ci.yml | 12 +++++++++++- test/api-lambda-dynamo-service.test.ts | 8 +++++++- test/cdk-app-stack.test.ts | 8 +++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/.github/workflows/platform-iac-ci.yml b/.github/workflows/platform-iac-ci.yml index 1095772..afb684e 100644 --- a/.github/workflows/platform-iac-ci.yml +++ b/.github/workflows/platform-iac-ci.yml @@ -53,8 +53,18 @@ jobs: output_file_path: console,results.sarif quiet: true - - name: Upload Checkov SARIF report + - name: Check for Checkov SARIF report + id: checkov-sarif if: always() + run: | + if [ -f results.sarif ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Upload Checkov SARIF report + if: always() && steps.checkov-sarif.outputs.exists == 'true' continue-on-error: true uses: github/codeql-action/upload-sarif@v4 with: diff --git a/test/api-lambda-dynamo-service.test.ts b/test/api-lambda-dynamo-service.test.ts index 3ca7b6e..9fcec21 100644 --- a/test/api-lambda-dynamo-service.test.ts +++ b/test/api-lambda-dynamo-service.test.ts @@ -36,8 +36,14 @@ describe('ApiLambdaDynamoService', () => { template.resourceCountIs('AWS::ApiGateway::RestApi', 1); template.resourceCountIs('AWS::DynamoDB::Table', 1); - template.resourceCountIs('AWS::Lambda::Function', 1); template.resourceCountIs('AWS::SQS::Queue', 1); + template.resourcePropertiesCountIs('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + SERVICE_NAME: 'orders-api', + }), + }, + }, 1); template.hasResourceProperties('AWS::DynamoDB::Table', { BillingMode: 'PAY_PER_REQUEST', diff --git a/test/cdk-app-stack.test.ts b/test/cdk-app-stack.test.ts index 54aaa81..983e871 100644 --- a/test/cdk-app-stack.test.ts +++ b/test/cdk-app-stack.test.ts @@ -24,7 +24,13 @@ describe('CdkAppStack', () => { test('creates core resources', () => { const template = buildTemplate('TestStack'); - template.resourceCountIs('AWS::Lambda::Function', 1); + template.resourcePropertiesCountIs('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + SERVICE_NAME: 'demo-api', + }), + }, + }, 1); template.resourceCountIs('AWS::ApiGateway::RestApi', 1); template.resourceCountIs('AWS::DynamoDB::Table', 1); template.resourceCountIs('AWS::KMS::Key', 1); From 3b72478dc449f1f72eadab08021725f444762a1e Mon Sep 17 00:00:00 2001 From: Tukue Gebremariam Gebregergis Date: Mon, 1 Jun 2026 12:09:00 +0200 Subject: [PATCH 4/5] Add improvement-form docs folder --- docs/improvement-form/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/improvement-form/.gitkeep diff --git a/docs/improvement-form/.gitkeep b/docs/improvement-form/.gitkeep new file mode 100644 index 0000000..e69de29 From 609c2ec83321b4cbc25d273c7da4c78d16e14ef0 Mon Sep 17 00:00:00 2001 From: Tukue Gebremariam Gebregergis Date: Mon, 1 Jun 2026 15:49:34 +0200 Subject: [PATCH 5/5] Fix checkov CKV_AWS_115, CKV_AWS_116, CKV_AWS_117 on CDK-generated custom resource Lambda Apply Aspect at stack level to reach the singleton provider Lambda, set reservedConcurrentExecutions and DLQ, and skip VPC placement check. --- .../src/api-lambda-dynamo-service/index.ts | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/platform-constructs/src/api-lambda-dynamo-service/index.ts b/packages/platform-constructs/src/api-lambda-dynamo-service/index.ts index 25a96fa..ce8fdaf 100644 --- a/packages/platform-constructs/src/api-lambda-dynamo-service/index.ts +++ b/packages/platform-constructs/src/api-lambda-dynamo-service/index.ts @@ -9,7 +9,7 @@ import * as lambda from 'aws-cdk-lib/aws-lambda'; import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; import * as logs from 'aws-cdk-lib/aws-logs'; import * as sqs from 'aws-cdk-lib/aws-sqs'; -import { Construct } from 'constructs'; +import * as constructs from 'constructs'; export interface ApiLambdaDynamoServiceProps { readonly serviceName: string; @@ -45,7 +45,7 @@ export interface ApiLambdaDynamoServiceLambdaOverrides { readonly runtime: lambda.Runtime; } -export class ApiLambdaDynamoService extends Construct { +export class ApiLambdaDynamoService extends constructs.Construct { public readonly api: apigateway.RestApi; public readonly backend: NodejsFunction; public readonly table: dynamodb.Table; @@ -55,7 +55,7 @@ export class ApiLambdaDynamoService extends Construct { public readonly encryptionKey: kms.IKey; public readonly itemsByCreatedAtIndexName: string; - constructor(scope: Construct, id: string, props: ApiLambdaDynamoServiceProps) { + constructor(scope: constructs.Construct, id: string, props: ApiLambdaDynamoServiceProps) { super(scope, id); validateProps(props); @@ -117,6 +117,7 @@ export class ApiLambdaDynamoService extends Construct { }); this.retryQueue = new sqs.Queue(this, 'RetryQueue', { + queueName: `${props.serviceName}-${props.stageName}-retryqueue`, encryption: sqs.QueueEncryption.KMS, encryptionMasterKey: this.encryptionKey, retentionPeriod: cdk.Duration.days(14), @@ -157,6 +158,27 @@ export class ApiLambdaDynamoService extends Construct { }, }); + const retryQueue = this.retryQueue; + cdk.Aspects.of(cdk.Stack.of(this)).add({ + visit(node: constructs.IConstruct): void { + if (node instanceof lambda.CfnFunction && !node.deadLetterConfig) { + node.deadLetterConfig = { targetArn: retryQueue.queueArn }; + node.reservedConcurrentExecutions = 1; + node.cfnOptions.metadata = { + ...node.cfnOptions.metadata, + checkov: { + skip: [ + { + id: 'CKV_AWS_117', + comment: 'Custom resource Lambda cannot be placed inside a VPC', + }, + ], + }, + }; + } + }, + }); + this.table.grantReadWriteData(this.backend); this.apiAccessLogs = new logs.LogGroup(this, 'ApiGatewayAccessLogs', {