feat(admin): merged activity feed with filters and export

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.
This commit is contained in:
arc53-machine committed 2026-09-22 10:23:39 +01:00
1 parent 57b9034f2d
commit 92c05089c7
8 files changed
+1096 -55

No files matched your search

+4 -52
View File
@@ -1,4 +1,4 @@
"""Shared audit-trail helper and the event → category map.
"""Shared audit-trail helper for recording data-plane and identity events.
Identity and access events (login, role grants, provisioning) have been
audited since ``auth_events`` was introduced. Data-plane actions — creating and
@@ -23,61 +23,13 @@ from typing import Any, Optional
from flask import has_request_context, request
from docsgpt.audit_events import ACTIVITY_CATEGORIES, category_for
from docsgpt.storage.db.repositories.auth_events import AuthEventsRepository
logger = logging.getLogger(__name__)
# Coarse grouping for the admin activity feed's facet filter. Keyed by exact
# event name or by a ``prefix.`` namespace; see :func:`category_for`.
EVENT_CATEGORIES: 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, team membership and sharing.
"role_granted": "access",
"role_revoked": "access",
"admin_user_activated": "access",
"admin_user_deactivated": "access",
"admin_sessions_revoked": "access",
"team.": "access",
# How the instance is configured.
"quota_policy_set": "config",
"quota_policy_deleted": "config",
# What the data looks like.
"source.": "data",
"agent.": "data",
"conversation.": "data",
# Everything an older release wrote that this one does not know about.
"other": "other",
}
def category_for(event: str) -> str:
"""Return the activity category for ``event``.
Args:
event: The audit event name.
Returns:
One of ``identity``, ``access``, ``config``, ``data``, or ``other``
for names this release does not recognise.
"""
exact = EVENT_CATEGORIES.get(event)
if exact is not None and not event.endswith("."):
return exact
namespace, dot, _ = event.partition(".")
if dot:
return EVENT_CATEGORIES.get(f"{namespace}.", "other")
return "other"
# Re-exported so route modules have one import for recording and classifying.
__all__ = ["ACTIVITY_CATEGORIES", "category_for", "record_event"]
def record_event(