@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/src/index.cdk.ts CHANGED
@@ -5,6 +5,7 @@ import * as cdk from 'aws-cdk-lib';
5
5
  import * as ssm from 'aws-cdk-lib/aws-ssm';
6
6
  import * as iam from 'aws-cdk-lib/aws-iam';
7
7
  import * as lambda from 'aws-cdk-lib/aws-lambda';
8
+ import { LogGroup, type RetentionDays } from 'aws-cdk-lib/aws-logs';
8
9
  import * as cr from 'aws-cdk-lib/custom-resources';
9
10
  import { Scope, registerConfig, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
10
11
  import type { ScopeParent } from '@aws-blocks/core';
@@ -21,7 +22,9 @@ export type { AppSettingOptions } from './types.js';
21
22
  * - String parameters use `aws-cdk-lib/aws-ssm.StringParameter` directly.
22
23
  * - SecureString parameters use a Custom Resource Lambda because
23
24
  * CloudFormation cannot natively create SecureString parameters.
24
- * - SecureString parameters are encrypted with the default `aws/ssm` KMS key.
25
+ * - SecureString parameters are encrypted with the default `aws/ssm` KMS key,
26
+ * or with a customer-managed key when `kmsKeyArn` is provided (the handler is
27
+ * then granted `kms:Decrypt`/`Encrypt` on that specific key ARN).
25
28
  */
26
29
  export class AppSetting<T = string> extends Scope {
27
30
  /**
@@ -40,7 +43,7 @@ export class AppSetting<T = string> extends Scope {
40
43
  static fromExisting<T = string>(
41
44
  scope: ScopeParent,
42
45
  id: string,
43
- options: { name: string; secret?: boolean },
46
+ options: { name: string; secret?: boolean; kmsKeyArn?: string },
44
47
  ): AppSetting<T> {
45
48
  const opts: InternalAppSettingOptions<T> = { ...options, external: true };
46
49
  return new AppSetting<T>(scope, id, opts);
@@ -72,6 +75,25 @@ export class AppSetting<T = string> extends Scope {
72
75
  throw err;
73
76
  }
74
77
 
78
+ if (options.kmsKeyArn !== undefined) {
79
+ if (!options.secret) {
80
+ const err = new Error(
81
+ `AppSetting '${id}': 'kmsKeyArn' is only valid with 'secret: true'. ` +
82
+ `Non-secret String parameters are not encrypted.`
83
+ );
84
+ err.name = AppSettingErrors.ValidationFailed;
85
+ throw err;
86
+ }
87
+ if (options.kmsKeyArn.trim() === '') {
88
+ const err = new Error(
89
+ `AppSetting '${id}': 'kmsKeyArn' must be a non-empty KMS key ARN. ` +
90
+ `Omit it to use the default aws/ssm key.`
91
+ );
92
+ err.name = AppSettingErrors.ValidationFailed;
93
+ throw err;
94
+ }
95
+ }
96
+
75
97
  if (options.secret && options.value !== undefined) {
76
98
  const err = new Error(
77
99
  `AppSetting '${id}': secrets should not have a value in source code. ` +
@@ -130,21 +152,33 @@ export class AppSetting<T = string> extends Scope {
130
152
  // then fail tagging it (AddTagsToResource needs ssm:GetParameters).
131
153
  // We only need runtime read access, granted below.
132
154
  if (!external) {
133
- registerSecret(cdk.Stack.of(this), parameterName);
155
+ registerSecret(cdk.Stack.of(this), parameterName, this.defaults.logRetention, options.kmsKeyArn);
134
156
  }
135
157
 
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`,
158
+ // Grant the handler KMS access. External secrets are read-only (Decrypt
159
+ // only); stack-managed secrets also need Encrypt so the app can write the
160
+ // value via put(). Standard-tier SecureStrings only use Encrypt/Decrypt
161
+ // (no GenerateDataKey — that's advanced-tier envelope encryption).
162
+ if (options.kmsKeyArn) {
163
+ // Customer-managed key: grant on the specific key ARN. NOTE: the key's
164
+ // own key policy must also allow this role (we can't edit a BYO key's
165
+ // policy from here) — see the README.
166
+ this.executionRole.addToPrincipalPolicy(new iam.PolicyStatement({
167
+ actions: external ? ['kms:Decrypt'] : ['kms:Decrypt', 'kms:Encrypt'],
168
+ resources: [options.kmsKeyArn],
169
+ }));
170
+ } else {
171
+ // Default aws/ssm key: scope the wildcard with a ViaService condition.
172
+ this.executionRole.addToPrincipalPolicy(new iam.PolicyStatement({
173
+ actions: external ? ['kms:Decrypt'] : ['kms:Decrypt', 'kms:Encrypt'],
174
+ resources: ['*'],
175
+ conditions: {
176
+ StringEquals: {
177
+ 'kms:ViaService': `ssm.${cdk.Stack.of(this).region}.amazonaws.com`,
178
+ },
145
179
  },
146
- },
147
- }));
180
+ }));
181
+ }
148
182
  } else if (!external) {
149
183
  // ── String parameter via CDK construct ──────────────────────────
150
184
  const param = new ssm.StringParameter(this, 'Param', {
@@ -159,7 +193,7 @@ export class AppSetting<T = string> extends Scope {
159
193
 
160
194
  // Grant handler SSM access on this parameter. External parameters are owned
161
195
  // elsewhere, so the app only reads them (no ssm:PutParameter).
162
- this.handler.addToRolePolicy(new iam.PolicyStatement({
196
+ this.executionRole.addToPrincipalPolicy(new iam.PolicyStatement({
163
197
  actions: external ? ['ssm:GetParameter'] : ['ssm:GetParameter', 'ssm:PutParameter'],
164
198
  resources: [parameterArn],
165
199
  }));
@@ -174,101 +208,133 @@ export class AppSetting<T = string> extends Scope {
174
208
 
175
209
  const SECRET_BULK_KEY = Symbol.for('BLOCKS_SECRET_BULK_INIT');
176
210
 
211
+ interface SecretParam {
212
+ name: string;
213
+ /** Customer-managed KMS key ARN, or undefined for the default aws/ssm key. */
214
+ keyId?: string;
215
+ }
216
+
177
217
  interface SecretBulkState {
178
- parameterNames: string[];
218
+ params: SecretParam[];
179
219
  }
180
220
 
181
221
  /**
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).
222
+ * Register a secret parameter (and its optional customer-managed KMS key). On
223
+ * first call, creates the shared Lambda, Provider, and a single CustomResource.
224
+ * All subsequent calls just append to the parameter list (resolved lazily at
225
+ * synth time).
185
226
  */
186
- function registerSecret(stack: cdk.Stack, parameterName: string): void {
227
+ function registerSecret(stack: cdk.Stack, parameterName: string, logRetention: RetentionDays, keyId?: string): void {
187
228
  let state = (stack as any)[SECRET_BULK_KEY] as SecretBulkState | undefined;
188
229
  if (state) {
189
- state.parameterNames.push(parameterName);
230
+ state.params.push({ name: parameterName, keyId });
190
231
  return;
191
232
  }
192
233
 
193
234
  // First secret in this stack — create all shared infrastructure
194
- state = { parameterNames: [parameterName] };
235
+ state = { params: [{ name: parameterName, keyId }] };
195
236
  (stack as any)[SECRET_BULK_KEY] = state;
196
237
 
197
238
  const secretInitFn = new lambda.Function(stack, 'BlocksSecretInitFn', {
198
239
  runtime: DEFAULT_NODE_RUNTIME,
199
240
  handler: 'index.handler',
241
+ // Own the log group so its retention follows the stack-wide default
242
+ // instead of AWS's infinite retention.
243
+ logGroup: new LogGroup(stack, 'BlocksSecretInitLogs', {
244
+ retention: logRetention,
245
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
246
+ }),
200
247
  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
- }
248
+ const { SSMClient, GetParameterCommand, PutParameterCommand, DeleteParameterCommand, AddTagsToResourceCommand } = require('@aws-sdk/client-ssm');
249
+ const crypto = require('crypto');
250
+ const client = new SSMClient({});
251
+ async function putSecret(p, tags) {
252
+ const secret = crypto.randomBytes(32).toString('base64url');
253
+ const input = { Name: p.name, Value: secret, Type: 'SecureString', Overwrite: false, Tags: tags };
254
+ // A CMK ARN pins the SecureString to a customer-managed key; omit for the default aws/ssm key.
255
+ if (p.keyId) input.KeyId = p.keyId;
256
+ try {
257
+ await client.send(new PutParameterCommand(input));
258
+ } catch (e) {
259
+ if (e.name !== 'ParameterAlreadyExists') throw e;
260
+ if (tags.length) {
261
+ await client.send(new AddTagsToResourceCommand({ ResourceType: 'Parameter', ResourceId: p.name, Tags: tags }));
227
262
  }
228
263
  }
229
- return { PhysicalResourceId: 'bb-secrets-bulk' };
230
264
  }
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
- }
265
+ // Re-encrypt an EXISTING secret under a new (or removed) KMS key, preserving its
266
+ // current value. Without this, changing kmsKeyArn leaves the value encrypted under
267
+ // the old key while the app's IAM grant flips to the new key, so the next get()
268
+ // fails with AccessDenied.
269
+ async function reencrypt(p) {
270
+ const cur = await client.send(new GetParameterCommand({ Name: p.name, WithDecryption: true }));
271
+ const value = cur.Parameter && cur.Parameter.Value;
272
+ if (value === undefined || value === null) return;
273
+ const input = { Name: p.name, Value: value, Type: 'SecureString', Overwrite: true };
274
+ if (p.keyId) input.KeyId = p.keyId; // omit => back to the default aws/ssm key
275
+ await client.send(new PutParameterCommand(input));
276
+ }
277
+ // A pre-CMK deployment stored ParameterNames (string[]); map it to the {name} shape.
278
+ function readOld(op) {
279
+ if (Array.isArray(op.Parameters)) return op.Parameters;
280
+ if (Array.isArray(op.ParameterNames)) return op.ParameterNames.map((n) => ({ name: n }));
281
+ return [];
282
+ }
283
+ exports.handler = async (event) => {
284
+ const params = event.ResourceProperties.Parameters || [];
285
+ const stackName = event.ResourceProperties.StackName || '';
286
+ const tags = stackName ? [{ Key: 'aws-blocks-stack', Value: stackName }] : [];
287
+ const oldParams = readOld(event.OldResourceProperties || {});
288
+ const names = params.map(p => p.name);
289
+ const oldByName = Object.fromEntries(oldParams.map(p => [p.name, p]));
290
+ if (event.RequestType === 'Delete') {
291
+ for (const name of names) {
292
+ try { await client.send(new DeleteParameterCommand({ Name: name })); } catch {}
245
293
  }
294
+ return { PhysicalResourceId: 'bb-secrets-bulk' };
246
295
  }
247
- for (const name of removed) {
248
- try { await client.send(new DeleteParameterCommand({ Name: name })); } catch {}
296
+ if (event.RequestType === 'Create') {
297
+ for (const p of params) await putSecret(p, tags);
298
+ return { PhysicalResourceId: 'bb-secrets-bulk' };
299
+ }
300
+ if (event.RequestType === 'Update') {
301
+ for (const p of params) {
302
+ const old = oldByName[p.name];
303
+ if (!old) { await putSecret(p, tags); continue; } // newly added
304
+ if ((old.keyId || '') !== (p.keyId || '')) await reencrypt(p); // key changed => re-key, preserving value
305
+ // else: unchanged — leave the runtime-managed value alone
306
+ }
307
+ for (const name of Object.keys(oldByName)) {
308
+ if (!names.includes(name)) { try { await client.send(new DeleteParameterCommand({ Name: name })); } catch {} }
309
+ }
310
+ return { PhysicalResourceId: 'bb-secrets-bulk' };
249
311
  }
250
312
  return { PhysicalResourceId: 'bb-secrets-bulk' };
251
- }
252
- return { PhysicalResourceId: 'bb-secrets-bulk' };
253
- };
254
- `),
313
+ };
314
+ `),
255
315
  });
