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
This commit is contained in:
tegwick 2026-10-06 14:02:11 +02:00
parent 7e86430913
commit 658cdbc206
2 changed files with 347 additions and 0 deletions

View file

@ -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/<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.

View file

@ -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).