diff --git a/.custodian-brief.md b/.custodian-brief.md index 48d7b8f..dd7340b 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,7 +2,7 @@ # Custodian Brief — railiance-platform **Domain:** financials -**Last synced:** 2026-10-11 21:24 UTC +**Last synced:** 2026-10-14 10:58 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams @@ -25,14 +25,14 @@ Progress: 4/6 done | workplan_id: `b2c25a01-4a80-55c1-90cf-8538000f7e0e` - ! T06 — Publish the Railiance/OpenBao custody handoff `3b9748c4` *(wait: Owner work first: the OpenBao owner confirms mount/path/fields and writer/reader authority and publishes the non-secret routing receipt; the handoff enters the attended window only when a command exists.)* -### Onboard rapp-policy-nexus to the ArgoCD production lane -Progress: 3/5 done | workplan_id: `ec41a4bd-df18-5b07-9b63-ccb80d9f001c` +### Admit the Playwright pilot and establish scoped consumer group access +Progress: 4/6 done | workplan_id: `31933d08-c877-5fda-aff9-6220c3705c92` **Open tasks:** -- ! Adopt policy-nexus (live, founder go-ahead) `8638fa73` - *(wait: Merge approved by D2; runs once T03 diff and root automation (RPF-WP-0044-T07) are proven.)* -- ! Prepare the Application and the dry-run evidence `2afb1aac` - *(wait: Steps 1 and 3 done 2026-10-11 (docs/evidence/2026-10-11-policy-nexus-adoption-readonly-precheck.json, no stop condition). Step 2 render diff needs the railiance-apps values path from RAPP-POLICY-NEXUS-WP-0002-T02; agents can run it read-only via the k3s tunnel.)* +- ! Connect platform identities and prove managed consumer operation `a82762eb` + *(wait: User admitted all identified platform humans and coding agents; their supported Kubernetes authentication/group-mapping route remains unimplemented.)* +- ! Obtain Custodian coordination of self-service consumer delivery `ef6c21bc` + *(wait: Await Custodian coordination acceptance, accountable coordinator and source task links for the existing cross-repository delivery.)* ### ArgoCD phase B: adopt the four existing Applications on railiance01 Progress: 5/8 done | workplan_id: `98140775-3b9a-5cf9-9af6-722502d487dc` @@ -44,11 +44,21 @@ Progress: 5/8 done | workplan_id: `98140775-3b9a-5cf9-9af6-722502d487dc` - ! Turn the root back to the production lane `844ff115` *(wait: Restore prune/selfHeal once T03-T06 have automated proof; no further founder go-ahead (D2).)* +### Onboard rapp-policy-nexus to the ArgoCD production lane +Progress: 3/5 done | workplan_id: `ec41a4bd-df18-5b07-9b63-ccb80d9f001c` + +**Open tasks:** +- ! Adopt policy-nexus (live, founder go-ahead) `8638fa73` + *(wait: Merge approved by D2; runs once T03 diff and root automation (RPF-WP-0044-T07) are proven.)* +- ! Prepare the Application and the dry-run evidence `2afb1aac` + *(wait: Steps 1 and 3 done 2026-10-11 (docs/evidence/2026-10-11-policy-nexus-adoption-readonly-precheck.json, no stop condition). Step 2 render diff needs the railiance-apps values path from RAPP-POLICY-NEXUS-WP-0002-T02; agents can run it read-only via the k3s tunnel.)* + ### Close Forgejo primary backup coverage on Scaleway Progress: 3/4 done | workplan_id: `7beec1a7-aa82-5a36-9a66-6b60008a2455` **Open tasks:** -- · Establish primary full-archive delivery and application recovery `a4807df0` +- ! Establish primary full-archive delivery and application recovery `a4807df0` + *(wait: Durable scheduled caller, verified inventory and quota/retention gates remain open; September 15 prohibits expiration and scheduled secondary cutover.)* ### Close S3 service assurance and ownership gaps Progress: 4/7 done | workplan_id: `ca639c3d-3a87-5fa4-ad13-6f2e014b0c84` diff --git a/Makefile b/Makefile index fdbefd5..6e97af9 100644 --- a/Makefile +++ b/Makefile @@ -526,41 +526,60 @@ audit-database-lease-approval-check: ## Validate an approved T02 receipt without python3 scripts/audit-core-database-lease-recovery.py validate-approval \ --approval "$(AUDIT_DATABASE_LEASE_APPROVAL)" -audit-database-lease-recovery-exercise: ## Run the separately approved attended T02 exercise - @test -n "$(AUDIT_DATABASE_LEASE_APPROVAL)" || { echo 'set AUDIT_DATABASE_LEASE_APPROVAL=/path/to/approved-receipt.json' >&2; exit 2; } - @test -n "$(AUDIT_DATABASE_LEASE_DRIVER)" || { echo 'set AUDIT_DATABASE_LEASE_DRIVER=/path/to/approved-load-driver' >&2; exit 2; } - python3 scripts/audit-core-database-lease-recovery.py exercise \ - --approval "$(AUDIT_DATABASE_LEASE_APPROVAL)" \ - --load-driver "$(AUDIT_DATABASE_LEASE_DRIVER)" \ +ATTENDED_EXEC = python3 scripts/openbao-attended-exec.py -- +AUDIT_T02_NEED_APPROVAL = @test -n "$(AUDIT_DATABASE_LEASE_APPROVAL)" || { echo 'set AUDIT_DATABASE_LEASE_APPROVAL=/path/to/approved-receipt.json' >&2; exit 2; } +AUDIT_T02_NEED_DRIVER = @test -n "$(AUDIT_DATABASE_LEASE_DRIVER)" || { echo 'set AUDIT_DATABASE_LEASE_DRIVER=/path/to/approved-load-driver' >&2; exit 2; } +AUDIT_SYNTHETIC_SENDER_NEED_CONFIRM = @test -n "$(AUDIT_SYNTHETIC_SENDER_CONFIRM)" || { echo 'set AUDIT_SYNTHETIC_SENDER_CONFIRM to the confirm string printed by preflight' >&2; exit 2; } +AUDIT_T02_NEED_BEARER = @test -n "$(AUDIT_T02_TOKEN_FILE)" || { echo 'set AUDIT_T02_TOKEN_FILE=/abs/path/outside/git (0700 dir)' >&2; exit 2; } + +audit-t02-attended-window: ## One contained login: project, port-forward, T02 exercise, evidence, cleanup + $(AUDIT_T02_NEED_APPROVAL) + $(AUDIT_T02_NEED_DRIVER) + $(AUDIT_SYNTHETIC_SENDER_NEED_CONFIRM) + $(AUDIT_T02_NEED_BEARER) + @test -n "$(AUDIT_T02_EVIDENCE_DIR)" || { echo 'set AUDIT_T02_EVIDENCE_DIR=/abs/dir/outside/git (0700)' >&2; exit 2; } + $(ATTENDED_EXEC) scripts/t02-attended-window.py \ + --approval "$(abspath $(AUDIT_DATABASE_LEASE_APPROVAL))" \ + --load-driver "$(abspath $(AUDIT_DATABASE_LEASE_DRIVER))" \ + --bearer-file "$(AUDIT_T02_TOKEN_FILE)" --evidence-dir "$(AUDIT_T02_EVIDENCE_DIR)" \ + --confirm "$(AUDIT_SYNTHETIC_SENDER_CONFIRM)" $(if $(AUDIT_T02_PORT),--port $(AUDIT_T02_PORT)) + +audit-database-lease-recovery-exercise: ## Exercise step alone, inside its own contained login (prefer audit-t02-attended-window) + $(AUDIT_T02_NEED_APPROVAL) + $(AUDIT_T02_NEED_DRIVER) + @test -n "$(AUDIT_T02_RESULT_FILE)" || { echo 'set AUDIT_T02_RESULT_FILE=/abs/new/file outside Git (the contained child must stay silent)' >&2; exit 2; } + $(ATTENDED_EXEC) scripts/audit-core-database-lease-recovery.py exercise --contained --result-file "$(AUDIT_T02_RESULT_FILE)" \ + --approval "$(abspath $(AUDIT_DATABASE_LEASE_APPROVAL))" \ + --load-driver "$(abspath $(AUDIT_DATABASE_LEASE_DRIVER))" \ --confirm RAILIANCE-WP-0024-T02:attended -AUDIT_SYNTHETIC_SENDER_LANE = python3 scripts/audit_core_synthetic_sender_lane.py -AUDIT_SYNTHETIC_SENDER_NEED_APPROVAL = @test -n "$(AUDIT_DATABASE_LEASE_APPROVAL)" || { echo 'set AUDIT_DATABASE_LEASE_APPROVAL=/path/to/approved-receipt.json' >&2; exit 2; } -AUDIT_SYNTHETIC_SENDER_NEED_CONFIRM = @test -n "$(AUDIT_SYNTHETIC_SENDER_CONFIRM)" || { echo 'set AUDIT_SYNTHETIC_SENDER_CONFIRM to the confirm string printed by preflight' >&2; exit 2; } +audit-synthetic-sender-test: ## Unit tests for the synthetic sender lane and T02 window (no network) + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_audit_core_synthetic_sender_lane tests.test_t02_attended_window -audit-synthetic-sender-test: ## Unit tests for the synthetic sender lane (no network) - PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_audit_core_synthetic_sender_lane - -audit-synthetic-sender-preflight: ## Read-only gate for the T02 synthetic sender (no registry values read) - $(AUDIT_SYNTHETIC_SENDER_NEED_APPROVAL) - $(AUDIT_SYNTHETIC_SENDER_LANE) preflight --approval "$(AUDIT_DATABASE_LEASE_APPROVAL)" \ +audit-synthetic-sender-preflight: ## Read-only T02 sender gate; cluster checks only (registry metadata needs --contained) + $(AUDIT_T02_NEED_APPROVAL) + python3 scripts/audit_core_synthetic_sender_lane.py preflight --approval "$(AUDIT_DATABASE_LEASE_APPROVAL)" \ $(if $(AUDIT_T02_TOKEN_FILE),--bearer-file "$(AUDIT_T02_TOKEN_FILE)") $(AUDIT_SYNTHETIC_SENDER_PREFLIGHT_ARGS) -audit-synthetic-sender-project: ## Attended: add the one T02 identity, write bearer file, restart audit-core - $(AUDIT_SYNTHETIC_SENDER_NEED_APPROVAL) +audit-synthetic-sender-project: ## Projection step alone, inside its own contained login (prefer audit-t02-attended-window) + $(AUDIT_T02_NEED_APPROVAL) $(AUDIT_SYNTHETIC_SENDER_NEED_CONFIRM) - @test -n "$(AUDIT_T02_TOKEN_FILE)" || { echo 'set AUDIT_T02_TOKEN_FILE=/abs/path/outside/git (0700 dir)' >&2; exit 2; } - $(AUDIT_SYNTHETIC_SENDER_LANE) project --approval "$(AUDIT_DATABASE_LEASE_APPROVAL)" \ + $(AUDIT_T02_NEED_BEARER) + @test -n "$(AUDIT_T02_RESULT_FILE)" || { echo 'set AUDIT_T02_RESULT_FILE=/abs/new/file outside Git (the contained child must stay silent)' >&2; exit 2; } + $(ATTENDED_EXEC) scripts/audit_core_synthetic_sender_lane.py project --contained --result-file "$(AUDIT_T02_RESULT_FILE)" \ + --approval "$(abspath $(AUDIT_DATABASE_LEASE_APPROVAL))" \ --bearer-file "$(AUDIT_T02_TOKEN_FILE)" --confirm "$(AUDIT_SYNTHETIC_SENDER_CONFIRM)" -audit-synthetic-sender-cleanup: ## Attended, idempotent: remove the T02 identity after evidence, restart audit-core - $(AUDIT_SYNTHETIC_SENDER_NEED_APPROVAL) +audit-synthetic-sender-cleanup: ## Idempotent cleanup inside a contained login (evidence or abort reason required) + $(AUDIT_T02_NEED_APPROVAL) $(AUDIT_SYNTHETIC_SENDER_NEED_CONFIRM) @test -n "$(AUDIT_T02_EVIDENCE)$(AUDIT_SYNTHETIC_SENDER_ABORT_REASON)" || { echo 'set AUDIT_T02_EVIDENCE=/path/to/exercise-result.json or AUDIT_SYNTHETIC_SENDER_ABORT_REASON=...' >&2; exit 2; } - $(AUDIT_SYNTHETIC_SENDER_LANE) cleanup --approval "$(AUDIT_DATABASE_LEASE_APPROVAL)" \ + @test -n "$(AUDIT_T02_RESULT_FILE)" || { echo 'set AUDIT_T02_RESULT_FILE=/abs/new/file outside Git (the contained child must stay silent)' >&2; exit 2; } + $(ATTENDED_EXEC) scripts/audit_core_synthetic_sender_lane.py cleanup --contained --result-file "$(AUDIT_T02_RESULT_FILE)" \ + --approval "$(abspath $(AUDIT_DATABASE_LEASE_APPROVAL))" \ --confirm "$(AUDIT_SYNTHETIC_SENDER_CONFIRM)" \ $(if $(AUDIT_T02_TOKEN_FILE),--bearer-file "$(AUDIT_T02_TOKEN_FILE)") \ - $(if $(AUDIT_T02_EVIDENCE),--exercise-evidence "$(AUDIT_T02_EVIDENCE)") \ + $(if $(AUDIT_T02_EVIDENCE),--exercise-evidence "$(abspath $(AUDIT_T02_EVIDENCE))") \ $(if $(AUDIT_SYNTHETIC_SENDER_ABORT_REASON),--abort-reason "$(AUDIT_SYNTHETIC_SENDER_ABORT_REASON)") railiance01-reboot-preflight: ## Read-only gate for the coordinated single-node reboot @@ -589,7 +608,7 @@ wp0024-t02-driver-register: ## Register committed audit-core driver source witho @python3 scripts/wp0024-t02-driver-candidate.py register \ --repo "$(abspath ../audit-core)" --driver "$(DRIVER)" --reviewer "$(REVIEWER)" -.PHONY: audit-synthetic-sender-test audit-synthetic-sender-preflight audit-synthetic-sender-project audit-synthetic-sender-cleanup audit-whitehat-e2-preflight audit-whitehat-e2-project audit-whitehat-e2-cleanup audit-database-lease-recovery-preflight audit-database-lease-approval-check audit-database-lease-recovery-exercise railiance01-reboot-preflight wp0024-owner-show wp0024-owner-verify wp0024-owner-status wp0024-t02-driver-show wp0024-t02-driver-status wp0024-t02-driver-register apps-pg-isolation-probe +.PHONY: audit-t02-attended-window audit-synthetic-sender-test audit-synthetic-sender-preflight audit-synthetic-sender-project audit-synthetic-sender-cleanup audit-whitehat-e2-preflight audit-whitehat-e2-project audit-whitehat-e2-cleanup audit-database-lease-recovery-preflight audit-database-lease-approval-check audit-database-lease-recovery-exercise railiance01-reboot-preflight wp0024-owner-show wp0024-owner-verify wp0024-owner-status wp0024-t02-driver-show wp0024-t02-driver-status wp0024-t02-driver-register apps-pg-isolation-probe .PHONY: db-deploy db-status db-shell db-logs forgejo-db-deploy forgejo-db-status forgejo-db-shell apps-pg-deploy apps-pg-status apps-pg-shell apps-pg-logs net-kingdom-pg-inter-hub-networkpolicy-deploy pg-deploy pg-status pg-pgpool-check valkey-deploy valkey-status openbao-repo openbao-dry-run openbao-overlay-apply openbao-verify-login-overlay openbao-deploy openbao-status openbao-verify openbao-verify-post-unseal openbao-configure-initial openbao-configure-ssh openbao-verify-ssh openbao-verify-authenticated openbao-configure-external-secrets-issue-core openbao-configure-external-secrets-activity-core openbao-configure-external-secrets-forgejo openbao-validate-restore-evidence openbao-validate-emergency-evidence credential-grants-validate credential-change-applier-dry-run credential-change-applier-apply-plan credential-change-applier-apply credential-change-runbook credential-change-record-evidence credential-change-lifecycle-plan credential-change-lifecycle-event credential-change-import-inventory agent-high-risk-boundary-check openbao-credential-change-appliers-dry-run openbao-configure-credential-change-appliers openbao-token-grants-dry-run openbao-configure-token-grants openbao-verify-token-grants-dry-run openbao-verify-token-grants openbao-verify-token-grants-smoke credential-helper-dry-run credential-tests credential-exec-ops-warden-smoke argocd-bootstrap-dry-run argocd-bootstrap-deploy argocd-repo-apply argocd-status backup forgejo-backup forgejo-backup-dry-run forgejo-backup-status forgejo-package-prune forgejo-package-prune-dry-run reef-deployables apps-pg-evidence consumption-preflight help ##@ S3 service assurance (read-only) diff --git a/docs/audit-core-database-lease-recovery.md b/docs/audit-core-database-lease-recovery.md index 251cee6..85d2adb 100644 --- a/docs/audit-core-database-lease-recovery.md +++ b/docs/audit-core-database-lease-recovery.md @@ -85,46 +85,86 @@ driver as `AUDIT_T02_SOURCE`/`AUDIT_T02_TENANT`; the confirm string is `platform/workloads/audit-core/senders` → ExternalSecret `audit-core-senders` → env `AUDIT_CORE_SENDERS`, so both projection and cleanup restart `deploy/audit-core`. The restarts must stay outside the restart-free proof: +project → restart → T02 exercise → evidence → cleanup → restart. -1. `make audit-synthetic-sender-preflight` — read-only; reads registry metadata - only (add `AUDIT_SYNTHETIC_SENDER_PREFLIGHT_ARGS=--inspect-registry` for an - attended in-memory conflict check). -2. `make audit-synthetic-sender-project` — only within the first 3 minutes of - the window and with ≥10 minutes of identity lifetime left. Writes the - bearer to a new mode-`0600` file (`AUDIT_T02_TOKEN_FILE`, absolute, in an - operator-owned `0700` directory outside every Git work tree; never stdout), - adds the one identity, forces `audit-core-senders` sync and runs - `rollout restart deploy/audit-core`, then waits for one Ready restarted pod - and prints its UID. Any failure removes the identity (restarting again) and - shreds the bearer file. -3. The T02 exercise (`make audit-database-lease-recovery-exercise`). Its - baseline must capture the post-projection pod UID; never project while the - exercise is running. -4. Record the exercise's JSON result (it is the evidence file). -5. `make audit-synthetic-sender-cleanup AUDIT_T02_EVIDENCE=` — - refuses unless that evidence names this `approval_id`, or an explicit - `AUDIT_SYNTHETIC_SENDER_ABORT_REASON` is given for an aborted run. Removes - only the exact named identity (refusing if the name carries a different - scope), restarts audit-core only if something was removed, shreds the - bearer file, and confirms 1/1 Ready. Re-running is a no-op. +**Contained OpenBao access only.** Every live command (lane `project` and +`cleanup`, harness `exercise`) requires `--contained` and refuses unless it runs +as the child of `scripts/openbao-attended-exec.py`, i.e. inside `warden access +openbao-platform-admin-login --exec`: `WARDEN_ROUTING_CATALOG` must be set, +`BAO_ADDR` must be the operator tunnel `http://127.0.0.1:18200`, and +`BAO_TOKEN`/`VAULT_TOKEN`/`OPENBAO_TOKEN` must be absent; `bao` authenticates +through Warden's isolated token helper. There is no token-file path any more: +the harness's former default `https://bao.coulomb.social` was retracted on +2026-09-15 (RMASTER-WP-0020-T09), and a standing 0600 platform-admin token file +is outside the accepted custody path. Outside the login, the lane's `preflight` +runs the cluster checks only; `--contained` adds registry metadata and +`--inspect-registry` (attended, in-memory) adds the conflict check. + +**One login per window.** `scripts/t02-attended-window.py` is the single reviewed +child of the contained login, so the ≤15-minute window needs one MFA login: + +```bash +make audit-t02-attended-window \ + AUDIT_DATABASE_LEASE_APPROVAL=/path/to/approved-receipt.json \ + AUDIT_DATABASE_LEASE_DRIVER=/path/to/audit-core/scripts/t02_synthetic_load_driver.py \ + AUDIT_SYNTHETIC_SENDER_CONFIRM=audit-core-synthetic-sender::attended \ + AUDIT_T02_TOKEN_FILE=/abs/0700-dir/t02.bearer \ + AUDIT_T02_EVIDENCE_DIR=/abs/0700-dir/evidence +``` + +It runs, in order: + +1. lane `project` — only within the first 3 minutes of the window and with + ≥10 minutes of identity lifetime left. Writes the bearer to a new mode-`0600` + file (absolute, operator-owned `0700` directory outside every Git work tree; + never stdout), adds the one identity, forces `audit-core-senders` sync, runs + `rollout restart deploy/audit-core`, and waits for one Ready restarted pod. + Any failure removes the identity (restarting again) and shreds the file. +2. port-forward — the lane and harness reach the cluster only via + `ssh railiance01 kubectl`, so the window uses the same path: `ssh -L + 127.0.0.1::127.0.0.1: railiance01 timeout 900 kubectl -n + audit-core port-forward --address 127.0.0.1 deploy/audit-core :8080` + (default port 18081, `AUDIT_T02_PORT` to override). No local kubeconfig is + needed; the remote forward is bounded by `timeout` even if SSH is cut. + audit-core's NetworkPolicies admit only named namespaces, which is why the + operator-side driver needs the forward. +3. harness `exercise --contained` with the registered driver; the driver env is + derived from the receipt: `AUDIT_T02_BASE_URL=http://127.0.0.1:`, + `AUDIT_T02_TOKEN_FILE`, `AUDIT_T02_SOURCE`/`AUDIT_T02_TENANT` from + `synthetic_sender`, `AUDIT_T02_FIXTURE_ID=t02-`, + `AUDIT_T02_OCCURRED_AT=window.start`. Its baseline captures the + post-projection pod UID. +4. evidence — the exercise JSON is written `0600` as + `/-exercise.json`; a value-safe window summary is + written as `-window.json` on success and on abort. +5. lane `cleanup` with that evidence — refuses unless it names this + `approval_id`. Removes only the exact named identity (refusing if the name + carries a different scope), restarts audit-core only if something was + removed, shreds the bearer file, and confirms 1/1 Ready. + +The port-forward is stopped before cleanup and on every exit. Any failure after +projection was attempted (including a failed `project`, whose registry write may +already have reached OpenBao) runs `cleanup --abort-reason` before the window +script exits non-zero. Children's stdout is captured and only their value-safe +JSON is re-emitted. + +The single-step targets (`audit-synthetic-sender-project`, +`audit-database-lease-recovery-exercise`, `audit-synthetic-sender-cleanup`) +remain for the abort path; each costs its own contained login. A missed cleanup +is run as `make audit-synthetic-sender-cleanup +AUDIT_SYNTHETIC_SENDER_ABORT_REASON=...` and is a no-op if nothing remains. If cleanup is missed, audit-core's `expires_at` enforcement makes the identity inert at window end; the attended cleanup is still required to remove it. +Projection and cleanup each restart audit-core once (Recreate, no surge), so +production senders see two short retryable gaps per window; announce them with +the window. -The driver runs operator-side. audit-core's NetworkPolicies admit only named -namespaces, so reach the pod with `kubectl -n audit-core port-forward -deploy/audit-core :8080` and set `AUDIT_T02_BASE_URL` to that local -URL; record the base URL and the driver's source/tenant (which must equal the -receipt's `synthetic_sender` block) in the window notes. Projection and cleanup -each restart audit-core once (Recreate, no surge), so production senders see two -short retryable gaps per window; announce them with the window. - -If `project` fails after the registry write may already have reached OpenBao (for -example a transport error on the write itself), treat the identity as possibly -present: run `cleanup --abort-reason ""` before leaving the window. -The lane's `--token-file` is the attended operator token for the registry write; -it must come from the contained ops-warden attended login accepted under RPF-WP-0017 -(isolated 0600 helper state, removed afterwards), not from a long-lived token file. +The contained login discards child output and fails closed (exit 12, token +revoked) on any byte the child prints. The window script therefore prints +nothing: its summary, exercise evidence or `window-error-*.json` land only in the +evidence directory. Single-step targets require `AUDIT_T02_RESULT_FILE` so the +lane/harness write their JSON there instead of stdout. ## Attended sequence @@ -165,13 +205,11 @@ bodies and clear all transient JSON from memory or mode-`0600` temporary files. (which restarts audit-core after the evidence is captured). The replacement remains managed by ESO and expires through its normal database role TTL. -Only inside the approved window, with the receipt and reviewed driver: - -```bash -make audit-database-lease-recovery-exercise \ - AUDIT_DATABASE_LEASE_APPROVAL=/path/to/approved-receipt.json \ - AUDIT_DATABASE_LEASE_DRIVER=/path/to/approved-load-driver -``` +Only inside the approved window, with the receipt and reviewed driver, run the +whole window through `make audit-t02-attended-window` (see the synthetic sender +section above); the exercise step alone is +`make audit-database-lease-recovery-exercise`, which also runs inside the +contained login. The exact confirmation string is embedded in the target. The harness rechecks the Secret/pod/lease immediately before revocation, requires at least five diff --git a/docs/rpf-wp-0015-attended-windows.md b/docs/rpf-wp-0015-attended-windows.md index 0f96980..50cf533 100644 --- a/docs/rpf-wp-0015-attended-windows.md +++ b/docs/rpf-wp-0015-attended-windows.md @@ -25,10 +25,15 @@ Runbook: `docs/audit-core-database-lease-recovery.md`. `RAILIANCE-WP-0024-T02-SYNTHETIC-01` with driver revision `sha256:941ba251…` (audit-core `8c8bcf4`), and the `synthetic_sender` block. 3. Announce two short audit-core restart gaps (projection and cleanup). -4. In the window: attended ops-warden login → `make audit-synthetic-sender-project` - → port-forward audit-core → `make audit-database-lease-recovery-exercise` → - record evidence → `make audit-synthetic-sender-cleanup`. -5. Abort: stop load, `cleanup --abort-reason`, never restart the pod by hand. +4. In the window: one contained ops-warden login runs the whole sequence — + `make audit-t02-attended-window` (approval receipt, driver path, confirm + string, bearer file and evidence dir outside Git). Through + `scripts/openbao-attended-exec.py` it runs lane project (restart #1) → + ssh-bounded port-forward → contained T02 exercise → 0600 evidence JSON → + lane cleanup with that evidence (restart #2). No token file is used. +5. Abort: the window script stops the port-forward and runs `cleanup --abort-reason` + itself; if it could not, run `make audit-synthetic-sender-cleanup + AUDIT_SYNTHETIC_SENDER_ABORT_REASON=...` (one more login). Never restart the pod by hand. ## T03 — coordinated railiance01 reboot diff --git a/interfaces/RAILIANCE-WP-0024-owner-reviews.json b/interfaces/RAILIANCE-WP-0024-owner-reviews.json index 00442ad..83bdf94 100644 --- a/interfaces/RAILIANCE-WP-0024-owner-reviews.json +++ b/interfaces/RAILIANCE-WP-0024-owner-reviews.json @@ -34,7 +34,8 @@ "docs/railiance01-coordinated-reboot.md", "scripts/audit-core-recovery-preflight.py", "scripts/audit-core-database-lease-recovery.py", - "scripts/audit_core_synthetic_sender_lane.py" + "scripts/audit_core_synthetic_sender_lane.py", + "scripts/t02-attended-window.py" ], "assertions": [ "The database-lease exercise uses only synthetic load and proves health 200, readiness and requests fail retryably with 503, then recover without changing the audit-core pod UID or restart count.", @@ -57,7 +58,8 @@ "docs/railiance01-coordinated-reboot.md", "scripts/audit-core-recovery-preflight.py", "scripts/audit-core-database-lease-recovery.py", - "docs/audit-core-database-lease-approval.example.json" + "docs/audit-core-database-lease-approval.example.json", + "scripts/t02-attended-window.py" ], "assertions": [ "Lease selection aborts unless exactly one live handle exists below database/creds/audit-core-runtime and its issue time coheres with the ExternalSecret refresh.", @@ -122,7 +124,8 @@ "scripts/audit-core-database-lease-recovery.py", "docs/audit-core-database-lease-approval.example.json", "docs/openbao-reboot-snapshot-receipt.example.json", - "scripts/audit_core_synthetic_sender_lane.py" + "scripts/audit_core_synthetic_sender_lane.py", + "scripts/t02-attended-window.py" ], "assertions": [ "T02 remains gated on a named window, abort operator, exact owner receipts and a revision-pinned synthetic-load driver; repository access alone cannot revoke a lease.", @@ -139,11 +142,12 @@ }, "artifact_sha256": { "docs/audit-core-database-lease-approval.example.json": "87e57e3290074508c6517938acf8c7fc1a75d977b8ff630f247a4ff5d2ee45c6", - "docs/audit-core-database-lease-recovery.md": "3544abde993505c1d84a5cfc882ce1619ca2e115b1cb74fdb1262a42984ee0be", + "docs/audit-core-database-lease-recovery.md": "ae9e6b0d6cf60514cfabbd1fd0a9818d33dbbac9291a94539a4e049492c48f21", "docs/openbao-reboot-snapshot-receipt.example.json": "b95903a3e1aacb607eecc3f0440f08e62480d09da47bd7ab96c3eb8814f2eaee", "docs/railiance01-coordinated-reboot.md": "6fd6301f808449ad6519764d994b05aee41a96be879b917ee1d18d8a65d5f6fd", - "scripts/audit-core-database-lease-recovery.py": "1de02f13dcd4e87e65de1c97ae14567f2b8e4a51aa0423e3fca78a224cf374b3", + "scripts/audit-core-database-lease-recovery.py": "3066718d06ce29b98fa8ee513bd967e0f48c47db2d66b5083c591f5f1c2bddd6", "scripts/audit-core-recovery-preflight.py": "05e89ae9d0b9a636b67d9a8d0c203ac746c97208e32061cb23f1ca785eda8e5c", - "scripts/audit_core_synthetic_sender_lane.py": "e71b3b3d56472a1cc91fc093d327a72116d89d07c1676f44bc24a8da36154bd4" + "scripts/audit_core_synthetic_sender_lane.py": "608145f43b5adea110c4c73a2f567370fcfb603ef73ca8230775de9bf7687fa6", + "scripts/t02-attended-window.py": "1faca370d14de9a55a3f04e55f6046105b5d3a606a4d79dadbe72c495ab75077" } } diff --git a/scripts/audit-core-database-lease-recovery.py b/scripts/audit-core-database-lease-recovery.py index 70ad346..46ffd2f 100755 --- a/scripts/audit-core-database-lease-recovery.py +++ b/scripts/audit-core-database-lease-recovery.py @@ -14,7 +14,6 @@ import hashlib import importlib.util import json import os -import stat import subprocess import sys import time @@ -122,14 +121,41 @@ def safe_run( return completed +# Operator OpenBao access is the SSH tunnel; the public endpoint was retracted +# 2026-09-15 (RMASTER-WP-0020-T09). +TUNNEL_ADDR = "http://127.0.0.1:18200" +CONTAINMENT_MARKER = "WARDEN_ROUTING_CATALOG" +TOKEN_ENV = ("BAO_TOKEN", "VAULT_TOKEN", "OPENBAO_TOKEN") + + +def contained_bao_env(environ: dict[str, str] | None = None) -> dict[str, str]: + """Environment for ``bao`` inside the contained attended login only. + + ``scripts/openbao-attended-exec.py`` runs ``warden access + openbao-platform-admin-login --exec`` with BAO_ADDR pinned to the tunnel, + no token variable, and the Warden routing marker; ``bao`` then + authenticates through Warden's isolated token helper. Anything else -- + a token file, a token variable, or another address -- is refused. + """ + env = dict(os.environ if environ is None else environ) + if not env.get(CONTAINMENT_MARKER): + raise ProcedureError( + "live OpenBao access requires the contained attended login " + "(scripts/openbao-attended-exec.py)" + ) + if env.get("BAO_ADDR", TUNNEL_ADDR) != TUNNEL_ADDR: + raise ProcedureError(f"BAO_ADDR must be the operator tunnel {TUNNEL_ADDR}") + if any(env.get(name) for name in TOKEN_ENV): + raise ProcedureError("token variables must be unset inside the contained login") + for name in TOKEN_ENV: + env.pop(name, None) + env["BAO_ADDR"] = TUNNEL_ADDR + return env + + class Authority: - def __init__(self, token_file: Path) -> None: - if not token_file.is_file() or stat.S_IMODE(token_file.stat().st_mode) != 0o600: - raise ProcedureError("OpenBao token file must exist with mode 0600") - token = token_file.read_text(encoding="utf-8").splitlines()[0].strip() - if not token: - raise ProcedureError("OpenBao token file is empty") - self.env = dict(os.environ, BAO_ADDR="https://bao.coulomb.social", BAO_TOKEN=token) + def __init__(self) -> None: + self.env = contained_bao_env() def bao(self, args: list[str], *, label: str) -> str: return safe_run(["bao", *args], label=label, env=self.env).stdout.strip() @@ -333,7 +359,7 @@ def exercise(args: argparse.Namespace) -> dict[str, Any]: if args.confirm != CONFIRM: raise ProcedureError(f"live exercise requires --confirm {CONFIRM}") remote = PREFLIGHT.Remote(args.remote) - authority = Authority(args.token_file) + authority = Authority() driver = LoadDriver( args.load_driver, approval["synthetic_load"]["contract_id"], @@ -450,18 +476,30 @@ def exercise(args: argparse.Namespace) -> dict[str, Any]: } -def main() -> int: + +def emit_result(result: dict, result_file: "Path | None") -> None: + """Under the contained login the child must stay silent: write, never print.""" + if result_file is None: + print(json.dumps(result, indent=2, sort_keys=True)) + return + fd = os.open(result_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(result, handle, indent=2, sort_keys=True) + handle.write("\n") + +def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument("command", choices=["validate-approval", "exercise"]) parser.add_argument("--approval", type=Path, required=True) parser.add_argument("--remote", default="railiance01") parser.add_argument( - "--token-file", type=Path, - default=Path.home() / ".local/openbao/platform-admin.token", + "--contained", action="store_true", + help="required for exercise: run inside the contained attended OpenBao login", ) parser.add_argument("--load-driver", type=Path) parser.add_argument("--confirm") - args = parser.parse_args() + parser.add_argument("--result-file", type=Path, help="write the JSON result here (0600, new) and print nothing") + args = parser.parse_args(argv) try: if args.command == "validate-approval": approval = load_approval(args.approval, datetime.now(UTC), require_open_window=False) @@ -472,13 +510,18 @@ def main() -> int: "secret_values_observed": False, } else: + if not args.contained: + raise ProcedureError("live exercise requires --contained (scripts/openbao-attended-exec.py)") if args.load_driver is None: raise ProcedureError("live exercise requires --load-driver") result = exercise(args) except (OSError, IndexError, ProcedureError, PREFLIGHT.PreflightError) as exc: - print(f"database lease recovery failed: {exc}", file=sys.stderr) + if args.result_file is None: + print(f"database lease recovery failed: {exc}", file=sys.stderr) + else: + emit_result({"status": "failed", "error": str(exc), "secret_values_observed": False}, args.result_file) return 1 - print(json.dumps(result, indent=2, sort_keys=True)) + emit_result(result, args.result_file) return 0 diff --git a/scripts/audit_core_synthetic_sender_lane.py b/scripts/audit_core_synthetic_sender_lane.py old mode 100644 new mode 100755 index 70d6142..9d85512 --- a/scripts/audit_core_synthetic_sender_lane.py +++ b/scripts/audit_core_synthetic_sender_lane.py @@ -262,6 +262,24 @@ def settled_state(remote: Any, *, previous_uid: str | None, timeout: float = 90) time.sleep(3) +class ContainedOperator(CREDS.Operator): + """The -02 registry/kubectl helpers, authenticated only by the contained login. + + No token file and no token variable: ``bao`` runs with the tunnel address + and Warden's isolated token helper (see ``LEASE.contained_bao_env``). + """ + + def __init__(self, remote: str) -> None: # noqa: D107 - deliberately no super().__init__ + self.remote = remote + self.bao_env = LEASE.contained_bao_env() + + +def contained_operator(args: argparse.Namespace) -> Any: + if not args.contained: + raise LaneError(f"{args.command} requires --contained (scripts/openbao-attended-exec.py)") + return ContainedOperator(args.remote) + + def registry_metadata(operator: Any) -> dict[str, Any]: raw = operator.bao(["kv", "metadata", "get", "-format=json", REGISTRY_PATH], label="read sender registry metadata").stdout try: @@ -303,9 +321,16 @@ def preflight(args: argparse.Namespace) -> dict[str, Any]: if args.offline: result["live_checks"] = "skipped" return result + if args.inspect_registry and not args.contained: + raise LaneError("--inspect-registry requires --contained") remote = PREFLIGHT.Remote(args.remote) result["audit_core"] = cluster_state(remote) - operator = CREDS.Operator(args.remote, args.token_file) + result["automated_checks_passed"] = result["audit_core"]["automated_checks_passed"] + if not args.contained: + # Outside the attended login there is no OpenBao credential at all. + result["sender_registry_metadata"] = "skipped-not-contained" + return result + operator = contained_operator(args) result["sender_registry_metadata"] = registry_metadata(operator) if args.inspect_registry: # Attended only: values are held in memory and never emitted. @@ -316,7 +341,6 @@ def preflight(args: argparse.Namespace) -> dict[str, Any]: ) result["registry_conflicts"] = registry_conflicts(registry, spec) registry.clear() - result["automated_checks_passed"] = result["audit_core"]["automated_checks_passed"] return result @@ -334,6 +358,7 @@ def project(args: argparse.Namespace) -> dict[str, Any]: approval, spec = load_receipt(args.approval, now, require_open_window=True) if args.confirm != confirm_token(approval): raise LaneError(f"project requires --confirm {confirm_token(approval)}") + operator = contained_operator(args) assert_projection_time(approval, spec, now) if args.bearer_file is None: raise LaneError("project requires --bearer-file outside Git") @@ -343,7 +368,6 @@ def project(args: argparse.Namespace) -> dict[str, Any]: if not before["automated_checks_passed"]: raise LaneError("audit-core recovery preflight is not green; refusing to project") LEASE.attest_chain_state(remote) - operator = CREDS.Operator(args.remote, args.token_file) registry = operator.registry() conflicts = registry_conflicts(registry, spec) if conflicts: @@ -384,8 +408,8 @@ def cleanup(args: argparse.Namespace) -> dict[str, Any]: approval, spec = load_receipt(args.approval, utc_now(), require_open_window=False) if args.confirm != confirm_token(approval): raise LaneError(f"cleanup requires --confirm {confirm_token(approval)}") + operator = contained_operator(args) gate = assert_evidence(args.exercise_evidence, args.abort_reason, approval) - operator = CREDS.Operator(args.remote, args.token_file) removed = _cleanup_registry(operator, spec) remaining = any( isinstance(entry, dict) and entry.get("name") == spec["name"] for entry in operator.registry() @@ -412,26 +436,42 @@ def cleanup(args: argparse.Namespace) -> dict[str, Any]: } + +def emit_result(result: dict, result_file: "Path | None") -> None: + """Under the contained login the child must stay silent: write, never print.""" + if result_file is None: + print(json.dumps(result, indent=2, sort_keys=True)) + return + fd = os.open(result_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(result, handle, indent=2, sort_keys=True) + handle.write("\n") + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("command", choices=["preflight", "project", "cleanup"]) parser.add_argument("--approval", type=Path, required=True, help="approved T02 window receipt") parser.add_argument("--bearer-file", type=Path, help="absolute path outside Git (mode-0700 directory)") parser.add_argument("--remote", default="railiance01") - parser.add_argument("--token-file", type=Path, default=Path.home() / ".local/openbao/platform-admin.token") + parser.add_argument("--contained", action="store_true", + help="run inside the contained attended OpenBao login (required for project/cleanup)") parser.add_argument("--confirm") parser.add_argument("--offline", action="store_true", help="preflight: validate receipt/path only") parser.add_argument("--inspect-registry", action="store_true", help="preflight: attended registry conflict check") parser.add_argument("--exercise-evidence", type=Path, help="cleanup: recorded T02 exercise result JSON") parser.add_argument("--abort-reason", help="cleanup: abort path when no exercise evidence exists") + parser.add_argument("--result-file", type=Path, help="write the JSON result here (0600, new) and print nothing") args = parser.parse_args(argv) try: handler = {"preflight": preflight, "project": project, "cleanup": cleanup}[args.command] result = handler(args) except ERRORS as exc: - print(f"synthetic sender lane failed: {exc}", file=sys.stderr) + if args.result_file is None: + print(f"synthetic sender lane failed: {exc}", file=sys.stderr) + else: + emit_result({"status": "failed", "error": str(exc), "secret_values_observed": False}, args.result_file) return 1 - print(json.dumps(result, indent=2, sort_keys=True)) + emit_result(result, args.result_file) return 0 diff --git a/scripts/t02-attended-window.py b/scripts/t02-attended-window.py new file mode 100755 index 0000000..6ffdf43 --- /dev/null +++ b/scripts/t02-attended-window.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +"""One contained attended window for RAILIANCE-WP-0024-T02. + +Run only as the single child of the contained attended login, so the whole +window costs one MFA login: + + python3 scripts/openbao-attended-exec.py -- scripts/t02-attended-window.py ... + +Order (each step a reviewed command; this file adds no OpenBao call of its own): + +1. lane ``project`` -- one temporary sender, bearer to a 0600 file, audit-core restart +2. port-forward -- ``ssh -L`` to ``kubectl -n audit-core port-forward`` on the remote +3. harness ``exercise`` with the registered load driver +4. evidence -- exercise JSON written 0600 outside Git +5. lane ``cleanup`` -- with that evidence (restart #2) + +Any failure after projection was attempted runs ``cleanup --abort-reason``. +The port-forward is always stopped. Children's stdout is captured and only +their value-safe JSON is re-emitted; nothing here reads the bearer. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import re +import subprocess +import sys +import time +import urllib.error +import urllib.request +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Callable + + +ROOT = Path(__file__).resolve().parents[1] +LANE_SCRIPT = ROOT / "scripts" / "audit_core_synthetic_sender_lane.py" +HARNESS_SCRIPT = ROOT / "scripts" / "audit-core-database-lease-recovery.py" +HARNESS_CONFIRM = "RAILIANCE-WP-0024-T02:attended" +REMOTE_PF_TIMEOUT_SECONDS = 15 * 60 +CONTAINER_PORT = 8080 + + +class WindowError(RuntimeError): + pass + + +def _load_lane() -> Any: + spec = importlib.util.spec_from_file_location("audit_core_synthetic_sender_lane", LANE_SCRIPT) + if not spec or not spec.loader: + raise WindowError("cannot load synthetic sender lane") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +LANE = _load_lane() + + +def stamp(value: datetime) -> str: + return value.astimezone(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def run_json(command: list[str], *, label: str, env: dict[str, str] | None = None) -> dict[str, Any]: + """Run a value-safe reviewed child; capture both streams (warden fails on any output).""" + completed = subprocess.run( + command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, check=False + ) + if completed.returncode != 0: + # Reviewed lane/harness errors are fixed value-safe strings; keep one bounded line. + reason = (completed.stderr.strip().splitlines() or [""])[-1][:200] + raise WindowError(f"{label} failed (exit {completed.returncode}): {reason}") + try: + payload = json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise WindowError(f"{label} returned invalid JSON") from exc + if not isinstance(payload, dict) or payload.get("secret_values_observed") is not False: + raise WindowError(f"{label} did not attest value safety") + return payload + + +def private_dir(path: Path) -> Path: + """An operator-owned 0700 directory outside every Git work tree.""" + path = path.resolve() + LANE.check_bearer_path(path / "probe", must_exist=False) + return path + + +def write_private_json(path: Path, document: dict[str, Any]) -> None: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(document, handle, indent=2, sort_keys=True) + handle.write("\n") + + +def slug(value: str) -> str: + cleaned = re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-") + if not cleaned: + raise WindowError("approval id cannot form a fixture id") + return cleaned[:48] + + +def driver_env(spec: dict[str, Any], approval: dict[str, Any], bearer: Path, port: int) -> dict[str, str]: + env = dict(os.environ) + env.update({ + "AUDIT_T02_BASE_URL": f"http://127.0.0.1:{port}", + "AUDIT_T02_TOKEN_FILE": str(bearer), + "AUDIT_T02_SOURCE": spec["source"], + "AUDIT_T02_TENANT": spec["tenant"], + "AUDIT_T02_FIXTURE_ID": f"t02-{slug(approval['approval_id'])}", + "AUDIT_T02_OCCURRED_AT": approval["window"]["start"], + }) + return env + + +class PortForward: + """``ssh -L`` to a remote, time-bounded ``kubectl port-forward`` on loopback. + + The lane and harness reach the cluster only through ``ssh kubectl``; + the port-forward uses the same path so the window needs no local kubeconfig. + """ + + def __init__(self, remote: str, port: int) -> None: + self.remote = remote + self.port = port + self.process: subprocess.Popen[bytes] | None = None + + def command(self) -> list[str]: + remote_cmd = ( + f"timeout {REMOTE_PF_TIMEOUT_SECONDS} kubectl -n audit-core port-forward " + f"--address 127.0.0.1 deploy/audit-core {self.port}:{CONTAINER_PORT}" + ) + return [ + "ssh", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes", + "-L", f"127.0.0.1:{self.port}:127.0.0.1:{self.port}", + self.remote, remote_cmd, + ] + + def start(self, *, timeout: float = 30) -> None: + self.process = subprocess.Popen( + self.command(), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if self.process.poll() is not None: + raise WindowError("audit-core port-forward exited early") + # A local ssh listener accepts before the remote forward is ready, so + # require a real /healthz answer through the whole chain. + try: + with urllib.request.urlopen(f"http://127.0.0.1:{self.port}/healthz", timeout=2) as response: + if response.status == 200: + return + except (OSError, urllib.error.URLError): + pass + time.sleep(1) + raise WindowError("audit-core port-forward did not become reachable") + + def stop(self) -> None: + if self.process is None or self.process.poll() is not None: + return + self.process.terminate() + try: + self.process.wait(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=5) + + +def window(args: argparse.Namespace, *, runner: Callable[..., dict[str, Any]] = run_json, + forward_factory: Callable[[str, int], Any] = PortForward) -> dict[str, Any]: + if not args.bearer_file.is_absolute() or not args.evidence_dir.is_absolute(): + raise WindowError("--bearer-file and --evidence-dir must be absolute") + LANE.LEASE.contained_bao_env() # refuse before any step outside the contained login + approval, spec = LANE.load_receipt(args.approval, datetime.now(UTC), require_open_window=True) + if args.confirm != LANE.confirm_token(approval): + raise WindowError(f"window requires --confirm {LANE.confirm_token(approval)}") + evidence_dir = private_dir(args.evidence_dir) + base = f"{slug(approval['approval_id'])}" + exercise_path = evidence_dir / f"{base}-exercise.json" + summary_path = evidence_dir / f"{base}-window.json" + for path in (exercise_path, summary_path): + if path.exists(): + raise WindowError(f"evidence file already exists: {path.name}") + lane = [sys.executable, str(LANE_SCRIPT)] + common = ["--approval", str(args.approval), "--remote", args.remote, "--contained", + "--confirm", args.confirm] + summary: dict[str, Any] = { + "procedure": "t02-attended-window", + "approval_id": approval["approval_id"], + "identity": LANE.identity_view(spec), + "started_at": stamp(datetime.now(UTC)), + "steps": [], + "secret_values_observed": False, + } + projection_attempted = False + forward = forward_factory(args.remote, args.port) + try: + projection_attempted = True + summary["project"] = runner( + [*lane, "project", *common, "--bearer-file", str(args.bearer_file)], label="lane project") + summary["steps"].append("project") + forward.start() + summary["steps"].append("port-forward") + exercise = runner( + [sys.executable, str(HARNESS_SCRIPT), "exercise", "--approval", str(args.approval), + "--remote", args.remote, "--contained", "--load-driver", str(args.load_driver), + "--confirm", HARNESS_CONFIRM], + label="T02 exercise", + env=driver_env(spec, approval, args.bearer_file, args.port), + ) + summary["steps"].append("exercise") + write_private_json(exercise_path, exercise) + summary["exercise_evidence"] = exercise_path.name + summary["steps"].append("evidence") + forward.stop() + summary["cleanup"] = runner( + [*lane, "cleanup", *common, "--bearer-file", str(args.bearer_file), + "--exercise-evidence", str(exercise_path)], label="lane cleanup") + summary["steps"].append("cleanup") + summary["outcome"] = "completed" + except BaseException as exc: + forward.stop() + summary["outcome"] = "aborted" + summary["abort_reason"] = str(exc)[:200] if isinstance(exc, WindowError) else type(exc).__name__ + if projection_attempted and "cleanup" not in summary["steps"]: + try: + summary["cleanup"] = runner( + [*lane, "cleanup", *common, "--bearer-file", str(args.bearer_file), + "--abort-reason", f"t02 window aborted: {summary['abort_reason']}"], + label="lane abort cleanup") + summary["steps"].append("abort-cleanup") + except BaseException as cleanup_exc: # keep the first failure; flag cleanup loudly + summary["abort_cleanup_failed"] = str(cleanup_exc)[:200] + summary["finished_at"] = stamp(datetime.now(UTC)) + write_private_json(summary_path, summary) + raise + finally: + forward.stop() + summary["finished_at"] = stamp(datetime.now(UTC)) + write_private_json(summary_path, summary) + return summary + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--approval", type=Path, required=True) + parser.add_argument("--load-driver", type=Path, required=True) + parser.add_argument("--bearer-file", type=Path, required=True) + parser.add_argument("--evidence-dir", type=Path, required=True) + parser.add_argument("--confirm", required=True) + parser.add_argument("--remote", default="railiance01") + parser.add_argument("--port", type=int, default=18081) + args = parser.parse_args(argv) + # The contained login discards child output and fails closed on any byte, + # so this script never prints; its outcome lives only in the evidence dir. + try: + window(args) + except (OSError, KeyError, WindowError, LANE.LaneError, LANE.LEASE.ProcedureError) as exc: + try: + LANE.emit_result( + {"status": "failed", "error": str(exc), "secret_values_observed": False}, + args.evidence_dir / f"window-error-{int(time.time())}.json", + ) + except OSError: + pass + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_audit_core_synthetic_sender_lane.py b/tests/test_audit_core_synthetic_sender_lane.py index 76ec17d..62b7195 100644 --- a/tests/test_audit_core_synthetic_sender_lane.py +++ b/tests/test_audit_core_synthetic_sender_lane.py @@ -200,15 +200,15 @@ class SyntheticSenderLaneTests(unittest.TestCase): bearer = self.dir / "t02.bearer" operator = FakeOperator([{"name": "user-engine", "tokens": ["p"], "sources": ["user-engine"], "tenants": ["*"]}]) args = argparse.Namespace( - approval=approval_path, confirm=lane.confirm_token(approval), bearer_file=bearer, - remote="railiance01", token_file=self.dir / "unused", + command="project", approval=approval_path, confirm=lane.confirm_token(approval), + bearer_file=bearer, remote="railiance01", contained=True, ) green = {"automated_checks_passed": True, "ready": "1/1", "pod_uid": "uid-1"} restart = mock.Mock(side_effect=[lane.CREDS.ProcedureError("rollout failed"), None]) with mock.patch.object(lane.PREFLIGHT, "Remote"), \ mock.patch.object(lane, "cluster_state", return_value=green), \ mock.patch.object(lane.LEASE, "attest_chain_state"), \ - mock.patch.object(lane.CREDS, "Operator", return_value=operator), \ + mock.patch.object(lane, "contained_operator", return_value=operator), \ mock.patch.object(lane.CREDS, "force_sender_sync_and_restart", restart): with self.assertRaises(lane.CREDS.ProcedureError): lane.project(args) @@ -226,6 +226,45 @@ class SyntheticSenderLaneTests(unittest.TestCase): self.assertIn("--confirm audit-core-synthetic-sender:t02-window-1:attended", stderr.getvalue()) self.assertEqual("", stdout.getvalue()) + def test_live_commands_refuse_outside_contained_login(self) -> None: + approval_path = self.write_receipt(receipt(self.now)) + approval = json.loads(approval_path.read_text()) + base = dict(approval=approval_path, confirm=lane.confirm_token(approval), + bearer_file=self.dir / "t02.bearer", remote="railiance01", + exercise_evidence=None, abort_reason="test") + for command in ("project", "cleanup"): + with self.subTest(command=command): + with self.assertRaisesRegex(lane.LaneError, "--contained"): + getattr(lane, command)(argparse.Namespace(command=command, contained=False, **base)) + contained = {"WARDEN_ROUTING_CATALOG": "/catalog.yaml", "BAO_ADDR": lane.LEASE.TUNNEL_ADDR} + refusals = [ + {}, + {**contained, "WARDEN_ROUTING_CATALOG": ""}, + {**contained, "BAO_ADDR": "https://bao.coulomb.social"}, + {**contained, "BAO_TOKEN": "x"}, + {**contained, "VAULT_TOKEN": "x"}, + ] + for env in refusals: + with self.subTest(env=sorted(env)): + with mock.patch.dict(os.environ, env, clear=True): + with self.assertRaises(lane.LEASE.ProcedureError): + lane.ContainedOperator("railiance01") + with mock.patch.dict(os.environ, contained, clear=True): + operator = lane.ContainedOperator("railiance01") + self.assertEqual(lane.LEASE.TUNNEL_ADDR, operator.bao_env["BAO_ADDR"]) + self.assertNotIn("BAO_TOKEN", operator.bao_env) + + def test_harness_exercise_requires_contained(self) -> None: + stderr = io.StringIO() + with redirect_stderr(stderr): + code = lane.LEASE.main(["exercise", "--approval", str(self.write_receipt(receipt(self.now))), + "--load-driver", "/bin/true", "--confirm", lane.LEASE.CONFIRM]) + self.assertEqual(1, code) + self.assertIn("--contained", stderr.getvalue()) + with mock.patch.dict(os.environ, {"BAO_ADDR": lane.LEASE.TUNNEL_ADDR}, clear=True): + with self.assertRaises(lane.LEASE.ProcedureError): + lane.LEASE.Authority() + def test_offline_preflight_is_value_safe(self) -> None: stdout = io.StringIO() with redirect_stdout(stdout): diff --git a/tests/test_t02_attended_window.py b/tests/test_t02_attended_window.py new file mode 100644 index 0000000..766b7a1 --- /dev/null +++ b/tests/test_t02_attended_window.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import tempfile +import unittest +from datetime import UTC, datetime +from pathlib import Path +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("t02_attended_window", ROOT / "scripts" / "t02-attended-window.py") +assert SPEC and SPEC.loader +window = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(window) + +TEST_SPEC = importlib.util.spec_from_file_location( + "test_audit_core_synthetic_sender_lane", ROOT / "tests" / "test_audit_core_synthetic_sender_lane.py") +assert TEST_SPEC and TEST_SPEC.loader +lane_tests = importlib.util.module_from_spec(TEST_SPEC) +TEST_SPEC.loader.exec_module(lane_tests) + +CONTAINED = {"WARDEN_ROUTING_CATALOG": "/catalog.yaml", "BAO_ADDR": "http://127.0.0.1:18200", "PATH": os.environ.get("PATH", "")} + + +class FakeForward: + def __init__(self, log: list[str], fail: bool = False) -> None: + self.log, self.fail = log, fail + + def start(self) -> None: + self.log.append("port-forward-start") + if self.fail: + raise window.WindowError("audit-core port-forward exited early") + + def stop(self) -> None: + self.log.append("port-forward-stop") + + +class Runner: + """Records the step of each reviewed child; optionally fails one step.""" + + def __init__(self, log: list[str], fail_on: str | None = None) -> None: + self.log, self.fail_on, self.envs = log, fail_on, {} + + def __call__(self, command: list[str], *, label: str, env: dict | None = None) -> dict: + step = "exercise" if "exercise" in command else command[2] + if step == "cleanup" and "--abort-reason" in command: + step = "abort-cleanup" + self.log.append(step) + self.envs[step] = env + assert "--contained" in command + if step == self.fail_on: + raise window.WindowError(f"{label} failed (exit 1)") + if step == "exercise": + return {"procedure": window.LANE.LEASE.PROCEDURE, "approval_id": "t02-window-1", + "completed_at": "2026-10-14T00:00:00Z", "secret_values_observed": False} + return {"secret_values_observed": False} + + +class AttendedWindowTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.dir = Path(self.temp.name) + os.chmod(self.dir, 0o700) + self.now = datetime.now(UTC) + document = lane_tests.receipt(self.now) + self.approval = self.dir / "receipt.json" + self.approval.write_text(json.dumps(document), encoding="utf-8") + self.evidence = self.dir / "evidence" + self.evidence.mkdir(mode=0o700) + self.args = argparse.Namespace( + approval=self.approval, load_driver=Path("/opt/driver"), bearer_file=self.dir / "t02.bearer", + evidence_dir=self.evidence, confirm=window.LANE.confirm_token(document), + remote="railiance01", port=18081, + ) + + def tearDown(self) -> None: + self.temp.cleanup() + + def run_window(self, runner: Runner, forward: FakeForward) -> dict: + with mock.patch.dict(os.environ, CONTAINED, clear=True): + return window.window(self.args, runner=runner, forward_factory=lambda remote, port: forward) + + def test_main_never_prints_and_records_failure_in_evidence_dir(self) -> None: + import contextlib + import io + + out, err = io.StringIO(), io.StringIO() + with tempfile.TemporaryDirectory() as tmp, contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + evidence = Path(tmp) + with mock.patch.object(window, "window", side_effect=window.WindowError("project failed (exit 1): x")): + code = window.main( + ["--approval", "/a", "--load-driver", "/d", "--bearer-file", "/b", + "--evidence-dir", str(evidence), "--confirm", "c"] + ) + records = list(evidence.glob("window-error-*.json")) + self.assertEqual(1, code) + self.assertEqual(1, len(records)) + self.assertEqual(0o600, records[0].stat().st_mode & 0o777) + self.assertEqual("failed", json.loads(records[0].read_text())["status"]) + with mock.patch.object(window, "window", return_value={"status": "ok"}): + self.assertEqual(0, window.main( + ["--approval", "/a", "--load-driver", "/d", "--bearer-file", "/b", + "--evidence-dir", str(evidence), "--confirm", "c"] + )) + self.assertEqual("", out.getvalue()) + self.assertEqual("", err.getvalue()) + + def test_result_file_is_new_private_and_suppresses_stdout(self) -> None: + import contextlib + import io + + out = io.StringIO() + with tempfile.TemporaryDirectory() as tmp, contextlib.redirect_stdout(out): + target = Path(tmp) / "r.json" + window.LANE.emit_result({"secret_values_observed": False}, target) + self.assertEqual(0o600, target.stat().st_mode & 0o777) + with self.assertRaises(FileExistsError): + window.LANE.emit_result({"secret_values_observed": False}, target) + self.assertEqual("", out.getvalue()) + + def test_happy_path_order_and_evidence(self) -> None: + log: list[str] = [] + runner = Runner(log) + summary = self.run_window(runner, FakeForward(log)) + self.assertEqual( + ["project", "port-forward-start", "exercise", "port-forward-stop", "cleanup", "port-forward-stop"], log) + self.assertEqual("completed", summary["outcome"]) + exercise = self.evidence / "t02-window-1-exercise.json" + self.assertEqual(0o600, exercise.stat().st_mode & 0o777) + self.assertTrue((self.evidence / "t02-window-1-window.json").exists()) + env = runner.envs["exercise"] + self.assertEqual("http://127.0.0.1:18081", env["AUDIT_T02_BASE_URL"]) + self.assertEqual(str(self.args.bearer_file), env["AUDIT_T02_TOKEN_FILE"]) + self.assertEqual("railiance-t02-synthetic", env["AUDIT_T02_SOURCE"]) + self.assertEqual("tenant:trial:t02-synthetic-1", env["AUDIT_T02_TENANT"]) + + def test_failures_after_projection_run_abort_cleanup(self) -> None: + for fail_on in ("project", "exercise", "cleanup"): + with self.subTest(fail_on=fail_on): + for path in self.evidence.iterdir(): + path.unlink() + log: list[str] = [] + with self.assertRaises(window.WindowError): + self.run_window(Runner(log, fail_on), FakeForward(log)) + self.assertEqual("abort-cleanup", log[-2] if log[-1] == "port-forward-stop" else log[-1]) + self.assertIn("port-forward-stop", log) + summary = json.loads((self.evidence / "t02-window-1-window.json").read_text()) + self.assertEqual("aborted", summary["outcome"]) + self.assertFalse(summary["secret_values_observed"]) + + def test_port_forward_failure_aborts_without_exercise(self) -> None: + log: list[str] = [] + with self.assertRaises(window.WindowError): + self.run_window(Runner(log), FakeForward(log, fail=True)) + self.assertNotIn("exercise", log) + self.assertIn("abort-cleanup", log) + + def test_refuses_outside_contained_login_before_any_step(self) -> None: + log: list[str] = [] + with mock.patch.dict(os.environ, {"BAO_TOKEN": "x", **CONTAINED}, clear=True): + with self.assertRaises(window.LANE.LEASE.ProcedureError): + window.window(self.args, runner=Runner(log), forward_factory=lambda r, p: FakeForward(log)) + with mock.patch.dict(os.environ, {"PATH": "/usr/bin"}, clear=True): + with self.assertRaises(window.LANE.LEASE.ProcedureError): + window.window(self.args, runner=Runner(log), forward_factory=lambda r, p: FakeForward(log)) + self.assertEqual([], log) + + def test_wrong_confirm_and_git_evidence_dir_refused_before_any_step(self) -> None: + log: list[str] = [] + self.args.confirm = "wrong" + with self.assertRaises(window.WindowError): + self.run_window(Runner(log), FakeForward(log)) + self.args.confirm = window.LANE.confirm_token(json.loads(self.approval.read_text())) + self.args.evidence_dir = ROOT / "docs" + with self.assertRaises(window.LANE.LaneError): + self.run_window(Runner(log), FakeForward(log)) + self.assertEqual([], log) + + def test_port_forward_uses_ssh_remote_loopback_and_bounded_kubectl(self) -> None: + command = window.PortForward("railiance01", 18081).command() + self.assertIn("ExitOnForwardFailure=yes", command) + self.assertIn("127.0.0.1:18081:127.0.0.1:18081", command) + self.assertTrue(command[-1].startswith("timeout 900 kubectl -n audit-core port-forward --address 127.0.0.1")) + self.assertTrue(command[-1].endswith("deploy/audit-core 18081:8080")) + + +if __name__ == "__main__": + unittest.main()