@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/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
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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.
|
|
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
|
-
|
|
218
|
+
params: SecretParam[];
|
|
179
219
|
}
|
|
180
220
|
|
|
181
221
|
/**
|
|
182
|
-
* Register a secret parameter
|
|
183
|
-
* Provider, and a single CustomResource.
|
|
184
|
-
* the parameter list (resolved lazily at
|
|
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.
|
|
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 = {
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
253
|
-
};
|
|
254
|
-
`),
|
|
313
|
+
};
|
|
314
|
+
`),
|
|
255
315
|
});
|
|
256
316
|
|
|
257
317
|
secretInitFn.addToRolePolicy(new iam.PolicyStatement({
|
|
258
|
-
|
|
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!.
|
|
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
|
-
|
|
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
|
|
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