From b7861de20e9867a018aa6ac2a47369231b8067e6 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 9 Sep 2026 01:32:45 +0200 Subject: [PATCH] feat: admit and exercise KeyCape verifier custody activation Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- ...eycape-secrets-engine-approval-client.yaml | 180 +++++----- ...ycape-approval-engine-operator-client.yaml | 130 ++++--- .../keycape-approval-clients-review.md | 33 +- ...2026-09-09-keycape-approval-admission.json | 36 ++ openbao/policies/agent-high-risk-boundary.hcl | 6 + scripts/keycape_approval_custody.py | 331 ++++++++++++++++++ tests/test_keycape_approval_custody.py | 114 ++++++ ...-WP-0035-credential-lane-implementation.md | 16 +- 8 files changed, 662 insertions(+), 184 deletions(-) create mode 100644 docs/evidence/2026-09-09-keycape-approval-admission.json create mode 100644 scripts/keycape_approval_custody.py create mode 100644 tests/test_keycape_approval_custody.py diff --git a/credential-change-requests/CCR-2026-0017-keycape-secrets-engine-approval-client.yaml b/credential-change-requests/CCR-2026-0017-keycape-secrets-engine-approval-client.yaml index 997e06a..4577092 100644 --- a/credential-change-requests/CCR-2026-0017-keycape-secrets-engine-approval-client.yaml +++ b/credential-change-requests/CCR-2026-0017-keycape-secrets-engine-approval-client.yaml @@ -3,22 +3,21 @@ kind: credential-change-request schema_version: 1 request_type: workload-kv-read title: KeyCape verifier custody for the secrets-engine-approval confidential client -status: proposed +status: approved created: '2026-09-08' -updated: '2026-09-09' +updated: '2026-09-08' requester: agent: claude - reason: >- - KEY-WP-0013-T02 (State Hub message 278a3ebe-b529-49f6-bd1a-e3ebcf318260) asks - railiance-platform to admit custody for two confidential client_credentials - registrations. This CCR covers the first: client secrets-engine-approval, - subject service:secrets-engine, audience approval-engine, tenant:platform per - resolved decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6, 15m token lifetime. - KeyCape owns the registration and issuance; this request establishes only the - custody path and the KeyCape-side delivery of the client secret it must verify - presented credentials against. It does not admit the client-side lane by which - secrets-engine would read its own copy — that is a separate request with a - separate consumer gate. + reason: "KEY-WP-0013-T02 (State Hub message 278a3ebe-b529-49f6-bd1a-e3ebcf318260)\ + \ asks railiance-platform to admit custody for two confidential client_credentials\ + \ registrations. This CCR covers the first: client secrets-engine-approval, subject\ + \ service:secrets-engine, audience approval-engine, tenant:platform per resolved\ + \ decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6, 15m token lifetime. KeyCape owns\ + \ the registration and issuance; this request establishes only the custody path\ + \ and the KeyCape-side delivery of the client secret it must verify presented\ + \ credentials against. It does not admit the client-side lane by which secrets-engine\ + \ would read its own copy \u2014 that is a separate request with a separate consumer\ + \ gate." review: required: true required_approvers: @@ -28,25 +27,36 @@ review: - at: '2026-09-08' reviewer: railiance-platform (codex/claude) decision: paths_confirmed_field_corrected - comment: >- - KV path platform/workloads/secrets-engine/approval-client is confirmed - unchanged: it matches the platform/workloads// convention - enforced by scripts/credential-change.py and used by every admitted platform - lane. The proposed field name client_secret is corrected to CLIENT_SECRET; - KV field names are uppercase by convention (PROVISIONER_TOKEN, - CORE_HUB_API_TOKEN) and lowercase names fail FIELD_NAME_RE validation. The - Kubernetes Secret key client-secret and the KeyCape environment name are - confirmed as proposed. + comment: 'KV path platform/workloads/secrets-engine/approval-client is confirmed + unchanged: it matches the platform/workloads// convention enforced + by scripts/credential-change.py and used by every admitted platform lane. The + proposed field name client_secret is corrected to CLIENT_SECRET; KV field names + are uppercase by convention (PROVISIONER_TOKEN, CORE_HUB_API_TOKEN) and lowercase + names fail FIELD_NAME_RE validation. The Kubernetes Secret key client-secret + and the KeyCape environment name are confirmed as proposed.' + - at: '2026-09-08T23:05:19+00:00' + reviewer: User (platform-operator; explicit session approval) + decision: approved + comment: User answered "I approve, go on." to the explicit two-CCR approval question + naming both required roles. Scope remains verifier-side custody and its attended + rollout, as reviewed in docs/credential-lane-designs/keycape-approval-clients-review.md. + No client-side read or factory spending grant. + - at: '2026-09-08T23:05:19+00:00' + reviewer: User (key-cape-owner; explicit session approval) + decision: approved + comment: User answered "I approve, go on." to the explicit two-CCR approval question + naming both required roles. Scope remains verifier-side custody and its attended + rollout, as reviewed in docs/credential-lane-designs/keycape-approval-clients-review.md. + No client-side read or factory spending grant. target: domain: financials tenant: platform workload: secrets-engine environment: production - purpose: >- - Hold the secrets-engine-approval confidential client secret in platform custody - and project it into the KeyCape runtime so KeyCape can verify presented - client_credentials without the value living in Git, a chart value, or a - hand-created Kubernetes Secret. + purpose: Hold the secrets-engine-approval confidential client secret in platform + custody and project it into the KeyCape runtime so KeyCape can verify presented + client_credentials without the value living in Git, a chart value, or a hand-created + Kubernetes Secret. openbao: mount: platform kv_path: platform/workloads/secrets-engine/approval-client @@ -76,94 +86,72 @@ access_frontdoor: readiness: pending-review delivery: surface: external-secrets - target: >- - ClusterSecretStore openbao-keycape-secrets-engine-approval, limited to + target: 'ClusterSecretStore openbao-keycape-secrets-engine-approval, limited to namespace sso, to ExternalSecret sso/keycape-secrets-engine-approval-client and - Secret sso/keycape-secrets-engine-approval-client with key client-secret. - KeyCape resolves it as KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET through a - secretKeyRef, matching the live KEYCAPE_RAPP_QONTO_CLIENT_SECRET shape observed - on image main-153258b. Manifests: - argocd/platform-addons/openbao-secretstore/openbao-keycape-approval-clients.clustersecretstore.yaml - and keycape-approval-clients.externalsecrets.yaml. + Secret sso/keycape-secrets-engine-approval-client with key client-secret. KeyCape + resolves it as KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET through a secretKeyRef, + matching the live KEYCAPE_RAPP_QONTO_CLIENT_SECRET shape observed on image main-153258b. + Manifests: argocd/platform-addons/openbao-secretstore/openbao-keycape-approval-clients.clustersecretstore.yaml + and keycape-approval-clients.externalsecrets.yaml.' risk: classification: high notes: - - >- - This is a verifier-side copy. KeyCape holding the client secret lets it - authenticate the client; it does not let KeyCape act as the client, but a leak - of this value allows anyone to present as secrets-engine for approval:read and - approval:consume until the registration is disabled. - - >- - Custody path is under the client's own workload prefix, not KeyCape's, because - the credential belongs to the secrets-engine client identity. The reader - admitted here is the External Secrets Operator on behalf of the sso namespace - only; a future secrets-engine-side read is a separate lane and separate policy. - - >- - The existing keycape-rapp-qonto-client Secret is hand-created and not - ESO-managed. Do not extend this store or ExternalSecret to adopt it; that is a - distinct migration with its own owner. - - >- - The two approval clients are deliberately kept on separate policies, roles and + - This is a verifier-side copy. KeyCape holding the client secret lets it authenticate + the client; it does not let KeyCape act as the client, but a leak of this value + allows anyone to present as secrets-engine for approval:read and approval:consume + until the registration is disabled. + - Custody path is under the client's own workload prefix, not KeyCape's, because + the credential belongs to the secrets-engine client identity. The reader admitted + here is the External Secrets Operator on behalf of the sso namespace only; a future + secrets-engine-side read is a separate lane and separate policy. + - The existing keycape-rapp-qonto-client Secret is hand-created and not ESO-managed. + Do not extend this store or ExternalSecret to adopt it; that is a distinct migration + with its own owner. + - The two approval clients are deliberately kept on separate policies, roles and stores so the operator client can be revoked without disturbing this one. Their scope sets differ; a shared policy would erase that boundary. - - >- - Compromise response is KeyCape disabling the client registration plus rotation - of this KV version. An OpenBao token expiry does not invalidate an already - issued client secret. + - Compromise response is KeyCape disabling the client registration plus rotation + of this KV version. An OpenBao token expiry does not invalidate an already issued + client secret. verification: positive: - - >- - The ExternalSecret in namespace sso syncs CLIENT_SECRET to Secret key - client-secret without printing the value. - - >- - The KeyCape build that reads KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET - starts and issues a token for subject service:secrets-engine with audience - approval-engine, tenant:platform, 15m lifetime, verified against live JWKS - signature. + - The ExternalSecret in namespace sso syncs CLIENT_SECRET to Secret key client-secret + without printing the value. + - The KeyCape build that reads KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET starts + and issues a token for subject service:secrets-engine with audience approval-engine, + tenant:platform, 15m lifetime, verified against live JWKS signature. - Exact claim bindings are asserted by KeyCape without any value or token logged. negative: - - >- - A namespace outside the approved ClusterSecretStore condition cannot use this + - A namespace outside the approved ClusterSecretStore condition cannot use this store to read the path. - - >- - A service account outside external-secrets/external-secrets cannot authenticate + - A service account outside external-secrets/external-secrets cannot authenticate through role external-secrets-keycape-secrets-engine-approval. - - >- - The role cannot read the sibling approval-engine operator-client path, any - parent listing, or any other platform workload path. - - >- - The secrets-engine-approval client is denied scopes outside approval:read and + - The role cannot read the sibling approval-engine operator-client path, any parent + listing, or any other platform workload path. + - The secrets-engine-approval client is denied scopes outside approval:read and approval:consume, and human identities are denied approval:consume. activation_conditions: - - >- - KeyCape and railiance-platform agree the single attended rollout window in - docs/credential-lane-designs/keycape-approval-clients.md; the KeyCape image that - reads both environment names is built and pinned but not yet deployed. - - >- - Attended first provision runs only through the governed - openbao-platform-admin-login lane (founder_required, attended OIDC via - netkingdom role=platform-admin), with a unique receipt path and no value in - chat, command arguments or shell history. - - >- - Policy, Kubernetes auth role and ClusterSecretStore applied before the - ExternalSecret; sync confirmed before the KeyCape image is rolled out. - - >- - Positive and negative results recorded with non-secret request ids or - timestamps. + - KeyCape and railiance-platform agree the single attended rollout window in docs/credential-lane-designs/keycape-approval-clients.md; + the KeyCape image that reads both environment names is built and pinned but not + yet deployed. + - Attended first provision runs only through the governed openbao-platform-admin-login + lane (founder_required, attended OIDC via netkingdom role=platform-admin), with + a unique receipt path and no value in chat, command arguments or shell history. + - Policy, Kubernetes auth role and ClusterSecretStore applied before the ExternalSecret; + sync confirmed before the KeyCape image is rolled out. + - Positive and negative results recorded with non-secret request ids or timestamps. evidence: [] lifecycle: - deactivate: >- - KeyCape disables the secrets-engine-approval registration; platform detaches - the policy from role external-secrets-keycape-secrets-engine-approval and - removes the ExternalSecret. The KV version is retained until KeyCape confirms + deactivate: KeyCape disables the secrets-engine-approval registration; platform + detaches the policy from role external-secrets-keycape-secrets-engine-approval + and removes the ExternalSecret. The KV version is retained until KeyCape confirms disablement. - rotate: >- - KeyCape mints a replacement client secret; platform writes the new KV version + rotate: KeyCape mints a replacement client secret; platform writes the new KV version under the same attended authority; ESO refresh delivers it and KeyCape restarts or re-reads. Rotation is independent of the operator client. - compromised: >- - Disable the registration at KeyCape first (that is what stops token issuance), - then rotate the KV version and confirm no other namespace consumed the store. + compromised: Disable the registration at KeyCape first (that is what stops token + issuance), then rotate the KV version and confirm no other namespace consumed + the store. state_hub: decision_id: b533a271-b704-4c5c-98a2-9a5951aadfb6 decision_api_url: http://127.0.0.1:8000/decisions/b533a271-b704-4c5c-98a2-9a5951aadfb6 diff --git a/credential-change-requests/CCR-2026-0018-keycape-approval-engine-operator-client.yaml b/credential-change-requests/CCR-2026-0018-keycape-approval-engine-operator-client.yaml index 3acc0a6..8692580 100644 --- a/credential-change-requests/CCR-2026-0018-keycape-approval-engine-operator-client.yaml +++ b/credential-change-requests/CCR-2026-0018-keycape-approval-engine-operator-client.yaml @@ -3,19 +3,17 @@ kind: credential-change-request schema_version: 1 request_type: workload-kv-read title: KeyCape verifier custody for the approval-engine-operator confidential client -status: proposed +status: approved created: '2026-09-08' -updated: '2026-09-09' +updated: '2026-09-08' requester: agent: claude - reason: >- - Second of the two registrations in KEY-WP-0013-T02 (State Hub message - 278a3ebe-b529-49f6-bd1a-e3ebcf318260): client approval-engine-operator, subject - service:approval-engine-operator, audience approval-engine, tenant:platform per - resolved decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6, 15m token lifetime. - Scopes are approval:create, read, approve, revoke, supersede, observe and emit, - explicitly without approval:consume. KeyCape owns registration and issuance; - this request establishes only custody and the KeyCape-side delivery. + reason: 'Second of the two registrations in KEY-WP-0013-T02 (State Hub message 278a3ebe-b529-49f6-bd1a-e3ebcf318260): + client approval-engine-operator, subject service:approval-engine-operator, audience + approval-engine, tenant:platform per resolved decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6, + 15m token lifetime. Scopes are approval:create, read, approve, revoke, supersede, + observe and emit, explicitly without approval:consume. KeyCape owns registration + and issuance; this request establishes only custody and the KeyCape-side delivery.' review: required: true required_approvers: @@ -25,20 +23,31 @@ review: - at: '2026-09-08' reviewer: railiance-platform (codex/claude) decision: paths_confirmed_field_corrected - comment: >- - KV path platform/workloads/approval-engine/operator-client is confirmed - unchanged and conforms to the platform/workloads// - convention. Field client_secret is corrected to CLIENT_SECRET for the same - uppercase convention and validator constraint as CCR-2026-0017. Kubernetes - Secret sso/keycape-approval-engine-operator-client with key client-secret and - the KeyCape environment name are confirmed as proposed. + comment: KV path platform/workloads/approval-engine/operator-client is confirmed + unchanged and conforms to the platform/workloads// convention. + Field client_secret is corrected to CLIENT_SECRET for the same uppercase convention + and validator constraint as CCR-2026-0017. Kubernetes Secret sso/keycape-approval-engine-operator-client + with key client-secret and the KeyCape environment name are confirmed as proposed. + - at: '2026-09-08T23:05:20+00:00' + reviewer: User (platform-operator; explicit session approval) + decision: approved + comment: User answered "I approve, go on." to the explicit two-CCR approval question + naming both required roles. Scope remains verifier-side custody and its attended + rollout, as reviewed in docs/credential-lane-designs/keycape-approval-clients-review.md. + No client-side read or factory spending grant. + - at: '2026-09-08T23:05:20+00:00' + reviewer: User (key-cape-owner; explicit session approval) + decision: approved + comment: User answered "I approve, go on." to the explicit two-CCR approval question + naming both required roles. Scope remains verifier-side custody and its attended + rollout, as reviewed in docs/credential-lane-designs/keycape-approval-clients-review.md. + No client-side read or factory spending grant. target: domain: financials tenant: platform workload: approval-engine environment: production - purpose: >- - Hold the approval-engine-operator confidential client secret in platform + purpose: Hold the approval-engine-operator confidential client secret in platform custody and project it into the KeyCape runtime so KeyCape can verify presented client_credentials for the operator client. openbao: @@ -70,77 +79,58 @@ access_frontdoor: readiness: pending-review delivery: surface: external-secrets - target: >- - ClusterSecretStore openbao-keycape-approval-engine-operator, limited to + target: 'ClusterSecretStore openbao-keycape-approval-engine-operator, limited to namespace sso, to ExternalSecret sso/keycape-approval-engine-operator-client and - Secret sso/keycape-approval-engine-operator-client with key client-secret. - KeyCape resolves it as KEYCAPE_APPROVAL_ENGINE_OPERATOR_CLIENT_SECRET through a - secretKeyRef. Manifests: - argocd/platform-addons/openbao-secretstore/openbao-keycape-approval-clients.clustersecretstore.yaml - and keycape-approval-clients.externalsecrets.yaml. + Secret sso/keycape-approval-engine-operator-client with key client-secret. KeyCape + resolves it as KEYCAPE_APPROVAL_ENGINE_OPERATOR_CLIENT_SECRET through a secretKeyRef. + Manifests: argocd/platform-addons/openbao-secretstore/openbao-keycape-approval-clients.clustersecretstore.yaml + and keycape-approval-clients.externalsecrets.yaml.' risk: classification: high notes: - - >- - This client carries the widest approval scope set in the pair — create, - approve, revoke and supersede. A leak allows forging approval lifecycle actions, - which is a stronger outcome than the read/consume client in CCR-2026-0017. - - >- - The absence of approval:consume is a deliberate separation of duties. Any later + - "This client carries the widest approval scope set in the pair \u2014 create,\ + \ approve, revoke and supersede. A leak allows forging approval lifecycle actions,\ + \ which is a stronger outcome than the read/consume client in CCR-2026-0017." + - The absence of approval:consume is a deliberate separation of duties. Any later request to add consume to this client is a new lane decision, not a scope edit. - - >- - Kept on its own policy, role and store so it can be revoked independently of the + - Kept on its own policy, role and store so it can be revoked independently of the secrets-engine-approval client. - - >- - Compromise response is KeyCape disabling the registration plus rotation of this + - Compromise response is KeyCape disabling the registration plus rotation of this KV version; approval actions already emitted are not retracted by rotation and need approval-engine's own audit review. verification: positive: - - >- - The ExternalSecret in namespace sso syncs CLIENT_SECRET to Secret key - client-secret without printing the value. - - >- - The KeyCape build issues a token for subject service:approval-engine-operator - with audience approval-engine, tenant:platform and 15m lifetime, verified - against live JWKS signature. + - The ExternalSecret in namespace sso syncs CLIENT_SECRET to Secret key client-secret + without printing the value. + - The KeyCape build issues a token for subject service:approval-engine-operator + with audience approval-engine, tenant:platform and 15m lifetime, verified against + live JWKS signature. negative: - - >- - A namespace outside the approved ClusterSecretStore condition cannot use this + - A namespace outside the approved ClusterSecretStore condition cannot use this store to read the path. - - >- - A service account outside external-secrets/external-secrets cannot authenticate + - A service account outside external-secrets/external-secrets cannot authenticate through role external-secrets-keycape-approval-engine-operator. - - >- - The role cannot read the sibling secrets-engine approval-client path, any parent + - The role cannot read the sibling secrets-engine approval-client path, any parent listing, or any other platform workload path. - - >- - The operator client is denied approval:consume, and denial is observed rather + - The operator client is denied approval:consume, and denial is observed rather than assumed. activation_conditions: - - >- - Same single attended rollout window and prepared-but-undeployed KeyCape image as - CCR-2026-0017; see docs/credential-lane-designs/keycape-approval-clients.md. - - >- - Attended first provision only through openbao-platform-admin-login - (founder_required, attended OIDC via netkingdom role=platform-admin) with a - unique receipt path. - - >- - Policy, Kubernetes auth role and ClusterSecretStore applied before the - ExternalSecret; sync confirmed before the KeyCape rollout. + - Same single attended rollout window and prepared-but-undeployed KeyCape image + as CCR-2026-0017; see docs/credential-lane-designs/keycape-approval-clients.md. + - Attended first provision only through openbao-platform-admin-login (founder_required, + attended OIDC via netkingdom role=platform-admin) with a unique receipt path. + - Policy, Kubernetes auth role and ClusterSecretStore applied before the ExternalSecret; + sync confirmed before the KeyCape rollout. - Positive and negative results recorded with non-secret request ids or timestamps. evidence: [] lifecycle: - deactivate: >- - KeyCape disables the approval-engine-operator registration; platform detaches the - policy from role external-secrets-keycape-approval-engine-operator and removes - the ExternalSecret. - rotate: >- - KeyCape mints a replacement client secret; platform writes the new KV version + deactivate: KeyCape disables the approval-engine-operator registration; platform + detaches the policy from role external-secrets-keycape-approval-engine-operator + and removes the ExternalSecret. + rotate: KeyCape mints a replacement client secret; platform writes the new KV version under the same attended authority. Rotation is independent of CCR-2026-0017. - compromised: >- - Disable the registration at KeyCape, rotate the KV version, and refer emitted - approval actions to approval-engine for audit review. + compromised: Disable the registration at KeyCape, rotate the KV version, and refer + emitted approval actions to approval-engine for audit review. state_hub: decision_id: efa90517-0cae-4eb6-a68d-5b0489c84d65 decision_api_url: http://127.0.0.1:8000/decisions/efa90517-0cae-4eb6-a68d-5b0489c84d65 diff --git a/docs/credential-lane-designs/keycape-approval-clients-review.md b/docs/credential-lane-designs/keycape-approval-clients-review.md index 9124d9d..8202cf3 100644 --- a/docs/credential-lane-designs/keycape-approval-clients-review.md +++ b/docs/credential-lane-designs/keycape-approval-clients-review.md @@ -1,21 +1,22 @@ # KeyCape approval-client custody: review packet -Prepared 2026-09-09 by the-custodian. Both requests are **proposed**; no named -approval or custody mutation has been recorded. This is the review gate for +Prepared 2026-09-09 by the-custodian. Both requests are **approved** by the user as platform operator and KeyCape +owner, explicitly recorded on 2026-09-09. The admission receipt is +[here](../evidence/2026-09-09-keycape-approval-admission.json). This is the review record for RPF-WP-0035-T05, consumed by HFACT-WP-0001-T03. -| Request | Secret path and field | Client authority | Pending decision | +| Request | Secret path and field | Client authority | Resolved decision | | --- | --- | --- | --- | | [CCR-2026-0017](../../credential-change-requests/CCR-2026-0017-keycape-secrets-engine-approval-client.yaml) | `platform/workloads/secrets-engine/approval-client`, `CLIENT_SECRET` | `approval:read`, `approval:consume` | `b533a271-b704-4c5c-98a2-9a5951aadfb6` | | [CCR-2026-0018](../../credential-change-requests/CCR-2026-0018-keycape-approval-engine-operator-client.yaml) | `platform/workloads/approval-engine/operator-client`, `CLIENT_SECRET` | create/read/approve/revoke/supersede/observe/emit; no consume | `efa90517-0cae-4eb6-a68d-5b0489c84d65` | -Both require **platform-operator and key-cape-owner** review. Each has its own +Both named **platform-operator and key-cape-owner** reviews are approved. Each has its own exact-path policy, Kubernetes auth role and ClusterSecretStore, bound to `external-secrets/external-secrets`, limited to `sso`. ESO owns the resulting Secret; authentication tokens have a 15-minute TTL. Client secrets require explicit rotation or registration disablement; token expiry does not revoke them. -The approved object would authorize verifier-side custody only. Client-side +The approved requests authorize verifier-side custody only. Client-side retrieval, audit sender/receiver custody, operator `approval:consume`, adoption of the Qonto Secret and factory spending remain outside these two requests. @@ -29,14 +30,16 @@ review, not substitutes for either named approval. Execution still follows the [owner rollout sequence](keycape-approval-clients.md) through the founder-attended Warden/OpenBao envelope: policy/roles, CAS=0 initial custody, Valid stores, SecretSynced delivery, compatible config/image replacement, -then positive and negative acceptance and metadata-only receipts. The generated -plan leaves initial secret provisioning to the contained owner procedure; it is -not an already exercised end-to-end activation command. Finish and exercise that -procedure before opening the activation window. Current proposed requests refuse -`apply-plan`; no status has been changed to get around that refusal. +then positive and negative acceptance and metadata-only receipts. The contained owner command is now `scripts/keycape_approval_custody.py activate`. +It preflights both config and deployment changes, seeds with CAS=0, verifies native +and namespace boundaries, then calls NetKingdom's contained compatible rollout. +A failure restores the prior config/image pair before detaching verifier delivery; +initial KV versions remain in custody. The four new exact data/metadata deny +stanzas extend the coding-agent boundary without changing any grant. -Review outcome: approve, deny or needs changes, naming the reviewer and the role -they are accepting. Both required role confirmations must be recorded for each -CCR before execution. A generic CLI status change alone is insufficient evidence -that both reviews occurred. No owner messages or browser login were triggered -by preparation of this packet. +Validation: six checks against a disposable local OpenBao, eight config/rollback +tests, and real HTTP signature/claim/scope acceptance against the pinned KeyCape +image with synthetic keys. Production in-pod CLI and existing-human-login checks +remain part of the live window. The image exercise omits the in-pod CLI explicitly. +Use a unique private receipt and protected owner recovery path; a fresh attended +login after the rollout establishes the existing human path on the new build. diff --git a/docs/evidence/2026-09-09-keycape-approval-admission.json b/docs/evidence/2026-09-09-keycape-approval-admission.json new file mode 100644 index 0000000..2183e62 --- /dev/null +++ b/docs/evidence/2026-09-09-keycape-approval-admission.json @@ -0,0 +1,36 @@ +{ + "schema": "railiance.keycape-custody-user-approval.v1", + "recorded_at": "2026-09-08T23:05:19.886216+00:00", + "user_response": "I approve, go on.", + "approved_question": "Do you approve CCR-2026-0017 and CCR-2026-0018, as platform operator and KeyCape owner, for the verifier-side credential delivery described in the review packet?", + "review_packet_revision": "52b24eab9a8aff3396e71b2296d0240a44f3b0db", + "roles": [ + "platform-operator", + "key-cape-owner" + ], + "scope": "Two verifier-side KeyCape client credential custody requests, including their governed attended provisioning and compatible rollout sequence.", + "client_side_read_authorized": false, + "factory_spending_authorized": false, + "requests": [ + { + "id": "CCR-2026-0017", + "decision_id": "b533a271-b704-4c5c-98a2-9a5951aadfb6", + "status": "approved", + "decision_status": "resolved", + "reviewed_roles": [ + "platform-operator", + "key-cape-owner" + ] + }, + { + "id": "CCR-2026-0018", + "decision_id": "efa90517-0cae-4eb6-a68d-5b0489c84d65", + "status": "approved", + "decision_status": "resolved", + "reviewed_roles": [ + "platform-operator", + "key-cape-owner" + ] + } + ] +} diff --git a/openbao/policies/agent-high-risk-boundary.hcl b/openbao/policies/agent-high-risk-boundary.hcl index 18c532a..926962e 100644 --- a/openbao/policies/agent-high-risk-boundary.hcl +++ b/openbao/policies/agent-high-risk-boundary.hcl @@ -123,3 +123,9 @@ path "platform/data/workloads/telemetry/grafana-admin" { path "platform/metadata/workloads/telemetry/grafana-admin" { capabilities = ["deny"] } + +# Approved verifier custody: CCR-2026-0017 / CCR-2026-0018. +path "platform/data/workloads/secrets-engine/approval-client" { capabilities = ["deny"] } +path "platform/data/workloads/approval-engine/operator-client" { capabilities = ["deny"] } +path "platform/metadata/workloads/secrets-engine/approval-client" { capabilities = ["deny"] } +path "platform/metadata/workloads/approval-engine/operator-client" { capabilities = ["deny"] } diff --git a/scripts/keycape_approval_custody.py b/scripts/keycape_approval_custody.py new file mode 100644 index 0000000..a367d05 --- /dev/null +++ b/scripts/keycape_approval_custody.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""Silent, attended first provision and verification of CCR-2026-0017/0018.""" +from __future__ import annotations +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import secrets +import time +from datetime import datetime, timezone +import yaml + +from state_hub_preflight_lane import ROOT, LaneError, assert_cluster, bao, command, data, revoke + +LANES = ( + ('CCR-2026-0017', 'secrets-engine', 'approval-client', 'keycape-secrets-engine-approval'), + ('CCR-2026-0018', 'approval-engine', 'operator-client', 'keycape-approval-engine-operator'), +) +BOUNDARY = 'sys/policies/acl/agent-high-risk-boundary' + + +def require(condition, reason): + if not condition: + raise LaneError(reason) + + +def contracts(): + 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) + result = [] + for ref, workload, purpose, suffix in LANES: + path = module.resolve_ccr(ref) + ccr, errors, _ = module.validate_ccr(path) + require(not errors and ccr['status'] in {'approved', 'applied', 'verified', 'active'}, 'approved_ccrs_required') + for role in ('platform-operator', 'key-cape-owner'): + require(any(x.get('decision') == 'approved' and role in x.get('reviewer', '') + and 'explicit session approval' in x.get('reviewer', '') + for x in ccr['review']['comments']), 'both_named_reviews_required') + expected_path = f'platform/workloads/{workload}/{purpose}' + policy = 'workload-kv-read-' + suffix + role = 'external-secrets-' + suffix + expected_role = {'bound_service_account_names': ['external-secrets'], + 'bound_service_account_namespaces': ['external-secrets'], + 'policies': policy, 'ttl': '15m'} + require(ccr['openbao']['kv_path'] == expected_path and ccr['openbao']['fields'] == ['CLIENT_SECRET'] + and ccr['openbao']['policy_name'] == policy + and ccr['openbao']['auth']['role'] == role + and ccr['openbao']['auth']['mount'] == 'kubernetes' + and ccr['openbao']['auth']['bound_claims_confirmed'] is True + and module.auth_payload(ccr) == expected_role, 'exact_approved_contract_required') + hcl = module.generated_policy_hcl(ccr) + require((ROOT / ccr['openbao']['policy_file']).read_text() == hcl, 'policy_source_drift') + result.append({'ccr': ref, 'kv': expected_path.replace('platform/', 'platform/data/', 1), + 'metadata': expected_path.replace('platform/', 'platform/metadata/', 1), + 'policy': policy, 'hcl': hcl, 'role': role, 'role_payload': expected_role, + 'store': 'openbao-' + suffix, 'secret': suffix + '-client', + 'source_sha256': hashlib.sha256(path.read_bytes()).hexdigest()}) + return result + + +def read_optional(path): + result = bao(['read', '-format=json', path], allow_failure=True) + if result.returncode == 0: + return data(result)['data'] + require(b'404' in result.stderr or b'No value found' in result.stderr, 'absence_not_proven') + return None + + +def role_matches(actual, lane): + return (actual.get('bound_service_account_names') == ['external-secrets'] + and actual.get('bound_service_account_namespaces') == ['external-secrets'] + and not actual.get('bound_service_account_namespace_selector') + and actual.get('token_policies') == [lane['policy']] + and actual.get('token_ttl') == 900 and not actual.get('token_period') + and not actual.get('audience') and not actual.get('token_no_default_policy') + and not actual.get('token_bound_cidrs') and not actual.get('token_num_uses')) + + +def provision(lanes, receipt): + # Complete all drift/absence checks before the first write. + for lane in lanes: + require(read_optional(lane['metadata']) is None, 'existing_custody_requires_resume_or_rotation_review') + old_policy = read_optional('sys/policies/acl/' + lane['policy']) + require(old_policy is None or old_policy['policy'] == lane['hcl'], 'policy_drift') + old_role = read_optional('auth/kubernetes/role/' + lane['role']) + require(old_role is None or role_matches(old_role, lane), 'role_drift') + # The new values must not become readable through a generic coding-agent grant. + boundary = read_optional(BOUNDARY) + require(boundary is not None, 'coding_agent_boundary_required') + current = boundary['policy'] + additions = '' + for lane in lanes: + for path in (lane['kv'], lane['metadata']): + if '"' + path + '"' not in current: + additions += 'path "' + path + '" { capabilities = ["deny"] }\n' + else: + require(re.search(r'path\s+"' + re.escape(path) + r'"\s*\{\s*capabilities\s*=\s*\["deny"\]\s*\}', current), 'boundary_path_drift') + if additions: + require(read_optional(BOUNDARY)['policy'] == current, 'boundary_revision_changed') + bao(['write', BOUNDARY, '-'], payload={'policy': current + '\n' + additions}) + require(read_optional(BOUNDARY)['policy'] == current + '\n' + additions, 'boundary_readback_failed') + receipt['coding_agent_boundary_extended_only_to_new_paths'] = bool(additions) + for lane in lanes: + row = {'ccr': lane['ccr'], 'source_sha256': lane['source_sha256'], 'policy_applied': False, + 'role_applied': False, 'custody_seeded': False} + receipt['lanes'].append(row) + bao(['write', 'sys/policies/acl/' + lane['policy'], '-'], payload={'policy': lane['hcl']}) + row['policy_applied'] = True + bao(['write', 'auth/kubernetes/role/' + lane['role'], '-'], payload=lane['role_payload']) + row['role_applied'] = True + require(read_optional('sys/policies/acl/' + lane['policy'])['policy'] == lane['hcl'], 'policy_readback_failed') + require(role_matches(read_optional('auth/kubernetes/role/' + lane['role']), lane), 'role_readback_failed') + for lane, row in zip(lanes, receipt['lanes']): + result = data(bao(['write', '-format=json', lane['kv'], '-'], + payload={'options': {'cas': 0}, 'data': {'CLIENT_SECRET': secrets.token_urlsafe(48)}})) + require(result['data']['version'] == 1, 'unexpected_initial_version') + row.update(custody_seeded=True, kv_version=1, request_id=result.get('request_id')) + receipt['initial_values_generation'] = 'independent CSPRNG 48-byte values; memory-to-OpenBao stdin only; CAS=0' + + +def require_denied(result): + require(result.returncode != 0 and b'403' in result.stderr and b'permission denied' in result.stderr.lower(), 'denial_not_proven') + + +def verify_native(kube, lanes, receipt): + for lane, row in zip(lanes, receipt['lanes']): + jwt = command(kube + ['-n', 'external-secrets', 'create', 'token', 'external-secrets', '--duration=10m']).stdout.decode().strip() + auth = data(bao(['write', '-format=json', 'auth/kubernetes/login', '-'], payload={'role': lane['role'], 'jwt': jwt}))['auth'] + token = auth['client_token'] + try: + require(set(auth['token_policies']) == {lane['policy'], 'default'} and 0 < auth['lease_duration'] <= 900, 'issued_role_or_ttl_mismatch') + native = data(bao(['read', '-format=json', lane['kv']], token=token))['data'] + require(set(native['data']) == {'CLIENT_SECRET'} and len(native['data']['CLIENT_SECRET']) == 64, 'custody_shape_mismatch') + sibling = next(x['kv'] for x in lanes if x['ccr'] != lane['ccr']) + require_denied(bao(['read', '-format=json', sibling], token=token, allow_failure=True)) + require_denied(bao(['list', '-format=json', 'platform/metadata/workloads'], token=token, allow_failure=True)) + row.update(native_reader_verified=True, cross_path_denied=True, parent_listing_denied=True, auth_ttl=auth['lease_duration']) + finally: + revoke(token) + require_denied(bao(['token', 'lookup', '-format=json'], token=token, allow_failure=True)) + row['reader_revocation_verified'] = True + bad_jwt = command(kube + ['-n', 'external-secrets', 'create', 'token', 'default', '--duration=10m']).stdout.decode().strip() + bad = bao(['write', '-format=json', 'auth/kubernetes/login', '-'], payload={'role': lane['role'], 'jwt': bad_jwt}, allow_failure=True) + if bad.returncode == 0: + revoke(data(bad)['auth']['client_token']) + raise LaneError('wrong_service_account_authenticated') + require(b'403' in bad.stderr and b'service account' in bad.stderr.lower(), 'wrong_service_account_denial_inconclusive') + row['wrong_service_account_denied'] = True + boundary_child = data(bao(['token', 'create', '-format=json', '-policy=' + lane['policy'], '-policy=agent-high-risk-boundary', '-no-default-policy', '-ttl=60s']))['auth']['client_token'] + try: + for path in (lane['kv'], lane['metadata']): + require_denied(bao(['read', '-format=json', path], token=boundary_child, allow_failure=True)) + row['coding_agent_deny_wins'] = True + finally: + revoke(boundary_child) + + +def deliver(kube, lanes, receipt): + base = ROOT / 'argocd/platform-addons/openbao-secretstore' + command(kube + ['apply', '-f', str(base / 'openbao-keycape-approval-clients.clustersecretstore.yaml')]) + for lane in lanes: + command(kube + ['wait', '--for=condition=Ready', 'clustersecretstore/' + lane['store'], '--timeout=45s']) + command(kube + ['apply', '-f', str(base / 'keycape-approval-clients.externalsecrets.yaml')]) + for lane, row in zip(lanes, receipt['lanes']): + command(kube + ['-n', 'sso', 'wait', '--for=condition=Ready', 'externalsecret/' + lane['secret'], '--timeout=45s']) + es = data(command(kube + ['-n', 'sso', 'get', 'externalsecret', lane['secret'], '-o', 'json'])) + secret = data(command(kube + ['-n', 'sso', 'get', 'secret', lane['secret'], '-o', 'json'])) + import base64 + native = data(bao(['read', '-format=json', lane['kv']]))['data'] + require(set(secret['data']) == {'client-secret'} and base64.b64decode(secret['data']['client-secret']).decode() == native['data']['CLIENT_SECRET'], 'eso_delivery_mismatch') + require(any(x['uid'] == es['metadata']['uid'] for x in secret['metadata'].get('ownerReferences', [])), 'eso_does_not_own_secret') + row.update(store_ready=True, external_secret_ready=True, delivery_matches=True, + secret_uid=secret['metadata']['uid'], secret_resource_version=secret['metadata']['resourceVersion']) + + + +def verify_namespace_boundaries(kube, lanes, receipt): + # A unique, exclusively created namespace isolates the negative probes. + ns = 'keycape-custody-check-' + secrets.token_hex(5) + created = data(command(kube + ['create', 'namespace', ns, '-o', 'json'])) + try: + command(kube + ['-n', ns, 'create', 'serviceaccount', 'external-secrets']) + jwt = command(kube + ['-n', ns, 'create', 'token', 'external-secrets', '--duration=10m']).stdout.decode().strip() + for lane, row in zip(lanes, receipt['lanes']): + bad = bao(['write', '-format=json', 'auth/kubernetes/login', '-'], + payload={'role': lane['role'], 'jwt': jwt}, allow_failure=True) + if bad.returncode == 0: + revoke(data(bad)['auth']['client_token']) + raise LaneError('wrong_namespace_authenticated') + require(b'403' in bad.stderr and b'namespace' in bad.stderr.lower(), 'wrong_namespace_denial_inconclusive') + row['wrong_namespace_denied'] = True + probe = {'apiVersion': 'external-secrets.io/v1', 'kind': 'ExternalSecret', + 'metadata': {'name': lane['secret'], 'namespace': ns}, + 'spec': {'refreshInterval': '15s', + 'secretStoreRef': {'kind': 'ClusterSecretStore', 'name': lane['store']}, + 'target': {'name': lane['secret'], 'creationPolicy': 'Owner'}, + 'data': [{'secretKey': 'client-secret', 'remoteRef': { + 'key': lane['kv'].removeprefix('platform/data/'), 'property': 'CLIENT_SECRET'}}]}} + command(kube + ['create', '-f', '-'], payload=probe) + denied = False + for _ in range(20): + result = data(command(kube + ['-n', ns, 'get', 'externalsecret', lane['secret'], '-o', 'json'])) + conditions = result.get('status', {}).get('conditions', []) + denied = any(c.get('type') == 'Ready' and c.get('status') == 'False' + and c.get('reason') == 'SecretSyncedError' for c in conditions) + if denied: + events = data(command(kube + ['-n', ns, 'get', 'events', + '--field-selector=involvedObject.uid=' + result['metadata']['uid'], '-o', 'json'])) + denied = any('not allowed' in e.get('message', '').lower() + and 'namespace' in e.get('message', '').lower() for e in events['items']) + if denied: + break + time.sleep(2) + require(denied, 'outside_namespace_store_denial_inconclusive') + absent = command(kube + ['-n', ns, 'get', 'secret', lane['secret'], '-o', 'json'], allow_failure=True) + require(absent.returncode != 0 and b'NotFound' in absent.stderr, 'outside_namespace_secret_not_absent') + row['outside_namespace_store_denied'] = True + finally: + command(kube + ['delete', '--raw=/api/v1/namespaces/' + ns, '-f', '/dev/stdin'], + payload={'apiVersion': 'v1', 'kind': 'DeleteOptions', + 'preconditions': {'uid': created['metadata']['uid']}}) + receipt['namespace_probe_cleanup_requested'] = True + + + +def load_rollout(): + path = Path('/home/worsch/net-kingdom/sso-mfa/k8s/keycape/approval-clients-rollout.py') + spec = importlib.util.spec_from_file_location('keycape_rollout', path) + module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module) + return module + + +def deactivate_delivery(kube, lanes, receipt): + # Config/image restoration runs in the owner helper before this call. + for lane in lanes: + existing = command(kube + ['-n', 'sso', 'get', 'externalsecret', lane['secret'], '-o', 'json'], allow_failure=True) + if existing.returncode == 0: + obj = data(existing) + require(obj['spec']['secretStoreRef'] == {'kind': 'ClusterSecretStore', 'name': lane['store']}, 'rollback_external_secret_drift') + command(kube + ['delete', '--raw=/apis/external-secrets.io/v1/namespaces/sso/externalsecrets/' + lane['secret'], '-f', '/dev/stdin'], + payload={'apiVersion': 'v1', 'kind': 'DeleteOptions', 'preconditions': {'uid': obj['metadata']['uid']}}) + else: + require(b'NotFound' in existing.stderr, 'rollback_external_secret_absence_unproven') + role = read_optional('auth/kubernetes/role/' + lane['role']) + if role is not None: + require(role_matches(role, lane), 'rollback_auth_role_drift') + payload = dict(lane['role_payload'], policies=[]) + bao(['write', 'auth/kubernetes/role/' + lane['role'], '-'], payload=payload) + receipt['verifier_delivery_disabled_custody_versions_retained'] = True + + +def run(args, receipt): + require(Path.home().parent.name == '.warden-attended-login' and not os.environ.get('BAO_TOKEN') and not os.environ.get('VAULT_TOKEN'), 'attended_warden_envelope_required') + lanes = contracts() + kube = ['kubectl', '--kubeconfig', args.kubeconfig] + assert_cluster(kube) + identity = data(bao(['token', 'lookup', '-format=json']))['data'] + require('platform-admin' in identity['policies'] and 'root' not in identity['policies'], 'attended_platform_admin_required') + env = os.environ.copy(); env['KUBECONFIG'] = args.kubeconfig + check = data(command(['python3', '-B', '/home/worsch/net-kingdom/sso-mfa/k8s/keycape/openbao-client-config.py', 'issuer-check-live'], env=env)) + require(check['issuer_matches'] and check['verified_issuer'] == 'https://auth.coulomb.social', 'verified_issuer_pin_required') + receipt['issuer_pin_revision'] = check['before']['resource_version'] + rollout = load_rollout() if args.action == 'activate' else None + if rollout: + # Exercise config construction and API admission before any custody write. + secret = rollout.get(kube, 'secret', 'keycape-config') + dep = rollout.get(kube, 'deployment', 'keycape') + config = rollout.replacement(secret, rollout.registrations()) + candidate = rollout.candidate_spec(dep) + rollout.patch_object(kube, 'secret', secret, '/data/config.yaml', config, dry=True) + rollout.patch_object(kube, 'deployment', dep, '/spec', candidate, dry=True) + require(args.recovery and not Path(args.recovery).exists(), 'unique_recovery_path_required') + for lane in lanes: + for kind in ('secret', 'externalsecret'): + existing = command(kube + ['-n', 'sso', 'get', kind, lane['secret'], '-o', 'json'], allow_failure=True) + require(existing.returncode != 0 and b'NotFound' in existing.stderr, 'existing_delivery_requires_reconciliation') + try: + receipt['phase'] = 'provision' + if args.action in {'provision', 'activate'}: + provision(lanes, receipt) + else: + receipt['lanes'] = [{'ccr': lane['ccr']} for lane in lanes] + receipt['phase'] = 'native_verification' + verify_native(kube, lanes, receipt) + receipt['phase'] = 'eso_delivery' + deliver(kube, lanes, receipt) + receipt['phase'] = 'namespace_boundaries' + verify_namespace_boundaries(kube, lanes, receipt) + receipt['status'] = 'custody_and_eso_verified_pending_keycape_rollout' + if rollout: + receipt['phase'] = 'keycape_rollout' + receipt['keycape'] = {'existing_human_login_before': True} + rollout.rollout(kube, receipt['keycape'], Path(args.recovery)) + receipt['status'] = 'custody_and_service_acceptance_passed_pending_fresh_human_login' + receipt['keycape_rollout_completed'] = True + receipt['phase'] = 'awaiting_fresh_human_login' + except Exception: + receipt['status'] = 'failed' + if rollout and (not receipt.get('keycape') or receipt['keycape'].get('compatible_pair_restored')): + deactivate_delivery(kube, lanes, receipt) + raise + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=['provision', 'verify', 'activate']) + parser.add_argument('--kubeconfig', required=True) + parser.add_argument('--receipt', required=True) + parser.add_argument('--recovery') + args = parser.parse_args() + receipt = {'schema': 'platform.keycape-approval-custody.v1', 'status': 'failed', 'lanes': [], + 'started_at': datetime.now(timezone.utc).isoformat(), 'credential_values_emitted': False, + 'client_side_read_admitted': False, 'keycape_rollout_completed': False} + fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + run(args, receipt) + except Exception as exc: + receipt['error'] = str(exc) if isinstance(exc, LaneError) else 'contained_operation_failed' + finally: + receipt['finished_at'] = datetime.now(timezone.utc).isoformat() + with os.fdopen(fd, 'w') as out: + json.dump(receipt, out, indent=2); out.write('\n') + return 0 if receipt['status'] != 'failed' else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_keycape_approval_custody.py b/tests/test_keycape_approval_custody.py new file mode 100644 index 0000000..cdf2a2a --- /dev/null +++ b/tests/test_keycape_approval_custody.py @@ -0,0 +1,114 @@ +"""Opt-in local OpenBao exercise; no production API or credential helper used.""" +import importlib.util +import json +import os +from pathlib import Path +import secrets +import shutil +import socket +import subprocess +import sys +import time +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'scripts')) +import keycape_approval_custody as lane + + +class CustodyExercise(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not shutil.which('bao'): + raise unittest.SkipTest('OpenBao executable required') + sock = socket.socket(); sock.bind(('127.0.0.1', 0)); port = sock.getsockname()[1]; sock.close() + cls.env = os.environ.copy() + token = secrets.token_urlsafe(32) + for name in ('BAO_ADDR', 'VAULT_ADDR'): + cls.env[name] = f'http://127.0.0.1:{port}' + for name in ('BAO_TOKEN', 'VAULT_TOKEN', 'BAO_DEV_ROOT_TOKEN_ID'): + cls.env[name] = token + cls.server = subprocess.Popen(['bao', 'server', '-dev', '-dev-no-store-token', + f'-dev-listen-address=127.0.0.1:{port}'], env=cls.env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + cls.environment = patch.dict(os.environ, cls.env, clear=True); cls.environment.start() + try: + for _ in range(40): + if lane.bao(['status'], allow_failure=True).returncode == 0: + break + time.sleep(.25) + else: + raise RuntimeError('local_dev_server_not_ready') + lane.bao(['secrets', 'enable', '-path=platform', 'kv-v2']) + lane.bao(['auth', 'enable', 'kubernetes']) + cls.lanes = lane.contracts() + except Exception: + cls.environment.stop(); cls.server.terminate(); cls.server.wait(timeout=10) + raise + + @classmethod + def tearDownClass(cls): + cls.environment.stop(); cls.server.terminate(); cls.server.wait(timeout=10) + + def test_01_first_provision_and_real_acl_boundaries(self): + lane.bao(['write', lane.BOUNDARY, '-'], payload={'policy': 'path "protected/fixture" { capabilities = ["deny"] }\n'}) + receipt = {'lanes': []} + lane.provision(self.lanes, receipt) + values = [] + for item in self.lanes: + value = lane.data(lane.bao(['read', '-format=json', item['kv']]))['data']['data']['CLIENT_SECRET'] + self.assertTrue(len(value) == 64) + values.append(value) + auth = lane.data(lane.bao(['token', 'create', '-format=json', '-policy=' + item['policy'], '-ttl=60s']))['auth'] + token = auth['client_token'] + try: + self.assertEqual(lane.bao(['read', '-format=json', item['kv']], token=token).returncode, 0) + sibling = next(x['kv'] for x in self.lanes if x != item) + lane.require_denied(lane.bao(['read', sibling], token=token, allow_failure=True)) + lane.require_denied(lane.bao(['list', 'platform/metadata/workloads'], token=token, allow_failure=True)) + finally: + lane.revoke(token) + lane.require_denied(lane.bao(['token', 'lookup'], token=token, allow_failure=True)) + child = lane.data(lane.bao(['token', 'create', '-format=json', '-policy=' + item['policy'], '-policy=agent-high-risk-boundary', '-ttl=60s']))['auth']['client_token'] + try: + lane.require_denied(lane.bao(['read', item['kv']], token=child, allow_failure=True)) + finally: + lane.revoke(child) + self.assertTrue(values[0] != values[1]) + self.assertTrue(all(x['kv_version'] == 1 for x in receipt['lanes'])) + self.assertFalse(any(value in json.dumps(receipt) for value in values)) + + def test_02_retry_refuses_existing_custody(self): + with self.assertRaisesRegex(lane.LaneError, 'existing_custody_requires'): + lane.provision(self.lanes, {'lanes': []}) + self.assertTrue(all(lane.read_optional(x['metadata'])['current_version'] == 1 for x in self.lanes)) + + def test_03_denials_must_be_authoritative(self): + for result in [subprocess.CompletedProcess([], 0, b'', b''), + subprocess.CompletedProcess([], 1, b'', b'404 Not Found'), + subprocess.CompletedProcess([], 1, b'', b'503 permission denied'), + subprocess.CompletedProcess([], 1, b'', b'403 unrelated refusal')]: + with self.assertRaises(lane.LaneError): + lane.require_denied(result) + + def test_04_missing_approval_refused(self): + with patch.object(lane, 'ROOT', Path('/nonexistent-keycape-contract')): + with self.assertRaises(FileNotFoundError): + lane.contracts() + + def test_05_role_drift_refused(self): + item = self.lanes[0] + actual = lane.read_optional('auth/kubernetes/role/' + item['role']) + self.assertTrue(lane.role_matches(actual, item)) + for field, value in [('token_policies', [item['policy'], 'extra']), + ('bound_service_account_namespaces', ['*']), + ('bound_service_account_names', ['*']), ('token_ttl', 3600), ('token_period', 900)]: + self.assertFalse(lane.role_matches(dict(actual, **{field: value}), item)) + + def test_06_direct_execution_refused(self): + with self.assertRaisesRegex(lane.LaneError, 'attended_warden_envelope_required'): + lane.run(None, {}) + +if __name__ == '__main__': + unittest.main() diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md index d71f800..51fc6d5 100644 --- a/workplans/RPF-WP-0035-credential-lane-implementation.md +++ b/workplans/RPF-WP-0035-credential-lane-implementation.md @@ -145,9 +145,9 @@ closure: `history/2026-09-05-preflight-signing-activation-complete.md`. ```task id: RPF-WP-0035-T05 -status: wait -needs_human: true -intervention_note: "Review CCR-2026-0017 and CCR-2026-0018 as platform-operator and key-cape-owner; both pending decisions are linked from the CCRs and the review packet. Signed issuer and exact configuration pin are proved." +status: progress +needs_human: false +intervention_note: "" priority: high state_hub_task_id: "e15d62c9-e5da-5721-a135-87c050f7851c" ``` @@ -218,6 +218,16 @@ human dependency; HFACT consumes it without a duplicate approval request. Custody/ESO activation, client verification and the separate client-side/audit lanes remain open. Current CLI apply plans refuse the proposed requests. + +2026-09-09 admission: the user explicitly approved both CCRs in the named +platform-operator and key-cape-owner roles. Both source requests are approved +and both linked Hub decisions are resolved. Receipt: +`docs/evidence/2026-09-09-keycape-approval-admission.json`. The completed review +intervention is cleared; T05 is progress while the contained first-provision +procedure is exercised and the admitted attended rollout is carried through. +Verifier-side scope, separate client-side/audit lanes and live acceptance remain +as defined in the reviewed requests. + ## Dependency review — 2026-09-06 SECRETS-WP-0008-T02 now records the local PIP claim/validation join implemented