Files
DocsGPT/tests/api/test_data_plane_audit.py
T
arc53-machine 57b9034f2d feat(audit): record data-plane events, not just identity ones
Logins, role grants and provisioning were audited from the first release;
creating and deleting sources, agents, agent keys and conversations were not.
An operator reviewing the trail could see who signed in but not who deleted
the source they were asking about.

Adds docsgpt/api/audit.py: one helper that records an event inside the
caller's transaction, in a savepoint, swallowing failures -- an audit write
must never be able to fail the action it describes -- and tolerating the
absence of a Flask request so a Celery task can record too.

Events added: source.created (upload and wiki), source.deleted,
source.reingested, agent.created, agent.updated, agent.deleted,
agent.key_regenerated, conversation.deleted, conversation.deleted_all.
agent.updated records field names only, never values, which can carry prompts
and credentials.

The same module carries the event -> category map (identity / access / config
/ data) that the admin activity feed filters on.
2026-09-22 10:18:22 +01:00

143 lines
5.1 KiB
Python

"""The data-plane routes must leave an audit trail.
Identity events were audited from the start; source/agent/conversation
mutations were not, so "who deleted that source" had no answer. These pin the
hooks at the route layer — the repositories are mocked, what is asserted is
that the audit row is written with the acting user and the resource id.
"""
from __future__ import annotations
from contextlib import ExitStack, contextmanager
from unittest.mock import MagicMock, 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 _authed(module: str, **patches):
"""Authenticate as ``u1`` and patch names inside ``module``."""
recorded: list[tuple] = []
def _record(_conn, event, *, actor, target=None, **metadata):
recorded.append((event, actor, target, metadata))
@contextmanager
def _conn():
yield MagicMock()
with ExitStack() as stack:
stack.enter_context(
patch("docsgpt.app.handle_auth", return_value={"sub": "u1"})
)
stack.enter_context(patch("docsgpt.app.resolve_roles", return_value=["user"]))
stack.enter_context(patch(f"{module}.db_session", _conn))
stack.enter_context(patch(f"{module}.db_readonly", _conn))
stack.enter_context(patch(f"{module}.record_event", _record))
for name, value in patches.items():
stack.enter_context(patch(f"{module}.{name}", value))
yield recorded
_SOURCES = "docsgpt.api.user.sources.routes"
_AGENTS = "docsgpt.api.user.agents.routes"
_CONVERSATIONS = "docsgpt.api.user.conversations.routes"
@pytest.mark.unit
class TestSourceAudit:
def test_delete_records_source_deleted(self, client):
repo = Mock()
repo.get_any.return_value = {"id": "src-1", "name": "Handbook"}
storage = Mock()
storage.file_exists.return_value = False
with _authed(
_SOURCES,
SourcesRepository=Mock(return_value=repo),
StorageCreator=Mock(get_storage=Mock(return_value=storage)),
) as recorded:
resp = client.get("/api/delete_old?source_id=src-1")
assert resp.status_code == 200
assert recorded[0][:3] == ("source.deleted", "u1", None)
assert recorded[0][3]["source_id"] == "src-1"
assert recorded[0][3]["name"] == "Handbook"
def test_nothing_recorded_when_the_source_is_missing(self, client):
repo = Mock()
repo.get_any.return_value = None
with _authed(_SOURCES, SourcesRepository=Mock(return_value=repo)) as recorded:
resp = client.get("/api/delete_old?source_id=nope")
assert resp.status_code == 404
assert recorded == []
@pytest.mark.unit
class TestAgentAudit:
def test_delete_records_agent_deleted(self, client):
repo = Mock()
repo.get_any.return_value = {
"id": "agent-1",
"name": "Support bot",
"agent_type": "classic",
}
with _authed(
_AGENTS,
AgentsRepository=Mock(return_value=repo),
WorkflowsRepository=Mock(),
UsersRepository=Mock(),
) as recorded:
resp = client.delete("/api/delete_agent?id=agent-1")
assert resp.status_code == 200
assert recorded[0][:3] == ("agent.deleted", "u1", None)
assert recorded[0][3]["agent_id"] == "agent-1"
assert recorded[0][3]["name"] == "Support bot"
def test_missing_agent_records_nothing(self, client):
repo = Mock()
repo.get_any.return_value = None
with _authed(_AGENTS, AgentsRepository=Mock(return_value=repo)) as recorded:
assert client.delete("/api/delete_agent?id=x").status_code == 404
assert recorded == []
@pytest.mark.unit
class TestConversationAudit:
def test_delete_records_conversation_deleted(self, client):
repo = Mock()
repo.get_any.return_value = {"id": "conv-1"}
with _authed(
_CONVERSATIONS, ConversationsRepository=Mock(return_value=repo)
) as recorded:
resp = client.post("/api/delete_conversation?id=conv-1")
assert resp.status_code == 200
assert recorded[0][:3] == ("conversation.deleted", "u1", None)
assert recorded[0][3]["conversation_id"] == "conv-1"
def test_delete_all_records_the_count(self, client):
repo = Mock()
repo.delete_all_for_user.return_value = 7
with _authed(
_CONVERSATIONS, ConversationsRepository=Mock(return_value=repo)
) as recorded:
resp = client.get("/api/delete_all_conversations")
assert resp.status_code == 200
assert recorded[0][0] == "conversation.deleted_all"
assert recorded[0][3]["deleted"] == 7
def test_unknown_conversation_records_nothing(self, client):
repo = Mock()
repo.get_any.return_value = None
with _authed(
_CONVERSATIONS, ConversationsRepository=Mock(return_value=repo)
) as recorded:
assert client.post("/api/delete_conversation?id=x").status_code == 200
assert recorded == []