Skip to content

Commit a88a98e

Browse files
committed
feat(hosting): add secret() for self-hosted deployments
Adds a secret() reference for sensitive values (API keys, third-party credentials, custom domains) backed by SSM Parameter Store SecureString, so they are never hardcoded, committed, or written into the CFN template. - secret('KEY') marker: dependency-free value object in @aws-blocks/hosting (re-exported via core/blocks), so @aws-blocks/pipeline can later share it without a dependency cycle. - Two resolution strategies, chosen per prop: * compute.environment -> RUNTIME (secure default): inject the SSM param NAME as BLOCKS_SECRET_PARAM_<KEY>, grant ssm:GetParameter (scoped) + kms:Decrypt (kms:ViaService); getSecret('KEY') fetches+decrypts on first use. Value stays encrypted at rest; never in the template. * domain.domainName and secret(..., { exposeAsEnv: true }) -> SYNTH TIME via SDK GetParameter(WithDecryption), inlined as literal. ssm-secure CFN dynamic refs are not allowed in CloudFront Aliases / Lambda env, so synth-time SDK resolution is correct; a domain is public anyway. Async, so use await Hosting.create(scope, id, props). - getSecret('KEY') runtime resolver: env-first (local dev / exposeAsEnv), else SSM fetch+decrypt, cached per cold start, concurrent calls coalesced. - secret CLI (runSecretCli/setSecret/listSecrets/removeSecret): blocks secret set/list/remove manage SecureStrings at /blocks/secrets/<KEY> (flat namespace, 1:1 key<->reference). list returns names only. Wired into the nextjs template as npm run secret. Path convention centralized in secretParameterName() (/blocks/secrets/<KEY>). Backward compatible: new Hosting() still works; only synth-time secrets need Hosting.create(). Unit + CDK-assertion tests cover marker, runtime resolver, partition/resolution helpers, IAM grants, and no-plaintext-leak.
1 parent 45a54dd commit a88a98e

19 files changed

Lines changed: 1191 additions & 4 deletions

File tree

