mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 20:14:16 +00:00
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:
1 parent
f5b02ed374
commit
cb5343397f
11 files changed
+483
-15
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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()]
|
||||
Reference in new issue
Block a user