mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 16:13:23 +00:00
- 0034's backfill set target_id to the acting admin for instance- and team-scoped quota policy changes, which are filed under the actor and have no user target. It now returns NULL for those, matching what quotas.py records going forward, with a test covering all three scopes. - The CSV export wrote every cell verbatim. user_agent is an attacker's raw header, recorded without authenticating on a denied login, and the export is opened in a spreadsheet by an admin -- a cell starting "=" would be evaluated there. Every cell now has a leading formula trigger neutralized. - The activity feed applied every response it received, so a slow reply for an old filter could overwrite the current one. Guarded by a request id, the same way settings/Analytics already does. - The activity detail expander and the top-user drill-down were row onClick handlers, unreachable without a mouse. Both are real buttons now, the expander carrying aria-expanded and a label naming its event. - FLOW_LABELS was applied to both breakdowns in the spend modal, so a model named "fallback" or "workflow" rendered as a flow description. Only the flow table maps keys now. - Changing the range in that modal left the previous range's totals and chart on screen while the new request was in flight.
256 lines
8.8 KiB
Python
256 lines
8.8 KiB
Python
"""Admin activity feed: one timeline over all three audit journals.
|
|
|
|
``GET /api/admin/audit`` remains the ``auth_events``-only feed. These endpoints
|
|
add the merged view an incident review actually wants — identity, access,
|
|
configuration, data-plane, remote-device and guardrail activity in one ordered
|
|
list — plus the filters and the export that make a review possible without
|
|
shelling into the database.
|
|
|
|
Every resource here is ``@admin_required``, like the rest of the namespace.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import io
|
|
import json
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Iterator, Optional
|
|
|
|
from flask import Response, jsonify, make_response, request, stream_with_context
|
|
from flask_restx import Resource
|
|
|
|
from docsgpt.api.admin.routes import _int_arg, _page, admin_ns
|
|
from docsgpt.api.user.authz import admin_required
|
|
from docsgpt.audit_events import ACTIVITY_CATEGORIES
|
|
from docsgpt.storage.db.repositories.activity import (
|
|
ACTIVITY_COLUMNS,
|
|
ActivityRepository,
|
|
)
|
|
from docsgpt.storage.db.session import db_readonly
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_FEEDS = ("auth", "device", "guardrail")
|
|
_EXPORT_FORMATS = ("csv", "ndjson")
|
|
|
|
# An export runs against a live instance, so it is bounded. Operators needing
|
|
# the full history take a database dump, not an HTTP request.
|
|
_EXPORT_MAX_ROWS = 100_000
|
|
_EXPORT_CHUNK = 1_000
|
|
|
|
_MAX_SEARCH_LENGTH = 200
|
|
|
|
|
|
def _facet_list(name: str, allowed: tuple[str, ...]) -> Optional[list[str]]:
|
|
"""Parse a repeated/comma-separated query arg, dropping unknown values.
|
|
|
|
Args:
|
|
name: Query parameter name.
|
|
allowed: The permitted values; anything else is ignored rather than
|
|
rejected, so an old bookmark degrades instead of erroring.
|
|
|
|
Returns:
|
|
The distinct valid values in the order given, or None when absent.
|
|
"""
|
|
raw: list[str] = []
|
|
for value in request.args.getlist(name):
|
|
raw.extend(part.strip() for part in value.split(","))
|
|
seen: list[str] = []
|
|
for value in raw:
|
|
if value in allowed and value not in seen:
|
|
seen.append(value)
|
|
return seen or None
|
|
|
|
|
|
def _event_list() -> Optional[list[str]]:
|
|
"""Event names to filter on. Unconstrained — the catalogue is data, not an enum."""
|
|
names: list[str] = []
|
|
for value in request.args.getlist("event"):
|
|
for part in value.split(","):
|
|
part = part.strip()
|
|
if part and part not in names:
|
|
names.append(part)
|
|
return names or None
|
|
|
|
|
|
def _time_arg(name: str) -> Optional[datetime]:
|
|
"""Parse an ISO-8601 timestamp query arg, or None when absent/unparseable."""
|
|
raw = (request.args.get(name) or "").strip()
|
|
if not raw:
|
|
return None
|
|
try:
|
|
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return None
|
|
# A naive bound is read as UTC, matching how the feed stores timestamps.
|
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
def _search_arg() -> Optional[str]:
|
|
value = (request.args.get("search") or "").strip()
|
|
return value[:_MAX_SEARCH_LENGTH] or None
|
|
|
|
|
|
def _filters() -> dict:
|
|
"""The filter set shared by the feed and the export."""
|
|
return {
|
|
"feeds": _facet_list("feed", _FEEDS),
|
|
"categories": _facet_list("category", ACTIVITY_CATEGORIES),
|
|
"events": _event_list(),
|
|
"actor_id": request.args.get("actor_id") or None,
|
|
"user_id": request.args.get("user_id") or None,
|
|
"since": _time_arg("since"),
|
|
"until": _time_arg("until"),
|
|
"search": _search_arg(),
|
|
}
|
|
|
|
|
|
def _serialize(row: dict) -> dict:
|
|
"""Make one feed row JSON-safe (timestamps to ISO-8601)."""
|
|
created = row.get("created_at")
|
|
return {
|
|
**row,
|
|
"created_at": created.isoformat() if hasattr(created, "isoformat") else created,
|
|
}
|
|
|
|
|
|
@admin_ns.route("/admin/activity")
|
|
class AdminActivityResource(Resource):
|
|
@admin_required
|
|
def get(self):
|
|
"""Merged audit feed, newest first, with filters and a total."""
|
|
page, page_size, offset = _page()
|
|
filters = _filters()
|
|
with db_readonly() as conn:
|
|
repo = ActivityRepository(conn)
|
|
total = repo.count(**filters)
|
|
rows = repo.list(limit=page_size, offset=offset, **filters)
|
|
return make_response(
|
|
jsonify(
|
|
{
|
|
"success": True,
|
|
"activity": [_serialize(row) for row in rows],
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"total": total,
|
|
"has_more": offset + len(rows) < total,
|
|
}
|
|
),
|
|
200,
|
|
)
|
|
|
|
|
|
@admin_ns.route("/admin/activity/events")
|
|
class AdminActivityCatalogueResource(Resource):
|
|
@admin_required
|
|
def get(self):
|
|
"""Distinct ``(event, category)`` pairs this instance has recorded.
|
|
|
|
The filter UI offers these instead of a free-text box, so an operator
|
|
never has to guess that denied logins are ``oidc_login_denied``.
|
|
"""
|
|
with db_readonly() as conn:
|
|
catalogue = ActivityRepository(conn).event_names()
|
|
return make_response(
|
|
jsonify(
|
|
{
|
|
"success": True,
|
|
"events": catalogue,
|
|
"categories": list(ACTIVITY_CATEGORIES),
|
|
"feeds": list(_FEEDS),
|
|
}
|
|
),
|
|
200,
|
|
)
|
|
|
|
|
|
@admin_ns.route("/admin/activity/export")
|
|
class AdminActivityExportResource(Resource):
|
|
@admin_required
|
|
def get(self):
|
|
"""Stream the filtered feed as CSV or NDJSON.
|
|
|
|
Streamed, not buffered: a compliance export of a busy instance should
|
|
not be built in memory first. Capped at ``_EXPORT_MAX_ROWS``, and the
|
|
``X-Export-Max-Rows`` header reports the cap that was in effect so a
|
|
truncated export is not mistaken for a complete one.
|
|
"""
|
|
fmt = (request.args.get("format") or "csv").lower()
|
|
if fmt not in _EXPORT_FORMATS:
|
|
return make_response(
|
|
jsonify({"success": False, "message": "Invalid format"}), 400
|
|
)
|
|
limit = max(1, min(_EXPORT_MAX_ROWS, _int_arg("max_rows", _EXPORT_MAX_ROWS)))
|
|
filters = _filters()
|
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
generator = _csv_rows if fmt == "csv" else _ndjson_rows
|
|
return Response(
|
|
stream_with_context(generator(filters, limit)),
|
|
mimetype="text/csv" if fmt == "csv" else "application/x-ndjson",
|
|
headers={
|
|
"Content-Disposition": (
|
|
f'attachment; filename="docsgpt-activity-{stamp}.{fmt}"'
|
|
),
|
|
# The cap in effect. Without it a truncated export reads as
|
|
# a complete one.
|
|
"X-Export-Max-Rows": str(limit),
|
|
"Cache-Control": "no-store",
|
|
},
|
|
)
|
|
|
|
|
|
def _export_rows(filters: dict, limit: int) -> Iterator[dict]:
|
|
"""Walk the filtered feed, holding the connection open for the whole stream."""
|
|
with db_readonly() as conn:
|
|
yield from ActivityRepository(conn).iter_all(
|
|
chunk_size=_EXPORT_CHUNK, max_rows=limit, **filters
|
|
)
|
|
|
|
|
|
# Characters a spreadsheet reads as the start of a formula.
|
|
_FORMULA_TRIGGERS = ("=", "+", "-", "@", "\t", "\r")
|
|
|
|
|
|
def _csv_safe(value: object) -> object:
|
|
"""Neutralize a leading formula trigger before the value becomes a CSV cell.
|
|
|
|
The feed carries attacker-controlled text: ``user_agent`` is the raw header
|
|
of whoever made the request, and a denied login records one without ever
|
|
authenticating. An export is opened in a spreadsheet by an admin, so a cell
|
|
beginning ``=`` would be evaluated there. Prefixing an apostrophe keeps the
|
|
value readable and inert.
|
|
"""
|
|
if isinstance(value, str) and value.startswith(_FORMULA_TRIGGERS):
|
|
return f"'{value}"
|
|
return value
|
|
|
|
|
|
def _csv_rows(filters: dict, limit: int) -> Iterator[str]:
|
|
buffer = io.StringIO()
|
|
writer = csv.DictWriter(buffer, fieldnames=list(ACTIVITY_COLUMNS))
|
|
writer.writeheader()
|
|
yield _drain(buffer)
|
|
for row in _export_rows(filters, limit):
|
|
serialized = _serialize(row)
|
|
# ``detail`` is a JSON object; a CSV cell holds its compact encoding.
|
|
serialized["detail"] = json.dumps(
|
|
serialized.get("detail") or {}, default=str
|
|
)
|
|
writer.writerow({key: _csv_safe(value) for key, value in serialized.items()})
|
|
yield _drain(buffer)
|
|
|
|
|
|
def _ndjson_rows(filters: dict, limit: int) -> Iterator[str]:
|
|
for row in _export_rows(filters, limit):
|
|
yield json.dumps(_serialize(row), default=str) + "\n"
|
|
|
|
|
|
def _drain(buffer: io.StringIO) -> str:
|
|
"""Return and clear the writer's buffer, so each row streams as written."""
|
|
value = buffer.getvalue()
|
|
buffer.seek(0)
|
|
buffer.truncate(0)
|
|
return value
|