feat(audit): record user_id on log events

This commit is contained in:
Nora
2026-06-22 14:58:01 -06:00
parent 609499543e
commit 9c57b493a7
4 changed files with 93 additions and 1 deletions
+19 -1
View File
@@ -41,6 +41,21 @@ from sqlalchemy.exc import IntegrityError
from cyclone.inbox_state import apply_999_rejections from cyclone.inbox_state import apply_999_rejections
from cyclone.inbox_state_277ca import apply_277ca_rejections from cyclone.inbox_state_277ca import apply_277ca_rejections
from cyclone.audit_log import AuditEvent, append_event, verify_chain from cyclone.audit_log import AuditEvent, append_event, verify_chain
def _actor_user_id(request: Request) -> int | None:
"""Return the acting user's id from ``request.state.user``, or None.
``get_current_user``/``matrix_gate`` populate ``request.state.user``
for both the authenticated path and the AUTH_DISABLED escape hatch.
Returns None when the state hasn't been set (e.g. background jobs
or unit tests that bypass auth). Used to stamp ``user_id`` onto
audit events without crashing the request.
"""
user = getattr(request.state, "user", None)
if user is None:
return None
return getattr(user, "id", None)
from cyclone.parsers.exceptions import CycloneParseError from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
from cyclone.parsers.models_270 import ( from cyclone.parsers.models_270 import (
@@ -766,6 +781,7 @@ async def parse_999_endpoint(
entity_id=cid, entity_id=cid,
payload={"source_batch_id": synthetic_id, "ack_id": row.id}, payload={"source_batch_id": synthetic_id, "ack_id": row.id},
actor="999-parser", actor="999-parser",
user_id=_actor_user_id(request),
)) ))
audit_s.commit() audit_s.commit()
@@ -989,6 +1005,7 @@ async def parse_277ca_endpoint(
"277ca_id": row.id, "277ca_id": row.id,
}, },
actor="277ca-parser", actor="277ca-parser",
user_id=_actor_user_id(request),
)) ))
audit_s.commit() audit_s.commit()
if apply_result.orphans: if apply_result.orphans:
@@ -2526,7 +2543,7 @@ def get_clearhouse():
@app.post("/api/clearhouse/submit") @app.post("/api/clearhouse/submit")
def submit_to_clearhouse(body: dict): def submit_to_clearhouse(request: Request, body: dict):
"""Submit a batch of claims to the clearhouse (SFTP). SP9: stub. """Submit a batch of claims to the clearhouse (SFTP). SP9: stub.
Body: ``{"claim_ids": [...], "payer_id": "CO_TXIX"}`` Body: ``{"claim_ids": [...], "payer_id": "CO_TXIX"}``
@@ -2654,6 +2671,7 @@ def submit_to_clearhouse(body: dict):
"stub": ch.sftp_block.stub, "stub": ch.sftp_block.stub,
}, },
actor="clearhouse-submit", actor="clearhouse-submit",
user_id=_actor_user_id(request),
)) ))
audit_s.commit() audit_s.commit()
return {"ok": True, "submitted": results, "stub": ch.sftp_block.stub} return {"ok": True, "submitted": results, "stub": ch.sftp_block.stub}
+8
View File
@@ -103,6 +103,12 @@ class AuditEvent:
computed hash. Payload must be JSON-serializable; the audit_log computed hash. Payload must be JSON-serializable; the audit_log
module handles the encoding so callers don't need to think about module handles the encoding so callers don't need to think about
canonical form. 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 event_type: str
@@ -111,6 +117,7 @@ class AuditEvent:
payload: dict[str, Any] = field(default_factory=dict) payload: dict[str, Any] = field(default_factory=dict)
actor: str = "system" actor: str = "system"
created_at: datetime | None = None created_at: datetime | None = None
user_id: int | None = None
def append_event( def append_event(
@@ -155,6 +162,7 @@ def append_event(
created_at=created_at, created_at=created_at,
prev_hash=prev_hash, prev_hash=prev_hash,
hash=GENESIS_PREV_HASH, # placeholder; updated below hash=GENESIS_PREV_HASH, # placeholder; updated below
user_id=event.user_id,
) )
session.add(row) session.add(row)
session.flush() # populate row.id session.flush() # populate row.id
+5
View File
@@ -669,6 +669,11 @@ class AuditLog(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
prev_hash: Mapped[str] = mapped_column(String(64), nullable=False) prev_hash: Mapped[str] = mapped_column(String(64), nullable=False)
hash: Mapped[str] = mapped_column(String(64), nullable=False) hash: Mapped[str] = mapped_column(String(64), nullable=False)
# SP-auth: which authenticated user performed this action. Nullable
# so existing (pre-auth) rows and system-initiated events stay valid.
# NOT part of the hash chain — verify_chain must continue to work on
# legacy rows that pre-date this column.
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
__table_args__ = ( __table_args__ = (
Index("idx_audit_log_entity", "entity_type", "entity_id"), Index("idx_audit_log_entity", "entity_type", "entity_id"),
+61
View File
@@ -0,0 +1,61 @@
"""Audit log entries record the acting user_id.
Schema: the ``audit_log`` table has a ``user_id INTEGER`` column added
by migration 0011; the SQLAlchemy ``AuditLog`` model itself does not
declare it yet, so ``append_event`` cannot pass it through. These tests
fail before the model + dataclass are updated, and pass after.
"""
from __future__ import annotations
import pytest
from sqlalchemy import delete, select
from cyclone.audit_log import AuditEvent, append_event
from cyclone.db import AuditLog, SessionLocal, User
@pytest.fixture(autouse=True)
def _clear():
with SessionLocal()() as db:
db.execute(delete(AuditLog))
db.execute(delete(User))
db.commit()
yield
with SessionLocal()() as db:
db.execute(delete(AuditLog))
db.execute(delete(User))
db.commit()
def test_append_event_accepts_user_id_kwarg():
with SessionLocal()() as db:
append_event(
db,
AuditEvent(
event_type="test",
entity_type="x",
entity_id="y",
user_id=42,
),
)
db.commit()
with SessionLocal()() as db:
row = db.execute(select(AuditLog)).scalars().one()
assert row.user_id == 42
def test_append_event_without_user_id_is_null():
with SessionLocal()() as db:
append_event(
db,
AuditEvent(
event_type="test",
entity_type="x",
entity_id="y",
),
)
db.commit()
with SessionLocal()() as db:
row = db.execute(select(AuditLog)).scalars().one()
assert row.user_id is None