import * as cdk from "aws-cdk-lib"; import * as kms from "aws-cdk-lib/aws-kms"; import * as iam from "aws-cdk-lib/aws-iam"; import * as ssm from "aws-cdk-lib/aws-ssm"; import { Construct } from "constructs"; /** * Shared customer-managed CMK for sensitive DynamoDB tables (INFRA-95 / M-3). * * Replaces the default AWS-owned key on tables holding FINANCIAL / PII data so * that the encryption key is account-controlled, rotated, and auditable: * `PaymentsDashboard`, `purchase-orders`, `exec-aide`, `WorkOrders`, * `WorkOrderComments`. * * Lives in its OWN CloudFormation stack (not the account-baseline stack) so the * key is an independent, shared dependency for three separate owning repos * (payments-dashboard SAM, procurement-ingest CDK, exec-aide CDK) and so its * deploys never contend with the account-baseline stack. * * Key-policy design (cross-reviewed by GPT-4.1, 2026-06-08): * - The consuming Lambda/Fargate roles carry CFN hash suffixes that change on * replacement, and `purchase-orders` has CROSS-STACK readers (seahaven-bot's * po-sync + wo-po-lookup). Hardcoding role ARNs in the key policy would be * fragile and would silently break access on any role replacement. * - Instead this uses the delegation-to-IAM pattern: the key policy authorizes * the whole account to use the key, but ONLY when the request reaches KMS via * DynamoDB in us-east-1 (`kms:ViaService`). Actual authZ is then gated by each * consumer role's identity policy, which must separately grant * `kms:Decrypt`/`kms:GenerateDataKey`/`kms:DescribeKey` on this CMK ARN. * - `kms:CreateGrant` is in the resource policy because DynamoDB SSE-KMS * operates through a grant: when a table is associated with the CMK, DynamoDB * calls CreateGrant on behalf of the deploy principal. The deploy roles also * need `kms:CreateGrant` in their identity policy — the CDK exec role has * AdministratorAccess; the SAM `github-cfn-execution-role` was granted it * (scoped `kms:GrantIsForAWSResource:true`) for the PaymentsDashboard * conversion. * - `kms:CallerAccount` is kept as cheap defense-in-depth against a future * cross-account confused-deputy on the same key. * - `kms:ReEncrypt*` deliberately omitted — DynamoDB SSE-KMS never calls it * (uses GenerateDataKey + Decrypt); CMK rotation re-encryption is handled by * AWS via the grant. * * The key ARN is published to SSM (`/seahaven/dynamodb/cmk-arn`) so consumer * stacks in other repos can resolve it without a hard CFN cross-stack export. * * removalPolicy RETAIN — deleting this CMK while any table still has data * encrypted under it would make that data permanently unrecoverable. */ export class DynamoDbCmkStack extends cdk.Stack { public readonly key: kms.Key; constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const account = this.account; const region = this.region; this.key = new kms.Key(this, "Key", { alias: "seahaven-dynamodb", description: "SSE for sensitive DynamoDB tables (PaymentsDashboard, purchase-orders, exec-aide, WorkOrders, WorkOrderComments) — INFRA-95/M-3", enableKeyRotation: true, removalPolicy: cdk.RemovalPolicy.RETAIN, }); // Account-wide use of the key, but ONLY via DynamoDB in this region. The // per-role identity grants (in each consumer stack) are what actually scope // which principals can read/write the encrypted tables. this.key.addToResourcePolicy( new iam.PolicyStatement({ sid: "AllowDynamoDbSSEViaService", effect: iam.Effect.ALLOW, principals: [new iam.AccountRootPrincipal()], actions: [ "kms:Encrypt", "kms:Decrypt", "kms:GenerateDataKey*", "kms:DescribeKey", "kms:CreateGrant", ], resources: ["*"], conditions: { StringEquals: { "kms:ViaService": `dynamodb.${region}.amazonaws.com`, "kms:CallerAccount": account, }, }, }), ); new ssm.StringParameter(this, "CmkArnParam", { parameterName: "/seahaven/dynamodb/cmk-arn", stringValue: this.key.keyArn, description: "ARN of the shared customer-managed CMK for sensitive DynamoDB tables (INFRA-95/M-3)", }); cdk.Tags.of(this).add("Project", "account-baseline"); cdk.Tags.of(this).add("Owner", "adam@seahavenind.com"); cdk.Tags.of(this).add("Environment", "prod"); cdk.Tags.of(this).add("ManagedBy", "cdk"); new cdk.CfnOutput(this, "DynamoDbCmkArn", { value: this.key.keyArn }); } }