Connect policy-gated browser review and audit runtime
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
2cc32168ac
commit
83849b75d4
35 changed files with 2381 additions and 83 deletions
42
SCOPE.md
42
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
81
docs/evidence/2026-09-11-browser-result.json
Normal file
81
docs/evidence/2026-09-11-browser-result.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
93
docs/evidence/2026-09-11-protected-browser-review.json
Normal file
93
docs/evidence/2026-09-11-protected-browser-review.json
Normal file
|
|
@ -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=<compiled-flex-auth> 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"
|
||||
}
|
||||
BIN
docs/evidence/2026-09-11-recorded-desktop.png
Normal file
BIN
docs/evidence/2026-09-11-recorded-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
BIN
docs/evidence/2026-09-11-review-desktop.png
Normal file
BIN
docs/evidence/2026-09-11-review-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
BIN
docs/evidence/2026-09-11-review-mobile.png
Normal file
BIN
docs/evidence/2026-09-11-review-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
104
docs/flex-auth-review-contract.md
Normal file
104
docs/flex-auth-review-contract.md
Normal file
|
|
@ -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.
|
||||
166
docs/protected-browser-review.md
Normal file
166
docs/protected-browser-review.md
Normal file
|
|
@ -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/<id>` | Entitled historical presentation and current submission result |
|
||||
| `GET /presentations/<id>/packet/<index>` | Fresh read permission and full attachment bytes |
|
||||
| `POST /presentations/<id>/ack` | Explicit acknowledgment of selected highlights |
|
||||
| `POST /presentations/<id>/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.
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
184
informed_decision/policy.py
Normal file
184
informed_decision/policy.py
Normal file
|
|
@ -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)
|
||||
176
informed_decision/review.py
Normal file
176
informed_decision/review.py
Normal file
|
|
@ -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
|
||||
130
informed_decision/runtime.py
Normal file
130
informed_decision/runtime.py
Normal file
|
|
@ -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))
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
122
informed_decision/ui.py
Normal file
122
informed_decision/ui.py
Normal file
|
|
@ -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 ('<!doctype html><html lang="en"><head><meta charset="utf-8">'
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
||||
f'<title>{text(title)} · Informed Decision</title><link rel="stylesheet" href="/assets/review.css">'
|
||||
'</head><body><header><a href="/">Informed Decision</a>'
|
||||
f'<small>{text(subject) if subject else "Human review"}</small></header><main>{content}'
|
||||
'<footer>An approval records a human decision. Execution has its own permission checks.</footer>'
|
||||
'</main></body></html>')
|
||||
|
||||
|
||||
def hidden(name, value):
|
||||
return f'<input type="hidden" name="{text(name)}" value="{text(value)}">'
|
||||
|
||||
|
||||
def error_page(message, subject=None):
|
||||
return document("Review unavailable", '<h1>Review unavailable</h1><p>'+text(message)
|
||||
+'</p><p><a href="/">Return to Informed Decision</a></p>', 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 = '<div class="notice" role="status"><strong>Approval entry recorded.</strong> This record retains the original review and acknowledgments. It does not authorize execution.</div>'
|
||||
elif uncertain:
|
||||
notice = '<div class="notice warning" role="status"><strong>Submission outcome is not confirmed.</strong> The entry may have reached Approval Engine. Do not submit another approval. This original record is retained for operator recovery.</div>'
|
||||
elif page.stale:
|
||||
notice = '<div class="notice warning" role="status">This is an earlier memo version. It cannot be used for a new action.</div>'
|
||||
elif blocked:
|
||||
notice = '<div class="notice warning" role="status">This approval is no longer open for a new entry.</div>'
|
||||
else:
|
||||
notice = '<div class="notice">Review the complete request and packet. Acknowledging highlights does not narrow what you accept.</div>'
|
||||
binding = page.binding
|
||||
facts = ''.join(f'<dt>{label}</dt><dd>{text(binding.get(key, "Not supplied"))}</dd>' 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'<span>✓ Acknowledged</span>' if acked else
|
||||
f'<label><input type="checkbox" name="h{index}" value="{text(h.id)}">I have reviewed this highlight</label>')
|
||||
highlights += (f'<div class="highlight"><p><strong>{text(h.note)}</strong></p>'
|
||||
f'<p class="muted">Document: {text(h.item_id)} · {"Required acknowledgment" if h.required_ack else "Optional acknowledgment"}</p>'
|
||||
+ (control if not blocked and not accepted and not uncertain else ('<p>Acknowledged</p>' if acked else '<p>Not acknowledged</p>')) + '</div>')
|
||||
ack_button = '' if blocked or accepted or uncertain else '<button class="secondary" type="submit">Record acknowledgments</button>'
|
||||
highlights = (f'<section class="card"><h2>Highlights</h2><form method="post" action="{base}/ack">'
|
||||
+ csrf + highlights + ack_button + '</form></section>') 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'<details><summary>{text(item.label)}</summary>'
|
||||
+ (f'<pre>{text(preview)}</pre>' if preview is not None else '<p>Open the complete attachment to review this document.</p>')
|
||||
+ f'<a href="{base}/packet/{index}">Download complete document</a><p class="hash">{text(item.hash)}</p></details>')
|
||||
packet = '<section class="card"><h2>Complete packet</h2>'+ (packets or '<p>No attachments.</p>') + '</section>'
|
||||
terms = ''.join(f'<h3>{label}</h3><p class="brief">{text(value)}</p>' 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 += '<label>Reason <select name="reason" required><option value="">Choose a reason</option><option value="clarification_needed">Clarification needed</option><option value="wrong_scope">Scope needs correction</option><option value="missing_information">Information missing</option></select></label>'
|
||||
if verb in (Verb.RETURN, Verb.DISCUSS):
|
||||
fields += '<label>Your note <textarea name="note" maxlength="8192"></textarea></label>'
|
||||
disabled = ' disabled' if missing and verb in (Verb.ACCEPT, Verb.DECLINE) else ''
|
||||
forms += (f'<details{" open" if verb is Verb.ACCEPT else ""}><summary>{label}</summary>'
|
||||
f'<form method="post" action="{base}/act">{fields}<p>'
|
||||
+ ('Acceptance covers the entire request, including the complete packet.' if verb is Verb.ACCEPT else
|
||||
'This records your response on the memo.')
|
||||
+ f'</p><button type="submit"{disabled}>{label}</button></form></details>')
|
||||
if missing:
|
||||
forms = '<p class="muted">Record all required acknowledgments before accepting or declining.</p>' + forms
|
||||
records = ''.join(f'<div class="record"><strong>{text(d.verb.value.capitalize())}</strong> · {text(d.at)}'
|
||||
+ (f'<p>{text(d.note)}</p>' if d.note else '') + '</div>' for d in page.dispositions)
|
||||
evidence = (f'<details><summary>Evidence details</summary><p>Memo {text(memo.id)} · version {memo.version}</p>'
|
||||
f'<p>Presentation {text(p.id)}</p><p class="hash">View: {text(p.view_hash)}</p>'
|
||||
f'<p class="hash">Act: {text(memo.approval_binding_digest)}</p>'
|
||||
f'<p>Account zone: {text(session.tenant.value)} ({text(session.tenant.route.value)}). This is separate from the act scope.</p></details>')
|
||||
content = (f'<p class="eyebrow">Decision review · version {memo.version}</p><h1>{text(memo.question)}</h1>'
|
||||
+ notice + '<div class="grid"><div><section class="card"><h2>The request</h2>'
|
||||
f'<p><strong>{text(memo.requested_act)}</strong></p><p class="brief">{text(memo.brief)}</p>{terms}</section>'
|
||||
+ highlights + packet + '<section class="card"><h2>Your response</h2>' + (forms or '<p>No new approval submission is available from this record.</p>')
|
||||
+ (f'<h3>Recorded responses</h3>{records}' if records else '') + '</section></div>'
|
||||
+ f'<aside class="card"><h2>What this act covers</h2><dl>{facts}<dt>Person being bound</dt>'
|
||||
f'<dd>{text(memo.binding.principal.display_name)} · {text(session.subject)}</dd></dl>{evidence}</aside></div>')
|
||||
return document(memo.question, content, session.subject)
|
||||
|
|
@ -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"<p>Signed in as {html.escape(session.subject)}.</p>"
|
||||
"<p>Decision review is being prepared. No approval has been recorded.</p>"
|
||||
+ ('<h1>Open a decision review</h1><p>Enter the memo identifier supplied with your review request.</p>'
|
||||
'<form method="get" action="/review"><label>Memo identifier <input name="memo_id" required maxlength="256"></label>'
|
||||
'<button type="submit">Open review</button></form>' if self.review is not None else
|
||||
'<p>Decision review is being prepared. No approval has been recorded.</p>') +
|
||||
'<form method="post" action="/auth/logout">'
|
||||
f'<input type="hidden" name="csrf" value="{session.csrf}">'
|
||||
'<button type="submit">Sign out</button></form>')
|
||||
else:
|
||||
content = '<p><a href="/auth/start">Sign in with KeyCape</a></p>'
|
||||
return 200, ('<!doctype html><html lang="en"><meta charset="utf-8">'
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
||||
'<title>Informed Decision</title><body><h1>Informed Decision</h1>'
|
||||
+ content + '</body></html>'), "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()
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
116
tests/browser_fixture.py
Normal file
116
tests/browser_fixture.py
Normal file
|
|
@ -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()
|
||||
117
tests/browser_review.mjs
Normal file
117
tests/browser_review.mjs
Normal file
|
|
@ -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(()=>{});
|
||||
}
|
||||
58
tests/fixtures/review-policy/fixtures.json
vendored
Normal file
58
tests/fixtures/review-policy/fixtures.json
vendored
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
6
tests/fixtures/review-policy/pin.json
vendored
Normal file
6
tests/fixtures/review-policy/pin.json
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"package": "informed-decision.fixture",
|
||||
"version": "v1",
|
||||
"package_digest": "sha256:bf39cd7fb33c3db6ba5adb3c036911216e35ef03c1168034e38fe741df67653a",
|
||||
"fixture_only": true
|
||||
}
|
||||
50
tests/fixtures/review-policy/policy.md
vendored
Normal file
50
tests/fixtures/review-policy/policy.md
vendored
Normal file
|
|
@ -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 {}
|
||||
}
|
||||
```
|
||||
18
tests/fixtures/review-policy/registry.json
vendored
Normal file
18
tests/fixtures/review-policy/registry.json
vendored
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
79
tests/review_components.py
Normal file
79
tests/review_components.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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)])
|
||||
|
|
|
|||
|
|
@ -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,{})
|
||||
|
|
|
|||
114
tests/test_policy_client.py
Normal file
114
tests/test_policy_client.py
Normal file
|
|
@ -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"
|
||||
76
tests/test_review_component.py
Normal file
76
tests/test_review_component.py
Normal file
|
|
@ -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 <x>& ü \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()==[]
|
||||
254
tests/test_review_controller.py
Normal file
254
tests/test_review_controller.py
Normal file
|
|
@ -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'<script>private-packet-sentinel</script>\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 <img src=x onerror=alert(1)>')
|
||||
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 '<img src=x' not in response['body']
|
||||
assert '<script>private-packet-sentinel' in response['body']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('fault',['origin','csrf','duplicate','content-type','field'])
|
||||
def test_post_form_refusals_precede_policy_and_side_effects(review,fault):
|
||||
controller,session,app,*_=review;p=opened(review).presentation
|
||||
body=urlencode({'csrf':session.csrf,'h0':'h-1'}).encode();origin=ORIGIN;ctype='application/x-www-form-urlencoded'
|
||||
if fault=='origin':origin='https://other.test'
|
||||
if fault=='csrf':body=b'csrf=wrong&h0=h-1'
|
||||
if fault=='duplicate':body+=b'&h0=other'
|
||||
if fault=='content-type':ctype='application/json'
|
||||
if fault=='field':body+=b'&subject=other'
|
||||
before=len(controller.policy.transport.calls)
|
||||
r=call(app,'/presentations/'+p.id+'/ack','POST',cookie=SESSION_COOKIE+'=fixture-session',body=body,origin=origin,content_type=ctype)
|
||||
assert r['status'] in (400,403) and len(controller.policy.transport.calls)==before
|
||||
assert not controller.store.presentation(p.id).acked_highlight_ids
|
||||
|
||||
|
||||
def test_highlights_gate_accept_then_one_real_entry_and_original_view(review):
|
||||
controller,session,app,memo,engine,transport=review;p=opened(review).presentation
|
||||
op=str(uuid.uuid4())
|
||||
assert post(review,p,'act',verb='accept',operation_id=op)['status']==409
|
||||
assert not engine.get('fixture').entries
|
||||
assert post(review,p,'ack',h0='h-1')['status']==303
|
||||
assert post(review,p,'act',verb='accept',operation_id=op)['status']==303
|
||||
assert len(engine.get('fixture').entries)==1
|
||||
posts=sum(method=='POST' for method,_ in transport.calls)
|
||||
assert post(review,p,'act',verb='accept',operation_id=op)['status']==303
|
||||
assert sum(method=='POST' for method,_ in transport.calls)==posts==1
|
||||
page=controller.open(session,memo.id)
|
||||
assert page.presentation.id==p.id and page.intent['state']=='confirmed'
|
||||
response=call(app,'/presentations/'+p.id,cookie=SESSION_COOKIE+'=fixture-session')
|
||||
assert 'Approval entry recorded.' in response['body']
|
||||
assert 'Accept the complete request</button>' 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</button>' 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()==[]
|
||||
82
tests/test_review_runtime.py
Normal file
82
tests/test_review_runtime.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue