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
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue