2026-07-04 00:39:03 +02:00
|
|
|
"""Pluggable audit backend contract.
|
|
|
|
|
|
|
|
|
|
See ``docs/audit-backend-contract.md`` for the full protocol, event schema,
|
|
|
|
|
retention policy, and migration path from the mock file backend.
|
|
|
|
|
"""
|
2026-06-01 23:44:03 +02:00
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
from datetime import datetime, timezone
|
2026-07-04 00:39:03 +02:00
|
|
|
from typing import Any, Literal, Protocol, runtime_checkable
|
2026-06-01 23:44:03 +02:00
|
|
|
from uuid import uuid4
|
|
|
|
|
|
2026-07-04 00:39:03 +02:00
|
|
|
SCHEMA_VERSION_V1ALPHA1 = "audit-core.event.v1alpha1"
|
|
|
|
|
|
2026-08-16 00:24:33 +02:00
|
|
|
CustodyClass = Literal["development", "operational", "archive", "hot_search"]
|
|
|
|
|
|
|
|
|
|
# Production Postgres reports ``operational``. Manifests written before
|
|
|
|
|
# AUDIT-WP-0006 required ``archive``. The two are aliases for one deploy so
|
|
|
|
|
# a mixed rollout cannot refuse to start. ``development`` is never an alias.
|
|
|
|
|
_PRODUCTION_CUSTODY_CLASSES = frozenset({"operational", "archive"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def custody_class_satisfies(actual: str, required: str) -> bool:
|
|
|
|
|
"""Whether a backend's class meets a startup requirement.
|
|
|
|
|
|
|
|
|
|
``operational`` and ``archive`` satisfy each other. A development
|
|
|
|
|
backend satisfies only ``development``.
|
|
|
|
|
"""
|
|
|
|
|
if actual == required:
|
|
|
|
|
return True
|
|
|
|
|
return {actual, required} <= _PRODUCTION_CUSTODY_CLASSES
|
2026-07-04 00:39:03 +02:00
|
|
|
|
|
|
|
|
_REQUIRED_STRING_FIELDS = (
|
|
|
|
|
"schema_version",
|
|
|
|
|
"event_id",
|
|
|
|
|
"observed_at",
|
|
|
|
|
"tenant",
|
|
|
|
|
"scope",
|
|
|
|
|
"source",
|
|
|
|
|
"action",
|
|
|
|
|
"resource",
|
|
|
|
|
"outcome",
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-01 23:44:03 +02:00
|
|
|
|
|
|
|
|
def utc_now() -> str:
|
|
|
|
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 00:39:03 +02:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class RetentionPolicy:
|
|
|
|
|
"""Declarative retention guarantees exposed by an audit backend."""
|
|
|
|
|
|
|
|
|
|
custody_class: CustodyClass
|
|
|
|
|
retention_days: int | None
|
|
|
|
|
immutable: bool
|
|
|
|
|
tamper_evidence: bool
|
|
|
|
|
durable: bool
|
2026-08-16 00:24:33 +02:00
|
|
|
# Recoverable history is the platform backup window, not a deletion
|
|
|
|
|
# policy. ``None`` means not declared (development backends).
|
|
|
|
|
recoverable_days: int | None = None
|
|
|
|
|
recoverable_source: str | None = None
|
|
|
|
|
recoverable_basis: str | None = None
|
|
|
|
|
|
|
|
|
|
def as_readiness(self) -> dict[str, Any]:
|
|
|
|
|
"""Sender-visible /readyz body. Keeps ``custody_class`` and adds recovery."""
|
|
|
|
|
payload: dict[str, Any] = {
|
|
|
|
|
"status": "ok",
|
|
|
|
|
"custody_class": self.custody_class,
|
|
|
|
|
"durable": self.durable,
|
2026-08-16 01:18:30 +02:00
|
|
|
"tamper_evidence": self.tamper_evidence,
|
2026-08-16 00:24:33 +02:00
|
|
|
}
|
|
|
|
|
if self.recoverable_days is not None or self.recoverable_source:
|
|
|
|
|
payload["recoverable_days"] = self.recoverable_days
|
|
|
|
|
payload["recoverable_source"] = self.recoverable_source
|
|
|
|
|
payload["recoverable_basis"] = self.recoverable_basis
|
|
|
|
|
return payload
|
2026-07-04 00:39:03 +02:00
|
|
|
|
|
|
|
|
|
2026-06-01 23:44:03 +02:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class AuditEvent:
|
2026-07-04 00:39:03 +02:00
|
|
|
"""Normalized audit event for the v1alpha1 module-level contract.
|
|
|
|
|
|
|
|
|
|
The flat record maps to JSON via :meth:`as_record`. The nested
|
|
|
|
|
``audit-core.event.v1`` envelope in the product requirements is the
|
|
|
|
|
long-term HTTP ingestion target; adapters will translate between them.
|
|
|
|
|
"""
|
2026-06-01 23:44:03 +02:00
|
|
|
|
|
|
|
|
source: str
|
|
|
|
|
action: str
|
|
|
|
|
resource: str
|
|
|
|
|
outcome: str
|
|
|
|
|
tenant: str = "platform"
|
|
|
|
|
scope: str = "platform-control-plane"
|
|
|
|
|
actor: str | None = None
|
|
|
|
|
reason: str | None = None
|
|
|
|
|
details: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
event_id: str = field(default_factory=lambda: str(uuid4()))
|
|
|
|
|
observed_at: str = field(default_factory=utc_now)
|
2026-07-04 00:39:03 +02:00
|
|
|
schema_version: str = SCHEMA_VERSION_V1ALPHA1
|
2026-06-01 23:44:03 +02:00
|
|
|
|
|
|
|
|
def as_record(self) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"schema_version": self.schema_version,
|
|
|
|
|
"event_id": self.event_id,
|
|
|
|
|
"observed_at": self.observed_at,
|
|
|
|
|
"tenant": self.tenant,
|
|
|
|
|
"scope": self.scope,
|
|
|
|
|
"source": self.source,
|
|
|
|
|
"actor": self.actor,
|
|
|
|
|
"action": self.action,
|
|
|
|
|
"resource": self.resource,
|
|
|
|
|
"outcome": self.outcome,
|
|
|
|
|
"reason": self.reason,
|
|
|
|
|
"details": self.details,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 00:39:03 +02:00
|
|
|
class EventValidationError(ValueError):
|
|
|
|
|
"""Raised when an event record fails contract validation."""
|
|
|
|
|
|
|
|
|
|
|
Route ingestion through the backend contract; fix error semantics
AUDIT-WP-0004 T01, T02, T07.
T01 - ingestion wrote to SQLite directly and never called the AuditBackend
contract, so a 202 meant a row existed rather than that a backend with a
declared retention policy had accepted the event. Adds IdempotentAuditBackend
to the contract: duplicate detection lives inside the backend so custody and
idempotency state share a transaction and cannot diverge. SQLiteAuditBackend
implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now
refuses any backend declaring durable=False, so the development file backend
cannot silently become the production sink.
The atomicity claim was tested rather than asserted, and the first attempt
failed: with a single shared connection, 16 racing submissions of one event
told two callers they were first. Storage was correct but the response was
not. Fixed with per-thread connections and BEGIN IMMEDIATE around the
insert/read pair, and locked in by a test.
T02 - storage errors previously escaped the handler with start_response never
called, and the auth check sat outside the try block so a non-ASCII
Authorization header crashed the request. Adds a catch-all, maps conflict to
409, backend unavailability to 503 and unexpected faults to 500, and
documents the full response contract with the retry semantics each status
implies, since senders key their behaviour off it.
T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather
than local time, and naive timestamps are rejected instead of silently
assumed.
Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy,
T05 operator read surface, T06 production serving layer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
|
|
|
class EventConflictError(Exception):
|
|
|
|
|
"""Raised when an event id is resubmitted with a different payload.
|
|
|
|
|
|
|
|
|
|
Distinct from :class:`EventValidationError`: the event is well-formed, but
|
|
|
|
|
it contradicts a record already in custody. Callers map this to a conflict
|
|
|
|
|
response rather than a rejection, because retrying it will never succeed.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BackendUnavailableError(Exception):
|
|
|
|
|
"""Raised when a backend cannot currently accept writes.
|
|
|
|
|
|
|
|
|
|
Signals a retryable condition — the event is not in custody and the caller
|
|
|
|
|
should try again. Never raised for events the backend has durably accepted.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class AcceptResult:
|
|
|
|
|
"""Outcome of an idempotent accept."""
|
|
|
|
|
|
|
|
|
|
duplicate: bool
|
|
|
|
|
reference: str
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 00:39:03 +02:00
|
|
|
def validate_event(event: AuditEvent) -> None:
|
|
|
|
|
"""Validate an event against the v1alpha1 contract.
|
|
|
|
|
|
|
|
|
|
Raises :class:`EventValidationError` when required fields are missing,
|
|
|
|
|
empty, or use an unsupported schema version.
|
|
|
|
|
"""
|
|
|
|
|
record = event.as_record()
|
|
|
|
|
errors: list[str] = []
|
|
|
|
|
|
|
|
|
|
if record["schema_version"] != SCHEMA_VERSION_V1ALPHA1:
|
|
|
|
|
errors.append(
|
|
|
|
|
f"unsupported schema_version: {record['schema_version']!r} "
|
|
|
|
|
f"(expected {SCHEMA_VERSION_V1ALPHA1!r})"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
for name in _REQUIRED_STRING_FIELDS:
|
|
|
|
|
value = record.get(name)
|
|
|
|
|
if not isinstance(value, str) or not value.strip():
|
|
|
|
|
errors.append(f"{name} must be a non-empty string")
|
|
|
|
|
|
|
|
|
|
if not isinstance(record.get("details"), dict):
|
|
|
|
|
errors.append("details must be a mapping")
|
|
|
|
|
|
|
|
|
|
if errors:
|
|
|
|
|
raise EventValidationError("; ".join(errors))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@runtime_checkable
|
2026-06-01 23:44:03 +02:00
|
|
|
class AuditBackend(Protocol):
|
2026-07-04 00:39:03 +02:00
|
|
|
"""Protocol implemented by replaceable audit sinks.
|
|
|
|
|
|
2026-08-16 00:24:33 +02:00
|
|
|
Production backends provide durable operational or archive custody.
|
2026-07-04 00:39:03 +02:00
|
|
|
Development backends (such as :class:`~audit_core.mock_file_backend.MockFileAuditBackend`)
|
|
|
|
|
are for wiring only and must not be treated as audit custody.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def retention_policy(self) -> RetentionPolicy:
|
|
|
|
|
"""Describe retention and custody guarantees for readiness checks."""
|
2026-06-01 23:44:03 +02:00
|
|
|
|
|
|
|
|
def emit(self, event: AuditEvent) -> str:
|
Route ingestion through the backend contract; fix error semantics
AUDIT-WP-0004 T01, T02, T07.
T01 - ingestion wrote to SQLite directly and never called the AuditBackend
contract, so a 202 meant a row existed rather than that a backend with a
declared retention policy had accepted the event. Adds IdempotentAuditBackend
to the contract: duplicate detection lives inside the backend so custody and
idempotency state share a transaction and cannot diverge. SQLiteAuditBackend
implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now
refuses any backend declaring durable=False, so the development file backend
cannot silently become the production sink.
The atomicity claim was tested rather than asserted, and the first attempt
failed: with a single shared connection, 16 racing submissions of one event
told two callers they were first. Storage was correct but the response was
not. Fixed with per-thread connections and BEGIN IMMEDIATE around the
insert/read pair, and locked in by a test.
T02 - storage errors previously escaped the handler with start_response never
called, and the auth check sat outside the try block so a non-ASCII
Authorization header crashed the request. Adds a catch-all, maps conflict to
409, backend unavailability to 503 and unexpected faults to 500, and
documents the full response contract with the retry semantics each status
implies, since senders key their behaviour off it.
T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather
than local time, and naive timestamps are rejected instead of silently
assumed.
Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy,
T05 operator read surface, T06 production serving layer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
|
|
|
"""Persist an event and return a backend-specific reference."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@runtime_checkable
|
|
|
|
|
class IdempotentAuditBackend(AuditBackend, Protocol):
|
|
|
|
|
"""An :class:`AuditBackend` that can absorb duplicate submissions.
|
|
|
|
|
|
|
|
|
|
Ingestion requires this rather than the plain backend protocol. The reason
|
|
|
|
|
is atomicity: if duplicate detection lived in the ingestion layer and
|
|
|
|
|
custody in the backend, the two could diverge — an event recorded as seen
|
|
|
|
|
but never durably stored, which is precisely the loss this service exists
|
|
|
|
|
to prevent. Keeping both inside one backend call lets an implementation put
|
|
|
|
|
them in a single transaction.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def accept(self, event: AuditEvent, payload_hash: str) -> AcceptResult:
|
|
|
|
|
"""Durably record an event, tolerating exact resubmission.
|
|
|
|
|
|
|
|
|
|
Returns an :class:`AcceptResult` whose ``duplicate`` flag distinguishes
|
|
|
|
|
a first acceptance from a replay of an identical event. Both mean the
|
|
|
|
|
event is in custody.
|
|
|
|
|
|
|
|
|
|
Raises :class:`EventConflictError` if ``event.event_id`` is already held
|
|
|
|
|
with a different ``payload_hash``, and :class:`BackendUnavailableError`
|
|
|
|
|
if the write could not be attempted. Returning normally must mean the
|
|
|
|
|
event is durable.
|
|
|
|
|
"""
|