Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions api/routes_audit_events.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from datetime import datetime
from typing import Optional

from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
Expand All @@ -20,14 +19,22 @@
def list_audit_events(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user_id: Optional[str] = Query(None),
service: Optional[str] = Query(None),
event_type: Optional[str] = Query(None),
decision: Optional[str] = Query(None),
from_timestamp: Optional[datetime] = Query(None),
to_timestamp: Optional[datetime] = Query(None),
db: Session = Depends(get_db),
_admin: dict = Depends(require_platform_admin),
user_id: str | None = Query(None),
service: str | None = Query(None),
event_type: str | None = Query(None),
decision: str | None = Query(None),
from_timestamp: datetime | None = Query(None), # noqa: B008 -- FastAPI's own documented query-param pattern, not a mutable-default bug
to_timestamp: datetime | None = Query(None), # noqa: B008 -- FastAPI's own documented query-param pattern, not a mutable-default bug
# HIPAA audit-integrity rollout: lets a platform_admin ask "show me
# every event that failed signature verification" (integrity_status=
# invalid) or isolate the still-unsigned backlog -- not just see the
# field per-row. No validation beyond `str | None`: the DB column
# itself is the source of truth for what values exist ("valid"/
# "invalid"/"unsigned" today, see audit/config.py's classifier), and
# an unrecognized value here just filters to zero rows, not an error.
integrity_status: str | None = Query(None),
db: Session = Depends(get_db), # noqa: B008 -- FastAPI's own documented dependency-injection pattern, not a mutable-default bug
_admin: dict = Depends(require_platform_admin), # noqa: B008 -- FastAPI's own documented dependency-injection pattern, not a mutable-default bug
) -> AuditEventListResponse:
rows, total = audit_query_service.list_audit_events(
db,
Expand All @@ -39,6 +46,7 @@ def list_audit_events(
decision=decision,
from_timestamp=from_timestamp,
to_timestamp=to_timestamp,
integrity_status=integrity_status,
)
total_pages = (total + page_size - 1) // page_size if total else 0
return AuditEventListResponse(
Expand Down
20 changes: 14 additions & 6 deletions schemas/audit.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from datetime import datetime
from typing import Any, Optional
from typing import Any

from pydantic import BaseModel, ConfigDict

Expand All @@ -15,14 +15,22 @@ class AuditEventOut(BaseModel):
timestamp: datetime
service: str
event_type: str
user_id: Optional[str] = None
user_id: str | None = None
action: str
resource: Optional[str] = None
decision: Optional[str] = None
reason: Optional[str] = None
trace_id: Optional[str] = None
resource: str | None = None
decision: str | None = None
reason: str | None = None
trace_id: str | None = None
context: dict[str, Any]
created_at: datetime
# HIPAA audit-integrity rollout: PR2/PR#5 added this column and the
# worker has classified every event ("valid"/"invalid"/"unsigned")
# since PR3a's deployment, but nothing surfaced it through this read
# API until now -- a platform_admin querying /audit/events had no way
# to see whether any event was ever actually verified. Always present
# (DB column is NOT NULL with server_default="unsigned"), never
# Optional -- matches AuditEventRecord.integrity_status exactly.
integrity_status: str


class AuditEventListResponse(BaseModel):
Expand Down
16 changes: 9 additions & 7 deletions services/audit_query_service.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from datetime import datetime
from typing import Optional

from sqlalchemy.orm import Session

Expand All @@ -10,12 +9,13 @@ def list_audit_events(
db: Session,
page: int,
page_size: int,
user_id: Optional[str] = None,
service: Optional[str] = None,
event_type: Optional[str] = None,
decision: Optional[str] = None,
from_timestamp: Optional[datetime] = None,
to_timestamp: Optional[datetime] = None,
user_id: str | None = None,
service: str | None = None,
event_type: str | None = None,
decision: str | None = None,
from_timestamp: datetime | None = None,
to_timestamp: datetime | None = None,
integrity_status: str | None = None,
) -> tuple[list[AuditEventRecord], int]:
"""Returns (page of AuditEventRecord rows, total matching rows).

Expand All @@ -42,6 +42,8 @@ def list_audit_events(
query = query.filter(AuditEventRecord.timestamp >= from_timestamp)
if to_timestamp is not None:
query = query.filter(AuditEventRecord.timestamp <= to_timestamp)
if integrity_status is not None:
query = query.filter(AuditEventRecord.integrity_status == integrity_status)

total = query.count()

Expand Down
35 changes: 31 additions & 4 deletions tests/test_audit_query_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
def _add(db_session, event_id, minutes_offset=0, **overrides):
row = AuditEventRecord(
event_id=event_id,
timestamp=datetime(2026, 1, 1, 12, 0, 0) + timedelta(minutes=minutes_offset),
timestamp=datetime(2026, 1, 1, 12, 0, 0) + timedelta(minutes=minutes_offset), # noqa: DTZ001 -- AuditEventRecord.timestamp is a naive DateTime column (db/models.py); an aware value here would mismatch it, not fix anything
service=overrides.get("service", "auth"),
event_type=overrides.get("event_type", "auth_login"),
user_id=overrides.get("user_id", "u1"),
Expand All @@ -21,6 +21,8 @@ def _add(db_session, event_id, minutes_offset=0, **overrides):
trace_id=overrides.get("trace_id"),
context=overrides.get("context", {}),
)
if "integrity_status" in overrides:
row.integrity_status = overrides["integrity_status"]
db_session.add(row)
return row

Expand Down Expand Up @@ -87,8 +89,33 @@ def test_filters_by_timestamp_range(db_session):
db_session,
page=1,
page_size=20,
from_timestamp=datetime(2026, 1, 1, 12, 5, 0),
to_timestamp=datetime(2026, 1, 1, 12, 15, 0),
from_timestamp=datetime(2026, 1, 1, 12, 5, 0), # noqa: DTZ001 -- matches the naive AuditEventRecord.timestamp column being filtered
to_timestamp=datetime(2026, 1, 1, 12, 15, 0), # noqa: DTZ001 -- matches the naive AuditEventRecord.timestamp column being filtered
)
assert total == 1
assert rows[0].event_id == "e2"


def test_filters_by_integrity_status(db_session):
_add(db_session, "e1", integrity_status="valid")
_add(db_session, "e2", integrity_status="invalid")
_add(db_session, "e3") # unspecified -- DB server_default="unsigned" applies
db_session.commit()

rows, total = audit_query_service.list_audit_events(
db_session, page=1, page_size=20, integrity_status="invalid"
)
assert total == 1
assert rows[0].event_id == "e2"


def test_filters_by_integrity_status_unsigned_matches_the_default(db_session):
_add(db_session, "e1", integrity_status="valid")
_add(db_session, "e2")
db_session.commit()

rows, total = audit_query_service.list_audit_events(
db_session, page=1, page_size=20, integrity_status="unsigned"
)
assert total == 1
assert rows[0].event_id == "e2"
Expand All @@ -115,7 +142,7 @@ def test_no_filters_returns_all(db_session):
_add(db_session, "e3")
db_session.commit()

rows, total = audit_query_service.list_audit_events(db_session, page=1, page_size=20)
_rows, total = audit_query_service.list_audit_events(db_session, page=1, page_size=20)
assert total == 3


Expand Down
41 changes: 35 additions & 6 deletions tests/test_routes_audit_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def _seed(session_factory, count=3):
db.add(
AuditEventRecord(
event_id=f"evt-{i}",
timestamp=datetime(2026, 1, 1, 12, 0, 0) + timedelta(minutes=i),
timestamp=datetime(2026, 1, 1, 12, 0, 0) + timedelta(minutes=i), # noqa: DTZ001 -- AuditEventRecord.timestamp is a naive DateTime column (db/models.py)
service="auth",
event_type="auth_login",
user_id="u1",
Expand Down Expand Up @@ -102,10 +102,15 @@ def test_response_contains_expected_fields(audit_events_client):
assert set(item.keys()) == {
"event_id", "timestamp", "service", "event_type", "user_id",
"action", "resource", "decision", "reason", "trace_id", "context",
"created_at",
"created_at", "integrity_status",
}
assert item["event_id"] == "evt-0"
assert item["context"] == {"i": 0}
# _seed() rows are constructed without an explicit integrity_status --
# the DB column's own server_default="unsigned" (0002_integrity_status)
# applies, same as every real historical event before any producer
# signed.
assert item["integrity_status"] == "unsigned"


def test_pagination_works(audit_events_client):
Expand Down Expand Up @@ -155,11 +160,11 @@ def test_filter_by_service_via_query_param(audit_events_client):
client, sessions = audit_events_client
db = sessions()
db.add(AuditEventRecord(
event_id="e1", timestamp=datetime(2026, 1, 1), service="auth",
event_id="e1", timestamp=datetime(2026, 1, 1), service="auth", # noqa: DTZ001 -- naive DateTime column
event_type="auth_login", context={},
))
db.add(AuditEventRecord(
event_id="e2", timestamp=datetime(2026, 1, 1), service="policy",
event_id="e2", timestamp=datetime(2026, 1, 1), service="policy", # noqa: DTZ001 -- naive DateTime column
event_type="policy_decision", context={},
))
db.commit()
Expand All @@ -178,11 +183,11 @@ def test_filter_by_decision_and_event_type_via_query_params(audit_events_client)
client, sessions = audit_events_client
db = sessions()
db.add(AuditEventRecord(
event_id="e1", timestamp=datetime(2026, 1, 1), service="auth",
event_id="e1", timestamp=datetime(2026, 1, 1), service="auth", # noqa: DTZ001 -- naive DateTime column
event_type="user_suspended", decision="success", context={},
))
db.add(AuditEventRecord(
event_id="e2", timestamp=datetime(2026, 1, 1), service="auth",
event_id="e2", timestamp=datetime(2026, 1, 1), service="auth", # noqa: DTZ001 -- naive DateTime column
event_type="user_suspended", decision="failure", context={},
))
db.commit()
Expand All @@ -199,6 +204,30 @@ def test_filter_by_decision_and_event_type_via_query_params(audit_events_client)
assert body["items"][0]["event_id"] == "e2"


def test_filter_by_integrity_status_via_query_param(audit_events_client):
client, sessions = audit_events_client
db = sessions()
db.add(AuditEventRecord(
event_id="e1", timestamp=datetime(2026, 1, 1), service="tes", # noqa: DTZ001 -- naive DateTime column
event_type="workflow_execution_denied", context={}, integrity_status="valid",
))
db.add(AuditEventRecord(
event_id="e2", timestamp=datetime(2026, 1, 1), service="tes", # noqa: DTZ001 -- naive DateTime column
event_type="workflow_execution_denied", context={}, integrity_status="invalid",
))
db.commit()
db.close()

resp = client.get(
"/audit/events", headers=_auth_headers(), params={"integrity_status": "invalid"}
)

body = resp.json()
assert body["total"] == 1
assert body["items"][0]["event_id"] == "e2"
assert body["items"][0]["integrity_status"] == "invalid"


# ---------------------------------------------------------------------------
# Existing endpoints unaffected
# ---------------------------------------------------------------------------
Expand Down
Loading