"""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 .coordination import FRAME_SECONDS 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 = FRAME_SECONDS, ) -> 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)