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

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
@@ -81,6 +81,10 @@ orders.query({
81
81
 
82
82
  The `order` field maps to DynamoDB's `ScanIndexForward` parameter (`'desc'` → `ScanIndexForward: false`). It defaults to `'asc'`.
83
83
 
84
+ **Ordering ties (GSI):** a GSI sort key need not be unique, so multiple items can share one sort-key value. The mock tie-breaks on the base-table primary key (partition key, then sort key) rather than on Map insertion order — otherwise results would depend on write order / disk-reload order. This **matches DynamoDB's observed ordering** for index rows with equal sort keys; AWS does not document a contractual guarantee for the tie order, so the mock is intentionally **deterministic even where DynamoDB's contract is unspecified** (a plus for reproducible tests — just don't rely on a specific tie order in production logic). Under `order: 'desc'` the whole index order, ties included, reverses.
85
+
86
+ **String-ordering boundary:** the mock compares sort/base keys with JavaScript `<`/`>` (UTF-16 code-unit order), which can differ from DynamoDB's UTF-8 byte order for non-ASCII string keys. This applies to both the index sort-key comparison and the base-key tie-break, and is a pre-existing property of the mock — surfaced here so the parity discussion is complete.
87
+
84
88
  ### D-DT-7: TTL via options field
85
89
 
86
90
  **Decision:** TTL is configured via `ttl: 'fieldName'` in the constructor options, not as a separate method or decorator.
@@ -123,6 +127,22 @@ A generic `ValidationException` is exactly the kind of catch-all bucket worth av
123
127
 
124
128
  **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
129
 
130
+ ### D-DT-10: Secure-by-default durability & encryption, sourced from stack `BlocksDefaults`
131
+
132
+ 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`.
133
+
134
+ **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).
135
+
136
+ **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`.
137
+
138
+ **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.
139
+
140
+ **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'`.
141
+
142
+ **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.
143
+
144
+ **`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.
145
+
126
146
  ## Infrastructure (CDK)
127
147
 
128
148
  Creates a single DynamoDB table:
@@ -133,7 +153,7 @@ Creates a single DynamoDB table:
133
153
  - **TTL:** Enabled via `TimeToLiveSpecification` when `options.ttl` is set
134
154
  - **Billing mode:** PAY_PER_REQUEST
135
155
  - **Table name:** Derived from `scope.fullId` (includes stack name for uniqueness)
136
- - **Removal policy:** DESTROY (sandbox), configurable for production
156
+ - **Durability & encryption:** Secure-by-default in production — PITR, deletion protection, SSE-KMS, and `RemovalPolicy.RETAIN` (see D-DT-10)
137
157
  - **Permissions:** `grantReadWriteData` to the parent scope's handler automatically, plus explicit `dynamodb:Query` on `index/*`
138
158
 
139
159
  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 +237,4 @@ Items are stored as DynamoDB JSON (marshalled via `@aws-sdk/lib-dynamodb` Docume
217
237
  | No IAM enforcement | Permission errors only surface in AWS | No mitigation at mock level — IAM is handled by CDK grants automatically |
218
238
  | 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
239
  | TTL not enforced locally | Items with expired TTL remain in mock data | Document the gap; test TTL behavior in sandbox |
240
+ | 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":"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"}
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":"AAYA,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,13 @@
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
+ import { LogGroup } from 'aws-cdk-lib/aws-logs';
7
8
  import { Provider } from 'aws-cdk-lib/custom-resources';
8
9
  import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
10
+ import { Key } from 'aws-cdk-lib/aws-kms';
9
11
  import { Scope, synthGuard, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
10
12
  import { fileURLToPath } from 'node:url';
11
13
  import { dirname, join } from 'node:path';
@@ -23,6 +25,18 @@ export class DistributedTable extends Scope {
23
25
  static fromExisting(tableName) {
24
26
  return { __brand: 'ExternalTableRef', tableName };
25
27
  }
28
+ /**
29
+ * Reference an existing customer-managed KMS key to encrypt the table,
30
+ * instead of letting `encryption: 'customer-managed'` provision a dedicated
31
+ * key per table. Pass the result as the `encryption` option so several
32
+ * tables can share one key (and one monthly charge).
33
+ *
34
+ * @param keyArn - ARN of a KMS key you already own. The deploying principal
35
+ * and the DynamoDB service must have the usual grants on it.
36
+ */
37
+ static fromKmsKey(keyArn) {
38
+ return { __brand: 'ExternalKmsKeyRef', keyArn };
39
+ }
26
40
  constructor(scope, id, options) {
27
41
  super(id, { parent: scope });
28
42
  this.options = options;
@@ -32,9 +46,20 @@ export class DistributedTable extends Scope {
32
46
  // and grant the runtime Lambda read/write + index query access.
33
47
  // We deliberately skip the GSI custom resource — the customer owns the
34
48
  // table's index lifecycle when they bring their own.
49
+ //
50
+ // Durability/encryption options don't apply to an existing table (we
51
+ // never emit a `Table` resource to attach them to). Surface that at
52
+ // synth so a `protection: 'locked'` on what looks like a fresh
53
+ // table isn't a silent no-op.
54
+ const ignoredForExisting = ['pointInTimeRecovery', 'protection', 'encryption']
55
+ .filter((key) => config[key] !== undefined);
56
+ if (ignoredForExisting.length > 0) {
57
+ Annotations.of(this).addWarningV2('@aws-blocks/bb-distributed-table:IgnoredOptionsForExistingTable', `Ignoring ${ignoredForExisting.join(', ')} because this table is wrapped via fromExisting() — ` +
58
+ `the existing table owns its own durability/encryption configuration.`);
59
+ }
35
60
  this.table = Table.fromTableName(this, 'table', config.table.tableName);
36
- this.table.grantReadWriteData(this.handler);
37
- this.handler.addToRolePolicy(new PolicyStatement({
61
+ this.table.grantReadWriteData(this.executionRole);
62
+ this.executionRole.addToPrincipalPolicy(new PolicyStatement({
38
63
  actions: ['dynamodb:Query'],
39
64
  resources: [`${this.table.tableArn}/index/*`],
40
65
  }));
@@ -55,6 +80,94 @@ export class DistributedTable extends Scope {
55
80
  return true; // no issues for this field → numeric
56
81
  };
57
82
  const getDdbType = (fieldName) => isNumericField(fieldName) ? AttributeType.NUMBER : AttributeType.STRING;
83
+ // Secure-by-default durability & encryption.
84
+ // The posture — removal policy, deletion protection, PITR — comes from the
85
+ // stack-wide `defaults` (BlocksPresets, #302): production retains + protects
86
+ // + backs up; sandbox is disposable so `sandbox:destroy` stays a one-command
87
+ // teardown and throwaway stacks don't accrue backup cost. Blocks read
88
+ // `this.defaults` rather than the `sandboxMode` context (and rather than the
89
+ // stack-level SandboxDisableDeletionProtection mixin, which can't reach the
90
+ // DynamoDB L2 `deletionProtection` prop — the reason that construct-level
91
+ // mechanism exists). A per-block `protection`/`pointInTimeRecovery`/
92
+ // `encryption` option always wins.
93
+ //
94
+ // `protection` is a single knob (disposable | retained | locked) spanning
95
+ // removal policy + deletion protection, so the contradictory
96
+ // "protect + destroy" state can't be expressed. `options` is typed `any`
97
+ // here, so guard against an unrecognized string (typo) rather than
98
+ // silently falling through to the stack default.
99
+ const PROTECTION_VALUES = ['disposable', 'retained', 'locked'];
100
+ if (config.protection !== undefined && !PROTECTION_VALUES.includes(config.protection)) {
101
+ Annotations.of(this).addWarningV2('@aws-blocks/bb-distributed-table:UnknownProtection', `Unrecognized protection '${String(config.protection)}' (expected 'disposable', 'retained', ` +
102
+ `or 'locked') — falling back to the stack defaults.`);
103
+ }
104
+ // `encryption` accepts two string literals or an ExternalKmsKeyRef
105
+ // (a `{ __brand: 'ExternalKmsKeyRef', keyArn }` from `fromKmsKey`).
106
+ // Anything else is a typo — warn rather than silently using the default.
107
+ const isKmsKeyRef = typeof config.encryption === 'object'
108
+ && config.encryption !== null
109
+ && config.encryption.__brand === 'ExternalKmsKeyRef';
110
+ if (config.encryption !== undefined
111
+ && config.encryption !== 'aws-managed'
112
+ && config.encryption !== 'customer-managed'
113
+ && !isKmsKeyRef) {
114
+ Annotations.of(this).addWarningV2('@aws-blocks/bb-distributed-table:UnknownEncryption', `Unrecognized encryption '${String(config.encryption)}' (expected 'aws-managed', ` +
115
+ `'customer-managed', or DistributedTable.fromKmsKey(arn)) — falling back to 'aws-managed'.`);
116
+ }
117
+ // PITR is one knob (`boolean | { retentionDays }`) resolved from the
118
+ // per-block option, else the stack-wide `defaults.pointInTimeRecovery`
119
+ // (#302 follow-up) — production on, sandbox off. The object form both
120
+ // enables PITR and pins the window, so "days set but PITR off" can't be
121
+ // expressed. `retentionDays` must be 1–35; warn and drop back to the
122
+ // 35-day default on an out-of-range value rather than failing the deploy.
123
+ const pitrSetting = config.pointInTimeRecovery ?? this.defaults.pointInTimeRecovery;
124
+ let pitrEnabled;
125
+ let pitrDays;
126
+ if (typeof pitrSetting === 'object' && pitrSetting !== null) {
127
+ pitrEnabled = true;
128
+ pitrDays = pitrSetting.retentionDays;
129
+ if (!Number.isInteger(pitrDays) || pitrDays < 1 || pitrDays > 35) {
130
+ Annotations.of(this).addWarningV2('@aws-blocks/bb-distributed-table:InvalidPitrDays', `pointInTimeRecovery.retentionDays must be an integer between 1 and 35 (got ${String(pitrDays)}) — ` +
131
+ `falling back to the 35-day default.`);
132
+ pitrDays = undefined;
133
+ }
134
+ }
135
+ else {
136
+ pitrEnabled = pitrSetting === true;
137
+ pitrDays = undefined;
138
+ }
139
+ // Resolve durability into the two CDK properties. The `protection` option
140
+ // is the richer per-block override (#282): when set it fully determines
141
+ // removal policy + deletion protection, and — being one knob — the
142
+ // contradictory "protect + destroy" state can't be expressed. When
143
+ // omitted, fall back to the stack-wide `defaults` (BlocksPresets, #302):
144
+ // production → RETAIN + protected, sandbox → DESTROY + unprotected. Read
145
+ // `deletionProtection` independently from `defaults`, never derived.
146
+ let removalPolicy;
147
+ let deletionProtection;
148
+ if (PROTECTION_VALUES.includes(config.protection)) {
149
+ deletionProtection = config.protection === 'locked';
150
+ removalPolicy = config.protection === 'disposable' ? RemovalPolicy.DESTROY : RemovalPolicy.RETAIN;
151
+ }
152
+ else {
153
+ removalPolicy = this.defaults.removalPolicy;
154
+ deletionProtection = this.defaults.deletionProtection;
155
+ }
156
+ // `fromKmsKey(arn)` → encrypt with an existing CMK (shareable across
157
+ // tables). `'customer-managed'` → CDK provisions a fresh dedicated CMK.
158
+ // Otherwise the AWS-managed `aws/dynamodb` key.
159
+ let encryptionKey;
160
+ let encryption;
161
+ if (isKmsKeyRef) {
162
+ encryption = TableEncryption.CUSTOMER_MANAGED;
163
+ encryptionKey = Key.fromKeyArn(this, 'encryption-key', config.encryption.keyArn);
164
+ }
165
+ else if (config.encryption === 'customer-managed') {
166
+ encryption = TableEncryption.CUSTOMER_MANAGED;
167
+ }
168
+ else {
169
+ encryption = TableEncryption.AWS_MANAGED;
170
+ }
58
171
  this.table = new Table(this, 'table', {
59
172
  tableName,
60
173
  partitionKey: {
@@ -67,21 +180,35 @@ export class DistributedTable extends Scope {
67
180
  } : undefined,
68
181
  billingMode: BillingMode.PAY_PER_REQUEST,
69
182
  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,
183
+ // PITR spec is only emitted when enabled leaving it undefined keeps
184
+ // the CloudFormation template clean for sandboxes / opt-outs.
185
+ // recoveryPeriodInDays is only set when the caller narrows it (an
186
+ // omitted value keeps DynamoDB's 35-day default without emitting it).
187
+ pointInTimeRecoverySpecification: pitrEnabled
188
+ ? {
189
+ pointInTimeRecoveryEnabled: true,
190
+ ...(pitrDays !== undefined ? { recoveryPeriodInDays: pitrDays } : {}),
191
+ }
192
+ : undefined,
193
+ // Resolved above from `protection` (per-block override) or the
194
+ // stack-wide `defaults` (#302) — supersedes main's placeholder that
195
+ // read `this.defaults` directly.
196
+ deletionProtection,
197
+ removalPolicy,
198
+ encryption,
199
+ // Only set when bringing an existing CMK; `CUSTOMER_MANAGED` without a
200
+ // key lets CDK provision a dedicated one.
201
+ encryptionKey,
75
202
  });
76
- this.table.grantReadWriteData(this.handler);
203
+ this.table.grantReadWriteData(this.executionRole);
77
204
  // Explicit index query permissions
78
- this.handler.addToRolePolicy(new PolicyStatement({
205
+ this.executionRole.addToPrincipalPolicy(new PolicyStatement({
79
206
  actions: ['dynamodb:Query'],
80
207
  resources: [`${this.table.tableArn}/index/*`],
81
208
  }));
82
209
  // Add GSI manager if indexes are defined
83
210
  if (config.indexes && Object.keys(config.indexes).length > 0) {
84
- const gsiProvider = getOrCreateGsiProvider(cdk.Stack.of(this));
211
+ const gsiProvider = getOrCreateGsiProvider(cdk.Stack.of(this), this.defaults.logRetention);
85
212
  gsiProvider.addTableArn(this.table.tableArn, isSandbox);
86
213
  const indexesWithTypes = {};
87
214
  for (const [indexName, indexConfig] of Object.entries(config.indexes)) {
@@ -123,24 +250,35 @@ export class DistributedTable extends Scope {
123
250
  }
124
251
  // ── Shared GSI Manager Provider (one per stack) ─────────────────────────────
125
252
  const GSI_PROVIDER_KEY = Symbol.for('BLOCKS_GSI_MANAGER_PROVIDER');
126
- function getOrCreateGsiProvider(stack) {
253
+ function getOrCreateGsiProvider(stack, logRetention) {
127
254
  const existing = stack[GSI_PROVIDER_KEY];
128
255
  if (existing)
129
256
  return existing;
130
257
  const __dirname = dirname(fileURLToPath(import.meta.url));
131
258
  const tableArns = [];
132
259
  const sandboxTableArns = [];
260
+ // Own the GSI-manager Lambdas' log groups so their retention follows the
261
+ // stack-wide default instead of AWS's infinite retention. Torn down with the
262
+ // stack (logs are not durable state).
133
263
  const gsiManagerLambda = new LambdaFunction(stack, 'BlocksGsiManager', {
134
264
  runtime: DEFAULT_NODE_RUNTIME,
135
265
  handler: 'index.handler',
136
266
  code: Code.fromAsset(join(__dirname, 'gsi-manager-lambda')),
137
267
  timeout: Duration.minutes(15),
268
+ logGroup: new LogGroup(stack, 'BlocksGsiManagerLogs', {
269
+ retention: logRetention,
270
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
271
+ }),
138
272
  });
139
273
  const gsiIsCompleteLambda = new LambdaFunction(stack, 'BlocksGsiIsComplete', {
140
274
  runtime: DEFAULT_NODE_RUNTIME,
141
275
  handler: 'index.isCompleteHandler',
142
276
  code: Code.fromAsset(join(__dirname, 'gsi-manager-lambda')),
143
277
  timeout: Duration.minutes(1),
278
+ logGroup: new LogGroup(stack, 'BlocksGsiIsCompleteLogs', {
279
+ retention: logRetention,
280
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
281
+ }),
144
282
  });
145
283
  // Production permissions — lazily resolved so ARNs accumulate as tables register
146
284
  gsiManagerLambda.addToRolePolicy(new PolicyStatement({