mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 22:17:00 +00:00
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.
This commit is contained in:
1 parent
cb5343397f
commit
57b9034f2d
7 files changed
+469
-1
No files matched your search
@@ -9,6 +9,7 @@ from flask_restx import fields, Namespace, Resource
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.audit import record_event
|
||||
from docsgpt.api.pat.rules import filter_listing, mask_agent_key, may_see_agent_keys
|
||||
from docsgpt.guardrails.config import AgentConfig
|
||||
from docsgpt.api.user.base import (
|
||||
@@ -760,6 +761,15 @@ class CreateAgent(Resource):
|
||||
**kwargs,
|
||||
)
|
||||
new_id = str(agent_row["id"])
|
||||
record_event(
|
||||
conn,
|
||||
"agent.created",
|
||||
actor=user,
|
||||
agent_id=new_id,
|
||||
name=data["name"],
|
||||
status=data["status"],
|
||||
agent_type=agent_type,
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error creating agent: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
@@ -1284,6 +1294,15 @@ class UpdateAgent(Resource):
|
||||
updated = bool(result)
|
||||
else:
|
||||
updated = agents_repo.update(pg_agent_id, user, update_fields)
|
||||
if updated:
|
||||
record_event(
|
||||
conn,
|
||||
"agent.updated",
|
||||
actor=user,
|
||||
agent_id=pg_agent_id,
|
||||
# Names only: values can hold prompts and credentials.
|
||||
fields=sorted(update_fields),
|
||||
)
|
||||
if not updated:
|
||||
return make_response(
|
||||
jsonify(
|
||||
@@ -1403,6 +1422,13 @@ class RegenerateAgentKey(Resource):
|
||||
SharedConversationsRepository(conn).reassign_api_key(
|
||||
old_key=old_key, new_key=new_key
|
||||
)
|
||||
record_event(
|
||||
conn,
|
||||
"agent.key_regenerated",
|
||||
actor=user,
|
||||
agent_id=pg_agent_id,
|
||||
name=existing_agent.get("name"),
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error regenerating agent key: {err}", exc_info=True
|
||||
@@ -1457,6 +1483,14 @@ class DeleteAgent(Resource):
|
||||
agents_repo.delete(pg_agent_id, user)
|
||||
# Strip pinned/shared entries for this agent from the owner's prefs.
|
||||
UsersRepository(conn).remove_agent_from_all(user, pg_agent_id)
|
||||
record_event(
|
||||
conn,
|
||||
"agent.deleted",
|
||||
actor=user,
|
||||
agent_id=pg_agent_id,
|
||||
name=agent.get("name"),
|
||||
agent_type=agent.get("agent_type"),
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error deleting agent: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
|
||||
@@ -7,6 +7,7 @@ from flask_restx import fields, Namespace, Resource
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.audit import record_event
|
||||
from docsgpt.api.answer.services.conversation_service import (
|
||||
TERMINATED_RESPONSE_PLACEHOLDER,
|
||||
)
|
||||
@@ -45,6 +46,12 @@ class DeleteConversation(Resource):
|
||||
conv = repo.get_any(conversation_id, user_id)
|
||||
if conv is not None:
|
||||
repo.delete(str(conv["id"]), user_id)
|
||||
record_event(
|
||||
conn,
|
||||
"conversation.deleted",
|
||||
actor=user_id,
|
||||
conversation_id=str(conv["id"]),
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error deleting conversation: {err}", exc_info=True
|
||||
@@ -65,7 +72,13 @@ class DeleteAllConversations(Resource):
|
||||
user_id = decoded_token.get("sub")
|
||||
try:
|
||||
with db_session() as conn:
|
||||
ConversationsRepository(conn).delete_all_for_user(user_id)
|
||||
deleted = ConversationsRepository(conn).delete_all_for_user(user_id)
|
||||
record_event(
|
||||
conn,
|
||||
"conversation.deleted_all",
|
||||
actor=user_id,
|
||||
deleted=deleted,
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error deleting all conversations: {err}", exc_info=True
|
||||
|
||||
@@ -10,6 +10,7 @@ from pydantic import ValidationError
|
||||
|
||||
from docsgpt.agents.tools.path_utils import validate_tool_path
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.audit import record_event
|
||||
from docsgpt.api.pat.rules import filter_listing
|
||||
from docsgpt.api.user.tasks import (
|
||||
convert_source_to_wiki,
|
||||
@@ -280,6 +281,13 @@ class DeleteOldIndexes(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
SourcesRepository(conn).delete(resolved_id, user)
|
||||
record_event(
|
||||
conn,
|
||||
"source.deleted",
|
||||
actor=user,
|
||||
source_id=resolved_id,
|
||||
name=doc.get("name"),
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error deleting source row: {err}", exc_info=True
|
||||
@@ -500,6 +508,23 @@ class ReingestSource(Resource):
|
||||
exc_info=True,
|
||||
)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
try:
|
||||
with db_session() as conn:
|
||||
record_event(
|
||||
conn,
|
||||
"source.reingested",
|
||||
actor=user,
|
||||
source_id=resolved_source_id,
|
||||
name=doc.get("name"),
|
||||
# A team editor can reingest a source they do not own.
|
||||
owner=owner if owner != user else None,
|
||||
task_id=task.id,
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.warning(
|
||||
f"Could not audit reingest for {resolved_source_id}: {err}",
|
||||
exc_info=True,
|
||||
)
|
||||
return make_response(jsonify({"success": True, "task_id": task.id}), 200)
|
||||
|
||||
|
||||
@@ -811,6 +836,14 @@ class CreateWikiSource(Resource):
|
||||
updated_via="human",
|
||||
)
|
||||
rebuild_wiki_directory_structure(conn, source_id, user)
|
||||
record_event(
|
||||
conn,
|
||||
"source.created",
|
||||
actor=user,
|
||||
source_id=source_id,
|
||||
name=name,
|
||||
type="wiki",
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error creating wiki source: {err}", exc_info=True
|
||||
|
||||
@@ -11,6 +11,7 @@ from flask_restx import fields, Namespace, Resource
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.audit import record_event
|
||||
from docsgpt.api.user.tasks import ingest, ingest_connector_task, ingest_remote
|
||||
from docsgpt.api.user.team_sharing import effective_write_owner
|
||||
from docsgpt.core.settings import settings
|
||||
@@ -400,6 +401,24 @@ class UploadFile(Resource):
|
||||
# ``source_id``; the worker uses it verbatim for every SSE event,
|
||||
# so the frontend can correlate inbound ``source.ingest.*`` to
|
||||
# this upload regardless of whether an idempotency key was set.
|
||||
# Audited here, not in the worker: this is the user action. The
|
||||
# ingest may still fail, which the source's own status records.
|
||||
try:
|
||||
with db_session() as conn:
|
||||
record_event(
|
||||
conn,
|
||||
"source.created",
|
||||
actor=user,
|
||||
source_id=str(source_uuid),
|
||||
name=job_name,
|
||||
type="local",
|
||||
task_id=response_task_id,
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.warning(
|
||||
"Could not audit upload for source %s: %s", source_uuid, err,
|
||||
exc_info=True,
|
||||
)
|
||||
response_payload: dict = {
|
||||
"success": True,
|
||||
"task_id": response_task_id,
|
||||
|
||||
Reference in new issue
Block a user