Skip to content

feat(hosting): add secret() support for self-hosted deployments#115

Open
osama-rizk wants to merge 23 commits into
mainfrom
feat/hosting-secrets
Open

feat(hosting): add secret() support for self-hosted deployments#115
osama-rizk wants to merge 23 commits into
mainfrom
feat/hosting-secrets

Conversation

@osama-rizk

@osama-rizk osama-rizk commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

What & why

Adds secret() support to hosting and pipeline — a way to reference sensitive values (API keys, third-party credentials, source connection ARNs, custom domains) without hardcoding them in source, committing them to git, or leaking them into the CloudFormation template.

Opt-in and non-blocking — apps not using secret() are unaffected, and apps using native .env / third-party stores keep working.

Target consumers

One shared engine serves the ways an SSR app reaches aws-blocks hosting, with no coupling between them:

Consumer How it uses hosting What secret() gives it
Blocks app via @aws-blocks/core's Hosting block (@aws-blocks/blocks/cdk) secret() in environment/domain + getSecret() in SSR + npm run secret CLI (/blocks/secrets/<KEY>). Can also keep secrets in a backend AppSetting — both work.
Standalone app imports only @aws-blocks/hosting (the L3 construct directly, no Blocks backend) The whole story — no backend, no AppSetting: HostingConstruct wires runtime markers; getSecret() + npm run secret with the app's own prefix.

The key enabler: runtime marker resolution lives in the shared HostingConstruct (not just core.Hosting), so a standalone app that uses the construct directly gets runtime secrets with zero extra code.

How it works

secret('STRIPE_KEY') returns a dependency-free marker (a "claim ticket"), not the value. The value lives encrypted in the store (Secrets Manager by default, per AWS's guidance for application credentials; SSM SecureString via store: 'ssm' for non-sensitive config) and is set out-of-band via the CLI. Markers resolve with one of two strategies, chosen per prop:

Prop Strategy Why
compute.environment Runtime (default) — inject the store locator, grant read on that one resource (secretsmanager:GetSecretValue or ssm:GetParameter) + kms:Decrypt, resolve via getSecret() on first use Value stays encrypted at rest; never in the template
domain.domainName, secret(…, { exposeAsEnv: true }) Synth-time — SDK fetch, inline literal ssm-secure CFN dynamic refs aren't allowed in CloudFront Aliases / Lambda env (allowlist-restricted); a domain is public anyway
pipeline source.connectionArn Synth-time — resolved via the shared hosting resolver Consumed by the CodePipeline service, which needs a literal ARN in the template
pipeline buildSecrets Build-time — injected as CodeBuild SECRETS_MANAGER/PARAMETER_STORE env vars Consumed by your build commands; CodeBuild fetches per build (masked in logs), so no redeploy to rotate
  • Store (default: Secrets Manager; SSM opt-in). Follows AWS's public guidance (How to choose the right AWS service for managing secrets and configurations): Secrets Manager for application secrets/credentials, Parameter Store for non-sensitive config. Pick per consumer via store.
  • IAM is least-privilege. Each runtime secret grants read (secretsmanager:GetSecretValue or ssm:GetParameter) on that one resource's ARN + kms:Decrypt conditioned on kms:ViaService.
  • Never leaves the server. Runtime secrets are fetched inside the compute Lambda and never enter the CloudFormation template, the client bundle, or list output.

Usage

End-to-end, there are three steps: declare the reference in infra, set the value out-of-band once, retrieve it where it's used.

1. Declare — reference the secret in infra

import { Hosting, secret } from '@aws-blocks/blocks/cdk';

// Async create() is required when any secret resolves at synth time
// (a custom domain, or exposeAsEnv). It's safe to use uniformly.
await Hosting.create(stack, 'Web', {
  root: '.', framework: 'nextjs', api: blocksStack,
  environment: {
    STRIPE_KEY: secret('STRIPE_KEY'),                       // runtime, encrypted (default)
    SENTRY_DSN: secret('SENTRY_DSN', { exposeAsEnv: true }),// synth-time plaintext env (escape hatch)
  },
  domain: { domainName: secret('DOMAIN_PROD') },            // synth-time literal
});

A standalone app (no Blocks backend) uses the construct directly:

import { HostingConstruct } from '@aws-blocks/hosting/constructs';
import { secret } from '@aws-blocks/hosting';

new HostingConstruct(stack, 'Web', {
  manifest,                                          // from the framework adapter
  environment: { STRIPE_KEY: secret('STRIPE_KEY') },
  secrets: { prefix: '/myapp/secrets' },             // optional: pin your own namespace/store
});

2. Set — write the value out-of-band (never in source)

npm run secret -- set STRIPE_KEY "sk_live_…"      # writes to the store, encrypted
npm run secret -- list                             # names only, never values
npm run secret -- remove STRIPE_KEY

Choosing the store (default is Secrets Manager; opt into SSM for non-sensitive config): the CLI wrapper passes store: 'ssm'. The CLI and the deploy must agree on the store and prefix so the value is written and read under the same locator.

3. Retrieve — read it at request time

import { getSecret } from '@aws-blocks/blocks';           // or '@aws-blocks/hosting' for a standalone app

export async function GET() {
  const key = await getSecret('STRIPE_KEY');              // fetch + decrypt once, then cached
  // … use `key` server-side; it is never sent to the browser …
}

For an exposeAsEnv secret, process.env.SENTRY_DSN works directly (no runtime call) — at the cost of the value being inlined in the Lambda config.

Per-stage values with shared fallback (optional)

Give one key a different value per environment, falling back to a shared default:

npm run secret -- set STRIPE_KEY "sk_live_prod"  --stage prod
npm run secret -- set STRIPE_KEY "sk_test_shared"           # shared fallback (no --stage)

Resolution tries <prefix>/<stage>/<key> and falls back to the shared <prefix>/<key> when the stage has none — so prod/beta differ while ephemeral PR/preview stages inherit the shared default. For runtime secrets the fallback is a two-try at request time, so a per-stage value can be added or rotated without a redeploy. Omit stage entirely for a flat single namespace (unchanged behavior).

Pipeline secrets

A pipeline uses secret() in two places, for its two different consumers:

import { Pipeline, secret } from '@aws-blocks/pipeline';

await Pipeline.create(stack, 'Pipeline', {
  // (1) The CodePipeline SERVICE authenticates to the repo — resolved at synth.
  source: { repo: 'org/app', connectionArn: secret('CONNECTION_ARN') },

  // (2) Credentials your BUILD COMMANDS need — fetched by CodeBuild at build
  //     time (SECRETS_MANAGER/PARAMETER_STORE env vars, masked in logs).
  buildSecrets: {
    NPM_TOKEN: secret('NPM_TOKEN'),
    DOCKERHUB_PASSWORD: secret('DOCKERHUB_PASSWORD'),
  },
  synth: {
    commands: [
      'npm ci --//registry.npmjs.org/:_authToken=$NPM_TOKEN',
      'docker login -u me -p $DOCKERHUB_PASSWORD',
      'npx cdk synth',
    ],
  },
  branches: [{ branch: 'main', stages: [{ name: 'prod' }] }],
});

Set both with the same CLI (secret set CONNECTION_ARN …, secret set NPM_TOKEN …). connectionArn is synth-time (the service needs a literal ARN in the template); buildSecrets are build-time (CodeBuild fetches them per build, so they rotate without a redeploy).

Rotation

Re-run secret set with the new value. Runtime hosting secrets and pipeline buildSecrets pick it up on the next cold start / next build — no redeploy. Synth-time secrets (exposeAsEnv, domain, connectionArn) are baked in at deploy, so those require a redeploy to re-inline.

Tests

  • hosting: marker creation/validation, path/env naming, the resolve engine (partition / resolve-synth / wire-runtime, per-stage + fallback), the CLI core (incl. --stage), getSecret() runtime resolver (env-first, fetch+decrypt, cache, concurrent coalescing, store hint, two-try fallback), and construct-level assertions (locator injected, value absent, exposeAsEnv rejected pre-resolution, IAM scoped).
  • pipeline: connectionArn marker resolved at synth + sync-path rejection.
  • core: unchanged (marker stripping + Hosting CDK-assertion tests).
  • Full suites green: hosting 788, pipeline 84.

End-to-end verification (live deploy)

Each consumer was deployed to a disposable sandbox account and probed at a route that calls getSecret('DEMO_SECRET') in the SSR Lambda. The probe returns only a masked fingerprint (value length + a truncated hash) — never the value — so resolution is proven without exposing the secret.

Verified on each deployed Lambda: the env var carries the store locator (not the value), the raw value is absent from the Lambda env and the CloudFormation template, the compute role's read grant is scoped to that one secret's ARN, and kms:Decrypt is conditioned on kms:ViaService. Both stores were exercised live — the SSM path and the Secrets Manager default each resolved end-to-end with the correct fingerprint and least-privilege IAM. The exposeAsEnv path correctly inlines a plaintext env var (the documented escape hatch). (Live account IDs, distribution hostnames, and secret material are intentionally omitted here.)

Bug caught by the live run (and fixed): the neutral default prefix /aws-hosting/secrets is rejected by AWS — SSM reserves any parameter path starting with aws/ssm. Changed to /hosting/secrets. A second issue on the Secrets Manager path — the created secret name and the IAM ARN the grant scoped to disagreed on the leading slash — is fixed by a single secretStoreLocator() helper shared by the CLI, the grant, and the runtime read.

Follow-ups (not in this PR)

  • Region ergonomics — the CLI writes in the default region; if that differs from the deploy region, synth/runtime reports "secret not set". Consider deriving/accepting the deploy region in the CLI.
  • Type-safe keys — a defineSecrets({...}) builder (pure TS inference, no codegen) could make a typo in a key a compile error rather than a runtime not-found.

@osama-rizk
osama-rizk force-pushed the feat/hosting-secrets branch from 24159af to 758abdf Compare July 1, 2026 12:01
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.
Decouples the secret mechanism from Blocks branding so @aws-blocks/hosting
stays a framework-neutral leaf that any consumer (a plain framework app, a
future standalone hosting package) can use. No behavior change for Blocks users.

@aws-blocks/hosting (neutral engine):
- secretParameterName(key, prefix?) now takes an injectable SSM prefix,
  defaulting to a neutral DEFAULT_SECRET_PARAMETER_PREFIX = '/aws-hosting/secrets'
  (was hardcoded '/blocks/secrets').
- env var prefix renamed BLOCKS_SECRET_PARAM_ -> HOSTING_SECRET_PARAM_
  (internal wiring, framework-neutral).
- Stripped Blocks-specific wording from doc comments + runtime error messages.

@aws-blocks/core (Blocks namespace):
- New secret-naming.ts: BLOCKS_SECRET_PARAMETER_PREFIX='/blocks/secrets' +
  blocksSecretParameterName() = neutral engine + Blocks prefix. Single place
  Blocks pins its namespace.
- hosting-secrets.ts (CDK wiring) and scripts/secret.ts (CLI) now route through
  blocksSecretParameterName(), so Blocks secrets still land at /blocks/secrets/<KEY>
  -> NO SSM-path migration, Blocks users unaffected.

Why: making the engine brand-neutral now turns a future standalone-secret
extraction into a mechanical move instead of a breaking SSM-path migration.
Full extraction into a standalone package is deliberately deferred to the
standalone-hosting work.

Tests: neutral-default + injected-prefix + neutral-env assertions (hosting),
new secret-naming test (core), updated CDK integration assertion to
HOSTING_SECRET_PARAM_. hosting 708 / core 507 green.
@osama-rizk
osama-rizk force-pushed the feat/hosting-secrets branch from 758abdf to 49a831d Compare July 1, 2026 12:03
@osama-rizk
osama-rizk changed the base branch from fix/hosting-phase0-bugs to main July 1, 2026 12:03
The secret CLI was only wired into the nextjs template. Add the same
wrapper (aws-blocks/scripts/secret.ts) + npm script ("secret") to every
template with a deploy flow (auth-cognito, backend, bare, default, demo,
react) so a scaffolded app can run:

  npm run secret -- set KEY value
  npm run secret -- list
  npm run secret -- remove KEY

out of the box — consistent with how deploy/sandbox/destroy are already
exposed as package.json scripts. The amplify template is intentionally
skipped (no standalone deploy flow).
Move secret marker resolution into the shared HostingConstruct so ANY
consumer (Blocks, standalone CDK, Amplify's defineHosting) gets runtime
secrets for free, not just core.Hosting.

- HostingConstruct.environment now accepts secret() markers (EnvValue) and
  a `secrets` prop (prefix/store). The construct partitions env, applies
  plain vars, wires RUNTIME markers (inject SSM locator + grant read/decrypt),
  and throws on unresolved synth-time (exposeAsEnv) markers.
- Add secret-resolve.ts: the CDK-aware engine (partitionEnvironment,
  resolveSecretsAtSynth, resolveDomainNames, wireRuntimeSecret) + a synth
  fetcher override for tests.
- Add secret-cli.ts: shared set/list/remove CLI core, parameterized by
  label/prefix/store so each consumer wraps it with its own namespace.
- Pluggable store: SecretStore = 'ssm' | 'secrets-manager'; runtime resolver
  reads the _STORE hint and fetches from SSM (default) or Secrets Manager.
- Fix reserved-prefix bug: DEFAULT_SECRET_PARAMETER_PREFIX '/aws-hosting/secrets'
  -> '/hosting/secrets'. SSM reserves any path starting with 'aws'/'ssm' and
  rejects it with "No access to reserved parameter name". Found via live e2e.

Tests: hosting 745 pass.
Let a CodeConnections connectionArn be supplied as a secret() marker,
resolved at SYNTH time from the store (SSM/Secrets Manager) via the shared
hosting resolver. CodeBuild has no .env, so a store-backed secret is the
right mechanism for a source credential.

- connectionArn: string | SecretValue.
- Pipeline.create() resolves a marker connectionArn before validation/use
  (neutral /hosting/secrets prefix); sync `new Pipeline()` rejects a marker
  with a "use Pipeline.create()" error.
- Re-export secret/isSecret/SecretValue from @aws-blocks/hosting/secret so
  pipeline users import the marker from one place.
- Add pipeline dep on @aws-blocks/hosting (leaf; no cycle).
- test-apps/pipeline: pipeline-secret.cdk.ts variant proving synth resolution.

Tests: pipeline 84 pass.
…lic AWS guidance)

The AWS guidance is now public — "How to choose the right AWS service for
managing secrets and configurations" recommends Secrets Manager for application
secrets/credentials (API tokens, DB passwords) and Parameter Store for
non-sensitive config. secret() holds credentials, so flip the default back:

- DEFAULT_SECRET_STORE: 'ssm' -> 'secrets-manager'. One-line change via the
  established seam; SSM SecureString stays a first-class opt-in (store: 'ssm')
  for non-sensitive config. secretStoreLocator() + _STORE runtime hint unchanged.
- Blocks/core unaffected (own SSM /blocks/secrets namespace, injects no hint).
- Tests flipped to assert SM as default + SSM as opt-in; the pure-locator test
  now asserts against DEFAULT_SECRET_STORE so it's robust to future flips.
- Doc comments cite the public guidance blog. hosting 746 pass, pipeline 84 pass.

Live-verified end-to-end on Secrets Manager (isolated Function-URL probe running
the real wireRuntimeSecret + getSecret): value fingerprint matched, no leak,
least-priv secretsmanager:GetSecretValue + kms:Decrypt (ViaService-scoped).
osama-rizk and others added 3 commits July 16, 2026 11:51
Add an optional `stage` so one logical secret can hold a different value per
environment, with a shared fallback — matching SST's --fallback use case
(ephemeral PR/preview stages inherit a shared default; prod/beta differ).

- secretStoreLocator gains `stage` → <prefix>/<stage>/<key>; omit for the shared
  <prefix>/<key> (the fallback target). New secretFallbackEnvVarName helper.
- SecretResolveOptions.stage. wireRuntimeSecret injects the stage locator + a
  _FALLBACK shared locator and grants read on BOTH ARNs (deduped when stageless).
- Runtime getSecret does a two-try: primary (stage) then _FALLBACK on not-found,
  caches the winner — so a per-stage value can be added/rotated without redeploy.
  No _FALLBACK wired ⇒ single fetch, unchanged.
- resolveSecretsAtSynth (domains, connectionArn) resolves stage-then-shared
  eagerly, with an error naming both when neither is set.
- CLI: `secret set/list/remove … --stage <name>` (and --stage=<name>); list is
  stage-scoped when given.

Fully opt-in and backward-compatible: omit `stage` and behavior is identical to
before (all 746 existing hosting tests unchanged). Core untouched; pipeline 84.
@changeset-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3cf3c2a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 25 packages
Name Type
@aws-blocks/hosting Minor
@aws-blocks/core Minor
@aws-blocks/pipeline Minor
@aws-blocks/create-blocks-app Patch
@aws-blocks/bb-kv-store Patch
@aws-blocks/bb-distributed-table Patch
@aws-blocks/auth-common Patch
@aws-blocks/bb-app-setting Patch
@aws-blocks/bb-data Patch
@aws-blocks/bb-distributed-data Patch
@aws-blocks/bb-auth-basic Patch
@aws-blocks/bb-auth-cognito Patch
@aws-blocks/bb-auth-oidc Patch
@aws-blocks/bb-realtime Patch
@aws-blocks/bb-async-job Patch
@aws-blocks/bb-dashboard Patch
@aws-blocks/bb-cron-job Patch
@aws-blocks/bb-file-bucket Patch
@aws-blocks/bb-agent Patch
@aws-blocks/bb-knowledge-base Patch
@aws-blocks/bb-logger Patch
@aws-blocks/bb-email-client Patch
@aws-blocks/bb-tracer Patch
@aws-blocks/bb-metrics Patch
@aws-blocks/blocks Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

osama-rizk and others added 3 commits July 20, 2026 14:44
Add `buildSecrets: { NAME: secret('NAME') }` to PipelineProps for credentials
the build/deploy commands need (npm publish, docker login, private npm ci,
signing). Distinct from source.connectionArn (consumed by the CodePipeline
service, synth-time): buildSecrets are consumed by the build commands and
fetched by CodeBuild AT BUILD TIME.

- Mapped to the synth CodeBuild project's environmentVariables with type
  SECRETS_MANAGER (default) or PARAMETER_STORE (secrets.store: 'ssm'), value =
  secretStoreLocator(key, {prefix, store}). CodeBuild grants the build role read
  and masks the value in logs; the value never enters the template, so rotating
  takes effect next build with no redeploy.
- New `secrets: { prefix?, store? }` prop selects namespace/store (shared with
  a secret connectionArn). Non-marker buildSecrets values are rejected.
- Reuses the hosting leaf's secretStoreLocator (no new store code). Optional +
  backward-compatible: no buildSecrets ⇒ buildEnvironment untouched.
- Tests: SM default, SSM+custom prefix, non-marker rejection, omitted case.
  pipeline 88 pass. Core untouched.
Comment thread packages/pipeline/src/pipeline-construct.ts Outdated
Comment thread packages/hosting/src/secret-resolve.ts Outdated
Comment thread test-apps/pipeline/aws-blocks/pipeline-secret.cdk.ts Outdated
Comment thread packages/core/src/secret-naming.ts Outdated
Comment thread packages/core/src/hosting-secrets.ts Outdated
…c fixes

Address review feedback (soberm):

- [major] resolveSourceSecrets ignored props.secrets, so a secret() connectionArn
  always resolved from the defaults (/hosting/secrets, Secrets Manager) even when
  the caller configured secrets: { store, prefix }. It now threads
  { prefix: props.secrets?.prefix, store: props.secrets?.store } into
  resolveSecretsAtSynth, so connectionArn and buildSecrets resolve from the same
  namespace/store. Added a test asserting the locator (capturing fetcher, not the
  value-returning global bypass).
- [minor] secret-resolve.ts SecretResolveOptions.store JSDoc: default 'ssm' →
  {@link DEFAULT_SECRET_STORE} ('secrets-manager').
- [minor] test-apps pipeline comment: connectionArn resolves from Secrets Manager
  (hosting/secrets/CONNECTION_ARN), not SSM.
- [nit] core/secret-naming.ts: /aws-hosting/secrets → /hosting/secrets (the value
  the reserved-prefix fix corrected).
- [nit] core/hosting-secrets.ts: BLOCKS_SECRET_PARAM_<KEY> → HOSTING_SECRET_PARAM_<KEY>.

pipeline 89 pass. Core changes are comment-only.
Comment thread packages/core/src/scripts/secret.ts Outdated
Comment thread packages/core/src/scripts/secret.ts
Comment thread packages/pipeline/src/pipeline.test.ts
Comment thread packages/pipeline/src/pipeline-construct.ts Outdated
Comment thread packages/pipeline/src/types.ts
The secret() changes rode in on top of a spaces->tabs reindent (plus
import re-sorting and re-wrapping of pre-existing code) that biome's
format-on-save applied. CI only runs `biome lint`, not the formatter,
so the pipeline package was space-indented on main and none of the
reformat was required. Reverted the three files to main's formatting and
re-applied ONLY the genuine secrets changes, so the diff now shows the
feature, not the reflow:

  types.ts             717 -> 46  (+45/-1)
  pipeline-construct   1100 -> 97 (+92/-5)
  pipeline.test.ts     3949 -> 138 (+138/-0, purely additive)

No behavior change; 89 pipeline tests pass.

@bobbor bobbor left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @osama-rizk, really impressive work here 🙌 The PR description alone deserves a shout-out, and the design has a lot of genuinely great calls: the shared secretStoreLocator() helper (love that it was born from a live-caught bug), the branded-symbol marker, the coalescing cache in getSecret(), the fail-fast on unresolved exposeAsEnv, and the CodeBuild-native buildSecrets wiring. Test coverage actually asserts the security-relevant behavior too, not just the happy paths 👏

That said, I dug in pretty deep and found a couple of things I'd consider blocking, plus some smaller ones. Details are in the inline comments; TL;DR:

🔴 Blocking

  1. Cross-stage IAM over-grant: preview/PR stages get standing IAM read on the shared fallback secret (see secret-resolve.ts comment)
  2. Duplicate kms:Decrypt statements per secret: policy bloat that can approach the 10KB inline policy limit

🟠 Worth fixing in this PR
3. Secret values land in argv / shell history via secret set KEY value
4. Default prefix /hosting/secrets is account-global, so two apps in one account collide
5. Plain Error throws in secret-resolve.ts while the rest of hosting uses structured HostingError
6. Pipeline's new dep on "@aws-blocks/hosting": "*" resolves to any version once published
7. Secrets Manager list leaks stage-scoped entries into the shared-namespace listing
8. core's hosting-secrets.ts duplicates the resolve engine as an SSM-only variant with no guard against divergence

🟡 Minor / fast-follow

  • No cache TTL in getSecret(): rotation only lands on cold start, which can be hours with provisioned concurrency
  • sub_path_exports.test.ts wasn't updated for the new ./secret entry
  • No README/docs updates despite the new public API and CLI workflow
  • Template secret.ts files use 2-space indent while biome.json mandates tabs; also the hosting/src/index.ts reformat of existing exports inflates the diff a bit
  • CLI region mismatch: you already called it out as a follow-up, a --region flag would be cheap insurance
  • Worth confirming API.md came from api-extractor and not a hand edit

One thing I checked so nobody else trips on it: requestHandler: { requestTimeout: 5000 } is totally fine 👍 the plain-object short form is officially supported since SDK v3.521.0, so no change needed there.

Overall: really solid feature, and the two blocking items are fixable without touching the architecture. Happy to re-review quickly once they're addressed 🚀

// fallback), deduped.
const locators = [...new Set([primary, ...(fallback ? [fallback] : [])])];
for (const locator of locators) {
grantSecretRead(fn, locator, store);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: cross-stage IAM over-grant on the shared fallback

This loop grants the compute role read on both the stage locator and the shared fallback <prefix>/<key>, unconditionally. The two-try fallback is only enforced in application logic; the IAM permission is standing. That means every ephemeral PR/preview deploy holds IAM read access to the shared fallback secret at all times, and a compromised handler in a preview stage can call GetSecretValue on the fallback ARN directly, bypassing the two-try logic.

The docs example (sk_test_shared as fallback) is safe, but nothing stops or warns the inverse (someone putting a prod value in the shared slot and letting previews "inherit" it).

Suggestions: make the fallback grant opt-in per stage, or document loudly that the shared fallback must never hold production values (safe dev defaults only).

}

/** Grant a Lambda role least-priv read+decrypt on one secret locator. */
function grantSecretRead(fn: cdk.aws_lambda.Function, locator: string, store: SecretStore): void {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: duplicate kms:Decrypt statements per secret

grantSecretRead is called once per locator, and each call appends its own kms:Decrypt statement (Resource: '*' + kms:ViaService condition). CDK doesn't deduplicate addToRolePolicy, so N secrets with stage fallbacks means up to 2N identical kms:Decrypt statements. Beyond the noise, this bloats the inline policy toward the 10KB limit for apps with several secrets.

Suggestion: hoist the KMS grant out of the per-locator path and add it once per store type after the read grants.

(The Resource: '*' + ViaService pattern itself is fine 👍 that's the documented approach for service-default keys and matches the existing AppSetting code.)

switch (subcommand) {
case 'set': {
const [key, ...valueParts] = rest;
const value = valueParts.join(' ');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Secret values land in argv and shell history

secret set STRIPE_KEY sk_live_… puts the value into process.argv, which makes it visible in ps output, /proc/*/cmdline, and shell history files. For a tool whose whole pitch is "secrets never end up in git", this is a bit ironic 😅 and it's exactly the kind of footgun the target audience (external devs) will hit.

Suggestion: support --value-stdin or an interactive prompt, or at minimum document the exposure prominently in the CLI help.

* those prefixes and rejects create/read with "No access to reserved parameter
* name". (An earlier `/aws-hosting/...` default hit exactly that.)
*/
export const DEFAULT_SECRET_PARAMETER_PREFIX = '/hosting/secrets';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Default prefix is account-global

Two standalone apps in the same account using this default share one namespace: same key name means literally the same secret, and secret list enumerates entries across apps. That's a realistic scenario for the target audience (one personal account, several apps).

Suggestion: derive the default from the app/stack name, or document clearly that multi-app accounts must set a unique prefix.

for (const [key, value] of Object.entries(environment ?? {})) {
if (isSecret(value)) {
if (value.key !== key) {
throw new Error(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Convention: plain Error vs HostingError

hosting_construct.ts uses structured HostingError ({code, message, resolution}) exclusively, 14 instances and zero plain throws, but the throws in this file (partitionEnvironment, resolveSecretsAtSynth, resolveDomainNames) are plain new Error(...). The exposeSecrets path in the construct got it right with HostingError('UnresolvedSecretError', …). Consumers lose the error-code contract for these paths.

"dev": "tsc --build --watch"
},
"dependencies": {
"@aws-blocks/hosting": "*"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Bare "*" dependency on a published package

Pipeline previously had no runtime deps. In the workspace this resolves locally, but in the published package "*" accepts any hosting version whatsoever, which will break consumers on the first incompatible hosting release. Suggest pinning to a proper semver range.

let nextToken: string | undefined;
do {
const result = await client.send(
new ListSecretsCommand({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 SM list leaks stage-scoped entries into the shared listing

The SSM path uses Recursive: false to keep the listing flat, but this Secrets Manager name filter is a plain prefix match with no depth control, so stage-scoped secrets show up in the shared-namespace listing as prod/KEY etc.

Suggestion: after slicing the prefix, filter out names containing / (or group them by stage, which would actually be a nice UX).

* Inject the SSM parameter NAME (not the value) for a runtime secret and grant
* the compute role read+decrypt access scoped to that one parameter.
*/
export function wireRuntimeSecret(fn: cdk.aws_lambda.Function, key: string): void {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 SSM-only duplicate of the hosting resolve engine

This is a reimplementation of hosting/src/secret-resolve.ts's engine, hardcoded to SSM (no _STORE hint, no stage/fallback, SSM-only IAM). That's consistent today because core pins the Blocks SSM namespace, but there's no comment or assertion saying "SSM-only by design", so the two engines can silently diverge (e.g. if someone later threads a store option through core, they'd get SSM grants with a Secrets Manager runtime read → AccessDenied).

Suggestion: a one-line comment/assertion making the SSM-only contract explicit, or share the engine.

}

/** Cache of resolved secret values, keyed by logical secret name. */
const cache = new Map<string, string>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 No TTL: rotation only lands on cold start

The docs say rotated values are picked up "on the next cold start", which is accurate, but with provisioned concurrency or steady traffic a warm sandbox can serve a stale value for hours. Not blocking, but a configurable TTL (even a generous default like 5-15 min) or a prominent note in the rotation docs would save someone a confusing incident.

"types": "./dist/hosting_error.d.ts",
"default": "./dist/hosting_error.js"
},
"./secret": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Missing subpath export test

sub_path_exports.test.ts covers ./constructs, ./adapters, and ./error but wasn't updated for this new ./secret entry. Cheap test, catches broken published artifacts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants