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:
tegwick 2026-09-11 00:31:03 +02:00
parent 2cc32168ac
commit 83849b75d4
35 changed files with 2381 additions and 83 deletions

View file

@ -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")

View file

@ -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):

View file

@ -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:

View file

@ -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
View 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
View 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

View 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))

View file

@ -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
View 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)

View file

@ -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()