fix(bb-distributed-table): secure-by-default durability & encryption on prod DDB tables - #282
fix(bb-distributed-table): secure-by-default durability & encryption on prod DDB tables#282osama-rizk wants to merge 2 commits into
Conversation
…on prod DDB tables
Production DynamoDB tables shipped with PointInTimeRecovery DISABLED,
DeletionProtection false, and no explicit SSE-KMS — so a stray `cdk destroy`
could permanently delete customer data and at-rest data used an AWS-owned key
with no CloudTrail auditability.
Prod tables now default to: PITR on, deletion protection on, SSE with the
AWS-managed `aws/dynamodb` KMS key, and RemovalPolicy.RETAIN. Sandbox mode
(`--context sandboxMode=true`) keeps PITR/deletion protection off and uses
RemovalPolicy.DESTROY so `sandbox:destroy` stays a one-command teardown.
Deletion protection is gated inside the construct (not via the stack-level
SandboxDisableDeletionProtection mixin) because that mixin duck-types on a
`deletionProtection` instance property the DynamoDB L2 Table doesn't expose.
Adds override options: pointInTimeRecovery, deletionProtection, encryption
('aws-managed' | 'customer-managed'), removalPolicy ('retain' | 'destroy').
Explicit values always win. `fromExisting()` tables are unaffected.
🦋 Changeset detectedLatest commit: 66d9671 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
| // Table does not expose — so it can't relax it after the fact. | ||
| const pitrEnabled = config.pointInTimeRecovery ?? !isSandbox; | ||
| const deletionProtection = config.deletionProtection ?? !isSandbox; | ||
| const removalPolicy = config.removalPolicy === 'destroy' |
There was a problem hiding this comment.
[minor] Both of these fall through to the environment default on any unrecognized string (typo, wrong casing, whatever), since options is typed any here. For encryption that's harmless, worst case you silently get the safer default. For removalPolicy it's not: a typo'd retain in sandbox falls through to DESTROY, the opposite of what someone typed. Might be worth throwing (or at least an Annotations warning) on an unrecognized value instead of treating it like undefined. Same pattern already exists in bb-kv-store so this isn't new, just flagging it since these are literally the durability knobs this PR is about.
There was a problem hiding this comment.
Good catch — agreed the danger is real for removalPolicy specifically. Added an addWarningV2 at synth for an unrecognized removalPolicy or encryption string instead of treating it as undefined. Left bb-kv-store as-is since it's out of scope here. (d13f97b→pushed)
| */ | ||
| removalPolicy?: 'retain' | 'destroy'; | ||
| /** Wrap an existing table instead of creating one. */ | ||
| table?: ExternalTableRef; |
There was a problem hiding this comment.
[minor] Now that there are four durability/encryption options, might be worth wiring up an Annotations.of(this).addWarningV2(...) synth warning (same pattern as pipeline-construct.ts / hosting_construct.ts) for when someone passes e.g. deletionProtection or encryption alongside table. Right now it's a silent no-op, documented in README/DESIGN.md but not surfaced anywhere at synth time. Easy to imagine someone setting deletionProtection: true on what they think is a fresh table, not realizing fromExisting means the option just gets ignored.
There was a problem hiding this comment.
Done — fromExisting now emits an addWarningV2 listing any durability/encryption options passed alongside it, so a deletionProtection: true on what looks like a fresh table is no longer a silent no-op. Followed the hosting_construct.ts pattern you referenced.
| }); | ||
| }); | ||
|
|
||
| test('CDK: fromExisting does not emit durability props (customer owns the table)', () => { |
There was a problem hiding this comment.
[minor] This is identical to the test right below it (fromExisting does NOT provision a table (regression)), same setup, same single assertion on resourceCountIs('AWS::DynamoDB::Table', 0). The name promises a check that durability props aren't emitted, but since no table resource exists at all there's nothing to check props on, it's really just re-testing that fromExisting doesn't provision a table. If the goal is to prove the new durability options are ignored when combined with table, it'd be more useful to pass pointInTimeRecovery: true, deletionProtection: true, encryption: 'customer-managed' here and also assert resourceCountIs('AWS::KMS::Key', 0), that'd actually exercise the ignore behavior instead of duplicating the test below it.
There was a problem hiding this comment.
You're right, it was a straight duplicate. Rewrote it to pass pointInTimeRecovery/deletionProtection: true + encryption: 'customer-managed' and assert both DynamoDB::Table and KMS::Key are 0 — so it now proves the options are ignored rather than re-testing the regression below.
| // way so `sandbox:destroy` stays a one-command teardown and throwaway | ||
| // stacks don't accrue backup cost. An explicit option always wins. | ||
| // | ||
| // NOTE: deletion protection is gated here (not left to the stack-level |
There was a problem hiding this comment.
[nit] This comment block (through line 84) is close to verbatim from DESIGN.md's D-DT-9 writeup on why deletion protection is gated in the constructor. The bullet you added to DESIGN.md's CDK infra list a few lines down just says "(see D-DT-9)" instead of repeating itself, might be worth doing the same here, e.g. // Gated here rather than the stack-level mixin, see DESIGN.md D-DT-9. One source of truth for the rationale instead of two copies to keep in sync.
There was a problem hiding this comment.
I'd lean toward keeping the rationale inline here — the mixin gotcha is exactly what the next person editing this constructor needs in front of them, and a pointer to D-DT-9 is easy to skip past. Happy to collapse it to // see DESIGN.md D-DT-9 if you'd prefer a single source of truth.
|
|
||
| Every default is overridable per table via new options — `pointInTimeRecovery`, `deletionProtection`, `encryption` (`'aws-managed' | 'customer-managed'`), and `removalPolicy` (`'retain' | 'destroy'`). Explicit values always win over the environment default. Tables bound via `fromExisting()` are unaffected. | ||
|
|
||
| > **Behavior change on next production deploy of an existing app:** the table will gain PITR, deletion protection, an SSE-KMS specification, and a `Retain` deletion policy. These are in-place updates (no table replacement). Because deletion protection becomes enabled, a future `cdk destroy` of a prod stack will refuse to delete the table until you disable protection or pass `deletionProtection: false`. |
There was a problem hiding this comment.
[major] Agreed with your own callout here, this is the one part of the PR I'd want an explicit maintainer/bar-raiser sign-off on before merge, since it changes behavior for every existing prod table on its next deploy with no opt-in required. The mechanics look right, PITR/DeletionProtection/SSE/RemovalPolicy are all no-interruption updates per the CFN docs so there's no replacement risk. My question is more about rollout: do we know how many internal apps currently consume DistributedTable in prod, and is there a comms plan beyond this changeset note? Most consumers won't read it until after deletion protection is already on and they hit a cdk destroy refusal.
There was a problem hiding this comment.
Agreed this needs a maintainer/bar-raiser call rather than just my sign-off. On mechanics we're aligned — all four are in-place updates, no table replacement.
The rollout question (prod-consumer count + comms beyond the changeset) I can't answer from this repo, so flagging it for a maintainer. The one sharp edge is that deletion protection turns on silently and the first thing a consumer notices is a cdk destroy refusal. Options to soften that: (a) ship behind an opt-in default for one release with a synth warning, then flip; or (b) keep the flip but pair it with a heads-up to known consumers. Deferring on which — happy to implement either.
| | `table` | `ExternalTableRef` | No | Wrap an existing DynamoDB table instead of creating one. | | ||
| | `pointInTimeRecovery` | `boolean` | No | Enable Point-in-Time Recovery (continuous backups, 35-day window). **Defaults to `true` in production, `false` in sandbox.** | | ||
| | `deletionProtection` | `boolean` | No | Block table deletion until turned off. **Defaults to `true` in production, `false` in sandbox.** | | ||
| | `encryption` | `'aws-managed' \| 'customer-managed'` | No | At-rest encryption key type. `'aws-managed'` (default) uses the `aws/dynamodb` KMS key (auditable, no key charge); `'customer-managed'` provisions a dedicated CMK. | |
There was a problem hiding this comment.
[minor] Worth a one-line callout that customer-managed provisions a brand-new dedicated CMK per table (confirmed by the resourceCountIs('AWS::KMS::Key', 1) test), not a shared one. An app with a dozen tables all opting into customer-managed encryption ends up with a dozen KMS keys at about a dollar a month each plus request charges, and there's currently no way to pass in an existing key so multiple tables could share one. Not necessarily asking for that as a feature, just flagging it so it's not a surprise on the bill.
There was a problem hiding this comment.
Added a callout: customer-managed provisions a dedicated CMK per table (~$1/mo each + requests), no shared-key option today, prefer aws-managed unless a table needs its own rotation/key-policy control. Not adding bring-your-own-key here since you weren't asking for it.
…; strengthen fromExisting test Address review feedback on #282: - Warn at synth on unrecognized removalPolicy/encryption strings instead of silently falling back to the environment default. - Warn when durability/encryption options are passed alongside fromExisting() (they're ignored — the existing table owns its config). - Rewrite the duplicate fromExisting test to actually exercise the ignore path (assert no Table AND no KMS::Key emitted). - README: note customer-managed provisions a dedicated CMK per table.
Summary
Fixes the bug-bash finding "DDB tables ship with PITR OFF, DeletionProtection OFF, SSE-KMS OFF" (project aws-amplify/141).
Every production DynamoDB table emitted by
bb-distributed-tablewas created with onlytableName, keys,billingMode, and TTL — so CloudFormation defaulted to:DeletionProtection: false→ a straycdk destroy/console delete is unrecoverablePointInTimeRecovery: DISABLED→ no restore from bad writes/deletesSSESpecification: null→ at-rest encryption uses an AWS-owned key (no CloudTrail auditability)What changed
Production tables are now secure by default:
pointInTimeRecovery?: booleandeletionProtection?: booleanaws/dynamodb)encryption?: 'aws-managed' | 'customer-managed'removalPolicy?: 'retain' | 'destroy'Explicit options always win over the environment default.
fromExisting()tables are untouched — the customer owns their config.Sandbox stays cheap and deletable: PITR/deletion-protection default off and removal policy is
DESTROY, sonpm run sandbox+sandbox:destroystill tears down in one command.Design note — why gate inside the construct
The natural alternative — enable deletion protection unconditionally and let the existing
SandboxDisableDeletionProtectionstack mixin relax it in sandbox — doesn't work: that mixin duck-types on adeletionProtectioninstance property, which the DynamoDB L2Tabledoesn't expose (only a constructor prop; pinned bycore/src/cdk/mixins.test.ts). So the sandbox/prod decision is made in the constructor from the samesandboxModecontext. AWS-managed KMS (not a CMK) is the default: auditable, no per-key charge, no extra resources.Validation
1. Red → green (TDD). Added 11 CDK-synth regression tests in
index.cdk.test.ts. Against unmodifiedorigin/mainsource, 6 failed — reproducing the bug (prod PITR/DeletionProtection/SSE all missing). After the fix, all pass.2. Standalone synth reproduction app (
test-apps/ddb-secure-defaults-repro/) — synthesizes real CloudFormation from the built package, no AWS account:before-repro.mjs(imports a transpile oforigin/main's CDK entry) → reproduces all 3 weak defaults 🐛synth-repro.mjs(this branch's build) → 11/11 checks pass ✅ covering prod defaults, sandbox defaults, prod opt-out, sandbox opt-in, and customer-managed-KMS (provisions anAWS::KMS::Key).3. No regressions:
npm run build✅npm run lint✅ ·npm run lint:deps✅ (307 files)bb-distributed-tablepackage: 107/107 tests ✅ (incl. mock + parity)@aws-blocks/blocksconditional-export parity: 41/41 ✅npm run update:api); umbrella re-exports by reference so its report is unchanged.Not run — and why:
test:e2e:local(dev-server) exercises runtime data paths. This change is entirely CDK provisioning-time; mock/runtime behavior is unchanged (proven by the 107 package tests incl. parity). No serialization or wire-format change, so no sandbox e2e required.Checklist
bb-distributed-table+blocks)synthGuardstubsAPI bar-raiser: durability & encryption options
What's new
Four optional fields on
DistributedTableOptions(all additive — no signature or return-type change, so this is backward-compatible):Alongside the fields, the default posture changed: a production
DistributedTableis now durable and protected out of the box, while sandbox deploys stay cheap and one-command-deletable. Every default is overridable; an explicit value always wins over the environment default.fromExisting()tables are unaffected — the caller owns that table's configuration.Why
encryption/removalPolicy. Public signatures must not leak AWS/CDK primitives (TableEncryption,RemovalPolicy) — those types would couple app code to a CDK major version and break the browser/mock builds where the enum doesn't exist.'aws-managed' | 'customer-managed'is self-documenting and stable across the four conditional-export layers.booleanfor PITR/deletion protection, tri-state viaundefined.undefinedmeans "use the environment default," which is what lets one flag behave correctly in both prod and sandbox without a separatesandbox-only API.aws-managedas the encryption default, not a CMK. It satisfies "SSE-KMS by default" (CloudTrail-auditable, customer-visible key) with no per-key monthly charge and no extra stack resource — the right cost/security balance for a framework default. CMK is one string away for teams that need key-policy/rotation control.Use cases
1. The default — a durable production table (no new code):
2. Cost-sensitive prod table — keep protection, skip continuous backups:
3. Compliance-strict table — dedicated customer-managed key:
4. Long-lived sandbox that mirrors prod — opt into durability explicitly:
5. Bring-your-own table — options are inert, caller owns config: