@aws-blocks/bb-app-setting 0.1.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/LICENSE +174 -0
- package/README.md +156 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +26 -0
- package/dist/index.aws.d.ts +76 -0
- package/dist/index.aws.d.ts.map +1 -0
- package/dist/index.aws.js +135 -0
- package/dist/index.browser.d.ts +5 -0
- package/dist/index.browser.d.ts.map +1 -0
- package/dist/index.browser.js +9 -0
- package/dist/index.cdk.d.ts +35 -0
- package/dist/index.cdk.d.ts.map +1 -0
- package/dist/index.cdk.js +242 -0
- package/dist/index.cdk.test.d.ts +2 -0
- package/dist/index.cdk.test.d.ts.map +1 -0
- package/dist/index.cdk.test.js +187 -0
- package/dist/index.hooks.d.ts +2 -0
- package/dist/index.hooks.d.ts.map +1 -0
- package/dist/index.hooks.js +3 -0
- package/dist/index.mock.d.ts +77 -0
- package/dist/index.mock.d.ts.map +1 -0
- package/dist/index.mock.js +153 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +542 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/package.json +46 -0
- package/src/errors.ts +27 -0
- package/src/index.aws.ts +165 -0
- package/src/index.browser.ts +10 -0
- package/src/index.cdk.test.ts +227 -0
- package/src/index.cdk.ts +291 -0
- package/src/index.hooks.ts +5 -0
- package/src/index.mock.ts +181 -0
- package/src/index.test.ts +627 -0
- package/src/types.ts +56 -0
- package/src/version.ts +3 -0
package/src/errors.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Typed error constants for AppSetting. Use with `isBlocksError()` in catch blocks.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { isBlocksError } from '@aws-blocks/core';
|
|
10
|
+
* import { AppSettingErrors } from '@aws-blocks/bb-app-setting';
|
|
11
|
+
*
|
|
12
|
+
* try {
|
|
13
|
+
* await setting.put(value);
|
|
14
|
+
* } catch (e: unknown) {
|
|
15
|
+
* if (isBlocksError(e, AppSettingErrors.ValidationFailed)) {
|
|
16
|
+
* // schema validation failed or value exceeds 4 KB
|
|
17
|
+
* }
|
|
18
|
+
* throw e;
|
|
19
|
+
* }
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export const AppSettingErrors = {
|
|
23
|
+
/** Thrown when the SSM parameter does not exist. */
|
|
24
|
+
ParameterNotFound: 'ParameterNotFoundException',
|
|
25
|
+
/** Thrown when schema validation fails, value exceeds 4 KB, or options are invalid. */
|
|
26
|
+
ValidationFailed: 'ValidationFailedException',
|
|
27
|
+
} as const;
|
package/src/index.aws.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
SSMClient,
|
|
6
|
+
GetParameterCommand,
|
|
7
|
+
PutParameterCommand,
|
|
8
|
+
} from '@aws-sdk/client-ssm';
|
|
9
|
+
import { Scope, registerSdkIdentifiers, getSdkIdentifiers } from '@aws-blocks/core';
|
|
10
|
+
import type { ScopeParent } from '@aws-blocks/core';
|
|
11
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
12
|
+
import { AppSettingErrors } from './errors.js';
|
|
13
|
+
import type { AppSettingOptions, InternalAppSettingOptions } from './types.js';
|
|
14
|
+
import { BB_NAME, BB_VERSION } from './version.js';
|
|
15
|
+
import { Logger } from '@aws-blocks/bb-logger';
|
|
16
|
+
import type { ChildLogger } from '@aws-blocks/bb-logger';
|
|
17
|
+
|
|
18
|
+
// Re-export public types from types module (canonical source)
|
|
19
|
+
export { AppSettingErrors } from './errors.js';
|
|
20
|
+
export type { AppSettingOptions } from './types.js';
|
|
21
|
+
|
|
22
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function blocksError(name: string, message: string): Error {
|
|
25
|
+
const err = new Error(`${name}: ${message}`);
|
|
26
|
+
err.name = name;
|
|
27
|
+
return err;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function validateSchema<T>(schema: StandardSchemaV1<T> | undefined, value: unknown): Promise<void> {
|
|
31
|
+
if (!schema) return;
|
|
32
|
+
const result = schema['~standard'].validate(value);
|
|
33
|
+
const resolved = result instanceof Promise ? await result : result;
|
|
34
|
+
if (resolved.issues) {
|
|
35
|
+
throw blocksError(AppSettingErrors.ValidationFailed, resolved.issues[0].message);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── AppSetting (AWS runtime) ────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A single application configuration value backed by SSM Parameter Store.
|
|
43
|
+
*
|
|
44
|
+
* **When to use:** You need to store and retrieve a non-secret configuration
|
|
45
|
+
* value at runtime — a feature flag, API URL, threshold, or structured config
|
|
46
|
+
* object. For sensitive values, set `secret: true` to use SSM SecureString.
|
|
47
|
+
*
|
|
48
|
+
* **When NOT to use:** If you need structured key-value data with conditional
|
|
49
|
+
* writes and queries, use `KVStore` or `DistributedTable`.
|
|
50
|
+
*
|
|
51
|
+
* **Best practices:**
|
|
52
|
+
* - One AppSetting per logical configuration value
|
|
53
|
+
* - Use a schema for structured objects to get type safety and runtime validation
|
|
54
|
+
* - Use `secret: true` for API keys, tokens, and passwords
|
|
55
|
+
*
|
|
56
|
+
* **Scaling:** Standard-tier SSM parameters. 40 TPS default for GetParameter
|
|
57
|
+
* (can be increased). No cost for standard parameters.
|
|
58
|
+
*/
|
|
59
|
+
export class AppSetting<T = string> extends Scope {
|
|
60
|
+
/**
|
|
61
|
+
* Reference an SSM parameter created and owned **outside this stack** (e.g. a
|
|
62
|
+
* connection string seeded by `ensureSecrets` before deploy). At runtime this
|
|
63
|
+
* reads the value like any other setting; the construct-time behavior (no
|
|
64
|
+
* create/seed/tag/delete, read-only grant) is applied by the CDK variant. The
|
|
65
|
+
* factory exists on every variant so app code uses one API across dev/deploy.
|
|
66
|
+
*/
|
|
67
|
+
static fromExisting<T = string>(
|
|
68
|
+
scope: ScopeParent,
|
|
69
|
+
id: string,
|
|
70
|
+
options: { name: string; secret?: boolean },
|
|
71
|
+
): AppSetting<T> {
|
|
72
|
+
const opts: InternalAppSettingOptions<T> = { ...options, external: true };
|
|
73
|
+
return new AppSetting<T>(scope, id, opts);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
readonly bbName = BB_NAME;
|
|
77
|
+
private schema?: StandardSchemaV1<T>;
|
|
78
|
+
private isSecret: boolean;
|
|
79
|
+
private client: SSMClient;
|
|
80
|
+
|
|
81
|
+
/** @internal Logger for internal operations. Defaults to error-level when not provided. */
|
|
82
|
+
protected log: ChildLogger;
|
|
83
|
+
|
|
84
|
+
constructor(scope: ScopeParent, id: string, options: AppSettingOptions<T>) {
|
|
85
|
+
super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION });
|
|
86
|
+
this.log = options?.logger ?? new Logger(this, 'logger', { level: 'error' });
|
|
87
|
+
|
|
88
|
+
const parameterName = options.name ?? `/${this.fullId}`;
|
|
89
|
+
this.schema = options.schema;
|
|
90
|
+
this.isSecret = options.secret ?? false;
|
|
91
|
+
this.client = new SSMClient({
|
|
92
|
+
customUserAgent: this.buildUserAgentChain(),
|
|
93
|
+
});
|
|
94
|
+
registerSdkIdentifiers(this.fullId, { parameterName });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Retrieve the current value.
|
|
99
|
+
*
|
|
100
|
+
* Returns the stored SSM parameter value. The parameter is guaranteed
|
|
101
|
+
* to exist because the CDK layer creates it with the initial value.
|
|
102
|
+
*
|
|
103
|
+
* @returns The current value.
|
|
104
|
+
* @throws {AppSettingErrors.ParameterNotFound} If the parameter does not exist in SSM (e.g., deleted out-of-band).
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```typescript
|
|
108
|
+
* const retries = await maxRetries.get();
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
async get(): Promise<T> {
|
|
112
|
+
const result = await this.client.send(new GetParameterCommand({
|
|
113
|
+
Name: getSdkIdentifiers(this).parameterName,
|
|
114
|
+
WithDecryption: this.isSecret,
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
this.client.config.customUserAgent = this.buildUserAgentChain();
|
|
118
|
+
const raw = result.Parameter?.Value;
|
|
119
|
+
if (raw === undefined || raw === null) {
|
|
120
|
+
throw blocksError(AppSettingErrors.ParameterNotFound, `Parameter "${getSdkIdentifiers(this).parameterName}" has no value`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let value: T;
|
|
124
|
+
try {
|
|
125
|
+
value = JSON.parse(raw) as T;
|
|
126
|
+
} catch {
|
|
127
|
+
// fallback for old-format values
|
|
128
|
+
value = raw as T;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (this.isSecret && value === ('' as unknown as T)) {
|
|
132
|
+
throw blocksError(AppSettingErrors.ParameterNotFound, `Secret parameter "${getSdkIdentifiers(this).parameterName}" has an empty value — secrets must not be empty`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Update the value at runtime.
|
|
140
|
+
*
|
|
141
|
+
* Overwrites the current SSM parameter value. When a schema is configured,
|
|
142
|
+
* the value is validated before writing.
|
|
143
|
+
*
|
|
144
|
+
* @param value - The new value to store.
|
|
145
|
+
* @throws {AppSettingErrors.ValidationFailed} If schema validation fails or value exceeds 4 KB.
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* ```typescript
|
|
149
|
+
* await maxRetries.put('5');
|
|
150
|
+
* await config.put({ maxRetries: 5, timeout: 10000 });
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
async put(value: T): Promise<void> {
|
|
154
|
+
await validateSchema(this.schema, value);
|
|
155
|
+
|
|
156
|
+
const serialized = JSON.stringify(value);
|
|
157
|
+
|
|
158
|
+
await this.client.send(new PutParameterCommand({
|
|
159
|
+
Name: getSdkIdentifiers(this).parameterName,
|
|
160
|
+
Value: serialized,
|
|
161
|
+
Type: this.isSecret ? 'SecureString' : 'String',
|
|
162
|
+
Overwrite: true,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// Browser stub - AppSetting runs server-side only
|
|
5
|
+
export class AppSetting {
|
|
6
|
+
static fromExisting(...args: any[]): any {
|
|
7
|
+
return new AppSetting();
|
|
8
|
+
}
|
|
9
|
+
constructor(...args: any[]) {}
|
|
10
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CDK-side tests for AppSetting.
|
|
6
|
+
*
|
|
7
|
+
* Verifies that the Custom Resource Lambda's IAM policy is scoped to specific
|
|
8
|
+
* parameter ARNs (not a wildcard) — regression test for #598.
|
|
9
|
+
*/
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import assert from 'node:assert';
|
|
12
|
+
import * as cdk from 'aws-cdk-lib';
|
|
13
|
+
import type { Construct } from 'constructs';
|
|
14
|
+
import { Template, Match } from 'aws-cdk-lib/assertions';
|
|
15
|
+
import { Scope, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
|
|
16
|
+
import { AppSetting } from './index.cdk.js';
|
|
17
|
+
|
|
18
|
+
class StubBlocksStack extends cdk.Stack {
|
|
19
|
+
public readonly handler: cdk.aws_lambda.Function;
|
|
20
|
+
public readonly id: string;
|
|
21
|
+
constructor(scope: Construct, id: string) {
|
|
22
|
+
super(scope, id);
|
|
23
|
+
this.id = id;
|
|
24
|
+
(globalThis as any).CURRENT_BLOCKS_STACK = this;
|
|
25
|
+
this.handler = new cdk.aws_lambda.Function(this, 'StubHandler', {
|
|
26
|
+
runtime: DEFAULT_NODE_RUNTIME,
|
|
27
|
+
handler: 'index.handler',
|
|
28
|
+
code: cdk.aws_lambda.Code.fromInline('exports.handler = async () => {};'),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function setup(): { stack: StubBlocksStack; parent: Scope } {
|
|
34
|
+
const app = new cdk.App();
|
|
35
|
+
const stack = new StubBlocksStack(app, 'TestStack');
|
|
36
|
+
const parent = new Scope('app');
|
|
37
|
+
return { stack, parent };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('CDK: secret AppSetting SSM policy is scoped to specific parameter ARN (not wildcard)', () => {
|
|
41
|
+
const { stack, parent } = setup();
|
|
42
|
+
new AppSetting(parent, 'my-secret', { secret: true, name: '/myapp/secret-key' });
|
|
43
|
+
const template = Template.fromStack(stack);
|
|
44
|
+
|
|
45
|
+
// The KitSecretInitFn should have an IAM policy for ssm:PutParameter/DeleteParameter
|
|
46
|
+
// scoped to the specific parameter, NOT a wildcard
|
|
47
|
+
const policies = template.findResources('AWS::IAM::Policy');
|
|
48
|
+
const policyLogicalIds = Object.keys(policies);
|
|
49
|
+
|
|
50
|
+
let foundSsmPolicy = false;
|
|
51
|
+
for (const logicalId of policyLogicalIds) {
|
|
52
|
+
const statements = policies[logicalId]?.Properties?.PolicyDocument?.Statement;
|
|
53
|
+
if (!Array.isArray(statements)) continue;
|
|
54
|
+
|
|
55
|
+
for (const stmt of statements) {
|
|
56
|
+
const actions = stmt.Action;
|
|
57
|
+
if (!Array.isArray(actions)) continue;
|
|
58
|
+
if (!actions.includes('ssm:PutParameter') || !actions.includes('ssm:DeleteParameter')) continue;
|
|
59
|
+
|
|
60
|
+
foundSsmPolicy = true;
|
|
61
|
+
// Resource must NOT be a wildcard — it should be a specific ARN
|
|
62
|
+
const resources = stmt.Resource;
|
|
63
|
+
if (Array.isArray(resources)) {
|
|
64
|
+
for (const res of resources) {
|
|
65
|
+
const arnStr = typeof res === 'string' ? res : JSON.stringify(res);
|
|
66
|
+
assert.ok(
|
|
67
|
+
!arnStr.includes('"*"') && arnStr !== '*',
|
|
68
|
+
`SSM policy resource must not be a wildcard, got: ${arnStr}`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
const arnStr = typeof resources === 'string' ? resources : JSON.stringify(resources);
|
|
73
|
+
assert.notStrictEqual(arnStr, '*', 'SSM policy resource must not be a wildcard');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
assert.ok(foundSsmPolicy, 'Expected to find an IAM policy with ssm:PutParameter/DeleteParameter');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('CDK: secret AppSetting SSM policy contains the correct parameter name', () => {
|
|
82
|
+
const { stack, parent } = setup();
|
|
83
|
+
new AppSetting(parent, 'db-password', { secret: true, name: '/myapp/db-password' });
|
|
84
|
+
const template = Template.fromStack(stack);
|
|
85
|
+
|
|
86
|
+
// Verify the policy resource ARN references the parameter name
|
|
87
|
+
const templateJson = JSON.stringify(template.toJSON());
|
|
88
|
+
assert.ok(
|
|
89
|
+
templateJson.includes('myapp/db-password'),
|
|
90
|
+
'Expected the synthesized template to contain the specific parameter name "myapp/db-password"'
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('CDK: multiple secret AppSettings produce scoped policy with all parameter ARNs', () => {
|
|
95
|
+
const { stack, parent } = setup();
|
|
96
|
+
new AppSetting(parent, 'secret-a', { secret: true, name: '/app/secret-a' });
|
|
97
|
+
new AppSetting(parent, 'secret-b', { secret: true, name: '/app/secret-b' });
|
|
98
|
+
const template = Template.fromStack(stack);
|
|
99
|
+
|
|
100
|
+
const templateJson = JSON.stringify(template.toJSON());
|
|
101
|
+
assert.ok(templateJson.includes('app/secret-a'), 'Expected template to reference parameter "app/secret-a"');
|
|
102
|
+
assert.ok(templateJson.includes('app/secret-b'), 'Expected template to reference parameter "app/secret-b"');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('CDK: non-secret AppSetting creates SSM StringParameter', () => {
|
|
106
|
+
const { stack, parent } = setup();
|
|
107
|
+
new AppSetting(parent, 'config', { value: 'hello', name: '/app/config' });
|
|
108
|
+
const template = Template.fromStack(stack);
|
|
109
|
+
template.resourceCountIs('AWS::SSM::Parameter', 1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('CDK: non-secret AppSetting grants handler scoped SSM access', () => {
|
|
113
|
+
const { stack, parent } = setup();
|
|
114
|
+
new AppSetting(parent, 'config', { value: 'hello', name: '/app/config' });
|
|
115
|
+
const template = Template.fromStack(stack);
|
|
116
|
+
|
|
117
|
+
// Should have a policy statement for ssm:GetParameter, ssm:PutParameter
|
|
118
|
+
// scoped to the specific parameter ARN
|
|
119
|
+
template.hasResourceProperties('AWS::IAM::Policy', {
|
|
120
|
+
PolicyDocument: {
|
|
121
|
+
Statement: Match.arrayWith([
|
|
122
|
+
Match.objectLike({
|
|
123
|
+
Action: ['ssm:GetParameter', 'ssm:PutParameter'],
|
|
124
|
+
Resource: Match.objectLike({
|
|
125
|
+
'Fn::Join': Match.anyValue(),
|
|
126
|
+
}),
|
|
127
|
+
}),
|
|
128
|
+
]),
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('CDK: external secret is NOT enrolled in bulk-init (no KitSecretsBulk / KitSecretInitFn)', () => {
|
|
134
|
+
const { stack, parent } = setup();
|
|
135
|
+
AppSetting.fromExisting(parent, 'db-url', { name: '/blocks/sandbox/db-abc-connection-string', secret: true });
|
|
136
|
+
const template = Template.fromStack(stack);
|
|
137
|
+
|
|
138
|
+
// No secret bulk-init custom resource and no init Lambda should be synthesized:
|
|
139
|
+
// an externally-owned parameter must not be created, tagged, or deleted by us.
|
|
140
|
+
template.resourceCountIs('AWS::CloudFormation::CustomResource', 0);
|
|
141
|
+
const lambdas = template.findResources('AWS::Lambda::Function');
|
|
142
|
+
for (const id of Object.keys(lambdas)) {
|
|
143
|
+
const code = JSON.stringify(lambdas[id]?.Properties?.Code ?? {});
|
|
144
|
+
assert.ok(!code.includes('AddTagsToResourceCommand'), `Lambda ${id} should not be the secret-init function`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('CDK: external secret grants READ-ONLY runtime access (GetParameter + Decrypt, scoped, no write)', () => {
|
|
149
|
+
const { stack, parent } = setup();
|
|
150
|
+
AppSetting.fromExisting(parent, 'db-url', { name: '/blocks/sandbox/db-abc-connection-string', secret: true });
|
|
151
|
+
const template = Template.fromStack(stack);
|
|
152
|
+
|
|
153
|
+
// ssm:GetParameter, scoped to the specific parameter ARN (not a wildcard).
|
|
154
|
+
template.hasResourceProperties('AWS::IAM::Policy', {
|
|
155
|
+
PolicyDocument: {
|
|
156
|
+
Statement: Match.arrayWith([
|
|
157
|
+
Match.objectLike({
|
|
158
|
+
Action: 'ssm:GetParameter',
|
|
159
|
+
Resource: Match.objectLike({ 'Fn::Join': Match.anyValue() }),
|
|
160
|
+
}),
|
|
161
|
+
]),
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
// kms:Decrypt for reading the SecureString.
|
|
165
|
+
template.hasResourceProperties('AWS::IAM::Policy', {
|
|
166
|
+
PolicyDocument: { Statement: Match.arrayWith([Match.objectLike({ Action: 'kms:Decrypt' })]) },
|
|
167
|
+
});
|
|
168
|
+
// Must NOT grant write to an externally-owned secret.
|
|
169
|
+
const policiesJson = JSON.stringify(template.findResources('AWS::IAM::Policy'));
|
|
170
|
+
assert.ok(!policiesJson.includes('ssm:PutParameter'), 'external secret must not grant ssm:PutParameter');
|
|
171
|
+
assert.ok(!policiesJson.includes('kms:Encrypt'), 'external secret must not grant kms:Encrypt');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('CDK: external non-secret creates no SSM parameter and grants read-only access', () => {
|
|
175
|
+
const { stack, parent } = setup();
|
|
176
|
+
AppSetting.fromExisting(parent, 'shared-config', { name: '/some/external/config' });
|
|
177
|
+
const template = Template.fromStack(stack);
|
|
178
|
+
|
|
179
|
+
// The construct does not create the parameter — it's owned externally.
|
|
180
|
+
template.resourceCountIs('AWS::SSM::Parameter', 0);
|
|
181
|
+
template.hasResourceProperties('AWS::IAM::Policy', {
|
|
182
|
+
PolicyDocument: {
|
|
183
|
+
Statement: Match.arrayWith([
|
|
184
|
+
Match.objectLike({
|
|
185
|
+
Action: 'ssm:GetParameter',
|
|
186
|
+
Resource: Match.objectLike({ 'Fn::Join': Match.anyValue() }),
|
|
187
|
+
}),
|
|
188
|
+
]),
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
assert.ok(
|
|
192
|
+
!JSON.stringify(template.findResources('AWS::IAM::Policy')).includes('ssm:PutParameter'),
|
|
193
|
+
'external non-secret must not grant write',
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('CDK: the internal external guard requires a name and forbids a value', () => {
|
|
198
|
+
// fromExisting() is the public API and makes `name` required at the type level;
|
|
199
|
+
// these assertions cover the runtime guard on the underlying `external` option
|
|
200
|
+
// (defense for JS callers / direct construction).
|
|
201
|
+
const { parent } = setup();
|
|
202
|
+
assert.throws(
|
|
203
|
+
() => new AppSetting(parent, 'ext-no-name', { secret: true, external: true } as any),
|
|
204
|
+
/requires an explicit 'name'/,
|
|
205
|
+
);
|
|
206
|
+
assert.throws(
|
|
207
|
+
() => new AppSetting(parent, 'ext-with-value', { external: true, name: '/x', value: 'v' } as any),
|
|
208
|
+
/must not have a value/,
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('CDK: fromExisting still registers the runtime config key (BLOCKS_SSM_PARAM_*)', () => {
|
|
213
|
+
// BLOCKS_SSM_PARAM_DB_URL is the ONLY link between db-pull's runtime resolveConnString()
|
|
214
|
+
// and the deployed parameter name. If config registration ever moved inside the
|
|
215
|
+
// non-external branch, every external setting would fail at runtime with
|
|
216
|
+
// ParameterNotFound — and nothing else would catch it. This pins the contract.
|
|
217
|
+
const { stack, parent } = setup();
|
|
218
|
+
AppSetting.fromExisting(parent, 'db-url', { name: '/blocks/sandbox/db-abc-connection-string', secret: true });
|
|
219
|
+
|
|
220
|
+
const registry = (stack as any)[Symbol.for('BLOCKS_CONFIG_REGISTRY')] as { entries: Map<string, unknown> } | undefined;
|
|
221
|
+
assert.ok(registry, 'config registry exists on the stack');
|
|
222
|
+
assert.ok(
|
|
223
|
+
registry.entries.has('BLOCKS_SSM_PARAM_DB_URL'),
|
|
224
|
+
'external setting must register BLOCKS_SSM_PARAM_DB_URL so the runtime can resolve the parameter',
|
|
225
|
+
);
|
|
226
|
+
assert.equal(registry.entries.get('BLOCKS_SSM_PARAM_DB_URL'), '/blocks/sandbox/db-abc-connection-string');
|
|
227
|
+
});
|