informed-decision/informed_decision/approval_http.py
tegwick 83849b75d4 Connect policy-gated browser review and audit runtime
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-11 00:31:03 +02:00

86 lines
4.5 KiB
Python

"""Authenticated get-by-id and human entry transport; no policy or consume API.
The caller owes entitlement, durable presentation/disposition and audit custody
before invoking add_entry. This adapter is deliberately not a browser route.
"""
from __future__ import annotations
from datetime import datetime
import re
import time
from .approval_client import ApprovalEngineError, EntryResult, is_success
from .http_transport import JSONTransport, TransportError, fixed_origin
from .oidc import HumanSession
from .provenance import assert_human_control_dischargeable
class ApprovalHTTPClient:
def __init__(self, origin: str, session: HumanSession, *, transport=None, clock=time.time,
allow_internal_http=False):
self.origin = fixed_origin(origin, allow_internal_http=allow_internal_http)
assert_human_control_dischargeable(session.principal_type)
if session.tenant.value != "tenant:platform":
raise ValueError("platform approver required")
self.session = session
self.transport = transport or JSONTransport(allow_internal_http=allow_internal_http)
self.clock = clock
def _call(self, method: str, approval_id: str, suffix="") -> dict:
if not isinstance(approval_id, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}", approval_id):
raise ValueError("invalid approval id")
if self.session.expires_at <= self.clock():
raise ApprovalEngineError(401, "session_expired")
try:
status, data = self.transport.request(method, self.origin + "/v1/approvals/" + approval_id + suffix,
headers={"Authorization": "Bearer " + self.session.access_token,
"Content-Type": "application/json"}, body=b"{}" if method == "POST" else None)
except TransportError:
# A failed POST transport may already have committed. Never retry it
# automatically or manufacture a disposition from this uncertainty.
raise ApprovalEngineError(502, "upstream_unavailable") from None
if status != 200:
reason = data.get("error")
known = {"unauthenticated", "forbidden", "not_found", "conflict",
"duplicate_approver", "unprocessable", "store_unavailable"}
raise ApprovalEngineError(status, reason if isinstance(reason, str) and reason in known else "upstream_refused")
binding = data.get("binding")
if (data.get("id") != approval_id or not isinstance(binding, dict)
or binding.get("human_control") is not True
or not isinstance(binding.get("digest"), str)
or not re.fullmatch(r"sha256:[0-9a-f]{64}", binding["digest"])
or data.get("status") not in {"requested", "approved", "consumed", "expired", "revoked", "superseded"}):
raise ApprovalEngineError(502, "invalid_approval_response")
return data
def get_approval(self, approval_id: str) -> dict:
return self._call("GET", approval_id)
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")
except ApprovalEngineError as exc:
if not is_success(exc):
raise
duplicate = True
data = self.get_approval(approval_id)
entries = data.get("entries")
if not isinstance(entries, list) or any(not isinstance(e, dict) for e in entries):
raise ApprovalEngineError(502, "entry_correlation_missing")
matches = [e for e in entries if e.get("subject_id") == self.session.subject]
try:
if len(matches) != 1 or matches[0].get("principal_type") != "human":
raise ValueError()
at = matches[0]["approved_at"]
if not isinstance(at, str) or datetime.fromisoformat(at.replace("Z", "+00:00")).tzinfo is None:
raise ValueError()
except (KeyError, TypeError, ValueError):
raise ApprovalEngineError(502, "entry_correlation_missing") from None
return EntryResult(approval_id, self.session.subject, at, data["status"], duplicate)