Skip to content

Latest commit

 

History

History
164 lines (130 loc) · 42.3 KB

File metadata and controls

164 lines (130 loc) · 42.3 KB

Milestone 4 — KubernetesSecretFromAwsSecretsManager + RdsCredentialSecret

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.

Contract Block

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 InputskeyMapping 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.

Out of Scope / Must Not Do

  • 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.Resource for v1; revisit when CSI driver attestations land for regulated consumers.
  • No automatic rotation triggers. The component re-extracts on pulumi up when the SM ARN's version stamp changes; consumers wanting tighter rotation use AWS-side rotation + a scheduled pulumi 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.

Pre-Flight

  1. Complete the Global Entry Rules.
  2. Read M3 lessons.
  3. Read the design record's KubernetesSecretFromAwsSecretsManager section in full — every alternative-mechanism trade-off is documented.
  4. Copy the Evidence Log.
  5. Re-state the load-bearing constraints: (i) two layers, foundation + wrapper; (ii) pulumi.dynamic.Resource for 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.
  6. Verify @aws-sdk/client-secrets-manager is on the npm registry at the intended version and the integrity hash captures cleanly via pnpm view.

Files Allowed To Change

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

Step-by-Step

  1. Add @aws-sdk/client-secrets-manager to packages/k8s-baseline/package.json as exact-pinned runtime dep. Capture integrity hash. Run pnpm install and verify resolution. Extend pin-guard + cooling-off-diff scripts.
  2. Write packages/k8s-baseline/tests/kubernetes-secret-from-asm.test.ts BDD: 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.
  3. Implement the dynamic provider (kubernetes-secret-from-asm.provider.ts): create calls GetSecretValueCommand, parses JSON with the bounded depth limit, applies the keyMapping, returns the K8s Secret data; update re-extracts; delete deletes the K8s Secret; all error paths sanitize the SM value out.
  4. Implement the foundation KubernetesSecretFromAwsSecretsManager ComponentResource wrapping the provider + a kubernetes.core.v1.Secret referencing the provider's outputs.
  5. Implement RdsCredentialSecret as a thin wrapper: constructs the foundation with the documented 6-key default mapping (usernameusername, passwordpassword, hosthost, portport, engineengine, dbClusterIdentifierdbClusterIdentifier), allows opt-in keyMapping override (e.g., the consumer wants passwordDB_PASSWORD).
  6. Wire re-exports. Run pnpm --filter @hulumi/k8s-baseline build && test && typecheck && lint — green.
  7. Write the kind integration test with a mocked SM client (env-injected fake) asserting the K8s Secret has the expected keys.
  8. Run full repo regression sweep. Update Tracker, lessons, completion.

BDD Acceptance Scenarios

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

Regression Tests

  • All M1 + M2 + M3 BDD scenarios continue to pass.
  • AWS + GitHub regression suites continue to pass.
  • tests/no-shell-exec.test.ts continues to pass with packages/k8s-baseline/src/ glob coverage.

Compatibility Checklist

  • 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-guard green.
  • License header on every new file.
  • @aws-sdk/client-secrets-manager exact-pinned with integrity hash.
  • RdsCredentialSecret default key list is unchanged (regression-locked).

E2E Runtime Validation

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

Smoke Tests

  • 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 status clean.

Evidence Log

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

Definition of Done

Same as M3. + docs/issue-candidates.md strikes #40.

Post-Flight

  • Tracker M4 → done.
  • Stubs for both components.
  • docs/issue-candidates.md — strike #40.

Notes

  • 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.