approval-engine/approval_engine/pep.py
tegwick 2bd2d19a98 Implement approval engine production readiness
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a05e2e-805b-7042-a750-71f473bceea2
2026-09-02 00:52:04 +02:00

114 lines
4.3 KiB
Python

"""Fail-closed PEP client and sequencing harness for GH-DEC-2026-003."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen
class ApprovalProtocolError(RuntimeError):
pass
class ApprovalHTTPClient:
def __init__(
self,
base_url: str,
token_file: str | Path,
*,
timeout_seconds: float = 3,
opener: Callable[..., Any] = urlopen,
) -> None:
self.base_url = base_url.rstrip("/")
self.token_file = Path(token_file)
self.timeout_seconds = timeout_seconds
self.opener = opener
def _request(
self, method: str, path: str, body: dict[str, Any] | None = None
) -> dict[str, Any]:
token = self.token_file.read_text(encoding="utf-8").strip()
if not token or any(ch.isspace() for ch in token):
raise ApprovalProtocolError("approval credential is unavailable")
encoded = None if body is None else json.dumps(body).encode("utf-8")
request = Request(
self.base_url + path,
data=encoded,
method=method,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
try:
response = self.opener(request, timeout=self.timeout_seconds)
status = int(response.getcode())
raw = response.read(256 * 1024 + 1)
response.close()
except (HTTPError, URLError, OSError) as exc:
raise ApprovalProtocolError(type(exc).__name__) from exc
if status != 200 or len(raw) > 256 * 1024:
raise ApprovalProtocolError(f"approval endpoint returned status {status}")
try:
result = json.loads(raw)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ApprovalProtocolError("approval endpoint returned invalid JSON") from exc
if not isinstance(result, dict):
raise ApprovalProtocolError("approval endpoint returned invalid payload")
return result
def claim(self, approval_id: str) -> dict[str, Any]:
encoded_id = quote(approval_id, safe="")
return self._request("GET", f"/v1/approvals/{encoded_id}/claim")
def consume(
self, approval_id: str, request_digest: str, decision_id: str
) -> dict[str, Any]:
encoded_id = quote(approval_id, safe="")
return self._request(
"POST",
f"/v1/approvals/{encoded_id}/consume",
{"request_digest": request_digest, "decision_id": decision_id},
)
class ProtectedActionHarness:
"""Sequence a supplied PDP decision and dry-run/protected callback.
The decision callback owns authorization. This class only enforces that a
fresh approval claim precedes it and a successful CAS consume precedes the
side effect.
"""
def __init__(self, client: ApprovalHTTPClient) -> None:
self.client = client
def execute(
self,
approval_id: str,
request_digest: str,
decide: Callable[[dict[str, Any]], dict[str, Any]],
side_effect: Callable[[], Any],
) -> Any:
claim = self.client.claim(approval_id)
if claim.get("valid_now") is not True or claim.get("consumed") is not False:
raise ApprovalProtocolError("approval claim is not valid for use")
decision = decide(claim)
if decision.get("effect") != "ALLOW":
raise ApprovalProtocolError("authorization decision did not allow")
decision_id = decision.get("decision_id")
if not isinstance(decision_id, str) or not decision_id:
raise ApprovalProtocolError("authorization decision lacks decision_id")
if decision.get("request_digest") != request_digest:
raise ApprovalProtocolError("authorization decision digest does not match")
consumed = self.client.consume(approval_id, request_digest, decision_id)
if (
consumed.get("status") != "consumed"
or consumed.get("request_digest") != request_digest
):
raise ApprovalProtocolError("approval consumption was not confirmed")
return side_effect()