@aws-blocks/bb-app-setting 0.1.3 → 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 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); existing values are left untouched and managed at runtime via `put()`
31
- - Granted `ssm:PutParameter` and `ssm:DeleteParameter` scoped to the parameter ARN, plus `kms:Encrypt` (with the `kms:ViaService` condition) so it can write the encrypted SecureString
32
- - A single shared Lambda + `CustomResource` is created per stack; each secret parameter name is appended to the resource's `ParameterNames` list
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
@@ -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;
@@ -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,GACzC,UAAU,CAAC,CAAC,CAAC;IAKhB,QAAQ,CAAC,MAAM,gBAAW;IAC1B,OAAO,CAAC,MAAM,CAAC,CAAsB;IACrC,OAAO,CAAC,QAAQ,CAAU;IAC1B,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;IAazE;;;;;;;;;;;;;OAaG;IACG,GAAG,IAAI,OAAO,CAAC,CAAC,CAAC;IA2BvB;;;;;;;;;;;;;;OAcG;IACG,GAAG,CAAC,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;CAYlC"}
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.aws.test.d.ts.map
@@ -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
+ });
@@ -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
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.cdk.d.ts","sourceRoot":"","sources":["../src/index.cdk.ts"],"names":[],"mappings":"AAQA,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;;;;;;;;GAQG;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,CAAA;KAAE,GACzC,UAAU,CAAC,CAAC,CAAC;gBAKJ,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,CAAC,CAAC,CAAC;CA0HzE"}
1
+ {"version":3,"file":"index.cdk.d.ts","sourceRoot":"","sources":["../src/index.cdk.ts"],"names":[],"mappings":"AAQA,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
@@ -15,7 +15,9 @@ export { AppSettingErrors } from './errors.js';
15
15
  * - String parameters use `aws-cdk-lib/aws-ssm.StringParameter` directly.
16
16
  * - SecureString parameters use a Custom Resource Lambda because
17
17
  * CloudFormation cannot natively create SecureString parameters.
18
- * - SecureString parameters are encrypted with the default `aws/ssm` KMS key.
18
+ * - SecureString parameters are encrypted with the default `aws/ssm` KMS key,
19
+ * or with a customer-managed key when `kmsKeyArn` is provided (the handler is
20
+ * then granted `kms:Decrypt`/`Encrypt` on that specific key ARN).
19
21
  */
20
22
  export class AppSetting extends Scope {
21
23
  /**
@@ -53,6 +55,20 @@ export class AppSetting extends Scope {
53
55
  err.name = AppSettingErrors.ValidationFailed;
54
56
  throw err;
55
57
  }
58
+ if (options.kmsKeyArn !== undefined) {
59
+ if (!options.secret) {
60
+ const err = new Error(`AppSetting '${id}': 'kmsKeyArn' is only valid with 'secret: true'. ` +
61
+ `Non-secret String parameters are not encrypted.`);
62
+ err.name = AppSettingErrors.ValidationFailed;
63
+ throw err;
64
+ }
65
+ if (options.kmsKeyArn.trim() === '') {
66
+ const err = new Error(`AppSetting '${id}': 'kmsKeyArn' must be a non-empty KMS key ARN. ` +
67
+ `Omit it to use the default aws/ssm key.`);
68
+ err.name = AppSettingErrors.ValidationFailed;
69
+ throw err;
70
+ }
71
+ }
56
72
  if (options.secret && options.value !== undefined) {
57
73
  const err = new Error(`AppSetting '${id}': secrets should not have a value in source code. ` +
58
74
  `Remove the value — a random secret will be generated on first deploy. ` +
@@ -96,20 +112,33 @@ export class AppSetting extends Scope {
96
112
  // then fail tagging it (AddTagsToResource needs ssm:GetParameters).
97
113
  // We only need runtime read access, granted below.
98
114
  if (!external) {
99
- registerSecret(cdk.Stack.of(this), parameterName);
115
+ registerSecret(cdk.Stack.of(this), parameterName, options.kmsKeyArn);
116
+ }
117
+ // Grant the handler KMS access. External secrets are read-only (Decrypt
118
+ // only); stack-managed secrets also need Encrypt so the app can write the
119
+ // value via put(). Standard-tier SecureStrings only use Encrypt/Decrypt
120
+ // (no GenerateDataKey — that's advanced-tier envelope encryption).
121
+ if (options.kmsKeyArn) {
122
+ // Customer-managed key: grant on the specific key ARN. NOTE: the key's
123
+ // own key policy must also allow this role (we can't edit a BYO key's
124
+ // policy from here) — see the README.
125
+ this.executionRole.addToPrincipalPolicy(new iam.PolicyStatement({
126
+ actions: external ? ['kms:Decrypt'] : ['kms:Decrypt', 'kms:Encrypt'],
127
+ resources: [options.kmsKeyArn],
128
+ }));
100
129
  }
101
- // Grant handler KMS access for the default aws/ssm key. External secrets
102
- // are read-only (Decrypt only); stack-managed secrets also need Encrypt
103
- // so the app can write the value via put().
104
- this.handler.addToRolePolicy(new iam.PolicyStatement({
105
- actions: external ? ['kms:Decrypt'] : ['kms:Decrypt', 'kms:Encrypt'],
106
- resources: ['*'],
107
- conditions: {
108
- StringEquals: {
109
- 'kms:ViaService': `ssm.${cdk.Stack.of(this).region}.amazonaws.com`,
130
+ else {
131
+ // Default aws/ssm key: scope the wildcard with a ViaService condition.
132
+ this.executionRole.addToPrincipalPolicy(new iam.PolicyStatement({
133
+ actions: external ? ['kms:Decrypt'] : ['kms:Decrypt', 'kms:Encrypt'],
134
+ resources: ['*'],
135
+ conditions: {
136
+ StringEquals: {
137
+ 'kms:ViaService': `ssm.${cdk.Stack.of(this).region}.amazonaws.com`,
138
+ },
110
139
  },
111
- },
112
- }));
140
+ }));
141
+ }
113
142
  }
114
143
  else if (!external) {
115
144
  // ── String parameter via CDK construct ──────────────────────────
@@ -125,7 +154,7 @@ export class AppSetting extends Scope {
125
154
  // (external non-secret: parameter exists already; nothing to create.)
126
155
  // Grant handler SSM access on this parameter. External parameters are owned
127
156
  // elsewhere, so the app only reads them (no ssm:PutParameter).
128
- this.handler.addToRolePolicy(new iam.PolicyStatement({
157
+ this.executionRole.addToPrincipalPolicy(new iam.PolicyStatement({
129
158
  actions: external ? ['ssm:GetParameter'] : ['ssm:GetParameter', 'ssm:PutParameter'],
130
159
  resources: [parameterArn],
131
160
  }));
@@ -137,90 +166,110 @@ export class AppSetting extends Scope {
137
166
  // ── Bulk Secret Initialization (one CustomResource per stack) ───────────────
138
167
  const SECRET_BULK_KEY = Symbol.for('BLOCKS_SECRET_BULK_INIT');
139
168
  /**
140
- * Register a secret parameter name. On first call, creates the shared Lambda,
141
- * Provider, and a single CustomResource. All subsequent calls just append to
142
- * the parameter list (resolved lazily at synth time).
169
+ * Register a secret parameter (and its optional customer-managed KMS key). On
170
+ * first call, creates the shared Lambda, Provider, and a single CustomResource.
171
+ * All subsequent calls just append to the parameter list (resolved lazily at
172
+ * synth time).
143
173
  */
144
- function registerSecret(stack, parameterName) {
174
+ function registerSecret(stack, parameterName, keyId) {
145
175
  let state = stack[SECRET_BULK_KEY];
146
176
  if (state) {
147
- state.parameterNames.push(parameterName);
177
+ state.params.push({ name: parameterName, keyId });
148
178
  return;
149
179
  }
150
180
  // First secret in this stack — create all shared infrastructure
151
- state = { parameterNames: [parameterName] };
181
+ state = { params: [{ name: parameterName, keyId }] };
152
182
  stack[SECRET_BULK_KEY] = state;
153
183
  const secretInitFn = new lambda.Function(stack, 'BlocksSecretInitFn', {
154
184
  runtime: DEFAULT_NODE_RUNTIME,
155
185
  handler: 'index.handler',
156
186
  code: lambda.Code.fromInline(`
157
- const { SSMClient, PutParameterCommand, DeleteParameterCommand, AddTagsToResourceCommand } = require('@aws-sdk/client-ssm');
158
- const crypto = require('crypto');
159
- const client = new SSMClient({});
160
- exports.handler = async (event) => {
161
- const names = event.ResourceProperties.ParameterNames || [];
162
- const stackName = event.ResourceProperties.StackName || '';
163
- const tags = stackName ? [{ Key: 'aws-blocks-stack', Value: stackName }] : [];
164
- const oldNames = (event.OldResourceProperties || {}).ParameterNames || [];
165
- if (event.RequestType === 'Delete') {
166
- for (const name of names) {
167
- try { await client.send(new DeleteParameterCommand({ Name: name })); } catch {}
168
- }
169
- return { PhysicalResourceId: 'bb-secrets-bulk' };
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
- }
187
+ const { SSMClient, GetParameterCommand, PutParameterCommand, DeleteParameterCommand, AddTagsToResourceCommand } = require('@aws-sdk/client-ssm');
188
+ const crypto = require('crypto');
189
+ const client = new SSMClient({});
190
+ async function putSecret(p, tags) {
191
+ const secret = crypto.randomBytes(32).toString('base64url');
192
+ const input = { Name: p.name, Value: secret, Type: 'SecureString', Overwrite: false, Tags: tags };
193
+ // A CMK ARN pins the SecureString to a customer-managed key; omit for the default aws/ssm key.
194
+ if (p.keyId) input.KeyId = p.keyId;
195
+ try {
196
+ await client.send(new PutParameterCommand(input));
197
+ } catch (e) {
198
+ if (e.name !== 'ParameterAlreadyExists') throw e;
199
+ if (tags.length) {
200
+ await client.send(new AddTagsToResourceCommand({ ResourceType: 'Parameter', ResourceId: p.name, Tags: tags }));
183
201
  }
184
202
  }
185
- return { PhysicalResourceId: 'bb-secrets-bulk' };
186
203
  }
187
- if (event.RequestType === 'Update') {
188
- const added = names.filter(n => !oldNames.includes(n));
189
- const removed = oldNames.filter(n => !names.includes(n));
190
- for (const name of added) {
191
- const secret = crypto.randomBytes(32).toString('base64url');
192
- try {
193
- await client.send(new PutParameterCommand({
194
- Name: name, Value: secret, Type: 'SecureString', Overwrite: false, Tags: tags,
195
- }));
196
- } catch (e) {
197
- if (e.name !== 'ParameterAlreadyExists') throw e;
198
- if (tags.length) {
199
- await client.send(new AddTagsToResourceCommand({ ResourceType: 'Parameter', ResourceId: name, Tags: tags }));
200
- }
204
+ // Re-encrypt an EXISTING secret under a new (or removed) KMS key, preserving its
205
+ // current value. Without this, changing kmsKeyArn leaves the value encrypted under
206
+ // the old key while the app's IAM grant flips to the new key, so the next get()
207
+ // fails with AccessDenied.
208
+ async function reencrypt(p) {
209
+ const cur = await client.send(new GetParameterCommand({ Name: p.name, WithDecryption: true }));
210
+ const value = cur.Parameter && cur.Parameter.Value;
211
+ if (value === undefined || value === null) return;
212
+ const input = { Name: p.name, Value: value, Type: 'SecureString', Overwrite: true };
213
+ if (p.keyId) input.KeyId = p.keyId; // omit => back to the default aws/ssm key
214
+ await client.send(new PutParameterCommand(input));
215
+ }
216
+ // A pre-CMK deployment stored ParameterNames (string[]); map it to the {name} shape.
217
+ function readOld(op) {
218
+ if (Array.isArray(op.Parameters)) return op.Parameters;
219
+ if (Array.isArray(op.ParameterNames)) return op.ParameterNames.map((n) => ({ name: n }));
220
+ return [];
221
+ }
222
+ exports.handler = async (event) => {
223
+ const params = event.ResourceProperties.Parameters || [];
224
+ const stackName = event.ResourceProperties.StackName || '';
225
+ const tags = stackName ? [{ Key: 'aws-blocks-stack', Value: stackName }] : [];
226
+ const oldParams = readOld(event.OldResourceProperties || {});
227
+ const names = params.map(p => p.name);
228
+ const oldByName = Object.fromEntries(oldParams.map(p => [p.name, p]));
229
+ if (event.RequestType === 'Delete') {
230
+ for (const name of names) {
231
+ try { await client.send(new DeleteParameterCommand({ Name: name })); } catch {}
201
232
  }
233
+ return { PhysicalResourceId: 'bb-secrets-bulk' };
202
234
  }
203
- for (const name of removed) {
204
- try { await client.send(new DeleteParameterCommand({ Name: name })); } catch {}
235
+ if (event.RequestType === 'Create') {
236
+ for (const p of params) await putSecret(p, tags);
237
+ return { PhysicalResourceId: 'bb-secrets-bulk' };
238
+ }
239
+ if (event.RequestType === 'Update') {
240
+ for (const p of params) {
241
+ const old = oldByName[p.name];
242
+ if (!old) { await putSecret(p, tags); continue; } // newly added
243
+ if ((old.keyId || '') !== (p.keyId || '')) await reencrypt(p); // key changed => re-key, preserving value
244
+ // else: unchanged — leave the runtime-managed value alone
245
+ }
246
+ for (const name of Object.keys(oldByName)) {
247
+ if (!names.includes(name)) { try { await client.send(new DeleteParameterCommand({ Name: name })); } catch {} }
248
+ }
249
+ return { PhysicalResourceId: 'bb-secrets-bulk' };
205
250
  }
206
251
  return { PhysicalResourceId: 'bb-secrets-bulk' };
207
- }
208
- return { PhysicalResourceId: 'bb-secrets-bulk' };
209
- };
210
- `),
252
+ };
253
+ `),
211
254
  });
212
255
  secretInitFn.addToRolePolicy(new iam.PolicyStatement({
213
- actions: ['ssm:PutParameter', 'ssm:DeleteParameter', 'ssm:AddTagsToResource'],
256
+ // GetParameter is needed to read a secret's current value when re-keying it.
257
+ actions: ['ssm:GetParameter', 'ssm:PutParameter', 'ssm:DeleteParameter', 'ssm:AddTagsToResource'],
214
258
  resources: cdk.Lazy.list({
215
- produce: () => state.parameterNames.map(name => stack.formatArn({
259
+ produce: () => state.params.map(p => stack.formatArn({
216
260
  service: 'ssm',
217
261
  resource: 'parameter',
218
- resourceName: name.replace(/^\//, ''),
262
+ resourceName: p.name.replace(/^\//, ''),
219
263
  })),
220
264
  }),
221
265
  }));
266
+ // SSM SecureString encryption goes through KMS via the SSM service. Scoping to
267
+ // `kms:ViaService = ssm.<region>` covers both the default aws/ssm key and any
268
+ // customer-managed key used above (the CMK's own key policy must also allow
269
+ // this role). Encrypt = create/re-key; Decrypt = read the current value when
270
+ // re-keying. Standard-tier SecureStrings don't use GenerateDataKey.
222
271
  secretInitFn.addToRolePolicy(new iam.PolicyStatement({
223
- actions: ['kms:Encrypt'],
272
+ actions: ['kms:Encrypt', 'kms:Decrypt'],
224
273
  resources: ['*'],
225
274
  conditions: {
226
275
  StringEquals: {
@@ -234,7 +283,7 @@ function registerSecret(stack, parameterName) {
234
283
  new cdk.CustomResource(stack, 'BlocksSecretsBulk', {
235
284
  serviceToken: provider.serviceToken,
236
285
  properties: {
237
- ParameterNames: cdk.Lazy.list({ produce: () => state.parameterNames }),
286
+ Parameters: cdk.Lazy.any({ produce: () => state.params }),
238
287
  StackName: (() => { let s = stack; while (s.nestedStackParent)
239
288
  s = s.nestedStackParent; return s.stackName; })(),
240
289
  },
@@ -14,15 +14,20 @@ import { Scope, DEFAULT_NODE_RUNTIME } 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;
18
19
  constructor(scope, id) {
19
20
  super(scope, id);
20
21
  this.id = id;
21
22
  globalThis.CURRENT_BLOCKS_STACK = this;
23
+ this.executionRole = new cdk.aws_iam.Role(this, 'BlocksRole', {
24
+ assumedBy: new cdk.aws_iam.ServicePrincipal('lambda.amazonaws.com'),
25
+ });
22
26
  this.handler = new cdk.aws_lambda.Function(this, 'StubHandler', {
23
27
  runtime: DEFAULT_NODE_RUNTIME,
24
28
  handler: 'index.handler',
25
29
  code: cdk.aws_lambda.Code.fromInline('exports.handler = async () => {};'),
30
+ role: this.executionRole,
26
31
  });
27
32
  }
28
33
  }
@@ -185,3 +190,106 @@ test('CDK: fromExisting still registers the runtime config key (BLOCKS_SSM_PARAM
185
190
  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
191
  assert.equal(registry.entries.get('BLOCKS_SSM_PARAM_DB_URL'), '/blocks/sandbox/db-abc-connection-string');
187
192
  });
193
+ const TEST_CMK = 'arn:aws:kms:us-east-1:111122223333:key/abcd1234-5678-90ab-cdef-1234567890ab';
194
+ test('CDK: secret with kmsKeyArn grants handler KMS on the specific CMK ARN (not a ViaService wildcard)', () => {
195
+ const { stack, parent } = setup();
196
+ new AppSetting(parent, 'cmk-secret', { secret: true, name: '/app/cmk-secret', kmsKeyArn: TEST_CMK });
197
+ const template = Template.fromStack(stack);
198
+ const policies = template.findResources('AWS::IAM::Policy');
199
+ let found = false;
200
+ for (const logicalId of Object.keys(policies)) {
201
+ const statements = policies[logicalId]?.Properties?.PolicyDocument?.Statement;
202
+ if (!Array.isArray(statements))
203
+ continue;
204
+ for (const stmt of statements) {
205
+ const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action];
206
+ if (!actions.includes('kms:Decrypt'))
207
+ continue;
208
+ // The CMK grant scopes to the exact key ARN and carries no ViaService condition.
209
+ const resStr = JSON.stringify(stmt.Resource);
210
+ if (resStr.includes(TEST_CMK)) {
211
+ found = true;
212
+ assert.ok(actions.includes('kms:Encrypt'), 'stack-managed CMK secret should grant kms:Encrypt');
213
+ // Standard-tier SecureStrings only use Encrypt/Decrypt — no GenerateDataKey.
214
+ assert.ok(!actions.some((a) => a.startsWith('kms:GenerateDataKey')), 'CMK grant should not include kms:GenerateDataKey* (advanced-tier only)');
215
+ assert.strictEqual(stmt.Condition, undefined, 'CMK grant should not use a ViaService wildcard condition');
216
+ }
217
+ }
218
+ }
219
+ assert.ok(found, `Expected a handler KMS grant scoped to the CMK ARN ${TEST_CMK}`);
220
+ });
221
+ test('CDK: secret with kmsKeyArn passes the KeyId to the bulk secret custom resource', () => {
222
+ const { stack, parent } = setup();
223
+ new AppSetting(parent, 'cmk-secret', { secret: true, name: '/app/cmk-secret', kmsKeyArn: TEST_CMK });
224
+ const template = Template.fromStack(stack);
225
+ const crs = template.findResources('AWS::CloudFormation::CustomResource');
226
+ const bulk = Object.values(crs).find((r) => Array.isArray(r?.Properties?.Parameters));
227
+ assert.ok(bulk, 'Expected a bulk-secrets custom resource with a Parameters list');
228
+ const params = bulk.Properties.Parameters;
229
+ const entry = params.find((p) => p.name === '/app/cmk-secret');
230
+ assert.ok(entry, 'the secret should appear in the bulk Parameters');
231
+ assert.strictEqual(entry?.keyId, TEST_CMK, 'the CMK ARN must be passed through as keyId');
232
+ });
233
+ test('CDK: a default-key secret carries no keyId in the bulk Parameters', () => {
234
+ const { stack, parent } = setup();
235
+ new AppSetting(parent, 'plain-secret', { secret: true, name: '/app/plain-secret' });
236
+ const template = Template.fromStack(stack);
237
+ const crs = template.findResources('AWS::CloudFormation::CustomResource');
238
+ const bulk = Object.values(crs).find((r) => Array.isArray(r?.Properties?.Parameters));
239
+ const params = bulk?.Properties?.Parameters;
240
+ const entry = params.find((p) => p.name === '/app/plain-secret');
241
+ assert.ok(entry, 'the secret should appear in the bulk Parameters');
242
+ assert.strictEqual(entry?.keyId, undefined, 'a default-key secret must not set keyId');
243
+ });
244
+ test('CDK: kmsKeyArn without secret throws ValidationFailed', () => {
245
+ const { parent } = setup();
246
+ assert.throws(() => new AppSetting(parent, 'bad', { value: 'x', name: '/app/bad', kmsKeyArn: TEST_CMK }), /only valid with 'secret: true'/);
247
+ });
248
+ test('CDK: fromExisting with kmsKeyArn grants read-only KMS scoped to the CMK ARN (Decrypt, no Encrypt)', () => {
249
+ const { stack, parent } = setup();
250
+ AppSetting.fromExisting(parent, 'ext-cmk', { name: '/ext/cmk-secret', secret: true, kmsKeyArn: TEST_CMK });
251
+ const template = Template.fromStack(stack);
252
+ const policies = template.findResources('AWS::IAM::Policy');
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))
257
+ continue;
258
+ for (const stmt of statements) {
259
+ const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action];
260
+ if (JSON.stringify(stmt.Resource).includes(TEST_CMK) && actions.includes('kms:Decrypt')) {
261
+ found = true;
262
+ assert.ok(!actions.includes('kms:Encrypt'), 'external (read-only) CMK secret must not grant kms:Encrypt');
263
+ }
264
+ }
265
+ }
266
+ assert.ok(found, 'external CMK secret should grant kms:Decrypt scoped to the CMK ARN');
267
+ });
268
+ test('CDK: bulk-init role can read + re-key secrets (ssm:GetParameter + kms:Encrypt/Decrypt), no GenerateDataKey', () => {
269
+ // The re-encrypt-on-key-change path reads the current value (GetParameter +
270
+ // Decrypt) and rewrites it under the new key (Encrypt). The bulk-init statement
271
+ // is uniquely identified by ssm:AddTagsToResource.
272
+ const { stack, parent } = setup();
273
+ new AppSetting(parent, 'cmk-secret', { secret: true, name: '/app/cmk-secret', kmsKeyArn: TEST_CMK });
274
+ const template = Template.fromStack(stack);
275
+ const policies = template.findResources('AWS::IAM::Policy');
276
+ let bulkSsm;
277
+ let bulkKms;
278
+ for (const logicalId of Object.keys(policies)) {
279
+ const statements = policies[logicalId]?.Properties?.PolicyDocument?.Statement;
280
+ if (!Array.isArray(statements))
281
+ continue;
282
+ for (const stmt of statements) {
283
+ const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action];
284
+ if (actions.includes('ssm:AddTagsToResource'))
285
+ bulkSsm = actions;
286
+ if (actions.includes('kms:Encrypt') && stmt.Condition?.StringEquals?.['kms:ViaService'])
287
+ bulkKms = actions;
288
+ }
289
+ }
290
+ assert.ok(bulkSsm, 'expected the bulk-init ssm statement (identified by AddTagsToResource)');
291
+ assert.ok(bulkSsm?.includes('ssm:GetParameter'), 'bulk-init needs ssm:GetParameter to read a value when re-keying');
292
+ assert.ok(bulkKms, 'expected the bulk-init KMS statement (ViaService-scoped)');
293
+ assert.ok(bulkKms?.includes('kms:Decrypt'), 'bulk-init needs kms:Decrypt to read the current value when re-keying');
294
+ assert.ok(!bulkKms?.some((a) => a.startsWith('kms:GenerateDataKey')), 'no GenerateDataKey* — standard-tier SecureStrings do not use it');
295
+ });
@@ -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;
@@ -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,GACzC,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"}
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"}