@aws-blocks/bb-app-setting 0.1.4 → 0.2.1
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 +128 -72
- package/dist/index.cdk.test.js +121 -3
- 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 +136 -3
- package/src/index.cdk.ts +140 -74
- package/src/index.mock.ts +1 -1
- package/src/types.ts +17 -1
- package/src/version.ts +1 -1
package/dist/index.mock.d.ts
CHANGED
|
@@ -36,6 +36,7 @@ export declare class AppSetting<T = string> extends Scope {
|
|
|
36
36
|
static fromExisting<T = string>(scope: ScopeParent, id: string, options: {
|
|
37
37
|
name: string;
|
|
38
38
|
secret?: boolean;
|
|
39
|
+
kmsKeyArn?: string;
|
|
39
40
|
}): AppSetting<T>;
|
|
40
41
|
private parameterName;
|
|
41
42
|
private initialValue;
|
package/dist/index.mock.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mock.d.ts","sourceRoot":"","sources":["../src/index.mock.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,EAA0B,MAAM,kBAAkB,CAAC;AAEjE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAMpD,OAAO,KAAK,EAAE,iBAAiB,EAA6B,MAAM,YAAY,CAAC;AAE/E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAIzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,YAAY,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAsCpD;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,UAAU,CAAC,CAAC,GAAG,MAAM,CAAE,SAAQ,KAAK;IAChD;;;;;;;;;OASG;IACH,MAAM,CAAC,YAAY,CAAC,CAAC,GAAG,MAAM,EAC7B,KAAK,EAAE,WAAW,EAClB,EAAE,EAAE,MAAM,EACV,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,
|
|
1
|
+
{"version":3,"file":"index.mock.d.ts","sourceRoot":"","sources":["../src/index.mock.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,EAA0B,MAAM,kBAAkB,CAAC;AAEjE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAMpD,OAAO,KAAK,EAAE,iBAAiB,EAA6B,MAAM,YAAY,CAAC;AAE/E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAIzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,YAAY,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAsCpD;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,UAAU,CAAC,CAAC,GAAG,MAAM,CAAE,SAAQ,KAAK;IAChD;;;;;;;;;OASG;IACH,MAAM,CAAC,YAAY,CAAC,CAAC,GAAG,MAAM,EAC7B,KAAK,EAAE,WAAW,EAClB,EAAE,EAAE,MAAM,EACV,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,OAAO,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAC7D,UAAU,CAAC,CAAC,CAAC;IAKhB,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,YAAY,CAAI;IACxB,OAAO,CAAC,MAAM,CAAC,CAAsB;IACrC,OAAO,CAAC,QAAQ,CAAU;IAE1B,2FAA2F;IAC3F,SAAS,CAAC,GAAG,EAAE,WAAW,CAAC;gBAEf,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,CAAC,CAAC,CAAC;IAyBzE;;;;;;;;;;;OAWG;IACG,GAAG,IAAI,OAAO,CAAC,CAAC,CAAC;IAWvB;;;;;;;;;;;;;;OAcG;IACG,GAAG,CAAC,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;CAelC"}
|
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.1
|
|
3
|
+
"version": "0.2.1",
|
|
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.4.0",
|
|
43
|
+
"@aws-blocks/bb-logger": "^0.1.6",
|
|
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
|
@@ -12,31 +12,47 @@ import assert from 'node:assert';
|
|
|
12
12
|
import * as cdk from 'aws-cdk-lib';
|
|
13
13
|
import type { Construct } from 'constructs';
|
|
14
14
|
import { Template, Match } from 'aws-cdk-lib/assertions';
|
|
15
|
-
import { Scope, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
|
|
15
|
+
import { Scope, DEFAULT_NODE_RUNTIME, BlocksPresets, type BlocksDefaults } from '@aws-blocks/core/cdk';
|
|
16
16
|
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;
|
|
22
|
+
public defaults: BlocksDefaults = BlocksPresets.production;
|
|
21
23
|
constructor(scope: Construct, id: string) {
|
|
22
24
|
super(scope, id);
|
|
23
25
|
this.id = id;
|
|
24
26
|
(globalThis as any).CURRENT_BLOCKS_STACK = this;
|
|
27
|
+
this.executionRole = new cdk.aws_iam.Role(this, 'BlocksRole', {
|
|
28
|
+
assumedBy: new cdk.aws_iam.ServicePrincipal('lambda.amazonaws.com'),
|
|
29
|
+
});
|
|
25
30
|
this.handler = new cdk.aws_lambda.Function(this, 'StubHandler', {
|
|
26
31
|
runtime: DEFAULT_NODE_RUNTIME,
|
|
27
32
|
handler: 'index.handler',
|
|
28
33
|
code: cdk.aws_lambda.Code.fromInline('exports.handler = async () => {};'),
|
|
34
|
+
role: this.executionRole,
|
|
29
35
|
});
|
|
30
36
|
}
|
|
31
37
|
}
|
|
32
38
|
|
|
33
|
-
function setup(): { stack: StubBlocksStack; parent: Scope } {
|
|
39
|
+
function setup(defaults: BlocksDefaults = BlocksPresets.production, stackId = 'TestStack'): { stack: StubBlocksStack; parent: Scope } {
|
|
34
40
|
const app = new cdk.App();
|
|
35
|
-
const stack = new StubBlocksStack(app,
|
|
41
|
+
const stack = new StubBlocksStack(app, stackId);
|
|
42
|
+
stack.defaults = defaults;
|
|
36
43
|
const parent = new Scope('app');
|
|
37
44
|
return { stack, parent };
|
|
38
45
|
}
|
|
39
46
|
|
|
47
|
+
test('CDK: the secret-init Lambda log group adopts defaults.logRetention', () => {
|
|
48
|
+
const { stack, parent } = setup(BlocksPresets.sandbox, 'SecretRetentionStack');
|
|
49
|
+
new AppSetting(parent, 'my-secret', { secret: true, name: '/myapp/secret-key' });
|
|
50
|
+
const template = Template.fromStack(stack);
|
|
51
|
+
// The secret-init Lambda now owns an explicit log group whose retention
|
|
52
|
+
// follows the stack-wide default (sandbox → one week) instead of infinite.
|
|
53
|
+
template.hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 7 });
|
|
54
|
+
});
|
|
55
|
+
|
|
40
56
|
test('CDK: secret AppSetting SSM policy is scoped to specific parameter ARN (not wildcard)', () => {
|
|
41
57
|
const { stack, parent } = setup();
|
|
42
58
|
new AppSetting(parent, 'my-secret', { secret: true, name: '/myapp/secret-key' });
|
|
@@ -225,3 +241,120 @@ test('CDK: fromExisting still registers the runtime config key (BLOCKS_SSM_PARAM
|
|
|
225
241
|
);
|
|
226
242
|
assert.equal(registry.entries.get('BLOCKS_SSM_PARAM_DB_URL'), '/blocks/sandbox/db-abc-connection-string');
|
|
227
243
|
});
|
|
244
|
+
|
|
245
|
+
const TEST_CMK = 'arn:aws:kms:us-east-1:111122223333:key/abcd1234-5678-90ab-cdef-1234567890ab';
|
|
246
|
+
|
|
247
|
+
test('CDK: secret with kmsKeyArn grants handler KMS on the specific CMK ARN (not a ViaService wildcard)', () => {
|
|
248
|
+
const { stack, parent } = setup();
|
|
249
|
+
new AppSetting(parent, 'cmk-secret', { secret: true, name: '/app/cmk-secret', kmsKeyArn: TEST_CMK });
|
|
250
|
+
const template = Template.fromStack(stack);
|
|
251
|
+
const policies = template.findResources('AWS::IAM::Policy');
|
|
252
|
+
|
|
253
|
+
let found = false;
|
|
254
|
+
for (const logicalId of Object.keys(policies)) {
|
|
255
|
+
const statements = policies[logicalId]?.Properties?.PolicyDocument?.Statement;
|
|
256
|
+
if (!Array.isArray(statements)) continue;
|
|
257
|
+
for (const stmt of statements) {
|
|
258
|
+
const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action];
|
|
259
|
+
if (!actions.includes('kms:Decrypt')) continue;
|
|
260
|
+
// The CMK grant scopes to the exact key ARN and carries no ViaService condition.
|
|
261
|
+
const resStr = JSON.stringify(stmt.Resource);
|
|
262
|
+
if (resStr.includes(TEST_CMK)) {
|
|
263
|
+
found = true;
|
|
264
|
+
assert.ok(actions.includes('kms:Encrypt'), 'stack-managed CMK secret should grant kms:Encrypt');
|
|
265
|
+
// Standard-tier SecureStrings only use Encrypt/Decrypt — no GenerateDataKey.
|
|
266
|
+
assert.ok(
|
|
267
|
+
!actions.some((a: string) => a.startsWith('kms:GenerateDataKey')),
|
|
268
|
+
'CMK grant should not include kms:GenerateDataKey* (advanced-tier only)',
|
|
269
|
+
);
|
|
270
|
+
assert.strictEqual(stmt.Condition, undefined, 'CMK grant should not use a ViaService wildcard condition');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
assert.ok(found, `Expected a handler KMS grant scoped to the CMK ARN ${TEST_CMK}`);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('CDK: secret with kmsKeyArn passes the KeyId to the bulk secret custom resource', () => {
|
|
278
|
+
const { stack, parent } = setup();
|
|
279
|
+
new AppSetting(parent, 'cmk-secret', { secret: true, name: '/app/cmk-secret', kmsKeyArn: TEST_CMK });
|
|
280
|
+
const template = Template.fromStack(stack);
|
|
281
|
+
|
|
282
|
+
const crs = template.findResources('AWS::CloudFormation::CustomResource');
|
|
283
|
+
const bulk = Object.values(crs).find((r) => Array.isArray(r?.Properties?.Parameters));
|
|
284
|
+
assert.ok(bulk, 'Expected a bulk-secrets custom resource with a Parameters list');
|
|
285
|
+
const params = bulk.Properties.Parameters as Array<{ name: string; keyId?: string }>;
|
|
286
|
+
const entry = params.find((p) => p.name === '/app/cmk-secret');
|
|
287
|
+
assert.ok(entry, 'the secret should appear in the bulk Parameters');
|
|
288
|
+
assert.strictEqual(entry?.keyId, TEST_CMK, 'the CMK ARN must be passed through as keyId');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('CDK: a default-key secret carries no keyId in the bulk Parameters', () => {
|
|
292
|
+
const { stack, parent } = setup();
|
|
293
|
+
new AppSetting(parent, 'plain-secret', { secret: true, name: '/app/plain-secret' });
|
|
294
|
+
const template = Template.fromStack(stack);
|
|
295
|
+
const crs = template.findResources('AWS::CloudFormation::CustomResource');
|
|
296
|
+
const bulk = Object.values(crs).find((r) => Array.isArray(r?.Properties?.Parameters));
|
|
297
|
+
const params = bulk?.Properties?.Parameters as Array<{ name: string; keyId?: string }>;
|
|
298
|
+
const entry = params.find((p) => p.name === '/app/plain-secret');
|
|
299
|
+
assert.ok(entry, 'the secret should appear in the bulk Parameters');
|
|
300
|
+
assert.strictEqual(entry?.keyId, undefined, 'a default-key secret must not set keyId');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('CDK: kmsKeyArn without secret throws ValidationFailed', () => {
|
|
304
|
+
const { parent } = setup();
|
|
305
|
+
assert.throws(
|
|
306
|
+
() => new AppSetting(parent, 'bad', { value: 'x', name: '/app/bad', kmsKeyArn: TEST_CMK }),
|
|
307
|
+
/only valid with 'secret: true'/,
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('CDK: fromExisting with kmsKeyArn grants read-only KMS scoped to the CMK ARN (Decrypt, no Encrypt)', () => {
|
|
312
|
+
const { stack, parent } = setup();
|
|
313
|
+
AppSetting.fromExisting(parent, 'ext-cmk', { name: '/ext/cmk-secret', secret: true, kmsKeyArn: TEST_CMK });
|
|
314
|
+
const template = Template.fromStack(stack);
|
|
315
|
+
const policies = template.findResources('AWS::IAM::Policy');
|
|
316
|
+
|
|
317
|
+
let found = false;
|
|
318
|
+
for (const logicalId of Object.keys(policies)) {
|
|
319
|
+
const statements = policies[logicalId]?.Properties?.PolicyDocument?.Statement;
|
|
320
|
+
if (!Array.isArray(statements)) continue;
|
|
321
|
+
for (const stmt of statements) {
|
|
322
|
+
const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action];
|
|
323
|
+
if (JSON.stringify(stmt.Resource).includes(TEST_CMK) && actions.includes('kms:Decrypt')) {
|
|
324
|
+
found = true;
|
|
325
|
+
assert.ok(!actions.includes('kms:Encrypt'), 'external (read-only) CMK secret must not grant kms:Encrypt');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
assert.ok(found, 'external CMK secret should grant kms:Decrypt scoped to the CMK ARN');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('CDK: bulk-init role can read + re-key secrets (ssm:GetParameter + kms:Encrypt/Decrypt), no GenerateDataKey', () => {
|
|
333
|
+
// The re-encrypt-on-key-change path reads the current value (GetParameter +
|
|
334
|
+
// Decrypt) and rewrites it under the new key (Encrypt). The bulk-init statement
|
|
335
|
+
// is uniquely identified by ssm:AddTagsToResource.
|
|
336
|
+
const { stack, parent } = setup();
|
|
337
|
+
new AppSetting(parent, 'cmk-secret', { secret: true, name: '/app/cmk-secret', kmsKeyArn: TEST_CMK });
|
|
338
|
+
const template = Template.fromStack(stack);
|
|
339
|
+
const policies = template.findResources('AWS::IAM::Policy');
|
|
340
|
+
|
|
341
|
+
let bulkSsm: string[] | undefined;
|
|
342
|
+
let bulkKms: string[] | undefined;
|
|
343
|
+
for (const logicalId of Object.keys(policies)) {
|
|
344
|
+
const statements = policies[logicalId]?.Properties?.PolicyDocument?.Statement;
|
|
345
|
+
if (!Array.isArray(statements)) continue;
|
|
346
|
+
for (const stmt of statements) {
|
|
347
|
+
const actions: string[] = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action];
|
|
348
|
+
if (actions.includes('ssm:AddTagsToResource')) bulkSsm = actions;
|
|
349
|
+
if (actions.includes('kms:Encrypt') && stmt.Condition?.StringEquals?.['kms:ViaService']) bulkKms = actions;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
assert.ok(bulkSsm, 'expected the bulk-init ssm statement (identified by AddTagsToResource)');
|
|
353
|
+
assert.ok(bulkSsm?.includes('ssm:GetParameter'), 'bulk-init needs ssm:GetParameter to read a value when re-keying');
|
|
354
|
+
assert.ok(bulkKms, 'expected the bulk-init KMS statement (ViaService-scoped)');
|
|
355
|
+
assert.ok(bulkKms?.includes('kms:Decrypt'), 'bulk-init needs kms:Decrypt to read the current value when re-keying');
|
|
356
|
+
assert.ok(
|
|
357
|
+
!bulkKms?.some((a) => a.startsWith('kms:GenerateDataKey')),
|
|
358
|
+
'no GenerateDataKey* — standard-tier SecureStrings do not use it',
|
|
359
|
+
);
|
|
360
|
+
});
|