@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.
@@ -14,7 +14,7 @@ import { test } from 'node:test';
14
14
  import assert from 'node:assert';
15
15
  import * as cdk from 'aws-cdk-lib';
16
16
  import type { Construct } from 'constructs';
17
- import { Template } from 'aws-cdk-lib/assertions';
17
+ import { Template, Match, Annotations } from 'aws-cdk-lib/assertions';
18
18
  import { Scope, DEFAULT_NODE_RUNTIME, BlocksPresets, type BlocksDefaults } from '@aws-blocks/core/cdk';
19
19
  import { z } from 'zod';
20
20
  import { DistributedTable } from './index.cdk.js';
@@ -27,16 +27,21 @@ const userSchema = z.object({
27
27
 
28
28
  class StubBlocksStack extends cdk.Stack {
29
29
  public readonly handler: cdk.aws_lambda.Function;
30
+ public readonly executionRole: cdk.aws_iam.IRole;
30
31
  public readonly id: string;
31
32
  public defaults: BlocksDefaults = BlocksPresets.production;
32
33
  constructor(scope: Construct, id: string) {
33
34
  super(scope, id);
34
35
  this.id = id;
35
36
  (globalThis as any).CURRENT_BLOCKS_STACK = this;
37
+ this.executionRole = new cdk.aws_iam.Role(this, 'BlocksRole', {
38
+ assumedBy: new cdk.aws_iam.ServicePrincipal('lambda.amazonaws.com'),
39
+ });
36
40
  this.handler = new cdk.aws_lambda.Function(this, 'StubHandler', {
37
41
  runtime: DEFAULT_NODE_RUNTIME,
38
42
  handler: 'index.handler',
39
43
  code: cdk.aws_lambda.Code.fromInline('exports.handler = async () => {};'),
44
+ role: this.executionRole,
40
45
  });
41
46
  }
42
47
  }
@@ -59,6 +64,244 @@ test('CDK: default DistributedTable provisions a DynamoDB table', () => {
59
64
  template.resourceCountIs('AWS::DynamoDB::Table', 1);
60
65
  });
61
66
 
67
+ // ── Secure-by-default durability & encryption (prod) ────────────────────────
68
+ // Regression for the bug bash finding: prod DDB tables shipped with
69
+ // DeletionProtection off, PITR disabled, and SSE (KMS) unset.
70
+
71
+ test('CDK: prod DistributedTable enables PITR by default', () => {
72
+ const { stack, parent } = setup();
73
+ new DistributedTable(parent, 'users', {
74
+ schema: userSchema,
75
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
76
+ });
77
+ const template = Template.fromStack(stack);
78
+ template.hasResourceProperties('AWS::DynamoDB::Table', {
79
+ PointInTimeRecoverySpecification: { PointInTimeRecoveryEnabled: true },
80
+ });
81
+ });
82
+
83
+ test('CDK: default PITR does not pin a recovery window (DynamoDB 35-day default)', () => {
84
+ const { stack, parent } = setup();
85
+ new DistributedTable(parent, 'users', {
86
+ schema: userSchema,
87
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
88
+ });
89
+ const template = Template.fromStack(stack);
90
+ template.hasResourceProperties('AWS::DynamoDB::Table', {
91
+ PointInTimeRecoverySpecification: {
92
+ PointInTimeRecoveryEnabled: true,
93
+ RecoveryPeriodInDays: Match.absent(),
94
+ },
95
+ });
96
+ });
97
+
98
+ test('CDK: pointInTimeRecovery { retentionDays } enables PITR and pins the window', () => {
99
+ const { stack, parent } = setup();
100
+ new DistributedTable(parent, 'users', {
101
+ schema: userSchema,
102
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
103
+ pointInTimeRecovery: { retentionDays: 7 },
104
+ });
105
+ const template = Template.fromStack(stack);
106
+ template.hasResourceProperties('AWS::DynamoDB::Table', {
107
+ PointInTimeRecoverySpecification: {
108
+ PointInTimeRecoveryEnabled: true,
109
+ RecoveryPeriodInDays: 7,
110
+ },
111
+ });
112
+ });
113
+
114
+ test('CDK: an out-of-range retentionDays falls back to the default window (still enabled)', () => {
115
+ const { stack, parent } = setup();
116
+ new DistributedTable(parent, 'users', {
117
+ schema: userSchema,
118
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
119
+ pointInTimeRecovery: { retentionDays: 60 },
120
+ });
121
+ const template = Template.fromStack(stack);
122
+ template.hasResourceProperties('AWS::DynamoDB::Table', {
123
+ PointInTimeRecoverySpecification: {
124
+ PointInTimeRecoveryEnabled: true,
125
+ RecoveryPeriodInDays: Match.absent(),
126
+ },
127
+ });
128
+ });
129
+
130
+ test('CDK: pointInTimeRecovery: false disables PITR', () => {
131
+ const { stack, parent } = setup();
132
+ new DistributedTable(parent, 'users', {
133
+ schema: userSchema,
134
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
135
+ pointInTimeRecovery: false,
136
+ });
137
+ const template = Template.fromStack(stack);
138
+ template.hasResourceProperties('AWS::DynamoDB::Table', {
139
+ PointInTimeRecoverySpecification: Match.absent(),
140
+ });
141
+ });
142
+
143
+ test('CDK: prod DistributedTable enables DeletionProtection by default', () => {
144
+ const { stack, parent } = setup();
145
+ new DistributedTable(parent, 'users', {
146
+ schema: userSchema,
147
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
148
+ });
149
+ const template = Template.fromStack(stack);
150
+ template.hasResourceProperties('AWS::DynamoDB::Table', {
151
+ DeletionProtectionEnabled: true,
152
+ });
153
+ });
154
+
155
+ test('CDK: prod DistributedTable enables SSE (KMS-managed) by default', () => {
156
+ const { stack, parent } = setup();
157
+ new DistributedTable(parent, 'users', {
158
+ schema: userSchema,
159
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
160
+ });
161
+ const template = Template.fromStack(stack);
162
+ // AWS_MANAGED emits SSEEnabled:true with no SSEType (the aws/dynamodb key).
163
+ // Contrast with the AWS-owned default, which emits no SSESpecification at all,
164
+ // and customer-managed, which adds SSEType:'KMS' + a KMSMasterKeyId.
165
+ template.hasResourceProperties('AWS::DynamoDB::Table', {
166
+ SSESpecification: { SSEEnabled: true, SSEType: Match.absent() },
167
+ });
168
+ });
169
+
170
+ test('CDK: prod DistributedTable table is RETAINed on stack delete by default', () => {
171
+ const { stack, parent } = setup();
172
+ new DistributedTable(parent, 'users', {
173
+ schema: userSchema,
174
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
175
+ });
176
+ const template = Template.fromStack(stack);
177
+ template.hasResource('AWS::DynamoDB::Table', {
178
+ DeletionPolicy: 'Retain',
179
+ });
180
+ });
181
+
182
+ // (Sandbox-default behavior — DESTROY, deletion protection off, PITR off — is
183
+ // covered by the "adopts sandbox defaults" / "PITR follows the stack defaults"
184
+ // tests above, which drive it through the stack `defaults` rather than the
185
+ // `sandboxMode` context.)
186
+
187
+ // ── Customer overrides win over the secure defaults ─────────────────────────
188
+
189
+ test('CDK: customer can opt OUT of PITR + protection in prod', () => {
190
+ const { stack, parent } = setup();
191
+ new DistributedTable(parent, 'users', {
192
+ schema: userSchema,
193
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
194
+ pointInTimeRecovery: false,
195
+ protection: 'disposable',
196
+ });
197
+ const template = Template.fromStack(stack);
198
+ template.hasResourceProperties('AWS::DynamoDB::Table', {
199
+ PointInTimeRecoverySpecification: Match.absent(),
200
+ DeletionProtectionEnabled: false,
201
+ });
202
+ template.hasResource('AWS::DynamoDB::Table', { DeletionPolicy: 'Delete' });
203
+ });
204
+
205
+ test('CDK: customer can opt INTO durable/protected tables even under the sandbox preset', () => {
206
+ const { stack, parent } = setup(BlocksPresets.sandbox);
207
+ new DistributedTable(parent, 'users', {
208
+ schema: userSchema,
209
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
210
+ pointInTimeRecovery: true,
211
+ protection: 'locked',
212
+ });
213
+ const template = Template.fromStack(stack);
214
+ template.hasResourceProperties('AWS::DynamoDB::Table', {
215
+ PointInTimeRecoverySpecification: { PointInTimeRecoveryEnabled: true },
216
+ DeletionProtectionEnabled: true,
217
+ });
218
+ template.hasResource('AWS::DynamoDB::Table', { DeletionPolicy: 'Retain' });
219
+ });
220
+
221
+ test("CDK: protection 'retained' orphans the table without locking deletes", () => {
222
+ const { stack, parent } = setup();
223
+ new DistributedTable(parent, 'users', {
224
+ schema: userSchema,
225
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
226
+ protection: 'retained',
227
+ });
228
+ const template = Template.fromStack(stack);
229
+ // RETAIN on stack delete, but deletion protection OFF (a direct delete works).
230
+ template.hasResource('AWS::DynamoDB::Table', { DeletionPolicy: 'Retain' });
231
+ template.hasResourceProperties('AWS::DynamoDB::Table', {
232
+ DeletionProtectionEnabled: false,
233
+ });
234
+ });
235
+
236
+ test('CDK: customer-managed encryption provisions a KMS key', () => {
237
+ const { stack, parent } = setup();
238
+ new DistributedTable(parent, 'users', {
239
+ schema: userSchema,
240
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
241
+ encryption: 'customer-managed',
242
+ });
243
+ const template = Template.fromStack(stack);
244
+ template.resourceCountIs('AWS::KMS::Key', 1);
245
+ template.hasResourceProperties('AWS::DynamoDB::Table', {
246
+ SSESpecification: { SSEEnabled: true, SSEType: 'KMS' },
247
+ });
248
+ });
249
+
250
+ test('CDK: fromKmsKey encrypts with an existing key and provisions no new KMS key', () => {
251
+ const { stack, parent } = setup();
252
+ const keyArn = 'arn:aws:kms:us-east-1:111122223333:key/abcd-1234-ef56';
253
+ new DistributedTable(parent, 'orders', {
254
+ schema: userSchema,
255
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
256
+ encryption: DistributedTable.fromKmsKey(keyArn),
257
+ });
258
+ const template = Template.fromStack(stack);
259
+ // Bringing an existing key must NOT mint a new one (the whole point — a
260
+ // shared key across tables instead of one dedicated key each).
261
+ template.resourceCountIs('AWS::KMS::Key', 0);
262
+ template.hasResourceProperties('AWS::DynamoDB::Table', {
263
+ SSESpecification: { SSEEnabled: true, SSEType: 'KMS', KMSMasterKeyId: keyArn },
264
+ });
265
+ });
266
+
267
+ test('CDK: two tables sharing one fromKmsKey ref provision zero KMS keys', () => {
268
+ const { stack, parent } = setup();
269
+ const sharedKey = DistributedTable.fromKmsKey('arn:aws:kms:us-east-1:111122223333:key/shared-1');
270
+ new DistributedTable(parent, 'orders', {
271
+ schema: userSchema,
272
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
273
+ encryption: sharedKey,
274
+ });
275
+ new DistributedTable(parent, 'events', {
276
+ schema: userSchema,
277
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
278
+ encryption: sharedKey,
279
+ });
280
+ const template = Template.fromStack(stack);
281
+ template.resourceCountIs('AWS::KMS::Key', 0);
282
+ template.resourceCountIs('AWS::DynamoDB::Table', 2);
283
+ });
284
+
285
+ test('CDK: fromExisting ignores durability props (customer owns the table)', () => {
286
+ const { stack, parent } = setup();
287
+ new DistributedTable(parent, 'users', {
288
+ schema: userSchema,
289
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
290
+ table: DistributedTable.fromExisting('preexisting-users-table'),
291
+ // These must be inert when wrapping an existing table.
292
+ pointInTimeRecovery: true,
293
+ protection: 'locked',
294
+ encryption: 'customer-managed',
295
+ });
296
+ const template = Template.fromStack(stack);
297
+ // No table is provisioned, and customer-managed encryption does NOT
298
+ // provision a CMK — proving the durability options are ignored.
299
+ template.resourceCountIs('AWS::DynamoDB::Table', 0);
300
+ template.resourceCountIs('AWS::KMS::Key', 0);
301
+ });
302
+
303
+ // ── Durability follows the stack `defaults` when no per-block override (#302) ──
304
+
62
305
  test('CDK: table adopts sandbox defaults (DESTROY, deletion protection off)', () => {
63
306
  const { stack, parent } = setup(BlocksPresets.sandbox);
64
307
  new DistributedTable(parent, 'users', {
@@ -81,6 +324,45 @@ test('CDK: table adopts production defaults (RETAIN, deletion protection on)', (
81
324
  template.hasResourceProperties('AWS::DynamoDB::Table', { DeletionProtectionEnabled: true });
82
325
  });
83
326
 
327
+ test('CDK: PITR follows the stack defaults — off under the sandbox preset', () => {
328
+ const { stack, parent } = setup(BlocksPresets.sandbox);
329
+ new DistributedTable(parent, 'users', {
330
+ schema: userSchema,
331
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
332
+ });
333
+ const template = Template.fromStack(stack);
334
+ template.hasResourceProperties('AWS::DynamoDB::Table', {
335
+ PointInTimeRecoverySpecification: Match.absent(),
336
+ });
337
+ });
338
+
339
+ test('CDK: options.pointInTimeRecovery overrides the stack defaults (on under sandbox)', () => {
340
+ const { stack, parent } = setup(BlocksPresets.sandbox);
341
+ new DistributedTable(parent, 'users', {
342
+ schema: userSchema,
343
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
344
+ pointInTimeRecovery: true,
345
+ });
346
+ const template = Template.fromStack(stack);
347
+ template.hasResourceProperties('AWS::DynamoDB::Table', {
348
+ PointInTimeRecoverySpecification: { PointInTimeRecoveryEnabled: true },
349
+ });
350
+ });
351
+
352
+ test('CDK: a per-block protection option overrides the stack defaults', () => {
353
+ // Sandbox preset would give DESTROY + no protection; `protection: 'locked'`
354
+ // must win, proving the per-block override sits on top of the #302 defaults.
355
+ const { stack, parent } = setup(BlocksPresets.sandbox);
356
+ new DistributedTable(parent, 'users', {
357
+ schema: userSchema,
358
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
359
+ protection: 'locked',
360
+ });
361
+ const template = Template.fromStack(stack);
362
+ template.hasResource('AWS::DynamoDB::Table', { DeletionPolicy: 'Retain' });
363
+ template.hasResourceProperties('AWS::DynamoDB::Table', { DeletionProtectionEnabled: true });
364
+ });
365
+
84
366
  test('CDK: DistributedTable.fromExisting does NOT provision a table (regression)', () => {
85
367
  const { stack, parent } = setup();
86
368
  new DistributedTable(parent, 'users', {
@@ -129,3 +411,47 @@ test('CDK: calling a runtime data method throws an actionable error (not a crypt
129
411
  );
130
412
  }
131
413
  });
414
+
415
+ // ── Synth-time warnings actually fire (not just the fallback values) ─────────
416
+
417
+ test('CDK: an unrecognized protection value warns at synth', () => {
418
+ const { stack, parent } = setup();
419
+ new DistributedTable(parent, 'users', {
420
+ schema: userSchema,
421
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
422
+ protection: 'retian', // intentional typo — exercise the synth guard
423
+ });
424
+ Annotations.fromStack(stack).hasWarning('*', Match.stringLikeRegexp('Unrecognized protection'));
425
+ });
426
+
427
+ test('CDK: an unrecognized encryption value warns at synth', () => {
428
+ const { stack, parent } = setup();
429
+ new DistributedTable(parent, 'users', {
430
+ schema: userSchema,
431
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
432
+ encryption: 'kms', // intentional typo — exercise the synth guard
433
+ });
434
+ Annotations.fromStack(stack).hasWarning('*', Match.stringLikeRegexp('Unrecognized encryption'));
435
+ });
436
+
437
+ test('CDK: an out-of-range retentionDays warns at synth', () => {
438
+ const { stack, parent } = setup();
439
+ new DistributedTable(parent, 'users', {
440
+ schema: userSchema,
441
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
442
+ pointInTimeRecovery: { retentionDays: 60 },
443
+ });
444
+ Annotations.fromStack(stack).hasWarning('*', Match.stringLikeRegexp('retentionDays must be an integer'));
445
+ });
446
+
447
+ test('CDK: durability options passed alongside fromExisting warn at synth', () => {
448
+ const { stack, parent } = setup();
449
+ new DistributedTable(parent, 'users', {
450
+ schema: userSchema,
451
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
452
+ table: DistributedTable.fromExisting('preexisting-users-table'),
453
+ protection: 'locked',
454
+ pointInTimeRecovery: { retentionDays: 14 },
455
+ });
456
+ Annotations.fromStack(stack).hasWarning('*', Match.stringLikeRegexp('wrapped via fromExisting'));
457
+ });
package/src/index.cdk.ts CHANGED
@@ -2,20 +2,21 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import { Construct } from 'constructs';
5
- import { Table, type ITable, AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
5
+ import { Table, type ITable, AttributeType, BillingMode, TableEncryption } from 'aws-cdk-lib/aws-dynamodb';
6
6
  import * as cdk from 'aws-cdk-lib';
7
- import { CustomResource, Duration } from 'aws-cdk-lib';
7
+ import { Annotations, CustomResource, Duration, RemovalPolicy } from 'aws-cdk-lib';
8
8
  import { Code, Function as LambdaFunction } from 'aws-cdk-lib/aws-lambda';
9
9
  import { Provider } from 'aws-cdk-lib/custom-resources';
10
10
  import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
11
+ import { Key, type IKey } from 'aws-cdk-lib/aws-kms';
11
12
  import { Scope, synthGuard, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
12
13
  import type { ScopeParent } from '@aws-blocks/core';
13
- import type { ExternalTableRef } from './types.js';
14
+ import type { ExternalTableRef, ExternalKmsKeyRef } from './types.js';
14
15
  import { fileURLToPath } from 'node:url';
15
16
  import { dirname, join } from 'node:path';
16
17
 
17
18
  export { DistributedTableErrors } from './errors.js';
18
- export type { DistributedTableOptions, ReadValidationMode, TableKeyConfig, TableKey, PutOptions, DeleteOptions, QueryOptions, ScanOptions, ExternalTableRef } from './types.js';
19
+ export type { DistributedTableOptions, ReadValidationMode, TableKeyConfig, TableKey, PutOptions, DeleteOptions, QueryOptions, ScanOptions, ExternalTableRef, ExternalKmsKeyRef } from './types.js';
19
20
 
20
21
  export class DistributedTable<T = any> extends Scope {
21
22
  private table: ITable;
@@ -31,6 +32,19 @@ export class DistributedTable<T = any> extends Scope {
31
32
  return { __brand: 'ExternalTableRef' as const, tableName };
32
33
  }
33
34
 
35
+ /**
36
+ * Reference an existing customer-managed KMS key to encrypt the table,
37
+ * instead of letting `encryption: 'customer-managed'` provision a dedicated
38
+ * key per table. Pass the result as the `encryption` option so several
39
+ * tables can share one key (and one monthly charge).
40
+ *
41
+ * @param keyArn - ARN of a KMS key you already own. The deploying principal
42
+ * and the DynamoDB service must have the usual grants on it.
43
+ */
44
+ static fromKmsKey(keyArn: string): ExternalKmsKeyRef {
45
+ return { __brand: 'ExternalKmsKeyRef' as const, keyArn };
46
+ }
47
+
34
48
  constructor(scope: ScopeParent, id: string, public options: any) {
35
49
  super(id, { parent: scope });
36
50
 
@@ -41,9 +55,23 @@ export class DistributedTable<T = any> extends Scope {
41
55
  // and grant the runtime Lambda read/write + index query access.
42
56
  // We deliberately skip the GSI custom resource — the customer owns the
43
57
  // table's index lifecycle when they bring their own.
58
+ //
59
+ // Durability/encryption options don't apply to an existing table (we
60
+ // never emit a `Table` resource to attach them to). Surface that at
61
+ // synth so a `protection: 'locked'` on what looks like a fresh
62
+ // table isn't a silent no-op.
63
+ const ignoredForExisting = (['pointInTimeRecovery', 'protection', 'encryption'] as const)
64
+ .filter((key) => config[key] !== undefined);
65
+ if (ignoredForExisting.length > 0) {
66
+ Annotations.of(this).addWarningV2(
67
+ '@aws-blocks/bb-distributed-table:IgnoredOptionsForExistingTable',
68
+ `Ignoring ${ignoredForExisting.join(', ')} because this table is wrapped via fromExisting() — ` +
69
+ `the existing table owns its own durability/encryption configuration.`,
70
+ );
71
+ }
44
72
  this.table = Table.fromTableName(this, 'table', config.table.tableName);
45
- this.table.grantReadWriteData(this.handler);
46
- this.handler.addToRolePolicy(new PolicyStatement({
73
+ this.table.grantReadWriteData(this.executionRole);
74
+ this.executionRole.addToPrincipalPolicy(new PolicyStatement({
47
75
  actions: ['dynamodb:Query'],
48
76
  resources: [`${this.table.tableArn}/index/*`],
49
77
  }));
@@ -71,6 +99,103 @@ export class DistributedTable<T = any> extends Scope {
71
99
  const getDdbType = (fieldName: string): AttributeType =>
72
100
  isNumericField(fieldName) ? AttributeType.NUMBER : AttributeType.STRING;
73
101
 
102
+ // Secure-by-default durability & encryption.
103
+ // The posture — removal policy, deletion protection, PITR — comes from the
104
+ // stack-wide `defaults` (BlocksPresets, #302): production retains + protects
105
+ // + backs up; sandbox is disposable so `sandbox:destroy` stays a one-command
106
+ // teardown and throwaway stacks don't accrue backup cost. Blocks read
107
+ // `this.defaults` rather than the `sandboxMode` context (and rather than the
108
+ // stack-level SandboxDisableDeletionProtection mixin, which can't reach the
109
+ // DynamoDB L2 `deletionProtection` prop — the reason that construct-level
110
+ // mechanism exists). A per-block `protection`/`pointInTimeRecovery`/
111
+ // `encryption` option always wins.
112
+ //
113
+ // `protection` is a single knob (disposable | retained | locked) spanning
114
+ // removal policy + deletion protection, so the contradictory
115
+ // "protect + destroy" state can't be expressed. `options` is typed `any`
116
+ // here, so guard against an unrecognized string (typo) rather than
117
+ // silently falling through to the stack default.
118
+ const PROTECTION_VALUES = ['disposable', 'retained', 'locked'] as const;
119
+ if (config.protection !== undefined && !PROTECTION_VALUES.includes(config.protection)) {
120
+ Annotations.of(this).addWarningV2(
121
+ '@aws-blocks/bb-distributed-table:UnknownProtection',
122
+ `Unrecognized protection '${String(config.protection)}' (expected 'disposable', 'retained', ` +
123
+ `or 'locked') — falling back to the stack defaults.`,
124
+ );
125
+ }
126
+ // `encryption` accepts two string literals or an ExternalKmsKeyRef
127
+ // (a `{ __brand: 'ExternalKmsKeyRef', keyArn }` from `fromKmsKey`).
128
+ // Anything else is a typo — warn rather than silently using the default.
129
+ const isKmsKeyRef = typeof config.encryption === 'object'
130
+ && config.encryption !== null
131
+ && config.encryption.__brand === 'ExternalKmsKeyRef';
132
+ if (
133
+ config.encryption !== undefined
134
+ && config.encryption !== 'aws-managed'
135
+ && config.encryption !== 'customer-managed'
136
+ && !isKmsKeyRef
137
+ ) {
138
+ Annotations.of(this).addWarningV2(
139
+ '@aws-blocks/bb-distributed-table:UnknownEncryption',
140
+ `Unrecognized encryption '${String(config.encryption)}' (expected 'aws-managed', ` +
141
+ `'customer-managed', or DistributedTable.fromKmsKey(arn)) — falling back to 'aws-managed'.`,
142
+ );
143
+ }
144
+
145
+ // PITR is one knob (`boolean | { retentionDays }`) resolved from the
146
+ // per-block option, else the stack-wide `defaults.pointInTimeRecovery`
147
+ // (#302 follow-up) — production on, sandbox off. The object form both
148
+ // enables PITR and pins the window, so "days set but PITR off" can't be
149
+ // expressed. `retentionDays` must be 1–35; warn and drop back to the
150
+ // 35-day default on an out-of-range value rather than failing the deploy.
151
+ const pitrSetting = config.pointInTimeRecovery ?? this.defaults.pointInTimeRecovery;
152
+ let pitrEnabled: boolean;
153
+ let pitrDays: number | undefined;
154
+ if (typeof pitrSetting === 'object' && pitrSetting !== null) {
155
+ pitrEnabled = true;
156
+ pitrDays = pitrSetting.retentionDays;
157
+ if (!Number.isInteger(pitrDays) || (pitrDays as number) < 1 || (pitrDays as number) > 35) {
158
+ Annotations.of(this).addWarningV2(
159
+ '@aws-blocks/bb-distributed-table:InvalidPitrDays',
160
+ `pointInTimeRecovery.retentionDays must be an integer between 1 and 35 (got ${String(pitrDays)}) — ` +
161
+ `falling back to the 35-day default.`,
162
+ );
163
+ pitrDays = undefined;
164
+ }
165
+ } else {
166
+ pitrEnabled = pitrSetting === true;
167
+ pitrDays = undefined;
168
+ }
169
+ // Resolve durability into the two CDK properties. The `protection` option
170
+ // is the richer per-block override (#282): when set it fully determines
171
+ // removal policy + deletion protection, and — being one knob — the
172
+ // contradictory "protect + destroy" state can't be expressed. When
173
+ // omitted, fall back to the stack-wide `defaults` (BlocksPresets, #302):
174
+ // production → RETAIN + protected, sandbox → DESTROY + unprotected. Read
175
+ // `deletionProtection` independently from `defaults`, never derived.
176
+ let removalPolicy: RemovalPolicy;
177
+ let deletionProtection: boolean;
178
+ if (PROTECTION_VALUES.includes(config.protection)) {
179
+ deletionProtection = config.protection === 'locked';
180
+ removalPolicy = config.protection === 'disposable' ? RemovalPolicy.DESTROY : RemovalPolicy.RETAIN;
181
+ } else {
182
+ removalPolicy = this.defaults.removalPolicy;
183
+ deletionProtection = this.defaults.deletionProtection;
184
+ }
185
+ // `fromKmsKey(arn)` → encrypt with an existing CMK (shareable across
186
+ // tables). `'customer-managed'` → CDK provisions a fresh dedicated CMK.
187
+ // Otherwise the AWS-managed `aws/dynamodb` key.
188
+ let encryptionKey: IKey | undefined;
189
+ let encryption: TableEncryption;
190
+ if (isKmsKeyRef) {
191
+ encryption = TableEncryption.CUSTOMER_MANAGED;
192
+ encryptionKey = Key.fromKeyArn(this, 'encryption-key', config.encryption.keyArn);
193
+ } else if (config.encryption === 'customer-managed') {
194
+ encryption = TableEncryption.CUSTOMER_MANAGED;
195
+ } else {
196
+ encryption = TableEncryption.AWS_MANAGED;
197
+ }
198
+
74
199
  this.table = new Table(this, 'table', {
75
200
  tableName,
76
201
  partitionKey: {
@@ -83,17 +208,31 @@ export class DistributedTable<T = any> extends Scope {
83
208
  } : undefined,
84
209
  billingMode: BillingMode.PAY_PER_REQUEST,
85
210
  timeToLiveAttribute: config.ttl || undefined,
86
- // Durability follows the stack-wide `defaults` (sandbox DESTROY +
87
- // no protection so a teardown is clean; production RETAIN + protected).
88
- // A richer per-block override lands with the `protection` option in #282.
89
- removalPolicy: this.defaults.removalPolicy,
90
- deletionProtection: this.defaults.deletionProtection,
211
+ // PITR spec is only emitted when enabled leaving it undefined keeps
212
+ // the CloudFormation template clean for sandboxes / opt-outs.
213
+ // recoveryPeriodInDays is only set when the caller narrows it (an
214
+ // omitted value keeps DynamoDB's 35-day default without emitting it).
215
+ pointInTimeRecoverySpecification: pitrEnabled
216
+ ? {
217
+ pointInTimeRecoveryEnabled: true,
218
+ ...(pitrDays !== undefined ? { recoveryPeriodInDays: pitrDays } : {}),
219
+ }
220
+ : undefined,
221
+ // Resolved above from `protection` (per-block override) or the
222
+ // stack-wide `defaults` (#302) — supersedes main's placeholder that
223
+ // read `this.defaults` directly.
224
+ deletionProtection,
225
+ removalPolicy,
226
+ encryption,
227
+ // Only set when bringing an existing CMK; `CUSTOMER_MANAGED` without a
228
+ // key lets CDK provision a dedicated one.
229
+ encryptionKey,
91
230
  });
92
231
 
93
- this.table.grantReadWriteData(this.handler);
232
+ this.table.grantReadWriteData(this.executionRole);
94
233
 
95
234
  // Explicit index query permissions
96
- this.handler.addToRolePolicy(new PolicyStatement({
235
+ this.executionRole.addToPrincipalPolicy(new PolicyStatement({
97
236
  actions: ['dynamodb:Query'],
98
237
  resources: [`${this.table.tableArn}/index/*`],
99
238
  }));
package/src/index.mock.ts CHANGED
@@ -15,6 +15,7 @@ export type {
15
15
  DistributedTableOptions,
16
16
  ReadValidationMode,
17
17
  ExternalTableRef,
18
+ ExternalKmsKeyRef,
18
19
  TableKey,
19
20
  PartitionKeyCondition,
20
21
  SortKeyCondition,
@@ -29,6 +30,7 @@ import type {
29
30
  TableKeyConfig,
30
31
  DistributedTableOptions,
31
32
  ExternalTableRef,
33
+ ExternalKmsKeyRef,
32
34
  SortKeyCondition,
33
35
  ScanOptions,
34
36
  PutOptions,
@@ -334,6 +336,10 @@ export class DistributedTable<
334
336
  return { __brand: 'ExternalTableRef' as const, tableName };
335
337
  }
336
338
 
339
+ static fromKmsKey(keyArn: string): ExternalKmsKeyRef {
340
+ return { __brand: 'ExternalKmsKeyRef' as const, keyArn };
341
+ }
342
+
337
343
  // ── Internal ────────────────────────────────────────────────────────────
338
344
 
339
345
  private checkFieldEquals(keyStr: string, fields: Partial<T>): void {