ops-mason/plans/keycape-secrets-engine-openbao-client-custody.md
tegwick 658cdbc206 MASON-WP-0009: plan OpenBao custody for the KeyCape secrets-engine-openbao client secret
Founder direction 2026-10-06: the secret lives in OpenBao. The plan confirms
platform/workloads/keycape/secrets-engine-openbao:CLIENT_SECRET (KV v2,
cas_required, max_versions=2) and keeps the sso-only ESO issuer reader. It
replaces the workstation consumer file with an attended operator OIDC reader
(5m, 4 uses, revoke-self, value in memory only) and records why the
dependency graph is acyclic. Status: reviewed, awaiting founder approvals A1-A3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 132360@bnt-lap001
Assistant-Session: 769b1afd-4330-4c86-96b2-e510e26db42b
2026-10-06 14:02:11 +02:00

16 KiB
Raw Blame History

id demand_source consumer_repo credential_type status approved_by approved_at created updated
keycape-secrets-engine-openbao-client-custody Founder direction 2026-10-06 (relayed by key-cape): the secrets-engine-openbao client secret lives in OpenBao; ops-mason decides where and how. KEY-WP-0037-T02, RPF-WP-0035-T02/T09, SECRETS-WP-0012-T02, CCR-2026-0033 secrets-engine openbao-kv-v2-two-reader (kubernetes ESO issuer reader + attended OIDC operator reader) reviewed null null 2026-10-06 2026-10-06

Construction plan: KeyCape secrets-engine-openbao client secret custody

1. Demand

KeyCape will register a confidential service client, secrets-engine-openbao (client_credentials, scope openbao:login, 15m JWTs). Two parties must know the one client secret: the KeyCape issuer, which checks it, and the secrets-engine CLI, which presents it to get the service JWT it then uses for its OpenBao JWT login. The founder directed on 2026-10-06 that the secret lives in OpenBao and that ops-mason decides where and how. This replaces the persistent workstation consumer file (secrets-engine decisions D1–D4 in SECRETS-WP-0012-T02 are superseded). ops-mason designs the structure only and never handles the value.

2. Existing-structure survey

Source survey only (2026-10-06). This task forbids bao commands, so no live bao secrets/auth/policy list output is attached. The live metadata-only survey (scripts/ survey generator, MASON-WP-0008) is a pre-build gate in MASON-WP-0009-T04, run by the building owner.

  • CCR-2026-0033 (railiance-platform, proposed) already defines the authoritative entry platform/workloads/keycape/secrets-engine-openbao, field CLIENT_SECRET, and the issuer reader: Kubernetes auth role external-secrets-keycape-secrets-engine-openbao, policy workload-kv-read-keycape-secrets-engine-openbao-eso, 15m, no default policy, metadata_read: false, sso-only ClusterSecretStore, target Secret sso/keycape-secrets-engine-openbao-client key client-secret. bound_claims_confirmed: false.
  • The same CCR's custody child (scripts/keycape_secrets_engine_openbao_custody.py, df24a77, never executed) also writes a persistent consumer file under /home/worsch/.secrets-engine/keycape/. That part is now out of direction.
  • The platform-mount applier only accepts platform/workloads/ (WARDEN-WP-0028). ops-mason's own lanes follow platform/workloads/<workload>/<entry> (e.g. platform/workloads/inbound-library/imap).
  • Attended operator reads already exist as OIDC roles on auth/netkingdom (railiance-backup-workload-kv-read, forgejo-admin-workload-kv-read, bound groups=net-kingdom-admins, confirmed via the live platform-admin role; callbacks UI + localhost:8250 + 127.0.0.1:8250). See railiance-platform/docs/workload-kv-access-lanes.md.
  • secrets-engine's provider (src/secrets_engine/service_auth.py) reads the secret only from SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE (read_client_secret_file); the approval profile shares that provider.

Existing-lane answer: the issuer reader in CCR-2026-0033 satisfies the issuer side and is reused unchanged. No lane lets the secrets-engine operator read this entry; one narrow attended reader is needed.

3. Proposed changes

3.1 Location (confirmed, with metadata added)

Item Decision
Mount / engine platform, KV version 2 (existing)
CLI path platform/workloads/keycape/secrets-engine-openbao (API: platform/data/..., platform/metadata/...)
Field CLIENT_SECRET (the only field; no other data at this path)
Metadata (kv metadata put, by the custody child) cas_required=true, max_versions=2, delete_version_after=0s
custom_metadata owner=railiance-platform, client_id=secrets-engine-openbao, issuer=keycape, readers=eso-sso-issuer,oidc-secrets-engine-operator, ccr=CCR-2026-0033, plan=ops-mason/keycape-secrets-engine-openbao-client-custody, classification=high, rotation=event-driven+90d

Platform's proposed path is confirmed. It is under the applier's platform/workloads/ scope and matches ops-mason's <workload>/<entry> shape (workload = the issuer that owns the registration). cas_required makes every write version-guarded server-side, not only by the child's discipline. max_versions=2 keeps exactly one predecessor for audit during a rotation; recovery is always roll-forward (see 3.4), never a read of an old version.

