diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index 2e09bdc..5895ff5 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -37,6 +37,21 @@ 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 + + +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.models import BatchSummary, ClaimOutput, Envelope, ParseResult from cyclone.parsers.models_270 import ( @@ -686,6 +701,7 @@ async def parse_999_endpoint( entity_id=cid, payload={"source_batch_id": synthetic_id, "ack_id": row.id}, actor="999-parser", + user_id=_actor_user_id(request), )) audit_s.commit() @@ -956,6 +972,7 @@ async def parse_277ca_endpoint( "277ca_id": row.id, }, actor="277ca-parser", + user_id=_actor_user_id(request), )) audit_s.commit() if apply_result.orphans: @@ -2386,7 +2403,7 @@ def get_clearhouse(): @app.post("/api/clearhouse/submit", dependencies=[Depends(matrix_gate)]) -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. Body: ``{"claim_ids": [...], "payer_id": "CO_TXIX"}`` @@ -2441,6 +2458,7 @@ def submit_to_clearhouse(body: dict): "stub": ch.sftp_block.stub, }, actor="clearhouse-submit", + user_id=_actor_user_id(request), )) audit_s.commit() return {"ok": True, "submitted": results, "stub": ch.sftp_block.stub} diff --git a/backend/src/cyclone/audit_log.py b/backend/src/cyclone/audit_log.py index 8cbe3ac..6bce09c 100644 --- a/backend/src/cyclone/audit_log.py +++ b/backend/src/cyclone/audit_log.py @@ -103,6 +103,12 @@ class AuditEvent: 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 @@ -111,6 +117,7 @@ class AuditEvent: payload: dict[str, Any] = field(default_factory=dict) actor: str = "system" created_at: datetime | None = None + user_id: int | None = None def append_event( @@ -155,6 +162,7 @@ def append_event( 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 diff --git a/backend/src/cyclone/db.py b/backend/src/cyclone/db.py index 9dc71f0..0725862 100644 --- a/backend/src/cyclone/db.py +++ b/backend/src/cyclone/db.py @@ -621,6 +621,11 @@ class AuditLog(Base): 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) + # 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__ = ( Index("idx_audit_log_entity", "entity_type", "entity_id"), diff --git a/backend/tests/test_audit_log_user_id.py b/backend/tests/test_audit_log_user_id.py new file mode 100644 index 0000000..6740a7f --- /dev/null +++ b/backend/tests/test_audit_log_user_id.py @@ -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