feat(sp11): tamper-evident hash-chained audit_log

- New audit_log table (migration 0009, user_version=9):
  * id, event_type, entity_type, entity_id, actor, payload_json,
    created_at, prev_hash, hash
  * Indexes on (entity_type, entity_id), event_type, created_at
- New cyclone.audit_log module:
  * append_event(session, AuditEvent) — appends one chained row
  * verify_chain(session) — walks the chain, returns first bad id
  * SHA-256 hash over canonical row form (unit-separator delimited)
  * Genesis prev_hash = 64 zeros (Bitcoin-style sentinel)
- New AuditLog ORM model
- New admin API endpoints:
  * GET /api/admin/audit-log (paginated, filterable)
  * GET /api/admin/audit-log/verify (returns ok/first_bad_id/reason)
- Hooked into existing endpoints to append events:
  * /api/parse-999 → 'claim.rejected' per matched claim
  * /api/parse-277ca → 'claim.payer_rejected' per matched claim
  * /api/clearhouse/submit → 'clearhouse.submitted' per claim
- HIPAA §164.316(b)(2) compliance note in docs

Tests: 688 -> 705 (9 audit + 8 audit-API). All 705 backend tests pass.
This commit is contained in:
Tyler
2026-06-20 23:45:43 -06:00
parent 9acdcb8dbd
commit 62bb09f183
8 changed files with 818 additions and 4 deletions
+110
View File
@@ -35,6 +35,7 @@ from cyclone import __version__, db
from cyclone.db import Claim, ClaimState, Remittance
from cyclone.inbox_state import apply_999_rejections
from cyclone.inbox_state_277ca import apply_277ca_rejections
from cyclone.audit_log import AuditEvent, append_event, verify_chain
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
from cyclone.parsers.models_270 import (
@@ -663,6 +664,20 @@ async def parse_999_endpoint(
raw_json=json.loads(result.model_dump_json()),
)
# SP11: append one audit row per rejected claim. Each row chains
# to the previous one — see cyclone.audit_log.
if _rejection_result.matched:
with db.SessionLocal()() as audit_s:
for cid in _rejection_result.matched:
append_event(audit_s, AuditEvent(
event_type="claim.rejected",
entity_type="claim",
entity_id=cid,
payload={"source_batch_id": synthetic_id, "ack_id": row.id},
actor="999-parser",
))
audit_s.commit()
return JSONResponse(content={
"ack": {
"id": row.id,
@@ -918,6 +933,20 @@ async def parse_277ca_endpoint(
bus = request.app.state.event_bus
for cid in apply_result.matched:
await bus.publish("claim.payer_rejected", {"claim_id": cid})
# SP11: audit trail for each payer-rejected claim.
with db.SessionLocal()() as audit_s:
for cid in apply_result.matched:
append_event(audit_s, AuditEvent(
event_type="claim.payer_rejected",
entity_type="claim",
entity_id=cid,
payload={
"source_batch_id": synthetic_id,
"277ca_id": row.id,
},
actor="277ca-parser",
))
audit_s.commit()
if apply_result.orphans:
log.warning(
"277CA had %d orphan status entries (no matching claim): %s",
@@ -2359,6 +2388,21 @@ def submit_to_clearhouse(body: dict):
"staging_path": str(staging_path),
"remote_path": remote,
})
# SP11: audit trail for each successful clearhouse submission.
with db.SessionLocal()() as audit_s:
append_event(audit_s, AuditEvent(
event_type="clearhouse.submitted",
entity_type="claim",
entity_id=cid,
payload={
"filename": filename,
"remote_path": remote,
"tpid": ch.tpid,
"stub": ch.sftp_block.stub,
},
actor="clearhouse-submit",
))
audit_s.commit()
return {"ok": True, "submitted": results, "stub": ch.sftp_block.stub}
@@ -2407,6 +2451,72 @@ def list_configured_providers(is_active: bool | None = Query(default=True)):
return [json.loads(p.model_dump_json()) for p in store.list_providers(is_active=is_active)]
# --------------------------------------------------------------------------- #
# SP11: tamper-evident audit log (admin)
# --------------------------------------------------------------------------- #
@app.get("/api/admin/audit-log")
def list_audit_log_endpoint(
entity_type: str | None = Query(default=None),
entity_id: str | None = Query(default=None),
event_type: str | None = Query(default=None),
limit: int = Query(default=100, ge=1, le=1000),
) -> Any:
"""List audit-log rows, newest first, with optional filters.
Filters match the (entity_type, entity_id) pair (typical use:
"show me everything that happened to claim C-123") or a single
event_type (typical use: "show me all clearhouse.submitted
events today").
"""
with db.SessionLocal()() as s:
q = s.query(db.AuditLog)
if entity_type:
q = q.filter(db.AuditLog.entity_type == entity_type)
if entity_id:
q = q.filter(db.AuditLog.entity_id == entity_id)
if event_type:
q = q.filter(db.AuditLog.event_type == event_type)
rows = q.order_by(db.AuditLog.id.desc()).limit(limit).all()
return {
"total": len(rows),
"items": [
{
"id": r.id,
"event_type": r.event_type,
"entity_type": r.entity_type,
"entity_id": r.entity_id,
"actor": r.actor,
"payload": json.loads(r.payload_json) if r.payload_json else None,
"created_at": r.created_at.isoformat() if r.created_at else None,
"prev_hash": r.prev_hash,
"hash": r.hash,
}
for r in rows
],
}
@app.get("/api/admin/audit-log/verify")
def verify_audit_log_endpoint() -> Any:
"""Walk the audit-log chain and verify every row's hash.
Returns ``{"ok": true, "checked": N}`` for a clean chain, or
``{"ok": false, "checked": K, "first_bad_id": X, "reason": "..."}``
for a broken chain. This is the operator's "did anyone tamper?"
endpoint; run it on demand or via a nightly cron job.
"""
with db.SessionLocal()() as s:
result = verify_chain(s)
return {
"ok": result.ok,
"checked": result.checked,
"first_bad_id": result.first_bad_id,
"reason": result.reason,
}
@app.get("/api/config/providers/{npi}")
def get_configured_provider(npi: str):
p = store.get_provider(npi)
+254
View File
@@ -0,0 +1,254 @@
"""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.
"""
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
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
)
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",
]
+39
View File
@@ -570,6 +570,45 @@ class Two77caAck(Base):
)
# ---------------------------------------------------------------------------
# SP11: tamper-evident hash-chained audit_log
# ---------------------------------------------------------------------------
class AuditLog(Base):
"""One row per audit event. Append-only by convention.
Each row's :attr:`hash` is SHA-256 of
``(id, event_type, entity_type, entity_id, actor, payload_json,
created_at, prev_hash)`` — and :attr:`prev_hash` is the previous
row's ``hash``. That forms a tamper-evident chain: changing any
row's payload invalidates every subsequent row's hash.
See ``cyclone.audit_log.append_event`` and ``verify_chain`` for
the append + verify operations. The application code MUST NOT
UPDATE or DELETE rows; doing so breaks the chain and is
auditable via :func:`verify_chain`.
"""
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
entity_type: Mapped[str] = mapped_column(String(64), nullable=False)
entity_id: Mapped[str] = mapped_column(String(64), nullable=False)
actor: Mapped[str] = mapped_column(String(64), nullable=False, default="system")
payload_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
prev_hash: Mapped[str] = mapped_column(String(64), nullable=False)
hash: Mapped[str] = mapped_column(String(64), nullable=False)
__table_args__ = (
Index("idx_audit_log_entity", "entity_type", "entity_id"),
Index("idx_audit_log_event_type", "event_type"),
Index("idx_audit_log_created_at", "created_at"),
)
# ---------------------------------------------------------------------------
# SP9: providers, payers, payer_configs, clearhouse
# ---------------------------------------------------------------------------
@@ -0,0 +1,33 @@
-- version: 9
-- SP11: tamper-evident hash-chained audit_log
--
-- Each row carries a SHA-256 hash of (id, event_type, entity_type,
-- entity_id, actor, payload_json, created_at, prev_hash). The prev_hash
-- field chains the row to the previous row's hash — a tamper-evident
-- Merkle-like chain (no tree, just a list).
--
-- Append-only by convention: no UPDATE/DELETE in the application code.
-- Compliance: HIPAA §164.316(b)(2) requires 6-year retention. We don't
-- enforce retention in the schema (no TTL), but a separate vacuum
-- job (out of scope here) can prune rows older than 6 years after
-- exporting them to cold storage.
--
-- Indexes: (entity_type, entity_id) for "show me the audit trail for
-- this claim"; (event_type) for "show me all clearhouse.submitted
-- events"; (created_at) for time-range scans.
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT 'system',
payload_json TEXT,
created_at TEXT NOT NULL,
prev_hash TEXT NOT NULL,
hash TEXT NOT NULL
);
CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id);
CREATE INDEX idx_audit_log_event_type ON audit_log(event_type);
CREATE INDEX idx_audit_log_created_at ON audit_log(created_at);