[INFRA-95] Shared DynamoDB CMK for sensitive finance/PII tables (M-3) (#21)

This commit is contained in:
Adam Moussa 2026-06-08 19:04:42 -04:00 • committed by GitHub
parent 77d9d6c574
commit ec620e4e79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 116 additions and 0 deletions

View file

@ -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

105
lib/dynamodb-cmk-stack.ts Normal file
View file

@ -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 });
}
}