approval-engine/approval_engine/audit.py
tegwick bfb1e66646 Heartbeat per event class, not per source
audit-core's completeness contract landed, and our single per-source beat
is the shape it rules inadequate: it is discharged by whichever class is
busy, so a revocation stream that has gone silent looks identical to a
quiet one — and revocation is the only silence here that matters.

Emit one nothing-to-report assertion per declared class, all four in one
transaction so a partial emission cannot report some classes healthy and
others stalled. Carry type audit-core.heartbeat with class and assertion
on data. Pin that the first beat goes out at startup rather than an
interval later, since a declared-but-never-sent class is their
no_heartbeat_since_registration finding and not a skip.

Declare heartbeat_classes and the reconciliation surface in the source
registration, including the residual neither control covers: a
compromised emitter suppresses the event and its own heartbeat together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyybaE7DUXrWYrhbnESCTe

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1275879@bnt-lap001
Assistant-Session: eb464208-f821-41b2-bc5a-a6c33d92a8ad
2026-09-10 20:36:21 +02:00

108 lines
3.6 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 {})
# audit-core's heartbeat contract puts the asserted class and the assertion
# on `data` itself, not inside a producer-shaped details object.
heartbeat = (
{"class": details.get("class"), "assertion": details.get("assertion")}
if payload["action"] == "audit-core.heartbeat"
else {}
)
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,
**heartbeat,
},
}
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)