From 658cdbc20618a5017c70899aba72b91fdd9038ea Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 6 Oct 2026 14:02:11 +0200 Subject: [PATCH] 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 Assistant: claude-code Assistant-Model: opus Assistant-Process: 132360@bnt-lap001 Assistant-Session: 769b1afd-4330-4c86-96b2-e510e26db42b --- ...e-secrets-engine-openbao-client-custody.md | 268 ++++++++++++++++++ ...e-secrets-engine-openbao-client-custody.md | 79 ++++++ 2 files changed, 347 insertions(+) create mode 100644 plans/keycape-secrets-engine-openbao-client-custody.md create mode 100644 workplans/MASON-WP-0009-keycape-secrets-engine-openbao-client-custody.md diff --git a/plans/keycape-secrets-engine-openbao-client-custody.md b/plans/keycape-secrets-engine-openbao-client-custody.md new file mode 100644 index 0000000..eced16c --- /dev/null +++ b/plans/keycape-secrets-engine-openbao-client-custody.md @@ -0,0 +1,268 @@ +--- +id: keycape-secrets-engine-openbao-client-custody +demand_source: "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" +consumer_repo: secrets-engine +credential_type: openbao-kv-v2-two-reader (kubernetes ESO issuer reader + attended OIDC operator reader) +status: reviewed +approved_by: null +approved_at: null +created: "2026-10-06" +updated: "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//` + (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 `/` 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=` (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=`, 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. diff --git a/workplans/MASON-WP-0009-keycape-secrets-engine-openbao-client-custody.md b/workplans/MASON-WP-0009-keycape-secrets-engine-openbao-client-custody.md new file mode 100644 index 0000000..c6b2fc4 --- /dev/null +++ b/workplans/MASON-WP-0009-keycape-secrets-engine-openbao-client-custody.md @@ -0,0 +1,79 @@ +--- +id: MASON-WP-0009 +type: workplan +title: "Place the KeyCape secrets-engine-openbao client secret in OpenBao" +domain: infotech +repo: ops-mason +status: blocked +flavor: planning +owner: claude +topic_slug: netkingdom +created: "2026-10-06" +updated: "2026-10-06" +--- + +# MASON-WP-0009 — Place the KeyCape `secrets-engine-openbao` client secret in OpenBao + +## Goal + +Founder direction 2026-10-06: the client secret lives in OpenBao and ops-mason +decides where and how. Plan: `plans/keycape-secrets-engine-openbao-client-custody.md` +(`status: reviewed`). Location `platform/workloads/keycape/secrets-engine-openbao` +field `CLIENT_SECRET` (KV v2, `cas_required`, `max_versions=2`). Issuer reads it +through the existing sso-only ESO/Kubernetes reader. secrets-engine reads it in +memory per command through a new attended operator OIDC role (5m, 4 uses, +revoke-self). There is no consumer copy at rest. The design supersedes +secrets-engine D1–D4. + +## Boundaries + +- ops-mason designs, reviews and verifies (capabilities only) and proposes the + pointer-only route. It builds nothing here (no OIDC writer; MASON-IN-0004). +- railiance-platform applies all objects and owns generation/write via its CCR. +- secrets-engine owns the in-memory provider. KeyCape/NetKingdom own the + registration, the claim and the restart. +- No value enters Git, State Hub, logs or agent context. + +## Design and review the lane + +```task +id: MASON-WP-0009-T01 +status: done +priority: high +``` + +Plan written and reviewed from source state. The live `bao` survey is +deferred to T04 (no bao access in this task). + +## Founder approval + +```task +id: MASON-WP-0009-T02 +status: wait +blocked_on: founder-approval +blocking_reason: "Founder must give A1 (this plan), A2 (revised CCR-2026-0033 with the operator reader and confirmed bound claims) and A3 (custody-child decision id); see plan section 5." +priority: high +``` + +## Owner changes delivered + +```task +id: MASON-WP-0009-T03 +status: wait +blocked_on: message-from:railiance-platform +blocking_reason: "railiance-platform revises CCR-2026-0033 and its custody child; secrets-engine replaces the file provider for this profile; key-cape confirms the groups claim and MFA assurance for the OpenBao OIDC client." +priority: high +``` + +## Verify and propose the route + +```task +id: MASON-WP-0009-T04 +status: todo +priority: medium +``` + +After platform applies the objects: a live metadata-only survey and the +plan's six verification checks (capabilities and metadata only). Then a +pointer-only ops-warden entry for the operator lane (`resolvable: false` +until the checks pass).