Files
DocsGPT/docsgpt/api/audit.py
T
arc53-machine 92c05089c7 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.
2026-09-22 10:23:39 +01:00

74 lines
3.0 KiB
Python

"""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
deleting sources, agents, agent keys and conversations — were not, which left
the trail unable to answer "who deleted that source". :func:`record_event` is
the one-line hook those routes use.
Two properties matter and are enforced here rather than at every call site:
1. **An audit write can never fail the action it records.** The insert runs in
a SAVEPOINT so a rejected row rolls back alone, and any exception is
swallowed with a log line.
2. **Request context is optional.** Celery tasks (ingestion finishing, a
scheduled run) have no Flask request; the row simply records without an IP
or user agent instead of raising.
"""
from __future__ import annotations
import logging
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__)
# Re-exported so route modules have one import for recording and classifying.
__all__ = ["ACTIVITY_CATEGORIES", "category_for", "record_event"]
def record_event(
conn,
event: str,
*,
actor: Optional[str],
target: Optional[str] = None,
**metadata: Any,
) -> None:
"""Append one audit event, best-effort, inside the caller's transaction.
Args:
conn: Open database connection, inside the action's transaction so the
audit row commits atomically with the change it describes.
event: Dotted event name (``source.deleted``, ``agent.created``).
actor: Who performed the action; ``"unknown"`` when unauthenticated.
target: The user acted upon, when the event is about a user. Data-plane
events act on a resource, not an account, so they leave this None.
**metadata: Free-form detail for the audit drill-down. ``None`` values
are dropped so the stored object only carries what was known.
"""
detail = {key: value for key, value in metadata.items() if value is not None}
ip = request.remote_addr if has_request_context() else None
user_agent = request.headers.get("User-Agent") if has_request_context() else None
resolved_actor = actor or "unknown"
try:
with conn.begin_nested():
AuthEventsRepository(conn).insert(
# ``user_id`` is the per-user feed key: the target when the
# event is about someone, else the actor's own trail.
user_id=target or resolved_actor,
event=event,
ip=ip,
user_agent=user_agent,
metadata=detail,
actor_id=resolved_actor,
target_id=target,
)
except Exception:
logger.warning("audit insert failed for event=%s", event, exc_info=True)