.changeset/hosting-secrets.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@aws-blocks/hosting": minor
3+
"@aws-blocks/core": minor
4+
---
5+
6+
Add `secret()` support to hosting for self-hosted deployments.
7+
8+
Introduces a `secret()` reference for sensitive values (API keys, third-party credentials, custom domains) backed by an SSM Parameter Store SecureString — so they're never hardcoded in source, committed to git, or written into the CloudFormation template.
9+
10+
- **`secret('KEY')` marker** — exported from `@aws-blocks/hosting` (and re-exported from `@aws-blocks/core` / `@aws-blocks/blocks`). A dependency-free value object so it can later be shared with `@aws-blocks/pipeline` without a dependency cycle. Pass it into `Hosting` props.
11+
- **Two resolution strategies, chosen per prop:**
12+
- `compute.environment` values resolve at **runtime** (the secure default): the SSM parameter *name* (never the value) is injected as `BLOCKS_SECRET_PARAM_<KEY>`, the compute role is granted `ssm:GetParameter` (scoped to that one ARN) + `kms:Decrypt` (conditioned on `kms:ViaService`), and the value is fetched + decrypted on first use via `getSecret('KEY')`. The secret stays encrypted at rest and never enters the template.
13+
- `domain.domainName` and `secret(..., { exposeAsEnv: true })` resolve at **synth time** via an SDK `GetParameter(WithDecryption)` call and are inlined as literals. `ssm-secure` CloudFormation dynamic references are restricted to an allowlist that excludes CloudFront `Aliases` and Lambda env vars, so synth-time SDK resolution is the correct mechanism; a domain is public anyway. Synth-time resolution is async — use the new `await Hosting.create(scope, id, props)`.
14+
- **`getSecret('KEY')` runtime resolver** — reads `process.env.KEY` first (local dev / `exposeAsEnv`), else fetches + decrypts the injected SSM parameter, caching per cold start and coalescing concurrent calls. Mirrors the existing `AppSetting` / external-DB connection-string pattern.
15+
- **`secret` CLI** (`runSecretCli`, `setSecret`, `listSecrets`, `removeSecret`) — `blocks secret set/list/remove` manage SecureStrings at `/blocks/secrets/<KEY>` (flat namespace, 1:1 key↔reference, no stage scoping). `list` returns names only, never values. Wired into the Next.js template as `npm run secret`.
16+
17+
Path convention is centralized in `secretParameterName()` (`/blocks/secrets/<KEY>`), alongside the existing `/blocks/{stage}/db-connection-string`. Backward compatible: `new Hosting(...)` keeps working for plain config and runtime secrets; only synth-time secrets require `Hosting.create()`.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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, it } from 'node:test';
6+
import { secret } from '@aws-blocks/hosting';
7+
import {
8+
collectSynthSecretKeys,
9+
partitionEnvironment,
10+
resolveDomainNames,
11+
resolveSecretsAtSynth,
12+
} from './hosting-secrets.js';
13+
14+
void describe('partitionEnvironment()', () => {
15+
void it('splits plain / runtime-secret / exposeAsEnv', () => {
16+
const { plain, runtimeSecrets, exposeSecrets } = partitionEnvironment({
17+
FLAG: 'on',
18+
STRIPE_KEY: secret('STRIPE_KEY'),
19+
LEGACY: secret('LEGACY', { exposeAsEnv: true }),
20+
});
21+
assert.deepStrictEqual(plain, { FLAG: 'on' });
22+
assert.deepStrictEqual(
23+
runtimeSecrets.map((s) => s.key),
24+
['STRIPE_KEY'],
25+
);
26+
assert.deepStrictEqual(
27+
exposeSecrets.map((s) => s.key),
28+
['LEGACY'],
29+
);
30+
});
31+
32+
void it('rejects env key / secret key mismatch', () => {
33+
assert.throws(
34+
() => partitionEnvironment({ STRIPE_KEY: secret('OTHER') }),
35+
/must match the environment variable name/,
36+
);
37+
});
38+
39+
void it('handles undefined', () => {
40+
const { plain, runtimeSecrets, exposeSecrets } = partitionEnvironment(undefined);
41+
assert.deepStrictEqual(plain, {});
42+
assert.strictEqual(runtimeSecrets.length, 0);
43+
assert.strictEqual(exposeSecrets.length, 0);
44+
});
45+
});
46+
47+
void describe('collectSynthSecretKeys()', () => {
48+
void it('gathers domain + exposeAsEnv keys, deduped', () => {
49+
const keys = collectSynthSecretKeys(
50+
['example.com', secret('DOMAIN_PROD')],
51+
[secret('LEGACY', { exposeAsEnv: true }), secret('DOMAIN_PROD', { exposeAsEnv: true })],
52+
);
53+
assert.deepStrictEqual([...keys].sort(), ['DOMAIN_PROD', 'LEGACY']);
54+
});
55+
56+
void it('returns nothing when no synth-time secrets exist', () => {
57+
assert.deepStrictEqual(collectSynthSecretKeys('example.com', []), []);
58+
assert.deepStrictEqual(collectSynthSecretKeys(undefined, []), []);
59+
});
60+
});
61+
62+
void describe('resolveSecretsAtSynth()', () => {
63+
void it('fetches each referenced key with decryption', async () => {
64+
const seen: string[] = [];
65+
const resolved = await resolveSecretsAtSynth(['DOMAIN_PROD'], async (name) => {
66+
seen.push(name);
67+
return 'prod.example.com';
68+
});
69+
assert.deepStrictEqual(seen, ['/blocks/secrets/DOMAIN_PROD']);
70+
assert.strictEqual(resolved.get('DOMAIN_PROD'), 'prod.example.com');
71+
});
72+
73+
void it('throws an actionable error when a secret is not set', async () => {
74+
await assert.rejects(
75+
resolveSecretsAtSynth(['DOMAIN_PROD'], async () => {
76+
const e = new Error('not found');
77+
e.name = 'ParameterNotFound';
78+
throw e;
79+
}),
80+
/blocks secret set DOMAIN_PROD/,
81+
);
82+
});
83+
});
84+
85+
void describe('resolveDomainNames()', () => {
86+
void it('replaces markers with resolved literals, preserving shape', () => {
87+
const resolved = new Map([['DOMAIN_PROD', 'prod.example.com']]);
88+
assert.strictEqual(resolveDomainNames(secret('DOMAIN_PROD'), resolved), 'prod.example.com');
89+
assert.deepStrictEqual(resolveDomainNames(['www.example.com', secret('DOMAIN_PROD')], resolved), [
90+
'www.example.com',
91+
'prod.example.com',
92+
]);
93+
});
94+
95+
void it('throws if a marker reached the sync path unresolved', () => {
96+
assert.throws(
97+
() => resolveDomainNames(secret('DOMAIN_PROD'), new Map()),
98+
/requires async resolution.*Hosting\.create/s,
99+
);
100+
});
101+
102+
void it('passes literal domains through untouched', () => {
103+
assert.strictEqual(resolveDomainNames('example.com', new Map()), 'example.com');
104+
});
105+
});
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/**
5+
* Resolution glue between the dependency-free `secret()` marker (defined in
6+
* `@aws-blocks/hosting`) and the `Hosting` CDK block. The marker is inert; this
7+
* module is where it actually gets wired into infrastructure.
8+
*
9+
* Two resolution strategies, chosen by where the marker appears:
10+
*
11+
* • `compute.environment` (runtime, the secure default) — we inject the SSM
12+
* parameter NAME (never the value) as `BLOCKS_SECRET_PARAM_<KEY>` and grant
13+
* the compute role `ssm:GetParameter` + `kms:Decrypt`. The value is fetched
14+
* and decrypted on first use at runtime via `getSecret()`. The secret stays
15+
* encrypted at rest; it never enters the CloudFormation template.
16+
*
17+
* • `domain.domainName` and `exposeAsEnv` env vars (synth time) — resolved by
18+
* an SDK `GetParameter(WithDecryption)` call DURING `cdk synth` and inlined
19+
* as a literal. A SecureString dynamic reference (`{{resolve:ssm-secure}}`)
20+
* can't be used in CloudFront Aliases (CloudFormation restricts ssm-secure
21+
* to an allowlist of properties), so synth-time SDK resolution is the
22+
* correct mechanism. A domain is public the moment the site is live, so
23+
* inlining the literal loses no confidentiality.
24+
*
25+
* Synth-time resolution is async, which is why callers that use domain secrets
26+
* or `exposeAsEnv` must construct via the async `Hosting.create()`.
27+
*
28+
* @module
29+
*/
30+
31+
import { isSecret, type SecretValue, secretEnvVarName, secretParameterName } from '@aws-blocks/hosting';
32+
import * as cdk from 'aws-cdk-lib';
33+
import * as iam from 'aws-cdk-lib/aws-iam';
34+
35+
/** A `compute.environment` value: a literal, or a deferred secret reference. */
36+
export type EnvValue = string | SecretValue;
37+
38+
/** A custom-domain name: a literal, a secret, or a mix in an array. */
39+
export type DomainNameInput = string | SecretValue | Array<string | SecretValue>;
40+
41+
/**
42+
* Split an environment map into the three handling buckets.
43+
* - `plain` — literal strings, injected verbatim (today's behavior).
44+
* - `runtimeSecrets` — `secret('K')`, resolved lazily at runtime.
45+
* - `exposeSecrets` — `secret('K', { exposeAsEnv: true })`, resolved at synth.
46+
*/
47+
export function partitionEnvironment(environment: Record<string, EnvValue> | undefined): {
48+
plain: Record<string, string>;
49+
runtimeSecrets: SecretValue[];
50+
exposeSecrets: SecretValue[];
51+
} {
52+
const plain: Record<string, string> = {};
53+
const runtimeSecrets: SecretValue[] = [];
54+
const exposeSecrets: SecretValue[] = [];
55+
56+
for (const [key, value] of Object.entries(environment ?? {})) {
57+
if (isSecret(value)) {
58+
// The env-var key the customer wrote IS the secret's logical key.
59+
// (Reject a mismatch so `STRIPE_KEY: secret('OTHER')` can't silently
60+
// resolve to the wrong parameter at runtime.)
61+
if (value.key !== key) {
62+
throw new Error(
63+
`Hosting environment '${key}': secret key '${value.key}' must match ` +
64+
`the environment variable name. Use ${key}: secret('${key}').`,
65+
);
66+
}
67+
(value.exposeAsEnv ? exposeSecrets : runtimeSecrets).push(value);
68+
} else {
69+
plain[key] = value;
70+
}
71+
}
72+
return { plain, runtimeSecrets, exposeSecrets };
73+
}
74+
75+
/** Every secret key that requires a synth-time SDK fetch (domain + exposeAsEnv). */
76+
export function collectSynthSecretKeys(
77+
domainName: DomainNameInput | undefined,
78+
exposeSecrets: SecretValue[],
79+
): string[] {
80+
const keys = new Set<string>();
81+
for (const name of toDomainArray(domainName)) {
82+
if (isSecret(name)) keys.add(name.key);
83+
}
84+
for (const s of exposeSecrets) keys.add(s.key);
85+
return [...keys];
86+
}
87+
88+
/** Normalize the domain input into an array (without resolving markers). */
89+
function toDomainArray(domainName: DomainNameInput | undefined): Array<string | SecretValue> {
90+
if (domainName === undefined) return [];
91+
return Array.isArray(domainName) ? domainName : [domainName];
92+
}
93+
94+
/**
95+
* Resolve a set of secret keys to plaintext via the SSM SDK at synth time.
96+
* Each parameter is fetched with decryption. Throws a clear, actionable error
97+
* if a referenced secret was never set with `blocks secret set`.
98+
*/
99+
export async function resolveSecretsAtSynth(
100+
keys: string[],
101+
fetcher: SsmGetParameter = defaultSsmGetParameter,
102+
): Promise<Map<string, string>> {
103+
const resolved = new Map<string, string>();
104+
await Promise.all(
105+
keys.map(async (key) => {
106+
const name = secretParameterName(key);
107+
try {
108+
resolved.set(key, await fetcher(name));
109+
} catch (error: unknown) {
110+
const e = error as { name?: string };
111+
if (e?.name === 'ParameterNotFound') {
112+
throw new Error(
113+
`Hosting: secret '${key}' is referenced (domain or exposeAsEnv) but ` +
114+
`not set. Set it before deploying:\n blocks secret set ${key} <value>`,
115+
);
116+
}
117+
throw error;
118+
}
119+
}),
120+
);
121+
return resolved;
122+
}
123+
124+
/** Resolve domain markers to literals using the synth-resolved secret map. */
125+
export function resolveDomainNames(domainName: DomainNameInput, resolved: Map<string, string>): string | string[] {
126+
const arr = toDomainArray(domainName).map((name) => {
127+
if (!isSecret(name)) return name;
128+
const value = resolved.get(name.key);
129+
if (value === undefined) {
130+
// Marker present but not resolved → caller used `new Hosting()` instead
131+
// of the async `Hosting.create()`.
132+
throw new Error(
133+
`Hosting: domain secret('${name.key}') requires async resolution. ` +
134+
`Construct with: await Hosting.create(scope, id, props).`,
135+
);
136+
}
137+
return value;
138+
});
139+
return Array.isArray(domainName) ? arr : arr[0];
140+
}
141+
142+
/**
143+
* Inject the SSM parameter NAME (not the value) for a runtime secret and grant
144+
* the compute role read+decrypt access scoped to that one parameter.
145+
*/
146+
export function wireRuntimeSecret(fn: cdk.aws_lambda.Function, key: string): void {
147+
const parameterName = secretParameterName(key);
148+
fn.addEnvironment(secretEnvVarName(key), parameterName);
149+
150+
const parameterArn = cdk.Stack.of(fn).formatArn({
151+
service: 'ssm',
152+
resource: 'parameter',
153+
// SSM ARNs omit the leading slash of the parameter name.
154+
resourceName: parameterName.replace(/^\//, ''),
155+
});
156+
157+
fn.addToRolePolicy(
158+
new iam.PolicyStatement({
159+
actions: ['ssm:GetParameter'],
160+
resources: [parameterArn],
161+
}),
162+
);
163+
// SecureStrings are encrypted with the default `aws/ssm` KMS key. Grant
164+
// Decrypt scoped via `kms:ViaService` so the role can only use the key
165+
// through SSM — the same scoping the AppSetting block uses.
166+
fn.addToRolePolicy(
167+
new iam.PolicyStatement({
168+
actions: ['kms:Decrypt'],
169+
resources: ['*'],
170+
conditions: {
171+
StringEquals: {
172+
'kms:ViaService': `ssm.${cdk.Stack.of(fn).region}.amazonaws.com`,
173+
},
174+
},
175+
}),
176+
);
177+
}
178+
179+
// ── SDK seam (overridable for tests) ────────────────────────────────────────
180+
181+
export type SsmGetParameter = (parameterName: string) => Promise<string>;
182+
183+
async function defaultSsmGetParameter(parameterName: string): Promise<string> {
184+
const { SSMClient, GetParameterCommand } = await import('@aws-sdk/client-ssm');
185+
const client = new SSMClient({});
186+
const result = await client.send(new GetParameterCommand({ Name: parameterName, WithDecryption: true }));
187+
const value = result.Parameter?.Value;
188+
if (value === undefined || value === null) {
189+
throw new Error(`Secret parameter "${parameterName}" has no value.`);
190+
}
191+
return value;
192+
}

0 commit comments

Comments
 (0)