audit-core/audit_core/interface.py

216 lines
7.3 KiB
Python
Raw Normal View History

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"
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
# 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,
"tamper_evidence": self.tamper_evidence,
}
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.
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.
"""