@aws-blocks/bb-app-setting 0.1.4 → 0.2.0
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 +7 -6
- package/README.md +12 -1
- package/dist/index.aws.d.ts +2 -0
- package/dist/index.aws.d.ts.map +1 -1
- package/dist/index.aws.js +5 -0
- package/dist/index.aws.test.d.ts +2 -0
- package/dist/index.aws.test.d.ts.map +1 -0
- package/dist/index.aws.test.js +56 -0
- package/dist/index.cdk.d.ts +4 -1
- package/dist/index.cdk.d.ts.map +1 -1
- package/dist/index.cdk.js +121 -72
- package/dist/index.cdk.test.js +108 -0
- package/dist/index.mock.d.ts +1 -0
- package/dist/index.mock.d.ts.map +1 -1
- package/dist/types.d.ts +17 -1
- 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.test.ts +59 -0
- package/src/index.aws.ts +6 -1
- package/src/index.cdk.test.ts +122 -0
- package/src/index.cdk.ts +133 -74
- package/src/index.mock.ts +1 -1
- package/src/types.ts +17 -1
- package/src/version.ts +1 -1
package/dist/types.d.ts
CHANGED
|
@@ -23,8 +23,24 @@ export interface AppSettingOptions<T = string> {
|
|
|
23
23
|
value?: T;
|
|
24
24
|
/** Runtime validation schema. Accepts any StandardSchemaV1 implementation (Zod, Valibot, ArkType). When provided, T is inferred from the schema. */
|
|
25
25
|
schema?: StandardSchemaV1<T>;
|
|
26
|
-
/** When true, creates an SSM SecureString parameter
|
|
26
|
+
/** When true, creates an SSM SecureString parameter. Encrypted with the default `aws/ssm` KMS key unless `kmsKeyArn` is set. */
|
|
27
27
|
secret?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* ARN of a **customer-managed KMS key** used to encrypt this secret's
|
|
30
|
+
* SecureString value. When omitted, SSM uses the default `aws/ssm`
|
|
31
|
+
* AWS-managed key. Use a CMK when you need to control the decrypt/grant scope
|
|
32
|
+
* (e.g. cross-account access, key rotation, or a dedicated key policy).
|
|
33
|
+
*
|
|
34
|
+
* Only valid together with `secret: true`. The CDK layer grants the shared
|
|
35
|
+
* handler `kms:Decrypt` (plus `kms:Encrypt` for stack-managed secrets it
|
|
36
|
+
* writes) on this key, and the runtime `put()` passes the key so an overwrite
|
|
37
|
+
* does not silently fall back to the default key. Changing this on an existing
|
|
38
|
+
* secret re-encrypts its current value under the new key at deploy time.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* new AppSetting(scope, 'apiKey', { secret: true, kmsKeyArn: myKey.keyArn });
|
|
42
|
+
*/
|
|
43
|
+
kmsKeyArn?: string;
|
|
28
44
|
/** Optional logger for internal operations. When omitted, a default Logger at error level is created. */
|
|
29
45
|
logger?: ChildLogger;
|
|
30
46
|
}
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAGA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAEzD;;GAEG;AACH,MAAM,WAAW,iBAAiB,CAAC,CAAC,GAAG,MAAM;IAC5C;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iLAAiL;IACjL,KAAK,CAAC,EAAE,CAAC,CAAC;IACV,oJAAoJ;IACpJ,MAAM,CAAC,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAC7B,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAGA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAEzD;;GAEG;AACH,MAAM,WAAW,iBAAiB,CAAC,CAAC,GAAG,MAAM;IAC5C;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iLAAiL;IACjL,KAAK,CAAC,EAAE,CAAC,CAAC;IACV,oJAAoJ;IACpJ,MAAM,CAAC,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAC7B,gIAAgI;IAChI,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;;;;OAcG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yGAAyG;IACzG,MAAM,CAAC,EAAE,WAAW,CAAC;CACrB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,yBAAyB,CAAC,CAAC,GAAG,MAAM,CAAE,SAAQ,iBAAiB,CAAC,CAAC,CAAC;IAClF;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACnB"}
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aws-blocks/bb-app-setting",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/aws-devtools-labs/aws-blocks.git",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"test": "node --test --test-concurrency=1 dist/**/*.test.js"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@aws-blocks/core": "^0.
|
|
43
|
-
"@aws-blocks/bb-logger": "^0.1.
|
|
42
|
+
"@aws-blocks/core": "^0.3.0",
|
|
43
|
+
"@aws-blocks/bb-logger": "^0.1.5",
|
|
44
44
|
"@aws-sdk/client-ssm": "^3.0.0",
|
|
45
45
|
"@standard-schema/spec": "^1.1.0"
|
|
46
46
|
},
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import assert from 'node:assert';
|
|
5
|
+
import { describe, test, mock } from 'node:test';
|
|
6
|
+
import { SSMClient } from '@aws-sdk/client-ssm';
|
|
7
|
+
import { Scope } from '@aws-blocks/core';
|
|
8
|
+
import { AppSetting } from './index.aws.js';
|
|
9
|
+
|
|
10
|
+
const TEST_CMK = 'arn:aws:kms:us-east-1:111122223333:key/abcd1234-5678-90ab-cdef-1234567890ab';
|
|
11
|
+
|
|
12
|
+
/** Capture the input of the next PutParameterCommand sent by the runtime. */
|
|
13
|
+
function captureSend() {
|
|
14
|
+
const inputs: any[] = [];
|
|
15
|
+
const m = mock.method(SSMClient.prototype, 'send', async (cmd: any) => {
|
|
16
|
+
inputs.push(cmd.input);
|
|
17
|
+
return {};
|
|
18
|
+
});
|
|
19
|
+
return { inputs, restore: () => m.mock.restore() };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('AWS runtime put() KMS key', () => {
|
|
23
|
+
test('passes the CMK as KeyId when a secret is backed by kmsKeyArn', async () => {
|
|
24
|
+
const { inputs, restore } = captureSend();
|
|
25
|
+
try {
|
|
26
|
+
const setting = new AppSetting(new Scope('app'), 'cmk', { secret: true, kmsKeyArn: TEST_CMK });
|
|
27
|
+
await setting.put('rotated-value');
|
|
28
|
+
assert.strictEqual(inputs.length, 1);
|
|
29
|
+
assert.strictEqual(inputs[0].Type, 'SecureString');
|
|
30
|
+
assert.strictEqual(inputs[0].KeyId, TEST_CMK, 'overwrite must re-specify the CMK, not fall back to aws/ssm');
|
|
31
|
+
} finally {
|
|
32
|
+
restore();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('omits KeyId for a default-key secret', async () => {
|
|
37
|
+
const { inputs, restore } = captureSend();
|
|
38
|
+
try {
|
|
39
|
+
const setting = new AppSetting(new Scope('app'), 'plain', { secret: true });
|
|
40
|
+
await setting.put('v');
|
|
41
|
+
assert.strictEqual(inputs[0].Type, 'SecureString');
|
|
42
|
+
assert.strictEqual(inputs[0].KeyId, undefined);
|
|
43
|
+
} finally {
|
|
44
|
+
restore();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('omits KeyId for a non-secret String parameter', async () => {
|
|
49
|
+
const { inputs, restore } = captureSend();
|
|
50
|
+
try {
|
|
51
|
+
const setting = new AppSetting<string>(new Scope('app'), 'cfg', { value: 'init' });
|
|
52
|
+
await setting.put('next');
|
|
53
|
+
assert.strictEqual(inputs[0].Type, 'String');
|
|
54
|
+
assert.strictEqual(inputs[0].KeyId, undefined);
|
|
55
|
+
} finally {
|
|
56
|
+
restore();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
package/src/index.aws.ts
CHANGED
|
@@ -67,7 +67,7 @@ export class AppSetting<T = string> extends Scope {
|
|
|
67
67
|
static fromExisting<T = string>(
|
|
68
68
|
scope: ScopeParent,
|
|
69
69
|
id: string,
|
|
70
|
-
options: { name: string; secret?: boolean },
|
|
70
|
+
options: { name: string; secret?: boolean; kmsKeyArn?: string },
|
|
71
71
|
): AppSetting<T> {
|
|
72
72
|
const opts: InternalAppSettingOptions<T> = { ...options, external: true };
|
|
73
73
|
return new AppSetting<T>(scope, id, opts);
|
|
@@ -76,6 +76,7 @@ export class AppSetting<T = string> extends Scope {
|
|
|
76
76
|
readonly bbName = BB_NAME;
|
|
77
77
|
private schema?: StandardSchemaV1<T>;
|
|
78
78
|
private isSecret: boolean;
|
|
79
|
+
private kmsKeyArn?: string;
|
|
79
80
|
private client: SSMClient;
|
|
80
81
|
|
|
81
82
|
/** @internal Logger for internal operations. Defaults to error-level when not provided. */
|
|
@@ -88,6 +89,7 @@ export class AppSetting<T = string> extends Scope {
|
|
|
88
89
|
const parameterName = options.name ?? `/${this.fullId}`;
|
|
89
90
|
this.schema = options.schema;
|
|
90
91
|
this.isSecret = options.secret ?? false;
|
|
92
|
+
this.kmsKeyArn = options.kmsKeyArn;
|
|
91
93
|
this.client = new SSMClient({
|
|
92
94
|
customUserAgent: this.buildUserAgentChain(),
|
|
93
95
|
});
|
|
@@ -160,6 +162,9 @@ export class AppSetting<T = string> extends Scope {
|
|
|
160
162
|
Value: serialized,
|
|
161
163
|
Type: this.isSecret ? 'SecureString' : 'String',
|
|
162
164
|
Overwrite: true,
|
|
165
|
+
// Re-specify the CMK on overwrite: SSM falls back to the default aws/ssm
|
|
166
|
+
// key when KeyId is omitted, which would silently downgrade encryption.
|
|
167
|
+
...(this.isSecret && this.kmsKeyArn ? { KeyId: this.kmsKeyArn } : {}),
|
|
163
168
|
}));
|
|
164
169
|
}
|
|
165
170
|
}
|
package/src/index.cdk.test.ts
CHANGED
|
@@ -17,15 +17,20 @@ import { AppSetting } from './index.cdk.js';
|
|
|
17
17
|
|
|
18
18
|
class StubBlocksStack extends cdk.Stack {
|
|
19
19
|
public readonly handler: cdk.aws_lambda.Function;
|
|
20
|
+
public readonly executionRole: cdk.aws_iam.IRole;
|
|
20
21
|
public readonly id: string;
|
|
21
22
|
constructor(scope: Construct, id: string) {
|
|
22
23
|
super(scope, id);
|
|
23
24
|
this.id = id;
|
|
24
25
|
(globalThis as any).CURRENT_BLOCKS_STACK = this;
|
|
26
|
+
this.executionRole = new cdk.aws_iam.Role(this, 'BlocksRole', {
|
|
27
|
+
assumedBy: new cdk.aws_iam.ServicePrincipal('lambda.amazonaws.com'),
|
|
28
|
+
});
|
|
25
29
|
this.handler = new cdk.aws_lambda.Function(this, 'StubHandler', {
|
|
26
30
|
runtime: DEFAULT_NODE_RUNTIME,
|
|
27
31
|
handler: 'index.handler',
|
|
28
32
|
code: cdk.aws_lambda.Code.fromInline('exports.handler = async () => {};'),
|
|
33
|
+
role: this.executionRole,
|
|
29
34
|
});
|
|
30
35
|
}
|
|
31
36
|
}
|
|
@@ -225,3 +230,120 @@ test('CDK: fromExisting still registers the runtime config key (BLOCKS_SSM_PARAM
|
|
|
225
230
|
);
|
|
226
231
|
assert.equal(registry.entries.get('BLOCKS_SSM_PARAM_DB_URL'), '/blocks/sandbox/db-abc-connection-string');
|
|
227
232
|
});
|
|
233
|
+
|
|
234
|
+
const TEST_CMK = 'arn:aws:kms:us-east-1:111122223333:key/abcd1234-5678-90ab-cdef-1234567890ab';
|
|
235
|
+
|
|
236
|
+
test('CDK: secret with kmsKeyArn grants handler KMS on the specific CMK ARN (not a ViaService wildcard)', () => {
|
|
237
|
+
const { stack, parent } = setup();
|
|
238
|
+
new AppSetting(parent, 'cmk-secret', { secret: true, name: '/app/cmk-secret', kmsKeyArn: TEST_CMK });
|
|
239
|
+
const template = Template.fromStack(stack);
|
|
240
|
+
const policies = template.findResources('AWS::IAM::Policy');
|
|
241
|
+
|
|
242
|
+
let found = false;
|
|
243
|
+
for (const logicalId of Object.keys(policies)) {
|
|
244
|
+
const statements = policies[logicalId]?.Properties?.PolicyDocument?.Statement;
|
|
245
|
+
if (!Array.isArray(statements)) continue;
|
|
246
|
+
for (const stmt of statements) {
|
|
247
|
+
const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action];
|
|
248
|
+
if (!actions.includes('kms:Decrypt')) continue;
|
|
249
|
+
// The CMK grant scopes to the exact key ARN and carries no ViaService condition.
|
|
250
|
+
const resStr = JSON.stringify(stmt.Resource);
|
|
251
|
+
if (resStr.includes(TEST_CMK)) {
|
|
252
|
+
found = true;
|
|
253
|
+
assert.ok(actions.includes('kms:Encrypt'), 'stack-managed CMK secret should grant kms:Encrypt');
|
|
254
|
+
// Standard-tier SecureStrings only use Encrypt/Decrypt — no GenerateDataKey.
|
|
255
|
+
assert.ok(
|
|
256
|
+
!actions.some((a: string) => a.startsWith('kms:GenerateDataKey')),
|
|
257
|
+
'CMK grant should not include kms:GenerateDataKey* (advanced-tier only)',
|
|
258
|
+
);
|
|
259
|
+
assert.strictEqual(stmt.Condition, undefined, 'CMK grant should not use a ViaService wildcard condition');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
assert.ok(found, `Expected a handler KMS grant scoped to the CMK ARN ${TEST_CMK}`);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('CDK: secret with kmsKeyArn passes the KeyId to the bulk secret custom resource', () => {
|
|
267
|
+
const { stack, parent } = setup();
|
|
268
|
+
new AppSetting(parent, 'cmk-secret', { secret: true, name: '/app/cmk-secret', kmsKeyArn: TEST_CMK });
|
|
269
|
+
const template = Template.fromStack(stack);
|
|
270
|
+
|
|
271
|
+
const crs = template.findResources('AWS::CloudFormation::CustomResource');
|
|
272
|
+
const bulk = Object.values(crs).find((r) => Array.isArray(r?.Properties?.Parameters));
|
|
273
|
+
assert.ok(bulk, 'Expected a bulk-secrets custom resource with a Parameters list');
|
|
274
|
+
const params = bulk.Properties.Parameters as Array<{ name: string; keyId?: string }>;
|
|
275
|
+
const entry = params.find((p) => p.name === '/app/cmk-secret');
|
|
276
|
+
assert.ok(entry, 'the secret should appear in the bulk Parameters');
|
|
277
|
+
assert.strictEqual(entry?.keyId, TEST_CMK, 'the CMK ARN must be passed through as keyId');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('CDK: a default-key secret carries no keyId in the bulk Parameters', () => {
|
|
281
|
+
const { stack, parent } = setup();
|
|
282
|
+
new AppSetting(parent, 'plain-secret', { secret: true, name: '/app/plain-secret' });
|
|
283
|
+
const template = Template.fromStack(stack);
|
|
284
|
+
const crs = template.findResources('AWS::CloudFormation::CustomResource');
|
|
285
|
+
const bulk = Object.values(crs).find((r) => Array.isArray(r?.Properties?.Parameters));
|
|
286
|
+
const params = bulk?.Properties?.Parameters as Array<{ name: string; keyId?: string }>;
|
|
287
|
+
const entry = params.find((p) => p.name === '/app/plain-secret');
|
|
288
|
+
assert.ok(entry, 'the secret should appear in the bulk Parameters');
|
|
289
|
+
assert.strictEqual(entry?.keyId, undefined, 'a default-key secret must not set keyId');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('CDK: kmsKeyArn without secret throws ValidationFailed', () => {
|
|
293
|
+
const { parent } = setup();
|
|
294
|
+
assert.throws(
|
|
295
|
+
() => new AppSetting(parent, 'bad', { value: 'x', name: '/app/bad', kmsKeyArn: TEST_CMK }),
|
|
296
|
+
/only valid with 'secret: true'/,
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('CDK: fromExisting with kmsKeyArn grants read-only KMS scoped to the CMK ARN (Decrypt, no Encrypt)', () => {
|
|
301
|
+
const { stack, parent } = setup();
|
|
302
|
+
AppSetting.fromExisting(parent, 'ext-cmk', { name: '/ext/cmk-secret', secret: true, kmsKeyArn: TEST_CMK });
|
|
303
|
+
const template = Template.fromStack(stack);
|
|
304
|
+
const policies = template.findResources('AWS::IAM::Policy');
|
|
305
|
+
|
|
306
|
+
let found = false;
|
|
307
|
+
for (const logicalId of Object.keys(policies)) {
|
|
308
|
+
const statements = policies[logicalId]?.Properties?.PolicyDocument?.Statement;
|
|
309
|
+
if (!Array.isArray(statements)) continue;
|
|
310
|
+
for (const stmt of statements) {
|
|
311
|
+
const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action];
|
|
312
|
+
if (JSON.stringify(stmt.Resource).includes(TEST_CMK) && actions.includes('kms:Decrypt')) {
|
|
313
|
+
found = true;
|
|
314
|
+
assert.ok(!actions.includes('kms:Encrypt'), 'external (read-only) CMK secret must not grant kms:Encrypt');
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
assert.ok(found, 'external CMK secret should grant kms:Decrypt scoped to the CMK ARN');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('CDK: bulk-init role can read + re-key secrets (ssm:GetParameter + kms:Encrypt/Decrypt), no GenerateDataKey', () => {
|
|
322
|
+
// The re-encrypt-on-key-change path reads the current value (GetParameter +
|
|
323
|
+
// Decrypt) and rewrites it under the new key (Encrypt). The bulk-init statement
|
|
324
|
+
// is uniquely identified by ssm:AddTagsToResource.
|
|
325
|
+
const { stack, parent } = setup();
|
|
326
|
+
new AppSetting(parent, 'cmk-secret', { secret: true, name: '/app/cmk-secret', kmsKeyArn: TEST_CMK });
|
|
327
|
+
const template = Template.fromStack(stack);
|
|
328
|
+
const policies = template.findResources('AWS::IAM::Policy');
|
|
329
|
+
|
|
330
|
+
let bulkSsm: string[] | undefined;
|
|
331
|
+
let bulkKms: string[] | undefined;
|
|
332
|
+
for (const logicalId of Object.keys(policies)) {
|
|
333
|
+
const statements = policies[logicalId]?.Properties?.PolicyDocument?.Statement;
|
|
334
|
+
if (!Array.isArray(statements)) continue;
|
|
335
|
+
for (const stmt of statements) {
|
|
336
|
+
const actions: string[] = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action];
|
|
337
|
+
if (actions.includes('ssm:AddTagsToResource')) bulkSsm = actions;
|
|
338
|
+
if (actions.includes('kms:Encrypt') && stmt.Condition?.StringEquals?.['kms:ViaService']) bulkKms = actions;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
assert.ok(bulkSsm, 'expected the bulk-init ssm statement (identified by AddTagsToResource)');
|
|
342
|
+
assert.ok(bulkSsm?.includes('ssm:GetParameter'), 'bulk-init needs ssm:GetParameter to read a value when re-keying');
|
|
343
|
+
assert.ok(bulkKms, 'expected the bulk-init KMS statement (ViaService-scoped)');
|
|
344
|
+
assert.ok(bulkKms?.includes('kms:Decrypt'), 'bulk-init needs kms:Decrypt to read the current value when re-keying');
|
|
345
|
+
assert.ok(
|
|
346
|
+
!bulkKms?.some((a) => a.startsWith('kms:GenerateDataKey')),
|
|
347
|
+
'no GenerateDataKey* — standard-tier SecureStrings do not use it',
|
|
348
|
+
);
|
|
349
|
+
});
|
package/src/index.cdk.ts
CHANGED
|
@@ -21,7 +21,9 @@ export type { AppSettingOptions } from './types.js';
|
|
|
21
21
|
* - String parameters use `aws-cdk-lib/aws-ssm.StringParameter` directly.
|
|
22
22
|
* - SecureString parameters use a Custom Resource Lambda because
|
|
23
23
|
* CloudFormation cannot natively create SecureString parameters.
|
|
24
|
-
* - SecureString parameters are encrypted with the default `aws/ssm` KMS key
|
|
24
|
+
* - SecureString parameters are encrypted with the default `aws/ssm` KMS key,
|
|
25
|
+
* or with a customer-managed key when `kmsKeyArn` is provided (the handler is
|
|
26
|
+
* then granted `kms:Decrypt`/`Encrypt` on that specific key ARN).
|
|
25
27
|
*/
|
|
26
28
|
export class AppSetting<T = string> extends Scope {
|
|
27
29
|
/**
|
|
@@ -40,7 +42,7 @@ export class AppSetting<T = string> extends Scope {
|
|
|
40
42
|
static fromExisting<T = string>(
|
|
41
43
|
scope: ScopeParent,
|
|
42
44
|
id: string,
|
|
43
|
-
options: { name: string; secret?: boolean },
|
|
45
|
+
options: { name: string; secret?: boolean; kmsKeyArn?: string },
|
|
44
46
|
): AppSetting<T> {
|
|
45
47
|
const opts: InternalAppSettingOptions<T> = { ...options, external: true };
|
|
46
48
|
return new AppSetting<T>(scope, id, opts);
|
|
@@ -72,6 +74,25 @@ export class AppSetting<T = string> extends Scope {
|
|
|
72
74
|
throw err;
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
if (options.kmsKeyArn !== undefined) {
|
|
78
|
+
if (!options.secret) {
|
|
79
|
+
const err = new Error(
|
|
80
|
+
`AppSetting '${id}': 'kmsKeyArn' is only valid with 'secret: true'. ` +
|
|
81
|
+
`Non-secret String parameters are not encrypted.`
|
|
82
|
+
);
|
|
83
|
+
err.name = AppSettingErrors.ValidationFailed;
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
if (options.kmsKeyArn.trim() === '') {
|
|
87
|
+
const err = new Error(
|
|
88
|
+
`AppSetting '${id}': 'kmsKeyArn' must be a non-empty KMS key ARN. ` +
|
|
89
|
+
`Omit it to use the default aws/ssm key.`
|
|
90
|
+
);
|
|
91
|
+
err.name = AppSettingErrors.ValidationFailed;
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
75
96
|
if (options.secret && options.value !== undefined) {
|
|
76
97
|
const err = new Error(
|
|
77
98
|
`AppSetting '${id}': secrets should not have a value in source code. ` +
|
|
@@ -130,21 +151,33 @@ export class AppSetting<T = string> extends Scope {
|
|
|
130
151
|
// then fail tagging it (AddTagsToResource needs ssm:GetParameters).
|
|
131
152
|
// We only need runtime read access, granted below.
|
|
132
153
|
if (!external) {
|
|
133
|
-
registerSecret(cdk.Stack.of(this), parameterName);
|
|
154
|
+
registerSecret(cdk.Stack.of(this), parameterName, options.kmsKeyArn);
|
|
134
155
|
}
|
|
135
156
|
|
|
136
|
-
// Grant handler KMS access
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
157
|
+
// Grant the handler KMS access. External secrets are read-only (Decrypt
|
|
158
|
+
// only); stack-managed secrets also need Encrypt so the app can write the
|
|
159
|
+
// value via put(). Standard-tier SecureStrings only use Encrypt/Decrypt
|
|
160
|
+
// (no GenerateDataKey — that's advanced-tier envelope encryption).
|
|
161
|
+
if (options.kmsKeyArn) {
|
|
162
|
+
// Customer-managed key: grant on the specific key ARN. NOTE: the key's
|
|
163
|
+
// own key policy must also allow this role (we can't edit a BYO key's
|
|
164
|
+
// policy from here) — see the README.
|
|
165
|
+
this.executionRole.addToPrincipalPolicy(new iam.PolicyStatement({
|
|
166
|
+
actions: external ? ['kms:Decrypt'] : ['kms:Decrypt', 'kms:Encrypt'],
|
|
167
|
+
resources: [options.kmsKeyArn],
|
|
168
|
+
}));
|
|
169
|
+
} else {
|
|
170
|
+
// Default aws/ssm key: scope the wildcard with a ViaService condition.
|
|
171
|
+
this.executionRole.addToPrincipalPolicy(new iam.PolicyStatement({
|
|
172
|
+
actions: external ? ['kms:Decrypt'] : ['kms:Decrypt', 'kms:Encrypt'],
|
|
173
|
+
resources: ['*'],
|
|
174
|
+
conditions: {
|
|
175
|
+
StringEquals: {
|
|
176
|
+
'kms:ViaService': `ssm.${cdk.Stack.of(this).region}.amazonaws.com`,
|
|
177
|
+
},
|
|
145
178
|
},
|
|
146
|
-
}
|
|
147
|
-
}
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
148
181
|
} else if (!external) {
|
|
149
182
|
// ── String parameter via CDK construct ──────────────────────────
|
|
150
183
|
const param = new ssm.StringParameter(this, 'Param', {
|
|
@@ -159,7 +192,7 @@ export class AppSetting<T = string> extends Scope {
|
|
|
159
192
|
|
|
160
193
|
// Grant handler SSM access on this parameter. External parameters are owned
|
|
161
194
|
// elsewhere, so the app only reads them (no ssm:PutParameter).
|
|
162
|
-
this.
|
|
195
|
+
this.executionRole.addToPrincipalPolicy(new iam.PolicyStatement({
|
|
163
196
|
actions: external ? ['ssm:GetParameter'] : ['ssm:GetParameter', 'ssm:PutParameter'],
|
|
164
197
|
resources: [parameterArn],
|
|
165
198
|
}));
|
|
@@ -174,101 +207,127 @@ export class AppSetting<T = string> extends Scope {
|
|
|
174
207
|
|
|
175
208
|
const SECRET_BULK_KEY = Symbol.for('BLOCKS_SECRET_BULK_INIT');
|
|
176
209
|
|
|
210
|
+
interface SecretParam {
|
|
211
|
+
name: string;
|
|
212
|
+
/** Customer-managed KMS key ARN, or undefined for the default aws/ssm key. */
|
|
213
|
+
keyId?: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
177
216
|
interface SecretBulkState {
|
|
178
|
-
|
|
217
|
+
params: SecretParam[];
|
|
179
218
|
}
|
|
180
219
|
|
|
181
220
|
/**
|
|
182
|
-
* Register a secret parameter
|
|
183
|
-
* Provider, and a single CustomResource.
|
|
184
|
-
* the parameter list (resolved lazily at
|
|
221
|
+
* Register a secret parameter (and its optional customer-managed KMS key). On
|
|
222
|
+
* first call, creates the shared Lambda, Provider, and a single CustomResource.
|
|
223
|
+
* All subsequent calls just append to the parameter list (resolved lazily at
|
|
224
|
+
* synth time).
|
|
185
225
|
*/
|
|
186
|
-
function registerSecret(stack: cdk.Stack, parameterName: string): void {
|
|
226
|
+
function registerSecret(stack: cdk.Stack, parameterName: string, keyId?: string): void {
|
|
187
227
|
let state = (stack as any)[SECRET_BULK_KEY] as SecretBulkState | undefined;
|
|
188
228
|
if (state) {
|
|
189
|
-
state.
|
|
229
|
+
state.params.push({ name: parameterName, keyId });
|
|
190
230
|
return;
|
|
191
231
|
}
|
|
192
232
|
|
|
193
233
|
// First secret in this stack — create all shared infrastructure
|
|
194
|
-
state = {
|
|
234
|
+
state = { params: [{ name: parameterName, keyId }] };
|
|
195
235
|
(stack as any)[SECRET_BULK_KEY] = state;
|
|
196
236
|
|
|
197
237
|
const secretInitFn = new lambda.Function(stack, 'BlocksSecretInitFn', {
|
|
198
238
|
runtime: DEFAULT_NODE_RUNTIME,
|
|
199
239
|
handler: 'index.handler',
|
|
200
240
|
code: lambda.Code.fromInline(`
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (event.RequestType === 'Create') {
|
|
216
|
-
for (const name of names) {
|
|
217
|
-
const secret = crypto.randomBytes(32).toString('base64url');
|
|
218
|
-
try {
|
|
219
|
-
await client.send(new PutParameterCommand({
|
|
220
|
-
Name: name, Value: secret, Type: 'SecureString', Overwrite: false, Tags: tags,
|
|
221
|
-
}));
|
|
222
|
-
} catch (e) {
|
|
223
|
-
if (e.name !== 'ParameterAlreadyExists') throw e;
|
|
224
|
-
if (tags.length) {
|
|
225
|
-
await client.send(new AddTagsToResourceCommand({ ResourceType: 'Parameter', ResourceId: name, Tags: tags }));
|
|
226
|
-
}
|
|
241
|
+
const { SSMClient, GetParameterCommand, PutParameterCommand, DeleteParameterCommand, AddTagsToResourceCommand } = require('@aws-sdk/client-ssm');
|
|
242
|
+
const crypto = require('crypto');
|
|
243
|
+
const client = new SSMClient({});
|
|
244
|
+
async function putSecret(p, tags) {
|
|
245
|
+
const secret = crypto.randomBytes(32).toString('base64url');
|
|
246
|
+
const input = { Name: p.name, Value: secret, Type: 'SecureString', Overwrite: false, Tags: tags };
|
|
247
|
+
// A CMK ARN pins the SecureString to a customer-managed key; omit for the default aws/ssm key.
|
|
248
|
+
if (p.keyId) input.KeyId = p.keyId;
|
|
249
|
+
try {
|
|
250
|
+
await client.send(new PutParameterCommand(input));
|
|
251
|
+
} catch (e) {
|
|
252
|
+
if (e.name !== 'ParameterAlreadyExists') throw e;
|
|
253
|
+
if (tags.length) {
|
|
254
|
+
await client.send(new AddTagsToResourceCommand({ ResourceType: 'Parameter', ResourceId: p.name, Tags: tags }));
|
|
227
255
|
}
|
|
228
256
|
}
|
|
229
|
-
return { PhysicalResourceId: 'bb-secrets-bulk' };
|
|
230
257
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
258
|
+
// Re-encrypt an EXISTING secret under a new (or removed) KMS key, preserving its
|
|
259
|
+
// current value. Without this, changing kmsKeyArn leaves the value encrypted under
|
|
260
|
+
// the old key while the app's IAM grant flips to the new key, so the next get()
|
|
261
|
+
// fails with AccessDenied.
|
|
262
|
+
async function reencrypt(p) {
|
|
263
|
+
const cur = await client.send(new GetParameterCommand({ Name: p.name, WithDecryption: true }));
|
|
264
|
+
const value = cur.Parameter && cur.Parameter.Value;
|
|
265
|
+
if (value === undefined || value === null) return;
|
|
266
|
+
const input = { Name: p.name, Value: value, Type: 'SecureString', Overwrite: true };
|
|
267
|
+
if (p.keyId) input.KeyId = p.keyId; // omit => back to the default aws/ssm key
|
|
268
|
+
await client.send(new PutParameterCommand(input));
|
|
269
|
+
}
|
|
270
|
+
// A pre-CMK deployment stored ParameterNames (string[]); map it to the {name} shape.
|
|
271
|
+
function readOld(op) {
|
|
272
|
+
if (Array.isArray(op.Parameters)) return op.Parameters;
|
|
273
|
+
if (Array.isArray(op.ParameterNames)) return op.ParameterNames.map((n) => ({ name: n }));
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
exports.handler = async (event) => {
|
|
277
|
+
const params = event.ResourceProperties.Parameters || [];
|
|
278
|
+
const stackName = event.ResourceProperties.StackName || '';
|
|
279
|
+
const tags = stackName ? [{ Key: 'aws-blocks-stack', Value: stackName }] : [];
|
|
280
|
+
const oldParams = readOld(event.OldResourceProperties || {});
|
|
281
|
+
const names = params.map(p => p.name);
|
|
282
|
+
const oldByName = Object.fromEntries(oldParams.map(p => [p.name, p]));
|
|
283
|
+
if (event.RequestType === 'Delete') {
|
|
284
|
+
for (const name of names) {
|
|
285
|
+
try { await client.send(new DeleteParameterCommand({ Name: name })); } catch {}
|
|
245
286
|
}
|
|
287
|
+
return { PhysicalResourceId: 'bb-secrets-bulk' };
|
|
246
288
|
}
|
|
247
|
-
|
|
248
|
-
|
|
289
|
+
if (event.RequestType === 'Create') {
|
|
290
|
+
for (const p of params) await putSecret(p, tags);
|
|
291
|
+
return { PhysicalResourceId: 'bb-secrets-bulk' };
|
|
292
|
+
}
|
|
293
|
+
if (event.RequestType === 'Update') {
|
|
294
|
+
for (const p of params) {
|
|
295
|
+
const old = oldByName[p.name];
|
|
296
|
+
if (!old) { await putSecret(p, tags); continue; } // newly added
|
|
297
|
+
if ((old.keyId || '') !== (p.keyId || '')) await reencrypt(p); // key changed => re-key, preserving value
|
|
298
|
+
// else: unchanged — leave the runtime-managed value alone
|
|
299
|
+
}
|
|
300
|
+
for (const name of Object.keys(oldByName)) {
|
|
301
|
+
if (!names.includes(name)) { try { await client.send(new DeleteParameterCommand({ Name: name })); } catch {} }
|
|
302
|
+
}
|
|
303
|
+
return { PhysicalResourceId: 'bb-secrets-bulk' };
|
|
249
304
|
}
|
|
250
305
|
return { PhysicalResourceId: 'bb-secrets-bulk' };
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
};
|
|
254
|
-
`),
|
|
306
|
+
};
|
|
307
|
+
`),
|
|
255
308
|
});
|
|
256
309
|
|
|
257
310
|
secretInitFn.addToRolePolicy(new iam.PolicyStatement({
|
|
258
|
-
|
|
311
|
+
// GetParameter is needed to read a secret's current value when re-keying it.
|
|
312
|
+
actions: ['ssm:GetParameter', 'ssm:PutParameter', 'ssm:DeleteParameter', 'ssm:AddTagsToResource'],
|
|
259
313
|
resources: cdk.Lazy.list({
|
|
260
|
-
produce: () => state!.
|
|
314
|
+
produce: () => state!.params.map(p =>
|
|
261
315
|
stack.formatArn({
|
|
262
316
|
service: 'ssm',
|
|
263
317
|
resource: 'parameter',
|
|
264
|
-
resourceName: name.replace(/^\//, ''),
|
|
318
|
+
resourceName: p.name.replace(/^\//, ''),
|
|
265
319
|
})
|
|
266
320
|
),
|
|
267
321
|
}),
|
|
268
322
|
}));
|
|
269
323
|
|
|
324
|
+
// SSM SecureString encryption goes through KMS via the SSM service. Scoping to
|
|
325
|
+
// `kms:ViaService = ssm.<region>` covers both the default aws/ssm key and any
|
|
326
|
+
// customer-managed key used above (the CMK's own key policy must also allow
|
|
327
|
+
// this role). Encrypt = create/re-key; Decrypt = read the current value when
|
|
328
|
+
// re-keying. Standard-tier SecureStrings don't use GenerateDataKey.
|
|
270
329
|
secretInitFn.addToRolePolicy(new iam.PolicyStatement({
|
|
271
|
-
actions: ['kms:Encrypt'],
|
|
330
|
+
actions: ['kms:Encrypt', 'kms:Decrypt'],
|
|
272
331
|
resources: ['*'],
|
|
273
332
|
conditions: {
|
|
274
333
|
StringEquals: {
|
|
@@ -284,7 +343,7 @@ function registerSecret(stack: cdk.Stack, parameterName: string): void {
|
|
|
284
343
|
new cdk.CustomResource(stack, 'BlocksSecretsBulk', {
|
|
285
344
|
serviceToken: provider.serviceToken,
|
|
286
345
|
properties: {
|
|
287
|
-
|
|
346
|
+
Parameters: cdk.Lazy.any({ produce: () => state!.params }),
|
|
288
347
|
StackName: (() => { let s = stack; while (s.nestedStackParent) s = s.nestedStackParent; return s.stackName; })(),
|
|
289
348
|
},
|
|
290
349
|
});
|
package/src/index.mock.ts
CHANGED
|
@@ -86,7 +86,7 @@ export class AppSetting<T = string> extends Scope {
|
|
|
86
86
|
static fromExisting<T = string>(
|
|
87
87
|
scope: ScopeParent,
|
|
88
88
|
id: string,
|
|
89
|
-
options: { name: string; secret?: boolean },
|
|
89
|
+
options: { name: string; secret?: boolean; kmsKeyArn?: string },
|
|
90
90
|
): AppSetting<T> {
|
|
91
91
|
const opts: InternalAppSettingOptions<T> = { ...options, external: true };
|
|
92
92
|
return new AppSetting<T>(scope, id, opts);
|
package/src/types.ts
CHANGED
|
@@ -27,8 +27,24 @@ export interface AppSettingOptions<T = string> {
|
|
|
27
27
|
value?: T;
|
|
28
28
|
/** Runtime validation schema. Accepts any StandardSchemaV1 implementation (Zod, Valibot, ArkType). When provided, T is inferred from the schema. */
|
|
29
29
|
schema?: StandardSchemaV1<T>;
|
|
30
|
-
/** When true, creates an SSM SecureString parameter
|
|
30
|
+
/** When true, creates an SSM SecureString parameter. Encrypted with the default `aws/ssm` KMS key unless `kmsKeyArn` is set. */
|
|
31
31
|
secret?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* ARN of a **customer-managed KMS key** used to encrypt this secret's
|
|
34
|
+
* SecureString value. When omitted, SSM uses the default `aws/ssm`
|
|
35
|
+
* AWS-managed key. Use a CMK when you need to control the decrypt/grant scope
|
|
36
|
+
* (e.g. cross-account access, key rotation, or a dedicated key policy).
|
|
37
|
+
*
|
|
38
|
+
* Only valid together with `secret: true`. The CDK layer grants the shared
|
|
39
|
+
* handler `kms:Decrypt` (plus `kms:Encrypt` for stack-managed secrets it
|
|
40
|
+
* writes) on this key, and the runtime `put()` passes the key so an overwrite
|
|
41
|
+
* does not silently fall back to the default key. Changing this on an existing
|
|
42
|
+
* secret re-encrypts its current value under the new key at deploy time.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* new AppSetting(scope, 'apiKey', { secret: true, kmsKeyArn: myKey.keyArn });
|
|
46
|
+
*/
|
|
47
|
+
kmsKeyArn?: string;
|
|
32
48
|
/** Optional logger for internal operations. When omitted, a default Logger at error level is created. */
|
|
33
49
|
logger?: ChildLogger;
|
|
34
50
|
}
|
package/src/version.ts
CHANGED