@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/src/index.aws.ts CHANGED
@@ -23,6 +23,7 @@ export type {
23
23
  DistributedTableOptions,
24
24
  ReadValidationMode,
25
25
  ExternalTableRef,
26
+ ExternalKmsKeyRef,
26
27
  TableKey,
27
28
  PartitionKeyCondition,
28
29
  SortKeyCondition,
@@ -37,6 +38,7 @@ import type {
37
38
  TableKeyConfig,
38
39
  DistributedTableOptions,
39
40
  ExternalTableRef,
41
+ ExternalKmsKeyRef,
40
42
  ScanOptions,
41
43
  PutOptions,
42
44
  DeleteOptions,
@@ -280,6 +282,10 @@ export class DistributedTable<
280
282
  return { __brand: 'ExternalTableRef' as const, tableName };
281
283
  }
282
284
 
285
+ static fromKmsKey(keyArn: string): ExternalKmsKeyRef {
286
+ return { __brand: 'ExternalKmsKeyRef' as const, keyArn };
287
+ }
288
+
283
289
  // ── Internal ────────────────────────────────────────────────────────────
284
290
 
285
291
  private async validateItem(item: T): Promise<void> {
@@ -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,59 @@ 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
+
366
+ test('CDK: GSI manager Lambda log groups adopt defaults.logRetention', () => {
367
+ const { stack, parent } = setup(BlocksPresets.sandbox);
368
+ new DistributedTable(parent, 'users', {
369
+ schema: userSchema,
370
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
371
+ indexes: { byEmail: { partitionKey: 'email' } },
372
+ });
373
+ const template = Template.fromStack(stack);
374
+ // The GSI manager + isComplete Lambdas now own explicit log groups whose
375
+ // retention follows the stack-wide default (sandbox → one week), instead of
376
+ // AWS's infinite default.
377
+ template.hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 7 });
378
+ });
379
+
84
380
  test('CDK: DistributedTable.fromExisting does NOT provision a table (regression)', () => {
85
381
  const { stack, parent } = setup();
86
382
  new DistributedTable(parent, 'users', {
@@ -129,3 +425,47 @@ test('CDK: calling a runtime data method throws an actionable error (not a crypt
129
425
  );
130
426
  }
131
427
  });
428
+
429
+ // ── Synth-time warnings actually fire (not just the fallback values) ─────────
430
+
431
+ test('CDK: an unrecognized protection value warns at synth', () => {
432
+ const { stack, parent } = setup();
433
+ new DistributedTable(parent, 'users', {
434
+ schema: userSchema,
435
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
436
+ protection: 'retian', // intentional typo — exercise the synth guard
437
+ });
438
+ Annotations.fromStack(stack).hasWarning('*', Match.stringLikeRegexp('Unrecognized protection'));
439
+ });
440
+
441
+ test('CDK: an unrecognized encryption value warns at synth', () => {
442
+ const { stack, parent } = setup();
443
+ new DistributedTable(parent, 'users', {
444
+ schema: userSchema,
445
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
446
+ encryption: 'kms', // intentional typo — exercise the synth guard
447
+ });
448
+ Annotations.fromStack(stack).hasWarning('*', Match.stringLikeRegexp('Unrecognized encryption'));
449
+ });
450
+
451
+ test('CDK: an out-of-range retentionDays warns at synth', () => {
452
+ const { stack, parent } = setup();
453
+ new DistributedTable(parent, 'users', {
454
+ schema: userSchema,
455
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
456
+ pointInTimeRecovery: { retentionDays: 60 },
457
+ });
458
+ Annotations.fromStack(stack).hasWarning('*', Match.stringLikeRegexp('retentionDays must be an integer'));
459
+ });
460
+
461
+ test('CDK: durability options passed alongside fromExisting warn at synth', () => {
462
+ const { stack, parent } = setup();
463
+ new DistributedTable(parent, 'users', {
464
+ schema: userSchema,
465
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
466
+ table: DistributedTable.fromExisting('preexisting-users-table'),
467
+ protection: 'locked',
468
+ pointInTimeRecovery: { retentionDays: 14 },
469
+ });
470
+ Annotations.fromStack(stack).hasWarning('*', Match.stringLikeRegexp('wrapped via fromExisting'));
471
+ });
package/src/index.cdk.ts CHANGED
@@ -2,20 +2,22 @@
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
+ import { LogGroup, type RetentionDays } from 'aws-cdk-lib/aws-logs';
9
10
  import { Provider } from 'aws-cdk-lib/custom-resources';
10
11
  import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
12
+ import { Key, type IKey } from 'aws-cdk-lib/aws-kms';
11
13
  import { Scope, synthGuard, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
12
14
  import type { ScopeParent } from '@aws-blocks/core';
13
- import type { ExternalTableRef } from './types.js';
15
+ import type { ExternalTableRef, ExternalKmsKeyRef } from './types.js';
14
16
  import { fileURLToPath } from 'node:url';
15
17
  import { dirname, join } from 'node:path';
16
18
 
17
19
  export { DistributedTableErrors } from './errors.js';
18
- export type { DistributedTableOptions, ReadValidationMode, TableKeyConfig, TableKey, PutOptions, DeleteOptions, QueryOptions, ScanOptions, ExternalTableRef } from './types.js';
20
+ export type { DistributedTableOptions, ReadValidationMode, TableKeyConfig, TableKey, PutOptions, DeleteOptions, QueryOptions, ScanOptions, ExternalTableRef, ExternalKmsKeyRef } from './types.js';
19
21
 
20
22
  export class DistributedTable<T = any> extends Scope {
21
23
  private table: ITable;
@@ -31,6 +33,19 @@ export class DistributedTable<T = any> extends Scope {
31
33
  return { __brand: 'ExternalTableRef' as const, tableName };
32
34
  }
33
35
 
36
+ /**
37
+ * Reference an existing customer-managed KMS key to encrypt the table,
38
+ * instead of letting `encryption: 'customer-managed'` provision a dedicated
39
+ * key per table. Pass the result as the `encryption` option so several
40
+ * tables can share one key (and one monthly charge).
41
+ *
42
+ * @param keyArn - ARN of a KMS key you already own. The deploying principal
43
+ * and the DynamoDB service must have the usual grants on it.
44
+ */
45
+ static fromKmsKey(keyArn: string): ExternalKmsKeyRef {
46
+ return { __brand: 'ExternalKmsKeyRef' as const, keyArn };
47
+ }
48
+
34
49
  constructor(scope: ScopeParent, id: string, public options: any) {
35
50
  super(id, { parent: scope });
36
51
 
@@ -41,9 +56,23 @@ export class DistributedTable<T = any> extends Scope {
41
56
  // and grant the runtime Lambda read/write + index query access.
42
57
  // We deliberately skip the GSI custom resource — the customer owns the
43
58
  // table's index lifecycle when they bring their own.
59
+ //
60
+ // Durability/encryption options don't apply to an existing table (we
61
+ // never emit a `Table` resource to attach them to). Surface that at
62
+ // synth so a `protection: 'locked'` on what looks like a fresh
63
+ // table isn't a silent no-op.
64
+ const ignoredForExisting = (['pointInTimeRecovery', 'protection', 'encryption'] as const)
65
+ .filter((key) => config[key] !== undefined);
66
+ if (ignoredForExisting.length > 0) {
67
+ Annotations.of(this).addWarningV2(
68
+ '@aws-blocks/bb-distributed-table:IgnoredOptionsForExistingTable',
69
+ `Ignoring ${ignoredForExisting.join(', ')} because this table is wrapped via fromExisting() — ` +
70
+ `the existing table owns its own durability/encryption configuration.`,
71
+ );
72
+ }
44
73
  this.table = Table.fromTableName(this, 'table', config.table.tableName);
45
- this.table.grantReadWriteData(this.handler);
46
- this.handler.addToRolePolicy(new PolicyStatement({
74
+ this.table.grantReadWriteData(this.executionRole);
75
+ this.executionRole.addToPrincipalPolicy(new PolicyStatement({
47
76
  actions: ['dynamodb:Query'],
48
77
  resources: [`${this.table.tableArn}/index/*`],
49
78
  }));
@@ -71,6 +100,103 @@ export class DistributedTable<T = any> extends Scope {
71
100
  const getDdbType = (fieldName: string): AttributeType =>
72
101
  isNumericField(fieldName) ? AttributeType.NUMBER : AttributeType.STRING;
73
102
 
103
+ // Secure-by-default durability & encryption.
104
+ // The posture — removal policy, deletion protection, PITR — comes from the
105
+ // stack-wide `defaults` (BlocksPresets, #302): production retains + protects
106
+ // + backs up; sandbox is disposable so `sandbox:destroy` stays a one-command
107
+ // teardown and throwaway stacks don't accrue backup cost. Blocks read
108
+ // `this.defaults` rather than the `sandboxMode` context (and rather than the
109
+ // stack-level SandboxDisableDeletionProtection mixin, which can't reach the
110
+ // DynamoDB L2 `deletionProtection` prop — the reason that construct-level
111
+ // mechanism exists). A per-block `protection`/`pointInTimeRecovery`/
112
+ // `encryption` option always wins.
113
+ //
114
+ // `protection` is a single knob (disposable | retained | locked) spanning
115
+ // removal policy + deletion protection, so the contradictory
116
+ // "protect + destroy" state can't be expressed. `options` is typed `any`
117
+ // here, so guard against an unrecognized string (typo) rather than
118
+ // silently falling through to the stack default.
119
+ const PROTECTION_VALUES = ['disposable', 'retained', 'locked'] as const;
120
+ if (config.protection !== undefined && !PROTECTION_VALUES.includes(config.protection)) {
121
+ Annotations.of(this).addWarningV2(
122
+ '@aws-blocks/bb-distributed-table:UnknownProtection',
123
+ `Unrecognized protection '${String(config.protection)}' (expected 'disposable', 'retained', ` +
124
+ `or 'locked') — falling back to the stack defaults.`,
125
+ );
126
+ }
127
+ // `encryption` accepts two string literals or an ExternalKmsKeyRef
128
+ // (a `{ __brand: 'ExternalKmsKeyRef', keyArn }` from `fromKmsKey`).
129
+ // Anything else is a typo — warn rather than silently using the default.
130
+ const isKmsKeyRef = typeof config.encryption === 'object'
131
+ && config.encryption !== null
132
+ && config.encryption.__brand === 'ExternalKmsKeyRef';
133
+ if (
134
+ config.encryption !== undefined
135
+ && config.encryption !== 'aws-managed'
136
+ && config.encryption !== 'customer-managed'
137
+ && !isKmsKeyRef
138
+ ) {
139
+ Annotations.of(this).addWarningV2(
140
+ '@aws-blocks/bb-distributed-table:UnknownEncryption',
141
+ `Unrecognized encryption '${String(config.encryption)}' (expected 'aws-managed', ` +
142
+ `'customer-managed', or DistributedTable.fromKmsKey(arn)) — falling back to 'aws-managed'.`,
143
+ );
144
+ }
145
+
146
+ // PITR is one knob (`boolean | { retentionDays }`) resolved from the
147
+ // per-block option, else the stack-wide `defaults.pointInTimeRecovery`
148
+ // (#302 follow-up) — production on, sandbox off. The object form both
149
+ // enables PITR and pins the window, so "days set but PITR off" can't be
150
+ // expressed. `retentionDays` must be 1–35; warn and drop back to the
151
+ // 35-day default on an out-of-range value rather than failing the deploy.
152
+ const pitrSetting = config.pointInTimeRecovery ?? this.defaults.pointInTimeRecovery;
153
+ let pitrEnabled: boolean;
154
+ let pitrDays: number | undefined;
155
+ if (typeof pitrSetting === 'object' && pitrSetting !== null) {
156
+ pitrEnabled = true;
157
+ pitrDays = pitrSetting.retentionDays;
158
+ if (!Number.isInteger(pitrDays) || (pitrDays as number) < 1 || (pitrDays as number) > 35) {
159
+ Annotations.of(this).addWarningV2(
160
+ '@aws-blocks/bb-distributed-table:InvalidPitrDays',
161
+ `pointInTimeRecovery.retentionDays must be an integer between 1 and 35 (got ${String(pitrDays)}) — ` +
162
+ `falling back to the 35-day default.`,
163
+ );
164
+ pitrDays = undefined;
165
+ }
166
+ } else {
167
+ pitrEnabled = pitrSetting === true;
168
+ pitrDays = undefined;
169
+ }
170
+ // Resolve durability into the two CDK properties. The `protection` option
171
+ // is the richer per-block override (#282): when set it fully determines
172
+ // removal policy + deletion protection, and — being one knob — the
173
+ // contradictory "protect + destroy" state can't be expressed. When
174
+ // omitted, fall back to the stack-wide `defaults` (BlocksPresets, #302):
175
+ // production → RETAIN + protected, sandbox → DESTROY + unprotected. Read
176
+ // `deletionProtection` independently from `defaults`, never derived.
177
+ let removalPolicy: RemovalPolicy;
178
+ let deletionProtection: boolean;
179
+ if (PROTECTION_VALUES.includes(config.protection)) {
180
+ deletionProtection = config.protection === 'locked';
181
+ removalPolicy = config.protection === 'disposable' ? RemovalPolicy.DESTROY : RemovalPolicy.RETAIN;
182
+ } else {
183
+ removalPolicy = this.defaults.removalPolicy;
184
+ deletionProtection = this.defaults.deletionProtection;
185
+ }
186
+ // `fromKmsKey(arn)` → encrypt with an existing CMK (shareable across
187
+ // tables). `'customer-managed'` → CDK provisions a fresh dedicated CMK.
188
+ // Otherwise the AWS-managed `aws/dynamodb` key.
189
+ let encryptionKey: IKey | undefined;
190
+ let encryption: TableEncryption;
191
+ if (isKmsKeyRef) {
192
+ encryption = TableEncryption.CUSTOMER_MANAGED;
193
+ encryptionKey = Key.fromKeyArn(this, 'encryption-key', config.encryption.keyArn);
194
+ } else if (config.encryption === 'customer-managed') {
195
+ encryption = TableEncryption.CUSTOMER_MANAGED;
196
+ } else {
197
+ encryption = TableEncryption.AWS_MANAGED;
198
+ }
199
+
74
200
  this.table = new Table(this, 'table', {
75
201
  tableName,
76
202
  partitionKey: {
@@ -83,24 +209,38 @@ export class DistributedTable<T = any> extends Scope {
83
209
  } : undefined,
84
210
  billingMode: BillingMode.PAY_PER_REQUEST,
85
211
  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,
212
+ // PITR spec is only emitted when enabled leaving it undefined keeps
213
+ // the CloudFormation template clean for sandboxes / opt-outs.
214
+ // recoveryPeriodInDays is only set when the caller narrows it (an
215
+ // omitted value keeps DynamoDB's 35-day default without emitting it).
216
+ pointInTimeRecoverySpecification: pitrEnabled
217
+ ? {
218
+ pointInTimeRecoveryEnabled: true,
219
+ ...(pitrDays !== undefined ? { recoveryPeriodInDays: pitrDays } : {}),
220
+ }
221
+ : undefined,
222
+ // Resolved above from `protection` (per-block override) or the
223
+ // stack-wide `defaults` (#302) — supersedes main's placeholder that
224
+ // read `this.defaults` directly.
225
+ deletionProtection,
226
+ removalPolicy,
227
+ encryption,
228
+ // Only set when bringing an existing CMK; `CUSTOMER_MANAGED` without a
229
+ // key lets CDK provision a dedicated one.
230
+ encryptionKey,
91
231
  });
92
232
 
93
- this.table.grantReadWriteData(this.handler);
233
+ this.table.grantReadWriteData(this.executionRole);
94
234
 
95
235
  // Explicit index query permissions
96
- this.handler.addToRolePolicy(new PolicyStatement({
236
+ this.executionRole.addToPrincipalPolicy(new PolicyStatement({
97
237
  actions: ['dynamodb:Query'],
98
238
  resources: [`${this.table.tableArn}/index/*`],
99
239
  }));
100
240
 
101
241
  // Add GSI manager if indexes are defined
102
242
  if (config.indexes && Object.keys(config.indexes).length > 0) {
103
- const gsiProvider = getOrCreateGsiProvider(cdk.Stack.of(this));
243
+ const gsiProvider = getOrCreateGsiProvider(cdk.Stack.of(this), this.defaults.logRetention);
104
244
  gsiProvider.addTableArn(this.table.tableArn, isSandbox);
105
245
 
106
246
  const indexesWithTypes: Record<string, any> = {};
@@ -154,7 +294,7 @@ interface SharedGsiProvider {
154
294
  addTableArn: (tableArn: string, isSandbox: boolean) => void;
155
295
  }
156
296
 
157
- function getOrCreateGsiProvider(stack: cdk.Stack): SharedGsiProvider {
297
+ function getOrCreateGsiProvider(stack: cdk.Stack, logRetention: RetentionDays): SharedGsiProvider {
158
298
  const existing = (stack as any)[GSI_PROVIDER_KEY] as SharedGsiProvider | undefined;
159
299
  if (existing) return existing;
160
300
 
@@ -163,11 +303,18 @@ function getOrCreateGsiProvider(stack: cdk.Stack): SharedGsiProvider {
163
303
  const tableArns: string[] = [];
164
304
  const sandboxTableArns: string[] = [];
165
305
 
306
+ // Own the GSI-manager Lambdas' log groups so their retention follows the
307
+ // stack-wide default instead of AWS's infinite retention. Torn down with the
308
+ // stack (logs are not durable state).
166
309
  const gsiManagerLambda = new LambdaFunction(stack, 'BlocksGsiManager', {
167
310
  runtime: DEFAULT_NODE_RUNTIME,
168
311
  handler: 'index.handler',
169
312
  code: Code.fromAsset(join(__dirname, 'gsi-manager-lambda')),
170
313
  timeout: Duration.minutes(15),
314
+ logGroup: new LogGroup(stack, 'BlocksGsiManagerLogs', {
315
+ retention: logRetention,
316
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
317
+ }),
171
318
  });
172
319
 
173
320
  const gsiIsCompleteLambda = new LambdaFunction(stack, 'BlocksGsiIsComplete', {
@@ -175,6 +322,10 @@ function getOrCreateGsiProvider(stack: cdk.Stack): SharedGsiProvider {
175
322
  handler: 'index.isCompleteHandler',
176
323
  code: Code.fromAsset(join(__dirname, 'gsi-manager-lambda')),
177
324
  timeout: Duration.minutes(1),
325
+ logGroup: new LogGroup(stack, 'BlocksGsiIsCompleteLogs', {
326
+ retention: logRetention,
327
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
328
+ }),
178
329
  });
179
330
 
180
331
  // Production permissions — lazily resolved so ARNs accumulate as tables register