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:
@@ -51,17 +51,18 @@ def test_migration_0002_creates_acks_table():
|
||||
|
||||
def test_migration_latest_idempotent_on_fresh_db():
|
||||
"""Re-running the migration on the same DB must be a no-op (PRAGMA
|
||||
user_version already at the latest version — currently 8 after
|
||||
user_version already at the latest version — currently 9 after
|
||||
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
|
||||
providers/payers/clearhouse, and SP10's 0008 payer_rejected)."""
|
||||
providers/payers/clearhouse, SP10's 0008 payer_rejected, and
|
||||
SP11's 0009 audit_log)."""
|
||||
with db.engine().begin() as c:
|
||||
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||
assert v1 == 8
|
||||
assert v1 == 9
|
||||
# A second run should not raise and should not bump the version.
|
||||
db_migrate.run(db.engine())
|
||||
with db.engine().begin() as c:
|
||||
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||
assert v2 == 8
|
||||
assert v2 == 9
|
||||
|
||||
|
||||
def test_add_ack_persists_row():
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Tests for the audit-log admin API endpoints. SP11 T3."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.audit_log import AuditEvent, append_event
|
||||
from cyclone.api import app
|
||||
from cyclone.db import AuditLog, init_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_db():
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _seed_audit(rows: list[AuditEvent]) -> None:
|
||||
with db.SessionLocal()() as s:
|
||||
for ev in rows:
|
||||
append_event(s, ev)
|
||||
s.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# List endpoint
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestListAuditLog:
|
||||
def test_empty_returns_zero_items(self, client: TestClient):
|
||||
r = client.get("/api/admin/audit-log")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 0
|
||||
assert body["items"] == []
|
||||
|
||||
def test_returns_newest_first(self, client: TestClient):
|
||||
_seed_audit([
|
||||
AuditEvent(event_type="a.first", entity_type="claim", entity_id="c1"),
|
||||
AuditEvent(event_type="a.second", entity_type="claim", entity_id="c1"),
|
||||
AuditEvent(event_type="a.third", entity_type="claim", entity_id="c1"),
|
||||
])
|
||||
r = client.get("/api/admin/audit-log")
|
||||
assert r.status_code == 200
|
||||
items = r.json()["items"]
|
||||
assert [i["event_type"] for i in items] == ["a.third", "a.second", "a.first"]
|
||||
|
||||
def test_filter_by_entity(self, client: TestClient):
|
||||
_seed_audit([
|
||||
AuditEvent(event_type="x", entity_type="claim", entity_id="c1"),
|
||||
AuditEvent(event_type="x", entity_type="claim", entity_id="c2"),
|
||||
AuditEvent(event_type="x", entity_type="batch", entity_id="b1"),
|
||||
])
|
||||
r = client.get("/api/admin/audit-log", params={"entity_type": "claim", "entity_id": "c1"})
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["entity_id"] == "c1"
|
||||
|
||||
def test_filter_by_event_type(self, client: TestClient):
|
||||
_seed_audit([
|
||||
AuditEvent(event_type="claim.parsed", entity_type="claim", entity_id="c1"),
|
||||
AuditEvent(event_type="claim.rejected", entity_type="claim", entity_id="c1"),
|
||||
])
|
||||
r = client.get("/api/admin/audit-log", params={"event_type": "claim.rejected"})
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["event_type"] == "claim.rejected"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Verify endpoint
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestVerifyAuditLog:
|
||||
def test_empty_chain_returns_ok(self, client: TestClient):
|
||||
r = client.get("/api/admin/audit-log/verify")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["checked"] == 0
|
||||
|
||||
def test_clean_chain_returns_ok(self, client: TestClient):
|
||||
_seed_audit([
|
||||
AuditEvent(event_type="x", entity_type="y", entity_id=f"id-{i}")
|
||||
for i in range(10)
|
||||
])
|
||||
r = client.get("/api/admin/audit-log/verify")
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["checked"] == 10
|
||||
|
||||
def test_tampered_chain_returns_failure(self, client: TestClient):
|
||||
_seed_audit([
|
||||
AuditEvent(event_type="x", entity_type="y", entity_id=f"id-{i}")
|
||||
for i in range(5)
|
||||
])
|
||||
with db.SessionLocal()() as s:
|
||||
row = s.query(AuditLog).filter(AuditLog.id == 3).first()
|
||||
row.payload_json = json.dumps({"evil": True})
|
||||
s.commit()
|
||||
r = client.get("/api/admin/audit-log/verify")
|
||||
body = r.json()
|
||||
assert body["ok"] is False
|
||||
assert body["first_bad_id"] == 3
|
||||
assert body["reason"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# End-to-end: 999 → audit row appears in list
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestAuditLogHookedIntoEndpoints:
|
||||
def test_parse_999_creates_audit_rows(self, client: TestClient):
|
||||
"""Upload a 999 with one rejected set, see an audit row appear."""
|
||||
# Seed a claim so the 999 rejection matches.
|
||||
from cyclone.db import Claim, ClaimState
|
||||
with db.SessionLocal()() as s:
|
||||
s.add(Claim(
|
||||
id="c1", batch_id="B-1", patient_control_number="0001",
|
||||
state=ClaimState.SUBMITTED,
|
||||
))
|
||||
s.commit()
|
||||
|
||||
text = (
|
||||
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HN*A*B*20240620*1200*1*X*005010X231A1~"
|
||||
"ST*999*0001*005010X231A1~"
|
||||
"AK1*HC*0001~"
|
||||
"AK2*837*0001~"
|
||||
"AK5*R~"
|
||||
"AK9*R*1*0*1~"
|
||||
"SE*7*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
r = client.post(
|
||||
"/api/parse-999",
|
||||
files={"file": ("r.txt", text, "text/plain")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# One audit row should have appeared for the rejected claim.
|
||||
r2 = client.get(
|
||||
"/api/admin/audit-log",
|
||||
params={"entity_type": "claim", "entity_id": "c1"},
|
||||
)
|
||||
items = r2.json()["items"]
|
||||
assert any(i["event_type"] == "claim.rejected" for i in items)
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Tests for the tamper-evident hash-chained audit_log.
|
||||
|
||||
SP11 T1.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.audit_log import (
|
||||
GENESIS_PREV_HASH,
|
||||
HASH_LEN,
|
||||
AuditEvent,
|
||||
append_event,
|
||||
verify_chain,
|
||||
)
|
||||
from cyclone.db import AuditLog, init_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_db():
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _row_count() -> int:
|
||||
with db.SessionLocal()() as s:
|
||||
return s.query(AuditLog).count()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Append
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestAppendEvent:
|
||||
def test_first_row_has_genesis_prev_hash(self):
|
||||
"""The first row in a fresh chain uses the all-zeros prev_hash."""
|
||||
with db.SessionLocal()() as s:
|
||||
row = append_event(s, AuditEvent(
|
||||
event_type="claim.parsed",
|
||||
entity_type="claim",
|
||||
entity_id="c1",
|
||||
payload={"patient_control_number": "PCN-1"},
|
||||
))
|
||||
s.commit()
|
||||
assert row.id == 1
|
||||
assert row.prev_hash == GENESIS_PREV_HASH
|
||||
assert len(row.hash) == HASH_LEN
|
||||
|
||||
def test_second_row_chains_to_first(self):
|
||||
"""Row N's prev_hash == row N-1's hash."""
|
||||
with db.SessionLocal()() as s:
|
||||
r1 = append_event(s, AuditEvent(
|
||||
event_type="claim.parsed", entity_type="claim", entity_id="c1",
|
||||
))
|
||||
s.commit()
|
||||
r2 = append_event(s, AuditEvent(
|
||||
event_type="claim.rejected", entity_type="claim", entity_id="c1",
|
||||
))
|
||||
s.commit()
|
||||
assert r2.prev_hash == r1.hash
|
||||
assert r2.id == r1.id + 1
|
||||
|
||||
def test_payload_canonicalization(self):
|
||||
"""Two payloads with the same content but different key order hash the same.
|
||||
|
||||
We rely on sort_keys=True to make the canonical form independent
|
||||
of dict insertion order.
|
||||
"""
|
||||
with db.SessionLocal()() as s:
|
||||
r1 = append_event(s, AuditEvent(
|
||||
event_type="x", entity_type="y", entity_id="z",
|
||||
payload={"a": 1, "b": 2},
|
||||
))
|
||||
s.commit()
|
||||
# New session to drop any in-memory ordering cache.
|
||||
with db.SessionLocal()() as s2:
|
||||
r2 = append_event(s2, AuditEvent(
|
||||
event_type="x", entity_type="y", entity_id="z",
|
||||
payload={"b": 2, "a": 1},
|
||||
))
|
||||
s2.commit()
|
||||
# The two rows have different IDs and different prev_hash
|
||||
# inputs, but their hash *recipe* (modulo id + prev_hash) is
|
||||
# the same. Just confirm the rows are different.
|
||||
assert r1.id != r2.id
|
||||
# The payload_json fields ARE byte-identical (canonical form).
|
||||
assert r1.payload_json == r2.payload_json
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Verify
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestVerifyChain:
|
||||
def test_empty_chain_is_ok(self):
|
||||
"""No rows = nothing to verify = ok."""
|
||||
with db.SessionLocal()() as s:
|
||||
result = verify_chain(s)
|
||||
assert result.ok is True
|
||||
assert result.checked == 0
|
||||
|
||||
def test_single_row_chain_is_ok(self):
|
||||
with db.SessionLocal()() as s:
|
||||
append_event(s, AuditEvent("x", "y", "z"))
|
||||
s.commit()
|
||||
result = verify_chain(s)
|
||||
assert result.ok is True
|
||||
assert result.checked == 1
|
||||
|
||||
def test_long_chain_is_ok(self):
|
||||
with db.SessionLocal()() as s:
|
||||
for i in range(50):
|
||||
append_event(s, AuditEvent("x", "y", f"id-{i}"))
|
||||
s.commit()
|
||||
result = verify_chain(s)
|
||||
assert result.ok is True
|
||||
assert result.checked == 50
|
||||
|
||||
def test_tampered_payload_detected(self):
|
||||
"""Modifying a row's payload_json breaks the chain at that row."""
|
||||
with db.SessionLocal()() as s:
|
||||
for i in range(5):
|
||||
append_event(s, AuditEvent("x", "y", f"id-{i}"))
|
||||
s.commit()
|
||||
# Tamper with row #3's payload (the first 3 rows are still
|
||||
# valid; row 3 will fail).
|
||||
with db.SessionLocal()() as s:
|
||||
row = s.query(AuditLog).filter(AuditLog.id == 3).first()
|
||||
row.payload_json = json.dumps({"evil": "tampered"})
|
||||
s.commit()
|
||||
with db.SessionLocal()() as s:
|
||||
result = verify_chain(s)
|
||||
assert result.ok is False
|
||||
assert result.first_bad_id == 3
|
||||
assert "hash" in (result.reason or "").lower()
|
||||
|
||||
def test_tampered_prev_hash_detected(self):
|
||||
"""Modifying a row's prev_hash invalidates that row's own hash.
|
||||
|
||||
Because the row's hash is computed from prev_hash (and the
|
||||
other fields), changing prev_hash changes the row's own hash,
|
||||
so the verifier detects the breakage at the tampered row
|
||||
itself (not the row after). The next-row check (which compares
|
||||
prev_hash to the previous row's hash) would catch a different
|
||||
class of tampering: a row whose prev_hash was set to a value
|
||||
matching the previous row's hash but whose own hash was also
|
||||
regenerated — but that requires recomputing the hash to match,
|
||||
which is what verify_chain would not detect by itself (the
|
||||
content-vs-hash check still catches content tampering).
|
||||
"""
|
||||
with db.SessionLocal()() as s:
|
||||
for i in range(5):
|
||||
append_event(s, AuditEvent("x", "y", f"id-{i}"))
|
||||
s.commit()
|
||||
with db.SessionLocal()() as s:
|
||||
row = s.query(AuditLog).filter(AuditLog.id == 2).first()
|
||||
row.prev_hash = "f" * 64 # fake
|
||||
s.commit()
|
||||
with db.SessionLocal()() as s:
|
||||
result = verify_chain(s)
|
||||
assert result.ok is False
|
||||
# The tampered row itself fails its hash check first.
|
||||
assert result.first_bad_id == 2
|
||||
assert "hash" in (result.reason or "").lower()
|
||||
|
||||
def test_deleted_row_detected(self):
|
||||
"""Deleting a middle row breaks the chain at the row after it."""
|
||||
with db.SessionLocal()() as s:
|
||||
for i in range(5):
|
||||
append_event(s, AuditEvent("x", "y", f"id-{i}"))
|
||||
s.commit()
|
||||
with db.SessionLocal()() as s:
|
||||
row = s.query(AuditLog).filter(AuditLog.id == 3).first()
|
||||
s.delete(row)
|
||||
s.commit()
|
||||
with db.SessionLocal()() as s:
|
||||
result = verify_chain(s)
|
||||
assert result.ok is False
|
||||
# Row 3 is gone; row 4's prev_hash now points at row 2's hash,
|
||||
# which doesn't match row 4's stored prev_hash.
|
||||
assert result.first_bad_id == 4
|
||||
Reference in New Issue
Block a user