@aws-blocks/bb-app-setting 0.1.4 → 0.2.0

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