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 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 523928@bnt-lap001 Assistant-Session: 6c737afb-9c14-4152-9f02-477214b9c350
This commit is contained in:
parent
fdd754e424
commit
f3b0c4f4b2
11 changed files with 1130 additions and 23 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
221
docs/credential-lane-designs/nk-bind-executor-go-live.md
Normal file
221
docs/credential-lane-designs/nk-bind-executor-go-live.md
Normal file
|
|
@ -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.
|
||||
101
manifests/nk-bind-executor-eso.yaml
Normal file
101
manifests/nk-bind-executor-eso.yaml
Normal file
|
|
@ -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
|
||||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
426
scripts/nk_bind_executor_custody.py
Normal file
426
scripts/nk_bind_executor_custody.py
Normal file
|
|
@ -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())
|
||||
277
tests/test_nk_bind_executor_custody.py
Normal file
277
tests/test_nk_bind_executor_custody.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue