mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-15 00:16:46 +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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user