tenant-engine/src/tenant_engine/audit_core.py

107 lines
3.7 KiB
Python
Raw Normal View History

"""Attributive emission to audit-core from the local outbox.
Trade (statute §9.6): mutation evidence is attributive. Emission is
atomic with the local outbox (crash between mutation and insert is
prevented). Drain to audit-core is after commit and MUST NOT fail a
mutation. Completeness is not claimed. See docs/evidence-emission.md.
This module has no SQL against audit-core's store and no credential
for it beyond a sender token used to POST /v1/events. The external
copy cannot be rewritten through tenant-engine's runtime database
credential because we do not hold that credential.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from uuid import uuid4
import httpx
SCHEMA_VERSION = "audit-core.event.v1alpha1"
SOURCE = "tenant-engine"
def new_event_id() -> str:
return str(uuid4())
def envelope_for(
*,
event_id: str,
event_type: str,
tenant_id: str,
observed_at: str,
payload: dict[str, Any],
) -> dict[str, Any]:
return {
"schema_version": SCHEMA_VERSION,
"event_id": event_id,
"observed_at": observed_at,
"tenant": tenant_id,
"scope": "tenant-engine",
"source": SOURCE,
"actor": payload.get("actor") or payload.get("granted_by") or payload.get("changed_by"),
"action": event_type,
"resource": f"tenant:{tenant_id}",
"outcome": "recorded",
"reason": payload.get("reason") or payload.get("authorization_reason"),
"details": payload,
}
@dataclass(frozen=True, slots=True)
class DeliveryResult:
event_id: str
status: str # delivered | duplicate | retry | dead | skipped
http_status: int | None = None
detail: str = ""
class AuditCoreClient:
"""POST /v1/events. The only audit-core surface this engine holds."""
def __init__(
self,
*,
base_url: str,
timeout_seconds: float = 3.0,
token_file: str | None = None,
transport: httpx.BaseTransport | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.token_file = token_file
self._client = httpx.Client(
base_url=self.base_url,
timeout=httpx.Timeout(timeout_seconds),
transport=transport,
)
def post_event(self, envelope: dict[str, Any]) -> DeliveryResult:
event_id = str(envelope.get("event_id") or "")
headers: dict[str, str] = {"Content-Type": "application/json"}
if self.token_file:
try:
token = open(self.token_file, encoding="utf-8").read().strip()
except OSError as exc:
return DeliveryResult(event_id, "retry", None, f"token_unreadable:{exc}")
if not token:
return DeliveryResult(event_id, "retry", None, "token_empty")
headers["Authorization"] = f"Bearer {token}"
try:
response = self._client.post("/v1/events", json=envelope, headers=headers)
except (httpx.HTTPError, OSError) as exc:
return DeliveryResult(event_id, "retry", None, f"unreachable:{exc.__class__.__name__}")
if response.status_code in (200, 202):
status = "duplicate" if response.status_code == 200 else "delivered"
return DeliveryResult(event_id, status, response.status_code)
if response.status_code in (400, 409):
return DeliveryResult(event_id, "dead", response.status_code, "rejected")
if response.status_code in (401, 403):
return DeliveryResult(event_id, "retry", response.status_code, "unauthorized")
return DeliveryResult(event_id, "retry", response.status_code, "unavailable")
def close(self) -> None:
self._client.close()