seahaven-org-baseline/lib/dynamodb-cmk-stack.ts

106 lines
4.6 KiB
TypeScript
Raw Normal View History

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