approval-engine/approval_engine/audit.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

100 lines
3.3 KiB
Python

"""Asynchronous delivery of the transactional outbox to audit-core."""
from __future__ import annotations
import json
import threading
from pathlib import Path
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from .store import Engine
class AuditDeliveryError(RuntimeError):
"""Bounded delivery failure; details deliberately exclude response bodies."""
def audit_envelope(payload: dict[str, Any]) -> dict[str, Any]:
details = dict(payload.get("details") or {})
return {
"id": payload["event_id"],
"type": payload["action"],
"source": payload["source"],
"subject": payload["resource"],
"tenant": payload["tenant"],
"correlation_id": details.get("approval_id") or payload["event_id"],
"occurred_at": payload["observed_at"],
"data": {
"schema_version": payload["schema_version"],
"scope": payload["scope"],
"actor": payload.get("actor"),
"resource": payload["resource"],
"outcome": payload["outcome"],
"reason": payload.get("reason"),
"details": details,
},
}
class AuditCoreSink:
def __init__(
self,
base_url: str,
token_file: str | Path,
*,
timeout_seconds: float = 5,
opener: Callable[..., Any] = urlopen,
) -> None:
self.url = base_url.rstrip("/") + "/v1/events"
self.token_file = Path(token_file)
self.timeout_seconds = timeout_seconds
self.opener = opener
def __call__(self, payload: dict[str, Any]) -> None:
token = self.token_file.read_text(encoding="utf-8").strip()
if not token or any(ch.isspace() for ch in token):
raise AuditDeliveryError("audit credential is unavailable")
body = json.dumps(audit_envelope(payload), sort_keys=True).encode("utf-8")
request = Request(
self.url,
data=body,
method="POST",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Idempotency-Key": payload["event_id"],
},
)
try:
response = self.opener(request, timeout=self.timeout_seconds)
status = int(response.getcode())
response.close()
except (HTTPError, URLError, OSError) as exc:
raise AuditDeliveryError(type(exc).__name__) from exc
if status not in {200, 202}:
raise AuditDeliveryError(f"audit ingest returned status {status}")
class OutboxWorker:
def __init__(
self,
engine: Engine,
sink: Callable[[dict[str, Any]], None],
*,
heartbeat_interval_seconds: int = 300,
) -> None:
self.engine = engine
self.sink = sink
self.heartbeat_interval_seconds = heartbeat_interval_seconds
def run_once(self) -> dict[str, int]:
if self.engine.heartbeat_due(self.heartbeat_interval_seconds):
self.engine.emit_heartbeat()
return self.engine.drain(self.sink)
def run_forever(self, stop: threading.Event, poll_seconds: float = 5) -> None:
while not stop.is_set():
self.run_once()
stop.wait(poll_seconds)