178 lines
6.2 KiB
Python
178 lines
6.2 KiB
Python
|
|
"""Durable SQLite audit backend.
|
||
|
|
|
||
|
|
Implements the idempotent backend contract for single-node deployments and for
|
||
|
|
development. Production custody moves to PostgreSQL under AUDIT-WP-0005; this
|
||
|
|
backend stays the development and test implementation and defines the
|
||
|
|
behaviour the Postgres backend must match.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import sqlite3
|
||
|
|
import threading
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
|
||
|
|
from audit_core.interface import (
|
||
|
|
AcceptResult,
|
||
|
|
AuditEvent,
|
||
|
|
BackendUnavailableError,
|
||
|
|
EventConflictError,
|
||
|
|
EventValidationError,
|
||
|
|
RetentionPolicy,
|
||
|
|
validate_event,
|
||
|
|
)
|
||
|
|
|
||
|
|
_SCHEMA = """
|
||
|
|
CREATE TABLE IF NOT EXISTS events (
|
||
|
|
event_id TEXT PRIMARY KEY,
|
||
|
|
payload_hash TEXT NOT NULL,
|
||
|
|
accepted_at TEXT NOT NULL,
|
||
|
|
correlation_id TEXT,
|
||
|
|
tenant TEXT NOT NULL,
|
||
|
|
record TEXT NOT NULL
|
||
|
|
);
|
||
|
|
CREATE INDEX IF NOT EXISTS events_correlation_idx ON events (correlation_id);
|
||
|
|
CREATE INDEX IF NOT EXISTS events_tenant_idx ON events (tenant);
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
class SQLiteAuditBackend:
|
||
|
|
"""Store audit events in SQLite with idempotent accept semantics.
|
||
|
|
|
||
|
|
Configured for durability rather than speed: WAL journalling, ``synchronous
|
||
|
|
= FULL`` so an acknowledged write has reached disk, and a busy timeout so
|
||
|
|
concurrent writers wait instead of raising immediately.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, path: str, retention_days: int | None = None, busy_timeout_ms: int = 5000) -> None:
|
||
|
|
self.path = path
|
||
|
|
self.retention_days = retention_days
|
||
|
|
self.busy_timeout_ms = int(busy_timeout_ms)
|
||
|
|
# One connection per thread. A shared connection lets concurrent
|
||
|
|
# statements interleave, which was observed to let two callers both
|
||
|
|
# believe they were the first to accept the same event.
|
||
|
|
self._local = threading.local()
|
||
|
|
with self._connect_raw() as setup:
|
||
|
|
setup.executescript(_SCHEMA)
|
||
|
|
|
||
|
|
def _connect_raw(self) -> sqlite3.Connection:
|
||
|
|
try:
|
||
|
|
db = sqlite3.connect(self.path, isolation_level=None)
|
||
|
|
db.execute("PRAGMA journal_mode = WAL")
|
||
|
|
db.execute("PRAGMA synchronous = FULL")
|
||
|
|
db.execute(f"PRAGMA busy_timeout = {self.busy_timeout_ms}")
|
||
|
|
return db
|
||
|
|
except sqlite3.Error as exc:
|
||
|
|
raise BackendUnavailableError(f"cannot open audit store: {exc}") from exc
|
||
|
|
|
||
|
|
@property
|
||
|
|
def db(self) -> sqlite3.Connection:
|
||
|
|
conn = getattr(self._local, "conn", None)
|
||
|
|
if conn is None:
|
||
|
|
conn = self._local.conn = self._connect_raw()
|
||
|
|
return conn
|
||
|
|
|
||
|
|
@property
|
||
|
|
def retention_policy(self) -> RetentionPolicy:
|
||
|
|
return RetentionPolicy(
|
||
|
|
custody_class="development",
|
||
|
|
retention_days=self.retention_days,
|
||
|
|
immutable=False,
|
||
|
|
tamper_evidence=False,
|
||
|
|
durable=True,
|
||
|
|
)
|
||
|
|
|
||
|
|
def emit(self, event: AuditEvent) -> str:
|
||
|
|
"""Persist an event, generating no idempotency guarantee."""
|
||
|
|
return self.accept(event, payload_hash=_record_hash(event)).reference
|
||
|
|
|
||
|
|
def accept(self, event: AuditEvent, payload_hash: str) -> AcceptResult:
|
||
|
|
try:
|
||
|
|
validate_event(event)
|
||
|
|
except EventValidationError:
|
||
|
|
raise
|
||
|
|
reference = f"audit:{event.event_id}"
|
||
|
|
details = event.details if isinstance(event.details, dict) else {}
|
||
|
|
db = self.db
|
||
|
|
try:
|
||
|
|
# BEGIN IMMEDIATE takes the write lock up front, so the insert and
|
||
|
|
# the follow-up read are one atomic pair. Without it, two callers
|
||
|
|
# racing on the same event id can both be told they were first.
|
||
|
|
db.execute("BEGIN IMMEDIATE")
|
||
|
|
except sqlite3.Error as exc:
|
||
|
|
raise BackendUnavailableError(str(exc)) from exc
|
||
|
|
|
||
|
|
try:
|
||
|
|
inserted = db.execute(
|
||
|
|
"""
|
||
|
|
INSERT INTO events
|
||
|
|
(event_id, payload_hash, accepted_at, correlation_id, tenant, record)
|
||
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
||
|
|
ON CONFLICT(event_id) DO NOTHING
|
||
|
|
RETURNING event_id
|
||
|
|
""",
|
||
|
|
(
|
||
|
|
event.event_id,
|
||
|
|
payload_hash,
|
||
|
|
datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
||
|
|
str(details.get("correlation_id") or "") or None,
|
||
|
|
event.tenant,
|
||
|
|
json.dumps(event.as_record(), sort_keys=True),
|
||
|
|
),
|
||
|
|
).fetchone()
|
||
|
|
existing = None
|
||
|
|
if inserted is None:
|
||
|
|
existing = db.execute(
|
||
|
|
"SELECT payload_hash FROM events WHERE event_id = ?", (event.event_id,)
|
||
|
|
).fetchone()
|
||
|
|
db.execute("COMMIT")
|
||
|
|
except sqlite3.Error as exc:
|
||
|
|
_rollback(db)
|
||
|
|
raise BackendUnavailableError(str(exc)) from exc
|
||
|
|
except BaseException:
|
||
|
|
_rollback(db)
|
||
|
|
raise
|
||
|
|
|
||
|
|
if inserted is not None:
|
||
|
|
return AcceptResult(duplicate=False, reference=reference)
|
||
|
|
if existing is None:
|
||
|
|
# The row vanished between the insert and the read inside one
|
||
|
|
# transaction, which should be impossible. Retryable rather than
|
||
|
|
# guessed at.
|
||
|
|
raise BackendUnavailableError("event disappeared during accept")
|
||
|
|
if existing[0] != payload_hash:
|
||
|
|
raise EventConflictError(
|
||
|
|
f"event_id {event.event_id} already held with a different payload"
|
||
|
|
)
|
||
|
|
return AcceptResult(duplicate=True, reference=reference)
|
||
|
|
|
||
|
|
def health(self) -> None:
|
||
|
|
"""Raise :class:`BackendUnavailableError` if the store is unusable."""
|
||
|
|
try:
|
||
|
|
self.db.execute("SELECT 1 FROM events LIMIT 1").fetchone()
|
||
|
|
except sqlite3.Error as exc:
|
||
|
|
raise BackendUnavailableError(str(exc)) from exc
|
||
|
|
|
||
|
|
def close(self) -> None:
|
||
|
|
"""Close this thread's connection, if it has one."""
|
||
|
|
conn = getattr(self._local, "conn", None)
|
||
|
|
if conn is not None:
|
||
|
|
conn.close()
|
||
|
|
self._local.conn = None
|
||
|
|
|
||
|
|
|
||
|
|
def _rollback(db: sqlite3.Connection) -> None:
|
||
|
|
try:
|
||
|
|
db.execute("ROLLBACK")
|
||
|
|
except sqlite3.Error:
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
def _record_hash(event: AuditEvent) -> str:
|
||
|
|
import hashlib
|
||
|
|
|
||
|
|
return hashlib.sha256(
|
||
|
|
json.dumps(event.as_record(), sort_keys=True).encode("utf-8")
|
||
|
|
).hexdigest()
|