feat(audit): split actor from subject on auth events

auth_events.user_id was overloaded and meant something different per call
site: admin mutations filed the row under the target user and hid the acting
admin in metadata->>'by', team events filed it under the actor, and quota
events switched between the two depending on scope. The practical consequence
was that "show me everything admin X did" had no answer.

Adds actor_id (never NULL) and target_id (NULL when the event is not about a
user) alongside the existing column, which keeps its meaning as the per-user
feed key so nothing historical is rewritten. The backfill recovers the actor
from metadata for rows that predate the columns.

Also adds the indexes the cross-user admin feed needs. The only index was
(user_id, created_at DESC), so the global feed -- which orders by created_at
with no user predicate -- fell back to a sequential scan plus a sort on every
page.

The feed gains actor, multi-event, until and free-text search filters, plus a
distinct-event catalogue so the UI can offer what the instance recorded
instead of asking operators to type a name from memory.
This commit is contained in:
arc53-machine committed 2026-09-22 10:18:16 +01:00
1 parent f5b02ed374
commit cb5343397f
11 files changed
+483 -15

No files matched your search

+5
View File
@@ -68,6 +68,11 @@ auth_events_table = Table(
metadata,
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
Column("user_id", Text, nullable=False),
# Who performed the action, and (when the action is about a user) whom it
# was performed on. ``user_id`` predates both and is kept as the per-user
# feed key; see migration 0034.
Column("actor_id", Text, nullable=False),
Column("target_id", Text),
Column("event", Text, nullable=False),
Column("ip", Text),
Column("user_agent", Text),
+122 -11
View File
@@ -3,15 +3,33 @@
from __future__ import annotations
import json
from typing import Optional
from typing import Any, Optional, Sequence
from sqlalchemy import Connection, text
from docsgpt.storage.db.base_repository import row_to_dict
# Actors that are not a person. Used where an automated system performs the
# action on a user's behalf, so the feed never reads as if the user did it.
SYSTEM_ACTOR_SCIM = "system:scim"
SYSTEM_ACTOR_OIDC = "system:oidc"
class AuthEventsRepository:
"""Append-only audit trail of login / logout / provisioning events."""
"""Append-only audit trail of identity, access and data-plane events.
Every row carries three attribution columns:
* ``actor_id`` — who performed the action. Never NULL.
* ``target_id`` — the user the action was performed on, or NULL when the
event is not about a user (a team change, an instance-wide policy).
* ``user_id`` — the legacy subject column, kept as the key for the
per-user feed (:meth:`list_recent`). Defaults to the target, falling
back to the actor for actor-only events.
See migration ``0034_auth_events_actor_target``.
"""
def __init__(self, conn: Connection) -> None:
self._conn = conn
@@ -23,18 +41,45 @@ class AuthEventsRepository:
ip: Optional[str] = None,
user_agent: Optional[str] = None,
metadata: Optional[dict] = None,
*,
actor_id: Optional[str] = None,
target_id: Optional[str] = "",
) -> dict:
"""Record one auth event and return the inserted row."""
"""Record one audit event and return the inserted row.
Args:
user_id: The row's subject, kept for the per-user feed.
event: Event name (``oidc_login``, ``source.deleted``, ...).
ip: Client IP, when the event came from a request.
user_agent: Client ``User-Agent``, when the event came from a request.
metadata: Free-form JSON detail shown in the audit drill-down.
actor_id: Who performed the action. Defaults to ``user_id``, which
is correct for self-service events (login, PAT create).
target_id: The user acted upon. Defaults to ``user_id``; pass
``None`` explicitly for events with no user target.
Returns:
The inserted row as a dict.
"""
# Sentinel default: ``None`` is a meaningful value here ("no target"),
# so it cannot double as "caller did not say".
resolved_target = user_id if target_id == "" else target_id
resolved_actor = actor_id or user_id
result = self._conn.execute(
text(
"""
INSERT INTO auth_events (user_id, event, ip, user_agent, metadata)
VALUES (:user_id, :event, :ip, :user_agent, CAST(:metadata AS jsonb))
INSERT INTO auth_events
(user_id, actor_id, target_id, event, ip, user_agent, metadata)
VALUES
(:user_id, :actor_id, :target_id, :event, :ip, :user_agent,
CAST(:metadata AS jsonb))
RETURNING *
"""
),
{
"user_id": user_id,
"actor_id": resolved_actor,
"target_id": resolved_target,
"event": event,
"ip": ip,
"user_agent": user_agent,
@@ -59,18 +104,45 @@ class AuthEventsRepository:
return [row_to_dict(row) for row in result.fetchall()]
@staticmethod
def _filter_clauses(event, user_id, since) -> tuple[str, dict]:
def _filter_clauses(
event: Optional[str],
user_id: Optional[str],
since: Any,
*,
events: Optional[Sequence[str]] = None,
actor_id: Optional[str] = None,
until: Any = None,
search: Optional[str] = None,
) -> tuple[str, dict]:
clauses: list[str] = []
params: dict = {}
if event:
clauses.append("event = :event")
params["event"] = event
if events:
clauses.append("event = ANY(:events)")
params["events"] = list(events)
if user_id:
clauses.append("user_id = :user_id")
clauses.append("(user_id = :user_id OR target_id = :user_id)")
params["user_id"] = user_id
if actor_id:
clauses.append("actor_id = :actor_id")
params["actor_id"] = actor_id
if since is not None:
clauses.append("created_at >= :since")
params["since"] = since
if until is not None:
clauses.append("created_at <= :until")
params["until"] = until
if search:
# Substring match across the human-meaningful columns plus the
# serialized metadata, so "ci-deploy-key" finds the PAT it named.
clauses.append(
"(user_id ILIKE :search OR actor_id ILIKE :search "
"OR target_id ILIKE :search OR event ILIKE :search "
"OR ip ILIKE :search OR metadata::text ILIKE :search)"
)
params["search"] = f"%{search}%"
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
return where, params
@@ -78,13 +150,25 @@ class AuthEventsRepository:
self,
*,
event: Optional[str] = None,
events: Optional[Sequence[str]] = None,
user_id: Optional[str] = None,
actor_id: Optional[str] = None,
since=None,
until=None,
search: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> list[dict]:
"""Global audit feed (admin), newest first; optional event/user/since filters."""
where, params = self._filter_clauses(event, user_id, since)
"""Global audit feed (admin), newest first; see :meth:`_filter_clauses`."""
where, params = self._filter_clauses(
event,
user_id,
since,
events=events,
actor_id=actor_id,
until=until,
search=search,
)
params.update({"limit": int(limit), "offset": int(offset)})
result = self._conn.execute(
text(
@@ -96,13 +180,40 @@ class AuthEventsRepository:
return [row_to_dict(row) for row in result.fetchall()]
def count_all(
self, *, event: Optional[str] = None, user_id: Optional[str] = None, since=None
self,
*,
event: Optional[str] = None,
events: Optional[Sequence[str]] = None,
user_id: Optional[str] = None,
actor_id: Optional[str] = None,
since=None,
until=None,
search: Optional[str] = None,
) -> int:
"""Total matching the same filters as :meth:`list_all` (for pagination)."""
where, params = self._filter_clauses(event, user_id, since)
where, params = self._filter_clauses(
event,
user_id,
since,
events=events,
actor_id=actor_id,
until=until,
search=search,
)
return int(
self._conn.execute(
text(f"SELECT count(*) FROM auth_events {where}"), params
).scalar()
or 0
)
def event_names(self) -> list[str]:
"""Distinct event names present in the table, alphabetically.
Feeds the admin filter's event picker, so operators pick from what the
instance actually recorded instead of typing a name from memory.
"""
result = self._conn.execute(
text("SELECT DISTINCT event FROM auth_events ORDER BY event")
)
return [row[0] for row in result.fetchall()]