"""Canonical, append-only audit writes, checkpoints and verification. The chain head is a single serialization point. PostgreSQL writers use a transaction-scoped advisory lock; SQLite test databases use an engine-scoped process lock held until the owning transaction ends. An event and the durable singleton checkpoint advance in the same transaction. """ from __future__ import annotations import hashlib import json import threading import uuid from collections.abc import Iterable from dataclasses import dataclass from datetime import UTC, datetime from typing import Any, Protocol from weakref import WeakKeyDictionary from sqlalchemy import event, select, text from sqlalchemy.engine import Connection, Engine from sqlalchemy.orm import Session from modelforge_api.domain.audit import ( AUDIT_CHAIN_SINGLETON_ID, AUDIT_CURRENT_HASH_FORMAT, AUDIT_EMPTY_LEGACY_PREFIX_SEAL, AUDIT_HASH_FORMAT_V1, AUDIT_HASH_FORMAT_V2, AUDIT_LEGACY_PREFIX_DOMAIN, canonical_audit_payload_and_hash, canonical_audit_payload_text_and_hash, normalise_audit_event_id, normalise_audit_timestamp, ) from modelforge_api.persistence.models import ( AuditChainHead, AuditEvent, _append_canonical_audit_event, ) AUDIT_CHAIN_POSTGRES_LOCK_KEY = int.from_bytes(b"MF_AUDIT", byteorder="big", signed=False) _SQLITE_LOCK_INFO_KEY = "modelforge_audit_chain_lock" _SQLITE_LOCKS: WeakKeyDictionary[Engine, threading.Lock] = WeakKeyDictionary() _SQLITE_LOCKS_GUARD = threading.Lock() class AuditChainIntegrityError(RuntimeError): """The writer cannot safely append to the authoritative audit chain.""" class AuditEventLike(Protocol): id: Any sequence: int occurred_at: Any correlation_id: str actor_type: str actor_id: str action: str resource_type: str resource_id: str | None outcome: str details: dict[str, Any] previous_event_hash: str | None event_hash: str hash_format: str canonical_payload: str | None @dataclass(frozen=True, slots=True) class AuditContext: """Authenticated/request identity seam for audit-producing services. Existing callers can keep passing their actor fields directly. The API authentication boundary can instead construct this context with its request correlation id without changing chain code. """ actor_type: str actor_id: str correlation_id: str | None = None @dataclass(frozen=True, slots=True) class AuditChainCheckpoint: """Transport-neutral checkpoint used by local and restored-database verification.""" singleton_id: int event_count: int last_sequence: int last_event_hash: str | None hash_format: str v2_start_sequence: int legacy_prefix_count: int legacy_prefix_seal: str @dataclass(frozen=True, slots=True) class AuditEventRecord: """Transport-neutral event used when recovery reads a database through psql.""" id: Any sequence: int occurred_at: Any correlation_id: str actor_type: str actor_id: str action: str resource_type: str resource_id: str | None outcome: str details: dict[str, Any] previous_event_hash: str | None event_hash: str hash_format: str canonical_payload: str | None = None def _decode_canonical_audit_payload(value: str) -> dict[str, Any]: def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: result: dict[str, Any] = {} for key, item in pairs: if key in result: raise ValueError(f"duplicate canonical audit payload key {key!r}") result[key] = item return result decoded = json.loads(value, object_pairs_hook=reject_duplicate_keys) if not isinstance(decoded, dict): raise ValueError("canonical audit payload is not an object") return decoded def _event_payload_violations(audit_event: AuditEventLike) -> list[str]: """Verify one event's semantic payload and exact stored hash bytes.""" identity = str(audit_event.id) if audit_event.hash_format == AUDIT_HASH_FORMAT_V1: violations: list[str] = [] if audit_event.canonical_payload is not None: violations.append(f"legacy audit event {identity} unexpectedly stores a v2 payload") try: _, expected_hash = canonical_audit_payload_and_hash( correlation_id=audit_event.correlation_id, actor_type=audit_event.actor_type, actor_id=audit_event.actor_id, action=audit_event.action, resource_type=audit_event.resource_type, resource_id=audit_event.resource_id, outcome=audit_event.outcome, details=audit_event.details, previous_event_hash=audit_event.previous_event_hash, hash_format=audit_event.hash_format, event_id=audit_event.id, occurred_at=audit_event.occurred_at, ) except (TypeError, ValueError): violations.append(f"audit event {identity} has a non-canonical payload") else: if audit_event.event_hash != expected_hash: violations.append( f"audit event {identity} content hash does not match its payload" ) return violations if audit_event.hash_format != AUDIT_HASH_FORMAT_V2: return [f"audit event {identity} has an unsupported hash format"] canonical_payload = audit_event.canonical_payload if not isinstance(canonical_payload, str): return [f"audit event {identity} has no exact v2 canonical payload"] violations = [] if hashlib.sha256(canonical_payload.encode("utf-8")).hexdigest() != audit_event.event_hash: violations.append(f"audit event {identity} content hash does not match stored bytes") try: decoded_payload = _decode_canonical_audit_payload(canonical_payload) _, expected_text, _expected_hash = canonical_audit_payload_text_and_hash( correlation_id=audit_event.correlation_id, actor_type=audit_event.actor_type, actor_id=audit_event.actor_id, action=audit_event.action, resource_type=audit_event.resource_type, resource_id=audit_event.resource_id, outcome=audit_event.outcome, details=audit_event.details, previous_event_hash=audit_event.previous_event_hash, hash_format=audit_event.hash_format, event_id=audit_event.id, occurred_at=audit_event.occurred_at, ) expected_payload = _decode_canonical_audit_payload(expected_text) except (json.JSONDecodeError, TypeError, ValueError): violations.append(f"audit event {identity} has a non-canonical payload") else: canonical_decoded = json.dumps( decoded_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False ) canonical_expected = json.dumps( expected_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False ) if canonical_decoded != canonical_expected: violations.append( f"audit event {identity} content hash bytes do not describe its event columns" ) return violations def _legacy_prefix_entry(event_row: AuditEventLike) -> bytes: payload = { "sequence": int(event_row.sequence), "id": normalise_audit_event_id(event_row.id), "occurred_at": normalise_audit_timestamp(event_row.occurred_at), "event_hash": str(event_row.event_hash), } return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n" def legacy_audit_prefix_seal(events: Iterable[AuditEventLike]) -> str: """Seal immutable v1 identities/timestamps without rewriting approved legacy events.""" digest = hashlib.sha256() digest.update(AUDIT_LEGACY_PREFIX_DOMAIN) for audit_event in events: digest.update(_legacy_prefix_entry(audit_event)) return digest.hexdigest() EMPTY_LEGACY_PREFIX_SEAL = AUDIT_EMPTY_LEGACY_PREFIX_SEAL def audit_chain_violations( events: Iterable[AuditEventLike], checkpoint: AuditChainHead | AuditChainCheckpoint | None ) -> list[str]: """Verify sequence, links, versioned hashes, the prefix seal and durable checkpoint.""" violations: list[str] = [] if checkpoint is None: violations.append("audit chain checkpoint is missing") cutover = 1 prefix_count = 0 else: cutover = int(checkpoint.v2_start_sequence) prefix_count = int(checkpoint.legacy_prefix_count) if checkpoint.singleton_id != AUDIT_CHAIN_SINGLETON_ID: violations.append("audit chain checkpoint has an invalid singleton id") if checkpoint.hash_format != AUDIT_CURRENT_HASH_FORMAT: violations.append("audit chain checkpoint names an unsupported current hash format") if cutover < 1 or prefix_count != cutover - 1: violations.append("audit chain checkpoint has an invalid v2 cutover") previous: AuditEventLike | None = None seen_sequences: set[int] = set() seen_event_ids: set[str] = set() observed = 0 prefix_observed = 0 prefix_digest = hashlib.sha256() prefix_digest.update(AUDIT_LEGACY_PREFIX_DOMAIN) for expected_sequence, audit_event in enumerate(events, start=1): observed += 1 identity = str(audit_event.id) try: normalised_identity = normalise_audit_event_id(audit_event.id) except ValueError: violations.append(f"audit event {identity} has a non-canonical UUID") else: if normalised_identity in seen_event_ids: violations.append(f"audit event UUID {normalised_identity} is duplicated") seen_event_ids.add(normalised_identity) sequence = int(audit_event.sequence) if sequence in seen_sequences: violations.append(f"audit sequence {sequence} is duplicated at event {identity}") seen_sequences.add(sequence) if sequence != expected_sequence: violations.append( f"audit event {identity} has sequence {sequence}; expected {expected_sequence}" ) expected_link = previous.event_hash if previous is not None else None if audit_event.previous_event_hash != expected_link: position = "first event" if previous is None else f"event after {previous.id}" violations.append( f"audit event {identity} has an invalid previous hash for the {position}" ) expected_format = AUDIT_HASH_FORMAT_V1 if sequence < cutover else AUDIT_HASH_FORMAT_V2 if audit_event.hash_format != expected_format: violations.append( f"audit event {identity} uses {audit_event.hash_format!r}; " f"expected {expected_format!r} at sequence {sequence}" ) violations.extend(_event_payload_violations(audit_event)) if sequence <= prefix_count: prefix_observed += 1 try: prefix_digest.update(_legacy_prefix_entry(audit_event)) except (TypeError, ValueError): violations.append( f"legacy audit event {identity} has a non-canonical identity or timestamp" ) previous = audit_event if checkpoint is not None: observed_last_sequence = int(previous.sequence) if previous is not None else 0 observed_last_hash = previous.event_hash if previous is not None else None if int(checkpoint.event_count) != observed: violations.append( f"audit checkpoint records {checkpoint.event_count} events; observed {observed}" ) if int(checkpoint.last_sequence) != observed_last_sequence: violations.append( "audit checkpoint last sequence does not match the retained event suffix" ) if checkpoint.last_event_hash != observed_last_hash: violations.append("audit checkpoint last hash does not match the retained event suffix") if prefix_observed != prefix_count: violations.append( f"audit checkpoint seals {prefix_count} legacy events; observed {prefix_observed}" ) if checkpoint.legacy_prefix_seal != prefix_digest.hexdigest(): violations.append("audit legacy-prefix seal does not match immutable legacy history") return violations def _audit_engine(session: Session) -> Engine: bind = session.get_bind() if isinstance(bind, Connection): return bind.engine return bind def _acquire_audit_write_lock(session: Session) -> None: """Hold the chain-head lock until the current root transaction completes.""" engine = _audit_engine(session) dialect = engine.dialect.name if dialect == "postgresql": session.execute( text("SELECT pg_advisory_xact_lock(:lock_key)"), {"lock_key": AUDIT_CHAIN_POSTGRES_LOCK_KEY}, ) return if dialect != "sqlite": raise RuntimeError(f"audit-chain writes do not support the {dialect!r} database dialect") if _SQLITE_LOCK_INFO_KEY in session.info: return if not session.in_transaction(): session.begin() with _SQLITE_LOCKS_GUARD: lock = _SQLITE_LOCKS.setdefault(engine, threading.Lock()) lock.acquire() session.info[_SQLITE_LOCK_INFO_KEY] = lock @event.listens_for(Session, "after_transaction_end") def _release_sqlite_audit_write_lock(session: Session, transaction: Any) -> None: if transaction.parent is not None: return lock = session.info.pop(_SQLITE_LOCK_INFO_KEY, None) if lock is not None: lock.release() def _checkpoint_from_mapping(row: Any) -> AuditChainCheckpoint: return AuditChainCheckpoint( singleton_id=int(row.singleton_id), event_count=int(row.event_count), last_sequence=int(row.last_sequence), last_event_hash=row.last_event_hash, hash_format=str(row.hash_format), v2_start_sequence=int(row.v2_start_sequence), legacy_prefix_count=int(row.legacy_prefix_count), legacy_prefix_seal=str(row.legacy_prefix_seal), ) def _load_checkpoint(session: Session) -> AuditChainCheckpoint: row = session.execute( select( AuditChainHead.singleton_id, AuditChainHead.event_count, AuditChainHead.last_sequence, AuditChainHead.last_event_hash, AuditChainHead.hash_format, AuditChainHead.v2_start_sequence, AuditChainHead.legacy_prefix_count, AuditChainHead.legacy_prefix_seal, ).where(AuditChainHead.singleton_id == AUDIT_CHAIN_SINGLETON_ID) ).one_or_none() if row is not None: return _checkpoint_from_mapping(row) raise AuditChainIntegrityError( "audit chain checkpoint is missing; only schema creation or the audited migration may seed it" ) def _assert_checkpoint_is_appendable( session: Session, checkpoint: AuditChainCheckpoint ) -> AuditEvent | None: """Validate the locked checkpoint and constant-size retained tail before append. Migration and recovery seal/verify the complete immutable prefix. Runtime append therefore proves the checkpoint shape, current tail hash/link and compare-and-set predecessor in O(1). A privileged edit in older middle history is intentionally the responsibility of the explicit strict invariant/recovery gates; ordinary SQLAlchemy audit DML is blocked separately. """ violations: list[str] = [] if checkpoint.singleton_id != AUDIT_CHAIN_SINGLETON_ID: violations.append("checkpoint singleton id is invalid") if checkpoint.hash_format != AUDIT_CURRENT_HASH_FORMAT: violations.append("checkpoint hash format is unsupported") if checkpoint.event_count < 0 or checkpoint.last_sequence < 0: violations.append("checkpoint counts cannot be negative") if checkpoint.event_count != checkpoint.last_sequence: violations.append("checkpoint event count and last sequence disagree") if checkpoint.v2_start_sequence < 1: violations.append("checkpoint v2 cutover is invalid") if checkpoint.legacy_prefix_count != checkpoint.v2_start_sequence - 1: violations.append("checkpoint legacy-prefix count and v2 cutover disagree") if checkpoint.legacy_prefix_count > checkpoint.event_count: violations.append("checkpoint legacy prefix exceeds the retained event count") if ( len(checkpoint.legacy_prefix_seal) != 64 or any(character not in "0123456789abcdef" for character in checkpoint.legacy_prefix_seal) ): violations.append("checkpoint legacy-prefix seal is malformed") if ( checkpoint.legacy_prefix_count == 0 and checkpoint.legacy_prefix_seal != EMPTY_LEGACY_PREFIX_SEAL ): violations.append("empty legacy-prefix checkpoint has the wrong seal") tail = list( session.scalars( select(AuditEvent) .order_by(AuditEvent.sequence.desc(), AuditEvent.id.desc()) .limit(2) .execution_options(populate_existing=True) ) ) latest = tail[0] if tail else None if checkpoint.event_count == 0: if latest is not None: violations.append("empty checkpoint has a retained audit tail") if checkpoint.last_event_hash is not None: violations.append("empty checkpoint carries a last-event hash") else: if latest is None: violations.append("non-empty checkpoint has no retained audit tail") elif ( latest.sequence != checkpoint.last_sequence or latest.event_hash != checkpoint.last_event_hash ): violations.append("checkpoint does not identify the current retained audit tail") if checkpoint.last_event_hash is None: violations.append("non-empty checkpoint has no last-event hash") elif len(checkpoint.last_event_hash) != 64 or any( character not in "0123456789abcdef" for character in checkpoint.last_event_hash ): violations.append("checkpoint last-event hash is malformed") if latest is not None: expected_format = ( AUDIT_HASH_FORMAT_V1 if latest.sequence < checkpoint.v2_start_sequence else AUDIT_HASH_FORMAT_V2 ) if latest.hash_format != expected_format: violations.append("retained audit tail uses the wrong hash format") payload_violations = _event_payload_violations(latest) violations.extend( f"retained audit tail: {violation}" for violation in payload_violations ) if latest.sequence == 1: if latest.previous_event_hash is not None: violations.append("first retained audit event has a previous hash") if len(tail) != 1: violations.append("checkpoint count one has more than one retained event") elif latest.sequence > 1: if len(tail) != 2: violations.append("retained audit tail has no predecessor") else: predecessor = tail[1] if predecessor.sequence != latest.sequence - 1: violations.append("retained audit tail predecessor is not contiguous") if latest.previous_event_hash != predecessor.event_hash: violations.append("retained audit tail link does not match its predecessor") if violations: raise AuditChainIntegrityError( "audit checkpoint/tail failed pre-append verification: " + "; ".join(violations[:5]) ) return latest class AuditWriter: def __init__( self, session: Session, actor_type: str | None = None, actor_id: str | None = None, *, context: AuditContext | None = None, ) -> None: if context is not None: if actor_type is not None or actor_id is not None: raise ValueError("pass either an audit context or actor fields, not both") resolved = context else: if actor_type is None or actor_id is None: raise ValueError("audit actor type and id are required") resolved = AuditContext(actor_type=actor_type, actor_id=actor_id) self.session = session self.context = resolved def write( self, action: str, resource_type: str, resource_id: str | None, details: dict[str, Any], outcome: str = "success", ) -> AuditEvent: _acquire_audit_write_lock(self.session) try: checkpoint = _load_checkpoint(self.session) previous = _assert_checkpoint_is_appendable(self.session, checkpoint) event_id = uuid.uuid4() occurred_at = datetime.now(UTC) return _append_canonical_audit_event( self.session, event_id=event_id, occurred_at=occurred_at, correlation_id=self.context.correlation_id or str(uuid.uuid4()), actor_type=self.context.actor_type, actor_id=self.context.actor_id, action=action, resource_type=resource_type, resource_id=resource_id, outcome=outcome, details=details, previous_event_hash=previous.event_hash if previous else None, expected_event_count=checkpoint.event_count, expected_last_sequence=checkpoint.last_sequence, expected_last_event_hash=checkpoint.last_event_hash, expected_hash_format=checkpoint.hash_format, expected_v2_start_sequence=checkpoint.v2_start_sequence, expected_legacy_prefix_count=checkpoint.legacy_prefix_count, expected_legacy_prefix_seal=checkpoint.legacy_prefix_seal, ) except Exception: # A caller must never be able to catch an audit failure and commit an unaudited domain # mutation or a detached event. Roll back the complete owning transaction fail-closed. self.session.rollback() raise