3.2 Issuer reader (confirmed as in CCR-2026-0033)

Kubernetes auth (not JWT/OIDC): ESO in external-secrets logs in with role external-secrets-keycape-secrets-engine-openbao, policy workload-kv-read-keycape-secrets-engine-openbao-eso = read on platform/data/workloads/keycape/secrets-engine-openbao only; no metadata, no list, token_no_default_policy=true, 15m/15m. ClusterSecretStore restricted to namespace sso; one ExternalSecret produces sso/keycape-secrets-engine-openbao-client key client-secret; KeyCape reads it as env KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET (NK-WP-0046). This reader must stay on Kubernetes auth: moving it to a KeyCape-issued identity would make KeyCape's own startup depend on KeyCape.

3.3 Consumer reader (new): attended operator OIDC, value in memory only

Object Shape
Policy workload-kv-read-keycape-secrets-engine-openbao-operator read on platform/data/workloads/keycape/secrets-engine-openbao; update on auth/token/revoke-self; nothing else (no metadata, list, parent or sibling)
OIDC role keycape-secrets-engine-openbao-operator-workload-kv-read on auth/netkingdom role_type=oidc, token_policies=[the policy above], token_no_default_policy=true, token_ttl=5m, token_max_ttl=5m, token_explicit_max_ttl=5m, token_num_uses=4, token_type=service, user_claim=sub, groups_claim=groups, oidc_scopes=openid,profile,email,groups, bound_claims={groups: [net-kingdom-admins]}, the three existing callbacks

Use-time flow, per secrets-engine command that selects --auth service-jwt for this profile:

  1. secrets-engine starts the OIDC login for exactly this role (browser + MFA through KeyCape/Authelia/privacyIDEA; an existing browser session is reused) without a token helper (-no-store semantics), capturing the resulting token through a pipe into process memory. Ambient tokens (BAO_TOKEN, ~/.vault-token, a platform-admin session) are never used for this read.
  2. One KV GET of CLIENT_SECRET, token in the request header, in process.
  3. auth/token/revoke-self on the operator token immediately.
  4. The value goes in memory to the KeyCape /token exchange (HTTP Basic), then the existing JWT login on role secrets-engine-openbao and the existing self-revoke at command end. The value is dropped when the command ends.

No workstation copy exists at any time: no file, tmpfs, env var, argv, keyring or cache. That is why no transient artefact is designed in: the read is cheap, and any cache would recreate the file this direction removes.

Alternatives rejected.

  • AppRole for secrets-engine: its secret-id becomes a persistent workstation credential with the same power as the old file — the same exposure under another name. It also falls inside MASON-IN-0004's open AppRole escalation finding.
  • Response-wrapped delivery per use: needs a second attended platform admin for every command. Kept only as a manual fallback, not a lane.
  • Using the operator's OIDC token for the engine's work directly: removes the separately reviewed secrets-engine-openbao identity and policy.

Why a human OIDC lane still adds something. The human token can only read this one field. The engine's OpenBao work runs under its own audited service identity and policy, not the operator's broader admin rights.

3.4 Circularity: resolved, acyclic

Read dependencies (arrow = "needs"):

  • secrets-engine OpenBao work → service JWT → CLIENT_SECRET → operator OIDC token → KeyCape human login (Authelia + privacyIDEA) → nothing in this chain.
  • KeyCape issuer → sso Secret → ESO → Kubernetes auth → nothing in this chain.
  • Writes/rotation → platform custody child under the attended platform-admin envelope → never the secrets-engine identity.

There is no cycle. The bootstrap credential for the consumer is a human's MFA-backed login, not a stored secret. The read lane never accepts the service JWT it serves, the issuer reader never uses a KeyCape token, and secrets-engine cannot write or rotate this entry. KeyCape is a shared dependency of the human login and the service exchange, not a loop: if KeyCape is down, service-jwt is unavailable wherever the secret lives. Break-glass stays the existing --auth auto bootstrap-file/BAO_TOKEN providers, and service-jwt keeps refusing to fall back. Three invariants keep it acyclic and become negative checks (section 4): (i) no auth/jwt role grants this path; (ii) the secrets-engine-openbao JWT policy has no capability on this path; (iii) the ESO reader is never moved off Kubernetes auth.

3.5 Generation, write, rotation, rollback

  • Generation/write (railiance-platform custody child, revised): attended, platform-admin envelope, no BAO_TOKEN/VAULT_TOKEN in env, umask 077, tracing off. One secrets.token_urlsafe(48) value in memory. Metadata-only preflight reads current_version. KV-v2 write with cas=0 (create) or cas=<observed version> (rotation); value on stdin/HTTP body only. Then kv metadata put with 3.1's settings (idempotent, compared before write). The consumer-file write, its parent/temp handling and the check-secret-file step are removed. Receipt: booleans, KV version, request id, CCR/decision ids, source digest. Never argv/stdout/logs/Git/hub/chat.
  • Issuer pickup: ESO refresh, then the KeyCape restart (secrets load at startup).
  • Consumer pickup: automatic — the next command reads the latest version. The cutover window runs from the KV write to the KeyCape restart. In that window exchanges fail closed (401). The child's runbook therefore puts the restart directly after the write.
  • Cadence: event-driven (compromise, operator change, KeyCape signing or registration incident) plus every 90 days, through the same attended child under a standing rotation approval for this exact CCR.
  • Revocation on compromise: KeyCape/NetKingdom disable the registration first. Then platform rotates (roll-forward) and detaches the operator OIDC role policy if the operator lane is suspected. Issued JWTs live ≤15m. OpenBao tokens from either lane self-revoke and are TTL-bound. No instant JWT recall is claimed.
  • Rollback: always roll-forward: a new version with cas=<current>, never a read or restore of an old value. Lane teardown order: (1) the operator OIDC role, then its policy; (2) ExternalSecret (uid precondition), then the ESO role/policy; (3) the KV entry is retained pending a separately approved destroy. A failed cleanup is recorded as open evidence.

3.6 Who builds what

ops-mason's executor has no OIDC role writer and its build grant has the open MASON-IN-0004 finding, so ops-mason builds nothing here. railiance-platform applies every object through its CCR flow. ops-mason verifies capabilities and proposes the pointer-only ops-warden route.

4. Review notes (phase 2)

  • Naming: follows platform's validator (workload-kv-read- policy prefix, -workload-kv-read OIDC role suffix) and the existing -eso sibling.
  • TTL 5m / 4 uses: one KV read plus revoke-self per command. 4 allows one retry. It is tighter than the 15m/30m human precedents because the token exists only inside one process. If the platform CCR schema cannot express token_num_uses for OIDC roles, platform extends it or the plan returns to review. TTL alone is not accepted as a substitute.
  • revoke-self explicit: token_no_default_policy=true removes the default policy's revoke-self, so the policy grants it explicitly.
  • Group binding: net-kingdom-admins is the confirmed claim of the live admin lanes. This role is strictly narrower than what members already hold, so it widens nothing. A dedicated group would be cleaner but is unconfirmed. KeyCape must confirm (a) the claim and (b) that the OpenBao OIDC client requires the MFA assurance. An unconfirmed binding blocks the build.
  • One value, two copies: the KV entry (authoritative) and the sso Secret (issuer delivery). The consumer holds none at rest. This narrows the founder's earlier two-copy boundary decision 632a8893….
  • Value handling: ops-mason never reads it. Proof is capabilities-only (sys/capabilities) plus the first live exchange (KEY-WP-0037-T04).
  • CCR target: CCR-2026-0033 records target.domain: financials. KeyCape and secrets-engine are infotech, so platform should correct it or explain it.

Verification required before routing activation

  1. Operator lane token: read on the data path; deny on the metadata path, the parent (list), the sibling platform/data/workloads/keycape/*, and any write.
  2. ESO lane token: read on the data path only; deny on metadata/parent/sibling.
  3. A non-member OIDC identity cannot obtain the operator role.
  4. Circularity invariants 3.4 (i)–(iii) report deny/absent.
  5. Metadata shows cas_required=true, max_versions=2, the custom_metadata above, and the expected version (value-free).
  6. A secrets-engine service-jwt run reads in memory and logs in. Evidence: provider name, accessor fingerprints, and both revoke-self results. A file source is refused for this profile.

5. Executive summary (phase 3)

  1. One-line ask. Keep the secrets-engine login secret only in OpenBao. KeyCape gets it through the cluster as today. Bernd's workstation reads it fresh, after a KeyCape login with MFA, for each secrets-engine command, and never stores it.
  2. Who/what gets access. Two readers. ESO in the cluster, for the sso namespace only (already proposed). A new 5-minute operator login role limited to members of net-kingdom-admins.
  3. To what. Read-only on exactly one OpenBao entry, platform/workloads/keycape/secrets-engine-openbao field CLIENT_SECRET.
  4. For how long. Operator tokens: 5 minutes, 4 uses, revoked after the read. ESO tokens: 15 minutes. The secret rotates every 90 days and on any incident.
  5. Blast radius if it leaks. The holder can obtain 15-minute secrets-engine service JWTs and, with them, whatever the separately reviewed secrets-engine-openbao OpenBao role allows. No disk copy exists to leak. Theft needs an admin's MFA-backed login or the sso namespace.
  6. Cost to reverse. Isolated. Remove one OIDC role, one policy, one ExternalSecret and one ESO role. The entry stays until a separate destroy approval. Rotation is a roll-forward.

Approvals requested (exact):

  • A1 — this plan's design as written in section 3 (location + metadata, the issuer reader unchanged, the new operator OIDC role and policy, no consumer copy at rest, roll-forward rotation with event-driven + 90-day cadence). Supersedes secrets-engine D1–D4.
  • A2 — railiance-platform's revised CCR-2026-0033 (consumer file removed, operator reader added or filed as a sibling CCR), approved with its three reviewers and bound_claims_confirmed: true for both readers.
  • A3 — the exact revised custody-child decision id, for the one attended generation/write run. It must differ from the boundary decision 632a8893….

Decision: approve / reject / send back with changes.