@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 +22 -1
- package/README.md +57 -1
- package/dist/index.aws.d.ts +3 -2
- package/dist/index.aws.d.ts.map +1 -1
- package/dist/index.aws.js +3 -0
- package/dist/index.cdk.d.ts +12 -2
- package/dist/index.cdk.d.ts.map +1 -1
- package/dist/index.cdk.js +151 -13
- package/dist/index.cdk.test.js +313 -1
- package/dist/index.mock.d.ts +10 -2
- package/dist/index.mock.d.ts.map +1 -1
- package/dist/index.mock.js +33 -1
- package/dist/index.test.js +42 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/src/index.aws.ts +6 -0
- package/src/index.cdk.test.ts +341 -1
- package/src/index.cdk.ts +166 -15
- package/src/index.mock.ts +35 -1
- package/src/index.test.ts +45 -0
- package/src/types.ts +85 -0
- package/src/version.ts +1 -1
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> {
|
package/src/index.cdk.test.ts
CHANGED
|
@@ -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.
|
|
46
|
-
this.
|
|
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
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
233
|
+
this.table.grantReadWriteData(this.executionRole);
|
|
94
234
|
|
|
95
235
|
// Explicit index query permissions
|
|
96
|
-
this.
|
|
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
|