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:
parent
7e86430913
commit
658cdbc206
2 changed files with 347 additions and 0 deletions
268
plans/keycape-secrets-engine-openbao-client-custody.md
Normal file
268
plans/keycape-secrets-engine-openbao-client-custody.md
Normal 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.
|
||||
|
|
@ -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).
|
||||
Loading…
Add table
Add a link
Reference in a new issue