@aws-blocks/bb-distributed-table 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +18 -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 +137 -6
- package/dist/index.cdk.test.js +324 -3
- package/dist/index.mock.d.ts +3 -2
- package/dist/index.mock.d.ts.map +1 -1
- package/dist/index.mock.js +3 -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 +353 -3
- package/src/index.cdk.ts +152 -8
- package/src/index.mock.ts +6 -0
- package/src/types.ts +85 -0
- package/src/version.ts +1 -1
package/src/index.cdk.test.ts
CHANGED
|
@@ -14,8 +14,8 @@ 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';
|
|
18
|
-
import { Scope, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
|
|
17
|
+
import { Template, Match, Annotations } from 'aws-cdk-lib/assertions';
|
|
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';
|
|
21
21
|
|
|
@@ -27,22 +27,29 @@ 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;
|
|
32
|
+
public defaults: BlocksDefaults = BlocksPresets.production;
|
|
31
33
|
constructor(scope: Construct, id: string) {
|
|
32
34
|
super(scope, id);
|
|
33
35
|
this.id = id;
|
|
34
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
|
+
});
|
|
35
40
|
this.handler = new cdk.aws_lambda.Function(this, 'StubHandler', {
|
|
36
41
|
runtime: DEFAULT_NODE_RUNTIME,
|
|
37
42
|
handler: 'index.handler',
|
|
38
43
|
code: cdk.aws_lambda.Code.fromInline('exports.handler = async () => {};'),
|
|
44
|
+
role: this.executionRole,
|
|
39
45
|
});
|
|
40
46
|
}
|
|
41
47
|
}
|
|
42
48
|
|
|
43
|
-
function setup(): { stack: StubBlocksStack; parent: Scope } {
|
|
49
|
+
function setup(defaults: BlocksDefaults = BlocksPresets.production): { stack: StubBlocksStack; parent: Scope } {
|
|
44
50
|
const app = new cdk.App();
|
|
45
51
|
const stack = new StubBlocksStack(app, 'TestStack');
|
|
52
|
+
stack.defaults = defaults;
|
|
46
53
|
const parent = new Scope('app');
|
|
47
54
|
return { stack, parent };
|
|
48
55
|
}
|
|
@@ -57,6 +64,305 @@ test('CDK: default DistributedTable provisions a DynamoDB table', () => {
|
|
|
57
64
|
template.resourceCountIs('AWS::DynamoDB::Table', 1);
|
|
58
65
|
});
|
|
59
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
|
+
|
|
305
|
+
test('CDK: table adopts sandbox defaults (DESTROY, deletion protection off)', () => {
|
|
306
|
+
const { stack, parent } = setup(BlocksPresets.sandbox);
|
|
307
|
+
new DistributedTable(parent, 'users', {
|
|
308
|
+
schema: userSchema,
|
|
309
|
+
key: { partitionKey: 'userId', sortKey: 'createdAt' },
|
|
310
|
+
});
|
|
311
|
+
const template = Template.fromStack(stack);
|
|
312
|
+
template.hasResource('AWS::DynamoDB::Table', { DeletionPolicy: 'Delete' });
|
|
313
|
+
template.hasResourceProperties('AWS::DynamoDB::Table', { DeletionProtectionEnabled: false });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('CDK: table adopts production defaults (RETAIN, deletion protection on)', () => {
|
|
317
|
+
const { stack, parent } = setup(BlocksPresets.production);
|
|
318
|
+
new DistributedTable(parent, 'users', {
|
|
319
|
+
schema: userSchema,
|
|
320
|
+
key: { partitionKey: 'userId', sortKey: 'createdAt' },
|
|
321
|
+
});
|
|
322
|
+
const template = Template.fromStack(stack);
|
|
323
|
+
template.hasResource('AWS::DynamoDB::Table', { DeletionPolicy: 'Retain' });
|
|
324
|
+
template.hasResourceProperties('AWS::DynamoDB::Table', { DeletionProtectionEnabled: true });
|
|
325
|
+
});
|
|
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
|
+
|
|
60
366
|
test('CDK: DistributedTable.fromExisting does NOT provision a table (regression)', () => {
|
|
61
367
|
const { stack, parent } = setup();
|
|
62
368
|
new DistributedTable(parent, 'users', {
|
|
@@ -105,3 +411,47 @@ test('CDK: calling a runtime data method throws an actionable error (not a crypt
|
|
|
105
411
|
);
|
|
106
412
|
}
|
|
107
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.
|
|
46
|
-
this.
|
|
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,12 +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,
|
|
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,
|
|
86
230
|
});
|
|
87
231
|
|
|
88
|
-
this.table.grantReadWriteData(this.
|
|
232
|
+
this.table.grantReadWriteData(this.executionRole);
|
|
89
233
|
|
|
90
234
|
// Explicit index query permissions
|
|
91
|
-
this.
|
|
235
|
+
this.executionRole.addToPrincipalPolicy(new PolicyStatement({
|
|
92
236
|
actions: ['dynamodb:Query'],
|
|
93
237
|
resources: [`${this.table.tableArn}/index/*`],
|
|
94
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 {
|