Files
cyclone/backend/src/cyclone/audit_log.py
T
2026-06-22 15:34:18 -06:00

263 lines
8.7 KiB
Python

"""Tamper-evident hash-chained audit_log.
SP11.
Each row's hash is SHA-256 of
``(id, event_type, entity_type, entity_id, actor, payload_json,
created_at, prev_hash)`` and ``prev_hash`` is the previous row's hash.
That forms a chain: changing any row's payload invalidates every
subsequent row's hash. :func:`verify_chain` walks the chain and
returns the first mismatch index (or ``None`` for a clean chain).
We use SHA-256 (FIPS-approved, fast on commodity hardware) instead
of a Merkle tree because the chain is linear: every row depends on
exactly one prior row. A Merkle tree would let us prove individual
membership with O(log n) witnesses, but the chain's whole point is
end-to-end integrity, not selective disclosure.
Append-only by convention: the application MUST NOT call
``session.delete(row)`` or modify an existing row. Doing so is
auditable via :func:`verify_chain`. We deliberately do not enforce
this at the DB level (no triggers, no revoked UPDATE permission)
because that breaks the test fixtures that recreate the DB.
"""
from __future__ import annotations
import hashlib
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.orm import Session
from cyclone.db import AuditLog
log = logging.getLogger(__name__)
# 64 hex chars = 256 bits. Constant for easy comparison.
HASH_LEN = 64
# Genesis row's prev_hash — a fixed "all zeros" sentinel so the first
# row in the chain has a deterministic predecessor. This is the same
# convention Bitcoin and other ledgers use.
GENESIS_PREV_HASH = "0" * HASH_LEN
# --------------------------------------------------------------------------- #
# Hashing
# --------------------------------------------------------------------------- #
def _hash_row(
*,
row_id: int,
event_type: str,
entity_type: str,
entity_id: str,
actor: str,
payload_json: str | None,
created_at: datetime,
prev_hash: str,
) -> str:
"""Compute SHA-256 hex of a row's canonical form.
The fields are concatenated with a separator that cannot appear
inside any field (``\\x1f`` — the ASCII unit separator). Using a
delimiter avoids length-ambiguity attacks where two different
payloads with the same string-joined form would hash to the same
digest.
"""
sep = "\x1f"
# Normalize the timestamp to an ISO 8601 UTC string so the hash is
# stable across timezone-aware and timezone-naive datetimes (the
# DB may give us either depending on the SQLite build).
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
created_at_iso = created_at.astimezone(timezone.utc).isoformat()
payload = payload_json or ""
canonical = sep.join([
str(row_id),
event_type,
entity_type,
entity_id,
actor,
created_at_iso,
payload,
prev_hash,
])
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
# --------------------------------------------------------------------------- #
# Append
# --------------------------------------------------------------------------- #
@dataclass
class AuditEvent:
"""An audit event ready to be appended.
Mirrors the ``AuditLog`` row shape minus the auto-assigned id and
computed hash. Payload must be JSON-serializable; the audit_log
module handles the encoding so callers don't need to think about
canonical form.
``user_id`` is the authenticated actor for this event, when known
(e.g. a parse-999 call made by user 7). It's stored on the row but
is NOT part of the hash chain — the chain hashes only the fields
that existed pre-SP-auth so verify_chain stays compatible with
pre-auth rows.
"""
event_type: str
entity_type: str
entity_id: str
payload: dict[str, Any] = field(default_factory=dict)
actor: str = "system"
created_at: datetime | None = None
user_id: int | None = None
def append_event(
session: Session,
event: AuditEvent,
) -> AuditLog:
"""Append one event to the audit_log chain and return the row.
The caller is responsible for ``session.commit()`` — this lets
callers batch multiple appends into one transaction (e.g., a
parser that appends one event per parsed claim).
"""
# Read the latest hash within the same session so concurrent
# appends don't see stale state. SQLite default isolation level
# gives us serializable reads for this query; for Postgres we'd
# need SELECT ... FOR UPDATE but that's overkill for v1.
latest = (
session.query(AuditLog)
.order_by(AuditLog.id.desc())
.first()
)
prev_hash = latest.hash if latest is not None else GENESIS_PREV_HASH
created_at = event.created_at or datetime.now(timezone.utc)
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
# Canonical payload form: sort_keys + compact separators. This
# makes the hash independent of dict insertion order across
# Python versions and across API runs.
payload_json = json.dumps(event.payload, sort_keys=True, separators=(",", ":")) if event.payload else None
# Insert the row with a placeholder hash, then UPDATE once we
# know the auto-assigned id. SQLite + SQLAlchemy gives us the id
# after the INSERT, so we can compute the real hash then.
row = AuditLog(
event_type=event.event_type,
entity_type=event.entity_type,
entity_id=event.entity_id,
actor=event.actor,
payload_json=payload_json,
created_at=created_at,
prev_hash=prev_hash,
hash=GENESIS_PREV_HASH, # placeholder; updated below
user_id=event.user_id,
)
session.add(row)
session.flush() # populate row.id
row.hash = _hash_row(
row_id=row.id,
event_type=row.event_type,
entity_type=row.entity_type,
entity_id=row.entity_id,
actor=row.actor,
payload_json=row.payload_json,
created_at=row.created_at,
prev_hash=row.prev_hash,
)
session.flush()
return row
# --------------------------------------------------------------------------- #
# Verify
# --------------------------------------------------------------------------- #
@dataclass
class VerifyResult:
"""Outcome of :func:`verify_chain`."""
ok: bool
checked: int
first_bad_id: int | None = None
reason: str | None = None
def verify_chain(session: Session) -> VerifyResult:
"""Walk the audit_log and verify every row's hash. Returns the first mismatch.
A clean chain returns ``VerifyResult(ok=True, checked=N)``. A
broken chain returns ``ok=False, first_bad_id=X, reason='...'``
describing what went wrong (hash mismatch, prev_hash mismatch,
or non-monotonic id).
This is intended to be called by the operator (e.g., a nightly
cron job or the admin UI's "Verify Audit Chain" button). It is
NOT a fast operation — for a 6-year-old chain with millions of
rows, expect seconds-to-minutes. Call it rarely.
"""
rows = session.query(AuditLog).order_by(AuditLog.id.asc()).all()
if not rows:
return VerifyResult(ok=True, checked=0)
expected_prev = GENESIS_PREV_HASH
last_id = 0
for i, row in enumerate(rows):
# Monotonic id check — covers attempted inserts with a
# custom id, or accidental out-of-order rows.
if row.id <= last_id:
return VerifyResult(
ok=False, checked=i, first_bad_id=row.id,
reason=f"non-monotonic id (previous={last_id}, this={row.id})",
)
last_id = row.id
# Recompute the hash from the row's content and compare.
expected_hash = _hash_row(
row_id=row.id,
event_type=row.event_type,
entity_type=row.entity_type,
entity_id=row.entity_id,
actor=row.actor,
payload_json=row.payload_json,
created_at=row.created_at,
prev_hash=row.prev_hash,
)
if expected_hash != row.hash:
return VerifyResult(
ok=False, checked=i, first_bad_id=row.id,
reason=f"hash mismatch (stored={row.hash[:16]}…, computed={expected_hash[:16]}…)",
)
# Check prev_hash linkage.
if row.prev_hash != expected_prev:
return VerifyResult(
ok=False, checked=i, first_bad_id=row.id,
reason=f"prev_hash mismatch (stored={row.prev_hash[:16]}…, expected={expected_prev[:16]}…)",
)
expected_prev = row.hash
return VerifyResult(ok=True, checked=len(rows))
__all__ = [
"AuditEvent",
"GENESIS_PREV_HASH",
"HASH_LEN",
"VerifyResult",
"append_event",
"verify_chain",
]