115 lines
4.2 KiB
Python
115 lines
4.2 KiB
Python
|
|
"""The seam to `approval-engine`.
|
||
|
|
|
||
|
|
A Protocol plus a fake, because the engine is not deployed
|
||
|
|
(`APPROVAL-WP-0002-T01` is `progress`, no pods). When it deploys this becomes a
|
||
|
|
wiring change rather than a build.
|
||
|
|
|
||
|
|
Hard rules, from `approval-engine/docs/approver-surface-requirements.md`:
|
||
|
|
|
||
|
|
- `GET /v1/approvals/{id}` and `/claim` need ``approval:read``;
|
||
|
|
`POST …/entries` needs ``approval:approve``.
|
||
|
|
- **Never** ``/consume``. Human principals are refused there in code, and
|
||
|
|
consumption belongs to the PEP causing the side effect (`GH-DEC-2026-003`).
|
||
|
|
- No path containing ``check`` or ending ``/authorize`` exists to call.
|
||
|
|
- ``POST /entries`` **discards its request body** — identity, assurance and
|
||
|
|
``evidence_ref`` come only from the verified token. So ``view_hash`` does not
|
||
|
|
ride into the entry; correlation is ``(approval_id, subject, approved_at)``.
|
||
|
|
- **No inbox.** Get-by-id only. Never poll for work.
|
||
|
|
- `approved` is a state of an object, never permission to act.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from typing import Protocol
|
||
|
|
|
||
|
|
FORBIDDEN_SCOPE = "approval:consume"
|
||
|
|
REQUIRED_SCOPES = ("openid", "approval:read", "approval:approve")
|
||
|
|
|
||
|
|
|
||
|
|
class ApprovalEngineError(Exception):
|
||
|
|
def __init__(self, status: int, reason: str) -> None:
|
||
|
|
super().__init__(f"{status} {reason}")
|
||
|
|
self.status = status
|
||
|
|
self.reason = reason
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class EntryResult:
|
||
|
|
"""What comes back after a successful entry.
|
||
|
|
|
||
|
|
These three fields are the correlation triple that ties the entry back to
|
||
|
|
our presentation record — `GOAL.md` DoD-3.
|
||
|
|
"""
|
||
|
|
|
||
|
|
approval_id: str
|
||
|
|
subject: str
|
||
|
|
approved_at: str
|
||
|
|
status: str
|
||
|
|
duplicate: bool = False
|
||
|
|
|
||
|
|
@property
|
||
|
|
def correlation(self) -> tuple[str, str, str]:
|
||
|
|
return (self.approval_id, self.subject, self.approved_at)
|
||
|
|
|
||
|
|
|
||
|
|
class ApprovalEngine(Protocol):
|
||
|
|
def get_approval(self, approval_id: str) -> dict: ...
|
||
|
|
def add_entry(self, approval_id: str) -> EntryResult: ...
|
||
|
|
|
||
|
|
|
||
|
|
def assert_scopes_permissible(scopes: tuple[str, ...]) -> None:
|
||
|
|
"""Refuse ``approval:consume`` before a token is ever requested."""
|
||
|
|
if FORBIDDEN_SCOPE in scopes:
|
||
|
|
raise ValueError(
|
||
|
|
f"{FORBIDDEN_SCOPE} must never be requested: approval-engine refuses it "
|
||
|
|
"for human principals and consumption belongs to the PEP causing the "
|
||
|
|
"side effect (GH-DEC-2026-003)"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def is_success(err: ApprovalEngineError) -> bool:
|
||
|
|
"""`409 duplicate_approver` is SUCCESS, not failure.
|
||
|
|
|
||
|
|
A browser double-submit is routine and the first entry stands. Rendering it
|
||
|
|
as an error is a lie to the approver.
|
||
|
|
"""
|
||
|
|
return err.status == 409 and err.reason == "duplicate_approver"
|
||
|
|
|
||
|
|
|
||
|
|
class FakeApprovalEngine:
|
||
|
|
"""In-process stand-in with the engine's actual refusal semantics."""
|
||
|
|
|
||
|
|
def __init__(self, approvals: dict[str, dict] | None = None) -> None:
|
||
|
|
self._approvals = approvals or {}
|
||
|
|
self._entries: dict[str, set[str]] = {}
|
||
|
|
self.available = True
|
||
|
|
|
||
|
|
def get_approval(self, approval_id: str) -> dict:
|
||
|
|
if not self.available:
|
||
|
|
raise ApprovalEngineError(503, "store_unavailable")
|
||
|
|
if approval_id not in self._approvals:
|
||
|
|
raise ApprovalEngineError(404, "not_found")
|
||
|
|
return dict(self._approvals[approval_id])
|
||
|
|
|
||
|
|
def add_entry(self, approval_id: str, subject: str = "approver") -> EntryResult:
|
||
|
|
if not self.available:
|
||
|
|
raise ApprovalEngineError(503, "store_unavailable")
|
||
|
|
approval = self._approvals.get(approval_id)
|
||
|
|
if approval is None:
|
||
|
|
raise ApprovalEngineError(404, "not_found")
|
||
|
|
if approval.get("status") in ("revoked", "superseded", "consumed", "expired"):
|
||
|
|
raise ApprovalEngineError(409, "conflict")
|
||
|
|
seen = self._entries.setdefault(approval_id, set())
|
||
|
|
duplicate = subject in seen
|
||
|
|
seen.add(subject)
|
||
|
|
if not duplicate and len(seen) >= approval.get("required_count", 1):
|
||
|
|
approval["status"] = "approved"
|
||
|
|
return EntryResult(
|
||
|
|
approval_id=approval_id,
|
||
|
|
subject=subject,
|
||
|
|
approved_at="2026-09-10T15:00:00Z",
|
||
|
|
status=approval.get("status", "requested"),
|
||
|
|
duplicate=duplicate,
|
||
|
|
)
|