mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-15 06:15:57 +00:00
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:
1 parent
57b9034f2d
commit
92c05089c7
8 files changed
+1096
-55
No files matched your search
@@ -0,0 +1,236 @@
|
||||
"""Endpoint tests for the merged admin activity feed, its filters and export.
|
||||
|
||||
The repository's SQL is covered by
|
||||
tests/storage/db/repositories/test_activity.py; these pin the route wiring,
|
||||
the @admin_required boundary, query-argument parsing and the export framing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from docsgpt.app import app as flask_app
|
||||
|
||||
flask_app.config["TESTING"] = True
|
||||
return flask_app.test_client()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _fake_conn():
|
||||
yield Mock()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _admin(repo: Mock):
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch("docsgpt.app.handle_auth", return_value={"sub": "admin1"})
|
||||
)
|
||||
stack.enter_context(
|
||||
patch("docsgpt.app.resolve_roles", return_value=["admin", "user"])
|
||||
)
|
||||
stack.enter_context(patch("docsgpt.api.admin.activity.db_readonly", _fake_conn))
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"docsgpt.api.admin.activity.ActivityRepository", return_value=repo
|
||||
)
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
def _row(**overrides):
|
||||
base = {
|
||||
"feed": "auth",
|
||||
"id": "1",
|
||||
"event": "oidc_login",
|
||||
"category": "identity",
|
||||
"actor_id": "u1",
|
||||
"target_id": "u1",
|
||||
"ip": "203.0.113.1",
|
||||
"user_agent": "curl/8",
|
||||
"outcome": None,
|
||||
"detail": {"via": "oidc"},
|
||||
"created_at": datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc),
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _repo(rows=None, total=None):
|
||||
repo = Mock()
|
||||
rows = [_row()] if rows is None else rows
|
||||
repo.list.return_value = rows
|
||||
repo.count.return_value = len(rows) if total is None else total
|
||||
repo.iter_all.return_value = iter(rows)
|
||||
repo.event_names.return_value = [
|
||||
{"event": "oidc_login", "category": "identity"},
|
||||
]
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGuard:
|
||||
def test_non_admin_forbidden(self, client):
|
||||
with patch("docsgpt.app.handle_auth", return_value={"sub": "u"}), patch(
|
||||
"docsgpt.app.resolve_roles", return_value=["user"]
|
||||
):
|
||||
assert client.get("/api/admin/activity").status_code == 403
|
||||
assert client.get("/api/admin/activity/export").status_code == 403
|
||||
assert client.get("/api/admin/activity/events").status_code == 403
|
||||
|
||||
def test_unauthenticated(self, client):
|
||||
with patch("docsgpt.app.handle_auth", return_value=None):
|
||||
assert client.get("/api/admin/activity").status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFeed:
|
||||
def test_returns_rows_with_iso_timestamps(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
body = json.loads(client.get("/api/admin/activity").data)
|
||||
assert body["success"] is True
|
||||
assert body["activity"][0]["created_at"] == "2026-01-02T03:04:05+00:00"
|
||||
assert body["total"] == 1
|
||||
assert body["has_more"] is False
|
||||
|
||||
def test_has_more_when_the_page_is_short_of_the_total(self, client):
|
||||
repo = _repo(total=500)
|
||||
with _admin(repo):
|
||||
body = json.loads(client.get("/api/admin/activity").data)
|
||||
assert body["has_more"] is True
|
||||
|
||||
def test_filters_reach_the_repository(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
client.get(
|
||||
"/api/admin/activity?feed=auth,device&category=data"
|
||||
"&event=source.deleted&actor_id=admin1&user_id=u2"
|
||||
"&since=2026-01-01T00:00:00Z&until=2026-02-01&search=handbook"
|
||||
)
|
||||
kwargs = repo.list.call_args.kwargs
|
||||
assert kwargs["feeds"] == ["auth", "device"]
|
||||
assert kwargs["categories"] == ["data"]
|
||||
assert kwargs["events"] == ["source.deleted"]
|
||||
assert kwargs["actor_id"] == "admin1"
|
||||
assert kwargs["user_id"] == "u2"
|
||||
assert kwargs["since"] == datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
# A naive bound is read as UTC.
|
||||
assert kwargs["until"] == datetime(2026, 2, 1, tzinfo=timezone.utc)
|
||||
assert kwargs["search"] == "handbook"
|
||||
|
||||
def test_unknown_facet_values_are_ignored_not_rejected(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
resp = client.get("/api/admin/activity?feed=bogus&category=bogus")
|
||||
assert resp.status_code == 200
|
||||
kwargs = repo.list.call_args.kwargs
|
||||
assert kwargs["feeds"] is None and kwargs["categories"] is None
|
||||
|
||||
def test_unparseable_time_is_ignored(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
assert client.get("/api/admin/activity?since=nonsense").status_code == 200
|
||||
assert repo.list.call_args.kwargs["since"] is None
|
||||
|
||||
def test_repeated_params_accumulate(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
client.get("/api/admin/activity?category=data&category=access")
|
||||
assert repo.list.call_args.kwargs["categories"] == ["data", "access"]
|
||||
|
||||
def test_search_is_length_capped(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
client.get("/api/admin/activity?search=" + "x" * 5000)
|
||||
assert len(repo.list.call_args.kwargs["search"]) == 200
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCatalogue:
|
||||
def test_lists_events_categories_and_feeds(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
body = json.loads(client.get("/api/admin/activity/events").data)
|
||||
assert body["events"] == [{"event": "oidc_login", "category": "identity"}]
|
||||
assert "identity" in body["categories"]
|
||||
assert body["feeds"] == ["auth", "device", "guardrail"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestExport:
|
||||
"""The export streams lazily, so every test consumes it inside the patch."""
|
||||
|
||||
def test_csv_has_a_header_and_one_row_per_event(self, client):
|
||||
repo = _repo(rows=[_row(), _row(id="2", event="pat_created")])
|
||||
with _admin(repo):
|
||||
resp = client.get("/api/admin/activity/export?format=csv")
|
||||
body = resp.get_data(as_text=True)
|
||||
assert resp.status_code == 200
|
||||
assert resp.mimetype == "text/csv"
|
||||
assert "attachment; filename=" in resp.headers["Content-Disposition"]
|
||||
rows = list(csv.DictReader(io.StringIO(body)))
|
||||
assert [row["event"] for row in rows] == ["oidc_login", "pat_created"]
|
||||
# ``detail`` is a JSON object, encoded into the cell.
|
||||
assert json.loads(rows[0]["detail"]) == {"via": "oidc"}
|
||||
|
||||
def test_ndjson_emits_one_object_per_line(self, client):
|
||||
repo = _repo(rows=[_row(), _row(id="2")])
|
||||
with _admin(repo):
|
||||
resp = client.get("/api/admin/activity/export?format=ndjson")
|
||||
body = resp.get_data(as_text=True)
|
||||
assert resp.mimetype == "application/x-ndjson"
|
||||
lines = body.strip().split("\n")
|
||||
assert [json.loads(line)["id"] for line in lines] == ["1", "2"]
|
||||
|
||||
def test_empty_csv_export_still_has_its_header(self, client):
|
||||
with _admin(_repo(rows=[])):
|
||||
body = client.get("/api/admin/activity/export").get_data(as_text=True)
|
||||
assert body.strip().startswith("feed,id,event,category")
|
||||
|
||||
def test_rejects_an_unknown_format(self, client):
|
||||
with _admin(_repo()):
|
||||
assert (
|
||||
client.get("/api/admin/activity/export?format=xlsx").status_code == 400
|
||||
)
|
||||
|
||||
def test_export_applies_the_same_filters(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
client.get(
|
||||
"/api/admin/activity/export?category=data&search=q"
|
||||
).get_data()
|
||||
kwargs = repo.iter_all.call_args.kwargs
|
||||
assert kwargs["categories"] == ["data"]
|
||||
assert kwargs["search"] == "q"
|
||||
|
||||
def test_row_cap_is_bounded_and_reported(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
resp = client.get("/api/admin/activity/export?max_rows=10")
|
||||
resp.get_data()
|
||||
assert resp.headers["X-Export-Max-Rows"] == "10"
|
||||
assert repo.iter_all.call_args.kwargs["max_rows"] == 10
|
||||
|
||||
def test_an_absurd_cap_clamps_to_the_ceiling(self, client):
|
||||
repo = _repo()
|
||||
with _admin(repo):
|
||||
resp = client.get("/api/admin/activity/export?max_rows=999999999")
|
||||
resp.get_data()
|
||||
assert resp.headers["X-Export-Max-Rows"] == "100000"
|
||||
|
||||
def test_export_is_not_cached(self, client):
|
||||
with _admin(_repo()):
|
||||
resp = client.get("/api/admin/activity/export")
|
||||
resp.get_data()
|
||||
assert resp.headers["Cache-Control"] == "no-store"
|
||||
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from docsgpt.api.audit import EVENT_CATEGORIES, category_for, record_event
|
||||
from docsgpt.api.audit import ACTIVITY_CATEGORIES, category_for, record_event
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -102,5 +102,22 @@ class TestCategories:
|
||||
def test_unknown_event_is_other(self):
|
||||
assert category_for("something_new") == "other"
|
||||
|
||||
def test_every_category_is_declared(self):
|
||||
assert "other" in EVENT_CATEGORIES
|
||||
@pytest.mark.parametrize(
|
||||
"event,expected",
|
||||
[("device.run_command", "device"), ("guardrail.input", "safety")],
|
||||
)
|
||||
def test_merged_feed_namespaces(self, event, expected):
|
||||
assert category_for(event) == expected
|
||||
|
||||
def test_every_produced_category_is_declared(self):
|
||||
assert "other" in ACTIVITY_CATEGORIES
|
||||
for event in (
|
||||
"oidc_login",
|
||||
"role_granted",
|
||||
"quota_policy_set",
|
||||
"source.deleted",
|
||||
"device.run_command",
|
||||
"guardrail.input",
|
||||
"mystery",
|
||||
):
|
||||
assert category_for(event) in ACTIVITY_CATEGORIES
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Repository tests for the merged admin activity feed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from docsgpt.storage.db.repositories.activity import (
|
||||
ACTIVITY_COLUMNS,
|
||||
ActivityRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.auth_events import AuthEventsRepository
|
||||
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def seeded(pg_conn):
|
||||
"""One row in each journal, oldest to newest: guardrail, device, auth."""
|
||||
now = datetime.now(timezone.utc)
|
||||
pg_conn.execute(
|
||||
text(
|
||||
"INSERT INTO guardrail_events "
|
||||
"(user_id, api_key, stage, check_name, detector_type, action, "
|
||||
" outcome, matched_value, created_at) VALUES "
|
||||
"('u-guard', 'sk-secret', 'input', 'pii', 'regex', 'block', "
|
||||
" 'blocked', 'alex@example.com', :ts)"
|
||||
),
|
||||
{"ts": now - timedelta(minutes=10)},
|
||||
)
|
||||
pg_conn.execute(
|
||||
text(
|
||||
"INSERT INTO devices "
|
||||
"(id, user_id, name, machine_pubkey_fingerprint, token_hash, status) "
|
||||
"VALUES ('dev-1', 'u-dev', 'laptop', 'fp-1', 'hash-1', 'active')"
|
||||
)
|
||||
)
|
||||
pg_conn.execute(
|
||||
text(
|
||||
"INSERT INTO device_audit_log "
|
||||
"(device_id, user_id, invocation_id, action, command, "
|
||||
" approval_mode, decision, issued_at, created_at) VALUES "
|
||||
"('dev-1', 'u-dev', 'inv-1', 'run_command', 'ls -la', "
|
||||
" 'ask', 'allowed', :ts, :ts)"
|
||||
),
|
||||
{"ts": now - timedelta(minutes=5)},
|
||||
)
|
||||
AuthEventsRepository(pg_conn).insert(
|
||||
"victim", "admin_user_deactivated", ip="203.0.113.9",
|
||||
actor_id="admin-1", target_id="victim",
|
||||
)
|
||||
return pg_conn
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def repo(seeded):
|
||||
return ActivityRepository(seeded)
|
||||
|
||||
|
||||
class TestMergedFeed:
|
||||
def test_spans_all_three_journals_newest_first(self, repo):
|
||||
rows = repo.list()
|
||||
assert [row["feed"] for row in rows] == ["auth", "device", "guardrail"]
|
||||
assert repo.count() == 3
|
||||
|
||||
def test_every_row_has_the_common_shape(self, repo):
|
||||
for row in repo.list():
|
||||
assert set(row) == set(ACTIVITY_COLUMNS)
|
||||
|
||||
def test_side_journals_get_namespaced_event_names(self, repo):
|
||||
events = {row["feed"]: row["event"] for row in repo.list()}
|
||||
assert events["device"] == "device.run_command"
|
||||
assert events["guardrail"] == "guardrail.input"
|
||||
|
||||
def test_categories_are_derived(self, repo):
|
||||
rows = {row["feed"]: row["category"] for row in repo.list()}
|
||||
assert rows == {
|
||||
"auth": "access",
|
||||
"device": "device",
|
||||
"guardrail": "safety",
|
||||
}
|
||||
|
||||
def test_outcome_carries_the_side_journal_verdict(self, repo):
|
||||
rows = {row["feed"]: row["outcome"] for row in repo.list()}
|
||||
assert rows["device"] == "allowed"
|
||||
assert rows["guardrail"] == "blocked"
|
||||
assert rows["auth"] is None
|
||||
|
||||
|
||||
class TestSecretExclusion:
|
||||
def test_guardrail_secrets_never_reach_the_feed(self, repo):
|
||||
"""``api_key`` is a raw agent key and ``matched_value`` is source text."""
|
||||
row = next(r for r in repo.list() if r["feed"] == "guardrail")
|
||||
serialized = str(row)
|
||||
assert "sk-secret" not in serialized
|
||||
assert "alex@example.com" not in serialized
|
||||
|
||||
|
||||
class TestFilters:
|
||||
def test_by_feed(self, repo):
|
||||
assert [r["feed"] for r in repo.list(feeds=["device"])] == ["device"]
|
||||
|
||||
def test_by_category(self, repo):
|
||||
rows = repo.list(categories=["safety"])
|
||||
assert [r["feed"] for r in rows] == ["guardrail"]
|
||||
assert repo.count(categories=["safety"]) == 1
|
||||
|
||||
def test_by_category_spanning_auth_and_a_side_journal(self, repo):
|
||||
rows = repo.list(categories=["access", "device"])
|
||||
assert {r["feed"] for r in rows} == {"auth", "device"}
|
||||
|
||||
def test_by_actor(self, repo):
|
||||
assert [r["actor_id"] for r in repo.list(actor_id="admin-1")] == ["admin-1"]
|
||||
|
||||
def test_by_user_matches_actor_or_target(self, repo):
|
||||
assert repo.count(user_id="victim") == 1
|
||||
assert repo.count(user_id="admin-1") == 1
|
||||
|
||||
def test_by_event(self, repo):
|
||||
assert repo.count(events=["device.run_command"]) == 1
|
||||
|
||||
def test_by_time_window(self, repo):
|
||||
now = datetime.now(timezone.utc)
|
||||
assert repo.count(since=now - timedelta(minutes=7)) == 2
|
||||
assert repo.count(until=now - timedelta(minutes=7)) == 1
|
||||
|
||||
def test_search_spans_detail(self, repo):
|
||||
assert repo.count(search="ls -la") == 1
|
||||
assert repo.count(search="203.0.113") == 1
|
||||
assert repo.count(search="nope") == 0
|
||||
|
||||
def test_unknown_feed_yields_nothing(self, repo):
|
||||
assert repo.list(feeds=["made-up"]) == []
|
||||
assert repo.count(feeds=["made-up"]) == 0
|
||||
|
||||
def test_category_no_journal_can_produce(self, repo):
|
||||
assert repo.count(feeds=["device"], categories=["data"]) == 0
|
||||
|
||||
|
||||
class TestPagination:
|
||||
def test_limit_and_offset(self, repo):
|
||||
assert len(repo.list(limit=2)) == 2
|
||||
assert [r["feed"] for r in repo.list(limit=2, offset=2)] == ["guardrail"]
|
||||
|
||||
|
||||
class TestExportStream:
|
||||
def test_iter_all_walks_every_row(self, repo):
|
||||
assert len(list(repo.iter_all(chunk_size=1))) == 3
|
||||
|
||||
def test_iter_all_honours_max_rows(self, repo):
|
||||
assert len(list(repo.iter_all(chunk_size=1, max_rows=2))) == 2
|
||||
|
||||
def test_iter_all_applies_filters(self, repo):
|
||||
rows = list(repo.iter_all(chunk_size=1, feeds=["auth"]))
|
||||
assert [r["feed"] for r in rows] == ["auth"]
|
||||
|
||||
|
||||
class TestCatalogue:
|
||||
def test_event_names_are_paired_with_categories(self, repo):
|
||||
catalogue = repo.event_names()
|
||||
assert {"event": "device.run_command", "category": "device"} in catalogue
|
||||
assert {"event": "admin_user_deactivated", "category": "access"} in catalogue
|
||||
Reference in new issue
Block a user