"""Browser login and protected review routes, enabled by owner configuration.""" from __future__ import annotations import html 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 .lifecycle_service import LifecycleError from .store import Conflict, EvidenceUnavailable, StoreError from .ui import STYLES, document, error_page, overview_section, review_page FLOW_COOKIE = "__Host-infd-flow" SESSION_COOKIE = "__Host-infd-session" 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, *, 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=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, review=None, *, readiness=lambda: False, lifecycle=None): self.login = login self.review, self.readiness = review, readiness self.lifecycle = lifecycle 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", 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: cookies = SimpleCookie() cookies.load(environ.get("HTTP_COOKIE", "")) sid = cookies[SESSION_COOKIE].value if SESSION_COOKIE in cookies else "" browser = cookies[FLOW_COOKIE].value if FLOW_COOKIE in cookies else "" if not sid.isascii() or not browser.isascii(): raise ValueError("invalid cookie") status, body, content_type, extra = self.dispatch(environ, sid, browser) headers.extend(extra) except LoginError: status, body, content_type = 401, "Sign-in could not be completed. Start a new sign-in.", "text/plain" headers.append(_cookie(FLOW_COOKIE, "", 0)) except (ValueError, CookieError, UnicodeError): status, body, content_type = 400, "Invalid request.", "text/plain" 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.", "invalid_memo_id": "Enter the memo identifier only, for example SECRETS-WP-0010-T03-apply. Do not include a URL or quotation marks.", "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 LifecycleError as exc: status, body, content_type = exc.status, document("Decision unavailable", '

Decision unavailable

The request could not complete its required checks. ' 'Sign in again if your session expired, or reload the decision before retrying.

' '

Return to the workspace

'), "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] def dispatch(self, environ, sid, browser): method, path = environ.get("REQUEST_METHOD", "GET"), environ.get("PATH_INFO", "/") 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)] if method == "GET" and path == "/auth/callback": params = _one(environ.get("QUERY_STRING", "")) # Even an issuer refusal consumes state. Never echo error_description. new_sid = self.login.finish(params.get("state", ""), browser, "" if "error" in params else params.get("code", "")) self.login.logout(sid) return 303, "", "text/plain", [("Location", "/"), _cookie(FLOW_COOKIE, "", 0), _cookie(SESSION_COOKIE, new_sid, 900)] session = self.login.session(sid) if self.lifecycle is not None and (path == "/decisions" or path.startswith("/decisions/")): if session is None: raise LifecycleError(401, "identity_expired") from .lifecycle_ui import LifecycleWorkspace params = _one(environ.get("QUERY_STRING", "")) form = self._form(environ, session) if method == "POST" else None status, body, extra = LifecycleWorkspace(self.lifecycle).dispatch(method, path, params, session, form) return status, body, "application/octet-stream" if isinstance(body, bytes) else "text/html", extra 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": try: params = _one(environ.get("QUERY_STRING", "")) except (ValueError, UnicodeError): raise ReviewError(400, "invalid_memo_id") from None memo_id = params.get("memo_id", "").strip() if set(params) != {"memo_id"} or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,255}", memo_id): raise ReviewError(400, "invalid_memo_id") page = self.review.open(session, 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", [] length = int(environ.get("CONTENT_LENGTH") or 0) if not 0 < length <= 2048 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") csrf = _one(raw.decode()).get("csrf", "") if not csrf.isascii() or not secrets.compare_digest(csrf, session.csrf): return 403, "Invalid sign-out request.", "text/plain", [] self.login.logout(sid) return 303, "", "text/plain", [("Location", "/"), _cookie(SESSION_COOKIE, "", 0)] if method == "GET" and path == "/": if session: content = (f"

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

" + ('

Open the decision workspace

' if self.lifecycle is not None else '') + (self._overview(session) if self.review is not None else '') + ('

Open a decision review

Enter the memo identifier supplied with your review request.

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

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

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

Sign in with KeyCape

' return 200, document("Informed Decision", content, session.subject if session else None), "text/html", [] return 404, "Not found.", "text/plain", [] def _overview(self, session): # The overview is orientation only. Its failure must never take the # memo form, sign-out or an unresolved-entry recovery path with it. try: return overview_section(self.review.overview(session)) except ReviewError as exc: if exc.code == "session_expired": raise except (PolicyError, ApprovalEngineError, StoreError, sqlite3.Error, ValueError, KeyError, TypeError): pass return ('

Your decisions

Your decisions could not be ' 'listed right now. You can still open a review by its memo identifier.
') 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 host = os.environ.get("INFD_LISTEN_HOST", "127.0.0.1") if host not in ("127.0.0.1", "0.0.0.0"): raise ValueError("unsupported listener address") # Waitress does not log request targets; a proxy must also omit callback # query strings and cookies. No debug traceback middleware belongs here. 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, lifecycle=runtime.lifecycle if runtime else None) try: serve(app, host=host, port=8080, threads=4) finally: if runtime: runtime.pump.stop()