diff --git a/SCOPE.md b/SCOPE.md index 12f3857..c9f3b77 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -4,13 +4,14 @@ > direction belongs in `INTENT.md`; the current stage belongs in `GOAL.md`; > current work and gates belong in `workplans/`. -## Status — 2026-09-10 +## Status — 2026-09-11 -**The domain core, browser sign-in shell, Approval Engine HTTP adapter, -durable evidence store and Audit Core delivery adapter are implemented. The approval surface is not deployed.** +**The protected browser review, policy consumer, Approval Engine adapter, +durable evidence store and scheduled Audit Core delivery are implemented. +The approval surface is not deployed.** -What exists and is tested (258 tests, including explicit checks against the -actual Approval Engine and Audit Core implementations with synthetic identities): +What exists and is tested (344 tests and 12 Chromium checks, including actual +Flex Auth, Approval Engine and Audit Core with synthetic identity/custody): - layer and stance declarations — `layer.yaml`, `pep-stance.yaml`, `informed_decision/stance.py`, with published-equals-shipped asserted; @@ -26,24 +27,30 @@ actual Approval Engine and Audit Core implementations with synthetic identities) profile, bounded server-side sessions, protected cookies and CSRF sign-out; - `approval_http.py` and `http_transport.py` — get-by-id and human-entry transport, declared-control checks, real entry correlation, no consume route - or automatic mutation retry. This adapter has no public browser mutation route; + or automatic mutation retry. Protected browser actions use this adapter; - `store.py` / `records.py` — private durable packet/memo/presentation/ disposition storage, append-only acknowledgments, transactional outbox and submission correlation, safe reservation and backup/restore; - `audit.py` — idempotent Audit Core delivery, bounded retry/blocked states, explicit per-class count/time-basis comparison. Heartbeats are generated - without hiding undelivered evidence; host scheduling remains pending. + without hiding undelivered evidence; +- `policy.py` / `review.py` / `ui.py` — fresh entitlement before rendering and + actions, exact response pins, immutable observations, required highlights, + accept/return/discuss/decline and visible original/unresolved entry state; +- `runtime.py` — explicit owner configuration, rotating credential-file readers, + 30-second audit draining, heartbeat/reconciliation and delivery readiness. -Remaining: admitted policy package/caller and entitlement-before-render -integration, L3 review/acknowledgment/binding UI, policy observation persistence, -visible unresolved-entry recovery, scheduled independent audit delivery and -native deployment/custody proof. The legacy `evidence.Outbox` remains an +Remaining: native policy package/caller/assignment admission, registered human +login and deployed binding, independent production audit custody, packaging, +backup/restore and operator recovery admission. The legacy `evidence.Outbox` remains an in-memory test double; the new `Store` supplies durable atomicity. Browser sessions are ephemeral, with no approval state. -`/readyz` returns 503 until the protected approval path is connected. -The origin `decisions.coulomb.social` still serves an nginx placeholder. +Without owner runtime configuration `/readyz` returns 503. With it, readiness +requires recent audit delivery health; it does not assert native admission. +The origin's last observed deployment was an nginx placeholder on 2026-09-10. See [browser-authentication.md](docs/browser-authentication.md) and -[durable-review-evidence.md](docs/durable-review-evidence.md). +[durable-review-evidence.md](docs/durable-review-evidence.md) and +[protected-browser-review.md](docs/protected-browser-review.md). `INFD-WP-0001-T08` remains open for the live end-to-end proof, which is gated on `APPROVAL-WP-0002-T01` and a deployed `approval-engine`. @@ -91,7 +98,7 @@ trail. - The unreachable-engine stance map, built to v0.8 obligation 3, with published-equals-shipped asserted by test (`tests/test_layer_conformance.py`). -**Built as domain operations with durable custody; protected HTTP integration remains:** +**Built with durable custody and protected HTTP integration:** - The presentation record: what was rendered, to whom, when, in which locale and UI release. @@ -102,10 +109,13 @@ trail. - The browser-facing OIDC client: authorization-code + S256 PKCE against `key-cape`, scopes `[openid, approval:read, approval:approve]`. +- An L3 approver surface calling `approval-engine`'s approval-entry mutation. + **Specified, not built:** -- An L3 approver surface calling `approval-engine`'s approval-entry mutation. - The evidence bundle as an offline-verifiable export. +- German browser acceptance and the broader step/awareness UI. The current + browser profile refuses unsupported locale/step/level rather than overstating it. ## Out of Scope diff --git a/docs/browser-authentication.md b/docs/browser-authentication.md index 766fb21..f2b7c65 100644 --- a/docs/browser-authentication.md +++ b/docs/browser-authentication.md @@ -1,7 +1,8 @@ # Browser authentication and Approval Engine transport -Implemented under `INFD-WP-0001-T08`. The service currently supplies sign-in and -sign-out. It does not yet render memos or expose an approval-entry route. +Implemented under `INFD-WP-0001-T08`. The service supplies sign-in and sign-out; +explicit owner configuration enables the protected memo and human-response +routes described in [protected-browser-review.md](protected-browser-review.md). Install and exercise from the repository: @@ -25,8 +26,9 @@ The entrypoint listens on `127.0.0.1:8080` for a local reverse proxy. The public origin/callback is fixed at `https://decisions.coulomb.social/auth/callback`; Host, forwarded headers and return URLs cannot replace it. A development HTTP listener does not replace this HTTPS callback registration. Deployment and -registration remain pending. `/healthz` checks this process; `/readyz` returns -503 because the protected approval path is incomplete. +registration rollout remain pending. `/healthz` checks this process; `/readyz` +returns 503 without owner review configuration and recent audit delivery health. +Readiness is not a declaration of native policy/login/custody admission. ## Identity boundary @@ -84,7 +86,7 @@ POST bodies are empty objects: Approval Engine derives identity and evidence from its verified token and discards caller content. The adapter extracts `(approval_id, subject_id, approved_at)` from the matching actual human entry, never `updated_at` or local time. A `409 duplicate_approver` triggers a GET of -that original entry and returns `duplicate=True`. The eventual UI must preserve +that original entry and returns `duplicate=True`. The controller preserves the original presentation correlation; it cannot attach a new presentation to an old entry just because a duplicate exists. Other conflicts remain refusals. @@ -93,17 +95,12 @@ There is no automatic POST retry. Durable disposition processing must reconcile the current engine entry and its correlation before reporting success or offering a retry. A failed dependency must never be recorded as a human decline. -The client is an internal seam, not a sufficient binding flow. Before a browser -route can call it, T08 must connect: +The client is an internal seam. `review.py` now connects fresh PDP entitlement, +durable presentations, actor/version/acknowledgment guards, local responses, +the transactional outbox and the original-entry recovery rules. `runtime.py` +schedules bounded delivery and reconciliation. No consume route exists. -1. The access-engine entitlement decision before rendering a named memo. -2. Wire the durable presentation/version, actor match and required acknowledgments. -3. Wire persisted dispositions and the transactional outbox, including return/discuss. -4. Independent audit custody/delivery and entry-correlation reconciliation. -5. Native registered KeyCape login and the deployed Approval Engine proof. - -The browser currently exposes no memo or entry route and no consume capability. -The legacy `evidence.Outbox` is an in-memory test double. `Store` now supplies -durable atomic state/evidence, and `audit.py` supplies delivery to the real -receiver contract; see [durable-review-evidence.md](durable-review-evidence.md). -Native custody, policy/controller integration and live admission stay in T08. +The legacy `evidence.Outbox` is an in-memory test double; `Store` supplies +durable atomic state/evidence. See [durable-review-evidence.md](durable-review-evidence.md). +Native policy/caller assignment, registered human login, deployment and +independent custody remain in T08. Disposable browser success does not close them. diff --git a/docs/durable-review-evidence.md b/docs/durable-review-evidence.md index 7242c43..301a63b 100644 --- a/docs/durable-review-evidence.md +++ b/docs/durable-review-evidence.md @@ -1,9 +1,10 @@ # Durable review evidence and audit delivery Implemented under `INFD-WP-0001-T08`. `store.py` replaces the domain tests' -in-memory outbox with a persistent internal store. It does not expose a browser -route, decide entitlement, or authorize an approval entry. `web.py` still has -no memo/entry routes and `/readyz` remains 503. +in-memory outbox with a persistent internal store. `review.py` and `web.py` now +connect it to the protected browser flow; the store itself never decides +entitlement. See [protected-browser-review.md](protected-browser-review.md) +for configuration, policy observations, schema v2 migration and readiness. ## Storage and recovery @@ -56,7 +57,7 @@ These are internal delivery records, not cached approval validity: 1. Record the human's accept intent and its evidence before an engine POST. 2. The controller must obtain a fresh applicable access-engine decision, verify the session and compare the memo's carried digest with the live approval. - **That controller/policy integration is still pending.** + `review.py` supplies these checks and persists the exact observation before use. 3. `begin_submission` atomically reserves one attempt. A stale presentation cannot start; a memo cannot be revised while its entry attempt is in flight or unresolved. Network I/O happens outside the SQLite transaction. @@ -76,7 +77,8 @@ does not prove which presentation caused it. The store does not attach it to the latest view or automatically POST again. A crash after reservation likewise does not reopen dispatch. The records remain visible for explicit controller/ operator recovery; no recovery path in this slice fabricates that missing link. -The remaining T08 controller must handle this state visibly before shipping. +The controller and UI now expose this state and remove the submission button; +native operator recovery admission remains in T08. ## Audit Core contract @@ -103,8 +105,9 @@ and auditor identities. That is contract proof, not production custody proof. Per-class heartbeat generation uses the existing 86,400-second declarations. It does not emit “nothing to report” for a class with undelivered evidence. -The host must schedule heartbeat generation, bounded draining and monitoring; -no background service or cadence is installed by this source change. +The configured `AuditPump` schedules 30-second ticks, bounded draining and +five-minute reconciliation. The deployment owner must admit its private +storage, credentials and monitoring; no running service was installed here. ## Reconciliation has two time bases @@ -127,18 +130,20 @@ The GH-DEC-2026-014 commitment-only limitation remains. ```sh INFD_APPROVAL_ENGINE_SOURCE=/home/worsch/approval-engine \ INFD_AUDIT_CORE_SOURCE=/home/worsch/audit-core \ +INFD_FLEX_AUTH_BINARY=/absolute/path/to/flex-auth \ uv run python -m pytest -q ``` -258 tests pass: 52 added tests cover transactional rollback, process death, +The prior 258-test milestone added 52 tests for transactional rollback, process death, restore, actor/version/ack guards, concurrent clicks, original correlation, ambiguous submissions, audit retry/refusal and actual receiver contracts. The opt-in suites use the actual Approval Engine and Audit Core implementations -with synthetic identity/custody. They make no policy decision and spend nothing. +with synthetic identity/custody. The current 344-test suite also exercises the +actual Flex Auth HTTP server; 12 Chromium checks cover the complete disposable +browser flow. No native credential or paid provider is used. -Remaining under T08: an admitted PDP package/caller and exact read/bind request, -protected review/ack/accept/return/discuss routes, durable policy observations, -visible unresolved-entry recovery, scheduled audit delivery/reconciliation, -native registered human login and deployed-engine/custody proof. No Informed +Remaining under T08: native PDP package/caller/assignment admission against the +[exact consumer contract](flex-auth-review-contract.md), registered human login, +deployed-engine/custody proof and operator recovery/backup admission. No Informed Decision policy package or registration was found in the checked Flex Auth examples, registry and docs at `88b3543`; do not substitute a local allow rule. diff --git a/docs/evidence/2026-09-11-browser-result.json b/docs/evidence/2026-09-11-browser-result.json new file mode 100644 index 0000000..6350d02 --- /dev/null +++ b/docs/evidence/2026-09-11-browser-result.json @@ -0,0 +1,81 @@ +{ + "fixture_only": true, + "browser": "Chromium", + "checks_passed": 12, + "checks": [ + "PKCE callback and secure browser session", + "Entitled page escapes memo and packet markup", + "Server refuses bypass of missing acknowledgments", + "Explicit acknowledgment unlocks the form", + "Complete attachment download stays behind entitlement", + "Return records a memo response without an engine entry", + "Mobile review fits the viewport", + "One actual approval entry, stable on browser reload", + "Audit receiver holds all commitments; unsigned decision gap retained", + "Real workload caller refusal hides review content", + "Sign-out clears the session; all forms carry the exact origin", + "No page errors or injected script execution" + ], + "fixture_transport": "real HTTPS on ephemeral ports; synthetic issuer and TokenReview", + "native_human_login_proven": false, + "native_policy_admitted": false, + "factory_attempts": 0, + "paid_model_calls": 0, + "final_component_state": { + "entries": 1, + "intents": [ + { + "disposition_id": "disp-8ad75d50-1222-4512-a74b-253a15e7d159", + "approval_id": "fixture", + "subject": "human-fixture", + "state": "confirmed", + "attempt": "980d4cd4-753e-40f7-8026-f21923a340fd", + "approved_at": "2026-09-10T22:29:05+00:00", + "presentation_id": "pres-5673438f-ad6c-4ce1-bf26-edfc9211e476", + "operation_id": "c60eaa0c-b627-4f59-8e84-d35fc69d4963" + } + ], + "outbox_states": [ + "delivered", + "delivered", + "delivered", + "delivered", + "delivered", + "delivered", + "delivered", + "delivered", + "delivered" + ], + "policy_outcomes": [ + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "allow", + "caller_refused" + ], + "decision_attributable": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "entry_posts": 1, + "consume_calls": 0, + "tokenreviews": 12 + } +} diff --git a/docs/evidence/2026-09-11-protected-browser-review.json b/docs/evidence/2026-09-11-protected-browser-review.json new file mode 100644 index 0000000..3eb46e1 --- /dev/null +++ b/docs/evidence/2026-09-11-protected-browser-review.json @@ -0,0 +1,93 @@ +{ + "schema": "informed-decision.protected-browser-review.v1", + "observed_at": "2026-09-10T22:29:50.412419+00:00", + "base_commit": "2cc32168acedb19698fa196911385745fbf897b1", + "contract_source_commits": { + "approval-engine": "a0a602976eef818f36dde35f76f7f2e589bd051b", + "audit-core": "5c0ad522fb36092aa7ec2e8d72f63a5a91853b5b", + "flex-auth": "88b354377c8e26b162f1234e673072f1c06dcd89", + "key-cape": "139994cfac28ff97163ce4bf263f2bb035bbe0a7" + }, + "source_task": "INFD-WP-0001-T08", + "source_task_status": "progress", + "verification": { + "command": "INFD_APPROVAL_ENGINE_SOURCE=/home/worsch/approval-engine INFD_AUDIT_CORE_SOURCE=/home/worsch/audit-core INFD_FLEX_AUTH_BINARY= make check", + "tests_passed": 344, + "tests_failed": 0, + "tests_skipped": 0, + "new_tests": 86, + "schema_json_valid": true, + "flex_binary_sha256": "c7a1f35aa2cd7cf2733272923ace7fc4b4e55171db4ef8f3d0d6629183dcf1fa", + "browser_checks_passed": 12, + "browser_receipt": "2026-09-11-browser-result.json", + "components": "actual Flex Auth HTTP server with caller enforcement and Approval Engine/Audit Core APIs; synthetic issuer, TokenReview, assignments and credentials" + }, + "implemented": [ + "fresh pinned PDP checks before render, download and each action, with separate workload caller", + "immutable policy observations and original act binding snapshot; decision_attributable remains false", + "English organizational approval UI with explicit acknowledgments and accept/return/discuss/decline", + "original confirmed entry and visible unresolved state; no automatic mutation retry", + "owner runtime configuration, rotating token file callbacks, scheduled bounded audit delivery and readiness", + "atomic SQLite schema v1-to-v2 upgrade; old UI-release/profile refusal" + ], + "browser_findings_fixed": [ + "no-referrer produced Origin:null on real form POST; same-origin pages preserve exact-origin CSRF checks" + ], + "component_findings_fixed": [ + "native caller plain-text 401/403 must remain caller_refused instead of policy_unavailable" + ], + "source_sha256": { + "SCOPE.md": "b4568fe033b443d66833d1501fd5b04e01c9d6e95b44bae9666134e914ae758a", + "docs/browser-authentication.md": "9354f8a95914cb353b93256df9e85c455bcf425107f507dd8f1ba6206026794a", + "docs/durable-review-evidence.md": "f7f4a15faa479f3a132120a6fae27837bd2673afda93df7dbd29e8171a7911d0", + "docs/evidence/2026-09-11-browser-result.json": "f405f5d11ae695a9ca8098f6c6a5f125042a62c3af9e87f21bc5e266b6f5a397", + "docs/evidence/2026-09-11-recorded-desktop.png": "89f6e5396094155f2b1e04599f0a2b2d040f4dce004afa4dda752f234eef566a", + "docs/evidence/2026-09-11-review-desktop.png": "e050eceb62cb389d516e4a889c67b1610df3f5539d04efdbafa4874700e90ca3", + "docs/evidence/2026-09-11-review-mobile.png": "3a0b5b1e37b3a54c4eb34688b04bb38b18181b94106b062150fc1b70aa4f3f8a", + "docs/flex-auth-review-contract.md": "3324b7b022fb5397d02520310bf43e0bfbff8c919e95ba9fecc94334dae8caf9", + "docs/protected-browser-review.md": "28f5666a795997db6b6483ab6c3e0c6dab4cc7df948e57f1e5d02e5c45968317", + "informed_decision/approval_http.py": "203f462ff78f1409f2f924d4723197fdd502087c7f6da319f46ffffce2a9d478", + "informed_decision/http_transport.py": "fc9c1fd05cc89120d70bfcdb4c60fe244b81c8054d21505df8cfa9c5a229f7bc", + "informed_decision/memo.py": "697058863a4e6de2c143a2433eaaf5e15e5e7375dc5459af6911b146567e8b5d", + "informed_decision/oidc.py": "35b37bae67968c2179b22af8151afe2981d5468debf4488771eaf3121b75e221", + "informed_decision/policy.py": "7bf814a8332ff054bf5f1f651d8816a408138b1f197b17da0abb7f87411e8812", + "informed_decision/review.py": "c2d2aa1cf48d10d85b3606acf2830ab22701c1274221e5e2760d29901adfd786", + "informed_decision/runtime.py": "6bc6a74657f526743cca6c6cdc0a86fff43d8282e0b867dc9288344636abe5eb", + "informed_decision/store.py": "7a58b36d409cb4161b65985545e13573a5196779b4c6e95ca03ce45c6fada808", + "informed_decision/ui.py": "69fb7fc1794c8842f42e7f0f5a261c4cf64176053ef0a532db46ee6566621219", + "informed_decision/web.py": "00300dc25af3e272db7de748c255f8b1aa0b0182a54f2573e874ed5865da0fd8", + "pyproject.toml": "26e77b2da1ce7d6f1878fb6ca46fdb2acebbad0f6e17f5528d5a1f1d01defe42", + "tests/browser_fixture.py": "31feaf6e3351d9560498ffac2f06cae602f0762a01474bcff2c919c33db3cfcf", + "tests/browser_review.mjs": "0285f2cff70e5d569f31c7e99c8b3a5b5ce98b3eb51c2203de3f2e958f2ac684", + "tests/fixtures/review-policy/fixtures.json": "4c84a411383bfdbd32e15763ce2e934eefec570d93bc68389d6c81d11f3d4243", + "tests/fixtures/review-policy/pin.json": "91c77e4daa0a1c8c394e14e6b0c9d9c493ff21389fefa592656aa363c1104611", + "tests/fixtures/review-policy/policy.md": "cd0a5a79db840e1dad7cabece3976d1683387c71d8a5507476f49b421d180c24", + "tests/fixtures/review-policy/registry.json": "cd358b139f5987dcd0b4c8ac9fffee7d86951b15a22216f6ec21240a51ec41cf", + "tests/review_components.py": "415ef229aedd4def7af737274b4d341ddf4f40a912a8103a017d901b2cdecf2e", + "tests/test_browser_auth.py": "c70e24495d2f968c0a48e4082032666b4f4325f60d7e736c5a27d0fb99ef0f33", + "tests/test_http_transport.py": "d3dcf8051a7acfd152a583f7a865327ae60d880b9b07abc5f5fa9c693e95354e", + "tests/test_policy_client.py": "8ab6f8a06e8b28b4933e0d8d23650c9781a5365343fc4926080c7c2d5e2962ed", + "tests/test_review_component.py": "cd28100fbc66b64703f065c2a3638486043bee5e3e4a6e75de19eb58235f0d47", + "tests/test_review_controller.py": "df3ddfb60dd06728ee871225df96da13462d69f648f214a5ea1b040388c78d53", + "tests/test_review_runtime.py": "e230d9f590b86675b4d42f978cfd2bc769de404b3f5ad510a400ff2fcec62bbd", + "workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md": "49f68a11c301b436655144d4819e827d0ec036fdb2b4463d2679ce8c36cc3b3a" + }, + "limits": [ + "fixture-only policy; native caller/package/assignment admission pending", + "unsigned decisions remain unattributable under GH-DEC-2026-010 / FLEX-WP-0024", + "no native registration rollout, human login, deployment or audit custody proof", + "English organizational approve profile only; German and wider product acceptance remain T08", + "uncertain entry causation remains unresolved; no reset/retry shortcut", + "audit accepted_at and source occurred_at counts do not prove completeness or reconstructability", + "production packaging, private state, backup/restore and recovery admission pending" + ], + "browser_routes_implemented": true, + "native_policy_admitted": false, + "native_human_login_proven": false, + "production_audit_custody_proven": false, + "deployed": false, + "factory_attempts": 0, + "paid_model_calls": 0, + "remaining_live_task": "INFD-WP-0001-T08", + "factory_gate_task": "HFACT-WP-0001-T03" +} diff --git a/docs/evidence/2026-09-11-recorded-desktop.png b/docs/evidence/2026-09-11-recorded-desktop.png new file mode 100644 index 0000000..ac148be Binary files /dev/null and b/docs/evidence/2026-09-11-recorded-desktop.png differ diff --git a/docs/evidence/2026-09-11-review-desktop.png b/docs/evidence/2026-09-11-review-desktop.png new file mode 100644 index 0000000..bdd824a Binary files /dev/null and b/docs/evidence/2026-09-11-review-desktop.png differ diff --git a/docs/evidence/2026-09-11-review-mobile.png b/docs/evidence/2026-09-11-review-mobile.png new file mode 100644 index 0000000..3c6b15b Binary files /dev/null and b/docs/evidence/2026-09-11-review-mobile.png differ diff --git a/docs/flex-auth-review-contract.md b/docs/flex-auth-review-contract.md new file mode 100644 index 0000000..0c1cad5 --- /dev/null +++ b/docs/flex-auth-review-contract.md @@ -0,0 +1,104 @@ +# Flex Auth review consumer contract + +Source integration under `INFD-WP-0001-T08`; **production admission pending**. +This is the concrete consumer request for owner review. It does not grant a +role, register a caller or publish an allow policy. The checked Flex Auth source +at `88b354377c8e26b162f1234e673072f1c06dcd89` has no Informed Decision +production package in the examined examples, registry or docs. + +## Caller and subject + +`POST /v1/check` uses an independently provisioned workload credential, expected +to be a projected Kubernetes service-account token for audience `flex-auth`. +Flex Auth must admit the exact caller/system binding with TokenReview. The +fixture exercises `system:serviceaccount:informed-decision:review` bound to +system `informed-decision`; **that spelling is a proposal until the deployment +and policy owners admit it**. A human `approval-engine` token is never the +caller credential. Wrong service accounts and human-token substitution fail +before evaluation in the actual component test. + +The subject comes from the verified KeyCape human session, including verified +roles and unchanged assurance facts. Membership tenant and act scope remain +distinct. The tenant provenance values are the consumer's typed +`directory-asserted` or `registration-supplied`; human provenance is +`authentication-derived`. Unknown routes refuse before a request is made. +The production package must deliberately interpret the admitted transitional +tenant route and action-specific MFA freshness; this surface does not infer a +mandate from membership, the named recipient match or a successful login. + +## Closed request profile + +```json +{ + "id": "fresh-uuid-for-each-check", + "tenant": "tenant:platform", + "subject": { + "id": "VERIFIED-HUMAN-SUBJECT", + "type": "human", + "tenant": "tenant:platform", + "attributes": { + "tenant_source": "registration-supplied", + "principal_type_source": "authentication-derived", + "assurance": {"mfa": {"satisfied": true, "method": "totp", "at": "ISSUER-TIMESTAMP"}}, + "roles": ["VERIFIED-ISSUER-ROLE"] + } + }, + "action": "read", + "resource": { + "id": "memo:EXACT-MEMO-ID", + "type": "decision-memo", + "system": "informed-decision", + "tenant": "tenant:platform" + }, + "context": { + "memo_version": 1, + "approval_id": "EXACT-APPROVAL-ID", + "approval_binding_digest": "CARRIED-NATIVE-SHA256-DIGEST" + }, + "policy_version": "OWNER-ADMITTED-VERSION" +} +``` + +The assurance object above is illustrative; `build_request` carries the verified +issuer object without synthesizing stronger facts. Supported actions are +`read`, `acknowledge`, `accept`, `return`, `discuss`, `decline`. Read covers memo +rendering, original presentation retrieval and packet download. Other verbs +always get their own decision. Only accept can cause an Approval Engine POST. +No `consume`, wildcard action, `view_hash`, presentation id, acknowledgment +state or presentation claim is supplied. This prevents the renderer's evidence +from becoming an input to its own permission decision. + +The owner must provide the exact package/version/digest, resource/subject +assignment source, admissible caller, scope and positive/negative fixtures. +The consumer does not guess an operator group or ship a local allow fallback. +The separately pending CCR-2026-0019 reader group does not automatically confer +permission to read or approve a memo. + +## Required response and refusal behavior + +The response is `flex-auth.decision-record.v1`, with matching request id, +submitted request digest, core actor/resource/action/context binding and exact +package/version/digest pins. The evaluated digest and registry snapshot digest +are observed from the owner; registry enrichment is not repeated locally. +`submitted_digest` implements this consumer's bounded Go JSON request profile, +not a second approval-act canonicalizer. Actual Go checks cover enrichment, +Unicode and HTML escaping; the native approval digest is always carried. + +Only an allow with a current TTL and no obligations is supported. Maximum use +is 30 seconds from dispatch, bounded by the returned expiry and not-before; +there is no decision cache. A declared obligation needs explicit implementation +before it can be admitted. Deny, unsupported effects/obligations, stale or +mismatched responses and transport/caller failures all refuse. HTTP 401/403 +remains `caller_refused` even when the native caller gate returns plain text. + +Each obtained observation is append-only in the private evidence database, +before content exposure or an act. Diagnostic response text and bearer tokens +are not retained. Policy linkage is private content, not part of the emitted +presentation claim. The envelope remains unsigned, so +`decision_attributable=false`; native deployment cannot close `FLEX-WP-0024`. + +`tests/fixtures/review-policy/` is deliberately a **fixture-only** package, with +one synthetic human/memo/approval and no production assignment. Its native +package pin proves the consumer seam and cannot be used as admission evidence. +Owner return must include admitted policy/caller pins and deployed allow/refusal +receipts for the exact native human/act before T08 can claim live binding. diff --git a/docs/protected-browser-review.md b/docs/protected-browser-review.md new file mode 100644 index 0000000..8b0f7b8 --- /dev/null +++ b/docs/protected-browser-review.md @@ -0,0 +1,166 @@ +# Protected browser review + +Implemented under `INFD-WP-0001-T08`, release `0.2.0`. The configured service +now presents one named memo, records explicit highlight acknowledgments and +accept/return/discuss/decline responses, and submits an accepted request to +Approval Engine. This source has passed a disposable Chromium exercise; native +policy, human login, custody and deployment admission remain open. + +This first browser profile accepts English, organizational `approve` memos +only. Other locales, weaker steps and AES/QES levels refuse before rendering, +so an English interface cannot attest to a German presentation or imply a +signature level it does not implement. German UI acceptance (PR-60), broader +steps and awareness controls remain T08 product work; the domain model retains +their vocabulary. The initial factory browser proof uses this bounded profile. + +## Request and evidence path + +1. KeyCape login verifies the human, paired token claims and MFA facts. The + browser holds only an opaque session cookie. The human access token is used + only for Approval Engine and never as the Flex Auth workload credential. +2. `review.py` checks the named recipient and requests a fresh Flex Auth `read` + decision before exposing memo/packet content or calling Approval Engine. + The [consumer contract](flex-auth-review-contract.md) defines all six actions. +3. The immutable policy observation is persisted before use. The returned + package/version/digest, submitted request digest, core binding and lifetime + must match. Unsupported effects/obligations and unavailable callers refuse. + Registry enrichment is observed, never reproduced by this consumer. +4. The live Approval Engine object must declare `human_control=true` and match + the memo's carried act digest. The requesting party and named human approver + remain distinct. The presentation privately retains the observed binding. +5. Every acknowledgment or response obtains another applicable decision. Accept + requires the current memo, required acknowledgments, a live human session, + current audit readiness and an open matching approval. Immediately before + the engine POST, the adapter rechecks the binding and the controller checks + policy/session freshness and reserves the single durable attempt. +6. Acceptance stores the original engine entry correlation. Reload does not + POST again. A lost response, in-flight attempt or unknown duplicate remains + visible and cannot acquire a new presentation link. Return, discussion and + decline record local human responses and never create an engine entry. + +The governed canonicalizer remains the sole writer of `view_hash`. No +presentation/acknowledgment fact is submitted to its own permission decision. +Policy observations stay in private content, outside the presentation claim. +`decision_attributable=false` remains explicit: checking the current unsigned +envelope is not GH-DEC-2026-010 decision-path validation (`FLEX-WP-0024`). + +The renderer requires `memo.ui_release=informed-decision@0.2.0`. An older memo +must receive a new immutable version; it is not silently relabelled or rendered +as though the old interface produced the current view. Schema v1 databases +migrate atomically to v2 by adding append-only policy observations, preserving +old evidence and submission records. + +## Browser surface + +| Route | Behavior | +| --- | --- | +| `GET /` | Sign-in or named memo identifier form; no approvals inbox | +| `GET /review?memo_id=...` | Entitled current memo; existing intent retrieves its original view | +| `GET /presentations/` | Entitled historical presentation and current submission result | +| `GET /presentations//packet/` | Fresh read permission and full attachment bytes | +| `POST /presentations//ack` | Explicit acknowledgment of selected highlights | +| `POST /presentations//act` | Accept, return, discuss or decline with an operation id | + +Forms require the exact fixed origin and session CSRF token, with bounded bodies +and rejection of duplicate/unknown fields. Session expiry and actor/version +checks apply server-side, including when a user bypasses a disabled button. +The page names the complete request and packet as the object of acceptance. +Highlights do not narrow its scope. Untrusted memo text is escaped, previews +are bounded text, and downloads are attachments with `nosniff`. + +Pages use `Referrer-Policy: same-origin`, while authentication paths and +downloads use `no-referrer`. The Chromium test caught that using `no-referrer` +on a form page produces `Origin: null` and rejects legitimate submissions. +Missing, foreign and null form origins remain refused. Cross-origin referrers, +inline scripts, framing and caching remain prohibited. There is no telemetry +or client-side token storage. The reverse proxy must still omit auth queries, +bearer headers and cookies from its logs. + +## Owner configuration and audit readiness + +Set `INFD_REVIEW_CONFIG` to an absolute, owned, regular 0600 JSON file. Its +parent evidence directory must already exist with mode 0700. Without this +configuration, the entrypoint remains the login shell and `/readyz` stays 503. +The following is a shape example, **not an admitted configuration**; the +placeholders intentionally do not pass validation: + +```json +{ + "schema": "informed-decision.review-runtime.v1", + "evidence_db": "/OWNER-PRIVATE-PATH/review.sqlite", + "approval_origin": "https://APPROVAL-OWNER-ORIGIN", + "policy": { + "origin": "https://FLEX-OWNER-ORIGIN", + "package": "OWNER-ADMITTED-PACKAGE", + "version": "OWNER-ADMITTED-VERSION", + "package_digest": "OWNER-ADMITTED-SHA256", + "caller_token_file": "/OWNER-PROJECTED-PATH/flex-caller-token" + }, + "audit": { + "origin": "https://AUDIT-OWNER-ORIGIN", + "sender_token_file": "/OWNER-PRIVATE-PATH/audit-sender-token" + } +} +``` + +`INFD_KEYCAPE_ISSUER` remains mandatory. Credential files are read afresh when +needed, so projected rotation does not require storing a token in configuration +or SQLite. Runtime loading provisions nothing. Fixed internal `.svc` origins +are supported explicitly; public cleartext origins and redirects are refused. +The server remains one Waitress process, loopback port 8080, four threads. +Multi-replica sessions/storage and deployment packaging require separate work. + +The audit thread ticks every 30 seconds, generates the declared per-class +heartbeats, drains at most ten records per tick and writes a private +`audit-reconciliation.json` at most every five minutes. A successful drain and +reconciliation with no retained delivery debt make readiness current for 90 +seconds. Errors or blocked records clear readiness; accept refuses while it is +closed. This is a process/delivery health signal, not an assertion of native +policy admission or authorization. Dispositions still persist transactionally. + +Credential/schema refusals need operator repair and explicit requeue; no failed +event is discarded. Source `occurred_at` and receiver `accepted_at` are retained +as different count windows, without an automatic loss or completeness claim. +An independent auditor reads the receiver in component tests. Production +sender/receiver custody, backup/restore and reconciliation procedures remain +admission work under T08 and `AUDIT-WP-0009-T11`. + +Uncertain entry recovery must preserve causation. Finding a later entry is +insufficient because Approval Engine stores no caller presentation/operation +id. Inspect the original immutable intent and independent evidence; never reset +an unresolved attempt merely to make the button available. If causation cannot +be established, keep it unresolved and obtain an owner-directed disposition of +the approval. This UI offers no shortcut that fabricates the missing link. + +## Reproduce the component and browser checks + +Build the checked Flex Auth source with its own `go build ./cmd/flex-auth`, then +set explicit source paths and the resulting binary: + +```sh +export INFD_APPROVAL_ENGINE_SOURCE=/path/to/approval-engine +export INFD_AUDIT_CORE_SOURCE=/path/to/audit-core +export INFD_FLEX_AUTH_BINARY=/absolute/path/to/flex-auth +make check +``` + +With the Python dev dependencies, Playwright and Chromium installed, start +`PYTHONPATH=. python tests/browser_fixture.py /tmp/infd-browser-UNIQUE` and run +`node tests/browser_review.mjs /tmp/infd-browser-UNIQUE` in another terminal. +`INFD_PLAYWRIGHT_PACKAGE` can name an installed Playwright package, and +`INFD_CHROMIUM_EXECUTABLE` can name its Chromium executable. The driver stops +the fixture and writes the result plus desktop/mobile screenshots. + +The harness uses real local HTTPS, browser redirects/forms/cookies, the actual +Flex Auth server with enforced caller authentication, and the actual Approval +Engine/Audit Core APIs with separate SQLite stores. Its issuer, TokenReview, +policy assignments, identities, credentials and act are synthetic. Browser +hostnames resolve to loopback; only the fixture certificate's public key is +trusted by the override. The harness control endpoints live only in tests. +Exact production callback/registration strings remain covered separately. + +The result covers login, escaped content, acknowledgment bypass refusal, +explicit acknowledgment, packet download, return, mobile fit, a single accepted +entry/reload, receiver delivery, workload caller refusal, sign-out and absence +of script execution. It proves no native human login, production policy/custody, +deployed UI or factory execution. See the dated JSON evidence for exact sources. diff --git a/informed_decision/approval_http.py b/informed_decision/approval_http.py index 5fa6019..77bacec 100644 --- a/informed_decision/approval_http.py +++ b/informed_decision/approval_http.py @@ -57,8 +57,12 @@ class ApprovalHTTPClient: def get_approval(self, approval_id: str) -> dict: return self._call("GET", approval_id) - def add_entry(self, approval_id: str) -> EntryResult: - self.get_approval(approval_id) # Require the declared human-control object before mutation. + def add_entry(self, approval_id: str, *, expected_binding_digest=None, before_post=None) -> EntryResult: + current = self.get_approval(approval_id) + if expected_binding_digest is not None and current["binding"]["digest"] != expected_binding_digest: + raise ApprovalEngineError(409, "binding_changed") + if before_post is not None: + before_post() # Recheck session/policy and reserve AFTER the last read. duplicate = False try: data = self._call("POST", approval_id, "/entries") diff --git a/informed_decision/http_transport.py b/informed_decision/http_transport.py index f894368..3a37518 100644 --- a/informed_decision/http_transport.py +++ b/informed_decision/http_transport.py @@ -64,9 +64,18 @@ class JSONTransport: raise TransportError("upstream redirect refused") raw = response.read(262145) if len(raw) > 262144: + if status >= 400: + return status, {} # Refusal status is known; discard the body. raise TransportError("upstream response too large") - result = json.loads(raw) + try: + result = json.loads(raw) + except (ValueError, UnicodeError): + if status >= 400: + return status, {} # Flex Auth's caller gate uses plain HTTP errors. + raise if not isinstance(result, dict): + if status >= 400: + return status, {} raise TransportError("upstream object required") return status, result except (URLError, OSError, ValueError, HTTPException): diff --git a/informed_decision/memo.py b/informed_decision/memo.py index e8a5143..3a4d3bf 100644 --- a/informed_decision/memo.py +++ b/informed_decision/memo.py @@ -189,7 +189,7 @@ class Memo: packet: tuple[PacketItem, ...] = () highlights: tuple[Highlight, ...] = () locale: str = "en" - ui_release: str = "informed-decision@0.1.0" + ui_release: str = "informed-decision@0.2.0" #: Co-reference to the act this memo presents. approval_id: str | None = None #: approval-engine's binding.digest over the five act fields, CARRIED here @@ -276,8 +276,8 @@ class Memo: *on whose behalf* the approval was issued; ours is the person being bound — the approver. Different roles, so dropping ours would remove *who was shown this* from `view_hash` and gut the promise this - repository exists to make. Raised with approval-engine rather than - assumed; if the two are the same field, this drops too. + repository exists to make. Approval Engine confirmed these distinct + roles in docs/approval-claim.md (a0a6029); this field remains. """ out: dict = {"principal": self.binding.principal.as_document()} if self.approval_binding_digest is None: diff --git a/informed_decision/oidc.py b/informed_decision/oidc.py index a92806e..f7f1f7b 100644 --- a/informed_decision/oidc.py +++ b/informed_decision/oidc.py @@ -39,6 +39,7 @@ class HumanSession: expires_at: float access_token: str = field(repr=False) csrf: str = field(default_factory=lambda: secrets.token_urlsafe(32), repr=False) + roles: tuple[str, ...] = () class KeyCapeLogin: @@ -168,7 +169,7 @@ class KeyCapeLogin: assert_human_control_dischargeable(human) session = HumanSession(access["sub"], Claim(access["tenant"], tenant_route), human, dict(assurance), min(identity["exp"], access["exp"], self.clock() + 900), - tokens["access_token"]) + tokens["access_token"], roles=tuple(access["roles"])) with self._lock: self._prune() if len(self._sessions) >= self.capacity: diff --git a/informed_decision/policy.py b/informed_decision/policy.py new file mode 100644 index 0000000..d477e00 --- /dev/null +++ b/informed_decision/policy.py @@ -0,0 +1,184 @@ +"""Consume Flex Auth decisions for this surface; never evaluate local policy. + +The submitted digest follows Flex Auth's published Go wire contract. It is +not the approval act digest, which this package only carries unchanged. +""" + +from dataclasses import dataclass +from datetime import datetime +import hashlib +import json +import re +import time +import uuid + +from .http_transport import JSONTransport, TransportError, fixed_origin +from .oidc import HumanSession + +DIGEST = re.compile(r"sha256:[0-9a-f]{64}") +ACTIONS = frozenset({"read", "acknowledge", "accept", "return", "discuss", "decline"}) +CONTRACT = "flex-auth.decision-record.v1" + + +class PolicyError(RuntimeError): + """Only bounded codes, never tokens or upstream diagnostic text.""" + + +def _sorted(value): + if isinstance(value, dict): + return {k: _sorted(value[k]) for k in sorted(value)} + if isinstance(value, list): + return [_sorted(v) for v in value] + if value is None or type(value) in (str, bool): + return value + if type(value) is int and abs(value) <= 2**53: + return value + raise ValueError("request profile contains an unsupported value") + + +def submitted_digest(request): + """Exact submitted tuple: Go struct order, sorted maps, HTML escaping. + + Supports this consumer's closed JSON profile (no floats or CARING input). + Real Flex Auth tests pin this against api.RequestDigest and enrichment. + """ + def ref(value, fields): + return {k: _sorted(value[k]) for k in fields if value.get(k)} + material = {"tenant": request["tenant"], + "subject": ref(request["subject"], ("id", "type", "tenant", "attributes")), + "action": request["action"], + "resource": ref(request["resource"], ("id", "type", "system", "tenant", "attributes"))} + if request.get("context"): + material["context"] = _sorted(request["context"]) + encoded = json.dumps(material, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + for char, escaped in (("<", r"\u003c"), (">", r"\u003e"), ("&", r"\u0026"), + ("\u2028", r"\u2028"), ("\u2029", r"\u2029")): + encoded = encoded.replace(char, escaped) + return "sha256:" + hashlib.sha256(encoded.encode()).hexdigest() + + +def build_request(session: HumanSession, memo, action, policy_version): + if action not in ACTIONS: + raise ValueError("unsupported review action") + return {"id": str(uuid.uuid4()), "tenant": "tenant:platform", + "subject": {"id": session.subject, "type": "human", "tenant": session.tenant.value, + "attributes": {"tenant_source": session.tenant.route.value, + "principal_type_source": session.principal_type.route.value, + "assurance": dict(session.assurance), "roles": list(session.roles)}}, + "action": action, + "resource": {"id": "memo:" + memo.id, "type": "decision-memo", + "system": "informed-decision", "tenant": "tenant:platform"}, + "context": {"memo_version": memo.version, "approval_id": memo.approval_id, + "approval_binding_digest": memo.approval_binding_digest}, + "policy_version": policy_version} + + +def _timestamp(value): + if not isinstance(value, str): + raise ValueError("timestamp required") + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError("timezone required") + return parsed.timestamp() + + +@dataclass(frozen=True) +class Observation: + request_json: str + decision_json: str | None + outcome: str + usable_until: float + response_sha256: str | None + + def require_current(self, now): + if self.outcome != "allow": + raise PolicyError(self.outcome) + if now >= self.usable_until: + raise PolicyError("decision_expired") + + +class PolicyClient: + def __init__(self, origin, token_provider, *, package, version, package_digest, + transport=None, clock=time.time, allow_internal_http=False): + self.origin = fixed_origin(origin, allow_internal_http=allow_internal_http) + if (not isinstance(package, str) or not package or not isinstance(version, str) or not version + or not isinstance(package_digest, str) or not DIGEST.fullmatch(package_digest)): + raise ValueError("exact policy package, version and digest pins are required") + self.package, self.version, self.package_digest = package, version, package_digest + self.token_provider = token_provider + self.transport = transport or JSONTransport(allow_internal_http=allow_internal_http) + self.clock = clock + + def check(self, request): + # Round-trip before dispatch: the observation always records the exact + # submitted object, even if another caller mutates its input later. + raw = json.dumps(request, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + request = json.loads(raw) + submitted = submitted_digest(request) + started = self.clock() + try: + token = self.token_provider() # Workload credential, NOT the browser token. + if (not isinstance(token, str) or not token or not token.isascii() + or len(token) > 32768 or any(ord(c) < 33 or ord(c) > 126 for c in token)): + raise ValueError() + except (OSError, ValueError): + return Observation(raw, None, "caller_unavailable", 0, None) + try: + status, data = self.transport.request("POST", self.origin + "/v1/check", + headers={"Authorization": "Bearer " + token, "Content-Type": "application/json"}, + body=raw.encode()) + except TransportError: + return Observation(raw, None, "policy_unavailable", 0, None) + # Retain no diagnostic/reason text, including on transport/auth failures. + try: + response_digest = "sha256:" + hashlib.sha256(json.dumps(data, sort_keys=True, + ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode()).hexdigest() + except (TypeError, ValueError, UnicodeError): + return Observation(raw, None, "invalid_decision", 0, None) + if status != 200: + return Observation(raw, None, "caller_refused" if status in (401, 403) else "policy_unavailable", 0, response_digest) + try: + if data.get("contract_version") != CONTRACT or data.get("request_id") != request["id"]: + raise ValueError() + if not isinstance(data.get("id"), str) or not data["id"] or len(data["id"]) > 256: + raise ValueError() + binding, provenance = data["binding"], data["provenance"] + if (binding["submitted_request_digest"] != submitted + or not DIGEST.fullmatch(binding["request_digest"]) + or binding["tenant"] != request["tenant"] or binding["action"] != request["action"] + or binding.get("context", {}) != request.get("context", {})): + raise ValueError() + # Registry may enrich attributes; core actor/resource identity must + # still name this request. Never reproduce registry enrichment. + for key, names in (("subject", ("id", "type", "tenant")), + ("resource", ("id", "type", "system", "tenant"))): + for name in names: + if binding[key].get(name) != request[key].get(name) or data[key].get(name) != binding[key].get(name): + raise ValueError() + if (provenance["policy_package"] != self.package or provenance["policy_version"] != self.version + or provenance["policy_package_digest"] != self.package_digest + or data.get("matched_policy_version") != self.version + or not DIGEST.fullmatch(provenance["registry_snapshot_digest"]) + or not isinstance(provenance.get("evaluator"), str) or not provenance["evaluator"].startswith("flex-auth/")): + raise ValueError() + now = self.clock() + decided = _timestamp(provenance["decision_time"]) + if decided > now + 30 or decided < started - 30: + raise ValueError() + safe = {k: data[k] for k in ("id", "contract_version", "request_id", "effect", + "matched_policy_version", "subject", "resource", "binding", "provenance")} + effect = data.get("effect") + if effect not in {"allow", "deny", "redact", "audit_only", "not_applicable"}: + raise ValueError() + until = 0 + outcome = "policy_denied" if effect == "deny" else "unsupported_effect" + if effect == "allow": + life = data["lifetime"] + until = min(_timestamp(life["expires_at"]), started + 30) + if life["kind"] != "ttl" or now >= until or _timestamp(life.get("not_before", provenance["decision_time"])) > now: + raise ValueError() + safe["lifetime"] = life + outcome = "allow" if data.get("obligations", []) == [] else "unsupported_obligations" + return Observation(raw, json.dumps(safe, ensure_ascii=False, separators=(",", ":")), outcome, until, response_digest) + except (KeyError, TypeError, ValueError, AttributeError): + return Observation(raw, None, "invalid_decision", 0, response_digest) diff --git a/informed_decision/review.py b/informed_decision/review.py new file mode 100644 index 0000000..503e2d4 --- /dev/null +++ b/informed_decision/review.py @@ -0,0 +1,176 @@ +"""Protected review orchestration. Policy comes only from the injected PDP client.""" + +from dataclasses import dataclass +import time + +from .approval_client import ApprovalEngineError +from .disposition import Actor, ActorKind, Verb +from .memo import BindingLevel, StepKind +from .oidc import HumanSession +from .policy import PolicyError, build_request +from .provenance import Route, assert_human_control_dischargeable +from .store import Conflict + +UI_RELEASE = "informed-decision@0.2.0" + + +class ReviewError(RuntimeError): + def __init__(self, status, code): + super().__init__(code) + self.status, self.code = status, code + + +@dataclass(frozen=True) +class ReviewPage: + memo: object + presentation: object + binding: dict + documents: dict + intent: dict | None + stale: bool + engine_status: str + dispositions: tuple = () + + +class ReviewController: + def __init__(self, store, policy, approval_factory, *, clock=time.time): + self.store, self.policy, self.approval_factory = store, policy, approval_factory + self.clock = clock + + def _session(self, session): + if not isinstance(session, HumanSession) or session.expires_at <= self.clock(): + raise ReviewError(401, "session_expired") + assert_human_control_dischargeable(session.principal_type) + if session.tenant.value != "tenant:platform" or session.tenant.route not in (Route.DIRECTORY, Route.REGISTRATION): + raise ReviewError(403, "wrong_identity") + + def _memo(self, session, memo): + self._session(session) + # Stage 1 is a named approver, not a mandate discovery mechanism. This + # structural match never grants entitlement; a PDP allow is still owed. + if memo.binding.principal.id != session.subject or memo.binding.principal.kind != "person": + raise ReviewError(403, "wrong_recipient") + if not memo.approval_id or not memo.approval_binding_digest: + raise ReviewError(409, "missing_act_binding") + if memo.ui_release != UI_RELEASE: + raise ReviewError(409, "renderer_changed") + if (memo.locale != "en" or memo.binding_level is not BindingLevel.ORGANIZATIONAL + or memo.step_kind is not StepKind.APPROVE): + raise ReviewError(409, "unsupported_review_profile") + + def _authorize(self, session, memo, action): + self._memo(session, memo) + request = build_request(session, memo, action, self.policy.version) + observation = self.policy.check(request) + policy_id = self.store.observe_policy(observation) + try: + observation.require_current(self.clock()) + except PolicyError: + if observation.outcome == "policy_unavailable": + self.store.record_unreachable(memo.id, "access-engine") + raise ReviewError(403 if observation.outcome == "policy_denied" else 503, observation.outcome) from None + self._session(session) + return policy_id, observation + + def _approval(self, session, memo): + current = self.approval_factory(session).get_approval(memo.approval_id) + if current["binding"]["digest"] != memo.approval_binding_digest: + raise ReviewError(409, "binding_changed") + return current + + def open(self, session, memo_id): + self._session(session) + memo = self.store.memo(memo_id) + policy_id, observation = self._authorize(session, memo, "read") + current = self._approval(session, memo) + observation.require_current(self.clock()) + self._session(session) + intent = self.store.intent_for(memo.approval_id, session.subject) + if intent is not None: + # Recover the original view instead of assigning an existing entry + # or uncertain attempt to a freshly rendered presentation. + return self.load(session, intent["presentation_id"]) + p = self.store.present(memo.id, principal_sub=session.subject, tenant=session.tenant, + principal_type=session.principal_type, expected_version=memo.version, + policy_id=policy_id, approval_binding=current["binding"]) + saved, p, docs = self.store.retrieve_presentation(p.id) + return ReviewPage(saved, p, current["binding"], docs, None, False, current["status"]) + + def _presentation(self, session, presentation_id, *, current=False): + self._session(session) + p = self.store.presentation(presentation_id) + if p.principal_sub != session.subject: + raise ReviewError(403, "wrong_recipient") + memo = self.store.memo(p.memo_id, p.memo_version) + self._memo(session, memo) + if current and self.store.memo(memo.id).version != memo.version: + raise ReviewError(409, "stale_presentation") + return memo, p + + def load(self, session, presentation_id): + memo, p = self._presentation(session, presentation_id) + _, observation = self._authorize(session, memo, "read") + current = self._approval(session, memo) + content = self.store.presentation_content(p.id) + binding = content.get("approval_binding") + if not isinstance(binding, dict) or binding.get("digest") != memo.approval_binding_digest: + raise ReviewError(409, "missing_act_binding") + observation.require_current(self.clock()) + self._session(session) + saved, p, docs = self.store.retrieve_presentation(p.id) + return ReviewPage(saved, p, binding, docs, + self.store.intent_for(memo.approval_id, session.subject), + self.store.memo(memo.id).version != memo.version, current["status"], + tuple(self.store.dispositions_for(p.id))) + + def acknowledge(self, session, presentation_id, highlight_ids): + memo, p = self._presentation(session, presentation_id, current=True) + policy_id, observation = self._authorize(session, memo, "acknowledge") + observation.require_current(self.clock()) + return self.store.acknowledge(p.id, Actor(session.subject, ActorKind.PERSON), + highlight_ids, policy_id=policy_id) + + def act(self, session, presentation_id, verb, *, operation_id, reasons=(), note=None): + if verb not in (Verb.ACCEPT, Verb.RETURN, Verb.DISCUSS, Verb.DECLINE): + raise ReviewError(400, "unsupported_action") + memo, p = self._presentation(session, presentation_id, current=True) + policy_id, observation = self._authorize(session, memo, verb.value) + if verb is Verb.ACCEPT: + original = self.store.intent_for(memo.approval_id, session.subject) + if original is not None and (original["presentation_id"] != p.id or original["state"] != "prepared"): + return original["presentation_id"] + current = self._approval(session, memo) + entries = current.get("entries", []) + if not isinstance(entries, list) or any(not isinstance(e, dict) for e in entries): + raise ReviewError(503, "invalid_approval_response") + if any(e.get("subject_id") == session.subject for e in entries): + raise ReviewError(409, "existing_entry_unlinked") + if current["status"] not in ("requested", "approved"): + raise ReviewError(409, "act_unavailable") + observation.require_current(self.clock()) + self._session(session) + d = self.store.record_disposition(p.id, Actor(session.subject, ActorKind.PERSON), verb, + operation_id=operation_id, reasons=reasons, note=note, policy_id=policy_id) + if verb is not Verb.ACCEPT: + return p.id + attempt = None + def before_post(): + nonlocal attempt + observation.require_current(self.clock()) + self._session(session) + attempt = self.store.begin_submission(d.id, policy_id=policy_id) + try: + result = self.approval_factory(session).add_entry(memo.approval_id, + expected_binding_digest=memo.approval_binding_digest, before_post=before_post) + except ApprovalEngineError: + if attempt is None: + raise + self.store.finish_submission(d.id, attempt) # May have committed; never POST again. + except Conflict: + if attempt is not None or self.store.submission(d.id)["state"] == "prepared": + raise + # Another request won the durable reservation. Its original view + # now exposes the in-flight/result state; no second POST occurs. + else: + self.store.finish_submission(d.id, attempt, result) + return p.id diff --git a/informed_decision/runtime.py b/informed_decision/runtime.py new file mode 100644 index 0000000..f004d1b --- /dev/null +++ b/informed_decision/runtime.py @@ -0,0 +1,130 @@ +"""Owner-configured runtime and scheduled durable audit delivery. + +This loads explicit configuration; it neither provisions credentials nor admits +a deployment. Projected caller tokens and Audit Core sender custody have owners. +""" + +from dataclasses import dataclass +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import stat +import tempfile +import threading +import time + +from .approval_http import ApprovalHTTPClient +from .audit import AuditCoreSink, OutboxWorker +from .policy import PolicyClient +from .review import ReviewController +from .store import Store + + +def token_file(path): + path = Path(path) + if not path.is_absolute(): + raise ValueError("credential file path must be absolute") + def read(): + with path.open("rb") as handle: + value = handle.read(32769) + if len(value) > 32768: + raise ValueError("credential file too large") + return value.decode("ascii").strip() + return read + + +class AuditPump: + """One process, bounded batches, 30s ticks; never drops a failed record.""" + def __init__(self, store, sink, *, clock=time.time): + self.store, self.worker, self.clock = store, OutboxWorker(store, sink), clock + self._stop = threading.Event() + self._thread = None + self._lock = threading.Lock() + self._last_ok = None + self._last_reconciled = 0 + + def ready(self): + with self._lock: + return self._last_ok is not None and self.clock() - self._last_ok < 90 + + def tick(self): + try: + self.store.queue_heartbeats() + result = self.worker.run_once(limit=10) + now = self.clock() + if now - self._last_reconciled >= 300: + stamp = lambda t: datetime.fromtimestamp(t, timezone.utc).isoformat(timespec="microseconds") + report = self.worker.reconcile(stamp(now - 86400), stamp(now)) + # Report the two time bases, not an invented loss/completeness + # result. A retained private snapshot is for operator inspection. + directory = self.store.path.parent + fd, temporary = tempfile.mkstemp(prefix=".reconciliation-", dir=directory) + try: + with os.fdopen(fd, "w") as handle: + json.dump(report, handle, sort_keys=True) + handle.write("\n"); handle.flush(); os.fsync(handle.fileno()) + os.replace(temporary, directory / "audit-reconciliation.json") + finally: + if os.path.exists(temporary): + os.unlink(temporary) + self._last_reconciled = now + healthy = not result["retrying"] and not result["blocked"] and not any( + row["state"] != "delivered" for row in self.store.outbox()) + with self._lock: + self._last_ok = now if healthy else None + except Exception: + # Keep readiness closed and retry next tick. Never log response + # bodies, file paths, bearer tokens or a fabricated human decline. + with self._lock: + self._last_ok = None + + def start(self): + if self._thread is not None: + raise RuntimeError("audit delivery already started") + def run(): + while not self._stop.is_set(): + self.tick() + self._stop.wait(30) + self._thread = threading.Thread(target=run, name="infd-audit-delivery", daemon=True) + self._thread.start() + + def stop(self): + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=6) + + +@dataclass(frozen=True) +class Runtime: + controller: ReviewController + pump: AuditPump + + @classmethod + def from_file(cls, filename): + path = Path(filename) + info = path.lstat() + if (not path.is_absolute() or not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 + or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600 + or info.st_size > 16384): + raise ValueError("runtime configuration must be an owned private 0600 file") + data = json.loads(path.read_text()) + if (set(data) != {"schema", "evidence_db", "policy", "approval_origin", "audit"} + or data["schema"] != "informed-decision.review-runtime.v1" + or set(data["policy"]) != {"origin", "package", "version", "package_digest", "caller_token_file"} + or set(data["audit"]) != {"origin", "sender_token_file"}): + raise ValueError("invalid review runtime configuration") + if not Path(data["evidence_db"]).is_absolute(): + raise ValueError("absolute evidence database path required") + from .http_transport import fixed_origin + approval_origin = fixed_origin(data["approval_origin"], allow_internal_http=True) + policy = data["policy"] + client = PolicyClient(policy["origin"], token_file(policy["caller_token_file"]), + package=policy["package"], version=policy["version"], package_digest=policy["package_digest"], + allow_internal_http=True) + sink = AuditCoreSink(data["audit"]["origin"], token_file(data["audit"]["sender_token_file"]), + allow_internal_http=True) + store = Store(data["evidence_db"]) + controller = ReviewController(store, client, lambda session: ApprovalHTTPClient( + approval_origin, session, allow_internal_http=True)) + return cls(controller, AuditPump(store, sink)) diff --git a/informed_decision/store.py b/informed_decision/store.py index bfeebca..e51123a 100644 --- a/informed_decision/store.py +++ b/informed_decision/store.py @@ -27,7 +27,7 @@ from .provenance import Claim, Route, assert_human_control_dischargeable from .stance import resolve from .records import disposition_from, dumps, memo_from, presentation_from -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 _SCHEMA = """ CREATE TABLE documents (digest TEXT PRIMARY KEY, media_type TEXT NOT NULL, content BLOB NOT NULL); CREATE TABLE memos (id TEXT NOT NULL, version INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(id,version)); @@ -99,8 +99,19 @@ class Store: triggers += (f"CREATE TRIGGER immutable_{table}_{operation} BEFORE {operation} ON {table} " "BEGIN SELECT RAISE(ABORT,'immutable evidence'); END;\n") db.executescript("BEGIN IMMEDIATE;" + _SCHEMA + triggers + "PRAGMA user_version=1; COMMIT;") - elif version != SCHEMA_VERSION: + elif version not in (1, SCHEMA_VERSION): raise StoreError("unsupported evidence schema version") + if version in (0, 1): + db.executescript("""BEGIN IMMEDIATE; + CREATE TABLE policy_observations (id TEXT PRIMARY KEY, at TEXT NOT NULL, + request TEXT NOT NULL, decision TEXT, outcome TEXT NOT NULL, + usable_until REAL NOT NULL, response_sha256 TEXT, + decision_attributable INTEGER NOT NULL CHECK(decision_attributable=0)); + CREATE TRIGGER immutable_policy_observations_UPDATE BEFORE UPDATE ON policy_observations + BEGIN SELECT RAISE(ABORT,'immutable evidence'); END; + CREATE TRIGGER immutable_policy_observations_DELETE BEFORE DELETE ON policy_observations + BEGIN SELECT RAISE(ABORT,'immutable evidence'); END; + PRAGMA user_version=2; COMMIT;""") @contextmanager def _connection(self): @@ -168,9 +179,35 @@ class Store: raise EvidenceUnavailable("informed-decision cannot produce the named memo version") return memo_from(json.loads(row["body"])) - def memo(self, memo_id): + def memo(self, memo_id, version=None): with self._connection() as db: - return self._memo(db, memo_id) + return self._memo(db, memo_id, version) + + def observe_policy(self, observation): + request = json.loads(observation.request_json) + _text(request["id"], "policy observation id") + with self._transaction() as db: + db.execute("INSERT INTO policy_observations VALUES (?,?,?,?,?,?,?,0)", + (request["id"], _now(), observation.request_json, observation.decision_json, + observation.outcome, observation.usable_until, observation.response_sha256)) + return request["id"] + + def policy_observations(self): + with self._connection() as db: + return [dict(r) for r in db.execute("SELECT * FROM policy_observations ORDER BY rowid")] + + def _guard_policy(self, db, policy_id, memo, subject, action): + if policy_id is None: + return # Existing internal domain API; the browser always supplies one. + row = db.execute("SELECT * FROM policy_observations WHERE id=?", (policy_id,)).fetchone() + if row is None or row["outcome"] != "allow" or row["usable_until"] <= time.time(): + raise Conflict("a current recorded policy observation is required") + request = json.loads(row["request"]) + if (request["action"] != action or request["subject"]["id"] != subject + or request["resource"]["id"] != "memo:" + memo.id + or request["context"] != {"memo_version": memo.version, "approval_id": memo.approval_id, + "approval_binding_digest": memo.approval_binding_digest}): + raise Conflict("policy observation does not name this act") def _presentation(self, db, presentation_id): row = db.execute("SELECT body FROM presentations WHERE id=?", (presentation_id,)).fetchone() @@ -202,7 +239,8 @@ class Store: db.execute("INSERT INTO outbox(id) VALUES (?)", (commitment.id,)) return commitment.id - def present(self, memo_id, *, principal_sub, tenant, principal_type, awareness=None): + def present(self, memo_id, *, principal_sub, tenant, principal_type, awareness=None, + expected_version=None, policy_id=None, approval_binding=None): _text(principal_sub, "principal") if (not isinstance(tenant, Claim) or tenant.value != "tenant:platform" or tenant.route not in (Route.DIRECTORY, Route.REGISTRATION) @@ -210,17 +248,26 @@ class Store: raise ValueError("platform tenant and principal provenance are required") with self._transaction() as db: memo = self._memo(db, memo_id) + if expected_version is not None and memo.version != expected_version: + raise Conflict("memo changed after the entitlement check") + self._guard_policy(db, policy_id, memo, principal_sub, "read") + if approval_binding is not None and approval_binding.get("digest") != memo.approval_binding_digest: + raise Conflict("observed act binding does not match this memo") presentation = render(memo, principal_sub=principal_sub, tenant=tenant, principal_type=principal_type, awareness=awareness) content = {"memo": json.loads(dumps(memo)), "presentation": json.loads(dumps(presentation)), "binding_document": memo.binding_document(), "awareness_document": memo.awareness_document(awareness)} + if policy_id is not None: + content["policy_observation_id"] = policy_id + if approval_binding is not None: + content["approval_binding"] = approval_binding # Observed act, never approval validity/state. db.execute("INSERT INTO presentations VALUES (?,?,?,?)", (presentation.id, memo.id, memo.version, dumps(presentation))) commitment = commit_presentation(presentation, custody="informed-decision:presentations:" + presentation.id) self._event(db, commitment, content) return presentation - def acknowledge(self, presentation_id, actor: Actor, highlight_ids): + def acknowledge(self, presentation_id, actor: Actor, highlight_ids, *, policy_id=None): ids = frozenset(highlight_ids) with self._transaction() as db: p = self._presentation(db, presentation_id) @@ -229,6 +276,7 @@ class Store: raise DispositionRefused("G_ACTOR", "actor did not receive this presentation") if memo.version != p.memo_version: raise DispositionRefused("G_PRES", "presentation is stale") + self._guard_policy(db, policy_id, memo, actor.sub, "acknowledge") if ids - {h.id for h in memo.highlights}: raise DispositionRefused("G_ACK", "unknown highlight") fresh = ids - p.acked_highlight_ids @@ -239,11 +287,11 @@ class Store: db.execute("INSERT INTO acknowledgments VALUES (?,?,?)", (p.id, highlight_id, at)) p = replace(p, acked_highlight_ids=p.acked_highlight_ids | fresh) c = commit_presentation(p, custody="informed-decision:presentations:" + p.id) - self._event(db, c, {"presentation": json.loads(dumps(p))}, + self._event(db, c, {"presentation": json.loads(dumps(p)), "policy_observation_id": policy_id}, {"event_kind": "acknowledgment", "acknowledged_at": at}) return p - def record_disposition(self, presentation_id, actor, verb, *, operation_id, reasons=(), note=None): + def record_disposition(self, presentation_id, actor, verb, *, operation_id, reasons=(), note=None, policy_id=None): _text(operation_id, "operation id") if note is not None and (not isinstance(note, str) or len(note) > 8192): raise ValueError("disposition note is too large or invalid") @@ -258,6 +306,7 @@ class Store: return disposition_from(json.loads(existing["body"])) p = self._presentation(db, presentation_id) memo = self._memo(db, p.memo_id) + self._guard_policy(db, policy_id, memo, actor.sub, verb.value) d = record(memo, p, verb, actor, reasons=tuple(reasons), note=note) if verb in BINDING_VERBS: if p.principal_type is None: @@ -272,11 +321,12 @@ class Store: if verb is Verb.ACCEPT: db.execute("INSERT INTO submissions(disposition_id,approval_id,subject) VALUES (?,?,?)", (d.id, memo.approval_id, actor.sub)) c = commit_disposition(d, custody="informed-decision:dispositions:" + d.id) - self._event(db, c, {"disposition": json.loads(dumps(d)), "presentation": json.loads(dumps(p))}, + self._event(db, c, {"disposition": json.loads(dumps(d)), "presentation": json.loads(dumps(p)), + "policy_observation_id": policy_id}, {"submission_state": "prepared"} if verb is Verb.ACCEPT else None) return d - def begin_submission(self, disposition_id): + def begin_submission(self, disposition_id, *, policy_id=None): """Reserve one external attempt, AFTER the caller's fresh policy check. No network occurs in this store. A crash after this reservation stays @@ -290,6 +340,7 @@ class Store: p = self._presentation(db, d.presentation_id) if self._memo(db, p.memo_id).version != p.memo_version: raise DispositionRefused("G_PRES", "presentation is stale") + self._guard_policy(db, policy_id, self._memo(db, p.memo_id), d.actor.sub, "accept") attempt = str(uuid.uuid4()) db.execute("UPDATE submissions SET state='in_flight',attempt=? WHERE disposition_id=?", (attempt, disposition_id)) return attempt @@ -323,6 +374,27 @@ class Store: row = db.execute("SELECT * FROM submissions WHERE disposition_id=?", (disposition_id,)).fetchone() return dict(row) if row else None + def intent_for(self, approval_id, subject): + with self._connection() as db: + row = db.execute("SELECT s.*,d.presentation_id,d.operation_id FROM submissions s " + "JOIN dispositions d ON d.id=s.disposition_id WHERE s.approval_id=? AND s.subject=?", + (approval_id, subject)).fetchone() + return dict(row) if row else None + + def dispositions_for(self, presentation_id): + with self._connection() as db: + return [disposition_from(json.loads(r[0])) for r in db.execute( + "SELECT body FROM dispositions WHERE presentation_id=? ORDER BY rowid", (presentation_id,))] + + def presentation_content(self, presentation_id): + with self._connection() as db: + row = db.execute("SELECT content FROM evidence WHERE class=? AND " + "json_extract(envelope,'$.data.presentation_id')=? ORDER BY rowid LIMIT 1", + (EventClass.PRESENTATION.value, presentation_id)).fetchone() + if row is None: + raise EvidenceUnavailable("informed-decision cannot produce the presentation content") + return json.loads(row[0]) + def retrieve_presentation(self, presentation_id): """Internal custody retrieval, not a browser export/entitlement route.""" with self._connection() as db: diff --git a/informed_decision/ui.py b/informed_decision/ui.py new file mode 100644 index 0000000..998ba66 --- /dev/null +++ b/informed_decision/ui.py @@ -0,0 +1,122 @@ +"""Server-rendered review; no scripts, telemetry or browser-held bearer token.""" + +from html import escape +from urllib.parse import quote +import uuid + +from .disposition import Verb, legal_verbs + + +STYLES = """ +:root{color-scheme:light;font-family:system-ui,sans-serif;color:#172d35;background:#f4f5ef} +*{box-sizing:border-box}body{margin:0}a{color:#145c65}header{background:#173d43;color:white;padding:1.25rem max(1.25rem,calc((100vw - 1080px)/2));display:flex;justify-content:space-between;align-items:center;gap:1rem}header a{color:inherit;text-decoration:none;font-weight:700}header small{color:#d2e8df}main{max-width:1080px;margin:2.5rem auto;padding:0 1.25rem}h1{font-size:clamp(1.7rem,3vw,2.6rem);line-height:1.2;max-width:850px}h2{font-size:1.2rem;margin-top:0}h3{font-size:1rem}p,li{line-height:1.6}.eyebrow{font-size:.75rem;letter-spacing:.1em;text-transform:uppercase;color:#486a6f}.grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(230px,1fr);gap:1.25rem;align-items:start}.card{background:white;border:1px solid #d9dfd7;border-radius:12px;padding:1.5rem;margin-bottom:1.25rem}.notice{padding:1rem 1.25rem;border-left:4px solid #356f63;background:#e3eee7;margin:1.25rem 0}.warning{border-color:#a26814;background:#fff1d6}dl{margin:0}dt{font-size:.8rem;color:#5e7375;margin-top:1rem}dt:first-child{margin-top:0}dd{margin:.3rem 0;overflow-wrap:anywhere}pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.88rem;line-height:1.5;background:#f3f5f0;padding:1rem;border-radius:6px}button{font:inherit;font-weight:600;border:1px solid #1f6460;border-radius:7px;padding:.7rem 1.1rem;background:#23665d;color:white;cursor:pointer}button.secondary{background:white;color:#244d50}button:disabled{opacity:.45;cursor:not-allowed}input,select,textarea{font:inherit;max-width:100%;padding:.65rem;border:1px solid #9badaa;border-radius:5px}input[type=checkbox]{width:1.2rem;height:1.2rem;vertical-align:middle;margin-right:.6rem}label{display:block;line-height:1.5;margin:.7rem 0}textarea{display:block;width:100%;min-height:90px}.muted{font-size:.9rem;color:#567074}.brief{white-space:pre-wrap}.actions{display:flex;flex-wrap:wrap;gap:.7rem}.highlight{padding:.9rem 0;border-bottom:1px solid #e3e8df}.highlight:last-of-type{border-bottom:0}details{margin:1rem 0}summary{cursor:pointer;font-weight:600}.hash{overflow-wrap:anywhere;font-family:monospace;font-size:.8rem}.record{padding:.7rem 0;border-bottom:1px solid #e3e8df}:focus-visible{outline:3px solid #b67e19;outline-offset:3px}footer{padding:1.5rem 0;color:#567074;font-size:.85rem}@media(max-width:740px){.grid{grid-template-columns:1fr}main{margin:1.5rem auto}header{align-items:start;flex-direction:column}} +""" + + +def text(value): + return escape(str(value), quote=True) + + +def document(title, content, subject=None): + return ('' + '' + f'{text(title)} · Informed Decision' + '
Informed Decision' + f'{text(subject) if subject else "Human review"}
{content}' + '
An approval records a human decision. Execution has its own permission checks.
' + '
') + + +def hidden(name, value): + return f'' + + +def error_page(message, subject=None): + return document("Review unavailable", '

Review unavailable

'+text(message) + +'

Return to Informed Decision

', subject) + + +def review_page(page, session): + memo, p = page.memo, page.presentation + base = "/presentations/" + quote(p.id, safe="") + csrf = hidden("csrf", session.csrf) + missing = memo.required_ack_ids - p.acked_highlight_ids + legal = legal_verbs(memo.step_kind) + intent = page.intent + blocked = page.stale or page.engine_status not in ("requested", "approved") + accepted = intent and intent["state"] == "confirmed" + uncertain = intent and intent["state"] in ("in_flight", "unresolved") + if accepted: + notice = '
Approval entry recorded. This record retains the original review and acknowledgments. It does not authorize execution.
' + elif uncertain: + notice = '
Submission outcome is not confirmed. The entry may have reached Approval Engine. Do not submit another approval. This original record is retained for operator recovery.
' + elif page.stale: + notice = '
This is an earlier memo version. It cannot be used for a new action.
' + elif blocked: + notice = '
This approval is no longer open for a new entry.
' + else: + notice = '
Review the complete request and packet. Acknowledging highlights does not narrow what you accept.
' + binding = page.binding + facts = ''.join(f'
{label}
{text(binding.get(key, "Not supplied"))}
' for key, label in + (("action", "Action"), ("target", "Act scope"), ("actor", "Executing identity"), + ("principal", "Requesting party"), ("reason", "Reason"))) + highlights = '' + for index, h in enumerate(memo.highlights): + acked = h.id in p.acked_highlight_ids + control = (f'✓ Acknowledged' if acked else + f'') + highlights += (f'

{text(h.note)}

' + f'

Document: {text(h.item_id)} · {"Required acknowledgment" if h.required_ack else "Optional acknowledgment"}

' + + (control if not blocked and not accepted and not uncertain else ('

Acknowledged

' if acked else '

Not acknowledged

')) + '
') + ack_button = '' if blocked or accepted or uncertain else '' + highlights = (f'

Highlights

' + + csrf + highlights + ack_button + '
') if memo.highlights else '' + packets = '' + for index, item in enumerate(memo.packet): + body = page.documents[item.item_id] + # Browser display is escaped text; every original byte remains available + # through an entitled attachment response, never as executable HTML. + try: + preview = body.decode("utf-8") if len(body) <= 65536 else None + except UnicodeDecodeError: + preview = None + packets += (f'
{text(item.label)}' + + (f'
{text(preview)}
' if preview is not None else '

Open the complete attachment to review this document.

') + + f'Download complete document

{text(item.hash)}

') + packet = '

Complete packet

'+ (packets or '

No attachments.

') + '
' + terms = ''.join(f'

{label}

{text(value)}

' for label, value in + (("Terms", memo.binding.terms), ("Justification", memo.binding.justification)) if value) + forms = '' + if not blocked and not accepted and not uncertain: + for verb, label in ((Verb.ACCEPT, "Accept the complete request"), (Verb.RETURN, "Return for improvement"), + (Verb.DISCUSS, "Request discussion"), (Verb.DECLINE, "Decline")): + if verb not in legal: + continue + operation = intent["operation_id"] if intent and verb is Verb.ACCEPT else str(uuid.uuid4()) + fields = csrf + hidden("verb", verb.value) + hidden("operation_id", operation) + if verb is Verb.RETURN: + fields += '' + if verb in (Verb.RETURN, Verb.DISCUSS): + fields += '' + disabled = ' disabled' if missing and verb in (Verb.ACCEPT, Verb.DECLINE) else '' + forms += (f'{label}' + f'
{fields}

' + + ('Acceptance covers the entire request, including the complete packet.' if verb is Verb.ACCEPT else + 'This records your response on the memo.') + + f'

') + if missing: + forms = '

Record all required acknowledgments before accepting or declining.

' + forms + records = ''.join(f'
{text(d.verb.value.capitalize())} · {text(d.at)}' + + (f'

{text(d.note)}

' if d.note else '') + '
' for d in page.dispositions) + evidence = (f'
Evidence details

Memo {text(memo.id)} · version {memo.version}

' + f'

Presentation {text(p.id)}

View: {text(p.view_hash)}

' + f'

Act: {text(memo.approval_binding_digest)}

' + f'

Account zone: {text(session.tenant.value)} ({text(session.tenant.route.value)}). This is separate from the act scope.

') + content = (f'

Decision review · version {memo.version}

{text(memo.question)}

' + + notice + '

The request

' + f'

{text(memo.requested_act)}

{text(memo.brief)}

{terms}
' + + highlights + packet + '

Your response

' + (forms or '

No new approval submission is available from this record.

') + + (f'

Recorded responses

{records}' if records else '') + '
' + + f'
') + return document(memo.question, content, session.subject) diff --git a/informed_decision/web.py b/informed_decision/web.py index 68338d4..ffaa5e7 100644 --- a/informed_decision/web.py +++ b/informed_decision/web.py @@ -1,4 +1,4 @@ -"""Browser login shell. Protected memo rendering/binding is not wired yet.""" +"""Browser login and protected review routes, enabled by owner configuration.""" from __future__ import annotations @@ -7,9 +7,18 @@ from http.cookies import CookieError, SimpleCookie import json import os import secrets +import re +import sqlite3 from urllib.parse import parse_qs from .oidc import KeyCapeLogin, LoginError, ORIGIN +from .approval_client import ApprovalEngineError +from .disposition import DispositionRefused, Verb +from .policy import PolicyError +from .provenance import HumanControlNotDischargeable +from .review import ReviewError +from .store import Conflict, EvidenceUnavailable, StoreError +from .ui import STYLES, document, error_page, review_page FLOW_COOKIE = "__Host-infd-flow" SESSION_COOKIE = "__Host-infd-session" @@ -19,24 +28,30 @@ def _cookie(name: str, value: str, seconds: int) -> tuple[str, str]: return "Set-Cookie", f"{name}={value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={seconds}" -def _one(query: str) -> dict[str, str]: - if len(query) > 8192: +def _one(query: str, *, limit=8192, fields=16) -> dict[str, str]: + if len(query) > limit: raise ValueError("request too large") - values = parse_qs(query, keep_blank_values=True, max_num_fields=16) + values = parse_qs(query, keep_blank_values=True, max_num_fields=fields, errors="strict") if any(len(v) != 1 for v in values.values()): raise ValueError("duplicate parameter") return {k: v[0] for k, v in values.items()} class App: - def __init__(self, login: KeyCapeLogin): + def __init__(self, login: KeyCapeLogin, review=None, *, readiness=lambda: False): self.login = login + self.review, self.readiness = review, readiness def __call__(self, environ, start_response): + path = environ.get("PATH_INFO", "/") + # Chromium sends Origin: null for a form under no-referrer. Review + # pages need same-origin so legitimate POSTs satisfy the exact-origin + # CSRF check. Authentication URLs and downloads never send a referrer. + referrer_policy = "no-referrer" if path.startswith("/auth/") or "/packet/" in path else "same-origin" headers = [ ("Cache-Control", "no-store"), ("Pragma", "no-cache"), - ("Referrer-Policy", "no-referrer"), ("X-Content-Type-Options", "nosniff"), - ("Content-Security-Policy", "default-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"), + ("Referrer-Policy", referrer_policy), ("X-Content-Type-Options", "nosniff"), + ("Content-Security-Policy", "default-src 'none'; style-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"), ("Strict-Transport-Security", "max-age=31536000"), ] try: @@ -53,8 +68,37 @@ class App: headers.append(_cookie(FLOW_COOKIE, "", 0)) except (ValueError, CookieError, UnicodeError): status, body, content_type = 400, "Invalid request.", "text/plain" - raw = body.encode() - headers += [("Content-Type", content_type + "; charset=utf-8"), ("Content-Length", str(len(raw)))] + except ReviewError as exc: + messages = { + "session_expired": "Your session expired. Sign in again to continue.", + "invalid_form": "This form could not be verified. Reopen the review before submitting again.", + "policy_denied": "The permission service refused this review action.", + "wrong_recipient": "This review is addressed to another person.", + "stale_presentation": "The memo changed. Open its current version before taking an action.", + "binding_changed": "The approval no longer matches this memo. A revised review is required.", + "renderer_changed": "This memo names an earlier review interface. A new memo version is required before showing it here.", + "unsupported_review_profile": "This review interface supports English organizational approvals. The memo requires a different review profile.", + "existing_entry_unlinked": "An approval entry already exists, but this review cannot prove its original presentation. Operator recovery is required; no new entry was submitted.", + "act_unavailable": "This approval is no longer open for a new entry.", + } + status, body, content_type = exc.status, error_page(messages.get(exc.code, + "The review could not complete its required checks. Reload after the service is available.")), "text/html" + except DispositionRefused as exc: + status, body, content_type = 409, error_page({ + "G_ACK": "Record all required highlight acknowledgments before accepting or declining.", + "G_REASONS": "Choose a reason when returning this memo.", + "G_PRES": "The memo changed. Open its current version before taking an action.", + }.get(exc.guard, "This action is not available for the current review.")), "text/html" + except HumanControlNotDischargeable: + status, body, content_type = 403, error_page("A verified human session is required."), "text/html" + except EvidenceUnavailable: + status, body, content_type = 404, error_page("The requested review or its evidence is unavailable."), "text/html" + except Conflict: + status, body, content_type = 409, error_page("The review changed or already has a submission. Reload the original record."), "text/html" + except (PolicyError, ApprovalEngineError, StoreError, sqlite3.Error): + status, body, content_type = 503, error_page("The required service or evidence store is unavailable. A submission may be unresolved; reopen the original review before taking another action."), "text/html" + raw = body if isinstance(body, bytes) else body.encode() + headers += [("Content-Type", content_type + ("; charset=utf-8" if content_type != "application/octet-stream" else "")), ("Content-Length", str(len(raw)))] start_response(f"{status} {'OK' if status < 400 else 'ERROR'}", headers) return [raw] @@ -63,7 +107,11 @@ class App: if method == "GET" and path == "/healthz": return 200, '{"status":"ok"}', "application/json", [] if method == "GET" and path == "/readyz": + if self.review is not None and self.readiness(): + return 200, '{"status":"ready"}', "application/json", [] return 503, json.dumps({"status": "incomplete", "reason": "approval_path_not_connected"}), "application/json", [] + if method == "GET" and path == "/assets/review.css": + return 200, STYLES, "text/css", [] if method == "GET" and path == "/auth/start": url, browser = self.login.start() return 303, "", "text/plain", [("Location", url), _cookie(FLOW_COOKIE, browser, 300)] @@ -76,6 +124,48 @@ class App: return 303, "", "text/plain", [("Location", "/"), _cookie(FLOW_COOKIE, "", 0), _cookie(SESSION_COOKIE, new_sid, 900)] session = self.login.session(sid) + if self.review is not None and (path == "/review" or path.startswith("/presentations/")): + if session is None: + raise ReviewError(401, "session_expired") + if method == "GET" and path == "/review": + params = _one(environ.get("QUERY_STRING", "")) + if set(params) != {"memo_id"} or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,255}", params["memo_id"]): + raise ValueError("invalid memo id") + page = self.review.open(session, params["memo_id"]) + return 200, review_page(page, session), "text/html", [] + match = re.fullmatch(r"/presentations/(pres-[a-f0-9-]{36})(?:/(ack|act|packet/([0-9]{1,3})))?", path) + if not match: + return 404, "Not found.", "text/plain", [] + presentation_id, route, index = match.groups() + if method == "GET" and (route is None or index is not None): + page = self.review.load(session, presentation_id) + if index is not None: + number = int(index) + if number >= len(page.memo.packet): + return 404, "Not found.", "text/plain", [] + item = page.memo.packet[number] + return 200, page.documents[item.item_id], "application/octet-stream", [ + ("Content-Disposition", f'attachment; filename="review-document-{number + 1}.bin"')] + return 200, review_page(page, session), "text/html", [] + if method == "POST" and route in ("ack", "act"): + params = self._form(environ, session) + if route == "ack": + if any(k != "csrf" and not re.fullmatch(r"h[0-9]{1,3}", k) for k in params): + raise ValueError("unexpected acknowledgment field") + self.review.acknowledge(session, presentation_id, [v for k,v in params.items() if k != "csrf"]) + target = presentation_id + else: + if set(params) - {"csrf", "verb", "operation_id", "reason", "note"}: + raise ValueError("unexpected action field") + if not re.fullmatch(r"[a-f0-9-]{36}", params.get("operation_id", "")): + raise ValueError("invalid operation id") + if params.get("verb") == "accept" and not self.readiness(): + raise ReviewError(503, "audit_delivery_unavailable") + target = self.review.act(session, presentation_id, Verb(params.get("verb")), + operation_id=params["operation_id"], reasons=(params["reason"],) if params.get("reason") else (), + note=params.get("note") or None) + return 303, "", "text/plain", [("Location", "/presentations/" + target)] + return 405, "Method not allowed.", "text/plain", [("Allow", "POST" if route in ("ack", "act") else "GET")] if method == "POST" and path == "/auth/logout": if environ.get("HTTP_ORIGIN") != ORIGIN or not session: return 403, "Invalid sign-out request.", "text/plain", [] @@ -93,22 +183,48 @@ class App: if method == "GET" and path == "/": if session: content = (f"

Signed in as {html.escape(session.subject)}.

" - "

Decision review is being prepared. No approval has been recorded.

" + + ('

Open a decision review

Enter the memo identifier supplied with your review request.

' + '
' + '
' if self.review is not None else + '

Decision review is being prepared. No approval has been recorded.

') + '
' f'' '
') else: content = '

Sign in with KeyCape

' - return 200, ('' - '' - 'Informed Decision

Informed Decision

' - + content + ''), "text/html", [] + return 200, document("Informed Decision", content, session.subject if session else None), "text/html", [] return 404, "Not found.", "text/plain", [] + def _form(self, environ, session): + if environ.get("HTTP_ORIGIN") != ORIGIN: + raise ReviewError(403, "invalid_form") + length = int(environ.get("CONTENT_LENGTH") or 0) + if not 0 < length <= 65536 or environ.get("CONTENT_TYPE", "").split(";")[0] != "application/x-www-form-urlencoded": + raise ValueError("invalid body") + raw = environ["wsgi.input"].read(length) + if len(raw) != length: + raise ValueError("truncated body") + params = _one(raw.decode(), limit=65536, fields=256) + csrf = params.get("csrf", "") + if not csrf.isascii() or not secrets.compare_digest(csrf, session.csrf): + raise ReviewError(403, "invalid_form") + return params + def main(): from waitress import serve # Waitress does not log request targets; a proxy must also omit callback # query strings and cookies. No debug traceback middleware belongs here. - app = App(KeyCapeLogin(os.environ["INFD_KEYCAPE_ISSUER"])) - serve(app, host="127.0.0.1", port=8080, threads=4) + login = KeyCapeLogin(os.environ["INFD_KEYCAPE_ISSUER"]) + runtime = None + if os.environ.get("INFD_REVIEW_CONFIG"): + from .runtime import Runtime + runtime = Runtime.from_file(os.environ["INFD_REVIEW_CONFIG"]) + runtime.pump.start() + app = App(login, runtime.controller if runtime else None, + readiness=runtime.pump.ready if runtime else lambda: False) + try: + serve(app, host="127.0.0.1", port=8080, threads=4) + finally: + if runtime: + runtime.pump.stop() diff --git a/pyproject.toml b/pyproject.toml index d1fee40..1eef16d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "informed-decision" -version = "0.1.0" +version = "0.2.0" description = "Presentation and binding surface for decisions — the Decision Memo and its evidence." requires-python = ">=3.11" dependencies = ["PyJWT[crypto]>=2.10,<3", "waitress>=3,<4"] diff --git a/tests/browser_fixture.py b/tests/browser_fixture.py new file mode 100644 index 0000000..8b69e29 --- /dev/null +++ b/tests/browser_fixture.py @@ -0,0 +1,116 @@ +"""Local-only browser exercise over actual components and synthetic identity. + +Run with all three INFD_* component source/binary variables, and pass an output +directory. The fixture controls below are NOT part of informed_decision.web. +""" + +import json +import os +from pathlib import Path +import sys +import threading +import base64 +import hashlib +import ssl +from datetime import datetime, timedelta, timezone +from urllib.parse import parse_qs, urlsplit +from wsgiref.simple_server import WSGIRequestHandler, make_server +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.x509.oid import NameOID + +from informed_decision.oidc import KeyCapeLogin +from informed_decision.runtime import AuditPump +from informed_decision.web import App +from test_browser_auth import ISSUER, IssuerFixture, signing_key +from test_approval_component import component +from test_durable_component import receiver +from test_review_controller import review +from review_components import flex_component + + +def main(): + directory=Path(sys.argv[1]);directory.mkdir(mode=0o700,parents=True,exist_ok=True) + key=signing_key.__wrapped__() + class Quiet(WSGIRequestHandler): + def log_message(self,*args):pass + tls_server=make_server('127.0.0.1',0,lambda e,s:[],handler_class=Quiet) + public_origin=f'https://decisions.coulomb.social:{tls_server.server_port}' + issuer_origin=f'https://keycape.test:{tls_server.server_port}' + # Test-only ports, real HTTPS and redirects. Exact production strings stay + # pinned by the separate registration/login contract tests. + import informed_decision.oidc as oidc_module + import informed_decision.web as web_module + import test_browser_auth as browser_auth + import test_approval_component as approval_component + oidc_module.ORIGIN=web_module.ORIGIN=public_origin + oidc_module.CALLBACK=browser_auth.CALLBACK=public_origin+'/auth/callback' + browser_auth.ISSUER=approval_component.ISSUER=issuer_origin + now=datetime.now(timezone.utc) + name=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'Browser fixture')]) + certificate=(x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()) + .serial_number(x509.random_serial_number()).not_valid_before(now-timedelta(minutes=1)) + .not_valid_after(now+timedelta(hours=1)).add_extension(x509.SubjectAlternativeName([ + x509.DNSName('decisions.coulomb.social'),x509.DNSName('keycape.test')]),critical=False).sign(key,hashes.SHA256())) + cert_path=directory/'browser-cert.pem';cert_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM)) + key_path=directory/'browser-key.pem';key_path.write_bytes(key.private_bytes(serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8,serialization.NoEncryption()));key_path.chmod(0o600) + context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER);context.load_cert_chain(cert_path,key_path) + tls_server.socket=context.wrap_socket(tls_server.socket,server_side=True) + spki=base64.b64encode(hashlib.sha256(key.public_key().public_bytes(serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo)).digest()).decode() + engine_fixture=component.__wrapped__(key,directory);engine_parts=next(engine_fixture) + sink,receiver_transport,backend=receiver.__wrapped__(directory) + controller,_,_,memo,engine,transport=review.__wrapped__(engine_parts,directory) + issuer=IssuerFixture(key);login=KeyCapeLogin(issuer_origin,transport=issuer) + original_start=login.start + def start(): + url,browser=original_start() + issuer.params={k:v[0] for k,v in parse_qs(urlsplit(url).query).items()} + return url,browser + login.start=start + with flex_component(os.environ['INFD_FLEX_AUTH_BINARY'],directory,key) as (policy,calls): + controller.policy=policy + pump=AuditPump(controller.store,sink);pump.tick() + assert pump.ready() + app=App(login,controller,readiness=pump.ready) + def fixture(environ,start_response): + path=environ.get('PATH_INFO') + if path=='/authorize': + params={k:v[0] for k,v in parse_qs(environ.get('QUERY_STRING','')).items()} + assert params['code_challenge_method']=='S256' + assert params['redirect_uri']==oidc_module.CALLBACK + start_response('303 See Other',[('Location',oidc_module.CALLBACK+'?state='+params['state']+'&code=synthetic-browser-code')]) + return [b''] + if path=='/_fixture/status': + pump.tick() + intents=[controller.store.intent_for('fixture','human-fixture')] + payload={'entries':len(engine.get('fixture').entries),'intents':intents, + 'outbox_states':[r['state'] for r in controller.store.outbox()], + 'policy_outcomes':[r['outcome'] for r in controller.store.policy_observations()], + 'decision_attributable':[r['decision_attributable'] for r in controller.store.policy_observations()], + 'entry_posts':sum(m=='POST' for m,_ in transport.calls), + 'consume_calls':sum(url.endswith('/consume') for _,url in transport.calls), + 'tokenreviews':len(calls)} + raw=json.dumps(payload).encode();start_response('200 OK',[('Content-Type','application/json')]);return [raw] + if path=='/_fixture/refuse-caller': + policy.token_provider=lambda:'synthetic-wrong-caller' + start_response('200 OK',[('Content-Type','application/json')]);return [b'{}'] + if path=='/_fixture/stop': + threading.Thread(target=server.shutdown,daemon=True).start() + start_response('200 OK',[('Content-Type','application/json')]);return [b'{}'] + return app(environ,start_response) + tls_server.set_app(fixture) + tls_thread=threading.Thread(target=tls_server.serve_forever,daemon=True);tls_thread.start() + with make_server('127.0.0.1',0,fixture,handler_class=Quiet) as server: + (directory/'server.json').write_text(json.dumps({'origin':f'http://127.0.0.1:{server.server_port}', + 'public_origin':public_origin,'issuer':issuer_origin,'certificate_spki':spki,'memo_id':memo.id,'fixture_only':True})+'\n') + print('Browser fixture ready',flush=True) + try:server.serve_forever() + except KeyboardInterrupt:pass + finally: + tls_server.shutdown();tls_server.server_close();tls_thread.join(timeout=5) + engine_fixture.close() + + +if __name__=='__main__':main() diff --git a/tests/browser_review.mjs b/tests/browser_review.mjs new file mode 100644 index 0000000..153837b --- /dev/null +++ b/tests/browser_review.mjs @@ -0,0 +1,117 @@ +// Automated browser contract exercise. All identities and grants are synthetic. +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url); +const {chromium}=require(process.env.INFD_PLAYWRIGHT_PACKAGE || 'playwright'); +const directory=process.argv[2]; +const fixture=JSON.parse(await fs.readFile(path.join(directory,'server.json'),'utf8')); +const checks=[]; +const browser=await chromium.launch({headless:true,executablePath:process.env.INFD_CHROMIUM_EXECUTABLE,args:[ + '--no-proxy-server', + '--host-resolver-rules=MAP decisions.coulomb.social 127.0.0.1, MAP keycape.test 127.0.0.1', + '--ignore-certificate-errors-spki-list='+fixture.certificate_spki +]}); +const context=await browser.newContext({viewport:{width:1360,height:1000},acceptDownloads:true}); +const errors=[]; +const navigation=[]; +const formOrigins=[]; +try { + // Both test names resolve to the disposable HTTPS server. All redirects, + // browser cookies and form Origins travel through real HTTP/TLS. Only this + // fixture certificate's public key is trusted by the browser override. + const page=await context.newPage();page.setDefaultTimeout(10000); + page.on('pageerror',error=>errors.push(String(error))); + page.on('request',request=>{if(request.method()==='POST')formOrigins.push({ + path:new URL(request.url()).pathname,origin:request.headers().origin || null});}); + page.on('response',response=>navigation.push({path:new URL(response.url()).pathname,status:response.status()})); + page.on('dialog',async dialog=>{errors.push('Unexpected script dialog');await dialog.dismiss();}); + await page.goto(fixture.public_origin+'/'); + await page.getByRole('link',{name:'Sign in with KeyCape'}).click(); + await page.getByRole('heading',{name:'Open a decision review'}).waitFor({timeout:10000}); + const cookies=await context.cookies(); + const session=cookies.find(c=>c.name==='__Host-infd-session'); + assert(session?.secure && session?.httpOnly && session?.sameSite==='Lax'); + checks.push('PKCE callback and secure browser session'); + await page.getByLabel('Memo identifier').fill(fixture.memo_id); + await page.getByRole('button',{name:'Open review',exact:true}).click(); + await page.getByRole('heading',{name:'The request',exact:true}).waitFor(); + const accept=()=>page.getByRole('button',{name:'Accept the complete request',exact:true}); + assert(await accept().isDisabled()); + assert.equal(await page.locator('img,script').count(),0); + await page.screenshot({path:path.join(directory,'review-desktop.png'),fullPage:true}); + checks.push('Entitled page escapes memo and packet markup'); + await accept().evaluate(button=>button.disabled=false); + await accept().click(); + await page.getByText('Record all required highlight acknowledgments before accepting or declining.').waitFor(); + let state=await (await fetch(fixture.origin+'/_fixture/status')).json(); + assert.equal(state.entries,0);assert.equal(state.entry_posts,0); + checks.push('Server refuses bypass of missing acknowledgments'); + await page.goBack(); + await page.getByLabel('I have reviewed this highlight').check(); + await page.getByRole('button',{name:'Record acknowledgments',exact:true}).click(); + assert(await accept().isEnabled()); + checks.push('Explicit acknowledgment unlocks the form'); + await page.locator('summary').filter({hasText:'Whole fixture document'}).click(); + const [download]=await Promise.all([ + page.waitForEvent('download'),page.getByRole('link',{name:'Download complete document',exact:true}).click() + ]); + const bytes=await fs.readFile(await download.path()); + assert(bytes.includes(Buffer.from('Full fixture terms.'))); + checks.push('Complete attachment download stays behind entitlement'); + await page.locator('summary').filter({hasText:'Return for improvement'}).click(); + const returned=page.locator('form').filter({has:page.locator('select[name="reason"]')}); + await returned.locator('select').selectOption('clarification_needed'); + await returned.locator('textarea').fill('Synthetic browser return: clarify the fixture scope.'); + await returned.getByRole('button',{name:'Return for improvement',exact:true}).click(); + await page.getByText('Synthetic browser return: clarify the fixture scope.').waitFor(); + state=await (await fetch(fixture.origin+'/_fixture/status')).json();assert.equal(state.entries,0); + checks.push('Return records a memo response without an engine entry'); + await page.setViewportSize({width:390,height:844}); + assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)); + await page.screenshot({path:path.join(directory,'review-mobile.png'),fullPage:true}); + checks.push('Mobile review fits the viewport'); + await page.setViewportSize({width:1360,height:1000}); + await accept().click(); + await page.getByText('Approval entry recorded.',{exact:true}).waitFor(); + assert.equal(await page.getByRole('button',{name:'Accept the complete request',exact:true}).count(),0); + await page.screenshot({path:path.join(directory,'recorded-desktop.png'),fullPage:true}); + await page.reload();await page.getByText('Approval entry recorded.',{exact:true}).waitFor(); + state=await (await fetch(fixture.origin+'/_fixture/status')).json(); + assert.equal(state.entries,1);assert.equal(state.entry_posts,1);assert.equal(state.consume_calls,0); + assert(state.outbox_states.every(s=>s==='delivered')); + assert(state.decision_attributable.every(value=>value===0)); + checks.push('One actual approval entry, stable on browser reload'); + checks.push('Audit receiver holds all commitments; unsigned decision gap retained'); + await fetch(fixture.origin+'/_fixture/refuse-caller'); + await page.reload();await page.getByRole('heading',{name:'Review unavailable'}).waitFor(); + assert(!(await page.content()).includes('private-brief-sentinel')); + state=await (await fetch(fixture.origin+'/_fixture/status')).json(); + assert.equal(state.policy_outcomes.at(-1),'caller_refused');assert.equal(state.entries,1); + checks.push('Real workload caller refusal hides review content'); + await page.goto(fixture.public_origin+'/'); + await page.getByRole('button',{name:'Sign out',exact:true}).click(); + await page.getByRole('link',{name:'Sign in with KeyCape'}).waitFor(); + assert(!(await context.cookies()).some(c=>c.name==='__Host-infd-session')); + assert(formOrigins.every(form=>form.origin===fixture.public_origin)); + checks.push('Sign-out clears the session; all forms carry the exact origin'); + assert.deepEqual(errors,[]); + checks.push('No page errors or injected script execution'); + const receipt={fixture_only:true,browser:'Chromium',checks_passed:checks.length,checks, + fixture_transport:'real HTTPS on ephemeral ports; synthetic issuer and TokenReview', + native_human_login_proven:false,native_policy_admitted:false,factory_attempts:0,paid_model_calls:0, + final_component_state:state}; + await fs.writeFile(path.join(directory,'browser-result.json'),JSON.stringify(receipt,null,2)+'\n'); + process.stdout.write(JSON.stringify({checks_passed:checks.length,entries:state.entries,entry_posts:state.entry_posts,fixture_only:true})+'\n'); +} finally { + if(checks.length<12 && context.pages()[0]) { + const page=context.pages()[0], url=new URL(page.url()); + await fs.writeFile(path.join(directory,'browser-debug.json'),JSON.stringify({ + path:url.pathname,body:await page.locator('body').innerText(),navigation,formOrigins, + cookie_names:(await context.cookies()).map(c=>c.name),errors},null,2)); + await page.screenshot({path:path.join(directory,'browser-debug.png'),fullPage:true}); + } + await browser.close(); + await fetch(fixture.origin+'/_fixture/stop').catch(()=>{}); +} diff --git a/tests/fixtures/review-policy/fixtures.json b/tests/fixtures/review-policy/fixtures.json new file mode 100644 index 0000000..62e86f0 --- /dev/null +++ b/tests/fixtures/review-policy/fixtures.json @@ -0,0 +1,58 @@ +[ + { + "id": "synthetic-allow", + "request": { + "id": "fixture-pin", + "tenant": "tenant:platform", + "subject": { + "id": "human-fixture", + "type": "human", + "tenant": "tenant:platform" + }, + "action": "read", + "resource": { + "id": "memo:memo-1", + "type": "decision-memo", + "system": "informed-decision", + "tenant": "tenant:platform" + }, + "context": { + "memo_version": 1, + "approval_id": "fixture", + "approval_binding_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "policy_version": "v1" + }, + "expect": { + "effect": "allow" + } + }, + { + "id": "synthetic-refusal", + "request": { + "id": "fixture-pin", + "tenant": "tenant:platform", + "subject": { + "id": "human-fixture", + "type": "human", + "tenant": "tenant:platform" + }, + "action": "consume", + "resource": { + "id": "memo:memo-1", + "type": "decision-memo", + "system": "informed-decision", + "tenant": "tenant:platform" + }, + "context": { + "memo_version": 1, + "approval_id": "fixture", + "approval_binding_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "policy_version": "v1" + }, + "expect": { + "effect": "deny" + } + } +] diff --git a/tests/fixtures/review-policy/pin.json b/tests/fixtures/review-policy/pin.json new file mode 100644 index 0000000..d1a7dc0 --- /dev/null +++ b/tests/fixtures/review-policy/pin.json @@ -0,0 +1,6 @@ +{ + "package": "informed-decision.fixture", + "version": "v1", + "package_digest": "sha256:bf39cd7fb33c3db6ba5adb3c036911216e35ef03c1168034e38fe741df67653a", + "fixture_only": true +} diff --git a/tests/fixtures/review-policy/policy.md b/tests/fixtures/review-policy/policy.md new file mode 100644 index 0000000..2d7e500 --- /dev/null +++ b/tests/fixtures/review-policy/policy.md @@ -0,0 +1,50 @@ +--- +id: informed-decision.fixture +name: Synthetic review API contract fixture +namespace: informed-decision:decision-memo +version: v1 +status: ready +package: flexauth.informed_decision.fixture +allow_ttl: 60s +actions: [read, acknowledge, accept, return, discuss, decline] +owner: fixture-only +fixtures: [fixtures.json] +caring: + profile: caring-0.4.0-rc2 + enforce: false +activation: + mode: local +--- + +# Synthetic component fixture, never a production policy + +Only the locally signed human-fixture and memo:memo-1 can exercise this test. +No production approver, group, mandate or permission is established here. +No presentation claim, view hash, acknowledgment or local outcome is input. + +```rego +import rego.v1 + +decision := {"effect": "allow", "reason": "synthetic_review_fixture"} if { + input.tenant == "tenant:platform" + input.subject.id == "human-fixture" + input.subject.type == "human" + input.resource.system == "informed-decision" + input.resource.type == "decision-memo" + input.resource.id == "memo:memo-1" + input.action in {"read", "acknowledge", "accept", "return", "discuss", "decline"} + input.context.approval_id == "fixture" +} else := {"effect": "deny", "reason": "fixture_scope_refused"} if { + true +} +``` + +```rego test +package flexauth.informed_decision.fixture_test +import rego.v1 +import data.flexauth.informed_decision.fixture + +test_unknown_request_denied if { + fixture.decision.effect == "deny" with input as {} +} +``` diff --git a/tests/fixtures/review-policy/registry.json b/tests/fixtures/review-policy/registry.json new file mode 100644 index 0000000..667dd35 --- /dev/null +++ b/tests/fixtures/review-policy/registry.json @@ -0,0 +1,18 @@ +{ + "subjects": [ + { + "id": "human-fixture", + "type": "Human", + "display_name": "Synthetic browser reviewer", + "organization_relation": "ServiceProvider", + "roles": [ + "Operator" + ], + "groups": [], + "tenant": "tenant:platform", + "metadata": { + "source": "synthetic-contract-fixture" + } + } + ] +} diff --git a/tests/review_components.py b/tests/review_components.py new file mode 100644 index 0000000..5e6c3ee --- /dev/null +++ b/tests/review_components.py @@ -0,0 +1,79 @@ +"""Disposable actual Flex Auth HTTP process, with synthetic Kubernetes custody. + +The evaluator and inbound caller authenticator are real. TokenReview and its +identities are fixtures; this does not admit any production caller or policy. +""" + +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import os +from pathlib import Path +import socket +import subprocess +import threading +import time + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.x509.oid import NameOID + +from informed_decision.http_transport import JSONTransport, TransportError +from informed_decision.policy import PolicyClient + +FIXTURE = Path(__file__).parent / 'fixtures/review-policy' + + +@contextmanager +def flex_component(binary, directory, signing_key): + tokens={'synthetic-workload-token':'system:serviceaccount:informed-decision:review', + 'synthetic-wrong-caller':'system:serviceaccount:other:other'} + calls=[] + class Reviewer(BaseHTTPRequestHandler): + def log_message(self,*args):pass + def do_POST(self): + body=json.loads(self.rfile.read(int(self.headers['Content-Length']))) + token=body['spec']['token'];calls.append({'known':token in tokens,'audiences':body['spec']['audiences']}) + valid=(self.path=='/apis/authentication.k8s.io/v1/tokenreviews' + and self.headers.get('Authorization')=='Bearer synthetic-reviewer' + and body['spec']['audiences']==['flex-auth'] and token in tokens) + raw=json.dumps({'status':{'authenticated':valid,'audiences':['flex-auth'] if valid else [], + 'user':{'username':tokens.get(token,'')}}}).encode() + self.send_response(201);self.send_header('Content-Type','application/json');self.end_headers();self.wfile.write(raw) + server=ThreadingHTTPServer(('127.0.0.1',0),Reviewer) + thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + now=datetime.now(timezone.utc) + name=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'synthetic-tokenreview-fixture')]) + cert=(x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(signing_key.public_key()) + .serial_number(x509.random_serial_number()).not_valid_before(now-timedelta(minutes=1)) + .not_valid_after(now+timedelta(hours=1)).add_extension(x509.BasicConstraints(ca=True,path_length=None),critical=True) + .sign(signing_key,hashes.SHA256())) + ca=directory/'fixture-ca.pem';ca.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + reviewer=directory/'reviewer-token';reviewer.write_text('synthetic-reviewer');reviewer.chmod(0o600) + with socket.socket() as listener: + listener.bind(('127.0.0.1',0));port=listener.getsockname()[1] + origin=f'http://127.0.0.1:{port}' + process=subprocess.Popen([str(binary),'serve','--addr',f'127.0.0.1:{port}', + '--registry',str(FIXTURE/'registry.json'),'--policy',str(FIXTURE/'policy.md'), + '--caller-auth-mode','enforce','--caller-binding','informed-decision=system:serviceaccount:informed-decision:review', + '--caller-kubernetes-url',f'http://127.0.0.1:{server.server_port}', + '--caller-reviewer-token-file',str(reviewer),'--caller-ca-file',str(ca)],stdout=subprocess.DEVNULL,stderr=subprocess.PIPE) + try: + transport=JSONTransport(allow_internal_http=True) + for _ in range(100): + if process.poll() is not None: + raise RuntimeError('fixture evaluator exited: '+process.stderr.read().decode()[:1000]) + try: + if transport.request('GET',origin+'/healthz')[0]==200:break + except TransportError:pass + time.sleep(.05) + else:raise RuntimeError('fixture evaluator did not start') + pin=json.loads((FIXTURE/'pin.json').read_text());pin.pop('fixture_only') + client=PolicyClient(origin,lambda:'synthetic-workload-token',allow_internal_http=True,**pin) + yield client,calls + finally: + process.terminate() + try:process.wait(timeout=5) + except subprocess.TimeoutExpired:process.kill();process.wait(timeout=5) + process.stderr.close();server.shutdown();server.server_close();thread.join(timeout=5) diff --git a/tests/test_browser_auth.py b/tests/test_browser_auth.py index f441c7a..203d228 100644 --- a/tests/test_browser_auth.py +++ b/tests/test_browser_auth.py @@ -274,7 +274,7 @@ def test_issuer_error_and_duplicate_params_do_not_leak_or_exchange(login): @pytest.mark.parametrize("origin,csrf,expected", [(ORIGIN, "correct", 303), (ORIGIN, "wrong", 403), - (None, "correct", 403), ("https://attacker.test", "correct", 403)]) + (None, "correct", 403), ("null", "correct", 403), ("https://attacker.test", "correct", 403)]) def test_logout_requires_origin_and_session_csrf(login, origin, csrf, expected): sid = finish(login) token = login[0].session(sid).csrf if csrf == "correct" else "wrong" @@ -284,6 +284,12 @@ def test_logout_requires_origin_and_session_csrf(login, origin, csrf, expected): assert bool(login[0].session(sid)) == (expected != 303) +@pytest.mark.parametrize("path,policy", [("/", "same-origin"), ("/review", "same-origin"), + ("/auth/callback", "no-referrer"), ("/presentations/pres-fixture/packet/0", "no-referrer")]) +def test_browser_referrer_policy_preserves_form_origin_and_protects_auth_urls(login, path, policy): + assert dict(call(App(login[0]), path)["headers"])["Referrer-Policy"] == policy + + @pytest.mark.parametrize("path,method,expected", [("/healthz", "GET", 200), ("/readyz", "GET", 503), ("/approvals/approval-fixture", "GET", 404), ("/approvals/approval-fixture/accept", "POST", 404), ("/v1/approvals/approval-fixture/entries", "POST", 404), ("/auth/logout", "GET", 404)]) diff --git a/tests/test_http_transport.py b/tests/test_http_transport.py index dcf8d28..13efc3d 100644 --- a/tests/test_http_transport.py +++ b/tests/test_http_transport.py @@ -22,6 +22,8 @@ def upstream(): "/ok": (200, b'{"ok":true}'), "/refuse": (403, b'{"error":"forbidden"}'), "/broken": (200, b"broken"), "/array": (200, b"[]"), "/large": (200, b" " * 262145), + "/plain-refuse": (403, b"forbidden secret-sentinel"), + "/large-refuse": (401, b"x" * 262145), }.get(self.path, (500, b"{}")) self.send_response(status) self.end_headers() @@ -60,3 +62,9 @@ def test_json_success_and_typed_refusal_preserved(): client = JSONTransport(allow_internal_http=True) assert client.request("GET", origin + "/ok") == (200, {"ok": True}) assert client.request("GET", origin + "/refuse") == (403, {"error": "forbidden"}) + + +@pytest.mark.parametrize("path,status", [("/plain-refuse",403),("/large-refuse",401)]) +def test_http_caller_refusal_is_not_relabelled_as_transport_outage(path,status): + with upstream() as (origin,calls): + assert JSONTransport(allow_internal_http=True).request("GET",origin+path)==(status,{}) diff --git a/tests/test_policy_client.py b/tests/test_policy_client.py new file mode 100644 index 0000000..a29493c --- /dev/null +++ b/tests/test_policy_client.py @@ -0,0 +1,114 @@ +import copy +from dataclasses import replace +from datetime import datetime, timezone +import json +import time + +import pytest + +from informed_decision.http_transport import TransportError +from informed_decision.oidc import HumanSession +from informed_decision.policy import CONTRACT, PolicyClient, PolicyError, build_request, submitted_digest +from informed_decision.provenance import Claim, Route +from test_skeleton import make_memo + +PIN = "sha256:" + "b" * 64 +STAMP = lambda t: datetime.fromtimestamp(t, timezone.utc).isoformat() + + +def human(): + return HumanSession("human-fixture", Claim("tenant:platform", Route.REGISTRATION), + Claim("human", Route.AUTHENTICATION), {"at": int(time.time()) - 20, "level": "aal2", "mfa": True, + "methods": ["pwd", "otp"], "source": "key-cape"}, time.time()+600, "synthetic-browser-token", roles=("Reviewer",)) + + +def envelope(request): + now = time.time() + return {"id": "decision-fixture", "contract_version": CONTRACT, "request_id": request["id"], + "effect": "allow", "matched_policy_version": "v1", "subject": copy.deepcopy(request["subject"]), + "resource": copy.deepcopy(request["resource"]), + "binding": {**copy.deepcopy({k:request[k] for k in ("tenant","subject","action","resource","context")}), + "request_digest": "sha256:" + "e" * 64, "submitted_request_digest": submitted_digest(request)}, + "lifetime": {"kind":"ttl", "not_before":STAMP(now-1), "expires_at":STAMP(now+60)}, + "provenance": {"evaluator":"flex-auth/local", "mode":"standalone", "policy_package":"informed-decision.fixture", + "policy_version":"v1", "policy_package_digest":PIN, "registry_snapshot_digest":"sha256:"+"c"*64, + "decision_time":STAMP(now)}} + + +class PolicyTransport: + def __init__(self): self.calls=[]; self.change=lambda d: None; self.status=200 + def request(self, method, url, *, headers=None, body=None): + self.calls.append((method,url,headers,json.loads(body))) + data=envelope(json.loads(body)); self.change(data) + return self.status, data + + +def policy(transport=None): + return PolicyClient("https://policy.test", lambda:"synthetic-workload-token", package="informed-decision.fixture", + version="v1", package_digest=PIN, transport=transport or PolicyTransport()) + + +def request(): + return build_request(human(), make_memo(approval_binding_digest="sha256:"+"a"*64), "read", "v1") + + +def test_request_preserves_identity_routes_and_excludes_presentation_claims(): + req=request(); attributes=req["subject"]["attributes"] + assert attributes["tenant_source"]=="registration-supplied" and attributes["principal_type_source"]=="authentication-derived" + assert attributes["roles"]==["Reviewer"] + assert set(req["context"])=={"memo_version","approval_id","approval_binding_digest"} + assert "synthetic-browser-token" not in json.dumps(req) + assert not any(k in json.dumps(req) for k in ["view_hash","presentation_id","acked_highlight_ids"]) + + +def test_fresh_exact_enriched_decision_and_separate_workload_identity(): + client=policy();req=request();observed=client.check(req) + observed.require_current(time.time()) + data=json.loads(observed.decision_json) + assert data["binding"]["request_digest"]!=data["binding"]["submitted_request_digest"] + assert client.transport.calls[0][2]["Authorization"]=="Bearer synthetic-workload-token" + assert observed.usable_until<=time.time()+30 + with pytest.raises(PolicyError):observed.require_current(time.time()+31) + + +@pytest.mark.parametrize("mutate", [ + lambda d:d.update(contract_version="unknown"), lambda d:d.update(request_id="other"), + lambda d:d["binding"].pop("submitted_request_digest"), + lambda d:d["binding"].update(submitted_request_digest="sha256:"+"0"*64), + lambda d:d["binding"].update(action="accept"), lambda d:d["binding"]["subject"].update(id="other"), + lambda d:d["binding"]["resource"].update(system="other"), + lambda d:d["binding"]["context"].update(memo_version=99), + lambda d:d["binding"].update(tenant="tenant:other"), lambda d:d["resource"].update(id="memo:other"), + lambda d:d["provenance"].update(policy_package="other"), lambda d:d["provenance"].update(policy_version="other"), + lambda d:d["provenance"].update(policy_package_digest="sha256:"+"0"*64), + lambda d:d.update(matched_policy_version="other"), lambda d:d["provenance"].pop("registry_snapshot_digest"), + lambda d:d["provenance"].update(decision_time=STAMP(time.time()-300)), + lambda d:d["lifetime"].update(expires_at=STAMP(time.time()-1)), + lambda d:d["lifetime"].update(not_before=STAMP(time.time()+300)), + lambda d:d["lifetime"].update(kind="forever"), lambda d:d.update(lifetime={}), + lambda d:d.update(obligations=[{"type":"unimplemented"}]), lambda d:d.update(obligations={}), + lambda d:d.update(obligations=None), lambda d:d.update(effect="redact"), + lambda d:d.update(effect="audit_only"), lambda d:d.update(effect="not_applicable"), + lambda d:d.update(effect="deny"), lambda d:d.update(diagnostics={"bad":float("nan")}), +]) +def test_unusable_decision_never_allows(mutate): + transport=PolicyTransport();transport.change=mutate + observed=policy(transport).check(request()) + with pytest.raises(PolicyError):observed.require_current(time.time()) + + +@pytest.mark.parametrize("status", [400,401,403,404,409,500,503]) +def test_upstream_errors_never_persist_response_body(status): + transport=PolicyTransport();transport.status=status + transport.change=lambda d:d.update(reason="secret-sentinel",diagnostics={"token":"secret-sentinel"}) + observed=policy(transport).check(request()) + assert observed.decision_json is None and "secret-sentinel" not in repr(observed) + with pytest.raises(PolicyError):observed.require_current(time.time()) + + +def test_transport_and_missing_caller_have_typed_observations(): + class Broken: + def request(self,*a,**kw):raise TransportError("secret-sentinel") + client=policy(Broken());assert client.check(request()).outcome=="policy_unavailable" + client.token_provider=lambda:None + assert client.check(request()).outcome=="caller_unavailable" diff --git a/tests/test_review_component.py b/tests/test_review_component.py new file mode 100644 index 0000000..84981b5 --- /dev/null +++ b/tests/test_review_component.py @@ -0,0 +1,76 @@ +from dataclasses import replace +import json +import os +from pathlib import Path +import time + +import pytest + +from informed_decision.disposition import Verb +from informed_decision.policy import build_request +from informed_decision.runtime import AuditPump +from informed_decision.web import SESSION_COOKIE +from test_browser_auth import call, signing_key +from test_approval_component import component +from test_review_controller import review, opened +from test_durable_component import receiver +from review_components import flex_component + + +@pytest.fixture +def pdp(tmp_path,signing_key): + binary=os.environ.get('INFD_FLEX_AUTH_BINARY') + if not binary:pytest.skip('set INFD_FLEX_AUTH_BINARY to test the actual evaluator and caller boundary') + assert Path(binary).is_file() + with flex_component(binary,tmp_path,signing_key) as value:yield value + + +def test_actual_pdp_enrichment_and_workload_caller_boundary(pdp,review): + client,calls=pdp;controller,session,_,memo,*_=review + request=build_request(session,memo,'read',client.version) + observation=client.check(request);assert observation.outcome=='allow',observation + decision=json.loads(observation.decision_json) + assert decision['binding']['request_digest']!=decision['binding']['submitted_request_digest'] + assert calls[-1]=={'known':True,'audiences':['flex-auth']} + client.token_provider=lambda:'synthetic-wrong-caller' + assert client.check(request).outcome=='caller_refused' + client.token_provider=lambda:session.access_token + assert client.check(request).outcome=='caller_refused' # Browser JWT is never a workload credential. + + +def test_actual_request_digest_handles_go_string_escaping(pdp,review): + client,_=pdp;_,session,_,memo,*_=review + session=replace(session,roles=('Reviewer & ü \u2028 \u2029',)) + observation=client.check(build_request(session,memo,'read',client.version)) + assert observation.outcome=='allow',observation + + +def test_actual_policy_engine_and_independent_audit_receive_browser_act(pdp,review,receiver): + controller,session,app,memo,engine,transport=review;controller.policy=pdp[0] + page=opened(review);p=page.presentation + controller.acknowledge(session,p.id,['h-1']) + controller.act(session,p.id,Verb.ACCEPT,operation_id='real-component-click') + assert len(engine.get('fixture').entries)==1 + sink,receiver_transport,_=receiver + pump=AuditPump(controller.store,sink);pump.tick();assert pump.ready() + assert all(row['state']=='delivered' for row in controller.store.outbox()) + observations=controller.store.policy_observations() + assert len(observations)==3 and all(row['outcome']=='allow' and row['decision_attributable']==0 for row in observations) + response=call(app,'/presentations/'+p.id,cookie=SESSION_COOKIE+'=fixture-session') + assert 'Approval entry recorded.' in response['body'] + event=controller.store.evidence()[0] + status,body=receiver_transport.request('GET','https://audit.test/v1/events/'+event['id'],headers={'Authorization':'Bearer synthetic-auditor-token'}) + assert status==200 and body['details']['data']['view_hash']==p.view_hash + assert 'private-brief-sentinel' not in json.dumps(body) + report=json.loads((controller.store.path.parent/'audit-reconciliation.json').read_text()) + assert report['source_time_basis']=='occurred_at' and report['receiver_time_basis']=='accepted_at' + assert not report['completeness_proven'] and not report['automatic_loss_finding'] + + +def test_actual_policy_refusal_prevents_content_and_engine_read(pdp,review): + controller,session,app,memo,engine,transport=review;controller.policy=pdp[0] + controller.store.save_memo(replace(memo,id='not-assigned-in-fixture')) + before=len(transport.calls) + response=call(app,'/review',query='memo_id=not-assigned-in-fixture',cookie=SESSION_COOKIE+'=fixture-session') + assert response['status']==403 and 'private-brief-sentinel' not in response['body'] + assert len(transport.calls)==before and controller.store.evidence()==[] diff --git a/tests/test_review_controller.py b/tests/test_review_controller.py new file mode 100644 index 0000000..629a64b --- /dev/null +++ b/tests/test_review_controller.py @@ -0,0 +1,254 @@ +from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace +import json +import sqlite3 +import time +from urllib.parse import urlencode +import uuid + +import pytest + +from informed_decision.approval_http import ApprovalHTTPClient +from informed_decision.disposition import DispositionRefused, Verb +from informed_decision.http_transport import TransportError +from informed_decision.memo import BindingLevel, Highlight, PacketItem, Principal, Scope, StepKind +from informed_decision.policy import PolicyError +from informed_decision.review import ReviewController, ReviewError +from informed_decision.store import Conflict, Store +from informed_decision.web import App, SESSION_COOKIE +from informed_decision.oidc import ORIGIN +from test_approval_component import component, signing_key +from test_browser_auth import call +from test_policy_client import policy +from test_skeleton import make_memo + + +@pytest.fixture +def review(component, tmp_path): + client,engine,transport,session,_=component + private=tmp_path/'private';private.mkdir(mode=0o700) + store=Store(private/'review.sqlite') + packet=store.put_document(b'\nFull fixture terms.') + memo=make_memo(approval_id='fixture',approval_binding_digest=client.get_approval('fixture')['binding']['digest'], + question='Approve the synthetic factory delivery?', requested_act='deliver', + highlights=(Highlight('h-1','doc-1','Disposable test target; no production action',required_ack=True),), + packet=(PacketItem('doc-1','Whole fixture document',packet),), + brief='Disposable browser/component exercise. private-brief-sentinel ') + memo=replace(memo,binding=replace(memo.binding,principal=Principal(session.subject,'person','Fixture reviewer'), + target=Scope('resource','fixture','Synthetic factory target','test'))) + store.save_memo(memo) + controller=ReviewController(store,policy(),lambda s:ApprovalHTTPClient('https://approval.test',s,transport=transport)) + class Login: + def session(self,sid):return session if sid=='fixture-session' else None + app=App(Login(),controller,readiness=lambda:True) + return controller,session,app,memo,engine,transport + + +def opened(review): + controller,session,*_=review + return controller.open(session,'memo-1') + + +def post(review,p,route,**params): + _,session,app,*_=review + return call(app,'/presentations/'+p.id+'/'+route,'POST',cookie=SESSION_COOKIE+'=fixture-session', + origin=ORIGIN,body=urlencode({'csrf':session.csrf,**params}).encode()) + + +def test_read_denial_precedes_disclosure_engine_read_and_presentation(review): + controller,session,app,memo,engine,transport=review + controller.policy.transport.change=lambda d:d.update(effect='deny') + before=len(transport.calls) + r=call(app,'/review',query='memo_id=memo-1',cookie=SESSION_COOKIE+'=fixture-session') + assert r['status']==403 and 'private-brief-sentinel' not in r['body'] + assert len(transport.calls)==before and controller.store.evidence()==[] + assert controller.store.policy_observations()[0]['outcome']=='policy_denied' + + +def test_no_session_or_wrong_named_recipient_cannot_check_or_render(review): + controller,session,app,*_=review + assert call(app,'/review',query='memo_id=memo-1')['status']==401 + with pytest.raises(ReviewError):controller.open(replace(session,subject='other'),'memo-1') + assert controller.policy.transport.calls==[] and controller.store.policy_observations()==[] + + +@pytest.mark.parametrize('change', [{'locale':'de'}, {'binding_level':BindingLevel.AES}, + {'binding_level':BindingLevel.QES}, {'step_kind':StepKind.REVIEW}]) +def test_unsupported_profile_cannot_misstate_locale_or_binding_level(review,change): + controller,session,_,memo,*_=review + controller.store.save_memo(memo.next_version(**change)) + with pytest.raises(ReviewError,match='unsupported_review_profile'): + controller.open(session,memo.id) + assert controller.policy.transport.calls==[] and controller.store.evidence()==[] + + +def test_open_persists_exact_policy_and_binding_but_not_browser_credential(review): + controller,session,app,*_=review + page=opened(review);content=controller.store.presentation_content(page.presentation.id) + observed=controller.store.policy_observations() + assert observed[0]['decision_attributable']==0 + assert content['policy_observation_id']==observed[0]['id'] + assert content['approval_binding']['digest']==page.memo.approval_binding_digest + assert session.access_token not in json.dumps(observed) + assert all('policy_observation_id' not in r['envelope'] for r in controller.store.evidence()) + response=call(app,'/presentations/'+page.presentation.id,cookie=SESSION_COOKIE+'=fixture-session') + assert response['status']==200 and '<img' in response['body'] and '' not in response['body'] + assert not any(url.endswith('/consume') for _,url in transport.calls) + + +@pytest.mark.parametrize('verb',[Verb.RETURN,Verb.DISCUSS,Verb.DECLINE]) +def test_nonaccept_actions_preserve_vocabulary_without_engine_mutation(review,verb): + controller,session,app,memo,engine,transport=review;p=opened(review).presentation + controller.acknowledge(session,p.id,['h-1']) + result=post(review,p,'act',verb=verb.value,operation_id=str(uuid.uuid4()),reason='clarification_needed' if verb is Verb.RETURN else '',note='Private response') + assert result['status']==303 and not engine.get('fixture').entries + assert controller.store.dispositions_for(p.id)[0].verb is verb + response=call(app,'/presentations/'+p.id,cookie=SESSION_COOKIE+'=fixture-session') + assert 'Private response' in response['body'] + assert all('Private response' not in r['envelope'] for r in controller.store.evidence()) + + +def test_fresh_act_refusal_after_allowed_render_cannot_submit(review): + controller,session,app,memo,engine,transport=review;p=opened(review).presentation + controller.acknowledge(session,p.id,['h-1']) + controller.policy.transport.change=lambda d:d.update(effect='deny') + assert post(review,p,'act',verb='accept',operation_id=str(uuid.uuid4()))['status']==403 + assert not engine.get('fixture').entries and controller.store.dispositions_for(p.id)==[] + + +def test_policy_storage_failure_prevents_presentation(review): + controller,session,*_=review + with sqlite3.connect(controller.store.path) as db: + db.execute("CREATE TRIGGER fail_observation BEFORE INSERT ON policy_observations BEGIN SELECT RAISE(ABORT,'disk failure'); END") + with pytest.raises(sqlite3.IntegrityError):opened(review) + assert controller.store.evidence()==[] + + +def test_revision_between_policy_and_present_cannot_render_unchecked_version(review): + controller,session,_,memo,*_=review + controller.policy.transport.change=lambda d:controller.store.save_memo(memo.next_version(brief='changed')) + with pytest.raises(Conflict):opened(review) + assert controller.store.evidence()==[] + + +def test_stale_presentation_and_wrong_actor_cannot_ack_or_act(review): + controller,session,_,memo,engine,_=review;p=opened(review).presentation + with pytest.raises(ReviewError):controller.acknowledge(replace(session,subject='other'),p.id,['h-1']) + controller.store.save_memo(memo.next_version(brief='new version')) + with pytest.raises(ReviewError):controller.acknowledge(session,p.id,['h-1']) + with pytest.raises(ReviewError):controller.act(session,p.id,Verb.ACCEPT,operation_id='stale') + assert not engine.get('fixture').entries + + +def test_expiry_after_last_engine_read_prevents_reservation_and_post(review): + controller,session,_,memo,engine,transport=review;p=opened(review).presentation + controller.acknowledge(session,p.id,['h-1']) + original=transport.request;reads=0 + def request(method,url,**kwargs): + nonlocal reads + result=original(method,url,**kwargs) + if method=='GET': + reads+=1 + if reads==2:controller.clock=lambda:time.time()+40 + return result + transport.request=request + with pytest.raises(PolicyError):controller.act(session,p.id,Verb.ACCEPT,operation_id='late') + assert not engine.get('fixture').entries + assert controller.store.intent_for('fixture',session.subject)['state']=='prepared' + + +def test_parallel_clicks_submit_once(review): + controller,session,_,memo,engine,transport=review;p=opened(review).presentation + controller.acknowledge(session,p.id,['h-1']) + def click(_):return controller.act(session,p.id,Verb.ACCEPT,operation_id='one-click') + with ThreadPoolExecutor(max_workers=2) as pool:assert list(pool.map(click,range(2)))==[p.id,p.id] + assert len(engine.get('fixture').entries)==1 + assert sum(method=='POST' for method,_ in transport.calls)==1 + + +def test_lost_entry_reply_is_visible_and_never_retried(review): + controller,session,app,memo,engine,transport=review;p=opened(review).presentation + controller.acknowledge(session,p.id,['h-1']) + original=transport.request + def lose_reply(method,url,**kwargs): + result=original(method,url,**kwargs) + if method=='POST':raise TransportError('lost response') + return result + transport.request=lose_reply + controller.act(session,p.id,Verb.ACCEPT,operation_id='lost') + assert controller.store.intent_for('fixture',session.subject)['state']=='unresolved' + response=call(app,'/presentations/'+p.id,cookie=SESSION_COOKIE+'=fixture-session') + assert 'Submission outcome is not confirmed.' in response['body'] + assert 'Accept the complete request' not in response['body'] + controller.act(session,p.id,Verb.ACCEPT,operation_id='new-click') + assert len(engine.get('fixture').entries)==1 and sum(m=='POST' for m,_ in transport.calls)==1 + + +def test_unknown_existing_entry_never_acquires_a_new_presentation_link(review): + controller,session,_,memo,engine,transport=review;p=opened(review).presentation + ApprovalHTTPClient('https://approval.test',session,transport=transport).add_entry('fixture') + with pytest.raises(ReviewError,match='existing_entry_unlinked'): + controller.act(session,p.id,Verb.ACCEPT,operation_id='unlinked') + assert controller.store.intent_for('fixture',session.subject) is None + assert sum(m=='POST' for m,_ in transport.calls)==1 + + +def test_packet_download_rechecks_entitlement_and_forces_attachment(review): + controller,session,app,*_=review;p=opened(review).presentation + path='/presentations/'+p.id+'/packet/0' + response=call(app,path,cookie=SESSION_COOKIE+'=fixture-session') + assert response['status']==200 + assert dict(response['headers'])['Content-Type']=='application/octet-stream' + assert dict(response['headers'])['Content-Disposition'].startswith('attachment;') + controller.policy.transport.change=lambda d:d.update(effect='deny') + denied=call(app,path,cookie=SESSION_COOKIE+'=fixture-session') + assert denied['status']==403 and 'private-packet-sentinel' not in denied['body'] + + +def test_audit_not_ready_blocks_accept_before_policy_or_post(review): + controller,session,app,memo,engine,_=review;p=opened(review).presentation + app.readiness=lambda:False + assert post(review,p,'act',verb='accept',operation_id=str(uuid.uuid4()))['status']==503 + assert not engine.get('fixture').entries + assert call(app,'/readyz')['status']==503 + + +def test_renderer_release_cannot_silently_change_under_a_memo(review): + controller,session,_,memo,*_=review + controller.store.save_memo(memo.next_version(ui_release='informed-decision@0.1.0')) + with pytest.raises(ReviewError,match='renderer_changed'):opened(review) + assert controller.store.evidence()==[] diff --git a/tests/test_review_runtime.py b/tests/test_review_runtime.py new file mode 100644 index 0000000..02e3b5c --- /dev/null +++ b/tests/test_review_runtime.py @@ -0,0 +1,82 @@ +from dataclasses import replace +import json +import sqlite3 +import time + +import pytest + +from informed_decision.runtime import Runtime, AuditPump, token_file +from informed_decision.store import Store +from test_policy_client import policy,request +from test_durable_store import storage,present +from test_durable_component import receiver + + +def test_schema_one_upgrade_preserves_existing_evidence(storage): + store,memo=storage;p=present(store,memo) + before=store.evidence() + with sqlite3.connect(store.path) as db: + db.execute('DROP TABLE policy_observations');db.execute('PRAGMA user_version=1') + reopened=Store(store.path) + assert reopened.retrieve_presentation(p.id)[0]==memo and reopened.evidence()==before + assert reopened.policy_observations()==[] + observation=policy().check(request());reopened.observe_policy(observation) + assert Store(store.path).policy_observations()[0]['decision_attributable']==0 + with sqlite3.connect(store.path) as db: + for statement in ['DELETE FROM policy_observations',"UPDATE policy_observations SET outcome='allow'"]: + with pytest.raises(sqlite3.IntegrityError):db.execute(statement) + + +def test_caller_token_is_reread_on_rotation_and_bounded(tmp_path): + path=tmp_path/'token';path.write_text('synthetic-first\n');read=token_file(path) + assert read()=='synthetic-first' + path.write_text('synthetic-rotated');assert read()=='synthetic-rotated' + path.write_bytes(b'x'*32769) + with pytest.raises(ValueError):read() + with pytest.raises(ValueError):token_file('relative') + + +def config(tmp_path): + private=tmp_path/'private';private.mkdir(mode=0o700) + path=private/'runtime.json' + data={'schema':'informed-decision.review-runtime.v1','evidence_db':str(private/'review.sqlite'), + 'approval_origin':'https://approval.test','policy':{'origin':'https://policy.test','package':'fixture', + 'version':'v1','package_digest':'sha256:'+'a'*64,'caller_token_file':str(private/'unprovisioned-caller')}, + 'audit':{'origin':'https://audit.test','sender_token_file':str(private/'unprovisioned-sender')}} + path.write_text(json.dumps(data));path.chmod(0o600) + return path,data + + +def test_config_load_does_not_provision_or_read_credentials(tmp_path): + path,data=config(tmp_path);runtime=Runtime.from_file(path) + assert not runtime.pump.ready() + assert not (path.parent/'unprovisioned-caller').exists() + runtime.pump.tick();assert not runtime.pump.ready() + assert all(r['state']=='blocked' for r in runtime.controller.store.outbox()) + + +@pytest.mark.parametrize('fault',['permissions','symlink','unknown-key','relative-store','missing-pin','unsafe-origin']) +def test_unsafe_or_incomplete_config_refused(tmp_path,fault): + path,data=config(tmp_path) + if fault=='permissions':path.chmod(0o644) + elif fault=='symlink': + linked=path.parent/'link.json';linked.symlink_to(path);path=linked + elif fault=='unknown-key':data['allow_everyone']=True + elif fault=='relative-store':data['evidence_db']='relative.db' + elif fault=='missing-pin':data['policy']['package_digest']='' + elif fault=='unsafe-origin':data['approval_origin']='http://public.example.com' + if fault not in ('permissions','symlink'):path.write_text(json.dumps(data)) + with pytest.raises(ValueError):Runtime.from_file(path) + + +def test_pump_retains_blocked_delivery_and_requires_repair(storage,receiver): + store,memo=storage;present(store,memo) + sink,_,_=receiver;original=sink.token_provider;sink.token_provider=lambda:'synthetic-wrong-token' + pump=AuditPump(store,sink);pump.tick();assert not pump.ready() + ids=[r['id'] for r in store.outbox() if r['state']=='blocked'];assert ids + sink.token_provider=original + pump.tick();assert not pump.ready() # No implicit credential repair/requeue. + for ident in ids:store.requeue_blocked(ident) + pump.tick();assert pump.ready() + assert (store.path.parent/'audit-reconciliation.json').stat().st_mode & 0o777 == 0o600 + pump.clock=lambda:time.time()+91;assert not pump.ready() diff --git a/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md b/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md index 785b3f6..c064217 100644 --- a/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md +++ b/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md @@ -8,7 +8,7 @@ status: active owner: claude topic_slug: netkingdom created: "2026-09-09" -updated: "2026-09-09" +updated: "2026-09-11" reviewed_at: "2026-09-09" reviewed_against_commit: "ee2cca5" reviewed_note: >- @@ -568,6 +568,50 @@ in reconciliation, supply native custody and registered human/deployed-engine proof. These remain live work in this task; `/readyz` stays 503 and browser bind routes remain absent. Details: `docs/durable-review-evidence.md`. +2026-09-11 — **protected review and actual browser exercise implemented.** +The configured `0.2.0` service now connects the named memo to fresh Flex Auth +checks before render, download, acknowledgment and each human response. The +separate workload caller, exact package/version/digest and submitted request +binding are validated; observations are immutable and retain +`decision_attributable=false`. No presentation claim becomes a policy input. +Actual native component checks cover caller enforcement, request digest wire +format and registry enrichment, with fixture-only assignments and credentials. + +The browser supports explicit highlights, accept/return/discuss/decline, current +actor/version/session/act checks, original confirmed correlation and visible +uncertain submissions without POST retry. An audit thread drains bounded batches, +emits declared heartbeats and saves a two-time-base reconciliation snapshot; +unhealthy delivery closes acceptance readiness. Owner configuration provisions +no identity, policy or custody. Existing v1 databases migrate atomically to v2; +old memo UI releases require a new version. The first browser profile is English +organizational approval; unsupported locale/step/level refuses before rendering. + +344 automated checks pass, including actual Flex Auth, Approval Engine and +Audit Core components. Twelve Chromium checks exercise local HTTPS, synthetic +PKCE login, cookies, entitlement, escaped packet content, acknowledgment bypass, +return, responsive layout, a single accepted entry/reload, audit delivery, +caller refusal and sign-out. The browser caught `no-referrer` suppressing the +form Origin; review pages now preserve same-origin headers while auth/downloads +remain no-referrer. Missing/null/foreign form origins and bad CSRF still refuse. +Plain-text native caller 401/403 responses also retain their refusal meaning. + +Evidence: `docs/evidence/2026-09-11-protected-browser-review.json` and the +accompanying browser receipt/screenshots. This is disposable component/browser +proof, **not native human login, admitted policy/custody, deployment or factory +execution**. Factory attempts and paid calls remain zero. + +**Next within T08:** obtain owner admission against +`docs/flex-auth-review-contract.md` for the exact production package/pins, +caller and assignment source. Admit native review-sender custody with +AUDIT-WP-0009-T11, registration rollout, service packaging/private state, +backup/restore and uncertain-entry operating procedure; then exercise the +deployed callback and actual human binding against the admitted Approval Engine. +German UI acceptance (PR-60), broader product steps/awareness controls and the +specified evidence export remain product work under this live task. They must +not be silently claimed by the bounded factory profile. The exact operational +setup and remaining limits are in `docs/protected-browser-review.md`. +T08 remains `progress`; no residual has been hidden by finishing the workplan. + ## Known risks - **T02 is a hard gate.** Writing the blueprint before the layer ruling risks