Parent runbook: docs/slo/completed/RUNBOOK-hulumi-k8s.md. Read the runbook's Global Execution Rules + Global Entry Rules + M3's lessons before starting.
Goal: After M4, @hulumi/k8s-baseline.KubernetesSecretFromAwsSecretsManager ships as the generic foundation that extracts a JSON-shaped Secrets-Manager value into a K8s Secret via a pulumi.dynamic.Resource, and @hulumi/k8s-baseline.RdsCredentialSecret ships as the convenience wrapper for the AWS-managed-master JSON shape (username, password, host, port, engine, dbClusterIdentifier). Closes #40. Independent of M2/M3.
Context: AWS RDS / Aurora / DocumentDB / Neptune all auto-manage master credentials in the same JSON shape (when manage_master_user_password=true, the AWS-recommended posture). Workloads on EKS commonly want a plain DB_PASSWORD env var, not the JSON blob. Today every consumer either (a) bridges by hand (drift-prone), (b) bakes a Secrets Manager client into every binary (heavyweight), or (c) installs the SM CSI driver (cluster-wide infra dep with attestation gaps). The dynamic-provider option, per the design record, lives on Pulumi state in encrypted form — the cleanest trust boundary for v1.
Important design rule: Two layers, not one. The generic KubernetesSecretFromAwsSecretsManager is the foundation; RdsCredentialSecret is a thin convenience wrapper. The foundation is the load-bearing component (it generalizes to ElastiCache AUTH tokens, third-party API credentials, opaque DSNs from any AWS service that uses the same SM-managed pattern). The wrapper is one line of consumer code vs. five-key plumbing — the design record already committed (Q5 → bias accepted: yes, ship the wrapper).
Refactor budget: Surgical addition only. New files. Modifies packages/k8s-baseline/src/index.ts, packages/k8s-baseline/package.json (add @aws-sdk/client-secrets-manager runtime dep), scripts/exact-pin-guard.mjs, scripts/cooling-off-diff.mjs.
| Field | Value |
|---|---|
| Inputs | (Foundation) new KubernetesSecretFromAwsSecretsManager(name, args) where args requires: secretsManagerArn: pulumi.Input<string>, keyMapping: Record<string, string> (SM-JSON-key → K8s-Secret-data-key; refused if empty), namespace: pulumi.Input<string>, secretName: string (refused if empty or contains / or ..). Optional: region?: string (defaults to provider/process region), secretType?: string (default "Opaque"), labels?, annotations?. (Wrapper) new RdsCredentialSecret(name, args) where args requires: rdsManagedMasterCredentialArn: pulumi.Input<string>, namespace: pulumi.Input<string>, secretName: string. Optional: region?, keyMapping?: Record<string, string> (default extracts username, password, host, port, engine, dbClusterIdentifier). The wrapper internally constructs the foundation. |
| Outputs | Both expose secretName, namespace, dataKeysWritten: pulumi.Output<string[]> (the K8s-side keys actually present in the rendered Secret). |
| Interfaces touched | New stable surface: @hulumi/k8s-baseline#{KubernetesSecretFromAwsSecretsManager,…Args,…Outputs,KUBERNETES_SECRET_FROM_ASM_COMPONENT_TYPE,RdsCredentialSecret,…Args,…Outputs,RDS_CREDENTIAL_SECRET_COMPONENT_TYPE}. |
| Data classification | Restricted — the milestone's load-bearing data flow is database credentials. SM secret values transit Pulumi state in encrypted form; the K8s Secret lives at rest in etcd (cluster-side encryption is the consumer's choice). The dynamic provider's read and update paths are the only places plaintext exists in-process. Compliance frontmatter applies: compliance: [soc2, asvs] for any consumer who imports either component into a SOC2/ASVS-scoped stack. |
| Proactive controls in play | (a) C1 Define Security Requirements — design record § Decision: KubernetesSecretFromAwsSecretsManager documents the trust-boundary trade-off explicitly. (b) C2 / C8 Data protection — Pulumi's standard pulumi.secret() discipline wraps the extracted JSON value before it lives in state; the K8s Secret is base64-encoded by the K8s API (NOT encrypted at rest by default — consumer's responsibility to enable etcd encryption). (c) C5 Validate All Inputs — keyMapping refused if empty; secretName refused if empty / contains / / .. (path-traversal-like). (d) C7 Authorization — the dynamic provider runs under the Pulumi process's IAM role; consumer is responsible for iam:GetSecretValue on the target ARN. (e) C9 Implement Security Logging and Monitoring — emits pulumi.log.warn with the K8s data-keys list (no values) when the rendered Secret omits a key the keyMapping requested (the SM JSON didn't have the source key). (f) C10 Handle All Errors and Exceptions — refuses construction with clear errors; SM API errors surface verbatim minus the secret value. |
| Abuse acceptance scenarios | Five BDD rows in the table below cite tm-hulumi-k8s-abuse-N. Slug-keyed: tm-hulumi-k8s-abuse-secret-name-traversal (refuses secretName: "../etc/secret"), tm-hulumi-k8s-abuse-key-mapping-empty (refuses empty mapping — silently writing zero keys is a real failure mode), tm-hulumi-k8s-abuse-rds-default-keys-load-bearing (the wrapper's default mapping covers exactly the 6 RDS auto-managed JSON keys; renaming any breaks consumer apps), tm-hulumi-k8s-abuse-secret-value-not-logged (asserts that on SM API error, the error message does not contain any value-bytes from the SM secret), tm-hulumi-k8s-abuse-shell-out-refused (no child_process.exec in dynamic provider — Forbidden shortcut (a)). |
| Files allowed to change | New: packages/k8s-baseline/src/{kubernetes-secret-from-asm.ts,kubernetes-secret-from-asm.args.ts,kubernetes-secret-from-asm.outputs.ts,kubernetes-secret-from-asm.provider.ts,rds-credential-secret.ts,rds-credential-secret.args.ts,rds-credential-secret.outputs.ts}; packages/k8s-baseline/tests/{kubernetes-secret-from-asm.test.ts,rds-credential-secret.test.ts}; packages/k8s-baseline/tests/integration/kind/secret-extraction.kind.test.ts (kind cannot exercise SM; the kind test stubs the SM client and asserts the K8s Secret is created with the correct shape); docs/slo/lessons/hulumi-k8s-m4.md; docs/slo/completion/hulumi-k8s-m4.md; docs/components/{kubernetes-secret-from-asm.md,rds-credential-secret.md} (one-line stubs). Modified: packages/k8s-baseline/src/index.ts (re-export); packages/k8s-baseline/package.json (add @aws-sdk/client-secrets-manager exact-pinned); scripts/exact-pin-guard.mjs and scripts/cooling-off-diff.mjs (add the new dep); docs/slo/completed/RUNBOOK-hulumi-k8s.md Milestone Tracker. Files outside this milestone's allow-list — REFUSE TO TOUCH including any packages/baseline/, packages/policies/, packages/drift/, M1/M2/M3 K8s files (other than index.ts). |
| Files to read before changing anything | docs/slo/completed/RUNBOOK-hulumi-k8s.md; docs/slo/design/hulumi-k8s-surface.md (§ Decision: KubernetesSecretFromAwsSecretsManager); Pulumi pulumi.dynamic.Resource documentation; AWS manage_master_user_password=true JSON shape reference; packages/baseline/src/aws/account-foundation.ts (KMS alias output pattern, in case the consumer integrates). |
| New files allowed | All "New" entries above. |
| New dependencies allowed | Runtime: @aws-sdk/client-secrets-manager@^3.x (exact-pin via integrity hash). The dynamic provider uses this SDK; no shell-out paths. |
| Migration allowed | no — additive only. |
| Compatibility commitments | Both components + args + outputs + type constants — stable from M4. The 6-key default mapping in RdsCredentialSecret is load-bearing for consumer apps that read those K8s data keys; renames are a breaking change. |
| Forbidden shortcuts | (a) NEVER child_process.exec in the dynamic provider. SDK only. (b) NEVER include any value-bytes from the SM secret in error messages, log lines, or pulumi diagnostic output. The dynamic provider redacts on the way out — even API errors get sanitized. (c) NEVER use JSON.parse on the SM secret value with no bound; cap the parse at 64 nesting levels (the same V4 deserialization-bomb defense the GitHub webhook adapter uses). (d) NEVER silently mask a missing-key in the SM JSON; emit pulumi.log.warn and write the K8s Secret with the keys that DID extract (consumer apps fail loud at startup when the env var is absent — preferable to silent placeholder values). (e) NEVER support keyMapping: {} (empty) — refuse construction. Silent zero-key extraction is the failure mode #40 specifically calls out. (f) NEVER add the SDK dep without the cooling-off CI check. |
- No SM CSI driver install. Consumer-side decision (Rule 0).
- No alternative extraction mechanisms (in-cluster Job, Helm post-renderer). Design record committed
pulumi.dynamic.Resourcefor v1; revisit when CSI driver attestations land for regulated consumers. - No automatic rotation triggers. The component re-extracts on
pulumi upwhen the SM ARN's version stamp changes; consumers wanting tighter rotation use AWS-side rotation + a scheduledpulumi up(out of scope here). - No Aurora-Serverless-V2 / DSQL specialized wrappers. The 6-key shape covers them; if AWS adds a new SM-managed credential family with a different JSON shape, that's a new convenience wrapper, not a foundation change.
- No multi-secret extraction (one component reading N SM ARNs into N K8s Secrets). One component = one ARN = one K8s Secret.
- No env-var injection helpers. The component creates a K8s Secret; consumers wire it into Deployment env-vars or volume mounts via standard K8s patterns.
- Complete the Global Entry Rules.
- Read M3 lessons.
- Read the design record's
KubernetesSecretFromAwsSecretsManagersection in full — every alternative-mechanism trade-off is documented. - Copy the Evidence Log.
- Re-state the load-bearing constraints: (i) two layers, foundation + wrapper; (ii)
pulumi.dynamic.Resourcefor v1, trust boundary documented; (iii) value-redaction in error paths; (iv)keyMapping: {}refused; (v)RdsCredentialSecret's 6-key default mapping is load-bearing for consumer apps. - Verify
@aws-sdk/client-secrets-manageris on the npm registry at the intended version and the integrity hash captures cleanly viapnpm view.
| File | Planned Change |
|---|---|
packages/k8s-baseline/src/kubernetes-secret-from-asm.ts |
NEW: ComponentResource wrapping the dynamic provider |
packages/k8s-baseline/src/kubernetes-secret-from-asm.args.ts |
NEW |
packages/k8s-baseline/src/kubernetes-secret-from-asm.outputs.ts |
NEW |
packages/k8s-baseline/src/kubernetes-secret-from-asm.provider.ts |
NEW: pulumi.dynamic.ResourceProvider with create, read, update, delete hooks calling @aws-sdk/client-secrets-manager |
packages/k8s-baseline/src/rds-credential-secret.ts |
NEW: thin convenience wrapper |
packages/k8s-baseline/src/rds-credential-secret.args.ts |
NEW |
packages/k8s-baseline/src/rds-credential-secret.outputs.ts |
NEW |
packages/k8s-baseline/tests/kubernetes-secret-from-asm.test.ts |
NEW: BDD covering happy path, missing-key warns, abuse rows; uses a mocked SM client |
packages/k8s-baseline/tests/rds-credential-secret.test.ts |
NEW: BDD covering default mapping, key-rename refused |
packages/k8s-baseline/tests/integration/kind/secret-extraction.kind.test.ts |
NEW: kind test asserting the K8s Secret is created with correct keys (SM client stubbed) |
packages/k8s-baseline/src/index.ts |
MODIFY: re-export |
packages/k8s-baseline/package.json |
MODIFY: add @aws-sdk/client-secrets-manager runtime dep, exact-pinned |
scripts/exact-pin-guard.mjs |
MODIFY: extend to include @aws-sdk/client-secrets-manager |
scripts/cooling-off-diff.mjs |
MODIFY: extend |
docs/components/kubernetes-secret-from-asm.md |
NEW (one-line stub) |
docs/components/rds-credential-secret.md |
NEW (one-line stub) |
docs/slo/completed/RUNBOOK-hulumi-k8s.md Milestone Tracker |
MODIFY |
docs/slo/runbook-milestones/hulumi-k8s-m4.md |
MODIFY (Evidence Log only) |
docs/slo/lessons/hulumi-k8s-m4.md |
NEW |
docs/slo/completion/hulumi-k8s-m4.md |
NEW |
- Add
@aws-sdk/client-secrets-managertopackages/k8s-baseline/package.jsonas exact-pinned runtime dep. Capture integrity hash. Runpnpm installand verify resolution. Extend pin-guard + cooling-off-diff scripts. - Write
packages/k8s-baseline/tests/kubernetes-secret-from-asm.test.tsBDD: happy path with mocked SM client, empty mapping refused, traversal-y secretName refused, missing-source-key warns, error-redaction asserted. Run — fail for module-not-found. - Implement the dynamic provider (
kubernetes-secret-from-asm.provider.ts):createcallsGetSecretValueCommand, parses JSON with the bounded depth limit, applies thekeyMapping, returns the K8s Secret data;updatere-extracts;deletedeletes the K8s Secret; all error paths sanitize the SM value out. - Implement the foundation
KubernetesSecretFromAwsSecretsManagerComponentResource wrapping the provider + akubernetes.core.v1.Secretreferencing the provider's outputs. - Implement
RdsCredentialSecretas a thin wrapper: constructs the foundation with the documented 6-key default mapping (username→username,password→password,host→host,port→port,engine→engine,dbClusterIdentifier→dbClusterIdentifier), allows opt-inkeyMappingoverride (e.g., the consumer wantspassword→DB_PASSWORD). - Wire re-exports. Run
pnpm --filter @hulumi/k8s-baseline build && test && typecheck && lint— green. - Write the kind integration test with a mocked SM client (env-injected fake) asserting the K8s Secret has the expected keys.
- Run full repo regression sweep. Update Tracker, lessons, completion.
Feature: KubernetesSecretFromAwsSecretsManager extracts JSON SM values into K8s Secrets with redaction discipline; RdsCredentialSecret ships the standard 6-key shape
| Scenario | Category | Given | When | Then | Threat-model row | Control |
|---|---|---|---|---|---|---|
| Happy path — foundation | happy path | mock-runtime; SM mock returns {"username":"u","password":"p","extra":"e"}; keyMapping: { username: "user", password: "pass" } |
construction | child kubernetes.core.v1.Secret is registered with data: { user: base64("u"), pass: base64("p") }; extra is NOT in the K8s Secret; dataKeysWritten output = ["user", "pass"] |
n/a | n/a |
Happy path — RdsCredentialSecret default mapping |
happy path | mock-runtime; SM mock returns RDS-shaped JSON | new RdsCredentialSecret(...) constructed |
K8s Secret has the 6 documented keys: username, password, host, port, engine, dbClusterIdentifier; dataKeysWritten lists all 6 |
n/a | n/a |
Happy path — RdsCredentialSecret opt-in key rename |
happy path | mock-runtime; consumer passes keyMapping: { password: "DB_PASSWORD" } |
construction | K8s Secret data key is DB_PASSWORD (not password); other 5 default keys still present |
n/a | n/a |
Invalid input — empty keyMapping |
invalid input | mock-runtime; keyMapping: {} |
construction | constructor throws Error('KubernetesSecretFromAwsSecretsManager: keyMapping must be non-empty') |
tm-hulumi-k8s-abuse-key-mapping-empty |
C5 + Forbidden shortcut (e) |
Invalid input — secretName traversal |
invalid input | mock-runtime; secretName: "../etc/secret" |
construction | constructor throws Error mentioning K8s name validity |
tm-hulumi-k8s-abuse-secret-name-traversal |
C5 |
Invalid input — secretName empty |
invalid input | mock-runtime; secretName: "" |
construction | constructor throws | n/a | C5 |
| Empty state — SM JSON missing requested key | empty state | mock-runtime; SM mock returns {"username":"u"}; keyMapping: { username: "user", password: "pass" } |
construction | pulumi.log.warn called mentioning password; K8s Secret has only user (not pass); dataKeysWritten = ["user"] |
n/a | C9 + Forbidden shortcut (d) |
| Dependency failure — SM API error | partial failure | mock-runtime; SM mock throws AccessDeniedException |
construction | error surfaces with the AWS error code + the requested ARN; the error message does NOT contain any value-bytes from any prior successful extraction | n/a | C10 + Forbidden shortcut (b) |
| Abuse case — JSON-bomb cap honored | abuse case | mock-runtime; SM mock returns a JSON value with 1000 nested objects | construction | dynamic provider rejects with Error('KubernetesSecretFromAwsSecretsManager: SM secret JSON exceeds max nesting depth (64)') |
n/a | Forbidden shortcut (c) |
| Abuse case — error path does not leak prior secret bytes | abuse case | mock-runtime; first create succeeds with password: "supersecret123"; second update throws ThrottlingException |
sequence runs | the throttling error message does NOT contain "supersecret123" (or any byte sequence from the prior successful extraction); the error mentions ThrottlingException + ARN + a generic redacted-value placeholder |
tm-hulumi-k8s-abuse-secret-value-not-logged |
Forbidden shortcut (b) |
Abuse case — no child_process.exec in dynamic provider |
abuse case | source file kubernetes-secret-from-asm.provider.ts |
static-grep test | tests/no-shell-exec.test.ts (existing) covers this file; no child_process import; no exec / execSync / spawn calls |
tm-hulumi-k8s-abuse-shell-out-refused |
Forbidden shortcut (a) |
Abuse case — RdsCredentialSecret default mapping is load-bearing |
abuse case | mock-runtime | inspect default mapping | the 6 default keys are exactly ["username","password","host","port","engine","dbClusterIdentifier"] (regression-locked; rename = breaking change) |
tm-hulumi-k8s-abuse-rds-default-keys-load-bearing |
type-layer schema lock |
| Compatibility — outputs lock | schema / compatibility | construction succeeds | inspect outputs | secretName, namespace, dataKeysWritten outputs are present and typed as documented |
n/a | type-layer schema lock |
- All M1 + M2 + M3 BDD scenarios continue to pass.
- AWS + GitHub regression suites continue to pass.
tests/no-shell-exec.test.tscontinues to pass withpackages/k8s-baseline/src/glob coverage.
- Components + args + outputs + type constants exported.
-
pnpm install --frozen-lockfile && pnpm -r build && pnpm -r test && pnpm -r typecheck && pnpm -r lint && pnpm run lint:license-boundary && pnpm run lint:exact-pin-guardgreen. - License header on every new file.
-
@aws-sdk/client-secrets-managerexact-pinned with integrity hash. -
RdsCredentialSecretdefault key list is unchanged (regression-locked).
| E2E Test | What It Proves | Pass Criteria |
|---|---|---|
extracts_secret_against_kind_with_mocked_sm |
The full extraction flow lands a K8s Secret | Kind test: mock SM, install component; kubectl get secret <name> -n <ns> -o jsonpath returns base64 of expected values |
pin_guard_catches_aws_sdk_drift |
The new dep is covered by exact-pin guard | Seeded fixture mutating @aws-sdk/client-secrets-manager integrity hash → pin-guard exits non-zero |
error_path_redaction_against_real_dynamic_provider |
The dynamic provider's create error path doesn't leak |
Test injects an SM mock that fails on second call; assert error message does not contain any value bytes |
- Full sweep green.
- Kind test green or skipped cleanly.
- In a Pulumi program:
new KubernetesSecretFromAwsSecretsManager("foo", { ... keyMapping: {} ... } as any)causes preview to fail with the empty-mapping error. -
git statusclean.
| Step | Command / Check | Expected Result | Actual Result | Pass/Fail | Notes |
|---|---|---|---|---|---|
| Baseline | pnpm -r build && pnpm -r test |
green pre-M4 | filled during execution | pending | |
| SDK dep added | pnpm view @aws-sdk/client-secrets-manager version |
resolves cleanly | filled during execution | pending | |
| Pin-guard extension | pnpm run lint:exact-pin-guard |
OK with new dep | filled during execution | pending | |
| BDD tests created | pnpm --filter @hulumi/k8s-baseline test -- kubernetes-secret-from-asm |
fail for module-not-found | filled during execution | pending | |
| Provider impl | filesystem | source files present | filled during execution | pending | |
| Foundation impl | filesystem | source files present + re-exported | filled during execution | pending | |
| Wrapper impl | filesystem | source files present + re-exported | filled during execution | pending | |
| Mock-runtime BDD | pnpm --filter @hulumi/k8s-baseline test |
all rows pass | filled during execution | pending | |
| Build / typecheck / lint | pnpm -r build && pnpm -r typecheck && pnpm -r lint |
green | filled during execution | pending | |
| Kind integration | KIND_E2E=1 pnpm --filter @hulumi/k8s-baseline test:integration:kind |
green or skipped | filled during execution | pending | |
| Smoke | full sweep | green | filled during execution | pending |
Same as M3. + docs/issue-candidates.md strikes #40.
- Tracker M4 →
done. - Stubs for both components.
docs/issue-candidates.md— strike #40.
- The two-layer split (foundation + wrapper) is the abstraction's load-bearing decision; record in lessons.
- The trust-boundary trade-off (Pulumi state encrypted vs. CSI-driver vs. in-cluster Job) is documented in the design record; do not relitigate during M4 execution.
- The error-path redaction is the controls' load-bearing security property; the abuse-case row asserting it is non-negotiable.