mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 10:15:21 +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
@@ -0,0 +1,94 @@
|
||||
"""0034 auth events actor/target — who did it, and to whom.
|
||||
|
||||
``auth_events.user_id`` was overloaded: admin mutations file the row under the
|
||||
*target* user and hide the acting admin in ``metadata->>'by'``, team events file
|
||||
it under the *actor*, and quota events switch between the two depending on
|
||||
scope. The practical consequence is that "show me everything admin X did" is
|
||||
unanswerable — the first question any audit review asks.
|
||||
|
||||
This adds two explicit columns:
|
||||
|
||||
* ``actor_id`` — who performed the action (never NULL going forward).
|
||||
* ``target_id`` — the user the action was performed on; NULL when the event is
|
||||
not about a user (a team or an instance-wide policy change).
|
||||
|
||||
``user_id`` is deliberately left alone. It stays the per-user feed key
|
||||
(``list_recent``) and every historical row keeps its meaning, so this migration
|
||||
is additive and the backfill never rewrites it.
|
||||
|
||||
Backfill rules, applied only to rows that predate the columns:
|
||||
|
||||
* ``actor_id`` = the first present of ``metadata->>'by'``,
|
||||
``metadata->>'granted_by'``, ``metadata->>'revoked_by'``, else ``user_id``.
|
||||
* ``target_id`` = ``user_id``, except for ``team.*`` events, which were always
|
||||
filed under the actor and have no single user target.
|
||||
|
||||
Also adds the indexes the global admin feed needs. Before this the only index
|
||||
was ``(user_id, created_at DESC)``, so the cross-user feed — which orders by
|
||||
``created_at DESC`` with no user predicate — degraded to a sequential scan plus
|
||||
a sort on every page.
|
||||
|
||||
Idempotent both ways.
|
||||
|
||||
Revision ID: 0034_auth_events_actor_target
|
||||
Revises: 0033_quotas
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0034_auth_events_actor_target"
|
||||
down_revision: Union[str, None] = "0033_quotas"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE auth_events ADD COLUMN IF NOT EXISTS actor_id TEXT;")
|
||||
op.execute("ALTER TABLE auth_events ADD COLUMN IF NOT EXISTS target_id TEXT;")
|
||||
|
||||
# Backfill. ``actor_id IS NULL`` scopes this to pre-migration rows, so a
|
||||
# re-run (or a downgrade/upgrade cycle) never clobbers written values.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE auth_events
|
||||
SET actor_id = COALESCE(
|
||||
metadata->>'by',
|
||||
metadata->>'granted_by',
|
||||
metadata->>'revoked_by',
|
||||
user_id
|
||||
),
|
||||
target_id = CASE
|
||||
WHEN event LIKE 'team.%' THEN NULL
|
||||
ELSE user_id
|
||||
END
|
||||
WHERE actor_id IS NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
# Backfilled every row above, so the NOT NULL is safe to assert now. New
|
||||
# rows always carry an actor (the repository derives one from ``user_id``).
|
||||
op.execute("ALTER TABLE auth_events ALTER COLUMN actor_id SET NOT NULL;")
|
||||
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS auth_events_created_idx "
|
||||
"ON auth_events (created_at DESC);"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS auth_events_event_created_idx "
|
||||
"ON auth_events (event, created_at DESC);"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS auth_events_actor_idx "
|
||||
"ON auth_events (actor_id, created_at DESC);"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS auth_events_actor_idx;")
|
||||
op.execute("DROP INDEX IF EXISTS auth_events_event_created_idx;")
|
||||
op.execute("DROP INDEX IF EXISTS auth_events_created_idx;")
|
||||
op.execute("ALTER TABLE auth_events DROP COLUMN IF EXISTS target_id;")
|
||||
op.execute("ALTER TABLE auth_events DROP COLUMN IF EXISTS actor_id;")
|
||||
@@ -119,6 +119,10 @@ def _audit(conn, event: str, scope: str, subject_id: Optional[str], detail: dict
|
||||
ip=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
metadata={"by": actor, "via": "admin_api", "scope": scope, "subject_id": subject_id, **detail},
|
||||
actor_id=actor or "unknown",
|
||||
# Only a user-scoped policy targets a user; instance and team policies
|
||||
# change configuration, not an account.
|
||||
target_id=subject_id if scope == "user" else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -171,6 +171,8 @@ class AdminUserResource(Resource):
|
||||
ip=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
metadata={"by": actor, "via": "admin_api"},
|
||||
actor_id=actor,
|
||||
target_id=user_id,
|
||||
)
|
||||
if not active:
|
||||
# Best-effort live-session revocation (mirrors SCIM deactivation).
|
||||
@@ -203,6 +205,8 @@ class AdminUserRoleResource(Resource):
|
||||
"granted_by": actor,
|
||||
"via": "admin_api",
|
||||
},
|
||||
actor_id=actor,
|
||||
target_id=user_id,
|
||||
)
|
||||
return make_response(
|
||||
jsonify({"success": True, "granted": inserted, "role": ROLE_ADMIN}), 200
|
||||
@@ -238,6 +242,8 @@ class AdminUserRoleResource(Resource):
|
||||
"revoked_by": actor,
|
||||
"via": "admin_api",
|
||||
},
|
||||
actor_id=actor,
|
||||
target_id=user_id,
|
||||
)
|
||||
return make_response(jsonify({"success": True, "revoked": removed}), 200)
|
||||
|
||||
@@ -261,6 +267,8 @@ class AdminUserSessionsResource(Resource):
|
||||
ip=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
metadata={"token_id": token_id, "by": _actor(), "via": "admin_sessions_revoked"},
|
||||
actor_id=_actor(),
|
||||
target_id=user_id,
|
||||
)
|
||||
AuthEventsRepository(conn).insert(
|
||||
user_id,
|
||||
@@ -273,6 +281,8 @@ class AdminUserSessionsResource(Resource):
|
||||
"persisted": ok,
|
||||
"personal_access_tokens_revoked": len(revoked_token_ids),
|
||||
},
|
||||
actor_id=_actor(),
|
||||
target_id=user_id,
|
||||
)
|
||||
return make_response(jsonify({"success": True, "revoked": ok}), 200)
|
||||
|
||||
|
||||
@@ -26,7 +26,10 @@ from docsgpt.api.oidc import denylist, provider
|
||||
from docsgpt.auth import handle_auth
|
||||
from docsgpt.cache import get_redis_instance
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.storage.db.repositories.auth_events import AuthEventsRepository
|
||||
from docsgpt.storage.db.repositories.auth_events import (
|
||||
SYSTEM_ACTOR_OIDC,
|
||||
AuthEventsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.user_roles import UserRolesRepository
|
||||
from docsgpt.storage.db.repositories.users import UsersRepository
|
||||
from docsgpt.storage.db.session import db_readonly, db_session
|
||||
@@ -129,6 +132,9 @@ def _reconcile_oidc_admin(user_id: str, groups: list[str] | None) -> None:
|
||||
ip=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
metadata={"role": "admin", "source": "oidc_group"},
|
||||
# The provider's group membership drove this, not the user.
|
||||
actor_id=SYSTEM_ACTOR_OIDC,
|
||||
target_id=user_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.error("OIDC admin reconcile failed for %s", user_id, exc_info=True)
|
||||
|
||||
@@ -307,6 +307,8 @@ class AdminToken(Resource):
|
||||
ip=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
metadata={"token_id": token_id, "by": actor, "via": "admin_api"},
|
||||
actor_id=actor,
|
||||
target_id=row["user_id"],
|
||||
)
|
||||
if not revoked:
|
||||
return _error("Token not found", 404)
|
||||
|
||||
@@ -20,7 +20,10 @@ from sqlalchemy import Connection
|
||||
|
||||
from docsgpt.api.oidc.denylist import deny_user
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.storage.db.repositories.auth_events import AuthEventsRepository
|
||||
from docsgpt.storage.db.repositories.auth_events import (
|
||||
SYSTEM_ACTOR_SCIM,
|
||||
AuthEventsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.users import UsersRepository
|
||||
from docsgpt.storage.db.session import db_readonly, db_session
|
||||
|
||||
@@ -201,7 +204,13 @@ def _audit(conn: Connection, user_id: str, event: str) -> None:
|
||||
"""Best-effort audit insert in a savepoint; failure never fails the request."""
|
||||
try:
|
||||
with conn.begin_nested():
|
||||
AuthEventsRepository(conn).insert(user_id, event, metadata={"via": "scim"})
|
||||
AuthEventsRepository(conn).insert(
|
||||
user_id,
|
||||
event,
|
||||
metadata={"via": "scim"},
|
||||
actor_id=SYSTEM_ACTOR_SCIM,
|
||||
target_id=user_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.error("SCIM audit insert failed for user %s event %s", user_id, event, exc_info=True)
|
||||
|
||||
|
||||
@@ -62,13 +62,26 @@ def _current_user() -> str | None:
|
||||
return token.get("sub") if isinstance(token, dict) else None
|
||||
|
||||
|
||||
# Metadata keys naming the user a team event acted on, most specific first.
|
||||
# Lets ``_audit`` fill ``target_id`` without every call site repeating it.
|
||||
_TARGET_METADATA_KEYS = ("target_user", "target_user_id", "new_owner")
|
||||
|
||||
|
||||
def _audit(conn, actor: str | None, event: str, **metadata) -> None:
|
||||
"""Append a team management event to the audit trail (best-effort).
|
||||
|
||||
Runs inside the action's transaction so the audit row commits atomically
|
||||
with the change. Never raises into the request path — an audit failure must
|
||||
not fail the operation.
|
||||
|
||||
Team events are filed under the acting user. When the action names another
|
||||
member (add, role change, removal, ownership transfer) that member becomes
|
||||
the row's ``target_id``; otherwise the event has no user target.
|
||||
"""
|
||||
detail = {k: v for k, v in metadata.items() if v is not None}
|
||||
target = next(
|
||||
(detail[key] for key in _TARGET_METADATA_KEYS if detail.get(key)), None
|
||||
)
|
||||
try:
|
||||
# SAVEPOINT: a failed audit insert poisons the surrounding txn, so
|
||||
# nest it — on failure only the audit rolls back, not the action.
|
||||
@@ -78,7 +91,9 @@ def _audit(conn, actor: str | None, event: str, **metadata) -> None:
|
||||
event=event,
|
||||
ip=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
metadata={k: v for k, v in metadata.items() if v is not None},
|
||||
metadata=detail,
|
||||
actor_id=actor or "unknown",
|
||||
target_id=target,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("team audit insert failed for event=%s", event, exc_info=True)
|
||||
|
||||
@@ -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