256
316
 
257
317
  secretInitFn.addToRolePolicy(new iam.PolicyStatement({
258
- actions: ['ssm:PutParameter', 'ssm:DeleteParameter', 'ssm:AddTagsToResource'],
318
+ // GetParameter is needed to read a secret's current value when re-keying it.
319
+ actions: ['ssm:GetParameter', 'ssm:PutParameter', 'ssm:DeleteParameter', 'ssm:AddTagsToResource'],
259
320
  resources: cdk.Lazy.list({
260
- produce: () => state!.parameterNames.map(name =>
321
+ produce: () => state!.params.map(p =>
261
322
  stack.formatArn({
262
323
  service: 'ssm',
263
324
  resource: 'parameter',
264
- resourceName: name.replace(/^\//, ''),
325
+ resourceName: p.name.replace(/^\//, ''),
265
326
  })
266
327
  ),
267
328
  }),
268
329
  }));
269
330
 
331
+ // SSM SecureString encryption goes through KMS via the SSM service. Scoping to
332
+ // `kms:ViaService = ssm.<region>` covers both the default aws/ssm key and any
333
+ // customer-managed key used above (the CMK's own key policy must also allow
334
+ // this role). Encrypt = create/re-key; Decrypt = read the current value when
335
+ // re-keying. Standard-tier SecureStrings don't use GenerateDataKey.
270
336
  secretInitFn.addToRolePolicy(new iam.PolicyStatement({
271
- actions: ['kms:Encrypt'],
337
+ actions: ['kms:Encrypt', 'kms:Decrypt'],
272
338
  resources: ['*'],
273
339
  conditions: {
274
340
  StringEquals: {
@@ -284,7 +350,7 @@ function registerSecret(stack: cdk.Stack, parameterName: string): void {
284
350
  new cdk.CustomResource(stack, 'BlocksSecretsBulk', {
285
351
  serviceToken: provider.serviceToken,
286
352
  properties: {
287
- ParameterNames: cdk.Lazy.list({ produce: () => state!.parameterNames }),
353
+ Parameters: cdk.Lazy.any({ produce: () => state!.params }),
288
354
  StackName: (() => { let s = stack; while (s.nestedStackParent) s = s.nestedStackParent; return s.stackName; })(),
289
355
  },
290
356
  });
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.1';