@aws-blocks/bb-distributed-table 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -123,6 +123,22 @@ A generic `ValidationException` is exactly the kind of catch-all bucket worth av
123
123
 
124
124
  **Mock/AWS parity:** both layers call the same `applyReadValidation()` helper in `errors.ts`, so all three modes (coerced output, raw-fallback + warn, strict throw) behave identically. `null` (a missing item) passes through untouched in every mode, preserving not-found semantics.
125
125
 
126
+ ### D-DT-10: Secure-by-default durability & encryption, sourced from stack `BlocksDefaults`
127
+
128
+ Durability posture is resolved from the stack-wide `BlocksDefaults` (`BlocksPresets` in `@aws-blocks/core/cdk`), the shared infrastructure-defaults mechanism (introduced in #302): `defaults.removalPolicy`, `defaults.deletionProtection`, and `defaults.pointInTimeRecovery`. Under `BlocksPresets.production` a table defaults to PITR **on**, deletion protection **on**, and `RemovalPolicy.RETAIN`; under `BlocksPresets.sandbox` all three flip off/`DESTROY` so throwaway stacks stay cheap and `sandbox:destroy` is a one-command teardown. SSE defaults to the AWS-managed KMS key. Every default is overridable per table via `protection`, `pointInTimeRecovery`, and `encryption`; a per-block option always wins (`option ?? scope.defaults.field`). Each field is read **independently** from `defaults` — deletion protection and PITR are never derived from `removalPolicy`.
129
+
130
+ **Why read `this.defaults` rather than the `sandboxMode` context.** An earlier revision of this PR read `--context sandboxMode=true` directly and gated durability on it. #302 replaced that per-block guessing with one stack-wide posture object every block reads, so an app tunes durability in one place and blocks stay consistent. Durability now flows entirely through `defaults`; the block only still reads `sandboxMode` for the GSI custom resource's drop-and-recreate fast path (an operational concern, not a durability default).
131
+
132
+ **Why gate deletion protection at the construct (via `defaults`) rather than the stack aspect.** A natural alternative was to enable deletion protection unconditionally and let `SandboxDisableDeletionProtection` relax it for sandboxes. That does **not** work: the mixin duck-types on a `deletionProtection` *instance* property, and the DynamoDB L2 `Table` only accepts `deletionProtection` as a constructor prop — it exposes no such instance property to flip afterward (pinned by `core/src/cdk/mixins.test.ts`). Reading `this.defaults.deletionProtection` in the constructor is exactly why that construct-level mechanism exists; the mixin is now `@deprecated`.
133
+
134
+ **Why one `protection` knob instead of separate `deletionProtection` + `removalPolicy`.** As a per-block override, deletion protection and removal policy answer a single question — "can this table be destroyed?" — and two booleans can encode a contradiction: `deletionProtection: true` + `removalPolicy: 'destroy'` tells CloudFormation to `DeleteTable` on stack delete while DynamoDB refuses the delete, wedging the stack in `DELETE_FAILED`. Collapsing them into one three-value enum (`'disposable'` → DESTROY + unprotected; `'retained'` → RETAIN + unprotected; `'locked'` → RETAIN + protected) makes the invalid combination unrepresentable while still exposing the meaningful middle state (retain without locking). When omitted, the two CDK properties come from `defaults` directly. Encryption stays a separate knob because it's an orthogonal concern.
135
+
136
+ **Why AWS-managed (not customer-managed) KMS by default.** AWS-managed SSE (`aws/dynamodb`) gives CloudTrail-auditable encryption at rest with no per-key monthly charge and no extra stack resources, satisfying "SSE-KMS by default" without imposing cost. Teams that need key rotation/policy control opt into a dedicated CMK with `encryption: 'customer-managed'`.
137
+
138
+ **Bring-your-own / shared CMK.** `encryption: 'customer-managed'` provisions a **dedicated CMK per table**, so an app with many customer-managed tables accrues one key (and one monthly charge) each. To share a single key across tables, callers pass `DistributedTable.fromKmsKey(keyArn)` — a branded `ExternalKmsKeyRef` — as the `encryption` value; the CDK layer resolves it with `Key.fromKeyArn(...)` and sets it as the table's `encryptionKey` (no new key is minted). The `encryption` option is therefore a three-way value (`'aws-managed' | 'customer-managed' | ExternalKmsKeyRef`) rather than a separate `encryptionKey` field — folding it into one knob avoids a contradictory state (e.g. `encryption: 'aws-managed'` alongside a customer key). This follows the same branded-reference pattern as `fromExisting()` (`ExternalTableRef`) and deliberately keeps the CDK `IKey` type out of the public `types.ts`, which must stay runtime-agnostic across the four export layers — the public surface only ever sees the key's ARN string wrapped in a brand.
139
+
140
+ **`fromExisting` is untouched.** When binding to a pre-existing table these options don't apply — the customer owns that table's durability/encryption configuration, exactly as they own its GSIs.
141
+
126
142
  ## Infrastructure (CDK)
127
143
 
128
144
  Creates a single DynamoDB table:
@@ -133,7 +149,7 @@ Creates a single DynamoDB table:
133
149
  - **TTL:** Enabled via `TimeToLiveSpecification` when `options.ttl` is set
134
150
  - **Billing mode:** PAY_PER_REQUEST
135
151
  - **Table name:** Derived from `scope.fullId` (includes stack name for uniqueness)
136
- - **Removal policy:** DESTROY (sandbox), configurable for production
152
+ - **Durability & encryption:** Secure-by-default in production — PITR, deletion protection, SSE-KMS, and `RemovalPolicy.RETAIN` (see D-DT-10)
137
153
  - **Permissions:** `grantReadWriteData` to the parent scope's handler automatically, plus explicit `dynamodb:Query` on `index/*`
138
154
 
139
155
  Attribute types are inferred from the schema at synth time. The CDK layer probes the schema's `StandardSchemaV1.validate()` method with a test value of `0` for each key field — if the field accepts it without issues, it's numeric (`AttributeType.NUMBER`), otherwise string (`AttributeType.STRING`). This is schema-library-agnostic and uses only the standard validation interface.
@@ -217,3 +233,4 @@ Items are stored as DynamoDB JSON (marshalled via `@aws-sdk/lib-dynamodb` Docume
217
233
  | No IAM enforcement | Permission errors only surface in AWS | No mitigation at mock level — IAM is handled by CDK grants automatically |
218
234
  | In-memory index queries vs DynamoDB index reads | Index query performance characteristics differ; no GSI throughput throttling | No mitigation — correctness is preserved. Performance testing requires sandbox |
219
235
  | TTL not enforced locally | Items with expired TTL remain in mock data | Document the gap; test TTL behavior in sandbox |
236
+ | Durability/encryption options (`pointInTimeRecovery`, `protection`, `encryption`) are CDK-only | These provisioning-time settings have no observable effect on mock reads/writes | No mitigation needed — they're infrastructure config, not data behavior; verify the synthesized template in sandbox/prod |
package/README.md CHANGED
@@ -40,8 +40,11 @@ const table = new DistributedTable(scope, id, options)
40
40
  | `key` | `TableKeyConfig<T>` | Yes | Primary key configuration: `{ partitionKey, sortKey? }`. Field names must exist in the schema. |
41
41
  | `indexes` | `Record<string, TableKeyConfig<T>>` | No | Global secondary index definitions. |
42
42
  | `ttl` | `keyof T & string` | No | Enable DynamoDB TTL on the specified attribute. The field should contain a Unix epoch timestamp in seconds. |
43
+ | `pointInTimeRecovery` | `boolean \| { retentionDays: number }` | No | Point-in-Time Recovery (continuous backups). `true` = on with the default 35-day window; `false` = off; `{ retentionDays: n }` = on with an `n`-day window (**1–35**). Defaults to the stack `defaults.pointInTimeRecovery` (on under `BlocksPresets.production`, off under `sandbox`). Bills for backup storage per GB-month; a shorter window trims cost. |
44
+ | `protection` | `'disposable' \| 'retained' \| 'locked'` | No | How hard the table is to destroy (spans removal policy + deletion protection). `'disposable'` = deleted with the stack; `'retained'` = orphaned on stack delete but a direct delete still works; `'locked'` = orphaned **and** deletion-protected. When omitted, removal policy + deletion protection follow the stack `defaults` (`BlocksPresets.production` ≈ `'locked'`, `sandbox` ≈ `'disposable'`). |
45
+ | `encryption` | `'aws-managed' \| 'customer-managed' \| ExternalKmsKeyRef` | No | At-rest encryption key. `'aws-managed'` (default) uses the `aws/dynamodb` KMS key (auditable, no key charge); `'customer-managed'` provisions a dedicated CMK; pass `DistributedTable.fromKmsKey(arn)` to encrypt with an existing CMK you own (shareable across tables). |
43
46
  | `readValidation` | `'off' \| 'coerce' \| 'strict'` | No | How reads (`get`/`getBatch`/`query`/`scan`) reconcile a stored item with `schema`. `'coerce'` (**default**) returns the coerced value and, on failure, the raw value + a warning (never throws); `'strict'` throws `ValidationFailed` on a non-conforming item; `'off'` returns the raw value with no validation. See [Reads and schema evolution](#reads-and-schema-evolution). |
44
- | `table` | `ExternalTableRef` | No | Wrap an existing DynamoDB table instead of creating one. |
47
+ | `table` | `ExternalTableRef` | No | Wrap an existing DynamoDB table instead of creating one. Durability/encryption options are ignored — the customer owns the table's configuration. |
45
48
  | `logger` | `ChildLogger` | No | Optional logger for internal operations. When omitted, a default Logger at error level is created. |
46
49
 
47
50
  ### Key Object Pattern
@@ -323,6 +326,59 @@ const legacy = new DistributedTable(scope, 'legacy', {
323
326
  - **Cost:** ~$1.25 per million writes, ~$0.25 per million reads
324
327
  - **Durability:** 99.999999999% (11 nines) across 3 AZs
325
328
 
329
+ ## Durability & Security defaults
330
+
331
+ Durability posture comes from the **stack-wide `BlocksDefaults`** you pass to `BlocksStack.create` / `BlocksBackend.create` (`BlocksPresets.production` or `BlocksPresets.sandbox` from `@aws-blocks/core/cdk`) — the same knobs every Building Block reads, so a table's removal policy, deletion protection, and continuous-backup posture all follow the app's chosen preset. There's no per-block `sandboxMode` guessing.
332
+
333
+ Under **`BlocksPresets.production`**, every table this block provisions ships with:
334
+
335
+ - **Point-in-Time Recovery** enabled (`defaults.pointInTimeRecovery`) — restore to any second in the last 35 days.
336
+ - **Retained + deletion-protected** (`defaults.removalPolicy = RETAIN`, `defaults.deletionProtection = true`) — neither a stack teardown nor a stray `cdk destroy`/console delete can wipe the table until you explicitly relax it. (Equivalent to `protection: 'locked'`.)
337
+ - **SSE-KMS** with the AWS-managed `aws/dynamodb` key — encryption-at-rest that's auditable via CloudTrail, at no per-key charge.
338
+
339
+ Under **`BlocksPresets.sandbox`** these flip the other way — PITR off, `RemovalPolicy.DESTROY`, deletion protection off (equivalent to `protection: 'disposable'`) — so throwaway stacks stay cheap and `sandbox:destroy` is a one-command teardown. SSE-KMS stays on in both (encryption isn't part of `BlocksDefaults` — it's a per-block option defaulting to `aws-managed`).
340
+
341
+ > **PITR is not free.** Point-in-Time Recovery charges for continuous-backup storage (per GB-month of table size), so a large production table carries an ongoing cost. It's on by default because unrecoverable data loss is usually the worse outcome — but for regenerable data (caches, derived tables) set `pointInTimeRecovery: false`.
342
+
343
+ > **`protection: 'locked'`/`'retained'` orphans the table on stack delete.** Because the removal policy is `RETAIN`, deleting the stack leaves the table behind (by design — your data survives). But the table name is derived deterministically from the block's id, so **redeploying the same app afterward fails with `Table already exists`** until you delete the orphaned table (`aws dynamodb delete-table`, after disabling deletion protection if `'locked'`) or import it into the new stack. This is inherent to retain-on-delete; use `protection: 'disposable'` for tables you expect to recreate freely.
344
+
345
+ Every stack default is overridable per table (a per-block option always wins over `defaults`):
346
+
347
+ ```typescript
348
+ // Cost-sensitive prod table: keep it protected, skip PITR's backup cost
349
+ const cache = new DistributedTable(scope, 'cache', {
350
+ schema, key: { partitionKey: 'id' },
351
+ pointInTimeRecovery: false,
352
+ });
353
+
354
+ // Long-lived data you still want to be able to delete directly
355
+ const staging = new DistributedTable(scope, 'staging', {
356
+ schema, key: { partitionKey: 'id' },
357
+ protection: 'retained', // survives stack delete, but not deletion-protected
358
+ });
359
+
360
+ // Compliance-strict table: dedicated customer-managed KMS key
361
+ const ledger = new DistributedTable(scope, 'ledger', {
362
+ schema, key: { partitionKey: 'id' },
363
+ encryption: 'customer-managed',
364
+ });
365
+
366
+ // Share one customer-managed key across several tables (one key, one bill)
367
+ const key = DistributedTable.fromKmsKey('arn:aws:kms:us-east-1:111122223333:key/abcd-1234');
368
+ const orders = new DistributedTable(scope, 'orders', {
369
+ schema, key: { partitionKey: 'id' },
370
+ encryption: key,
371
+ });
372
+ const events = new DistributedTable(scope, 'events', {
373
+ schema, key: { partitionKey: 'id' },
374
+ encryption: key,
375
+ });
376
+ ```
377
+
378
+ > Overrides always win over the environment default, so you can force a fully durable, protected table in a sandbox (or relax one in prod) explicitly. When you bring your own table via `fromExisting()`, none of these options apply — you own that table's configuration.
379
+
380
+ > **`customer-managed` provisions a dedicated CMK per table** — an app with a dozen customer-managed tables gets a dozen KMS keys (~$1/month each, plus request charges). To share one key across several tables, create the key once and pass `DistributedTable.fromKmsKey(keyArn)` as the `encryption` option on each table (see below). Use the default `'aws-managed'` unless a table needs its own rotation/key-policy control.
381
+
326
382
  ## Local Development
327
383
 
328
384
  Mock data persists to disk at `.bb-data/{fullId}/` across dev server restarts. Wipe with `rm -rf .bb-data`. The mock validates the 400 KB item size limit, schema validation, and conditional check failures, matching AWS behavior. Index queries are implemented via in-memory filtering — correctness is preserved but performance characteristics differ from DynamoDB.
@@ -1,8 +1,8 @@
1
1
  import { Scope } from '@aws-blocks/core';
2
2
  import type { ScopeParent } from '@aws-blocks/core';
3
3
  export { DistributedTableErrors } from './errors.js';
4
- export type { TableKeyConfig, DistributedTableOptions, ReadValidationMode, ExternalTableRef, TableKey, PartitionKeyCondition, SortKeyCondition, KeyCondition, QueryOptions, ScanOptions, PutOptions, DeleteOptions, } from './types.js';
5
- import type { TableKeyConfig, DistributedTableOptions, ExternalTableRef, ScanOptions, PutOptions, DeleteOptions, TableKey } from './types.js';
4
+ export type { TableKeyConfig, DistributedTableOptions, ReadValidationMode, ExternalTableRef, ExternalKmsKeyRef, TableKey, PartitionKeyCondition, SortKeyCondition, KeyCondition, QueryOptions, ScanOptions, PutOptions, DeleteOptions, } from './types.js';
5
+ import type { TableKeyConfig, DistributedTableOptions, ExternalTableRef, ExternalKmsKeyRef, ScanOptions, PutOptions, DeleteOptions, TableKey } from './types.js';
6
6
  import type { QueryOptions } from './types.js';
7
7
  import type { ChildLogger } from '@aws-blocks/bb-logger';
8
8
  export declare class DistributedTable<T, K extends TableKeyConfig<T> = TableKeyConfig<T>, Indexes extends Record<string, TableKeyConfig<T>> = Record<string, TableKeyConfig<T>>> extends Scope {
@@ -34,6 +34,7 @@ export declare class DistributedTable<T, K extends TableKeyConfig<T> = TableKeyC
34
34
  putBatch(items: T[]): Promise<void>;
35
35
  deleteBatch(keys: TableKey<T, K>[]): Promise<void>;
36
36
  static fromExisting(tableName: string): ExternalTableRef;
37
+ static fromKmsKey(keyArn: string): ExternalKmsKeyRef;
37
38
  private validateItem;
38
39
  /**
39
40
  * Reconcile a stored value with the schema per this table's `readValidation`
@@ -1 +1 @@
1
- {"version":3,"file":"index.aws.d.ts","sourceRoot":"","sources":["../src/index.aws.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.aws.d.ts","sourceRoot":"","sources":["../src/index.aws.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,KAAK,EAA6C,MAAM,kBAAkB,CAAC;AACpF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAIpD,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACrD,YAAY,EACX,cAAc,EACd,uBAAuB,EACvB,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,EACjB,QAAQ,EACR,qBAAqB,EACrB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,UAAU,EACV,aAAa,GACb,MAAM,YAAY,CAAC;AAEpB,OAAO,KAAK,EACX,cAAc,EACd,uBAAuB,EACvB,gBAAgB,EAChB,iBAAiB,EACjB,WAAW,EACX,UAAU,EACV,aAAa,EAGb,QAAQ,EAER,MAAM,YAAY,CAAC;AAEpB,OAAO,KAAK,EAAgB,YAAY,EAAE,MAAM,YAAY,CAAC;AAE7D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAwBzD,qBAAa,gBAAgB,CAC5B,CAAC,EACD,CAAC,SAAS,cAAc,CAAC,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,EAC/C,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC,CACpF,SAAQ,KAAK;IAWqC,OAAO,EAAE,uBAAuB,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC;IAVlG,QAAQ,CAAC,MAAM,sBAAW;IAC1B,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,SAAS,CAAI;IACrB,OAAO,CAAC,OAAO,CAAU;IACzB,OAAO,CAAC,cAAc,CAAqB;IAC3C,OAAO,CAAC,SAAS,CAAyB;IAE1C,0FAA0F;IAC1F,SAAS,CAAC,GAAG,EAAE,WAAW,CAAC;gBAEf,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAS,OAAO,EAAE,uBAAuB,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC;IAkB5F,GAAG,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAQ3C,GAAG,CAAC,IAAI,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBpD,MAAM,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAa5E;;;;;;;;OAQG;IACI,KAAK,CACX,OAAO,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC,GAClC,aAAa,CAAC,CAAC,CAAC;IAsCZ,IAAI,CAAC,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,CAAC,CAAC;IAoB9C,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,GAAG,OAAO,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,EAAE,CAAC;IAwBvD,QAAQ,CAAC,KAAK,EAAE,CAAC,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAsBnC,WAAW,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBxD,MAAM,CAAC,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB;IAIxD,MAAM,CAAC,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,iBAAiB;YAMtC,YAAY;IAQ1B;;;;;OAKG;IACH,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,QAAQ;IAMhB,OAAO,CAAC,OAAO;IAWf;;;;;;;;;;;;;;;OAeG;YACW,gBAAgB;IAkB9B,OAAO,CAAC,yBAAyB;IAkBjC,OAAO,CAAC,iBAAiB;CA4CzB"}
package/dist/index.aws.js CHANGED
@@ -196,6 +196,9 @@ export class DistributedTable extends Scope {
196
196
  static fromExisting(tableName) {
197
197
  return { __brand: 'ExternalTableRef', tableName };
198
198
  }
199
+ static fromKmsKey(keyArn) {
200
+ return { __brand: 'ExternalKmsKeyRef', keyArn };
201
+ }
199
202
  // ── Internal ────────────────────────────────────────────────────────────
200
203
  async validateItem(item) {
201
204
  const result = this.schema['~standard'].validate(item);
@@ -1,8 +1,8 @@
1
1
  import { Scope } from '@aws-blocks/core/cdk';
2
2
  import type { ScopeParent } from '@aws-blocks/core';
3
- import type { ExternalTableRef } from './types.js';
3
+ import type { ExternalTableRef, ExternalKmsKeyRef } from './types.js';
4
4
  export { DistributedTableErrors } from './errors.js';
5
- export type { DistributedTableOptions, ReadValidationMode, TableKeyConfig, TableKey, PutOptions, DeleteOptions, QueryOptions, ScanOptions, ExternalTableRef } from './types.js';
5
+ export type { DistributedTableOptions, ReadValidationMode, TableKeyConfig, TableKey, PutOptions, DeleteOptions, QueryOptions, ScanOptions, ExternalTableRef, ExternalKmsKeyRef } from './types.js';
6
6
  export declare class DistributedTable<T = any> extends Scope {
7
7
  options: any;
8
8
  private table;
@@ -14,6 +14,16 @@ export declare class DistributedTable<T = any> extends Scope {
14
14
  * will not modify the table when this factory is used.
15
15
  */
16
16
  static fromExisting(tableName: string): ExternalTableRef;
17
+ /**
18
+ * Reference an existing customer-managed KMS key to encrypt the table,
19
+ * instead of letting `encryption: 'customer-managed'` provision a dedicated
20
+ * key per table. Pass the result as the `encryption` option so several
21
+ * tables can share one key (and one monthly charge).
22
+ *
23
+ * @param keyArn - ARN of a KMS key you already own. The deploying principal
24
+ * and the DynamoDB service must have the usual grants on it.
25
+ */
26
+ static fromKmsKey(keyArn: string): ExternalKmsKeyRef;
17
27
  constructor(scope: ScopeParent, id: string, options: any);
18
28
  get(..._args: unknown[]): never;
19
29
  put(..._args: unknown[]): never;
@@ -1 +1 @@
1
- {"version":3,"file":"index.cdk.d.ts","sourceRoot":"","sources":["../src/index.cdk.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,KAAK,EAAoC,MAAM,sBAAsB,CAAC;AAC/E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAInD,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACrD,YAAY,EAAE,uBAAuB,EAAE,kBAAkB,EAAE,cAAc,EAAE,QAAQ,EAAE,UAAU,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEhL,qBAAa,gBAAgB,CAAC,CAAC,GAAG,GAAG,CAAE,SAAQ,KAAK;IAcA,OAAO,EAAE,GAAG;IAb/D,OAAO,CAAC,KAAK,CAAS;IAEtB;;;;;;OAMG;IACH,MAAM,CAAC,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB;gBAI5C,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAS,OAAO,EAAE,GAAG;IAwG/D,GAAG,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAC/B,GAAG,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAC/B,MAAM,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAClC,KAAK,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IACjC,IAAI,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAChC,QAAQ,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IACpC,QAAQ,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IACpC,WAAW,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;CACvC"}
1
+ {"version":3,"file":"index.cdk.d.ts","sourceRoot":"","sources":["../src/index.cdk.ts"],"names":[],"mappings":"AAWA,OAAO,EAAE,KAAK,EAAoC,MAAM,sBAAsB,CAAC;AAC/E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAItE,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACrD,YAAY,EAAE,uBAAuB,EAAE,kBAAkB,EAAE,cAAc,EAAE,QAAQ,EAAE,UAAU,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEnM,qBAAa,gBAAgB,CAAC,CAAC,GAAG,GAAG,CAAE,SAAQ,KAAK;IA2BA,OAAO,EAAE,GAAG;IA1B/D,OAAO,CAAC,KAAK,CAAS;IAEtB;;;;;;OAMG;IACH,MAAM,CAAC,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB;IAIxD;;;;;;;;OAQG;IACH,MAAM,CAAC,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,iBAAiB;gBAIxC,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAS,OAAO,EAAE,GAAG;IAqO/D,GAAG,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAC/B,GAAG,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAC/B,MAAM,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAClC,KAAK,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IACjC,IAAI,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAChC,QAAQ,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IACpC,QAAQ,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IACpC,WAAW,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;CACvC"}
package/dist/index.cdk.js CHANGED
@@ -1,11 +1,12 @@
1
1
  // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
- import { Table, AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
3
+ import { Table, AttributeType, BillingMode, TableEncryption } from 'aws-cdk-lib/aws-dynamodb';
4
4
  import * as cdk from 'aws-cdk-lib';
5
- import { CustomResource, Duration } from 'aws-cdk-lib';
5
+ import { Annotations, CustomResource, Duration, RemovalPolicy } from 'aws-cdk-lib';
6
6
  import { Code, Function as LambdaFunction } from 'aws-cdk-lib/aws-lambda';
7
7
  import { Provider } from 'aws-cdk-lib/custom-resources';
8
8
  import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
9
+ import { Key } from 'aws-cdk-lib/aws-kms';
9
10
  import { Scope, synthGuard, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
10
11
  import { fileURLToPath } from 'node:url';
11
12
  import { dirname, join } from 'node:path';
@@ -23,6 +24,18 @@ export class DistributedTable extends Scope {
23
24
  static fromExisting(tableName) {
24
25
  return { __brand: 'ExternalTableRef', tableName };
25
26
  }
27
+ /**
28
+ * Reference an existing customer-managed KMS key to encrypt the table,
29
+ * instead of letting `encryption: 'customer-managed'` provision a dedicated
30
+ * key per table. Pass the result as the `encryption` option so several
31
+ * tables can share one key (and one monthly charge).
32
+ *
33
+ * @param keyArn - ARN of a KMS key you already own. The deploying principal
34
+ * and the DynamoDB service must have the usual grants on it.
35
+ */
36
+ static fromKmsKey(keyArn) {
37
+ return { __brand: 'ExternalKmsKeyRef', keyArn };
38
+ }
26
39
  constructor(scope, id, options) {
27
40
  super(id, { parent: scope });
28
41
  this.options = options;
@@ -32,9 +45,20 @@ export class DistributedTable extends Scope {
32
45
  // and grant the runtime Lambda read/write + index query access.
33
46
  // We deliberately skip the GSI custom resource — the customer owns the
34
47
  // table's index lifecycle when they bring their own.
48
+ //
49
+ // Durability/encryption options don't apply to an existing table (we
50
+ // never emit a `Table` resource to attach them to). Surface that at
51
+ // synth so a `protection: 'locked'` on what looks like a fresh
52
+ // table isn't a silent no-op.
53
+ const ignoredForExisting = ['pointInTimeRecovery', 'protection', 'encryption']
54
+ .filter((key) => config[key] !== undefined);
55
+ if (ignoredForExisting.length > 0) {
56
+ Annotations.of(this).addWarningV2('@aws-blocks/bb-distributed-table:IgnoredOptionsForExistingTable', `Ignoring ${ignoredForExisting.join(', ')} because this table is wrapped via fromExisting() — ` +
57
+ `the existing table owns its own durability/encryption configuration.`);
58
+ }
35
59
  this.table = Table.fromTableName(this, 'table', config.table.tableName);
36
- this.table.grantReadWriteData(this.handler);
37
- this.handler.addToRolePolicy(new PolicyStatement({
60
+ this.table.grantReadWriteData(this.executionRole);
61
+ this.executionRole.addToPrincipalPolicy(new PolicyStatement({
38
62
  actions: ['dynamodb:Query'],
39
63
  resources: [`${this.table.tableArn}/index/*`],
40
64
  }));
@@ -55,6 +79,94 @@ export class DistributedTable extends Scope {
55
79
  return true; // no issues for this field → numeric
56
80
  };
57
81
  const getDdbType = (fieldName) => isNumericField(fieldName) ? AttributeType.NUMBER : AttributeType.STRING;
82
+ // Secure-by-default durability & encryption.
83
+ // The posture — removal policy, deletion protection, PITR — comes from the
84
+ // stack-wide `defaults` (BlocksPresets, #302): production retains + protects
85
+ // + backs up; sandbox is disposable so `sandbox:destroy` stays a one-command
86
+ // teardown and throwaway stacks don't accrue backup cost. Blocks read
87
+ // `this.defaults` rather than the `sandboxMode` context (and rather than the
88
+ // stack-level SandboxDisableDeletionProtection mixin, which can't reach the
89
+ // DynamoDB L2 `deletionProtection` prop — the reason that construct-level
90
+ // mechanism exists). A per-block `protection`/`pointInTimeRecovery`/
91
+ // `encryption` option always wins.
92
+ //
93
+ // `protection` is a single knob (disposable | retained | locked) spanning
94
+ // removal policy + deletion protection, so the contradictory
95
+ // "protect + destroy" state can't be expressed. `options` is typed `any`
96
+ // here, so guard against an unrecognized string (typo) rather than
97
+ // silently falling through to the stack default.
98
+ const PROTECTION_VALUES = ['disposable', 'retained', 'locked'];
99
+ if (config.protection !== undefined && !PROTECTION_VALUES.includes(config.protection)) {
100
+ Annotations.of(this).addWarningV2('@aws-blocks/bb-distributed-table:UnknownProtection', `Unrecognized protection '${String(config.protection)}' (expected 'disposable', 'retained', ` +
101
+ `or 'locked') — falling back to the stack defaults.`);
102
+ }
103
+ // `encryption` accepts two string literals or an ExternalKmsKeyRef
104
+ // (a `{ __brand: 'ExternalKmsKeyRef', keyArn }` from `fromKmsKey`).
105
+ // Anything else is a typo — warn rather than silently using the default.
106
+ const isKmsKeyRef = typeof config.encryption === 'object'
107
+ && config.encryption !== null
108
+ && config.encryption.__brand === 'ExternalKmsKeyRef';
109
+ if (config.encryption !== undefined
110
+ && config.encryption !== 'aws-managed'
111
+ && config.encryption !== 'customer-managed'
112
+ && !isKmsKeyRef) {
113
+ Annotations.of(this).addWarningV2('@aws-blocks/bb-distributed-table:UnknownEncryption', `Unrecognized encryption '${String(config.encryption)}' (expected 'aws-managed', ` +
114
+ `'customer-managed', or DistributedTable.fromKmsKey(arn)) — falling back to 'aws-managed'.`);
115
+ }
116
+ // PITR is one knob (`boolean | { retentionDays }`) resolved from the
117
+ // per-block option, else the stack-wide `defaults.pointInTimeRecovery`
118
+ // (#302 follow-up) — production on, sandbox off. The object form both
119
+ // enables PITR and pins the window, so "days set but PITR off" can't be
120
+ // expressed. `retentionDays` must be 1–35; warn and drop back to the
121
+ // 35-day default on an out-of-range value rather than failing the deploy.
122
+ const pitrSetting = config.pointInTimeRecovery ?? this.defaults.pointInTimeRecovery;
123
+ let pitrEnabled;
124
+ let pitrDays;
125
+ if (typeof pitrSetting === 'object' && pitrSetting !== null) {
126
+ pitrEnabled = true;
127
+ pitrDays = pitrSetting.retentionDays;
128
+ if (!Number.isInteger(pitrDays) || pitrDays < 1 || pitrDays > 35) {
129
+ Annotations.of(this).addWarningV2('@aws-blocks/bb-distributed-table:InvalidPitrDays', `pointInTimeRecovery.retentionDays must be an integer between 1 and 35 (got ${String(pitrDays)}) — ` +
130
+ `falling back to the 35-day default.`);
131
+ pitrDays = undefined;
132
+ }
133
+ }
134
+ else {
135
+ pitrEnabled = pitrSetting === true;
136
+ pitrDays = undefined;
137
+ }
138
+ // Resolve durability into the two CDK properties. The `protection` option
139
+ // is the richer per-block override (#282): when set it fully determines
140
+ // removal policy + deletion protection, and — being one knob — the
141
+ // contradictory "protect + destroy" state can't be expressed. When
142
+ // omitted, fall back to the stack-wide `defaults` (BlocksPresets, #302):
143
+ // production → RETAIN + protected, sandbox → DESTROY + unprotected. Read
144
+ // `deletionProtection` independently from `defaults`, never derived.
145
+ let removalPolicy;
146
+ let deletionProtection;
147
+ if (PROTECTION_VALUES.includes(config.protection)) {
148
+ deletionProtection = config.protection === 'locked';
149
+ removalPolicy = config.protection === 'disposable' ? RemovalPolicy.DESTROY : RemovalPolicy.RETAIN;
150
+ }
151
+ else {
152
+ removalPolicy = this.defaults.removalPolicy;
153
+ deletionProtection = this.defaults.deletionProtection;
154
+ }
155
+ // `fromKmsKey(arn)` → encrypt with an existing CMK (shareable across
156
+ // tables). `'customer-managed'` → CDK provisions a fresh dedicated CMK.
157
+ // Otherwise the AWS-managed `aws/dynamodb` key.
158
+ let encryptionKey;
159
+ let encryption;
160
+ if (isKmsKeyRef) {
161
+ encryption = TableEncryption.CUSTOMER_MANAGED;
162
+ encryptionKey = Key.fromKeyArn(this, 'encryption-key', config.encryption.keyArn);
163
+ }
164
+ else if (config.encryption === 'customer-managed') {
165
+ encryption = TableEncryption.CUSTOMER_MANAGED;
166
+ }
167
+ else {
168
+ encryption = TableEncryption.AWS_MANAGED;
169
+ }
58
170
  this.table = new Table(this, 'table', {
59
171
  tableName,
60
172
  partitionKey: {
@@ -67,15 +179,29 @@ export class DistributedTable extends Scope {
67
179
  } : undefined,
68
180
  billingMode: BillingMode.PAY_PER_REQUEST,
69
181
  timeToLiveAttribute: config.ttl || undefined,
70
- // Durability follows the stack-wide `defaults` (sandbox DESTROY +
71
- // no protection so a teardown is clean; production RETAIN + protected).
72
- // A richer per-block override lands with the `protection` option in #282.
73
- removalPolicy: this.defaults.removalPolicy,
74
- deletionProtection: this.defaults.deletionProtection,
182
+ // PITR spec is only emitted when enabled leaving it undefined keeps
183
+ // the CloudFormation template clean for sandboxes / opt-outs.
184
+ // recoveryPeriodInDays is only set when the caller narrows it (an
185
+ // omitted value keeps DynamoDB's 35-day default without emitting it).
186
+ pointInTimeRecoverySpecification: pitrEnabled
187
+ ? {
188
+ pointInTimeRecoveryEnabled: true,
189
+ ...(pitrDays !== undefined ? { recoveryPeriodInDays: pitrDays } : {}),
190
+ }
191
+ : undefined,
192
+ // Resolved above from `protection` (per-block override) or the
193
+ // stack-wide `defaults` (#302) — supersedes main's placeholder that
194
+ // read `this.defaults` directly.
195
+ deletionProtection,
196
+ removalPolicy,
197
+ encryption,
198
+ // Only set when bringing an existing CMK; `CUSTOMER_MANAGED` without a
199
+ // key lets CDK provision a dedicated one.
200
+ encryptionKey,
75
201
  });
76
- this.table.grantReadWriteData(this.handler);
202
+ this.table.grantReadWriteData(this.executionRole);
77
203
  // Explicit index query permissions
78
- this.handler.addToRolePolicy(new PolicyStatement({
204
+ this.executionRole.addToPrincipalPolicy(new PolicyStatement({
79
205
  actions: ['dynamodb:Query'],
80
206
  resources: [`${this.table.tableArn}/index/*`],
81
207
  }));