approval-engine/approval_engine/api.py
tegwick c3f1dfbc07 Finish approval engine spine
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a05e2e-805b-7042-a750-71f473bceea2
2026-09-01 23:45:48 +02:00

149 lines
5.8 KiB
Python

"""HTTP surface. Introspection and lifecycle mutation; never a decision."""
from __future__ import annotations
import json
from typing import Any, Callable
from .errors import ApprovalError
from .store import Engine
FORBIDDEN_DECISION_KEYS = frozenset({"effect", "decision", "allow", "deny"})
def _read_json(environ: dict[str, Any]) -> dict[str, Any]:
length = int(environ.get("CONTENT_LENGTH") or 0)
if length == 0:
return {}
raw = environ["wsgi.input"].read(length)
if not raw:
return {}
try:
data = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ApprovalError("invalid json") from exc
if not isinstance(data, dict):
raise ApprovalError("json object required")
return data
def _assert_not_decision(payload: Any) -> None:
if isinstance(payload, dict):
bad = FORBIDDEN_DECISION_KEYS & set(payload)
if bad:
raise RuntimeError(f"decision-shaped keys leaked: {sorted(bad)}")
for value in payload.values():
_assert_not_decision(value)
elif isinstance(payload, list):
for item in payload:
_assert_not_decision(item)
class App:
def __init__(self, engine: Engine) -> None:
self.engine = engine
def __call__(self, environ: dict[str, Any], start_response: Callable) -> list[bytes]:
method = environ.get("REQUEST_METHOD", "GET").upper()
path = environ.get("PATH_INFO") or "/"
try:
status, body = self.dispatch(method, path, environ)
except ApprovalError as exc:
status, body = exc.http_status, {"error": exc.reason_code, "message": str(exc)}
except ValueError as exc:
status, body = 422, {"error": "unprocessable", "message": str(exc)}
_assert_not_decision(body)
payload = json.dumps(body, sort_keys=True).encode("utf-8")
start_response(
f"{status} {'OK' if status < 400 else 'ERROR'}",
[
("Content-Type", "application/json"),
("Content-Length", str(len(payload))),
],
)
return [payload]
def dispatch(self, method: str, path: str, environ: dict[str, Any]) -> tuple[int, dict[str, Any]]:
if path in ("/readyz", "/v1/readyz") and method == "GET":
self.engine.outbox_stats()
return 200, {"status": "ok", "store": "ok"}
if path == "/v1/cadence" and method == "GET":
return 200, self.engine.transition_counts() | {"form": "heartbeat-or-reconciliation"}
if path == "/v1/outbox/stats" and method == "GET":
return 200, self.engine.outbox_stats()
if path == "/v1/heartbeat" and method == "POST":
return 200, self.engine.emit_heartbeat()
if path == "/v1/approvals" and method == "POST":
data = _read_json(environ)
obj = self.engine.create(
data.get("binding") or {},
data.get("validity") or {},
int(data.get("required_count") or 1),
pdp_digest=data.get("pdp_digest"),
approval_id=data.get("id"),
)
return 201, obj.as_dict()
parts = path.strip("/").split("/")
if len(parts) >= 3 and parts[0] == "v1" and parts[1] == "approvals":
approval_id = parts[2]
rest = parts[3:]
if not rest and method == "GET":
return 200, self.engine.get(approval_id).as_dict()
if rest == ["claim"] and method == "GET":
return 200, self.engine.claim(approval_id)
if rest == ["entries"] and method == "POST":
data = _read_json(environ)
obj = self.engine.add_entry(
approval_id,
data.get("subject_id") or "",
assurance=data.get("assurance"),
evidence_ref=data.get("evidence_ref"),
)
return 200, obj.as_dict()
if rest == ["revoke"] and method == "POST":
return 200, self.engine.revoke(approval_id).as_dict()
if rest == ["supersede"] and method == "POST":
data = _read_json(environ)
return 200, self.engine.supersede(approval_id, data.get("successor_id"))
if rest == ["consume"] and method == "POST":
data = _read_json(environ)
return 200, self.engine.consume(
approval_id,
data.get("request_digest"),
decision_id=data.get("decision_id"),
)
if "check" in path or path.endswith("/authorize"):
return 404, {"error": "not_found", "message": "no such surface"}
return 404, {"error": "not_found", "message": path}
def call(app: App, method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, dict[str, Any]]:
"""In-process WSGI helper for tests."""
raw = json.dumps(body or {}).encode("utf-8") if body is not None else b""
environ = {
"REQUEST_METHOD": method,
"PATH_INFO": path,
"wsgi.input": _Bytes(raw),
"CONTENT_LENGTH": str(len(raw)) if body is not None else "0",
"QUERY_STRING": "",
}
status_headers: list[tuple[str, list]] = []
def start_response(status: str, headers: list[tuple[str, str]]) -> None:
status_headers.append((status, headers))
result = b"".join(app(environ, start_response))
code = int(status_headers[0][0].split()[0])
return code, json.loads(result.decode("utf-8"))
class _Bytes:
def __init__(self, data: bytes) -> None:
self._data = data
def read(self, n: int = -1) -> bytes:
if n < 0:
out, self._data = self._data, b""
return out
out, self._data = self._data[:n], self._data[n:]
return out