@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/DESIGN.md
CHANGED
|
@@ -27,16 +27,17 @@ CloudFormation cannot natively create SSM SecureString parameters. The CDK imple
|
|
|
27
27
|
- Uses `lambda.Code.fromInline()` with `@aws-sdk/client-ssm` and `crypto`
|
|
28
28
|
- On `Create`: generates a **random** secret via `crypto.randomBytes(32).toString('base64url')` and calls `PutParameterCommand` with `Type: 'SecureString'`, `Overwrite: false` (no serialized initial value — secrets never come from source)
|
|
29
29
|
- On `Delete`: calls `DeleteParameterCommand` to clean up the parameter
|
|
30
|
-
- On `Update`: generates a random secret for newly added names (same as `Create`) **and** deletes parameters for names removed since the previous deployment (not a no-op)
|
|
31
|
-
- Granted `ssm:PutParameter
|
|
32
|
-
- A single shared Lambda + `CustomResource` is created per stack; each secret
|
|
30
|
+
- On `Update`: generates a random secret for newly added names (same as `Create`) **and** deletes parameters for names removed since the previous deployment (not a no-op). Existing values are left untouched **unless the secret's `keyId` changed** (i.e. `kmsKeyArn` was added/changed/removed) — in that case it reads the current value (`GetParameter` with decryption) and rewrites it under the new key (`Overwrite: true`), so the value survives a key rotation and the handler's new key-scoped grant stays consistent
|
|
31
|
+
- Granted `ssm:GetParameter`/`ssm:PutParameter`/`ssm:DeleteParameter` scoped to the parameter ARN, plus `kms:Encrypt`/`kms:Decrypt` (scoped by the `kms:ViaService` condition to `ssm.{region}`) so it can create, read-when-re-keying, and re-encrypt SecureStrings under either the default `aws/ssm` key or a customer-managed key
|
|
32
|
+
- A single shared Lambda + `CustomResource` is created per stack; each secret is appended to the resource's `Parameters` list as `{ name, keyId? }` — the optional `keyId` carries the customer-managed KMS key ARN and is passed as `KeyId` on `PutParameter`
|
|
33
33
|
|
|
34
|
-
- **KMS encryption:** Uses the default `aws/ssm` managed KMS key (no custom key needed, $0/month)
|
|
34
|
+
- **KMS encryption:** Uses the default `aws/ssm` managed KMS key (no custom key needed, $0/month). Set `kmsKeyArn` to encrypt with a **customer-managed key** instead: the bulk-init Custom Resource passes it as `KeyId` on `PutParameter`, the handler is granted `kms:Decrypt`/`Encrypt` on that specific key ARN, and the runtime `put()` re-specifies the key on overwrite (SSM would otherwise fall back to `aws/ssm`). Changing `kmsKeyArn` later re-encrypts the existing value (see the Update behavior above). The CMK's own key policy must permit the app's execution role.
|
|
35
35
|
|
|
36
36
|
- **Handler permissions (the runtime `this.handler`):**
|
|
37
37
|
- `ssm:GetParameter` on the parameter ARN
|
|
38
38
|
- `ssm:PutParameter` on the parameter ARN
|
|
39
|
-
- `kms:Decrypt` **and** `kms:Encrypt` with a `kms:ViaService` condition restricting usage to `ssm.{region}.amazonaws.com`
|
|
39
|
+
- Default `aws/ssm` key: `kms:Decrypt` **and** `kms:Encrypt` with a `kms:ViaService` condition restricting usage to `ssm.{region}.amazonaws.com`
|
|
40
|
+
- Customer-managed key (`kmsKeyArn` set): `kms:Decrypt` **and** `kms:Encrypt` scoped to that specific key ARN, with **no** `ViaService` condition (see *KMS encryption* above). The CMK's own key policy must also permit this role.
|
|
40
41
|
|
|
41
42
|
## Serialization & Validation
|
|
42
43
|
|
|
@@ -63,7 +64,7 @@ The 4 KB (4096 bytes) size limit applies to the **JSON-encoded** value of non-se
|
|
|
63
64
|
- `put()` writes the value to disk immediately.
|
|
64
65
|
- Schema validation on `put()` when configured, throws `ValidationFailedException`.
|
|
65
66
|
- Validates 4 KB serialized value size limit for non-secret parameters.
|
|
66
|
-
- `secret: true` behaves identically to non-secret (no encryption locally).
|
|
67
|
+
- `secret: true` behaves identically to non-secret (no encryption locally); `kmsKeyArn` is ignored in the mock.
|
|
67
68
|
|
|
68
69
|
### Mock vs AWS Behavior Differences
|
|
69
70
|
|
package/README.md
CHANGED
|
@@ -26,9 +26,20 @@ const setting = new AppSetting(scope, id, options)
|
|
|
26
26
|
| `name` | `string` | No | SSM parameter path. When omitted, derived from the scope tree as `/${fullId}`, guaranteeing uniqueness within the stack. |
|
|
27
27
|
| `value` | `T` | No | Initial value. Required for non-secret parameters. Must not be provided when `secret` is set. |
|
|
28
28
|
| `schema` | `StandardSchemaV1<T>` | No | Runtime validation schema (Zod, Valibot, ArkType). Infers `T` from the schema. Cannot be used with `secret`. |
|
|
29
|
-
| `secret` | `boolean` | No | When `true`, creates an SSM SecureString encrypted with the `aws/ssm` KMS key. Cannot be used with `schema` or `value`. |
|
|
29
|
+
| `secret` | `boolean` | No | When `true`, creates an SSM SecureString encrypted with the default `aws/ssm` KMS key (or `kmsKeyArn` when set). Cannot be used with `schema` or `value`. |
|
|
30
|
+
| `kmsKeyArn` | `string` | No | ARN of a customer-managed KMS key to encrypt the SecureString, instead of the default `aws/ssm` key. Only valid with `secret: true`. See the note below. |
|
|
30
31
|
| `logger` | `ChildLogger` | No | Optional logger for internal operations. When omitted, a default Logger at error level is created. |
|
|
31
32
|
|
|
33
|
+
> **Customer-managed KMS key (`kmsKeyArn`):** Provide a CMK ARN to control the
|
|
34
|
+
> decrypt/grant scope of a secret (e.g. cross-account access or a dedicated key
|
|
35
|
+
> policy). The construct grants the shared handler `kms:Decrypt` (plus
|
|
36
|
+
> `kms:Encrypt` for secrets it writes) on that specific key ARN. Because AWS
|
|
37
|
+
> Blocks does not own a bring-your-own key, **the key's own key policy must also
|
|
38
|
+
> allow the app's Lambda execution role** to use it (or delegate to IAM via the
|
|
39
|
+
> account-root statement CDK adds to same-account keys). Adding or changing
|
|
40
|
+
> `kmsKeyArn` on an already-deployed secret re-encrypts its current value under
|
|
41
|
+
> the new key on the next deploy, so decryption keeps working.
|
|
42
|
+
|
|
32
43
|
> **Naming:** When you omit `name`, the framework derives a unique SSM path from
|
|
33
44
|
> the construct scope tree (`/${fullId}`). This is the recommended approach — it
|
|
34
45
|
> prevents collisions across stacks automatically. If you provide an explicit
|
package/dist/index.aws.d.ts
CHANGED
|
@@ -33,10 +33,12 @@ export declare class AppSetting<T = string> extends Scope {
|
|
|
33
33
|
static fromExisting<T = string>(scope: ScopeParent, id: string, options: {
|
|
34
34
|
name: string;
|
|
35
35
|
secret?: boolean;
|
|
36
|
+
kmsKeyArn?: string;
|
|
36
37
|
}): AppSetting<T>;
|
|
37
38
|
readonly bbName = "AppSetting";
|
|
38
39
|
private schema?;
|
|
39
40
|
private isSecret;
|
|
41
|
+
private kmsKeyArn?;
|
|
40
42
|
private client;
|
|
41
43
|
/** @internal Logger for internal operations. Defaults to error-level when not provided. */
|
|
42
44
|
protected log: ChildLogger;
|
package/dist/index.aws.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.aws.d.ts","sourceRoot":"","sources":["../src/index.aws.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,KAAK,EAA6C,MAAM,kBAAkB,CAAC;AACpF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAGpD,OAAO,KAAK,EAAE,iBAAiB,EAA6B,MAAM,YAAY,CAAC;AAG/E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAGzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,YAAY,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAqBpD;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,UAAU,CAAC,CAAC,GAAG,MAAM,CAAE,SAAQ,KAAK;IAChD;;;;;;OAMG;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.aws.d.ts","sourceRoot":"","sources":["../src/index.aws.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,KAAK,EAA6C,MAAM,kBAAkB,CAAC;AACpF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAGpD,OAAO,KAAK,EAAE,iBAAiB,EAA6B,MAAM,YAAY,CAAC;AAG/E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAGzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,YAAY,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAqBpD;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,UAAU,CAAC,CAAC,GAAG,MAAM,CAAE,SAAQ,KAAK;IAChD;;;;;;OAMG;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,QAAQ,CAAC,MAAM,gBAAW;IAC1B,OAAO,CAAC,MAAM,CAAC,CAAsB;IACrC,OAAO,CAAC,QAAQ,CAAU;IAC1B,OAAO,CAAC,SAAS,CAAC,CAAS;IAC3B,OAAO,CAAC,MAAM,CAAY;IAE1B,2FAA2F;IAC3F,SAAS,CAAC,GAAG,EAAE,WAAW,CAAC;gBAEf,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,CAAC,CAAC,CAAC;IAczE;;;;;;;;;;;;;OAaG;IACG,GAAG,IAAI,OAAO,CAAC,CAAC,CAAC;IA2BvB;;;;;;;;;;;;;;OAcG;IACG,GAAG,CAAC,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;CAelC"}
|
package/dist/index.aws.js
CHANGED
|
@@ -56,6 +56,7 @@ export class AppSetting extends Scope {
|
|
|
56
56
|
bbName = BB_NAME;
|
|
57
57
|
schema;
|
|
58
58
|
isSecret;
|
|
59
|
+
kmsKeyArn;
|
|
59
60
|
client;
|
|
60
61
|
/** @internal Logger for internal operations. Defaults to error-level when not provided. */
|
|
61
62
|
log;
|
|
@@ -65,6 +66,7 @@ export class AppSetting extends Scope {
|
|
|
65
66
|
const parameterName = options.name ?? `/${this.fullId}`;
|
|
66
67
|
this.schema = options.schema;
|
|
67
68
|
this.isSecret = options.secret ?? false;
|
|
69
|
+
this.kmsKeyArn = options.kmsKeyArn;
|
|
68
70
|
this.client = new SSMClient({
|
|
69
71
|
customUserAgent: this.buildUserAgentChain(),
|
|
70
72
|
});
|
|
@@ -130,6 +132,9 @@ export class AppSetting extends Scope {
|
|
|
130
132
|
Value: serialized,
|
|
131
133
|
Type: this.isSecret ? 'SecureString' : 'String',
|
|
132
134
|
Overwrite: true,
|
|
135
|
+
// Re-specify the CMK on overwrite: SSM falls back to the default aws/ssm
|
|
136
|
+
// key when KeyId is omitted, which would silently downgrade encryption.
|
|
137
|
+
...(this.isSecret && this.kmsKeyArn ? { KeyId: this.kmsKeyArn } : {}),
|
|
133
138
|
}));
|
|
134
139
|
}
|
|
135
140
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.aws.test.d.ts","sourceRoot":"","sources":["../src/index.aws.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import assert from 'node:assert';
|
|
4
|
+
import { describe, test, mock } from 'node:test';
|
|
5
|
+
import { SSMClient } from '@aws-sdk/client-ssm';
|
|
6
|
+
import { Scope } from '@aws-blocks/core';
|
|
7
|
+
import { AppSetting } from './index.aws.js';
|
|
8
|
+
const TEST_CMK = 'arn:aws:kms:us-east-1:111122223333:key/abcd1234-5678-90ab-cdef-1234567890ab';
|
|
9
|
+
/** Capture the input of the next PutParameterCommand sent by the runtime. */
|
|
10
|
+
function captureSend() {
|
|
11
|
+
const inputs = [];
|
|
12
|
+
const m = mock.method(SSMClient.prototype, 'send', async (cmd) => {
|
|
13
|
+
inputs.push(cmd.input);
|
|
14
|
+
return {};
|
|
15
|
+
});
|
|
16
|
+
return { inputs, restore: () => m.mock.restore() };
|
|
17
|
+
}
|
|
18
|
+
describe('AWS runtime put() KMS key', () => {
|
|
19
|
+
test('passes the CMK as KeyId when a secret is backed by kmsKeyArn', async () => {
|
|
20
|
+
const { inputs, restore } = captureSend();
|
|
21
|
+
try {
|
|
22
|
+
const setting = new AppSetting(new Scope('app'), 'cmk', { secret: true, kmsKeyArn: TEST_CMK });
|
|
23
|
+
await setting.put('rotated-value');
|
|
24
|
+
assert.strictEqual(inputs.length, 1);
|
|
25
|
+
assert.strictEqual(inputs[0].Type, 'SecureString');
|
|
26
|
+
assert.strictEqual(inputs[0].KeyId, TEST_CMK, 'overwrite must re-specify the CMK, not fall back to aws/ssm');
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
restore();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
test('omits KeyId for a default-key secret', async () => {
|
|
33
|
+
const { inputs, restore } = captureSend();
|
|
34
|
+
try {
|
|
35
|
+
const setting = new AppSetting(new Scope('app'), 'plain', { secret: true });
|
|
36
|
+
await setting.put('v');
|
|
37
|
+
assert.strictEqual(inputs[0].Type, 'SecureString');
|
|
38
|
+
assert.strictEqual(inputs[0].KeyId, undefined);
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
restore();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
test('omits KeyId for a non-secret String parameter', async () => {
|
|
45
|
+
const { inputs, restore } = captureSend();
|
|
46
|
+
try {
|
|
47
|
+
const setting = new AppSetting(new Scope('app'), 'cfg', { value: 'init' });
|
|
48
|
+
await setting.put('next');
|
|
49
|
+
assert.strictEqual(inputs[0].Type, 'String');
|
|
50
|
+
assert.strictEqual(inputs[0].KeyId, undefined);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
restore();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
package/dist/index.cdk.d.ts
CHANGED
|
@@ -10,7 +10,9 @@ export type { AppSettingOptions } from './types.js';
|
|
|
10
10
|
* - String parameters use `aws-cdk-lib/aws-ssm.StringParameter` directly.
|
|
11
11
|
* - SecureString parameters use a Custom Resource Lambda because
|
|
12
12
|
* CloudFormation cannot natively create SecureString parameters.
|
|
13
|
-
* - SecureString parameters are encrypted with the default `aws/ssm` KMS key
|
|
13
|
+
* - SecureString parameters are encrypted with the default `aws/ssm` KMS key,
|
|
14
|
+
* or with a customer-managed key when `kmsKeyArn` is provided (the handler is
|
|
15
|
+
* then granted `kms:Decrypt`/`Encrypt` on that specific key ARN).
|
|
14
16
|
*/
|
|
15
17
|
export declare class AppSetting<T = string> extends Scope {
|
|
16
18
|
/**
|
|
@@ -29,6 +31,7 @@ export declare class AppSetting<T = string> extends Scope {
|
|
|
29
31
|
static fromExisting<T = string>(scope: ScopeParent, id: string, options: {
|
|
30
32
|
name: string;
|
|
31
33
|
secret?: boolean;
|
|
34
|
+
kmsKeyArn?: string;
|
|
32
35
|
}): AppSetting<T>;
|
|
33
36
|
constructor(scope: ScopeParent, id: string, options: AppSettingOptions<T>);
|
|
34
37
|
}
|
package/dist/index.cdk.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cdk.d.ts","sourceRoot":"","sources":["../src/index.cdk.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.cdk.d.ts","sourceRoot":"","sources":["../src/index.cdk.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,KAAK,EAAwC,MAAM,sBAAsB,CAAC;AACnF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpD,OAAO,KAAK,EAAE,iBAAiB,EAA6B,MAAM,YAAY,CAAC;AAE/E,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,YAAY,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEpD;;;;;;;;;;GAUG;AACH,qBAAa,UAAU,CAAC,CAAC,GAAG,MAAM,CAAE,SAAQ,KAAK;IAChD;;;;;;;;;;;;OAYG;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;gBAKJ,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,CAAC,CAAC,CAAC;CAyJzE"}
|
package/dist/index.cdk.js
CHANGED
|
@@ -4,6 +4,7 @@ import * as cdk from 'aws-cdk-lib';
|
|
|
4
4
|
import * as ssm from 'aws-cdk-lib/aws-ssm';
|
|
5
5
|
import * as iam from 'aws-cdk-lib/aws-iam';
|
|
6
6
|
import * as lambda from 'aws-cdk-lib/aws-lambda';
|
|
7
|
+
import { LogGroup } from 'aws-cdk-lib/aws-logs';
|
|
7
8
|
import * as cr from 'aws-cdk-lib/custom-resources';
|
|
8
9
|
import { Scope, registerConfig, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
|
|
9
10
|
import { AppSettingErrors } from './errors.js';
|
|
@@ -15,7 +16,9 @@ export { AppSettingErrors } from './errors.js';
|
|
|
15
16
|
* - String parameters use `aws-cdk-lib/aws-ssm.StringParameter` directly.
|
|
16
17
|
* - SecureString parameters use a Custom Resource Lambda because
|
|
17
18
|
* CloudFormation cannot natively create SecureString parameters.
|
|
18
|
-
* - SecureString parameters are encrypted with the default `aws/ssm` KMS key
|
|
19
|
+
* - SecureString parameters are encrypted with the default `aws/ssm` KMS key,
|
|
20
|
+
* or with a customer-managed key when `kmsKeyArn` is provided (the handler is
|
|
21
|
+
* then granted `kms:Decrypt`/`Encrypt` on that specific key ARN).
|
|
19
22
|
*/
|
|
20
23
|
export class AppSetting extends Scope {
|
|
21
24
|
/**
|
|
@@ -53,6 +56,20 @@ export class AppSetting extends Scope {
|
|
|
53
56
|
err.name = AppSettingErrors.ValidationFailed;
|
|
54
57
|
throw err;
|
|
55
58
|
}
|
|
59
|
+
if (options.kmsKeyArn !== undefined) {
|
|
60
|
+
if (!options.secret) {
|
|
61
|
+
const err = new Error(`AppSetting '${id}': 'kmsKeyArn' is only valid with 'secret: true'. ` +
|
|
62
|
+
`Non-secret String parameters are not encrypted.`);
|
|
63
|
+
err.name = AppSettingErrors.ValidationFailed;
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
if (options.kmsKeyArn.trim() === '') {
|
|
67
|
+
const err = new Error(`AppSetting '${id}': 'kmsKeyArn' must be a non-empty KMS key ARN. ` +
|
|
68
|
+
`Omit it to use the default aws/ssm key.`);
|
|
69
|
+
err.name = AppSettingErrors.ValidationFailed;
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
56
73
|
if (options.secret && options.value !== undefined) {
|
|
57
74
|
const err = new Error(`AppSetting '${id}': secrets should not have a value in source code. ` +
|
|
58
75
|
`Remove the value — a random secret will be generated on first deploy. ` +
|
|
@@ -96,20 +113,33 @@ export class AppSetting extends Scope {
|
|
|
96
113
|
// then fail tagging it (AddTagsToResource needs ssm:GetParameters).
|
|
97
114
|
// We only need runtime read access, granted below.
|
|
98
115
|
if (!external) {
|
|
99
|
-
registerSecret(cdk.Stack.of(this), parameterName);
|
|
116
|
+
registerSecret(cdk.Stack.of(this), parameterName, this.defaults.logRetention, options.kmsKeyArn);
|
|
117
|
+
}
|
|
118
|
+
// Grant the handler KMS access. External secrets are read-only (Decrypt
|
|
119
|
+
// only); stack-managed secrets also need Encrypt so the app can write the
|
|
120
|
+
// value via put(). Standard-tier SecureStrings only use Encrypt/Decrypt
|
|
121
|
+
// (no GenerateDataKey — that's advanced-tier envelope encryption).
|
|
122
|
+
if (options.kmsKeyArn) {
|
|
123
|
+
// Customer-managed key: grant on the specific key ARN. NOTE: the key's
|
|
124
|
+
// own key policy must also allow this role (we can't edit a BYO key's
|
|
125
|
+
// policy from here) — see the README.
|
|
126
|
+
this.executionRole.addToPrincipalPolicy(new iam.PolicyStatement({
|
|
127
|
+
actions: external ? ['kms:Decrypt'] : ['kms:Decrypt', 'kms:Encrypt'],
|
|
128
|
+
resources: [options.kmsKeyArn],
|
|
129
|
+
}));
|
|
100
130
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
131
|
+
else {
|
|
132
|
+
// Default aws/ssm key: scope the wildcard with a ViaService condition.
|
|
133
|
+
this.executionRole.addToPrincipalPolicy(new iam.PolicyStatement({
|
|
134
|
+
actions: external ? ['kms:Decrypt'] : ['kms:Decrypt', 'kms:Encrypt'],
|
|
135
|
+
resources: ['*'],
|
|
136
|
+
conditions: {
|
|
137
|
+
StringEquals: {
|
|
138
|
+
'kms:ViaService': `ssm.${cdk.Stack.of(this).region}.amazonaws.com`,
|
|
139
|
+
},
|
|
110
140
|
},
|
|
111
|
-
}
|
|
112
|
-
}
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
113
143
|
}
|
|
114
144
|
else if (!external) {
|
|
115
145
|
// ── String parameter via CDK construct ──────────────────────────
|
|
@@ -125,7 +155,7 @@ export class AppSetting extends Scope {
|
|
|
125
155
|
// (external non-secret: parameter exists already; nothing to create.)
|
|
126
156
|
// Grant handler SSM access on this parameter. External parameters are owned
|
|
127
157
|
// elsewhere, so the app only reads them (no ssm:PutParameter).
|
|
128
|
-
this.
|
|
158
|
+
this.executionRole.addToPrincipalPolicy(new iam.PolicyStatement({
|
|
129
159
|
actions: external ? ['ssm:GetParameter'] : ['ssm:GetParameter', 'ssm:PutParameter'],
|
|
130
160
|
resources: [parameterArn],
|
|
131
161
|
}));
|
|
@@ -137,90 +167,116 @@ export class AppSetting extends Scope {
|
|
|
137
167
|
// ── Bulk Secret Initialization (one CustomResource per stack) ───────────────
|
|
138
168
|
const SECRET_BULK_KEY = Symbol.for('BLOCKS_SECRET_BULK_INIT');
|
|
139
169
|
/**
|
|
140
|
-
* Register a secret parameter
|
|
141
|
-
* Provider, and a single CustomResource.
|
|
142
|
-
* the parameter list (resolved lazily at
|
|
170
|
+
* Register a secret parameter (and its optional customer-managed KMS key). On
|
|
171
|
+
* first call, creates the shared Lambda, Provider, and a single CustomResource.
|
|
172
|
+
* All subsequent calls just append to the parameter list (resolved lazily at
|
|
173
|
+
* synth time).
|
|
143
174
|
*/
|
|
144
|
-
function registerSecret(stack, parameterName) {
|
|
175
|
+
function registerSecret(stack, parameterName, logRetention, keyId) {
|
|
145
176
|
let state = stack[SECRET_BULK_KEY];
|
|
146
177
|
if (state) {
|
|
147
|
-
state.
|
|
178
|
+
state.params.push({ name: parameterName, keyId });
|
|
148
179
|
return;
|
|
149
180
|
}
|
|
150
181
|
// First secret in this stack — create all shared infrastructure
|
|
151
|
-
state = {
|
|
182
|
+
state = { params: [{ name: parameterName, keyId }] };
|
|
152
183
|
stack[SECRET_BULK_KEY] = state;
|
|
153
184
|
const secretInitFn = new lambda.Function(stack, 'BlocksSecretInitFn', {
|
|
154
185
|
runtime: DEFAULT_NODE_RUNTIME,
|
|
155
186
|
handler: 'index.handler',
|
|
187
|
+
// Own the log group so its retention follows the stack-wide default
|
|
188
|
+
// instead of AWS's infinite retention.
|
|
189
|
+
logGroup: new LogGroup(stack, 'BlocksSecretInitLogs', {
|
|
190
|
+
retention: logRetention,
|
|
191
|
+
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
192
|
+
}),
|
|
156
193
|
code: lambda.Code.fromInline(`
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (event.RequestType === 'Create') {
|
|
172
|
-
for (const name of names) {
|
|
173
|
-
const secret = crypto.randomBytes(32).toString('base64url');
|
|
174
|
-
try {
|
|
175
|
-
await client.send(new PutParameterCommand({
|
|
176
|
-
Name: name, Value: secret, Type: 'SecureString', Overwrite: false, Tags: tags,
|
|
177
|
-
}));
|
|
178
|
-
} catch (e) {
|
|
179
|
-
if (e.name !== 'ParameterAlreadyExists') throw e;
|
|
180
|
-
if (tags.length) {
|
|
181
|
-
await client.send(new AddTagsToResourceCommand({ ResourceType: 'Parameter', ResourceId: name, Tags: tags }));
|
|
182
|
-
}
|
|
194
|
+
const { SSMClient, GetParameterCommand, PutParameterCommand, DeleteParameterCommand, AddTagsToResourceCommand } = require('@aws-sdk/client-ssm');
|
|
195
|
+
const crypto = require('crypto');
|
|
196
|
+
const client = new SSMClient({});
|
|
197
|
+
async function putSecret(p, tags) {
|
|
198
|
+
const secret = crypto.randomBytes(32).toString('base64url');
|
|
199
|
+
const input = { Name: p.name, Value: secret, Type: 'SecureString', Overwrite: false, Tags: tags };
|
|
200
|
+
// A CMK ARN pins the SecureString to a customer-managed key; omit for the default aws/ssm key.
|
|
201
|
+
if (p.keyId) input.KeyId = p.keyId;
|
|
202
|
+
try {
|
|
203
|
+
await client.send(new PutParameterCommand(input));
|
|
204
|
+
} catch (e) {
|
|
205
|
+
if (e.name !== 'ParameterAlreadyExists') throw e;
|
|
206
|
+
if (tags.length) {
|
|
207
|
+
await client.send(new AddTagsToResourceCommand({ ResourceType: 'Parameter', ResourceId: p.name, Tags: tags }));
|
|
183
208
|
}
|
|
184
209
|
}
|
|
185
|
-
return { PhysicalResourceId: 'bb-secrets-bulk' };
|
|
186
210
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
211
|
+
// Re-encrypt an EXISTING secret under a new (or removed) KMS key, preserving its
|
|
212
|
+
// current value. Without this, changing kmsKeyArn leaves the value encrypted under
|
|
213
|
+
// the old key while the app's IAM grant flips to the new key, so the next get()
|
|
214
|
+
// fails with AccessDenied.
|
|
215
|
+
async function reencrypt(p) {
|
|
216
|
+
const cur = await client.send(new GetParameterCommand({ Name: p.name, WithDecryption: true }));
|
|
217
|
+
const value = cur.Parameter && cur.Parameter.Value;
|
|
218
|
+
if (value === undefined || value === null) return;
|
|
219
|
+
const input = { Name: p.name, Value: value, Type: 'SecureString', Overwrite: true };
|
|
220
|
+
if (p.keyId) input.KeyId = p.keyId; // omit => back to the default aws/ssm key
|
|
221
|
+
await client.send(new PutParameterCommand(input));
|
|
222
|
+
}
|
|
223
|
+
// A pre-CMK deployment stored ParameterNames (string[]); map it to the {name} shape.
|
|
224
|
+
function readOld(op) {
|
|
225
|
+
if (Array.isArray(op.Parameters)) return op.Parameters;
|
|
226
|
+
if (Array.isArray(op.ParameterNames)) return op.ParameterNames.map((n) => ({ name: n }));
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
exports.handler = async (event) => {
|
|
230
|
+
const params = event.ResourceProperties.Parameters || [];
|
|
231
|
+
const stackName = event.ResourceProperties.StackName || '';
|
|
232
|
+
const tags = stackName ? [{ Key: 'aws-blocks-stack', Value: stackName }] : [];
|
|
233
|
+
const oldParams = readOld(event.OldResourceProperties || {});
|
|
234
|
+
const names = params.map(p => p.name);
|
|
235
|
+
const oldByName = Object.fromEntries(oldParams.map(p => [p.name, p]));
|
|
236
|
+
if (event.RequestType === 'Delete') {
|
|
237
|
+
for (const name of names) {
|
|
238
|
+
try { await client.send(new DeleteParameterCommand({ Name: name })); } catch {}
|
|
201
239
|
}
|
|
240
|
+
return { PhysicalResourceId: 'bb-secrets-bulk' };
|
|
202
241
|
}
|
|
203
|
-
|
|
204
|
-
|
|
242
|
+
if (event.RequestType === 'Create') {
|
|
243
|
+
for (const p of params) await putSecret(p, tags);
|
|
244
|
+
return { PhysicalResourceId: 'bb-secrets-bulk' };
|
|
245
|
+
}
|
|
246
|
+
if (event.RequestType === 'Update') {
|
|
247
|
+
for (const p of params) {
|
|
248
|
+
const old = oldByName[p.name];
|
|
249
|
+
if (!old) { await putSecret(p, tags); continue; } // newly added
|
|
250
|
+
if ((old.keyId || '') !== (p.keyId || '')) await reencrypt(p); // key changed => re-key, preserving value
|
|
251
|
+
// else: unchanged — leave the runtime-managed value alone
|
|
252
|
+
}
|
|
253
|
+
for (const name of Object.keys(oldByName)) {
|
|
254
|
+
if (!names.includes(name)) { try { await client.send(new DeleteParameterCommand({ Name: name })); } catch {} }
|
|
255
|
+
}
|
|
256
|
+
return { PhysicalResourceId: 'bb-secrets-bulk' };
|
|
205
257
|
}
|
|
206
258
|
return { PhysicalResourceId: 'bb-secrets-bulk' };
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
};
|
|
210
|
-
`),
|
|
259
|
+
};
|
|
260
|
+
`),
|
|
211
261
|
});
|
|
212
262
|
secretInitFn.addToRolePolicy(new iam.PolicyStatement({
|
|
213
|
-
|
|
263
|
+
// GetParameter is needed to read a secret's current value when re-keying it.
|
|
264
|
+
actions: ['ssm:GetParameter', 'ssm:PutParameter', 'ssm:DeleteParameter', 'ssm:AddTagsToResource'],
|
|
214
265
|
resources: cdk.Lazy.list({
|
|
215
|
-
produce: () => state.
|
|
266
|
+
produce: () => state.params.map(p => stack.formatArn({
|
|
216
267
|
service: 'ssm',
|
|
217
268
|
resource: 'parameter',
|
|
218
|
-
resourceName: name.replace(/^\//, ''),
|
|
269
|
+
resourceName: p.name.replace(/^\//, ''),
|
|
219
270
|
})),
|
|
220
271
|
}),
|
|
221
272
|
}));
|
|
273
|
+
// SSM SecureString encryption goes through KMS via the SSM service. Scoping to
|
|
274
|
+
// `kms:ViaService = ssm.<region>` covers both the default aws/ssm key and any
|
|
275
|
+
// customer-managed key used above (the CMK's own key policy must also allow
|
|
276
|
+
// this role). Encrypt = create/re-key; Decrypt = read the current value when
|
|
277
|
+
// re-keying. Standard-tier SecureStrings don't use GenerateDataKey.
|
|
222
278
|
secretInitFn.addToRolePolicy(new iam.PolicyStatement({
|
|
223
|
-
actions: ['kms:Encrypt'],
|
|
279
|
+
actions: ['kms:Encrypt', 'kms:Decrypt'],
|
|
224
280
|
resources: ['*'],
|
|
225
281
|
conditions: {
|
|
226
282
|
StringEquals: {
|
|
@@ -234,7 +290,7 @@ function registerSecret(stack, parameterName) {
|
|
|
234
290
|
new cdk.CustomResource(stack, 'BlocksSecretsBulk', {
|
|
235
291
|
serviceToken: provider.serviceToken,
|
|
236
292
|
properties: {
|
|
237
|
-
|
|
293
|
+
Parameters: cdk.Lazy.any({ produce: () => state.params }),
|
|
238
294
|
StackName: (() => { let s = stack; while (s.nestedStackParent)
|
|
239
295
|
s = s.nestedStackParent; return s.stackName; })(),
|
|
240
296
|
},
|
package/dist/index.cdk.test.js
CHANGED
|
@@ -10,28 +10,43 @@ import { test } from 'node:test';
|
|
|
10
10
|
import assert from 'node:assert';
|
|
11
11
|
import * as cdk from 'aws-cdk-lib';
|
|
12
12
|
import { Template, Match } from 'aws-cdk-lib/assertions';
|
|
13
|
-
import { Scope, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
|
|
13
|
+
import { Scope, DEFAULT_NODE_RUNTIME, BlocksPresets } from '@aws-blocks/core/cdk';
|
|
14
14
|
import { AppSetting } from './index.cdk.js';
|
|
15
15
|
class StubBlocksStack extends cdk.Stack {
|
|
16
16
|
handler;
|
|
17
|
+
executionRole;
|
|
17
18
|
id;
|
|
19
|
+
defaults = BlocksPresets.production;
|
|
18
20
|
constructor(scope, id) {
|
|
19
21
|
super(scope, id);
|
|
20
22
|
this.id = id;
|
|
21
23
|
globalThis.CURRENT_BLOCKS_STACK = this;
|
|
24
|
+
this.executionRole = new cdk.aws_iam.Role(this, 'BlocksRole', {
|
|
25
|
+
assumedBy: new cdk.aws_iam.ServicePrincipal('lambda.amazonaws.com'),
|
|
26
|
+
});
|
|
22
27
|
this.handler = new cdk.aws_lambda.Function(this, 'StubHandler', {
|
|
23
28
|
runtime: DEFAULT_NODE_RUNTIME,
|
|
24
29
|
handler: 'index.handler',
|
|
25
30
|
code: cdk.aws_lambda.Code.fromInline('exports.handler = async () => {};'),
|
|
31
|
+
role: this.executionRole,
|
|
26
32
|
});
|
|
27
33
|
}
|
|
28
34
|
}
|
|
29
|
-
function setup() {
|
|
35
|
+
function setup(defaults = BlocksPresets.production, stackId = 'TestStack') {
|
|
30
36
|
const app = new cdk.App();
|
|
31
|
-
const stack = new StubBlocksStack(app,
|
|
37
|
+
const stack = new StubBlocksStack(app, stackId);
|
|
38
|
+
stack.defaults = defaults;
|
|
32
39
|
const parent = new Scope('app');
|
|
33
40
|
return { stack, parent };
|
|
34
41
|
}
|
|
42
|
+
test('CDK: the secret-init Lambda log group adopts defaults.logRetention', () => {
|
|
43
|
+
const { stack, parent } = setup(BlocksPresets.sandbox, 'SecretRetentionStack');
|
|
44
|
+
new AppSetting(parent, 'my-secret', { secret: true, name: '/myapp/secret-key' });
|
|
45
|
+
const template = Template.fromStack(stack);
|
|
46
|
+
// The secret-init Lambda now owns an explicit log group whose retention
|
|
47
|
+
// follows the stack-wide default (sandbox → one week) instead of infinite.
|
|
48
|
+
template.hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 7 });
|
|
49
|
+
});
|
|
35
50
|
test('CDK: secret AppSetting SSM policy is scoped to specific parameter ARN (not wildcard)', () => {
|
|
36
51
|
const { stack, parent } = setup();
|
|
37
52
|
new AppSetting(parent, 'my-secret', { secret: true, name: '/myapp/secret-key' });
|
|
@@ -185,3 +200,106 @@ test('CDK: fromExisting still registers the runtime config key (BLOCKS_SSM_PARAM
|
|
|
185
200
|
assert.ok(registry.entries.has('BLOCKS_SSM_PARAM_DB_URL'), 'external setting must register BLOCKS_SSM_PARAM_DB_URL so the runtime can resolve the parameter');
|
|
186
201
|
assert.equal(registry.entries.get('BLOCKS_SSM_PARAM_DB_URL'), '/blocks/sandbox/db-abc-connection-string');
|
|
187
202
|
});
|
|
203
|
+
const TEST_CMK = 'arn:aws:kms:us-east-1:111122223333:key/abcd1234-5678-90ab-cdef-1234567890ab';
|
|
204
|
+
test('CDK: secret with kmsKeyArn grants handler KMS on the specific CMK ARN (not a ViaService wildcard)', () => {
|
|
205
|
+
const { stack, parent } = setup();
|
|
206
|
+
new AppSetting(parent, 'cmk-secret', { secret: true, name: '/app/cmk-secret', kmsKeyArn: TEST_CMK });
|
|
207
|
+
const template = Template.fromStack(stack);
|
|
208
|
+
const policies = template.findResources('AWS::IAM::Policy');
|
|
209
|
+
let found = false;
|
|
210
|
+
for (const logicalId of Object.keys(policies)) {
|
|
211
|
+
const statements = policies[logicalId]?.Properties?.PolicyDocument?.Statement;
|
|
212
|
+
if (!Array.isArray(statements))
|
|
213
|
+
continue;
|
|
214
|
+
for (const stmt of statements) {
|
|
215
|
+
const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action];
|
|
216
|
+
if (!actions.includes('kms:Decrypt'))
|
|
217
|
+
continue;
|
|
218
|
+
// The CMK grant scopes to the exact key ARN and carries no ViaService condition.
|
|
219
|
+
const resStr = JSON.stringify(stmt.Resource);
|
|
220
|
+
if (resStr.includes(TEST_CMK)) {
|
|
221
|
+
found = true;
|
|
222
|
+
assert.ok(actions.includes('kms:Encrypt'), 'stack-managed CMK secret should grant kms:Encrypt');
|
|
223
|
+
// Standard-tier SecureStrings only use Encrypt/Decrypt — no GenerateDataKey.
|
|
224
|
+
assert.ok(!actions.some((a) => a.startsWith('kms:GenerateDataKey')), 'CMK grant should not include kms:GenerateDataKey* (advanced-tier only)');
|
|
225
|
+
assert.strictEqual(stmt.Condition, undefined, 'CMK grant should not use a ViaService wildcard condition');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
assert.ok(found, `Expected a handler KMS grant scoped to the CMK ARN ${TEST_CMK}`);
|
|
230
|
+
});
|
|
231
|
+
test('CDK: secret with kmsKeyArn passes the KeyId to the bulk secret custom resource', () => {
|
|
232
|
+
const { stack, parent } = setup();
|
|
233
|
+
new AppSetting(parent, 'cmk-secret', { secret: true, name: '/app/cmk-secret', kmsKeyArn: TEST_CMK });
|
|
234
|
+
const template = Template.fromStack(stack);
|
|
235
|
+
const crs = template.findResources('AWS::CloudFormation::CustomResource');
|
|
236
|
+
const bulk = Object.values(crs).find((r) => Array.isArray(r?.Properties?.Parameters));
|
|
237
|
+
assert.ok(bulk, 'Expected a bulk-secrets custom resource with a Parameters list');
|
|
238
|
+
const params = bulk.Properties.Parameters;
|
|
239
|
+
const entry = params.find((p) => p.name === '/app/cmk-secret');
|
|
240
|
+
assert.ok(entry, 'the secret should appear in the bulk Parameters');
|
|
241
|
+
assert.strictEqual(entry?.keyId, TEST_CMK, 'the CMK ARN must be passed through as keyId');
|
|
242
|
+
});
|
|
243
|
+
test('CDK: a default-key secret carries no keyId in the bulk Parameters', () => {
|
|
244
|
+
const { stack, parent } = setup();
|
|
245
|
+
new AppSetting(parent, 'plain-secret', { secret: true, name: '/app/plain-secret' });
|
|
246
|
+
const template = Template.fromStack(stack);
|
|
247
|
+
const crs = template.findResources('AWS::CloudFormation::CustomResource');
|
|
248
|
+
const bulk = Object.values(crs).find((r) => Array.isArray(r?.Properties?.Parameters));
|
|
249
|
+
const params = bulk?.Properties?.Parameters;
|
|
250
|
+
const entry = params.find((p) => p.name === '/app/plain-secret');
|
|
251
|
+
assert.ok(entry, 'the secret should appear in the bulk Parameters');
|
|
252
|
+
assert.strictEqual(entry?.keyId, undefined, 'a default-key secret must not set keyId');
|
|
253
|
+
});
|
|
254
|
+
test('CDK: kmsKeyArn without secret throws ValidationFailed', () => {
|
|
255
|
+
const { parent } = setup();
|
|
256
|
+
assert.throws(() => new AppSetting(parent, 'bad', { value: 'x', name: '/app/bad', kmsKeyArn: TEST_CMK }), /only valid with 'secret: true'/);
|
|
257
|
+
});
|
|
258
|
+
test('CDK: fromExisting with kmsKeyArn grants read-only KMS scoped to the CMK ARN (Decrypt, no Encrypt)', () => {
|
|
259
|
+
const { stack, parent } = setup();
|
|
260
|
+
AppSetting.fromExisting(parent, 'ext-cmk', { name: '/ext/cmk-secret', secret: true, kmsKeyArn: TEST_CMK });
|
|
261
|
+
const template = Template.fromStack(stack);
|
|
262
|
+
const policies = template.findResources('AWS::IAM::Policy');
|
|
263
|
+
let found = false;
|
|
264
|
+
for (const logicalId of Object.keys(policies)) {
|
|
265
|
+
const statements = policies[logicalId]?.Properties?.PolicyDocument?.Statement;
|
|
266
|
+
if (!Array.isArray(statements))
|
|
267
|
+
continue;
|
|
268
|
+
for (const stmt of statements) {
|
|
269
|
+
const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action];
|
|
270
|
+
if (JSON.stringify(stmt.Resource).includes(TEST_CMK) && actions.includes('kms:Decrypt')) {
|
|
271
|
+
found = true;
|
|
272
|
+
assert.ok(!actions.includes('kms:Encrypt'), 'external (read-only) CMK secret must not grant kms:Encrypt');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
assert.ok(found, 'external CMK secret should grant kms:Decrypt scoped to the CMK ARN');
|
|
277
|
+
});
|
|
278
|
+
test('CDK: bulk-init role can read + re-key secrets (ssm:GetParameter + kms:Encrypt/Decrypt), no GenerateDataKey', () => {
|
|
279
|
+
// The re-encrypt-on-key-change path reads the current value (GetParameter +
|
|
280
|
+
// Decrypt) and rewrites it under the new key (Encrypt). The bulk-init statement
|
|
281
|
+
// is uniquely identified by ssm:AddTagsToResource.
|
|
282
|
+
const { stack, parent } = setup();
|
|
283
|
+
new AppSetting(parent, 'cmk-secret', { secret: true, name: '/app/cmk-secret', kmsKeyArn: TEST_CMK });
|
|
284
|
+
const template = Template.fromStack(stack);
|
|
285
|
+
const policies = template.findResources('AWS::IAM::Policy');
|
|
286
|
+
let bulkSsm;
|
|
287
|
+
let bulkKms;
|
|
288
|
+
for (const logicalId of Object.keys(policies)) {
|
|
289
|
+
const statements = policies[logicalId]?.Properties?.PolicyDocument?.Statement;
|
|
290
|
+
if (!Array.isArray(statements))
|
|
291
|
+
continue;
|
|
292
|
+
for (const stmt of statements) {
|
|
293
|
+
const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action];
|
|
294
|
+
if (actions.includes('ssm:AddTagsToResource'))
|
|
295
|
+
bulkSsm = actions;
|
|
296
|
+
if (actions.includes('kms:Encrypt') && stmt.Condition?.StringEquals?.['kms:ViaService'])
|
|
297
|
+
bulkKms = actions;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
assert.ok(bulkSsm, 'expected the bulk-init ssm statement (identified by AddTagsToResource)');
|
|
301
|
+
assert.ok(bulkSsm?.includes('ssm:GetParameter'), 'bulk-init needs ssm:GetParameter to read a value when re-keying');
|
|
302
|
+
assert.ok(bulkKms, 'expected the bulk-init KMS statement (ViaService-scoped)');
|
|
303
|
+
assert.ok(bulkKms?.includes('kms:Decrypt'), 'bulk-init needs kms:Decrypt to read the current value when re-keying');
|
|
304
|
+
assert.ok(!bulkKms?.some((a) => a.startsWith('kms:GenerateDataKey')), 'no GenerateDataKey* — standard-tier SecureStrings do not use it');
|
|
305
|
+
});
|