Revise KeyCape secrets-engine-openbao custody: OpenBao-only, operator reader CCR-2026-0034 (source only)
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:
parent
9e23eaa067
commit
4a284f0a3b
11 changed files with 734 additions and 307 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
135
credential-change-requests/CCR-2026-0034.yaml
Normal file
135
credential-change-requests/CCR-2026-0034.yaml
Normal 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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
path "platform/data/workloads/keycape/secrets-engine-openbao" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
path "auth/token/revoke-self" {
|
||||
capabilities = ["update"]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)})())
|
||||
|
|
|
|||
|
|
@ -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__':
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue