diff --git a/bin/app.ts b/bin/app.ts index 1a08184..06564a7 100644 --- a/bin/app.ts +++ b/bin/app.ts @@ -5,6 +5,7 @@ import { AccountBaselineStack } from "../lib/account-baseline-stack"; import { BackupOffsiteStack } from "../lib/backup-offsite-stack"; import { BackupStack } from "../lib/backup-stack"; import { RegionalBaselineStack } from "../lib/regional-baseline-stack"; +import { DynamoDbCmkStack } from "../lib/dynamodb-cmk-stack"; const ACCOUNT = "328440206208"; @@ -17,6 +18,16 @@ new AccountBaselineStack(app, "account-baseline", { budgetAlertEmail: "adam@seahavenind.com", }); +// ── Shared DynamoDB CMK (INFRA-95 / M-3) ───────────────────────────────────── +// Dedicated, standalone stack so the customer-managed key for sensitive +// finance/PII DynamoDB tables is an independent shared dependency for the owning +// app repos (payments-dashboard, procurement-ingest, exec-aide). Its ARN is +// published to SSM (/seahaven/dynamodb/cmk-arn) for those stacks to consume. +new DynamoDbCmkStack(app, "dynamodb-cmk", { + stackName: "seahaven-dynamodb-cmk", + env: { account: ACCOUNT, region: "us-east-1" }, +}); + // ── Secondary-region baselines (INFRA-16, INFRA-91) ────────────────────────── // The us-east-1 baseline above is region-pinned by design. These stacks extend // a minimal detective/logging footprint into the secondary regions, codifying diff --git a/lib/dynamodb-cmk-stack.ts b/lib/dynamodb-cmk-stack.ts new file mode 100644 index 0000000..b2897d4 --- /dev/null +++ b/lib/dynamodb-cmk-stack.ts @@ -0,0 +1,105 @@ +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 }); + } +}