From f3b0c4f4b205e3029318dfeeb55c6c8649c83462 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 4 Nov 2026 01:59:30 +0100 Subject: [PATCH] RPF-WP-0069-T05/T09/T10: bounded executor credential go-live runbook (NK-WP-0056-T06) - CCR-2026-0063: NetKingdom's answer recorded (ESO as drafted); delivery aligned to the Secret the rapp Job mounts (sso/bounded-executor-tokens, key audit-core); reader role in the CCR-2026-0033/0041/0060 shape (audience openbao, explicit max TTL, self-lifecycle); custody_procedure names audit-core's seeder (one seeder only). - CCR-2026-0060: delivery block (store rendered from the issuer template); fence step named. - manifests/nk-bind-executor-eso.yaml: two sso-restricted stores and ExternalSecrets. - scripts/nk_bind_executor_custody.py plan|fence|verify with synthetic-fixture tests. - Source coding-agent boundary carries the KeyCape nk-bind-executor-approval path. - docs/credential-lane-designs/nk-bind-executor-go-live.md: exact founder sequence. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 523928@bnt-lap001 Assistant-Session: 6c737afb-9c14-4152-9f02-477214b9c350 --- WORK-RECORDS.md | 1 + ...-executor-approval-keycape-eso-reader.yaml | 16 +- ...ind-executor-approval-consumer-reader.yaml | 4 +- ...26-0063-nk-bind-executor-audit-sender.yaml | 54 ++- .../nk-bind-executor-go-live.md | 221 +++++++++ manifests/nk-bind-executor-eso.yaml | 101 +++++ openbao/policies/agent-high-risk-boundary.hcl | 5 + ...orkload-kv-read-nk-bind-executor-audit.hcl | 8 + scripts/nk_bind_executor_custody.py | 426 ++++++++++++++++++ tests/test_nk_bind_executor_custody.py | 277 ++++++++++++ .../RPF-WP-0069-bounded-executor-lanes.md | 40 +- 11 files changed, 1130 insertions(+), 23 deletions(-) create mode 100644 docs/credential-lane-designs/nk-bind-executor-go-live.md create mode 100644 manifests/nk-bind-executor-eso.yaml create mode 100644 scripts/nk_bind_executor_custody.py create mode 100644 tests/test_nk_bind_executor_custody.py diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 33fe974..0d03429 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -235,6 +235,7 @@ | task | RPF-WP-0069-T07 | done | — | workplans/RPF-WP-0069-bounded-executor-lanes.md | | task | RPF-WP-0069-T08 | done | — | workplans/RPF-WP-0069-bounded-executor-lanes.md | | task | RPF-WP-0069-T09 | wait | — | workplans/RPF-WP-0069-bounded-executor-lanes.md | +| task | RPF-WP-0069-T10 | wait | — | workplans/RPF-WP-0069-bounded-executor-lanes.md | | task | RPF-WP-0070-T01 | done | — | workplans/RPF-WP-0070-mas-evidence-recovery.md | | task | RPF-WP-0070-T02 | done | — | workplans/RPF-WP-0070-mas-evidence-recovery.md | | task | RPF-WP-0070-T03 | done | — | workplans/RPF-WP-0070-mas-evidence-recovery.md | diff --git a/credential-change-requests/CCR-2026-0060-nk-bind-executor-approval-keycape-eso-reader.yaml b/credential-change-requests/CCR-2026-0060-nk-bind-executor-approval-keycape-eso-reader.yaml index 4d72bea..ff4e459 100644 --- a/credential-change-requests/CCR-2026-0060-nk-bind-executor-approval-keycape-eso-reader.yaml +++ b/credential-change-requests/CCR-2026-0060-nk-bind-executor-approval-keycape-eso-reader.yaml @@ -5,7 +5,7 @@ request_type: workload-kv-read title: 'nk-bind-executor-approval: keycape-reader-policy and eso-role (oidc-service-client@0.2)' status: proposed created: '2026-10-31' -updated: '2026-11-01' +updated: '2026-11-04' requester: agent: railiance-platform reason: 'NK-WP-0056-T14 first case of SecurityCaseType oidc-service-client 0.2 (net-kingdom @@ -51,6 +51,15 @@ openbao: service_account_namespaces: - external-secrets bound_claims_confirmed: false +delivery: + surface: external-secrets + namespace: sso + target: 'ClusterSecretStore openbao-keycape-nk-bind-executor-approval-issuer (rendered + from manifests/templates/keycape-issuer-eso.yaml; serviceAccountRef external-secrets/external-secrets, + audience openbao) restricted to namespace sso; ExternalSecret and Secret sso/keycape-nk-bind-executor-approval-client, + key client-secret (type deliver-secret step). KeyCape binds it as env + KEYCAPE_NK_BIND_EXECUTOR_APPROVAL_CLIENT_SECRET (net-kingdom bind-env, after Ready=True).' + manifest: manifests/nk-bind-executor-eso.yaml access_frontdoor: type: external-secrets catalog_id: bounded-executor-keycape-nk-bind-executor-approval @@ -81,7 +90,10 @@ verification: f8044f81). - SecurityCase nk-bind-executor-approval accepted by owner and founder. - Custody entry minted at version 1 by keycape-client-secret-generate, by the broker, which admits - oidc-service-client@0.2 since RPF-WP-0069-T08. + oidc-service-client@0.2 since RPF-WP-0069-T08. The broker needs the case file at status + materialized with acceptance {at, record, by [owner, founder]}. + - Coding-agent deny on the data and metadata paths (scripts/nk_bind_executor_custody.py fence) + before the role exists. - platform-owner approval of this exact request and bound_claims_confirmed set to true. lifecycle: deactivate: delete ExternalSecret sso/keycape-nk-bind-executor-approval-client, then role diff --git a/credential-change-requests/CCR-2026-0061-nk-bind-executor-approval-consumer-reader.yaml b/credential-change-requests/CCR-2026-0061-nk-bind-executor-approval-consumer-reader.yaml index f93be38..f9b991f 100644 --- a/credential-change-requests/CCR-2026-0061-nk-bind-executor-approval-consumer-reader.yaml +++ b/credential-change-requests/CCR-2026-0061-nk-bind-executor-approval-consumer-reader.yaml @@ -5,7 +5,7 @@ request_type: workload-kv-read title: 'nk-bind-executor-approval: consumer reader for sso/nk-bind-executor (oidc-service-client@0.2)' status: proposed created: '2026-10-31' -updated: '2026-11-01' +updated: '2026-11-04' requester: agent: railiance-platform reason: 'NK-WP-0056-T14 first case of SecurityCaseType oidc-service-client 0.2 (net-kingdom @@ -69,7 +69,7 @@ access_frontdoor: agent_fence: data_and_metadata_denied: true note: Coding agents are denied data and metadata of platform/workloads/keycape/nk-bind-executor-approval; - setup checks the deny before the role is created. + scripts/nk_bind_executor_custody.py fence adds and proves the deny before the role is created. lifecycle: role_window: Standing, as CCR-2026-0059. The executor exchanges a fresh token per call and keeps neither the secret nor a token beyond the call. diff --git a/credential-change-requests/CCR-2026-0063-nk-bind-executor-audit-sender.yaml b/credential-change-requests/CCR-2026-0063-nk-bind-executor-audit-sender.yaml index e88405a..dbc6f45 100644 --- a/credential-change-requests/CCR-2026-0063-nk-bind-executor-audit-sender.yaml +++ b/credential-change-requests/CCR-2026-0063-nk-bind-executor-audit-sender.yaml @@ -5,7 +5,7 @@ request_type: workload-kv-read title: NetKingdom bounded-executor audit sender custody and delivery status: proposed created: '2026-11-03' -updated: '2026-11-03' +updated: '2026-11-04' requester: agent: claude-code reason: RPF-WP-0069-T05 / NK-WP-0056-T15 / NK-WP-0040. Audit-core admitted and named @@ -19,7 +19,15 @@ review: - platform-owner - audit-core-owner - net-kingdom-owner - comments: [] + comments: + - at: '2026-11-04T00:00:00+00:00' + reviewer: net-kingdom (agent answer relayed by the railiance-platform worker) + decision: open_question_answered + comment: NetKingdom answers the open question for NK-WP-0056-T15 as ESO, like the + other in-cluster senders, not an OpenBao read role for the executor. The executor's + audit client reads a token file. This is the net-kingdom repository's answer only; + the net-kingdom-owner approval is the founder's and is recorded separately with + the approve tool. target: domain: infotech tenant: platform @@ -39,19 +47,26 @@ openbao: policy_name: workload-kv-read-nk-bind-executor-audit policy_file: openbao/policies/workload-kv-read-nk-bind-executor-audit.hcl metadata_read: false + # Same reader shape as CCR-2026-0033/0041/0060 (audience openbao, explicit max TTL, + # no default policy, token self-lifecycle), not the older CCR-2026-0021/0022 shape. + token_self_lifecycle: true auth: method: kubernetes mount: kubernetes role: external-secrets-nk-bind-executor-audit + audience: openbao + ttl: 15m + token_max_ttl: 15m + token_explicit_max_ttl: 15m + token_no_default_policy: true + policies: + - workload-kv-read-nk-bind-executor-audit bound_claims: service_account_names: - external-secrets service_account_namespaces: - external-secrets bound_claims_confirmed: false - policies: - - workload-kv-read-nk-bind-executor-audit - ttl: 15m access_frontdoor: type: external-secrets catalog_id: nk-bind-executor-audit @@ -61,11 +76,16 @@ delivery: surface: external-secrets namespace: sso service_account: nk-bind-executor - target: 'ClusterSecretStore openbao-nk-bind-executor-audit restricted to namespace - sso; ExternalSecret and Secret sso/nk-bind-executor-audit, key audit-token, mounted - as a file in the executor pod (sso/nk-bind-executor, uid bd15569c). The store and - ExternalSecret manifests belong in the reviewed rapp-* package next to the executor, - as for CCR-2026-0053; this request creates none.' + target: 'ClusterSecretStore openbao-nk-bind-executor-audit (serviceAccountRef + external-secrets/external-secrets, audience openbao) restricted to namespace sso; + ExternalSecret and Secret sso/bounded-executor-tokens, key audit-core, which the + rapp-bounded-executor run Job already mounts at /var/run/secrets/bounded-executor/audit-core + (rapp docs/platform-handoffs.md names this delivery as railiance-platform''s). The + platform carries the store and ExternalSecret in manifests/nk-bind-executor-eso.yaml, + as it does for CCR-2026-0021/0022/0047. The executor pod (sso/nk-bind-executor, + uid bd15569c) mounts the Secret; the ServiceAccount has no Secret read in sso.' + manifest: manifests/nk-bind-executor-eso.yaml + custody_procedure: audit-core scripts/attended-seed-nk-bounded-executor-sender.sh (seed and receiver reload); railiance-platform scripts/nk_bind_executor_custody.py fence and verify receiver: POST /v1/events to http://audit-core.audit-core.svc.cluster.local:8080; envelope source equals the sender scope source; Idempotency-Key is the envelope id and correlation_id is the run_id. Ingress audit-core-nk-bounded-executor-ingress @@ -83,18 +103,17 @@ risk: must remove the sender token from the receiver registry and prove refusal. - The sso namespace also runs Authelia, LLDAP and KeyCape. The Secret is readable by any workload allowed to read Secrets in sso; only the executor pod mounts it. - - 'Open for net-kingdom-owner review: NK-WP-0056-T15 prefers an OpenBao read role - for the executor over an ESO Secret. This request follows the shape audit-core - and the platform owner answer agreed (ESO, like the other in-cluster senders); - a direct read role would need a changed request.' + - 'Settled 2026-11-04: NK-WP-0056-T15 had preferred an OpenBao read role for the + executor over an ESO Secret. NetKingdom answered ESO as drafted, like the other + in-cluster senders (review comment above).' - The new paths extend the coding-agent deny boundary (agent-high-risk-boundary). No existing high-risk grant is widened. verification: positive: - Independent CAS=0 token creation and exact sender merge with registry-version compare-and-set; repeated runs preserve all existing senders and registry fields. - - ESO delivers only AUDIT_TOKEN into sso/nk-bind-executor-audit:audit-token; source - and receiver copies agree without displaying values. + - ESO delivers only AUDIT_TOKEN into sso/bounded-executor-tokens:audit-core; source + and receiver copies agree by in-memory hash without displaying values. - The deployed receiver accepts a synthetic started event and receipt for the exact source and tenant (202), returns 200 for a duplicate and 409 for a reused run_id with a different body. @@ -110,7 +129,8 @@ verification: - All three named owner reviews approve before any native credential mutation. - Audit-core's founder-run rollout of the sender scope ConfigMap and the ingress NetworkPolicy is done, and the executor accepts 202 (NK-WP-0056-T15). - - The rapp-* package carries the ClusterSecretStore and ExternalSecret; the binding + - The platform manifest manifests/nk-bind-executor-eso.yaml carries the ClusterSecretStore + and ExternalSecret, matching the rapp's mounted Secret name and key; the binding is confirmed against it. - Use the Warden attended platform-admin login envelope; preserve an independent receipt and revoke the session on exit. diff --git a/docs/credential-lane-designs/nk-bind-executor-go-live.md b/docs/credential-lane-designs/nk-bind-executor-go-live.md new file mode 100644 index 0000000..a54842d --- /dev/null +++ b/docs/credential-lane-designs/nk-bind-executor-go-live.md @@ -0,0 +1,221 @@ +# Bounded executor credential go-live (NK-WP-0056-T06; RPF-WP-0069-T05/T09) + +Founder runbook for the platform side of the bounded executor's two credentials. Source only: +nothing here has been run. Every command is founder-run and attended, from +`/home/worsch/railiance-platform` at the reviewed commit. No command prints a credential value. + +| Request | What it grants | Approvers | +| --- | --- | --- | +| CCR-2026-0060 | KeyCape's ESO reader of `platform/workloads/keycape/nk-bind-executor-approval` (`CLIENT_SECRET`), role `external-secrets-keycape-nk-bind-executor-approval` | platform-owner | +| CCR-2026-0061 | The executor's own reader of the same path, role `keycape-nk-bind-executor-approval-bounded-executor-workload-kv-read`, bound to `sso/nk-bind-executor`, audience `openbao`, 15m | platform-owner, net-kingdom-owner | +| CCR-2026-0063 | Audit-core sender bearer custody `platform/workloads/net-kingdom/bounded-executor/audit-sender`, ESO reader role `external-secrets-nk-bind-executor-audit` | platform-owner, audit-core-owner, net-kingdom-owner | +| CCR-2026-0058 | Window-scoped svc-authelia write role (already approved; created per admission window by net-kingdom `tools/service-binds/executor-lanes.py`, not here) | done | + +The KeyCape client is SecurityCase `nk-bind-executor-approval` of type `oidc-service-client` 0.2 +(acknowledged 2026-11-01). Its client secret is minted straight into OpenBao by the credential broker, +delivered to KeyCape by ESO, and read natively by the executor through CCR-2026-0061's role. + +## Tools + +- `scripts/credential-change.py approve | confirm-binding | applier-apply`: approvals and the delegated + metadata applier (policies and roles). Since RPF-WP-0069-T07, `approve` records each approval in turn; + for `workload-kv-read` no hand-recorded second approval is needed. +- `scripts/credential.py generate --grant keycape/client-secret-generate`: the broker mint + (`scripts/keycape_client_secret_generate.py`, admits `oidc-service-client@0.2` since RPF-WP-0069-T08). + Silent, CAS 0, create-only, metadata-only verification, mode-0600 receipt. +- `scripts/nk_bind_executor_custody.py` (new): `plan` (offline), `fence` (coding-agent deny for both + custody paths, append-only on the live boundary) and `verify` (metadata, capabilities, login results + and in-memory hashes only; never reads the KeyCape client secret). Silent; results in the receipt. + Tests: `tests/test_nk_bind_executor_custody.py`. +- audit-core `scripts/attended-seed-nk-bounded-executor-sender.sh`: mints the audit bearer CAS 0 into + the CCR-2026-0063 path, merges the `nk-bounded-executor` registry row with compare-and-set, force-syncs + `audit-core-senders`, restarts the receiver and proves the bearer (audit-core + `docs/bounded-executor-receipts.md`, "Founder runbook"). +- `manifests/nk-bind-executor-eso.yaml`: two ClusterSecretStores (restricted to `sso`, audience + `openbao`) and two ExternalSecrets: `sso/keycape-nk-bind-executor-approval-client` (key + `client-secret`, rendered from `manifests/templates/keycape-issuer-eso.yaml`) and + `sso/bounded-executor-tokens` (key `audit-core`, the Secret the rapp run Job mounts). + +## Gates owned elsewhere (before the steps that need them) + +- **G1, before step 5 (mint).** The founder accepts case `nk-bind-executor-approval` (owner and + founder), and net-kingdom commits + `security-case-types/oidc-service-client/examples/nk-bind-executor-approval.yaml` with + `status: materialized` and `acceptance: {at, record, by: [{role: owner}, {role: founder}]}`. The + broker refuses anything else (`case_not_materialized`, `case_source_validation_failed`); a synthetic + copy with exactly that shape passed the broker's case check on 2026-11-04. +- **G2, before step 4 (audit seed).** audit-core founder runbook steps 1 and 2: the ingress + `audit-core-nk-bounded-executor-ingress` is applied and the live scope ConfigMap carries + `nk-bounded-executor`. + +## Order relative to KeyCape + +KeyCape resolves a confidential client's secret at start and fails closed when the binding is missing. +Its Deployment runs one replica with strategy Recreate. So: + +1. platform steps 1 to 7 below finish first, and step 7 reports + `sso/keycape-nk-bind-executor-approval-client` Ready=True; +2. only then does net-kingdom patch Deployment `sso/keycape` with env + `KEYCAPE_NK_BIND_EXECUTOR_APPROVAL_CLIENT_SECRET` from Secret + `keycape-nk-bind-executor-approval-client` key `client-secret` (bind-env), **and** register client + `nk-bind-executor-approval` (`keycape-client.py apply`), in one rollout, bind-env first; +3. then the executor's request phase can exchange tokens. + +If the ExternalSecret is not Ready when KeyCape restarts, the new pod cannot start and every client +loses sign-in. + +## Step 0: session + +```bash +cd /home/worsch/railiance-platform && git pull --ff-only +bridge up openbao-ui-railiance01 +mkdir -p -m 700 /home/worsch/.local/state/railiance/receipts +python3 scripts/nk_bind_executor_custody.py plan +python3 -m pytest -q tests/test_nk_bind_executor_custody.py tests/test_keycape_client_secret_generate.py +``` + +Expected: the plan lists the three CCRs with the roles, stores and ExternalSecrets above; tests pass. + +## Step 1: approvals and binding confirmations + +```bash +cd /home/worsch/railiance-platform +python3 scripts/credential-change.py approve CCR-2026-0060 --reviewer platform-owner --comment "Approved by the founder 2026-11-04: KeyCape ESO reader for SecurityCase nk-bind-executor-approval (oidc-service-client 0.2), NK-WP-0056-T06." +python3 scripts/credential-change.py confirm-binding CCR-2026-0060 --reviewer platform-owner --comment "Binding confirmed: external-secrets/external-secrets, audience openbao, as CCR-2026-0033/0041." +python3 scripts/credential-change.py approve CCR-2026-0061 --reviewer platform-owner --comment "Approved by the founder 2026-11-04: executor reader of the nk-bind-executor-approval client secret, NK-WP-0056-T06." +python3 scripts/credential-change.py approve CCR-2026-0061 --reviewer net-kingdom-owner --comment "Approved by the founder 2026-11-04 for NK-WP-0056-T06." +python3 scripts/credential-change.py confirm-binding CCR-2026-0061 --reviewer platform-owner --comment "Binding confirmed: ServiceAccount nk-bind-executor in namespace sso only (uid bd15569c-8a85-4625-b001-d9f799cb857b), audience openbao, from rapp-bounded-executor." +python3 scripts/credential-change.py approve CCR-2026-0063 --reviewer platform-owner --comment "Approved by the founder 2026-11-04: bounded-executor audit sender custody, ESO to sso/bounded-executor-tokens, NK-WP-0056-T15/T06." +python3 scripts/credential-change.py approve CCR-2026-0063 --reviewer audit-core-owner --comment "Approved by the founder 2026-11-04: sender nk-bounded-executor as registered in AUDIT-WP-0018-T02." +python3 scripts/credential-change.py approve CCR-2026-0063 --reviewer net-kingdom-owner --comment "Approved by the founder 2026-11-04: ESO as drafted (NetKingdom answer recorded 2026-11-04)." +python3 scripts/credential-change.py confirm-binding CCR-2026-0063 --reviewer platform-owner --comment "Binding confirmed: external-secrets/external-secrets, audience openbao; store restricted to namespace sso." +for c in CCR-2026-0060 CCR-2026-0061 CCR-2026-0063; do python3 scripts/credential-change.py validate $c; python3 scripts/credential-change.py applier-dry-run $c | tail -3; done +git add credential-change-requests/CCR-2026-0060-*.yaml credential-change-requests/CCR-2026-0061-*.yaml credential-change-requests/CCR-2026-0063-*.yaml +git commit -m "CCR-2026-0060/0061/0063: founder approvals and binding confirmations (NK-WP-0056-T06)" +git push origin main +``` + +Expected: `[OK]` with no "bound claim is not confirmed" warning; each dry-run ends with `(matches)`. +The CCRs must be committed before step 2: `nk_bind_executor_custody.py` refuses uncommitted CCRs. + +## Step 2: coding-agent fence (before any role exists) + +```bash +cd /home/worsch/railiance-platform +BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200 warden access openbao-platform-admin-login --exec -- python3 /home/worsch/railiance-platform/scripts/nk_bind_executor_custody.py fence --receipt /home/worsch/.local/state/railiance/receipts/nk-bind-executor-fence-r1.json +python3 -c 'import json; r=json.load(open("/home/worsch/.local/state/railiance/receipts/nk-bind-executor-fence-r1.json")); print(r["status"], r.get("error"), r.get("fence_lines_added"))' +``` + +Expected: `fenced None 2` (the audit-sender lines may already be live; then fewer, never a rewrite of +existing lines). A rerun needs a new receipt name (`-r2.json`). + +## Step 3: policies and roles (delegated applier) + +```bash +cd /home/worsch/railiance-platform +BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200 warden access openbao-platform-admin-login --exec -- python3 /home/worsch/railiance-platform/scripts/credential-change.py applier-apply CCR-2026-0060 --confirm "DELEGATED APPLY CCR-2026-0060" --actor founder --record-state-hub --quiet +BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200 warden access openbao-platform-admin-login --exec -- python3 /home/worsch/railiance-platform/scripts/credential-change.py applier-apply CCR-2026-0061 --confirm "DELEGATED APPLY CCR-2026-0061" --actor founder --record-state-hub --quiet +BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200 warden access openbao-platform-admin-login --exec -- python3 /home/worsch/railiance-platform/scripts/credential-change.py applier-apply CCR-2026-0063 --confirm "DELEGATED APPLY CCR-2026-0063" --actor founder --record-state-hub --quiet +git add credential-change-requests/CCR-2026-0060-*.yaml credential-change-requests/CCR-2026-0061-*.yaml credential-change-requests/CCR-2026-0063-*.yaml +git commit -m "CCR-2026-0060/0061/0063: delegated metadata apply (NK-WP-0056-T06)" +git push origin main +``` + +Expected: each CCR moves to `applied` with a `delegated_metadata_apply` evidence entry; the applier +reads back the policy and role and refuses on drift. + +## Step 4: audit sender seed (audit-core's founder runbook, step 3; needs G2) + +```bash +BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200 warden access openbao-platform-admin-login --exec -- /home/worsch/audit-core/scripts/attended-seed-nk-bounded-executor-sender.sh /home/worsch/audit-core/docs/evidence/nk-bounded-executor-sender-seed-r1.json +python3 -c 'import json; r=json.load(open("/home/worsch/audit-core/docs/evidence/nk-bounded-executor-sender-seed-r1.json")); print(r["status"], r.get("error"), r.get("receiver_probe"), r.get("registry_secret_hash_match"), r["credential_values_emitted"])' +``` + +Expected: `seeded_pending_sso_delivery None {"read": 403, "own_tenant": 200, "other_tenant": 403, +"other_source": 403} True False`. The seed refuses unless CCR-2026-0063 carries all three approvals and +the fence from step 2 is live. + +## Step 5: mint the KeyCape client secret into custody (needs G1) + +The caller is the founder's own OIDC login to the broker issuer role (`net-kingdom-admins` + aal2, +CCR-2026-0043), not the platform-admin envelope. + +```bash +cd /home/worsch/railiance-platform +export BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200 +unset BAO_TOKEN VAULT_TOKEN +CASE=/home/worsch/net-kingdom/security-case-types/oidc-service-client/examples/nk-bind-executor-approval.yaml +grep -q '^ status: materialized$' "$CASE" && echo case-materialized +REF="nk-bind-executor-approval@sha256:$(sha256sum "$CASE" | cut -d' ' -f1)" +bao login -no-print -method=oidc -path=netkingdom role=credential-broker-keycape-client-secret-generate +python3 scripts/credential.py generate --grant keycape/client-secret-generate --client-id nk-bind-executor-approval --case-file "$CASE" --case-ref "$REF" --dry-run --receipt /home/worsch/.local/state/railiance/receipts/nk-bind-executor-approval-mint-dry.json +python3 scripts/credential.py generate --grant keycape/client-secret-generate --client-id nk-bind-executor-approval --case-file "$CASE" --case-ref "$REF" --record-state-hub --receipt /home/worsch/.local/state/railiance/receipts/nk-bind-executor-approval-mint-1.json +bao token revoke -self +python3 -c 'import json; [print(r["outcome"], r["exit_code"], r.get("kv_version"), r.get("grant_token_revoked"), r["credential_values_emitted"]) for r in (json.load(open("/home/worsch/.local/state/railiance/receipts/nk-bind-executor-approval-mint-"+n+".json")) for n in ("dry","1"))]' +``` + +Expected: `case-materialized`; then `dry_run_would_generate 0 None True False` and +`generated 0 1 True False`. Exit 3 (`already_present`) means an entry exists and was not touched; stop +and reconcile, never rerun with another tool. No value is printed or returned. + +## Step 6: ESO delivery to `sso` + +```bash +cd /home/worsch/railiance-platform +KUBECONFIG=$HOME/.kube/config-railiance01 kubectl apply --dry-run=server -f manifests/nk-bind-executor-eso.yaml +KUBECONFIG=$HOME/.kube/config-railiance01 kubectl apply -f manifests/nk-bind-executor-eso.yaml +KUBECONFIG=$HOME/.kube/config-railiance01 kubectl wait --for=condition=Ready clustersecretstore/openbao-keycape-nk-bind-executor-approval-issuer clustersecretstore/openbao-nk-bind-executor-audit --timeout=120s +KUBECONFIG=$HOME/.kube/config-railiance01 kubectl -n sso wait --for=condition=Ready externalsecret/keycape-nk-bind-executor-approval-client externalsecret/bounded-executor-tokens --timeout=180s +``` + +Expected: four objects created; both stores and both ExternalSecrets `condition met`. These are status +reads only. + +## Step 7: verify (metadata, capabilities, logins and hashes only) + +```bash +cd /home/worsch/railiance-platform +KUBECONFIG=$HOME/.kube/config-railiance01 BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200 warden access openbao-platform-admin-login --exec -- python3 /home/worsch/railiance-platform/scripts/nk_bind_executor_custody.py verify --kubeconfig /home/worsch/.kube/config-railiance01 --receipt /home/worsch/.local/state/railiance/receipts/nk-bind-executor-verify-r1.json +python3 -c 'import json; r=json.load(open("/home/worsch/.local/state/railiance/receipts/nk-bind-executor-verify-r1.json")); print(r["status"], r.get("error"), r.get("phase"), r.get("keycape_client_secret_kv_version"), r.get("audit_sender_kv_version"), r.get("audit_copies_agree_by_hash"), r.get("keycape_client_secret_value_read"))' +BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200 warden access openbao-platform-admin-login --exec -- /home/worsch/audit-core/scripts/attended-seed-nk-bounded-executor-sender.sh /home/worsch/audit-core/docs/evidence/nk-bounded-executor-sender-seed-r2.json +``` + +Expected: `custody_verified_pending_keycape_and_executor None done 1 1 True False`. The verify proves: + +- policies and roles equal the CCR-generated bodies; the coding-agent fence beats each lane policy; a + default-only token cannot read either path; +- custody metadata: both paths at a live version 1; +- `sso/nk-bind-executor` logs in to its role with exactly one policy and a lease of at most 15m, reads + only its path (sibling KeyCape clients, the audit path, the svc-authelia bind, metadata and + `auth/token/create` denied); the `default` ServiceAccount, a wrong audience and the two ESO roles are + refused; `kubectl auth can-i get secrets -n sso` as the executor answers `no`; +- both stores and ExternalSecrets are Ready with the exact role, audience and one field; the + delivered Secrets hold exactly one key each; +- the audit bearer in custody, in `sso/bounded-executor-tokens` and in the registry row agree by + SHA-256 in memory; the registry row equals the declared scope. + +The KeyCape client secret is never read: its delivery is proven by ExternalSecret status and the +executor's own first token exchange (type verification `service-token-claims`, net-kingdom). + +The audit-core rerun (`-r2`) writes nothing; its `sso` hash comparison looks for the older Secret name +`nk-bind-executor-audit` and is skipped until audit-core aligns it to `bounded-executor-tokens`/`audit-core` (requested 2026-11-04). Step 7's hash check covers it either way. + +## Then (not platform) + +- net-kingdom: bind-env and register-client in one KeyCape rollout (see "Order relative to KeyCape"), + then `service-token-claims` and `excess-scope-refused` from the executor. +- net-kingdom: the window-scoped CCR-2026-0058 role per admission (`executor-lanes.py`), the founder's + admission, then the pilot run (NK-WP-0056-T06). +- railiance-platform: record the receipts as CCR evidence (`credential-change.py record-evidence`) and + move the three CCRs to `verified` after the first executor run. + +## Rollback + +- Executor reader: delete role `keycape-nk-bind-executor-approval-bounded-executor-workload-kv-read`, + then its policy. +- KeyCape delivery: only after net-kingdom has removed the client and the env binding, delete + ExternalSecret `sso/keycape-nk-bind-executor-approval-client`, the store, role + `external-secrets-keycape-nk-bind-executor-approval` and its policy; then the custody metadata. +- Audit sender: stop the executor, remove only its registry row with CAS and reload audit-core, then + delete `sso/bounded-executor-tokens`, its store, role and policy; keep the KV versions. +- The coding-agent fence lines stay. diff --git a/manifests/nk-bind-executor-eso.yaml b/manifests/nk-bind-executor-eso.yaml new file mode 100644 index 0000000..08815e7 --- /dev/null +++ b/manifests/nk-bind-executor-eso.yaml @@ -0,0 +1,101 @@ +# Bounded executor credential delivery (NK-WP-0056-T06; RPF-WP-0069-T05/T09). +# Apply only after CCR-2026-0060 and CCR-2026-0063 are approved and applied and both custody +# entries exist (docs/credential-lane-designs/nk-bind-executor-go-live.md). No value is in this file. +# +# 1. KeyCape's copy of the nk-bind-executor-approval client secret (CCR-2026-0060), rendered from +# manifests/templates/keycape-issuer-eso.yaml for client_id nk-bind-executor-approval. +apiVersion: external-secrets.io/v1 +kind: ClusterSecretStore +metadata: + name: openbao-keycape-nk-bind-executor-approval-issuer + annotations: + railiance.io/credential-change: CCR-2026-0060 +spec: + provider: + vault: + server: http://openbao.openbao.svc:8200 + path: platform + version: v2 + auth: + kubernetes: + mountPath: kubernetes + role: external-secrets-keycape-nk-bind-executor-approval + serviceAccountRef: + name: external-secrets + namespace: external-secrets + audiences: + - openbao + conditions: + - namespaces: + - sso +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: keycape-nk-bind-executor-approval-client + namespace: sso + annotations: + railiance.io/credential-change: CCR-2026-0060 +spec: + refreshInterval: 5m + secretStoreRef: + kind: ClusterSecretStore + name: openbao-keycape-nk-bind-executor-approval-issuer + target: + name: keycape-nk-bind-executor-approval-client + creationPolicy: Owner + deletionPolicy: Retain + data: + - secretKey: client-secret + remoteRef: + key: workloads/keycape/nk-bind-executor-approval + property: CLIENT_SECRET +--- +# 2. The executor's audit-core sender bearer (CCR-2026-0063). Secret name and key are the ones the +# rapp-bounded-executor run Job mounts (/var/run/secrets/bounded-executor/audit-core). +apiVersion: external-secrets.io/v1 +kind: ClusterSecretStore +metadata: + name: openbao-nk-bind-executor-audit + annotations: + railiance.io/credential-change: CCR-2026-0063 +spec: + provider: + vault: + server: http://openbao.openbao.svc:8200 + path: platform + version: v2 + auth: + kubernetes: + mountPath: kubernetes + role: external-secrets-nk-bind-executor-audit + serviceAccountRef: + name: external-secrets + namespace: external-secrets + audiences: + - openbao + conditions: + - namespaces: + - sso +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: bounded-executor-tokens + namespace: sso + annotations: + railiance.io/credential-change: CCR-2026-0063 +spec: + refreshInterval: 5m + secretStoreRef: + kind: ClusterSecretStore + name: openbao-nk-bind-executor-audit + target: + name: bounded-executor-tokens + creationPolicy: Owner + deletionPolicy: Retain + data: + - secretKey: audit-core + remoteRef: + key: workloads/net-kingdom/bounded-executor/audit-sender + property: AUDIT_TOKEN diff --git a/openbao/policies/agent-high-risk-boundary.hcl b/openbao/policies/agent-high-risk-boundary.hcl index 7ea03e9..ae63ebf 100644 --- a/openbao/policies/agent-high-risk-boundary.hcl +++ b/openbao/policies/agent-high-risk-boundary.hcl @@ -163,3 +163,8 @@ path "platform/data/workloads/net-kingdom/identity-backup/publisher" { capabilit path "platform/metadata/workloads/net-kingdom/identity-backup/publisher" { capabilities = ["deny"] } path "platform/data/workloads/net-kingdom/identity-backup/retention" { capabilities = ["deny"] } path "platform/metadata/workloads/net-kingdom/identity-backup/retention" { capabilities = ["deny"] } + +# Bounded-executor approval client secret (oidc-service-client@0.2 case nk-bind-executor-approval): +# CCR-2026-0060/0061 (RPF-WP-0069-T09). Applied by scripts/nk_bind_executor_custody.py fence. +path "platform/data/workloads/keycape/nk-bind-executor-approval" { capabilities = ["deny"] } +path "platform/metadata/workloads/keycape/nk-bind-executor-approval" { capabilities = ["deny"] } diff --git a/openbao/policies/workload-kv-read-nk-bind-executor-audit.hcl b/openbao/policies/workload-kv-read-nk-bind-executor-audit.hcl index a1efcff..f7e3aed 100644 --- a/openbao/policies/workload-kv-read-nk-bind-executor-audit.hcl +++ b/openbao/policies/workload-kv-read-nk-bind-executor-audit.hcl @@ -1,3 +1,11 @@ path "platform/data/workloads/net-kingdom/bounded-executor/audit-sender" { capabilities = ["read"] } + +path "auth/token/lookup-self" { + capabilities = ["read"] +} + +path "auth/token/revoke-self" { + capabilities = ["update"] +} diff --git a/scripts/nk_bind_executor_custody.py b/scripts/nk_bind_executor_custody.py new file mode 100644 index 0000000..f4417f8 --- /dev/null +++ b/scripts/nk_bind_executor_custody.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python3 +"""Attended custody for the bounded executor's credentials (NK-WP-0056-T06, RPF-WP-0069-T05/T09). + +Covers the platform side of three requests. The delegated applier +(credential-change.py applier-apply) writes their policies and roles; the +credential broker (credential.py generate) mints the KeyCape client secret. +audit-core's founder-run scripts/seed-nk-bounded-executor-sender.py seeds the audit +bearer (CAS=0 custody plus registry merge and receiver reload). This script adds +what none of those does: + + plan offline: check the three CCRs against the fixed contract and print names and paths. + fence attended platform-admin: add the coding-agent deny for both custody paths to the live + agent-high-risk-boundary (append only, live content preserved) and read it back. + verify attended platform-admin plus admin kubeconfig: metadata, capabilities, login results and + in-memory hashes only. Never reads the KeyCape client secret's data path. + + CCR-2026-0060 KeyCape ESO reader of platform/workloads/keycape/nk-bind-executor-approval + CCR-2026-0061 executor (sso/nk-bind-executor) reader of the same path + CCR-2026-0063 audit sender custody platform/workloads/net-kingdom/bounded-executor/audit-sender + +Every action except plan requires the three CCRs approved by their named owners with confirmed bindings, +committed unmodified in Git, and the Warden attended-login envelope (no BAO_TOKEN/VAULT_TOKEN, a +platform-admin token that is not root). fence and verify print nothing (the Warden envelope fails +closed on output); the exit code is 0 or 1 and the receipt (mode 0600, refused if it exists) holds names, versions, booleans and digests only. +""" +from __future__ import annotations + +import argparse +import base64 +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import subprocess +from datetime import datetime, timezone + +from state_hub_preflight_lane import (ROOT, LaneError, assert_cluster, bao, capabilities, command, data, + revoke) + +BOUNDARY = 'sys/policies/acl/agent-high-risk-boundary' +REGISTRY = 'platform/data/workloads/audit-core/senders' +CLIENT_ID = 'nk-bind-executor-approval' +KEYCAPE_KV = 'platform/workloads/keycape/' + CLIENT_ID +AUDIT_KV = 'platform/workloads/net-kingdom/bounded-executor/audit-sender' +SA, NS = 'nk-bind-executor', 'sso' +ESO = ('external-secrets', 'external-secrets') +SENDER = { + 'name': 'nk-bounded-executor', + 'sources': ['net-kingdom.bounded-executor'], + 'tenants': ['platform'], + 'may_write': True, + 'may_read': False, + 'evidence_kind': 'load-bearing', + 'secret_policy': 'reject', + 'closing_classes': {'execution.run.started': {'closed_by': 'execution.attribution.receipt', + 'within_seconds': 7200}}, +} + + +def data_path(kv): + return kv.replace('platform/', 'platform/data/', 1) + + +def metadata_path(kv): + return kv.replace('platform/', 'platform/metadata/', 1) + + +# The fixed contract. Any drift in a CCR refuses before OpenBao is contacted. +LANES = { + 'eso': { + 'ccr': 'CCR-2026-0060', 'file': 'CCR-2026-0060-nk-bind-executor-approval-keycape-eso-reader.yaml', + 'approvers': ('platform-owner',), 'kv': KEYCAPE_KV, 'fields': ['CLIENT_SECRET'], + 'policy': 'workload-kv-read-keycape-nk-bind-executor-approval-eso', + 'role': 'external-secrets-keycape-nk-bind-executor-approval', 'bound': ESO, + 'external_secret': ('sso', 'keycape-nk-bind-executor-approval-client'), + 'store': 'openbao-keycape-nk-bind-executor-approval-issuer', 'secret_key': 'client-secret', + }, + 'consumer': { + 'ccr': 'CCR-2026-0061', 'file': 'CCR-2026-0061-nk-bind-executor-approval-consumer-reader.yaml', + 'approvers': ('platform-owner', 'net-kingdom-owner'), 'kv': KEYCAPE_KV, 'fields': ['CLIENT_SECRET'], + 'policy': 'workload-kv-read-keycape-nk-bind-executor-approval-bounded-executor', + 'role': 'keycape-nk-bind-executor-approval-bounded-executor-workload-kv-read', 'bound': (SA, NS), + }, + 'audit': { + 'ccr': 'CCR-2026-0063', 'file': 'CCR-2026-0063-nk-bind-executor-audit-sender.yaml', + 'approvers': ('platform-owner', 'audit-core-owner', 'net-kingdom-owner'), 'kv': AUDIT_KV, + 'fields': ['AUDIT_TOKEN', 'CUSTODY_REQUEST'], + 'policy': 'workload-kv-read-nk-bind-executor-audit', + 'role': 'external-secrets-nk-bind-executor-audit', 'bound': ESO, + 'external_secret': ('sso', 'bounded-executor-tokens'), + 'store': 'openbao-nk-bind-executor-audit', 'secret_key': 'audit-core', + }, +} +FENCED = (data_path(KEYCAPE_KV), metadata_path(KEYCAPE_KV), data_path(AUDIT_KV), metadata_path(AUDIT_KV)) + + +def require(condition, code): + if not condition: + raise LaneError(code) + + +def credential_module(): + spec = importlib.util.spec_from_file_location('nk_ccr', ROOT / 'scripts/credential-change.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def committed(path): + """The CCR must be the reviewed, committed bytes: tracked and unmodified.""" + result = subprocess.run(['git', '-C', str(ROOT), 'status', '--porcelain', '--', str(path)], + capture_output=True, text=True, timeout=20) + tracked = subprocess.run(['git', '-C', str(ROOT), 'ls-files', '--error-unmatch', str(path)], + capture_output=True, timeout=20) + return result.returncode == 0 and not result.stdout.strip() and tracked.returncode == 0 + + +def contracts(*, approved, module=None, git_check=committed): + module = module or credential_module() + out = {} + for name, lane in LANES.items(): + path = ROOT / 'credential-change-requests' / lane['file'] + ccr, errors, _ = module.validate_ccr(path) + require(not errors and ccr['id'] == lane['ccr'], name + ':invalid_credential_request') + o = ccr['openbao'] + auth = o['auth'] + require(ccr['request_type'] == 'workload-kv-read' and o['kv_path'] == lane['kv'] + and o['fields'] == lane['fields'] and o['policy_name'] == lane['policy'] + and o.get('metadata_read') is False and o.get('token_self_lifecycle') is True + and auth.get('method') == 'kubernetes' and auth.get('mount') == 'kubernetes' + and auth.get('role') == lane['role'] and auth.get('policies') == [lane['policy']] + and auth.get('audience') == 'openbao' and auth.get('token_no_default_policy') is True + and all(auth.get(k) == '15m' for k in ('ttl', 'token_max_ttl', 'token_explicit_max_ttl')) + and not auth.get('token_num_uses') + and auth.get('bound_claims') == {'service_account_names': [lane['bound'][0]], + 'service_account_namespaces': [lane['bound'][1]]}, + name + ':exact_contract_required') + hcl = module.generated_policy_hcl(ccr) + require(module.normalized_policy_body((ROOT / o['policy_file']).read_text()) + == module.normalized_policy_body(hcl), name + ':policy_source_drift') + if approved: + require(ccr['status'] in {'approved', 'applied', 'verified', 'active'}, name + ':approved_ccr_required') + given = {c.get('reviewer') for c in ccr['review'].get('comments', []) if c.get('decision') == 'approved'} + require(set(lane['approvers']) <= given, name + ':named_owner_approvals_required') + require(auth.get('bound_claims_confirmed') is True, name + ':binding_unconfirmed') + require(git_check(path), name + ':ccr_not_committed') + out[name] = {'ccr': ccr, 'hcl': hcl, 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} + return out + + +def attended_platform_admin(): + 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') + identity = data(bao(['token', 'lookup', '-format=json']))['data'] + require('platform-admin' in identity.get('policies', []) and 'root' not in identity.get('policies', []), + 'attended_platform_admin_required') + + +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 + or b'No value found' in result.stdout, 'absence_not_proven') + return None + + +def deny_line(path): + return f'path "{path}" {{ capabilities = ["deny"] }}' + + +def fence(receipt): + current = read_optional(BOUNDARY) + require(current is not None, 'coding_agent_boundary_required') + current = current['policy'] + additions = [] + for path in FENCED: + if f'"{path}"' in current: + require(re.search(r'path\s+"' + re.escape(path) + r'"\s*\{\s*capabilities\s*=\s*\["deny"\]\s*\}', + current), 'boundary_path_drift') + else: + additions.append(deny_line(path)) + if additions: + block = ('\n# Bounded executor custody (CCR-2026-0060/0061/0063), ' + 'scripts/nk_bind_executor_custody.py fence.\n' + '\n'.join(additions) + '\n') + require(read_optional(BOUNDARY)['policy'] == current, 'boundary_revision_changed') + bao(['write', BOUNDARY, '-'], payload={'policy': current + block}) + require(read_optional(BOUNDARY)['policy'] == current + block, 'boundary_readback_failed') + receipt.update(fence_lines_added=len(additions), fenced_paths=list(FENCED), status='fenced') + + +def registry_snapshot(): + raw = data(bao(['read', '-format=json', REGISTRY]))['data'] + version = raw['metadata']['version'] + require(type(version) is int and version > 0, 'registry_version_missing') + body = raw['data'] + require(isinstance(body, dict) and isinstance(body.get('senders.json'), str), 'registry_shape_invalid') + try: + rows = json.loads(body['senders.json']) + except (ValueError, TypeError): + raise LaneError('registry_shape_invalid') from None + require(isinstance(rows, list) and bool(rows), 'registry_must_preserve_existing_senders') + names, tokens = set(), set() + for row in rows: + require(isinstance(row, dict) and isinstance(row.get('name'), str) and row['name'] + and row['name'] not in names, 'registry_identity_invalid_or_duplicate') + names.add(row['name']) + values = row.get('tokens') or ([row['token']] if row.get('token') else []) + require(isinstance(values, list) and bool(values), 'registry_tokens_invalid') + for value in values: + require(isinstance(value, str) and value and value not in tokens, 'registry_token_collision') + tokens.add(value) + return version, body, rows, tokens + + +def role_ok(actual, lane): + return (actual.get('bound_service_account_names') == [lane['bound'][0]] + and actual.get('bound_service_account_namespaces') == [lane['bound'][1]] + and not actual.get('bound_service_account_namespace_selector') + and actual.get('token_policies') == [lane['policy']] and actual.get('audience') == 'openbao' + and actual.get('token_ttl') == 900 and actual.get('token_max_ttl') == 900 + and actual.get('token_explicit_max_ttl') == 900 and actual.get('token_no_default_policy') is True + and not actual.get('token_period') and not actual.get('token_num_uses')) + + +def kube_json(kube, args): + return data(command(kube + [*args, '-o', 'json'])) + + +def ready(obj): + return any(c.get('type') == 'Ready' and c.get('status') == 'True' + for c in (obj.get('status') or {}).get('conditions', [])) + + +def sha(value): + return hashlib.sha256(value.encode() if isinstance(value, str) else value).hexdigest() + + +def login(kube, sa, namespace, audience, role, negative=False): + jwt = command(kube + ['-n', namespace, 'create', 'token', sa, '--audience=' + audience, + '--duration=10m']).stdout.decode().strip() + result = bao(['write', '-format=json', 'auth/kubernetes/login', '-'], payload={'role': role, 'jwt': jwt}, + allow_failure=True) + del jwt + if negative: + if result.returncode == 0: + revoke(data(result)['auth']['client_token']) + raise LaneError('negative_login_accepted') + require(b'403' in result.stderr or b'400' in result.stderr, 'negative_login_unclear') + return None + require(result.returncode == 0, 'login_refused') + return data(result)['auth'] + + +def verify(kube, contract, receipt): + module = credential_module() + receipt['phase'] = 'read_back' + for name, lane in LANES.items(): + live = read_optional('sys/policies/acl/' + lane['policy']) + require(live is not None and module.normalized_policy_body(live['policy']) + == module.normalized_policy_body(contract[name]['hcl']), name + ':policy_drift') + role = read_optional('auth/kubernetes/role/' + lane['role']) + require(role is not None and role_ok(role, lane), name + ':role_drift') + boundary = read_optional(BOUNDARY)['policy'] + require(all(deny_line(p) in boundary or re.search(r'path\s+"' + re.escape(p) + r'"\s*\{\s*capabilities\s*=\s*\["deny"\]\s*\}', boundary) + for p in FENCED), 'agent_fence_missing') + + receipt['phase'] = 'custody_metadata' + for kv, key in ((KEYCAPE_KV, 'keycape_client_secret'), (AUDIT_KV, 'audit_sender')): + meta = read_optional(metadata_path(kv)) + require(meta is not None, key + ':custody_missing') + current = meta.get('current_version') + version = (meta.get('versions') or {}).get(str(current)) or {} + require(type(current) is int and current >= 1 and not version.get('deletion_time') + and not version.get('destroyed'), key + ':custody_version_not_live') + receipt[key + '_kv_version'] = current + + receipt['phase'] = 'fence_effective' + for name, lane in LANES.items(): + child = data(bao(['token', 'create', '-format=json', '-policy=' + lane['policy'], + '-policy=agent-high-risk-boundary', '-no-default-policy', '-ttl=60s']))['auth']['client_token'] + try: + caps = capabilities(child, [data_path(lane['kv']), metadata_path(lane['kv'])]) + require(all(v == ['deny'] for v in caps.values()), name + ':fence_ineffective') + finally: + revoke(child) + default_child = data(bao(['token', 'create', '-format=json', '-policy=default', '-ttl=60s']))['auth']['client_token'] + try: + caps = capabilities(default_child, [data_path(KEYCAPE_KV), data_path(AUDIT_KV)]) + require(all(v == ['deny'] for v in caps.values()), 'default_identity_can_read') + finally: + revoke(default_child) + + receipt['phase'] = 'consumer_login' + consumer = LANES['consumer'] + auth = login(kube, SA, NS, 'openbao', consumer['role']) + token = auth['client_token'] + try: + require(auth['token_policies'] == [consumer['policy']] and not auth.get('identity_policies') + and 0 < auth['lease_duration'] <= 900, 'consumer_policy_or_ttl') + siblings = ['platform/data/workloads/keycape/secrets-engine-openbao', + 'platform/data/workloads/keycape/forgejo', data_path(AUDIT_KV), + 'platform/data/workloads/net-kingdom/directory-binds/svc-authelia', + metadata_path(KEYCAPE_KV), 'platform/metadata/workloads/keycape', 'auth/token/create'] + caps = capabilities(token, [data_path(KEYCAPE_KV), *siblings]) + require(caps[data_path(KEYCAPE_KV)] == ['read'] and all(caps[p] == ['deny'] for p in siblings), + 'consumer_capabilities') + finally: + revoke(token) + + receipt['phase'] = 'negative_logins' + login(kube, 'default', NS, 'openbao', consumer['role'], True) + login(kube, SA, NS, 'wrong-audience', consumer['role'], True) + login(kube, SA, NS, 'openbao', LANES['eso']['role'], True) + login(kube, SA, NS, 'openbao', LANES['audit']['role'], True) + + receipt['phase'] = 'rbac' + can = command(kube + ['auth', 'can-i', 'get', 'secrets', '-n', NS, + '--as=system:serviceaccount:%s:%s' % (NS, SA)], allow_failure=True) + require(can.stdout.strip() == b'no', 'consumer_can_read_secrets_in_sso') + + receipt['phase'] = 'eso_delivery' + for name in ('eso', 'audit'): + lane = LANES[name] + store = kube_json(kube, ['get', 'clustersecretstore', lane['store']]) + vault = store['spec']['provider']['vault'] + k8s = vault['auth']['kubernetes'] + require(ready(store) and vault['path'] == 'platform' and k8s['role'] == lane['role'] + and k8s['serviceAccountRef'] == {'name': 'external-secrets', 'namespace': 'external-secrets', + 'audiences': ['openbao']} + and store['spec'].get('conditions') == [{'namespaces': [NS]}], name + ':store_drift_or_not_ready') + namespace, es_name = lane['external_secret'] + es = kube_json(kube, ['-n', namespace, 'get', 'externalsecret', es_name]) + entries = es['spec']['data'] + require(ready(es) and es['spec']['secretStoreRef'] == {'kind': 'ClusterSecretStore', 'name': lane['store']} + and len(entries) == 1 and entries[0]['secretKey'] == lane['secret_key'] + and entries[0]['remoteRef']['key'] == lane['kv'].removeprefix('platform/') + and entries[0]['remoteRef']['property'] == lane['fields'][0], name + ':external_secret_drift_or_not_ready') + target = es['spec']['target'].get('name', es_name) + secret = kube_json(kube, ['-n', namespace, 'get', 'secret', target]) + require(sorted(secret.get('data') or {}) == [lane['secret_key']], name + ':delivered_keys') + receipt[name + '_delivery'] = {'external_secret': f'{namespace}/{es_name}', 'ready': True, + 'secret_uid': secret['metadata']['uid'], 'keys': [lane['secret_key']]} + if name == 'audit': + # Hash equality in memory: custody, delivered Secret and registry row hold the same bearer. + custody = data(bao(['read', '-format=json', data_path(AUDIT_KV)]))['data']['data'] + require(sorted(custody) == ['AUDIT_TOKEN', 'CUSTODY_REQUEST'] + and custody['CUSTODY_REQUEST'] == 'CCR-2026-0063', 'audit_custody_shape') + digest = sha(custody['AUDIT_TOKEN']) + delivered = sha(base64.b64decode(secret['data'][lane['secret_key']])) + _, _, rows, _ = registry_snapshot() + row = [r for r in rows if r['name'] == SENDER['name']] + require(len(row) == 1 and {k: v for k, v in row[0].items() if k != 'tokens'} == SENDER + and [sha(t) for t in row[0]['tokens']] == [digest], 'audit_registry_row_mismatch') + require(delivered == digest, 'audit_delivery_disagrees_with_custody') + del custody, rows, row + receipt['audit_copies_agree_by_hash'] = True + receipt.update(phase='done', status='custody_verified_pending_keycape_and_executor', + exact_policies_and_roles=True, coding_agents_denied=True, default_identity_denied=True, + consumer_reads_only_its_path=True, consumer_lease_at_most_15m=True, + wrong_service_account_denied=True, wrong_audience_denied=True, + consumer_cannot_use_eso_roles=True, consumer_cannot_read_sso_secrets=True, + keycape_client_secret_value_read=False) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('action', choices=['plan', 'fence', 'verify']) + parser.add_argument('--receipt', type=Path) + parser.add_argument('--kubeconfig') + args = parser.parse_args(argv) + receipt = {'schema': 'platform.nk-bind-executor-custody.v1', 'action': args.action, 'status': 'refused', + 'credential_values_emitted': False, 'started_at': datetime.now(timezone.utc).isoformat()} + fd = None + + def save(): + if fd is not None: + os.lseek(fd, 0, os.SEEK_SET) + os.ftruncate(fd, 0) + os.write(fd, (json.dumps(receipt, indent=2, sort_keys=True) + '\n').encode()) + os.fsync(fd) + try: + if args.action == 'plan': + contract = contracts(approved=False) + receipt.update(status='proposed', fenced_paths=list(FENCED), sender=SENDER, lanes={ + n: {'ccr': l['ccr'], 'status': contract[n]['ccr']['status'], 'kv': l['kv'], 'policy': l['policy'], + 'role': l['role'], 'bound': '/'.join(reversed(l['bound'])), + 'store': l.get('store'), 'external_secret': '/'.join(l.get('external_secret', ())) or None, + 'ccr_sha256': contract[n]['sha256']} for n, l in LANES.items()}) + print(json.dumps(receipt, indent=2)) + return 0 + require(args.receipt is not None, 'receipt_required') + fd = os.open(args.receipt, os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + receipt['phase'] = 'contract' + contract = contracts(approved=True) + receipt['ccr_sha256'] = {LANES[n]['ccr']: c['sha256'] for n, c in contract.items()} + receipt['phase'] = 'identity' + attended_platform_admin() + save() + if args.action == 'fence': + fence(receipt) + else: + require(args.kubeconfig, 'kubeconfig_required') + kube = ['kubectl', '--kubeconfig', args.kubeconfig, '--request-timeout=20s'] + assert_cluster(kube) + verify(kube, contract, receipt) + code = 0 + except LaneError as exc: + receipt.update(status='refused', error=str(exc)) + code = 1 + except Exception: + receipt.update(status='refused', error='contained_operation_failed') + code = 1 + receipt['finished_at'] = datetime.now(timezone.utc).isoformat() + save() + if fd is not None: + os.close(fd) + # Silent outside plan: the Warden envelope fails closed on any output. Read the receipt. + return code + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_nk_bind_executor_custody.py b/tests/test_nk_bind_executor_custody.py new file mode 100644 index 0000000..ebf3347 --- /dev/null +++ b/tests/test_nk_bind_executor_custody.py @@ -0,0 +1,277 @@ +"""Synthetic-fixture tests for scripts/nk_bind_executor_custody.py; no OpenBao, cluster or credential.""" +import base64 +import copy +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'scripts')) +import nk_bind_executor_custody as lane # noqa: E402 +import keycape_issuer_eso # noqa: E402 + +AUDIT_CORE = Path(os.environ.get('AUDIT_CORE_SOURCE', ROOT.parent / 'audit-core')) +RAPP = ROOT.parent / 'rapp-bounded-executor' + + +def approved_module(mutate=None, drop=None): + module = lane.credential_module() + original = module.validate_ccr + + def candidate(path): + ccr, errors, warnings = original(path) + ccr = copy.deepcopy(ccr) + ccr['status'] = 'approved' + ccr['openbao']['auth']['bound_claims_confirmed'] = True + owners = next(l['approvers'] for l in lane.LANES.values() if l['ccr'] == ccr['id']) + ccr['review']['comments'] = [{'reviewer': o, 'decision': 'approved'} for o in owners if o != drop] + if mutate: + mutate(ccr) + return ccr, errors, warnings + module.validate_ccr = candidate + return module + + +def ok(payload=None, stderr=b''): + return subprocess.CompletedProcess([], 0, json.dumps(payload).encode() if payload is not None else b'', stderr) + + +def missing(): + return subprocess.CompletedProcess([], 2, b'', b'No value found at path') + + +class FakeBao: + """Dictionary-backed KV v2 and sys/policies with CAS; records every write.""" + + def __init__(self, registry_rows, boundary='path "platform/data/x" { capabilities = ["deny"] }\n'): + self.kv = {lane.REGISTRY: {'version': 3, 'data': {'senders.json': json.dumps(registry_rows), + 'other': 'kept'}}} + self.policies = {'agent-high-risk-boundary': boundary} + self.writes = [] + self.fail_registry_cas = False + + def __call__(self, args, payload=None, token=None, allow_failure=False): + if args[0] == 'read': + path = args[-1] + if path.startswith('sys/policies/acl/'): + name = path.rsplit('/', 1)[1] + return ok({'data': {'policy': self.policies[name]}}) if name in self.policies else missing() + if path.startswith('platform/metadata/'): + key = path.replace('platform/metadata/', 'platform/data/', 1) + if key not in self.kv: + return missing() + v = self.kv[key]['version'] + return ok({'data': {'current_version': v, 'versions': {str(v): {}}}}) + if path in self.kv: + entry = self.kv[path] + return ok({'data': {'data': entry['data'], 'metadata': {'version': entry['version']}}}) + return missing() + if args[0] == 'write': + path = args[-2] + self.writes.append((path, copy.deepcopy(payload))) + if path.startswith('sys/policies/acl/'): + self.policies[path.rsplit('/', 1)[1]] = payload['policy'] + return ok({}) + cas = payload['options']['cas'] + current = self.kv.get(path, {'version': 0})['version'] + if cas != current or (path == lane.REGISTRY and self.fail_registry_cas): + return subprocess.CompletedProcess([], 2, b'', b'check-and-set parameter did not match') + self.kv[path] = {'version': current + 1, 'data': copy.deepcopy(payload['data'])} + return ok({'data': {'version': current + 1}, 'request_id': 'req-%d' % len(self.writes)}) + raise AssertionError('unexpected bao call %r' % (args,)) + + +EXISTING = [{'name': 'approval-engine', 'tokens': ['A' * 64], 'sources': ['approval-engine'], + 'tenants': ['tenant:platform'], 'may_write': True, 'may_read': False}, + {'name': 'informed-decision', 'tokens': ['B' * 64], 'sources': ['informed-decision'], + 'tenants': ['tenant:platform'], 'may_write': True, 'may_read': False}] + + +class Contracts(unittest.TestCase): + def test_plan_checks_the_drafts_offline(self): + contract = lane.contracts(approved=False) + self.assertEqual(sorted(contract), ['audit', 'consumer', 'eso']) + out = io.StringIO() + with patch.object(lane, 'bao', side_effect=AssertionError('plan must not contact Bao')), redirect_stdout(out): + self.assertEqual(lane.main(['plan']), 0) + plan = json.loads(out.getvalue()) + self.assertEqual(plan['lanes']['consumer']['role'], + 'keycape-nk-bind-executor-approval-bounded-executor-workload-kv-read') + self.assertEqual(plan['lanes']['audit']['external_secret'], 'sso/bounded-executor-tokens') + + def test_unapproved_requests_refuse(self): + module = lane.credential_module() + original = module.validate_ccr + + def proposed(path): + ccr, e, w = original(path) + ccr = copy.deepcopy(ccr) + ccr['status'] = 'proposed' + return ccr, e, w + module.validate_ccr = proposed + with self.assertRaisesRegex(lane.LaneError, 'approved_ccr_required'): + lane.contracts(approved=True, module=module, git_check=lambda p: True) + + def test_every_named_owner_must_approve(self): + for name, spec in lane.LANES.items(): + for owner in spec['approvers']: + module = approved_module(drop=owner) + with self.assertRaisesRegex(lane.LaneError, 'named_owner_approvals_required'): + lane.contracts(approved=True, module=module, git_check=lambda p: True) + + def test_unconfirmed_binding_or_uncommitted_ccr_refuses(self): + module = approved_module(lambda c: c['openbao']['auth'].update(bound_claims_confirmed=False)) + with self.assertRaisesRegex(lane.LaneError, 'binding_unconfirmed'): + lane.contracts(approved=True, module=module, git_check=lambda p: True) + with self.assertRaisesRegex(lane.LaneError, 'ccr_not_committed'): + lane.contracts(approved=True, module=approved_module(), git_check=lambda p: False) + + def test_drifted_coordinates_refuse_even_when_approved(self): + drifts = [lambda c: c['openbao'].update(kv_path='platform/workloads/keycape/forgejo'), + lambda c: c['openbao']['auth'].update(audience='vault'), + lambda c: c['openbao']['auth'].update(ttl='1h'), + lambda c: c['openbao']['auth']['bound_claims'].update(service_account_names=['default'])] + for drift in drifts: + with self.assertRaisesRegex(lane.LaneError, 'exact_contract_required'): + lane.contracts(approved=True, module=approved_module(drift), git_check=lambda p: True) + + def test_approved_contract_passes(self): + contract = lane.contracts(approved=True, module=approved_module(), git_check=lambda p: True) + self.assertIn('auth/token/revoke-self', contract['audit']['hcl']) + + +class Fence(unittest.TestCase): + def test_appends_only_missing_denies_and_preserves_live_content(self): + live = 'path "platform/data/x" { capabilities = ["deny"] }\n' + lane.deny_line(lane.FENCED[2]) + '\n' + fake = FakeBao([], boundary=live) + receipt = {} + with patch.object(lane, 'bao', side_effect=fake): + lane.fence(receipt) + self.assertEqual(receipt['fence_lines_added'], 3) + after = fake.policies['agent-high-risk-boundary'] + self.assertTrue(after.startswith(live)) + for path in lane.FENCED: + self.assertIn(f'"{path}"', after) + lane.fence(receipt) + self.assertEqual(receipt['fence_lines_added'], 0) + self.assertEqual(len(fake.writes), 1) + + def test_present_path_without_deny_refuses(self): + live = 'path "%s" { capabilities = ["read"] }\n' % lane.FENCED[0] + fake = FakeBao([], boundary=live) + with patch.object(lane, 'bao', side_effect=fake), self.assertRaisesRegex(lane.LaneError, 'boundary_path_drift'): + lane.fence({}) + self.assertEqual(fake.writes, []) + + def test_source_boundary_carries_the_same_denies(self): + source = (ROOT / 'openbao/policies/agent-high-risk-boundary.hcl').read_text() + for path in lane.FENCED: + self.assertIn(lane.deny_line(path), source) + + +class Registry(unittest.TestCase): + def test_snapshot_refuses_empty_duplicate_or_colliding_registry(self): + for rows in ([], EXISTING + [copy.deepcopy(EXISTING[0])], + EXISTING + [{'name': 'x', 'tokens': ['A' * 64]}]): + with patch.object(lane, 'bao', side_effect=FakeBao(rows)), self.assertRaises(lane.LaneError): + lane.registry_snapshot() + with patch.object(lane, 'bao', side_effect=FakeBao(EXISTING)): + self.assertEqual(lane.registry_snapshot()[0], 3) + + def test_receiver_parser_accepts_the_row_with_the_declared_overlay(self): + if not (AUDIT_CORE / 'audit_core/senders.py').exists(): + self.skipTest('audit-core source not checked out') + row = dict(lane.SENDER, tokens=['fixture-' + 'x' * 56]) + overlay = (AUDIT_CORE / 'deploy/senders-scope.json').read_text() + self.assertIn(lane.SENDER['name'], [e['name'] for e in json.loads(overlay)]) + probe = ('import json,sys\nfrom audit_core.senders import SenderRegistry\n' + 'row,overlay=json.loads(sys.argv[1]),sys.argv[2]\n' + 'r=SenderRegistry.from_env({"AUDIT_CORE_SENDERS":json.dumps([row]),"AUDIT_CORE_SENDERS_SCOPE":overlay})\n' + 's=r.authenticate("Bearer "+row["tokens"][0])\n' + 'print(json.dumps([s.secret_policy,s.permits_source("net-kingdom.bounded-executor"),' + 's.permits_tenant("platform"),s.permits_tenant("tenant:platform"),s.may_read]))\n') + result = subprocess.run([sys.executable, '-c', probe, json.dumps(row), overlay], cwd=AUDIT_CORE, + capture_output=True, text=True) + if result.returncode != 0: + self.skipTest('audit-core dependencies unavailable: ' + result.stderr.strip().splitlines()[-1]) + self.assertEqual(json.loads(result.stdout), ['reject', True, True, False, False]) + + +class Manifests(unittest.TestCase): + docs = list(yaml.safe_load_all((ROOT / 'manifests/nk-bind-executor-eso.yaml').read_text())) + + def test_keycape_delivery_is_the_platform_template_rendering(self): + store, external = keycape_issuer_eso.documents(lane.CLIENT_ID) + self.assertEqual(self.docs[0]['spec'], store['spec']) + self.assertEqual(self.docs[0]['metadata']['name'], store['metadata']['name']) + self.assertEqual(self.docs[1]['spec'], external['spec']) + self.assertEqual(self.docs[1]['metadata']['name'], external['metadata']['name']) + self.assertEqual(self.docs[1]['metadata']['namespace'], 'sso') + + def test_each_store_matches_its_lane_and_delivers_one_field(self): + for (store, external), name in zip((self.docs[0:2], self.docs[2:4]), ('eso', 'audit')): + spec = lane.LANES[name] + auth = store['spec']['provider']['vault']['auth']['kubernetes'] + self.assertEqual(store['metadata']['name'], spec['store']) + self.assertEqual(auth['role'], spec['role']) + self.assertEqual(auth['serviceAccountRef'], {'name': 'external-secrets', 'namespace': 'external-secrets', + 'audiences': ['openbao']}) + self.assertEqual(store['spec']['conditions'], [{'namespaces': ['sso']}]) + self.assertEqual((external['metadata']['namespace'], external['metadata']['name']), spec['external_secret']) + self.assertEqual(external['spec']['data'], [{'secretKey': spec['secret_key'], 'remoteRef': { + 'key': spec['kv'].removeprefix('platform/'), 'property': spec['fields'][0]}}]) + + def test_audit_secret_is_the_one_the_executor_job_mounts(self): + job = RAPP / 'templates/job.yaml' + if not job.exists(): + self.skipTest('rapp-bounded-executor not checked out') + pod = list(yaml.safe_load_all(job.read_text()))[1]['spec']['template']['spec'] + volume = next(v for v in pod['volumes'] if v['name'] == 'tokens')['secret'] + self.assertEqual(volume['secretName'], lane.LANES['audit']['external_secret'][1]) + self.assertEqual([i['key'] for i in volume['items']], [lane.LANES['audit']['secret_key']]) + + +class Main(unittest.TestCase): + def test_refuses_outside_approval_and_never_prints_a_value(self): + with tempfile.TemporaryDirectory() as directory: + receipt = Path(directory) / 'r.json' + out = io.StringIO() + with patch.object(lane, 'bao', side_effect=AssertionError('must not contact Bao')), redirect_stdout(out): + code = lane.main(['fence', '--receipt', str(receipt)]) + self.assertEqual(code, 1) + body = json.loads(receipt.read_text()) + self.assertEqual(body['status'], 'refused') + self.assertEqual(oct(receipt.stat().st_mode & 0o777), '0o600') + self.assertEqual(out.getvalue(), '') + with redirect_stdout(io.StringIO()): + self.assertEqual(lane.main(['verify', '--receipt', str(receipt)]), 1) # existing receipt refused + + +class Verify(unittest.TestCase): + def test_delivered_audit_copy_must_hash_equal_custody(self): + token = 'T' * 64 + self.assertEqual(lane.sha(token), lane.sha(base64.b64decode(base64.b64encode(token.encode())))) + self.assertNotEqual(lane.sha(token), lane.sha('U' * 64)) + + def test_role_readback_shape(self): + spec = lane.LANES['consumer'] + good = {'bound_service_account_names': ['nk-bind-executor'], 'bound_service_account_namespaces': ['sso'], + 'token_policies': [spec['policy']], 'audience': 'openbao', 'token_ttl': 900, 'token_max_ttl': 900, + 'token_explicit_max_ttl': 900, 'token_no_default_policy': True} + self.assertTrue(lane.role_ok(good, spec)) + for key, value in (('audience', ''), ('token_ttl', 3600), ('token_policies', [spec['policy'], 'x']), + ('bound_service_account_namespace_selector', '{}'), ('token_period', 60)): + self.assertFalse(lane.role_ok(dict(good, **{key: value}), spec)) + + +if __name__ == '__main__': + unittest.main() diff --git a/workplans/RPF-WP-0069-bounded-executor-lanes.md b/workplans/RPF-WP-0069-bounded-executor-lanes.md index f845bbd..e4aa634 100644 --- a/workplans/RPF-WP-0069-bounded-executor-lanes.md +++ b/workplans/RPF-WP-0069-bounded-executor-lanes.md @@ -9,7 +9,7 @@ flavor: implementation owner: claude topic_slug: railiance created: "2026-10-31" -updated: "2026-11-03" +updated: "2026-11-04" related: - NK-WP-0056 - NK-WP-0040 @@ -124,7 +124,7 @@ id: RPF-WP-0069-T05 status: wait priority: medium blocked_on: human -blocking_reason: "Owner reviews of CCR-2026-0063 (platform-owner, audit-core-owner, net-kingdom-owner)" +blocking_reason: "Founder approvals of CCR-2026-0063 (platform-owner, audit-core-owner, net-kingdom-owner), then the go-live runbook (T10)" state_hub_task_id: "5c582aef-ee9d-56ab-a4eb-2019933f00ac" ``` @@ -152,6 +152,18 @@ ESO Secret, while audit-core and our answer specify ESO. The CCR records this. A binding confirmation and the attended seed follow, after audit-core's founder-run rollout and the executor's 202 fix. +2026-11-04: NetKingdom answered the open question: ESO as drafted (recorded as a review comment, not +as the net-kingdom-owner approval, which is the founder's). Drafting fixes: + +- Delivery target aligned with the consumer: `sso/bounded-executor-tokens`, key `audit-core`, which the + rapp run Job already mounts (was `sso/nk-bind-executor-audit`/`audit-token`). The platform carries the + store and ExternalSecret in `manifests/nk-bind-executor-eso.yaml`, as for CCR-2026-0021/0022/0047. +- Reader role brought to the CCR-2026-0033/0041/0060 shape: audience `openbao`, explicit max TTL 15m, + no default policy, token self-lifecycle (policy file regenerated). +- One seeder: `custody_procedure` names audit-core's `scripts/attended-seed-nk-bounded-executor-sender.sh` + (CAS 0 custody, registry CAS merge, receiver reload and probe). railiance-platform adds only the fence + and the verify. audit-core asked to align its `sso` Secret name and key. + ## LLDAP credential custody for the executor ```task @@ -257,3 +269,27 @@ Waits for net-kingdom's acceptance of case `nk-bind-executor-approval` and the a 2. confirm the bindings: `external-secrets/external-secrets` for 0060, `sso/nk-bind-executor` (uid bd15569c) for 0061; 3. the delegated applier or an attended platform-admin applies, with value-free positive and negative receipts. + +2026-11-04: CCR-2026-0060/0061 checked against the type 0.2 rendering (net-kingdom +`python3 -m pytest security-case-types` 36 pass, including the CCR name comparison) and each other. +CCR-2026-0060 gained its `delivery` block (store `openbao-keycape-nk-bind-executor-approval-issuer`, rendered +from the platform template); both name the coding-agent fence step. The source boundary carries the +KeyCape path deny. Founder sequence: T10. + +## Bounded executor credential go-live runbook + +```task +id: RPF-WP-0069-T10 +status: wait +priority: high +needs_human: true +blocked_on: human +blocking_reason: "Founder runs docs/credential-lane-designs/nk-bind-executor-go-live.md; needs case nk-bind-executor-approval materialized with acceptance (net-kingdom) and audit-core ingress/ConfigMap (audit-core steps 1-2)" +``` + +Runbook `docs/credential-lane-designs/nk-bind-executor-go-live.md`: approvals and binding confirmations, +coding-agent fence (`scripts/nk_bind_executor_custody.py fence`), delegated apply of CCR-2026-0060/0061/0063, +audit-core's sender seed, broker mint of the KeyCape client secret (CAS 0, silent, receipt), ESO apply of +`manifests/nk-bind-executor-eso.yaml`, and `nk_bind_executor_custody.py verify` (metadata, capabilities, +logins and hashes only; tests `tests/test_nk_bind_executor_custody.py`, synthetic fixtures). KeyCape's +bind-env and register-client (net-kingdom) follow only after the KeyCape ExternalSecret is Ready.