mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 18:13:03 +00:00
DocsGPT keeps three append-only journals -- auth_events, device_audit_log and guardrail_events -- each readable only on its own terms, and the second of those had an admin endpoint that no UI ever called. An operator reviewing an incident wants one timeline, not three. Adds GET /api/admin/activity: all three journals projected onto a common row shape and merged, ordered newest first, with facets for journal, category, event name, actor, affected user, a time window and free-text search over the detail payload. A category filter that only one journal can satisfy drops the others from the union rather than scanning and discarding them. GET /api/admin/activity/events returns the distinct (event, category) pairs the instance has actually recorded, so the filter UI can offer real choices instead of asking an operator to type "oidc_login_denied" from memory. GET /api/admin/activity/export streams the same filtered feed as CSV or NDJSON. Streamed rather than buffered, and capped, so a compliance export of a busy instance is bounded work. guardrail_events.api_key (a raw agent key) and matched_value (unredacted source text) are excluded from the projection, mirroring the exclusion the per-agent guardrail view already makes. Admin-gating is not a reason to widen what a list response carries.
105 lines
3.6 KiB
Python
105 lines
3.6 KiB
Python
"""Audit event taxonomy: the categories the admin activity feed filters on.
|
|
|
|
Lives at the top level (like ``docsgpt/pricing.py``) because both the API
|
|
layer — which records events — and the storage layer — which has to filter a
|
|
merged feed in SQL — need the same map, and neither should import the other.
|
|
|
|
Two representations of one table:
|
|
|
|
* :func:`category_for` classifies a single event name in Python.
|
|
* :func:`category_case_sql` emits the equivalent ``CASE`` expression so a
|
|
category filter can be pushed into the query instead of enumerating every
|
|
event name a release might have written.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
# Categories in the order the UI shows them. ``other`` catches event names an
|
|
# older release wrote that this one does not know about — the feed must never
|
|
# hide a row just because its name is unfamiliar.
|
|
ACTIVITY_CATEGORIES = ("identity", "access", "config", "data", "device", "safety", "other")
|
|
|
|
# Exact event names.
|
|
_EXACT: dict[str, str] = {
|
|
# Who you are: authentication, provisioning, personal credentials.
|
|
"oidc_login": "identity",
|
|
"oidc_login_denied": "identity",
|
|
"oidc_refresh": "identity",
|
|
"backchannel_logout": "identity",
|
|
"scim_created": "identity",
|
|
"scim_deactivated": "identity",
|
|
"scim_reactivated": "identity",
|
|
"pat_created": "identity",
|
|
"pat_revoked": "identity",
|
|
"pat_regenerated": "identity",
|
|
# What you may do: roles, account state.
|
|
"role_granted": "access",
|
|
"role_revoked": "access",
|
|
"admin_user_activated": "access",
|
|
"admin_user_deactivated": "access",
|
|
"admin_sessions_revoked": "access",
|
|
# How the instance is configured.
|
|
"quota_policy_set": "config",
|
|
"quota_policy_deleted": "config",
|
|
}
|
|
|
|
# Dotted namespaces, matched on the part before the first ``.``.
|
|
_NAMESPACES: dict[str, str] = {
|
|
"team": "access",
|
|
"source": "data",
|
|
"agent": "data",
|
|
"conversation": "data",
|
|
"device": "device",
|
|
"guardrail": "safety",
|
|
}
|
|
|
|
|
|
def category_for(event: str) -> str:
|
|
"""Return the activity category for ``event``.
|
|
|
|
Args:
|
|
event: The audit event name (``oidc_login``, ``source.deleted``).
|
|
|
|
Returns:
|
|
One of :data:`ACTIVITY_CATEGORIES`; ``"other"`` for names this release
|
|
does not recognise.
|
|
"""
|
|
namespace, dot, _ = event.partition(".")
|
|
if dot:
|
|
return _NAMESPACES.get(namespace, "other")
|
|
return _EXACT.get(event, "other")
|
|
|
|
|
|
def category_case_sql(column: str) -> str:
|
|
"""Build the SQL ``CASE`` that classifies ``column`` the way :func:`category_for` does.
|
|
|
|
Uses ``split_part`` rather than ``LIKE`` so the expression carries no ``%``
|
|
— which a pyformat DBAPI would otherwise read as a parameter marker.
|
|
|
|
Args:
|
|
column: The SQL expression holding an event name.
|
|
|
|
Returns:
|
|
A parenthesised ``CASE`` expression. Every literal in it comes from the
|
|
maps above, never from user input, so it is safe to interpolate.
|
|
"""
|
|
namespace_branches = " ".join(
|
|
f"WHEN '{namespace}' THEN '{category}'"
|
|
for namespace, category in _NAMESPACES.items()
|
|
)
|
|
by_category: dict[str, list[str]] = {}
|
|
for event, category in _EXACT.items():
|
|
by_category.setdefault(category, []).append(event)
|
|
exact_branches = " ".join(
|
|
f"WHEN {column} IN ({', '.join(repr(event) for event in sorted(events))}) "
|
|
f"THEN '{category}'"
|
|
for category, events in sorted(by_category.items())
|
|
)
|
|
return (
|
|
"(CASE "
|
|
f"WHEN position('.' in {column}) > 0 "
|
|
f"THEN (CASE split_part({column}, '.', 1) {namespace_branches} ELSE 'other' END) "
|
|
f"{exact_branches} "
|
|
"ELSE 'other' END)"
|
|
)
|