Skip to content

fix(bb-distributed-table): secure-by-default durability & encryption on prod DDB tables - #282

Open
osama-rizk wants to merge 2 commits into
mainfrom
fix/ddb-secure-defaults
Open

fix(bb-distributed-table): secure-by-default durability & encryption on prod DDB tables#282
osama-rizk wants to merge 2 commits into
mainfrom
fix/ddb-secure-defaults

Conversation

@osama-rizk

@osama-rizk osama-rizk commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

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-table was created with only tableName, keys, billingMode, and TTL — so CloudFormation defaulted to:

  • DeletionProtection: false → a stray cdk destroy/console delete is unrecoverable
  • PointInTimeRecovery: DISABLED → no restore from bad writes/deletes
  • SSESpecification: null → at-rest encryption uses an AWS-owned key (no CloudTrail auditability)

What changed

Production tables are now secure by default:

Property Prod default Sandbox default Override
Point-in-Time Recovery on off pointInTimeRecovery?: boolean
Deletion protection on off deletionProtection?: boolean
At-rest encryption SSE-KMS (aws/dynamodb) SSE-KMS encryption?: 'aws-managed' | 'customer-managed'
Stack-delete behavior Retain Destroy 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, so npm run sandbox + sandbox:destroy still tears down in one command.

Design note — why gate inside the construct

The natural alternative — enable deletion protection unconditionally and let the existing SandboxDisableDeletionProtection stack mixin relax it in sandbox — doesn't work: that mixin duck-types on a deletionProtection instance property, which the DynamoDB L2 Table doesn't expose (only a constructor prop; pinned by core/src/cdk/mixins.test.ts). So the sandbox/prod decision is made in the constructor from the same sandboxMode context. 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 unmodified origin/main source, 6 failed — reproducing the bug (prod PITR/DeletionProtection/SSE all missing). After the fix, all pass.

BEFORE:  # tests 16 | # pass 10 | # fail 6
  not ok - CDK: prod DistributedTable enables PITR by default
  not ok - CDK: prod DistributedTable enables DeletionProtection by default
  not ok - CDK: prod DistributedTable enables SSE (KMS-managed) by default
AFTER:   # tests 16 | # pass 16 | # fail 0

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 of origin/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 an AWS::KMS::Key).

3. No regressions:

  • npm run build
  • npm run lint ✅ · npm run lint:deps ✅ (307 files)
  • bb-distributed-table package: 107/107 tests ✅ (incl. mock + parity)
  • @aws-blocks/blocks conditional-export parity: 41/41
  • Full workspace unit tests: 3426/3426
  • API report regenerated (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

  • build / lint / lint:deps / unit / parity green locally
  • Changeset added (covers bb-distributed-table + blocks)
  • README + DESIGN + JSDoc updated in this PR
  • Conditional-export parity holds; runtime methods keep synthGuard stubs
  • New behavior ships tests
  • Breaking-ish default change flagged above for maintainer review

API 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):

interface DistributedTableOptions<T, K, Indexes> {
  // ...existing: schema, key, indexes, ttl, table, logger
  pointInTimeRecovery?: boolean;                        // default: true in prod, false in sandbox
  deletionProtection?: boolean;                         // default: true in prod, false in sandbox
  encryption?: 'aws-managed' | 'customer-managed';      // default: 'aws-managed'
  removalPolicy?: 'retain' | 'destroy';                 // default: 'retain' in prod, 'destroy' in sandbox
}

Alongside the fields, the default posture changed: a production DistributedTable is 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

  • Secure-by-default over configure-to-be-safe. The old surface required zero options to provision a table, which is exactly why the weak defaults went unnoticed across 6 reports / 3 apps — nobody opts into PITR or deletion protection they don't know exists. The safe path is now the default path; you opt out, not in.
  • Options object, not new constructor params or method variants (API guideline G — options are non-breaking to extend; positional args and overloads are not). Adding these as fields means a future 5th knob is also non-breaking.
  • String unions, not raw CDK enums, for 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.
  • boolean for PITR/deletion protection, tri-state via undefined. undefined means "use the environment default," which is what lets one flag behave correctly in both prod and sandbox without a separate sandbox-only API.
  • aws-managed as 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):

const orders = new DistributedTable(scope, 'orders', {
  schema: orderSchema,
  key: { partitionKey: 'orderId' },
});
// prod: PITR on, deletion protection on, SSE-KMS, RETAIN on stack delete
// sandbox: all off + DESTROY — sandbox:destroy still tears down in one command

2. Cost-sensitive prod table — keep protection, skip continuous backups:

const cache = new DistributedTable(scope, 'cache', {
  schema: cacheSchema,
  key: { partitionKey: 'key' },
  ttl: 'expiresAt',
  pointInTimeRecovery: false,   // regenerable data; don't pay for 35-day backups
});

3. Compliance-strict table — dedicated customer-managed key:

const ledger = new DistributedTable(scope, 'ledger', {
  schema: ledgerSchema,
  key: { partitionKey: 'accountId', sortKey: 'txnId' },
  encryption: 'customer-managed',   // provisions a CMK; full rotation/key-policy control
});

4. Long-lived sandbox that mirrors prod — opt into durability explicitly:

const shared = new DistributedTable(scope, 'shared', {
  schema, key: { partitionKey: 'id' },
  deletionProtection: true,     // wins over the sandbox default; survives an accidental destroy
  pointInTimeRecovery: true,
});

5. Bring-your-own table — options are inert, caller owns config:

const users = new DistributedTable(scope, 'users', {
  schema, key: { partitionKey: 'userId' },
  table: DistributedTable.fromExisting('legacy-users-table'),
  // durability/encryption options ignored — the existing table's settings stand
});

…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-bot

changeset-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 66d9671

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@aws-blocks/bb-distributed-table Patch
@aws-blocks/blocks Patch

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

@osama-rizk
osama-rizk marked this pull request as ready for review July 30, 2026 12:04
@osama-rizk
osama-rizk requested a review from a team as a code owner July 30, 2026 12:04
soberm
soberm previously approved these changes Jul 30, 2026
// 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'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@soberm
soberm self-requested a review July 30, 2026 15:30
@pranavosu
pranavosu self-requested a review July 30, 2026 21:31
…; 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants