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