Revise KeyCape secrets-engine-openbao custody: OpenBao-only, operator reader CCR-2026-0034 (source only)
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s

Founder direction 2026-10-06 and ops-mason plan keycape-secrets-engine-openbao-client-custody
(658cdbc, MASON-WP-0009): the client secret lives only in OpenBao.

- CCR-2026-0033: consumer file and secrets-engine D1-D4 removed; KV metadata declared
  (cas_required, max_versions=2, delete_version_after=0s, custom_metadata); target.domain
  infotech; live order and roll-forward rollback revised; status stays proposed.
- New sibling CCR-2026-0034 + policy: attended OIDC operator reader on auth/netkingdom,
  read data path + revoke-self only, 5m/4 uses, no default policy, bound
  groups=net-kingdom-admins and /assurance/level=aal2 (KeyCape 7d96ee4). Proposed,
  bound claims unconfirmed (founder item for both readers).
- credential-change.py: OIDC roles carry token_max_ttl/explicit_max_ttl/no_default_policy/
  num_uses/type and bound_claims_type (string only); token_revoke_self policy option;
  validated openbao.kv_metadata. Tests added.
- Custody child: consumer write and check-secret-file removed; cas=0 create / --rotate
  cas=<current>; compare-before-write metadata put; refusals unchanged.
- Founder runbook (keycape/founder-runbook.md), window docs, RPF-WP-0035-T09 (waits on A1/A2/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:
codex 2026-10-06 14:26:42 +02:00
parent 9e23eaa067
commit 4a284f0a3b
11 changed files with 734 additions and 307 deletions

View file

@ -52,6 +52,7 @@
| task | RPF-WP-0035-T06 | wait | — | workplans/RPF-WP-0035-credential-lane-implementation.md |
| task | RPF-WP-0035-T07 | wait | — | workplans/RPF-WP-0035-credential-lane-implementation.md |
| task | RPF-WP-0035-T08 | wait | — | workplans/RPF-WP-0035-credential-lane-implementation.md |
| task | RPF-WP-0035-T09 | wait | — | workplans/RPF-WP-0035-credential-lane-implementation.md |
| task | RPF-WP-0036-T01 | done | — | workplans/RPF-WP-0036-platform-service-assurance.md |
| task | RPF-WP-0036-T02 | done | — | workplans/RPF-WP-0036-platform-service-assurance.md |
| task | RPF-WP-0036-T03 | wait | — | workplans/RPF-WP-0036-platform-service-assurance.md |

View file

@ -2,14 +2,16 @@ id: CCR-2026-0033
kind: credential-change-request
schema_version: 1
request_type: workload-kv-read
title: KeyCape secrets-engine-openbao issuer workload reader
title: KeyCape secrets-engine-openbao issuer workload reader and authoritative custody
status: proposed
created: '2026-10-04'
updated: '2026-10-06'
requester:
agent: key-cape
reason: KEY-WP-0037-T02 owner packet; proposed issuer-side read of separately approved
service client custody
reason: 'KEY-WP-0037-T02 owner packet; issuer-side read of the authoritative secrets-engine-openbao
client secret. Revised 2026-10-06 per founder direction (the secret lives only in OpenBao)
and ops-mason plan keycape-secrets-engine-openbao-client-custody (658cdbc, MASON-WP-0009):
consumer file and secrets-engine D1-D4 removed.'
review:
required: true
required_approvers:
@ -18,12 +20,12 @@ review:
- net-kingdom-owner
comments: []
target:
domain: financials
domain: infotech
tenant: platform
workload: keycape
environment: production
purpose: Deliver separately approved secrets-engine-openbao client secret to issuer
through sso-only ESO
purpose: Hold the secrets-engine-openbao client secret authoritatively in platform KV and
deliver it to the KeyCape issuer through sso-only ESO
openbao:
mount: platform
kv_path: platform/workloads/keycape/secrets-engine-openbao
@ -49,6 +51,19 @@ openbao:
service_account_namespaces:
- external-secrets
bound_claims_confirmed: false
kv_metadata:
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
access_frontdoor:
type: external-secrets
catalog_id: keycape-secrets-engine-openbao-eso
@ -57,24 +72,28 @@ access_frontdoor:
risk:
classification: high
notes:
- Reads whole proposed path holding the secrets-engine-openbao confidential value;
issuer and consumer copies need separate acceptance
- Only ExternalSecrets in namespace sso may use the store; sso namespace writers
are within this boundary
- Issuer ESO does not deliver the separate secrets-engine consumer file or authorize
client registration
- Reads the whole path holding the secrets-engine-openbao confidential value
- Only ExternalSecrets in namespace sso may use the store; sso namespace writers are within
this boundary
- 'One value, two copies: KV (authoritative) and the sso Secret (issuer delivery). The consumer
holds no copy at rest; it reads in memory through CCR-2026-0034.'
- 'Must stay on kubernetes auth: a KeyCape-issued identity would make KeyCape startup depend
on KeyCape.'
verification:
positive:
- Owner-reviewed native exact identity and exact path proof required
negative:
- Owner-reviewed wrong identity, sibling GET and parent LIST denials required
- Metadata shows cas_required=true, max_versions=2 and the declared custom_metadata (value-free).
activation_conditions:
- Exact CCR approval, confirmed identity, attended custody and native receipt acceptance
required
lifecycle:
deactivate: Owner-reviewed delivery withdrawal and token revocation; exact rollback
receipt required
rotate: Separate version-guarded owner approval and contained value delivery required
deactivate: Owner-reviewed delivery withdrawal and token revocation; exact rollback receipt
required
rotate: 'Roll-forward only: custody child writes a new version with cas=<current> under
a standing rotation approval for this CCR (event-driven + 90d); never a read or restore
of an old version'
compromised: Owner incident process; no automatic read, reseed or cleanup
scaffold:
draft_only: true
@ -88,83 +107,85 @@ state_hub:
related_workplan: KEY-WP-0037
delivery:
surface: external-secrets
target: sso/keycape-secrets-engine-openbao-client key client-secret; issuer-side
delivery only
target: sso/keycape-secrets-engine-openbao-client key client-secret; issuer-side delivery
only
review_bundle: docs/provisioning-windows/2026-10-04-1000/keycape
consumer_file_delivery: same-process attended custody child; no client-side OpenBao
read
consumer_delivery: none at rest; attended operator OIDC reader CCR-2026-0034 reads into
secrets-engine process memory per command
boundary_decision:
id: 632a8893-0d6a-4591-a50b-40bbdb798f32
approves: custody boundary only (platform authoritative KV and least-privilege issuer
delivery; secrets-engine-owned mode-0600 consumer file outside every worktree)
approves: custody boundary only (platform authoritative KV and least-privilege issuer delivery;
secrets-engine-owned mode-0600 consumer file outside every worktree)
does_not_approve: generation, KV write, fetch, delivery, registration or deployment
path_correction: 'Candidate path platform/services/keycape/secrets-engine-openbao
from the KeyCape packet is outside the platform-mount applier scope (WARDEN-WP-0028:
platform/workloads/ only). Corrected to platform/workloads/keycape/secrets-engine-openbao,
same field CLIENT_SECRET. Consumers bind to the sso Secret and the consumer file,
not the KV path.'
note: 'The design narrows this boundary: the secrets-engine consumer file it approved is
withdrawn (founder direction 2026-10-06).'
path_correction: 'Candidate path platform/services/keycape/secrets-engine-openbao from the
KeyCape packet is outside the platform-mount applier scope (WARDEN-WP-0028: platform/workloads/
only). Corrected to platform/workloads/keycape/secrets-engine-openbao, same field CLIENT_SECRET.
The issuer binds to the sso Secret; the operator reader (CCR-2026-0034) reads the exact
data path.'
custody_child:
script: scripts/keycape_secrets_engine_openbao_custody.py
tests: tests/test_keycape_secrets_engine_openbao_custody.py
status: source-only; never executed
requires:
- --approval-decision <founder decision id for this exact custody child>; refused
when absent, when equal to the boundary decision, or not in the exact CCR review
- CCR status approved with platform-owner, key-cape-owner and net-kingdom-owner
- --approval-decision <A3-decision-id>; refused when absent, malformed, equal to the boundary
decision, or not recorded in an approved review comment of this CCR
- this CCR and sibling CCR-2026-0034 approved with platform-owner, key-cape-owner and net-kingdom-owner
approvals and bound_claims_confirmed true
- attended warden platform-admin envelope; no BAO_TOKEN/VAULT_TOKEN in environment
- umask 077, shell tracing off
generation: one secrets.token_urlsafe(48) value in process memory (64 chars); never
in argv, environment, stdout, stderr, logs, Git or the hub
authoritative_write: KV-v2 write with options.cas=0 (absent) or cas=<observed current
version> (rotation); value passed on bao stdin only; metadata-only preflight
consumer_write: parent 0700 created or verified (owner = effective user); O_CREAT|O_EXCL|O_WRONLY
temp file 0600 in the parent; write, fsync, rename, directory fsync; refused if
the KV write did not succeed
consumer_validation: secrets-engine service-auth check-secret-file --path <file>
--json; only ok/check names recorded
receipt: O_EXCL 0600 JSON with booleans, KV version, request id, CCR/decision ids,
source digest and check-secret-file pass/fail only
failure_cleanup: removes only its own exact temp file; a written KV version is retained
and recorded (recovery is a fresh version-guarded run, never a KV read)
retry: a prior failed receipt with kv_written and no consumer file permits one new
version with cas=<that version>; no import or reuse of the earlier value
consumer_copy:
owner: secrets-engine (SECRETS-WP-0012, commit a3897c2)
host: operator workstation (WSL2) - founder decision D1, recommended workstation
only
os_user: worsch - founder decision D2, recommended worsch
file: /home/worsch/.secrets-engine/keycape/secrets-engine-openbao.client-secret
mode: '0600 regular file, one token; parent /home/worsch/.secrets-engine/keycape
0700 worsch'
storage: persistent ext4 home - founder decision D3, recommended persistent and
excluded from backup tools
rotation_cadence: founder decision D4, recommended event-driven plus 90 days
decisions_pending: SECRETS-WP-0012-T02 (needs_human)
- --rotate for any run after the first (cas=<observed current version>); without it an existing
entry is refused
generation: one secrets.token_urlsafe(48) value in process memory (64 chars); never in argv,
environment, stdout, stderr, logs, Git or the hub
authoritative_write: KV-v2 write with options.cas=0 (absent) or cas=<observed current version>
(rotation, --rotate); value only in process memory and the bao stdin/HTTP body; metadata-only
preflight
receipt: O_EXCL 0600 JSON with booleans, KV version, request id, CAS, metadata applied/unchanged,
CCR/decision ids and CCR digest only
failure_cleanup: nothing to clean locally; a written KV version is retained and recorded;
recovery is a fresh roll-forward run with --rotate (cas=<current>), never a KV read
metadata_write: kv metadata put of openbao.kv_metadata, compared before write against a
metadata-only read; skipped when equal
rotation_revocation:
generation_and_authoritative_version: railiance-platform (custody child, version-guarded)
issuer_pickup: key-cape / net-kingdom (ESO refresh then KeyCape restart)
consumer_copy_replacement: secrets-engine file, executed inside platform child after
issuer reload confirmed
disable_on_compromise: key-cape / net-kingdom remove registration first
retire_versions: railiance-platform per separate approval
remove_consumer_copy: secrets-engine operator
generation_and_authoritative_version: railiance-platform custody child, version-guarded,
roll-forward
issuer_pickup: 'key-cape / net-kingdom: ESO refresh then KeyCape restart immediately (exchanges
fail closed 401 between KV write and restart)'
consumer_pickup: 'automatic: the next secrets-engine command reads the latest version in
memory (CCR-2026-0034)'
disable_on_compromise: key-cape / net-kingdom remove registration first; platform rotates
and detaches the CCR-2026-0034 role policy
retire_versions: railiance-platform per separate destroy approval
live_order:
- 1 founder approves this CCR (status approved, three reviews, bound claims confirmed)
and the exact custody-child decision; secrets-engine D1-D4 resolved
- 2 attended custody child (this repo) writes KV v1 and consumer file; receipt
- 3 platform applies issuer reader policy/role and sso-only ClusterSecretStore + ExternalSecret;
sso Secret keycape-secrets-engine-openbao-client/client-secret exists (metadata
receipt only)
- 4 net-kingdom env patch secrets-engine-openbao-client.proposed.patch.yaml (NK-WP-0046)
- 5 KeyCape registration merge (KEY-WP-0037-T03)
- 6 KeyCape restart (rollout helper)
- 7 KeyCape live issuance check (KEY-WP-0037-T04)
- 8 platform OpenBao JWT role/policy secrets-engine-openbao and login proof (RPF-WP-0035-T02)
- 0 founder approvals A1 design, A2 this CCR + CCR-2026-0034 (three reviews, bound claims
confirmed for both readers), A3 custody-child decision id
- 1 attended custody child (this repo) writes KV v1 and metadata; non-secret receipt
- 2 platform applies policies/roles (this CCR and CCR-2026-0034) and the sso-only ClusterSecretStore
+ ExternalSecret; Secret sso/keycape-secrets-engine-openbao-client exists (metadata receipt
only)
- 3 net-kingdom env patch secrets-engine-openbao-client.proposed.patch.yaml (NK-WP-0046)
- 4 KeyCape registration merge (KEY-WP-0037-T03)
- 5 KeyCape restart immediately after step 4
- 6 KeyCape live issuance check (KEY-WP-0037-T04)
- 7 platform OpenBao JWT role/policy secrets-engine-openbao and login proof (RPF-WP-0035-T02)
rollback:
before_step_4: delete ExternalSecret (uid precondition), detach role policies; consumer
file removed by secrets-engine operator (exact file, then rmdir parent only if
empty); KV version retained pending separate destroy approval
after_step_4: net-kingdom rollout helper restores prior config/Deployment first,
then the before_step_4 sequence
cleanup_receipts: native, value-free, per step owner
before_step_3: delete the CCR-2026-0034 OIDC role, then its policy; delete ExternalSecret
(uid precondition), then the ESO role/policy; KV entry retained pending separate destroy
approval
after_step_3: net-kingdom rollout helper restores prior config/Deployment first, then the
before_step_3 sequence
cleanup_receipts: native, value-free, per step owner; a failed cleanup is recorded as open
evidence
founder_confirmation_items:
- 'bound_claims_confirmed stays false until the founder confirms BOTH readers: this CCR (kubernetes,
external-secrets/external-secrets, audience openbao) and sibling CCR-2026-0034 (OIDC, groups=net-kingdom-admins
and /assurance/level=aal2).'
- 'Approvals: A1 ops-mason design; A2 this CCR plus CCR-2026-0034 with platform-owner, key-cape-owner
and net-kingdom-owner reviews; A3 the exact custody-child decision id (must differ from
boundary decision 632a8893-0d6a-4591-a50b-40bbdb798f32).'
runbook: docs/provisioning-windows/2026-10-04-1000/keycape/founder-runbook.md
related:
sibling_ccr: CCR-2026-0034
design: ops-mason plans/keycape-secrets-engine-openbao-client-custody.md (658cdbc)

View file

@ -0,0 +1,135 @@
id: CCR-2026-0034
kind: credential-change-request
schema_version: 1
request_type: workload-kv-read
title: KeyCape secrets-engine-openbao attended operator reader (in-memory, per command)
status: proposed
created: '2026-10-06'
updated: '2026-10-06'
requester:
agent: railiance-platform
reason: Founder direction 2026-10-06 - the secrets-engine-openbao client secret lives
only in OpenBao; ops-mason plan keycape-secrets-engine-openbao-client-custody
(658cdbc, MASON-WP-0009) section 3.3. Sibling of CCR-2026-0033 (issuer ESO reader);
one auth role per CCR is this repo's convention (cf. CCR-2026-0019). Replaces the
superseded secrets-engine consumer file (SECRETS-WP-0012-T02 D1-D4).
review:
required: true
required_approvers:
- platform-owner
- key-cape-owner
- net-kingdom-owner
comments:
- at: '2026-10-06'
reviewer: key-cape (owner packet, key-cape 7d96ee4)
decision: claim_shape_reported
comment: 'KeyCape reports the groups claim is always emitted, so groups_claim groups
bound to net-kingdom-admins works as in CCR-2026-0019. MFA is not pinned on the
openbao-admin client; it holds only through the provider default requireForAll.
KeyCape asks that the role also bind the assurance claim (/assurance/level = aal2,
issued only after a verified factor). This is an owner report, not an approval
and not the founder confirmation of bound claims.'
target:
domain: infotech
tenant: platform
workload: keycape
environment: production
purpose: Let a net-kingdom-admins operator running secrets-engine read CLIENT_SECRET
into process memory once per command, revoke the token, and present it to KeyCape;
no copy at rest anywhere.
openbao:
mount: platform
kv_path: platform/workloads/keycape/secrets-engine-openbao
fields:
- CLIENT_SECRET
policy_name: workload-kv-read-keycape-secrets-engine-openbao-operator
policy_file: openbao/policies/workload-kv-read-keycape-secrets-engine-openbao-operator.hcl
metadata_read: false
token_revoke_self: true
auth:
method: oidc
mount: netkingdom
role: keycape-secrets-engine-openbao-operator-workload-kv-read
allowed_redirect_uris:
- https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback
- http://localhost:8250/oidc/callback
- http://127.0.0.1:8250/oidc/callback
oidc_scopes:
- openid
- profile
- email
- groups
user_claim: sub
groups_claim: groups
bound_claims_type: string
bound_claims:
groups:
- net-kingdom-admins
/assurance/level: aal2
bound_claims_confirmed: false
policies:
- workload-kv-read-keycape-secrets-engine-openbao-operator
ttl: 5m
token_max_ttl: 5m
token_explicit_max_ttl: 5m
token_no_default_policy: true
token_num_uses: 4
token_type: service
founder_confirmation_items:
- bound_claims_confirmed stays false until the founder confirms BOTH readers' bindings
(this role's groups=net-kingdom-admins plus /assurance/level=aal2, and CCR-2026-0033's
external-secrets/external-secrets ServiceAccount). An unconfirmed binding blocks the
applier (applier-dry-run refuses).
- The aal2 binding is the only MFA guarantee on this lane; the openbao-admin client
itself does not pin MFA (KeyCape 7d96ee4).
access_frontdoor:
type: ops-warden
catalog_id: keycape-secrets-engine-openbao-operator
readiness: pending-review
resolvable: false
delivery:
surface: operator-process-memory
target: secrets-engine --auth service-jwt, profile using the KeyCape secrets-engine-openbao
client; OIDC login without a token helper, one KV GET, revoke-self, value in memory
only. No file, tmpfs, env var, argv, keyring or cache.
risk:
classification: high
notes:
- Presenting copy of the secrets-engine-openbao client; the holder can mint 15-minute
service JWTs and use the separately reviewed secrets-engine-openbao JWT role.
- Strictly narrower than what net-kingdom-admins members already hold; widens nothing.
- 'Acyclic: the bootstrap is a human MFA login (aal2), never the service JWT this
lane serves. Invariants: no auth/jwt role grants this path; the secrets-engine-openbao
JWT policy has no capability on it; the ESO reader (CCR-2026-0033) stays on kubernetes
auth.'
verification:
positive:
- Operator token from this role has read on platform/data/workloads/keycape/secrets-engine-openbao
and update on auth/token/revoke-self (sys/capabilities-self, value-free).
- A secrets-engine service-jwt run reads in memory, logs in, and both revoke-self
calls succeed; evidence is provider name, accessor fingerprints and revoke results.
negative:
- deny on the metadata path, the parent LIST, sibling platform/data/workloads/keycape/*
and any write.
- A non-member identity, or a member without aal2, cannot obtain the role.
- No auth/jwt role and not policy secrets-engine-login-self has any capability on
this path.
- A file source is refused for this secrets-engine profile.
activation_conditions:
- Founder approvals A1 (ops-mason design), A2 (this CCR and CCR-2026-0033 with three
reviews and bound_claims_confirmed true) and a completed CCR-2026-0033 custody
run (A3).
lifecycle:
deactivate: Delete the OIDC role first, then the policy; value-free receipt.
rotate: Not applicable to the reader; the value rotates through the CCR-2026-0033
custody child (roll-forward, cas=<current>).
compromised: KeyCape/NetKingdom disable the registration first; platform detaches
this role's policy and rotates via the custody child; tokens are 5m/4-use and
self-revoked.
state_hub:
workplan_id: RPF-WP-0035
task_id: RPF-WP-0035-T09
related_workplan: KEY-WP-0037
related:
sibling_ccr: CCR-2026-0033
design: ops-mason plans/keycape-secrets-engine-openbao-client-custody.md (658cdbc)

View file

@ -47,27 +47,43 @@ the refusal. This is separate from actual provider registration/custody.
The KeyCape packet's candidate path `platform/services/keycape/secrets-engine-openbao`
is outside the platform-mount applier scope (`platform/workloads/` only) and was
corrected on 2026-10-06 to `platform/workloads/keycape/secrets-engine-openbao`,
field CLIENT_SECRET. Consumers bind to the sso Secret and the consumer file, not
the KV path. The attended custody child
`scripts/keycape_secrets_engine_openbao_custody.py` (source only, never run) and
the secrets-engine consumer copy (SECRETS-WP-0012, a3897c2) are recorded in
CCR-2026-0033 with the explicit live order.
Proposed CCR-2026-0033 and input
`docs/credential-lane-designs/keycape-secrets-engine-openbao-issuer-input.yaml`
declare only the issuer reader. Policy
`workload-kv-read-keycape-secrets-engine-openbao-eso` reads exactly that data path.
Role `external-secrets-keycape-secrets-engine-openbao` binds the existing
external-secrets/external-secrets identity, audience openbao, 15-minute
TTL/max/explicit max and no default policy. The proposed store admits only sso;
target Secret keycape-secrets-engine-openbao-client projects client-secret.
Target annotations are explicit. Auth payload, ESO and receipt checklist are
prepared under this window's `keycape/` directory and outside active GitOps.
field CLIENT_SECRET.
CCR-2026-0033 is proposed, claims unconfirmed and frontdoor unresolvable.
No value exists by implication of this draft. The service client custody child
cannot be finalized before the secrets-engine consumer coordinates are accepted:
issuer-side ESO does not supply a host/path/mechanism for the consumer copy.
No guessed runtime directory, automatic token seed or live registration is added.
**Revised 2026-10-06 (founder direction: the secret lives only in OpenBao; ops-mason
plan `keycape-secrets-engine-openbao-client-custody`, 658cdbc, MASON-WP-0009).**
The secrets-engine consumer file and secrets-engine D1-D4 are withdrawn. Two readers:
- Issuer: CCR-2026-0033, unchanged ESO reader. Policy
`workload-kv-read-keycape-secrets-engine-openbao-eso`, Kubernetes role
`external-secrets-keycape-secrets-engine-openbao` (external-secrets/external-secrets,
audience openbao, 15m, no default policy). The store admits sso only; target Secret
`sso/keycape-secrets-engine-openbao-client` key `client-secret`. It must stay on
Kubernetes auth.
- Operator: sibling CCR-2026-0034, OIDC role
`keycape-secrets-engine-openbao-operator-workload-kv-read` on `auth/netkingdom`.
Policy `workload-kv-read-keycape-secrets-engine-openbao-operator` grants read on
the data path and update on `auth/token/revoke-self` only. The token is 5m, 4 uses,
service type, with no default policy. Bound claims are `groups=net-kingdom-admins`
and `/assurance/level=aal2` (KeyCape 7d96ee4: MFA is not pinned on the openbao-admin
client). secrets-engine reads the value into memory per command, with no copy
at rest.
CCR-2026-0033 now declares the KV metadata: `cas_required=true`, `max_versions=2`,
`delete_version_after=0s`, and custom_metadata as in the plan. Its target.domain is
corrected to infotech. The custody child
`scripts/keycape_secrets_engine_openbao_custody.py` (source only, never run) writes
KV with `cas=0`, or with `cas=<current>` under `--rotate`, then compares the
metadata before it writes. It writes no consumer file. Both CCRs remain proposed with
bound claims unconfirmed. Confirming them is a founder item for both readers.
Founder approvals: A1 design, A2 both CCRs, A3 custody-child decision id (not
632a8893). Live order: custody child → platform applies policies/roles/ESO (Secret
sso/keycape-secrets-engine-openbao-client exists) → NetKingdom env patch (NK-WP-0046)
→ KeyCape registration merge (KEY-WP-0037-T03) → KeyCape restart immediately → live
issuance check (KEY-WP-0037-T04) → OpenBao JWT role/login proof (RPF-WP-0035-T02).
Exact founder commands, including the metadata-only survey:
`keycape/founder-runbook.md`. Payloads and ESO stay outside active GitOps in this
window's `keycape/` directory. No value exists by implication of these drafts.
## Dependency boundary

View file

@ -0,0 +1,155 @@
# Founder runbook: KeyCape `secrets-engine-openbao` client secret custody
Source only. Nothing here has been run. Every command below is run by the
founder, attended, from `/home/worsch/railiance-platform` at the reviewed
commit. No command prints a secret value. Design: ops-mason
`plans/keycape-secrets-engine-openbao-client-custody.md` (658cdbc, MASON-WP-0009).
CCRs: `credential-change-requests/CCR-2026-0033.yaml` (authoritative custody +
issuer ESO reader) and `CCR-2026-0034.yaml` (attended operator OIDC reader).
## Gates before any command
- **A1** approve the ops-mason design.
- **A2** approve CCR-2026-0033 and CCR-2026-0034, each with platform-owner,
key-cape-owner and net-kingdom-owner reviews, and confirm the bound claims of
**both** readers (`bound_claims_confirmed: true`):
- CCR-2026-0033: Kubernetes SA `external-secrets/external-secrets`, audience `openbao`.
- CCR-2026-0034: `groups=net-kingdom-admins` **and** `/assurance/level=aal2`
(`bound_claims_type: string`). KeyCape (7d96ee4) reports that MFA is not pinned on
the openbao-admin client, so this aal2 binding is the lane's MFA guarantee.
- **A3** a founder decision id for the one custody-child run, recorded in an
approved review comment of CCR-2026-0033. It must differ from boundary
decision `632a8893-0d6a-4591-a50b-40bbdb798f32`. Written below as `<A3-decision-id>`.
## Live order
1. Custody child (section B) writes KV v1 plus metadata.
2. Platform applies the policies/roles of both CCRs and the sso-only ESO
(section C). Secret `sso/keycape-secrets-engine-openbao-client` exists.
3. NetKingdom env patch (NK-WP-0046).
4. KeyCape registration merge (KEY-WP-0037-T03).
5. KeyCape restart **immediately** after step 4. Between the KV write and the
restart, exchanges fail closed (401).
6. Live issuance check (KEY-WP-0037-T04).
7. OpenBao JWT role/login proof (RPF-WP-0035-T02).
Steps 3 to 7 belong to their owners and each needs its own go.
## A. Metadata-only survey (read-only; before step 1)
ops-mason's survey generator refuses data paths and runs under the scoped,
value-blind `ops-mason-build` grant:
```bash
cd /home/worsch/ops-mason
./scripts/bao-session.sh grant MASON-WP-0009-T04
./scripts/plan-survey.py --kv-path platform/workloads/keycape/secrets-engine-openbao \
--policy workload-kv-read-keycape-secrets-engine-openbao-operator \
--k8s-role external-secrets-keycape-secrets-engine-openbao \
--consumer secrets-engine --keyword keycape --keyword secrets-engine-openbao
./scripts/bao-session.sh revoke
```
Expected: a Markdown survey block. The block shows KV metadata **absent** at the
path (otherwise the first run must not be used; see `--rotate`). It also shows no
existing role or policy with the two proposed names, and no `auth/jwt` role touching
the path. Paste it into MASON-WP-0009-T04. It contains no value.
## B. Attended custody run (step 1)
Pre-check, offline (no OpenBao call): both CCRs validate and the child is the
reviewed revision.
```bash
python3 scripts/credential-change.py validate credential-change-requests/CCR-2026-0033.yaml
python3 scripts/credential-change.py validate credential-change-requests/CCR-2026-0034.yaml
python3 -m pytest -q tests/test_keycape_secrets_engine_openbao_custody.py
git log -1 --format=%H -- scripts/keycape_secrets_engine_openbao_custody.py
```
Expected: `[OK]` for both with **no** "bound claim is not confirmed" warning;
tests pass. The child has no separate dry-run mode. It refuses before any OpenBao
call when the decision id is missing, wrong or unrecorded, or when either CCR is
not approved and confirmed. It also refuses outside the attended envelope or when
`BAO_TOKEN`/`VAULT_TOKEN` is set.
Run (first write, `cas=0`):
```bash
set +x; umask 077; unset BAO_TOKEN VAULT_TOKEN
warden access openbao-platform-admin-login --exec -- \
python3 scripts/keycape_secrets_engine_openbao_custody.py provision \
--approval-decision <A3-decision-id> \
--receipt /home/worsch/.local/state/railiance/receipts/CCR-2026-0033-custody-$(date -u +%Y%m%dT%H%M%SZ).json
```
Rotation uses the same command with `--rotate` (`cas=<observed current version>`)
under a standing rotation approval for CCR-2026-0033. It is roll-forward only.
Expected: exit 0, no output, and a new mode-0600 receipt (create its parent
directory 0700 first if absent):
```json
{"schema": "platform.keycape-secrets-engine-openbao-custody.v1", "ccr": "CCR-2026-0033",
"status": "custody_written_pending_issuer_delivery", "phase": "done",
"credential_values_emitted": false, "client_side_read_admitted": false,
"approval_decision_id": "<A3-decision-id>", "ccr_sha256": "<digest>",
"kv_written": true, "kv_version": 1, "kv_cas": 0, "kv_request_id": "<id>",
"kv_metadata": "applied", "started_at": "...", "finished_at": "..."}
```
On `status: failed`, read `error` and `phase`. A failure with `kv_written: true`
keeps that version. Recover only with a fresh `--rotate` run, never by reading
the value.
## C. Policies, roles and ESO (step 2)
Policies and roles go through the sanctioned delegated applier. It refuses
unapproved CCRs and unconfirmed bindings, and it diffs the policy files against
the generated bodies.
```bash
# dry-run (offline review of policy diff and role payload, incl. token_num_uses=4, aal2 claim)
python3 scripts/credential-change.py applier-dry-run credential-change-requests/CCR-2026-0033.yaml
python3 scripts/credential-change.py applier-dry-run credential-change-requests/CCR-2026-0034.yaml
python3 scripts/credential-change.py applier-apply credential-change-requests/CCR-2026-0034.yaml --plan-only
# apply (attended platform-admin; one phrase per CCR)
warden access openbao-platform-admin-login --exec -- \
python3 scripts/credential-change.py applier-apply credential-change-requests/CCR-2026-0033.yaml \
--confirm "DELEGATED APPLY CCR-2026-0033" --actor founder --record-state-hub
warden access openbao-platform-admin-login --exec -- \
python3 scripts/credential-change.py applier-apply credential-change-requests/CCR-2026-0034.yaml \
--confirm "DELEGATED APPLY CCR-2026-0034" --actor founder --record-state-hub
```
Expected: `[OK] CCR-2026-0033.yaml delegated metadata apply recorded`, the same for
0034, and a value-free evidence entry in each CCR.
ESO: `eso.draft.yaml` in this directory carries `railiance.io/admission:
pending-review`. Promote it to `approved` in a reviewed commit after A2 first. Then:
```bash
kubectl apply --dry-run=server -f docs/provisioning-windows/2026-10-04-1000/keycape/eso.draft.yaml
kubectl apply -f docs/provisioning-windows/2026-10-04-1000/keycape/eso.draft.yaml
kubectl -n sso get externalsecret keycape-secrets-engine-openbao-client \
-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}{"\n"}'
kubectl -n sso get secret keycape-secrets-engine-openbao-client -o name
```
Expected: `ClusterSecretStore ... created (server dry run)` and `ExternalSecret ...
created (server dry run)`, then the real apply, then `True`, then
`secret/keycape-secrets-engine-openbao-client`. Never use another output format
or a template on the Secret.
After step 2, ops-mason runs its capabilities-only verification (MASON-WP-0009-T04).
It checks operator-token read on the data path and deny everywhere else, and that a
non-member or non-aal2 identity cannot obtain the role. It also checks the
circularity invariants and the metadata settings.
## Rollback (roll-forward; never a read of an old value)
Before step 3: delete the CCR-2026-0034 OIDC role, then its policy. Delete the
ExternalSecret (uid precondition), then the ESO role and policy. The KV entry is
kept until a separate destroy approval. After step 3: the NetKingdom rollout
helper restores the prior config/Deployment first, then the same sequence.

View file

@ -0,0 +1,7 @@
path "platform/data/workloads/keycape/secrets-engine-openbao" {
capabilities = ["read"]
}
path "auth/token/revoke-self" {
capabilities = ["update"]
}

View file

@ -78,6 +78,9 @@ APPLIER_ALLOWED_ENVIRONMENTS = {
}
WORKLOAD_KV_POLICY_PREFIX = "workload-kv-read-"
OIDC_WORKLOAD_ROLE_SUFFIX = "-workload-kv-read"
TOKEN_TYPES = {"service", "batch", "default"}
TOKEN_BOUND_KEYS = ("token_max_ttl", "token_explicit_max_ttl", "token_no_default_policy",
"token_num_uses", "token_type")
KUBERNETES_ROLE_SUFFIXES = ("-workload-kv-read", "-secrets-read")
KUBERNETES_ROLE_PREFIXES = ("external-secrets-",)
EVIDENCE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
@ -248,6 +251,10 @@ def validate_workload_kv_read(ccr: dict[str, Any], errors: list[str], warnings:
errors.append("openbao.metadata_read must be boolean")
if "token_self_lifecycle" in openbao and not isinstance(openbao["token_self_lifecycle"], bool):
errors.append("openbao.token_self_lifecycle must be boolean")
if "token_revoke_self" in openbao and not isinstance(openbao["token_revoke_self"], bool):
errors.append("openbao.token_revoke_self must be boolean")
if "kv_metadata" in openbao:
validate_kv_metadata(openbao["kv_metadata"], errors)
fields = [str(field) for field in require_list(openbao.get("fields"), "openbao.fields", errors)]
if not fields:
errors.append("openbao.fields must contain at least one field")
@ -276,14 +283,24 @@ def validate_workload_kv_read(ccr: dict[str, Any], errors: list[str], warnings:
if method in {"oidc", "kubernetes"}:
require_string(auth.get("mount"), "openbao.auth.mount", errors)
require_string(auth.get("role"), "openbao.auth.role", errors)
if method == "kubernetes":
if "audience" in auth:
require_string(auth["audience"], "openbao.auth.audience", errors)
if method == "kubernetes" and "audience" in auth:
require_string(auth["audience"], "openbao.auth.audience", errors)
if method in {"oidc", "kubernetes"}:
for key in ("token_max_ttl", "token_explicit_max_ttl"):
if key in auth and (not isinstance(auth[key], str) or not TTL_RE.match(auth[key])):
errors.append(f"openbao.auth.{key} must be a TTL string")
if "token_no_default_policy" in auth and not isinstance(auth["token_no_default_policy"], bool):
errors.append("openbao.auth.token_no_default_policy must be boolean")
if "token_num_uses" in auth and (
isinstance(auth["token_num_uses"], bool)
or not isinstance(auth["token_num_uses"], int)
or auth["token_num_uses"] < 1
):
errors.append("openbao.auth.token_num_uses must be a positive integer")
if "token_type" in auth and auth["token_type"] not in TOKEN_TYPES:
errors.append(f"openbao.auth.token_type must be one of {sorted(TOKEN_TYPES)}")
if method == "oidc" and "bound_claims_type" in auth and auth["bound_claims_type"] != "string":
errors.append("openbao.auth.bound_claims_type must be 'string'; glob matching is not admitted")
if method == "oidc":
redirect_uris = require_list(
auth.get("allowed_redirect_uris"),
@ -432,6 +449,33 @@ def validate_workload_kv_read(ccr: dict[str, Any], errors: list[str], warnings:
require_string(lifecycle.get(field), f"lifecycle.{field}", errors)
def validate_kv_metadata(value: Any, errors: list[str]) -> None:
"""Declared KV-v2 metadata, applied by the owning custody child, never by the applier."""
meta = require_object(value, "openbao.kv_metadata", errors)
if not meta:
return
unknown = set(meta) - {"cas_required", "max_versions", "delete_version_after", "custom_metadata"}
if unknown:
errors.append(f"openbao.kv_metadata has unknown keys: {sorted(unknown)}")
if "cas_required" in meta and not isinstance(meta["cas_required"], bool):
errors.append("openbao.kv_metadata.cas_required must be boolean")
max_versions = meta.get("max_versions")
if max_versions is not None and (
isinstance(max_versions, bool) or not isinstance(max_versions, int) or max_versions < 1
):
errors.append("openbao.kv_metadata.max_versions must be a positive integer")
after = meta.get("delete_version_after")
if after is not None and after != "0s" and (not isinstance(after, str) or not TTL_RE.match(after)):
errors.append("openbao.kv_metadata.delete_version_after must be '0s' or a TTL string")
custom = meta.get("custom_metadata")
if custom is not None:
custom = require_object(custom, "openbao.kv_metadata.custom_metadata", errors)
for key, item in custom.items():
if not isinstance(key, str) or not isinstance(item, str) or not key or not item:
errors.append("openbao.kv_metadata.custom_metadata must map non-empty strings to strings")
break
def validate_ccr(path: Path) -> tuple[dict[str, Any], list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
@ -573,6 +617,8 @@ def generated_policy_hcl(ccr: dict[str, Any]) -> str:
if openbao.get("token_self_lifecycle", False):
body += ('\npath "auth/token/lookup-self" {\n capabilities = ["read"]\n}\n'
'\npath "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n')
elif openbao.get("token_revoke_self", False):
body += '\npath "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n'
return body
@ -641,7 +687,7 @@ def auth_payload(ccr: dict[str, Any]) -> dict[str, Any]:
"policies": ",".join(auth["policies"]),
"ttl": auth.get("ttl", "15m"),
}
for key in ("audience", "token_max_ttl", "token_explicit_max_ttl", "token_no_default_policy"):
for key in ("audience", *TOKEN_BOUND_KEYS):
if key in auth:
payload[key] = auth[key]
return payload
@ -660,6 +706,9 @@ def auth_payload(ccr: dict[str, Any]) -> dict[str, Any]:
payload["allowed_redirect_uris"] = auth["allowed_redirect_uris"]
if auth.get("oidc_scopes"):
payload["oidc_scopes"] = auth["oidc_scopes"]
for key in ("bound_claims_type", *TOKEN_BOUND_KEYS):
if key in auth:
payload[key] = auth[key]
return payload

View file

@ -1,14 +1,16 @@
#!/usr/bin/env python3
"""Attended custody child for CCR-2026-0033: KeyCape `secrets-engine-openbao` client secret.
SOURCE ONLY until the founder approves this exact child. One run generates one
value in process memory, compare-before-writes it as the authoritative KV-v2
version, writes the secrets-engine consumer copy in the same process, runs the
consumer's value-free acceptance check and writes a metadata-only receipt.
SOURCE ONLY until the founder approves this exact child (A3). One run generates
one value in process memory, writes it as the authoritative KV-v2 version with
compare-and-set (cas=0 to create, cas=<current> to rotate with --rotate), then
compare-before-writes the declared KV metadata and writes a metadata-only receipt.
It never reads the KV value, never prints the value, never puts it in argv or
the environment, and does not touch policies, roles, ESO, KeyCape or the
cluster (later live steps, see CCR-2026-0033 live_order).
There is no consumer copy: secrets-engine reads the value in memory per command
through the attended operator reader CCR-2026-0034 (founder direction 2026-10-06,
ops-mason plan keycape-secrets-engine-openbao-client-custody). The child never
reads the KV value, never prints it, never puts it in argv or the environment,
and does not touch policies, roles, ESO, KeyCape or the cluster.
"""
from __future__ import annotations
@ -20,7 +22,6 @@ import os
from pathlib import Path
import re
import secrets
import stat
import sys
from datetime import datetime, timezone
@ -29,13 +30,13 @@ from state_hub_preflight_lane import ROOT, LaneError, bao, command, data # noqa
CCR_ID = 'CCR-2026-0033'
CCR_PATH = ROOT / 'credential-change-requests/CCR-2026-0033.yaml'
SIBLING_ID = 'CCR-2026-0034'
SIBLING_PATH = ROOT / 'credential-change-requests/CCR-2026-0034.yaml'
BOUNDARY_DECISION = '632a8893-0d6a-4591-a50b-40bbdb798f32'
KV_PATH = 'platform/workloads/keycape/secrets-engine-openbao'
KV_DATA = 'platform/data/workloads/keycape/secrets-engine-openbao'
KV_METADATA = 'platform/metadata/workloads/keycape/secrets-engine-openbao'
FIELD = 'CLIENT_SECRET'
# secrets-engine coordinates (SECRETS-WP-0012, a3897c2); D1-D4 pending founder choice.
CONSUMER_FILE = Path('/home/worsch/.secrets-engine/keycape/secrets-engine-openbao.client-secret')
APPROVERS = ('platform-owner', 'key-cape-owner', 'net-kingdom-owner')
UUID_RE = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')
@ -50,36 +51,54 @@ def attended_envelope():
and not os.environ.get('BAO_TOKEN') and not os.environ.get('VAULT_TOKEN'))
def contract(decision_id):
require(isinstance(decision_id, str) and UUID_RE.match(decision_id), 'approval_decision_id_required')
require(decision_id != BOUNDARY_DECISION, 'boundary_decision_does_not_approve_custody_child')
def _load_validator():
spec = importlib.util.spec_from_file_location('credential_change', ROOT / 'scripts/credential-change.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
ccr, errors, _ = module.validate_ccr(CCR_PATH)
require(not errors and ccr['id'] == CCR_ID, 'valid_ccr_required')
return module
def _approved(module, path, ccr_id):
ccr, errors, _ = module.validate_ccr(path)
require(not errors and ccr['id'] == ccr_id, 'valid_ccr_required')
require(ccr['status'] == 'approved', 'approved_ccr_required')
comments = ccr['review']['comments']
for role in APPROVERS:
require(any(c.get('decision') == 'approved' and role in c.get('reviewer', '') for c in comments),
'all_named_reviews_required')
require(any(c.get('decision') == 'approved' and decision_id in str(c.get('comment', '')) for c in comments),
'custody_child_decision_not_recorded_in_ccr')
openbao = ccr['openbao']
require(openbao['kv_path'] == KV_PATH and openbao['fields'] == [FIELD]
and openbao['auth']['bound_claims_confirmed'] is True
and ccr['custody_child']['script'] == 'scripts/keycape_secrets_engine_openbao_custody.py'
and ccr['consumer_copy']['file'] == str(CONSUMER_FILE), 'exact_approved_contract_required')
return hashlib.sha256(CCR_PATH.read_bytes()).hexdigest()
require(any(c.get('decision') == 'approved' and role in c.get('reviewer', '')
for c in ccr['review']['comments']), 'all_named_reviews_required')
require(ccr['openbao']['auth']['bound_claims_confirmed'] is True, 'bound_claims_confirmation_required')
require(ccr['openbao']['kv_path'] == KV_PATH and ccr['openbao']['fields'] == [FIELD],
'exact_approved_contract_required')
return ccr
def kv_current_version():
"""Metadata only. None when the path has never been written."""
def contract(decision_id):
"""Returns (ccr digest, desired metadata). Refuses before any OpenBao call."""
require(isinstance(decision_id, str) and UUID_RE.match(decision_id), 'approval_decision_id_required')
require(decision_id != BOUNDARY_DECISION, 'boundary_decision_does_not_approve_custody_child')
module = _load_validator()
ccr = _approved(module, CCR_PATH, CCR_ID)
require(any(c.get('decision') == 'approved' and decision_id in str(c.get('comment', ''))
for c in ccr['review']['comments']), 'custody_child_decision_not_recorded_in_ccr')
require(ccr['custody_child']['script'] == 'scripts/keycape_secrets_engine_openbao_custody.py'
and 'consumer_copy' not in ccr, 'exact_approved_contract_required')
_approved(module, SIBLING_PATH, SIBLING_ID)
meta = ccr['openbao'].get('kv_metadata') or {}
require(meta.get('cas_required') is True and meta.get('max_versions') == 2
and meta.get('delete_version_after') == '0s' and meta.get('custom_metadata'),
'declared_kv_metadata_required')
return hashlib.sha256(CCR_PATH.read_bytes()).hexdigest(), meta
def read_metadata():
"""Metadata only (never the data path). None when the path has never been written."""
result = bao(['read', '-format=json', KV_METADATA], allow_failure=True)
if result.returncode != 0:
require(b'404' in result.stderr or b'No value found' in result.stderr, 'absence_not_proven')
return None
meta = data(result)['data']
return data(result)['data']
def live_version(meta):
current = meta['current_version']
version = meta['versions'][str(current)]
require(not version.get('destroyed') and not version.get('deletion_time'), 'current_version_not_live')
@ -93,100 +112,46 @@ def write_kv(value, cas):
return result['data']['version'], result.get('request_id')
def fsync_dir(path):
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(fd)
finally:
os.close(fd)
def metadata_matches(observed, desired):
observed = observed or {}
return all((observed.get(key) or {}) == value if key == 'custom_metadata' else observed.get(key) == value
for key, value in desired.items())
def prepare_parent(target):
parent = target.parent
grand = os.lstat(parent.parent)
require(stat.S_ISDIR(grand.st_mode), 'consumer_grandparent_invalid')
try:
info = os.lstat(parent)
except FileNotFoundError:
os.mkdir(parent, 0o700)
fsync_dir(parent.parent)
info = os.lstat(parent)
require(stat.S_ISDIR(info.st_mode) and info.st_uid == os.geteuid()
and stat.S_IMODE(info.st_mode) == 0o700, 'consumer_parent_must_be_owned_0700_directory')
require(not os.path.lexists(target), 'consumer_file_exists_rotation_needs_separate_approval')
def write_consumer(target, value, receipt):
parent = target.parent
temp = parent / ('.' + target.name + '.' + secrets.token_hex(8) + '.tmp')
fd = os.open(temp, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
try:
try:
os.write(fd, (value + '\n').encode())
os.fsync(fd)
finally:
os.close(fd)
os.rename(temp, target)
fsync_dir(parent)
except BaseException:
try:
os.unlink(temp)
receipt['consumer_temp_removed'] = True
except FileNotFoundError:
pass
raise
info = os.lstat(target)
require(stat.S_ISREG(info.st_mode) and stat.S_IMODE(info.st_mode) == 0o600
and info.st_uid == os.geteuid(), 'consumer_file_readback_failed')
def check_consumer(target, secrets_engine):
result = command([secrets_engine, 'service-auth', 'check-secret-file', '--path', str(target), '--json'],
allow_failure=True)
try:
ok = bool(json.loads(result.stdout).get('ok'))
except (ValueError, AttributeError):
ok = False
return result.returncode == 0 and ok
def retry_cas(prior_path, current):
prior = json.loads(Path(prior_path).read_text())
require(prior.get('ccr') == CCR_ID and prior.get('status') == 'failed' and prior.get('kv_written')
and not prior.get('consumer_written'), 'retryable_partial_receipt_required')
require(current is not None and prior.get('kv_version') == current, 'kv_version_changed_since_prior_receipt')
return current
def ensure_metadata(desired):
"""Compare before write; idempotent. Returns 'unchanged' or 'applied'."""
if metadata_matches(read_metadata(), desired):
return 'unchanged'
bao(['write', KV_METADATA, '-'], payload=dict(desired))
require(metadata_matches(read_metadata(), desired), 'kv_metadata_readback_mismatch')
return 'applied'
def run(args, receipt):
receipt['approval_decision_id'] = args.approval_decision
receipt['ccr_sha256'] = contract(args.approval_decision)
receipt['ccr_sha256'], desired = contract(args.approval_decision)
require(attended_envelope(), 'attended_warden_envelope_required')
os.umask(0o077)
identity = data(bao(['token', 'lookup', '-format=json']))['data']
require('platform-admin' in identity['policies'] and 'root' not in identity['policies'],
'attended_platform_admin_required')
current = kv_current_version()
if args.prior_receipt:
cas = retry_cas(args.prior_receipt, current)
receipt['retry_of_kv_version'] = cas
meta = read_metadata()
current = None if meta is None else live_version(meta)
if args.rotate:
require(current is not None, 'rotation_requires_existing_custody')
cas = current
else:
require(current is None, 'existing_custody_requires_retry_receipt_or_rotation_approval')
require(current is None, 'existing_custody_requires_rotate')
cas = 0
prepare_parent(CONSUMER_FILE)
receipt['phase'] = 'kv_write'
value = secrets.token_urlsafe(48)
try:
version, request_id = write_kv(value, cas)
receipt.update(kv_written=True, kv_version=version, kv_request_id=request_id, kv_cas=cas)
receipt['phase'] = 'consumer_write'
write_consumer(CONSUMER_FILE, value, receipt)
receipt['consumer_written'] = True
finally:
value = None
receipt['phase'] = 'consumer_check'
receipt['consumer_check_ok'] = check_consumer(CONSUMER_FILE, args.secrets_engine)
require(receipt['consumer_check_ok'], 'consumer_check_failed')
receipt.update(kv_written=True, kv_version=version, kv_request_id=request_id, kv_cas=cas)
receipt['phase'] = 'kv_metadata'
receipt['kv_metadata'] = ensure_metadata(desired)
receipt['status'] = 'custody_written_pending_issuer_delivery'
receipt['phase'] = 'done'
@ -195,16 +160,16 @@ def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument('action', choices=['provision'])
parser.add_argument('--approval-decision', required=True,
help='founder decision id approving this exact custody child')
help='founder decision id approving this exact custody child (A3)')
parser.add_argument('--receipt', required=True)
parser.add_argument('--prior-receipt', help='failed receipt with kv_written and no consumer file')
parser.add_argument('--secrets-engine', default='secrets-engine', help='secrets-engine executable')
parser.add_argument('--rotate', action='store_true',
help='roll forward an existing entry with cas=<observed current version>')
args = parser.parse_args(argv)
receipt = {'schema': 'platform.keycape-secrets-engine-openbao-custody.v1', 'ccr': CCR_ID,
'status': 'failed', 'phase': 'preflight',
'started_at': datetime.now(timezone.utc).isoformat(),
'credential_values_emitted': False, 'client_side_read_admitted': False,
'kv_written': False, 'consumer_written': False}
'kv_written': False}
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
run(args, receipt)

View file

@ -453,6 +453,65 @@ class CredentialChangeTests(unittest.TestCase):
["openid", "profile", "email", "groups"],
)
def test_oidc_operator_reader_carries_token_bounds_and_assurance(self) -> None:
path = REPO_DIR / "credential-change-requests/CCR-2026-0034.yaml"
ccr, errors, _warnings = credential_change.validate_ccr(path)
self.assertEqual(errors, [])
payload = credential_change.auth_payload(ccr)
self.assertEqual(payload["token_num_uses"], 4)
self.assertEqual((payload["ttl"], payload["token_max_ttl"], payload["token_explicit_max_ttl"]),
("5m", "5m", "5m"))
self.assertIs(payload["token_no_default_policy"], True)
self.assertEqual(payload["token_type"], "service")
self.assertEqual(payload["bound_claims_type"], "string")
self.assertEqual(payload["bound_claims"],
{"groups": ["net-kingdom-admins"], "/assurance/level": "aal2"})
body = credential_change.generated_policy_hcl(ccr)
self.assertIn('path "auth/token/revoke-self"', body)
self.assertNotIn("lookup-self", body)
self.assertNotIn("/metadata/", body)
self.assertEqual(credential_change.policy_artifact_diff(ccr)["status"], "matches")
self.assertEqual(credential_change.applier_policy_violations(ccr), [])
def test_oidc_token_bounds_and_kv_metadata_are_validated(self) -> None:
import yaml
source = yaml.safe_load((REPO_DIR / "credential-change-requests/CCR-2026-0034.yaml").read_text())
cases = [
(("openbao", "auth", "token_num_uses"), 0, "token_num_uses must be a positive integer"),
(("openbao", "auth", "token_num_uses"), True, "token_num_uses must be a positive integer"),
(("openbao", "auth", "token_type"), "root", "token_type must be one of"),
(("openbao", "auth", "bound_claims_type"), "glob", "glob matching is not admitted"),
(("openbao", "auth", "token_max_ttl"), "5 minutes", "token_max_ttl must be a TTL string"),
(("openbao", "kv_metadata"), {"max_versions": 0}, "max_versions must be a positive integer"),
(("openbao", "kv_metadata"), {"delete_version_after": "never"}, "delete_version_after"),
(("openbao", "kv_metadata"), {"versions": 2}, "unknown keys"),
(("openbao", "kv_metadata"), {"custom_metadata": {"a": 1}}, "custom_metadata must map"),
]
with tempfile.TemporaryDirectory() as tmp:
for keys, value, message in cases:
ccr = yaml.safe_load(yaml.safe_dump(source))
target = ccr
for key in keys[:-1]:
target = target[key]
target[keys[-1]] = value
path = Path(tmp) / "CCR-2026-0034.yaml"
path.write_text(yaml.safe_dump(ccr))
_ccr, errors, _ = credential_change.validate_ccr(path)
self.assertTrue(any(message in error for error in errors), (keys, value, errors))
def test_keycape_secrets_engine_openbao_ccr_declares_kv_metadata_without_consumer_copy(self) -> None:
path = REPO_DIR / "credential-change-requests/CCR-2026-0033.yaml"
ccr, errors, _ = credential_change.validate_ccr(path)
self.assertEqual(errors, [])
self.assertNotIn("consumer_copy", ccr)
self.assertEqual(ccr["target"]["domain"], "infotech")
self.assertEqual(ccr["status"], "proposed")
meta = ccr["openbao"]["kv_metadata"]
self.assertEqual((meta["cas_required"], meta["max_versions"], meta["delete_version_after"]),
(True, 2, "0s"))
self.assertEqual(meta["custom_metadata"]["readers"], "eso-sso-issuer,oidc-secrets-engine-operator")
self.assertIs(ccr["openbao"]["auth"]["bound_claims_confirmed"], False)
def test_apply_plan_refuses_unapproved_ccr(self) -> None:
with self.assertRaises(SystemExit):
credential_change.command_apply_plan(type("Args", (), {"ref": str(self.issue_core)})())

View file

@ -1,5 +1,6 @@
"""CCR-2026-0033 custody child: synthetic values and fakes only; no OpenBao, cluster or real file."""
import contextlib
import copy
import io
import json
import os
@ -25,19 +26,31 @@ def result(code=0, stdout=b'', stderr=b''):
class FakeBao:
def __init__(self, current=None, fail_write=False):
"""KV-v2 metadata/data behaviour for one path; refuses data reads outright."""
def __init__(self, current=None, fail_write=False, metadata=None):
self.current, self.fail_write, self.calls, self.payloads = current, fail_write, [], []
self.metadata = metadata or {'cas_required': False, 'max_versions': 0,
'delete_version_after': '0s', 'custom_metadata': None}
self.metadata_writes = []
def __call__(self, args, *, payload=None, token=None, allow_failure=False):
self.calls.append(list(args))
if args[:2] == ['token', 'lookup']:
return result(stdout=json.dumps({'data': {'policies': ['default', 'platform-admin']}}).encode())
if args[0] == 'read' and args[-1] == child.KV_DATA:
raise AssertionError('data path read attempted')
if args[0] == 'read' and args[-1] == child.KV_METADATA:
if self.current is None:
return result(2, stderr=b'No value found at ' + child.KV_METADATA.encode())
return result(stdout=json.dumps({'data': {'current_version': self.current,
'versions': {str(self.current): {'destroyed': False, 'deletion_time': ''}}}}).encode())
if args[0] == 'write' and args[2] == child.KV_DATA:
body = dict(self.metadata, current_version=self.current,
versions={str(self.current): {'destroyed': False, 'deletion_time': ''}})
return result(stdout=json.dumps({'data': body}).encode())
if args[0] == 'write' and child.KV_METADATA in args:
self.metadata_writes.append(payload)
self.metadata.update(copy.deepcopy(payload))
return result()
if args[0] == 'write' and child.KV_DATA in args:
self.payloads.append(payload)
if self.fail_write or payload['options']['cas'] != (self.current or 0):
raise child.LaneError('command_failed')
@ -46,30 +59,27 @@ class FakeBao:
raise AssertionError('unexpected bao call: ' + ' '.join(args[:2]))
def approve(path, base, name, decision=DECISION):
ccr = yaml.safe_load(path.read_text())
ccr['status'] = 'approved'
ccr['openbao']['auth']['bound_claims_confirmed'] = True
ccr['review']['comments'] = [
{'at': '2026-10-06', 'reviewer': r, 'decision': 'approved',
'comment': 'custody child approved by decision ' + decision} for r in child.APPROVERS]
out = base / name
out.write_text(yaml.safe_dump(ccr, sort_keys=False))
return out, ccr
class CustodyChild(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
base = Path(self.tmp.name)
os.chmod(base, 0o755)
self.consumer = base / 'home/.secrets-engine/keycape/secrets-engine-openbao.client-secret'
self.consumer.parent.parent.mkdir(parents=True)
ccr = yaml.safe_load((ROOT / 'credential-change-requests/CCR-2026-0033.yaml').read_text())
ccr['status'] = 'approved'
ccr['openbao']['auth']['bound_claims_confirmed'] = True
ccr['consumer_copy']['file'] = str(self.consumer)
ccr['review']['comments'] = [
{'reviewer': r, 'decision': 'approved', 'date': '2026-10-06',
'comment': 'custody child approved by decision ' + DECISION} for r in child.APPROVERS]
self.ccr = base / 'CCR-2026-0033.yaml'
self.ccr.write_text(yaml.safe_dump(ccr, sort_keys=False))
checker = base / 'check.py'
checker.write_text('#!' + sys.executable + '\nimport json,os,stat,sys\np=sys.argv[sys.argv.index("--path")+1]\n'
'ok=os.path.isfile(p) and stat.S_IMODE(os.stat(p).st_mode)==0o600\n'
'print(json.dumps({"ok":ok}))\nsys.exit(0 if ok else 1)\n')
checker.chmod(0o700)
self.checker = str(checker)
self.ccr, data = approve(child.CCR_PATH, base, 'CCR-2026-0033.yaml')
self.desired = data['openbao']['kv_metadata']
self.sibling, _ = approve(child.SIBLING_PATH, base, 'CCR-2026-0034.yaml')
self.bao = FakeBao()
self.patches = [patch.object(child, 'CCR_PATH', self.ccr), patch.object(child, 'CONSUMER_FILE', self.consumer),
self.patches = [patch.object(child, 'CCR_PATH', self.ccr), patch.object(child, 'SIBLING_PATH', self.sibling),
patch.object(child, 'bao', self.bao), patch.object(child, 'attended_envelope', lambda: True),
patch.object(child.secrets, 'token_urlsafe', lambda n: SYNTHETIC)]
for p in self.patches:
@ -85,16 +95,17 @@ class CustodyChild(unittest.TestCase):
def run_child(self, *extra, decision=DECISION):
receipt = Path(self.tmp.name) / ('receipt-%d.json' % len(list(Path(self.tmp.name).glob('receipt-*'))))
out, err = io.StringIO(), io.StringIO()
argv = ['provision', '--receipt', str(receipt), '--secrets-engine', self.checker, *extra]
argv = ['provision', '--receipt', str(receipt), *extra]
if decision is not None:
argv += ['--approval-decision', decision]
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
code = child.main(argv)
text = receipt.read_text()
for blob in (text, out.getvalue(), err.getvalue(), json.dumps(self.bao.calls)):
for blob in (text, out.getvalue(), err.getvalue(), json.dumps(self.bao.calls), ' '.join(argv)):
self.assertNotIn(SYNTHETIC, blob)
self.assertEqual(out.getvalue(), '')
self.assertEqual(oct(receipt.stat().st_mode & 0o777), oct(0o600))
return code, json.loads(text), receipt
return code, json.loads(text)
def test_refuses_without_or_with_wrong_decision(self):
with self.assertRaises(SystemExit), contextlib.redirect_stderr(io.StringIO()):
@ -102,92 +113,95 @@ class CustodyChild(unittest.TestCase):
for decision, reason in (('not-a-uuid', 'approval_decision_id_required'),
(child.BOUNDARY_DECISION, 'boundary_decision_does_not_approve_custody_child'),
('11111111-1111-4111-8111-111111111111', 'custody_child_decision_not_recorded_in_ccr')):
code, receipt, _ = self.run_child(decision=decision)
code, receipt = self.run_child(decision=decision)
self.assertEqual((code, receipt['error']), (1, reason))
self.assertEqual(self.bao.calls, [])
def test_committed_proposed_ccr_is_refused(self):
def test_committed_proposed_ccrs_are_refused(self):
with patch.object(child, 'CCR_PATH', ROOT / 'credential-change-requests/CCR-2026-0033.yaml'):
code, receipt, _ = self.run_child()
code, receipt = self.run_child()
self.assertEqual((code, receipt['error']), (1, 'approved_ccr_required'))
with patch.object(child, 'SIBLING_PATH', ROOT / 'credential-change-requests/CCR-2026-0034.yaml'):
code, receipt = self.run_child()
self.assertEqual((code, receipt['error']), (1, 'approved_ccr_required'))
self.assertEqual(self.bao.calls, [])
def test_refuses_outside_attended_envelope(self):
def test_refuses_ambient_token_or_outside_envelope(self):
with patch.object(child, 'attended_envelope', lambda: False):
code, receipt, _ = self.run_child()
code, receipt = self.run_child()
self.assertEqual(receipt['error'], 'attended_warden_envelope_required')
self.assertEqual(self.bao.calls, [])
self.patches[3].stop()
try:
with patch.object(child.Path, 'home', lambda: Path('/x/.warden-attended-login/u')):
with patch.dict(os.environ, {}, clear=False):
os.environ.pop('BAO_TOKEN', None)
os.environ.pop('VAULT_TOKEN', None)
self.assertTrue(child.attended_envelope())
for var in ('BAO_TOKEN', 'VAULT_TOKEN'):
with patch.dict(os.environ, {var: 'synthetic-ambient'}):
self.assertFalse(child.attended_envelope())
code, receipt = self.run_child()
self.assertEqual(receipt['error'], 'attended_warden_envelope_required')
finally:
self.patches[3].start()
self.assertEqual(self.bao.calls, [])
def test_provision_writes_kv_and_consumer_in_one_process(self):
code, receipt, _ = self.run_child()
def test_create_writes_kv_cas0_and_metadata(self):
code, receipt = self.run_child()
self.assertEqual(code, 0)
self.assertEqual(receipt['status'], 'custody_written_pending_issuer_delivery')
self.assertEqual((receipt['kv_version'], receipt['kv_cas']), (1, 0))
self.assertTrue(receipt['consumer_written'] and receipt['consumer_check_ok'])
self.assertEqual((receipt['kv_version'], receipt['kv_cas'], receipt['kv_metadata']), (1, 0, 'applied'))
self.assertEqual(self.bao.payloads, [{'options': {'cas': 0}, 'data': {'CLIENT_SECRET': SYNTHETIC}}])
self.assertNotIn(['read', '-format=json', child.KV_DATA], self.bao.calls)
self.assertEqual(self.consumer.read_text(), SYNTHETIC + '\n')
self.assertEqual(self.consumer.stat().st_mode & 0o777, 0o600)
self.assertEqual(self.consumer.parent.stat().st_mode & 0o777, 0o700)
self.assertEqual(sorted(p.name for p in self.consumer.parent.iterdir()), [self.consumer.name])
self.assertEqual(self.bao.metadata_writes, [self.desired])
self.assertTrue(self.bao.metadata['cas_required'])
self.assertEqual(self.bao.metadata['max_versions'], 2)
self.assertEqual(self.bao.metadata['custom_metadata']['ccr'], 'CCR-2026-0033')
self.assertEqual(sorted(p.name for p in Path(self.tmp.name).iterdir()),
['CCR-2026-0033.yaml', 'CCR-2026-0034.yaml', 'receipt-0.json'])
def test_existing_custody_or_consumer_file_is_refused(self):
def test_existing_entry_requires_rotate_and_rotation_uses_current_cas(self):
self.bao.current = 1
code, receipt, _ = self.run_child()
self.assertEqual(receipt['error'], 'existing_custody_requires_retry_receipt_or_rotation_approval')
self.bao.current = None
self.consumer.parent.mkdir(mode=0o700)
self.consumer.write_text('old\n')
code, receipt, _ = self.run_child()
self.assertEqual(receipt['error'], 'consumer_file_exists_rotation_needs_separate_approval')
self.bao.metadata.update(copy.deepcopy(self.desired))
code, receipt = self.run_child()
self.assertEqual((code, receipt['error']), (1, 'existing_custody_requires_rotate'))
self.assertEqual(self.bao.payloads, [])
code, receipt = self.run_child('--rotate')
self.assertEqual((code, receipt['kv_cas'], receipt['kv_version'], receipt['kv_metadata']), (0, 1, 2, 'unchanged'))
self.assertEqual(self.bao.metadata_writes, [])
def test_rotate_without_entry_is_refused(self):
code, receipt = self.run_child('--rotate')
self.assertEqual((code, receipt['error']), (1, 'rotation_requires_existing_custody'))
self.assertEqual(self.bao.payloads, [])
def test_shared_parent_is_refused(self):
self.consumer.parent.mkdir(mode=0o755)
os.chmod(self.consumer.parent, 0o755)
code, receipt, _ = self.run_child()
self.assertEqual(receipt['error'], 'consumer_parent_must_be_owned_0700_directory')
self.assertEqual(self.bao.payloads, [])
def test_failed_kv_write_writes_no_consumer_file(self):
def test_failed_kv_write_records_no_version_and_no_metadata(self):
self.bao.fail_write = True
code, receipt, _ = self.run_child()
self.assertEqual((code, receipt['kv_written'], receipt['consumer_written']), (1, False, False))
self.assertFalse(self.consumer.exists())
self.assertEqual(list(self.consumer.parent.iterdir()), [])
code, receipt = self.run_child()
self.assertEqual((code, receipt['kv_written'], receipt['error']), (1, False, 'command_failed'))
self.assertEqual(self.bao.metadata_writes, [])
def test_consumer_failure_cleans_temp_and_retry_preserves_version(self):
with patch.object(child.os, 'rename', side_effect=OSError('synthetic')):
code, first, path = self.run_child()
self.assertEqual((code, first['kv_written'], first['kv_version'], first['consumer_written']), (1, True, 1, False))
self.assertTrue(first['consumer_temp_removed'])
self.assertEqual(list(self.consumer.parent.iterdir()), [])
code, receipt, _ = self.run_child()
self.assertEqual(receipt['error'], 'existing_custody_requires_retry_receipt_or_rotation_approval')
code, second, _ = self.run_child('--prior-receipt', str(path))
self.assertEqual((code, second['kv_version'], second['kv_cas'], second['retry_of_kv_version']), (0, 2, 1, 1))
self.assertEqual([p['options']['cas'] for p in self.bao.payloads], [0, 1])
self.bao.current = 3
Path(self.consumer).unlink()
code, third, _ = self.run_child('--prior-receipt', str(path))
self.assertEqual(third['error'], 'kv_version_changed_since_prior_receipt')
def test_metadata_readback_mismatch_fails_but_records_written_version(self):
real = self.bao.__call__
bao = self.bao
def test_failed_consumer_check_fails_receipt(self):
bad = Path(self.tmp.name) / 'bad.py'
bad.write_text('#!' + sys.executable + '\nimport sys\nprint(\'{"ok": false}\')\nsys.exit(1)\n')
bad.chmod(0o700)
self.checker = str(bad)
code, receipt, _ = self.run_child()
self.assertEqual((code, receipt['consumer_written'], receipt['consumer_check_ok'], receipt['error']),
(1, True, False, 'consumer_check_failed'))
def wrapper(args, **kw):
if args[0] == 'write' and child.KV_METADATA in args:
bao.calls.append(list(args))
return result()
return real(args, **kw)
with patch.object(child, 'bao', wrapper):
code, receipt = self.run_child()
self.assertEqual((code, receipt['kv_written'], receipt['kv_version'], receipt['error']),
(1, True, 1, 'kv_metadata_readback_mismatch'))
def test_receipt_contains_only_metadata_keys(self):
_, receipt, _ = self.run_child()
_, receipt = self.run_child()
self.assertTrue(set(receipt) <= {
'schema', 'ccr', 'status', 'phase', 'started_at', 'finished_at', 'credential_values_emitted',
'client_side_read_admitted', 'kv_written', 'consumer_written', 'approval_decision_id', 'ccr_sha256',
'kv_version', 'kv_request_id', 'kv_cas', 'consumer_check_ok', 'retry_of_kv_version',
'consumer_temp_removed', 'error'})
'client_side_read_admitted', 'kv_written', 'approval_decision_id', 'ccr_sha256',
'kv_version', 'kv_request_id', 'kv_cas', 'kv_metadata', 'error'})
self.assertFalse(any('consumer' in key for key in receipt))
if __name__ == '__main__':

View file

@ -769,14 +769,19 @@ id: RPF-WP-0035-T09
status: wait
needs_human: true
priority: high
depends_on: [SECRETS-WP-0012-T02]
blocking_reason: "Founder must approve proposed CCR-2026-0033 (platform-owner, key-cape-owner, net-kingdom-owner reviews; bound claims confirmed) and the exact attended custody child run with a decision id recorded in the CCR review. Boundary decision 632a8893 does not cover generation, write or delivery. secrets-engine D1-D4 resolve first."
depends_on: []
blocking_reason: "Waits for founder approvals: A1 ops-mason design (keycape-secrets-engine-openbao-client-custody, MASON-WP-0009); A2 revised CCR-2026-0033 plus sibling operator reader CCR-2026-0034 (platform-owner, key-cape-owner, net-kingdom-owner reviews; bound claims confirmed for both readers incl. groups=net-kingdom-admins and /assurance/level=aal2); A3 the exact custody-child decision id recorded in the CCR-2026-0033 review (not boundary decision 632a8893). secrets-engine D1-D4 are superseded (no consumer copy)."
state_hub_task_id: "7c753be6-42f1-58a6-a4a4-a7ed686f461f"
```
Packet: `credential-change-requests/CCR-2026-0033.yaml` (custody_child,
consumer_copy, live_order, rollback). Run after approval, attended:
`python3 scripts/keycape_secrets_engine_openbao_custody.py provision
--approval-decision <id> --receipt <new path>` inside the warden platform-admin
envelope. Later live steps (ESO, NetKingdom patch, registration, restart,
KEY-WP-0037-T04 issuance, T02 JWT login proof) each need their own go.
Packets: `credential-change-requests/CCR-2026-0033.yaml` (custody child, KV
metadata, issuer ESO reader, live_order, rollback) and `CCR-2026-0034.yaml`
(attended operator OIDC reader, in-memory, 5m/4 uses). Revised 2026-10-06 per
founder direction; the consumer file is removed. Exact founder commands:
`docs/provisioning-windows/2026-10-04-1000/keycape/founder-runbook.md`. Live
order: custody child → platform applies policies/roles/ESO (Secret
sso/keycape-secrets-engine-openbao-client exists) → NetKingdom env patch
(NK-WP-0046) → KeyCape registration merge (KEY-WP-0037-T03) → KeyCape restart
immediately → live issuance check (KEY-WP-0037-T04) → OpenBao JWT role/login
proof (RPF-WP-0035-T02). Each live step after the custody run needs its own go.