From 4a284f0a3b80558e4cf2c1d28dcdb4e36ca555b2 Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 6 Oct 2026 14:26:42 +0200 Subject: [PATCH] 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=; 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 Assistant: claude-code Assistant-Model: opus Assistant-Process: 132360@bnt-lap001 Assistant-Session: 769b1afd-4330-4c86-96b2-e510e26db42b --- WORK-RECORDS.md | 1 + credential-change-requests/CCR-2026-0033.yaml | 181 +++++++++-------- credential-change-requests/CCR-2026-0034.yaml | 135 +++++++++++++ .../dependency-requirements.md | 56 ++++-- .../keycape/founder-runbook.md | 155 +++++++++++++++ ...eycape-secrets-engine-openbao-operator.hcl | 7 + scripts/credential-change.py | 57 +++++- .../keycape_secrets_engine_openbao_custody.py | 181 +++++++---------- tests/test_credential_change.py | 59 ++++++ ..._keycape_secrets_engine_openbao_custody.py | 188 ++++++++++-------- ...-WP-0035-credential-lane-implementation.md | 21 +- 11 files changed, 734 insertions(+), 307 deletions(-) create mode 100644 credential-change-requests/CCR-2026-0034.yaml create mode 100644 docs/provisioning-windows/2026-10-04-1000/keycape/founder-runbook.md create mode 100644 openbao/policies/workload-kv-read-keycape-secrets-engine-openbao-operator.hcl diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 9330688..3b5f292 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/credential-change-requests/CCR-2026-0033.yaml b/credential-change-requests/CCR-2026-0033.yaml index 982088d..5319de9 100644 --- a/credential-change-requests/CCR-2026-0033.yaml +++ b/credential-change-requests/CCR-2026-0033.yaml @@ -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= 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 ; 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 ; 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= (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 - --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=; 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=); 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= + (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=), 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) diff --git a/credential-change-requests/CCR-2026-0034.yaml b/credential-change-requests/CCR-2026-0034.yaml new file mode 100644 index 0000000..3ed74d4 --- /dev/null +++ b/credential-change-requests/CCR-2026-0034.yaml @@ -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=). + 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) diff --git a/docs/provisioning-windows/2026-10-04-1000/dependency-requirements.md b/docs/provisioning-windows/2026-10-04-1000/dependency-requirements.md index 7d3c4cf..d6df222 100644 --- a/docs/provisioning-windows/2026-10-04-1000/dependency-requirements.md +++ b/docs/provisioning-windows/2026-10-04-1000/dependency-requirements.md @@ -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=` 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 diff --git a/docs/provisioning-windows/2026-10-04-1000/keycape/founder-runbook.md b/docs/provisioning-windows/2026-10-04-1000/keycape/founder-runbook.md new file mode 100644 index 0000000..8e07473 --- /dev/null +++ b/docs/provisioning-windows/2026-10-04-1000/keycape/founder-runbook.md @@ -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 ``. + +## 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 \ + --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=`) +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": "", "ccr_sha256": "", + "kv_written": true, "kv_version": 1, "kv_cas": 0, "kv_request_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. diff --git a/openbao/policies/workload-kv-read-keycape-secrets-engine-openbao-operator.hcl b/openbao/policies/workload-kv-read-keycape-secrets-engine-openbao-operator.hcl new file mode 100644 index 0000000..d5e643b --- /dev/null +++ b/openbao/policies/workload-kv-read-keycape-secrets-engine-openbao-operator.hcl @@ -0,0 +1,7 @@ +path "platform/data/workloads/keycape/secrets-engine-openbao" { + capabilities = ["read"] +} + +path "auth/token/revoke-self" { + capabilities = ["update"] +} diff --git a/scripts/credential-change.py b/scripts/credential-change.py index 05d0ba8..1fe9c85 100755 --- a/scripts/credential-change.py +++ b/scripts/credential-change.py @@ -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 diff --git a/scripts/keycape_secrets_engine_openbao_custody.py b/scripts/keycape_secrets_engine_openbao_custody.py index 15f9bd1..f24b6cf 100644 --- a/scripts/keycape_secrets_engine_openbao_custody.py +++ b/scripts/keycape_secrets_engine_openbao_custody.py @@ -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= 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=') 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) diff --git a/tests/test_credential_change.py b/tests/test_credential_change.py index 24d4adb..00fe480 100644 --- a/tests/test_credential_change.py +++ b/tests/test_credential_change.py @@ -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)})()) diff --git a/tests/test_keycape_secrets_engine_openbao_custody.py b/tests/test_keycape_secrets_engine_openbao_custody.py index 73117eb..b7856c5 100644 --- a/tests/test_keycape_secrets_engine_openbao_custody.py +++ b/tests/test_keycape_secrets_engine_openbao_custody.py @@ -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__': diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md index 9d090b0..94c2ee2 100644 --- a/workplans/RPF-WP-0035-credential-lane-implementation.md +++ b/workplans/RPF-WP-0035-credential-lane-implementation.md @@ -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 --receipt ` 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.