mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 12:13:05 +00:00
Correctness - /api/remote never recorded source.created, so URL, GitHub and connector sources had a source.deleted with no matching creation. All three creation paths now go through one _audit_source_created helper. - The prompt-cache rate divided cached tokens by a whole bucket's prompt tokens. A bucket is a day and mixes calls whose provider reports a cache breakdown with calls whose provider does not, so filtering buckets in the client could not separate them and the rate was understated by however much traffic ran on a non-reporting provider. The denominator is now computed in SQL over the reporting rows. - The outcome pill matched values nothing writes. Guardrails emit triggered / not_evaluated and the device feed emits dispatched; the map had blocked / denied / allowed, so a guardrail that fired rendered neutral grey -- the one signal the merged feed exists to surface. Fixtures were seeding the fictional values, so the tests passed on it too. - Stream duration_ms timed the consumer. stream_token_usage is a generator, so start-to-exhaustion includes the agent loop's tool handling and the SSE client's pace; a slow browser recorded ~30s for a sub-second call. It now accumulates only the time spent inside next(). Safety - Activity filters failed open: an unknown facet or unparseable timestamp was dropped, and no filter means every row, so a typo widened an audit view and on the export streamed the full history. Both are now a 400. - The search term was interpolated into an ILIKE pattern, so "100%" matched everything and "q1_report" matched more than it should. Escaped. - 0034 set actor_id NOT NULL with no default. A previous-release process inserting mid-rollout would raise, and in admin/routes.py that insert shares the request transaction, so a role grant beside it would roll back too. Noise and dead code - The per-user panel is a security panel: data-plane events file under the actor, so an active account's routine deletes pushed a denied login out of the 20-row window. It now excludes them; the Activity tab shows everything. - device_audit_log had no created_at-leading index, so the merged feed sequentially scanned that branch every page (migration 0036). - conversation.deleted_all no longer records when nothing was deleted, and agent.updated no longer records an empty field list. - Dropped by_model from /admin/usage (no consumer; an extra aggregate per page load), the duplicate filter surface on AuthEventsRepository that nothing called, and the unreachable FLOW_LABELS.schedule entry. - Type hints on record_event's conn and the remaining unannotated helpers.
445 lines
17 KiB
Python
445 lines
17 KiB
Python
"""Admin-gated management endpoints (RBAC ``admin`` role required).
|
|
|
|
Every resource here is behind ``@admin_required``. The frontend route guard is
|
|
cosmetic — this server-side decorator is the actual security boundary, so any
|
|
new endpoint added to this namespace MUST carry it.
|
|
|
|
Reads are cross-user aggregates/feeds (deliberately *not* scoped to the
|
|
caller's ``sub``, unlike the rest of the API). Mutations (role grant/revoke,
|
|
deactivate, force-logout) are written through the existing repositories and
|
|
audited to ``auth_events`` with the acting admin recorded.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from flask import jsonify, make_response, request
|
|
from flask_restx import Namespace, Resource
|
|
|
|
from docsgpt.api.oidc import denylist
|
|
from docsgpt.api.user.authz import ROLE_ADMIN, admin_required
|
|
from docsgpt.storage.db.repositories.admin_stats import AdminStatsRepository
|
|
from docsgpt.storage.db.repositories.auth_events import AuthEventsRepository
|
|
from docsgpt.storage.db.repositories.device_audit_log import DeviceAuditLogRepository
|
|
from docsgpt.storage.db.repositories.personal_access_tokens import (
|
|
PersonalAccessTokensRepository,
|
|
)
|
|
from docsgpt.storage.db.repositories.token_usage import TokenUsageRepository
|
|
from docsgpt.storage.db.repositories.user_roles import UserRolesRepository
|
|
from docsgpt.storage.db.repositories.users import UsersRepository
|
|
from docsgpt.storage.db.session import db_readonly, db_session
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
admin_ns = Namespace("admin", description="Admin-only management endpoints", path="/api")
|
|
|
|
_DEFAULT_PAGE_SIZE = 25
|
|
_MAX_PAGE_SIZE = 100
|
|
|
|
# Event namespaces the per-user security panel leaves out; see the drill-down.
|
|
_DATA_PLANE_PREFIXES = ("source.", "agent.", "conversation.")
|
|
|
|
|
|
def _int_arg(name: str, default: int) -> int:
|
|
try:
|
|
return int(request.args.get(name, default))
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def _page() -> tuple[int, int, int]:
|
|
page = max(1, _int_arg("page", 1))
|
|
page_size = max(1, min(_MAX_PAGE_SIZE, _int_arg("page_size", _DEFAULT_PAGE_SIZE)))
|
|
return page, page_size, (page - 1) * page_size
|
|
|
|
|
|
def _actor() -> str | None:
|
|
token = getattr(request, "decoded_token", None)
|
|
return token.get("sub") if isinstance(token, dict) else None
|
|
|
|
|
|
def _since_arg(default_days: int) -> datetime | None:
|
|
"""``?since_days=N`` → a UTC cutoff datetime, or None for 'all time'."""
|
|
days = _int_arg("since_days", default_days)
|
|
if days <= 0:
|
|
return None
|
|
return datetime.now(timezone.utc) - timedelta(days=days)
|
|
|
|
|
|
@admin_ns.route("/admin/overview")
|
|
class AdminOverviewResource(Resource):
|
|
@admin_required
|
|
def get(self):
|
|
"""Top-line KPIs for the dashboard home."""
|
|
with db_readonly() as conn:
|
|
stats = AdminStatsRepository(conn).overview()
|
|
return make_response(jsonify({"success": True, **stats}), 200)
|
|
|
|
|
|
@admin_ns.route("/admin/users")
|
|
class AdminUsersResource(Resource):
|
|
@admin_required
|
|
def get(self):
|
|
"""List users, paginated, most-recently-seen first.
|
|
|
|
Each row carries ``last_seen`` (max auth-event time). Admin status is
|
|
intentionally not joined here; the dashboard cross-references
|
|
GET /api/admin/admins for badges.
|
|
"""
|
|
page, page_size, offset = _page()
|
|
user_id_filter = request.args.get("user_id") or None
|
|
with db_readonly() as conn:
|
|
total, rows = AdminStatsRepository(conn).list_users(
|
|
user_id_filter, offset, page_size
|
|
)
|
|
users = [
|
|
{
|
|
"user_id": row.get("user_id"),
|
|
"active": bool(row.get("active", True)),
|
|
"created_at": row.get("created_at"),
|
|
"last_seen": row.get("last_seen"),
|
|
}
|
|
for row in rows
|
|
]
|
|
return make_response(
|
|
jsonify(
|
|
{
|
|
"success": True,
|
|
"users": users,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"total": total,
|
|
"has_more": offset + len(rows) < total,
|
|
}
|
|
),
|
|
200,
|
|
)
|
|
|
|
|
|
@admin_ns.route("/admin/users/<string:user_id>")
|
|
class AdminUserResource(Resource):
|
|
@admin_required
|
|
def get(self, user_id):
|
|
"""Per-user drill-down: profile, roles, recent auth events, counts."""
|
|
with db_readonly() as conn:
|
|
user = UsersRepository(conn).get(user_id)
|
|
if user is None:
|
|
return make_response(jsonify({"success": False}), 404)
|
|
roles_repo = UserRolesRepository(conn)
|
|
body = {
|
|
"success": True,
|
|
"user": {
|
|
"user_id": user.get("user_id"),
|
|
"active": bool(user.get("active", True)),
|
|
"created_at": user.get("created_at"),
|
|
"updated_at": user.get("updated_at"),
|
|
},
|
|
"roles": sorted({"user", *roles_repo.role_names_for(user_id)}),
|
|
"grants": roles_repo.list_for(user_id),
|
|
# Identity and access only. Data-plane events file under the
|
|
# actor, so an active user's routine deletes would push a
|
|
# denied login or a role grant out of a 20-row window.
|
|
# The Activity tab, filtered by user, shows everything.
|
|
"recent_events": AuthEventsRepository(conn).list_recent(
|
|
user_id, limit=20, exclude_events_like=_DATA_PLANE_PREFIXES
|
|
),
|
|
"counts": AdminStatsRepository(conn).user_counts(user_id),
|
|
}
|
|
return make_response(jsonify(body), 200)
|
|
|
|
@admin_required
|
|
def patch(self, user_id):
|
|
"""Activate/deactivate a user. Deactivation also revokes live sessions."""
|
|
actor = _actor()
|
|
data = request.get_json(silent=True) or {}
|
|
if "active" not in data or not isinstance(data["active"], bool):
|
|
return make_response(
|
|
jsonify({"success": False, "message": "Body requires boolean 'active'"}),
|
|
400,
|
|
)
|
|
active = data["active"]
|
|
if not active and user_id == actor:
|
|
return make_response(
|
|
jsonify(
|
|
{"success": False, "message": "You cannot deactivate yourself"}
|
|
),
|
|
409,
|
|
)
|
|
with db_session() as conn:
|
|
user = UsersRepository(conn).get(user_id)
|
|
if user is None:
|
|
return make_response(jsonify({"success": False}), 404)
|
|
updated = UsersRepository(conn).set_active(str(user["id"]), active)
|
|
AuthEventsRepository(conn).insert(
|
|
user_id,
|
|
"admin_user_activated" if active else "admin_user_deactivated",
|
|
ip=request.remote_addr,
|
|
user_agent=request.headers.get("User-Agent"),
|
|
metadata={"by": actor, "via": "admin_api"},
|
|
actor_id=actor,
|
|
target_id=user_id,
|
|
)
|
|
if not active:
|
|
# Best-effort live-session revocation (mirrors SCIM deactivation).
|
|
denylist.deny_user(user_id)
|
|
return make_response(
|
|
jsonify({"success": True, "active": bool(updated.get("active", active))}),
|
|
200,
|
|
)
|
|
|
|
|
|
@admin_ns.route("/admin/users/<string:user_id>/role")
|
|
class AdminUserRoleResource(Resource):
|
|
@admin_required
|
|
def post(self, user_id):
|
|
"""Grant the admin role to ``user_id`` (manual source). Idempotent."""
|
|
actor = _actor()
|
|
with db_session() as conn:
|
|
inserted = UserRolesRepository(conn).grant(
|
|
user_id, ROLE_ADMIN, source="manual", granted_by=actor
|
|
)
|
|
if inserted:
|
|
AuthEventsRepository(conn).insert(
|
|
user_id,
|
|
"role_granted",
|
|
ip=request.remote_addr,
|
|
user_agent=request.headers.get("User-Agent"),
|
|
metadata={
|
|
"role": ROLE_ADMIN,
|
|
"source": "manual",
|
|
"granted_by": actor,
|
|
"via": "admin_api",
|
|
},
|
|
actor_id=actor,
|
|
target_id=user_id,
|
|
)
|
|
return make_response(
|
|
jsonify({"success": True, "granted": inserted, "role": ROLE_ADMIN}), 200
|
|
)
|
|
|
|
@admin_required
|
|
def delete(self, user_id):
|
|
"""Revoke the manual admin grant. Refuses to remove the last admin."""
|
|
actor = _actor()
|
|
with db_session() as conn:
|
|
roles_repo = UserRolesRepository(conn)
|
|
admins = roles_repo.list_admins()
|
|
if len(admins) <= 1 and any(a["user_id"] == user_id for a in admins):
|
|
return make_response(
|
|
jsonify(
|
|
{
|
|
"success": False,
|
|
"message": "Cannot remove the last admin",
|
|
}
|
|
),
|
|
409,
|
|
)
|
|
removed = roles_repo.revoke(user_id, ROLE_ADMIN, source="manual")
|
|
if removed:
|
|
AuthEventsRepository(conn).insert(
|
|
user_id,
|
|
"role_revoked",
|
|
ip=request.remote_addr,
|
|
user_agent=request.headers.get("User-Agent"),
|
|
metadata={
|
|
"role": ROLE_ADMIN,
|
|
"source": "manual",
|
|
"revoked_by": actor,
|
|
"via": "admin_api",
|
|
},
|
|
actor_id=actor,
|
|
target_id=user_id,
|
|
)
|
|
return make_response(jsonify({"success": True, "revoked": removed}), 200)
|
|
|
|
|
|
@admin_ns.route("/admin/users/<string:user_id>/revoke-sessions")
|
|
class AdminUserSessionsResource(Resource):
|
|
@admin_required
|
|
def post(self, user_id):
|
|
"""Force-logout: revoke the user's live OIDC sessions (best-effort)."""
|
|
ok = denylist.deny_user(user_id)
|
|
with db_session() as conn:
|
|
# A forced logout that left API credentials alive would not be one.
|
|
revoked_token_ids = PersonalAccessTokensRepository(conn).revoke_all_for_user(
|
|
user_id, reason="admin_sessions_revoked"
|
|
)
|
|
# One pat_revoked event per token, like every other revocation path.
|
|
for token_id in revoked_token_ids:
|
|
AuthEventsRepository(conn).insert(
|
|
user_id,
|
|
"pat_revoked",
|
|
ip=request.remote_addr,
|
|
user_agent=request.headers.get("User-Agent"),
|
|
metadata={"token_id": token_id, "by": _actor(), "via": "admin_sessions_revoked"},
|
|
actor_id=_actor(),
|
|
target_id=user_id,
|
|
)
|
|
AuthEventsRepository(conn).insert(
|
|
user_id,
|
|
"admin_sessions_revoked",
|
|
ip=request.remote_addr,
|
|
user_agent=request.headers.get("User-Agent"),
|
|
metadata={
|
|
"by": _actor(),
|
|
"via": "admin_api",
|
|
"persisted": ok,
|
|
"personal_access_tokens_revoked": len(revoked_token_ids),
|
|
},
|
|
actor_id=_actor(),
|
|
target_id=user_id,
|
|
)
|
|
return make_response(jsonify({"success": True, "revoked": ok}), 200)
|
|
|
|
|
|
@admin_ns.route("/admin/users/<string:user_id>/usage")
|
|
class AdminUserUsageResource(Resource):
|
|
@admin_required
|
|
def get(self, user_id):
|
|
"""One user's spend: daily series plus a split by model and by flow."""
|
|
days = max(1, min(365, _int_arg("days", 30)))
|
|
start = datetime.now(timezone.utc) - timedelta(days=days)
|
|
with db_readonly() as conn:
|
|
series = TokenUsageRepository(conn).bucketed_totals(
|
|
bucket_unit="day", user_id=user_id, timestamp_gte=start
|
|
)
|
|
breakdown = AdminStatsRepository(conn).user_usage_breakdown(
|
|
user_id, since=start
|
|
)
|
|
return make_response(
|
|
jsonify({"success": True, "days": days, "series": series, **breakdown}),
|
|
200,
|
|
)
|
|
|
|
|
|
@admin_ns.route("/admin/admins")
|
|
class AdminAdminsResource(Resource):
|
|
@admin_required
|
|
def get(self):
|
|
"""List all admins with earliest grant time + grant sources."""
|
|
with db_readonly() as conn:
|
|
admins = UserRolesRepository(conn).list_admins()
|
|
return make_response(jsonify({"success": True, "admins": admins}), 200)
|
|
|
|
|
|
@admin_ns.route("/admin/usage")
|
|
class AdminUsageResource(Resource):
|
|
_GROUP_BY = ("none", "model", "agent", "source")
|
|
_BUCKETS = ("day", "hour")
|
|
|
|
@admin_required
|
|
def get(self):
|
|
"""Global usage: time-bucketed series, totals, spend, latency, top users.
|
|
|
|
Every bucket carries both tokens and USD cost. Quota policies have
|
|
always been settable in dollars; until now nothing showed the spend
|
|
they were capping.
|
|
"""
|
|
days = max(1, min(365, _int_arg("days", 30)))
|
|
bucket = request.args.get("bucket", "day")
|
|
group_by = request.args.get("group_by", "none")
|
|
if bucket not in self._BUCKETS or group_by not in self._GROUP_BY:
|
|
return make_response(jsonify({"success": False, "message": "Invalid option"}), 400)
|
|
start = datetime.now(timezone.utc) - timedelta(days=days)
|
|
with db_readonly() as conn:
|
|
usage_repo = TokenUsageRepository(conn)
|
|
stats_repo = AdminStatsRepository(conn)
|
|
series = usage_repo.bucketed_totals(
|
|
bucket_unit=bucket,
|
|
timestamp_gte=start,
|
|
group_by=None if group_by == "none" else group_by,
|
|
)
|
|
total = usage_repo.sum_tokens_in_range(
|
|
start=start, end=datetime.now(timezone.utc)
|
|
)
|
|
latency = stats_repo.latency_summary(since=start)
|
|
top_users = stats_repo.top_token_users(since=start, limit=10)
|
|
return make_response(
|
|
jsonify(
|
|
{
|
|
"success": True,
|
|
"days": days,
|
|
"bucket": bucket,
|
|
"group_by": group_by,
|
|
"series": series,
|
|
"total_tokens": int(total),
|
|
# Summed from the series, not from ``by_model``: the latter
|
|
# drops rows with no model_id, so it would undercount.
|
|
"total_cost": round(sum(row["cost"] for row in series), 4),
|
|
"latency": latency,
|
|
"top_users": top_users,
|
|
}
|
|
),
|
|
200,
|
|
)
|
|
|
|
|
|
@admin_ns.route("/admin/audit")
|
|
class AdminAuditResource(Resource):
|
|
@admin_required
|
|
def get(self):
|
|
"""Global auth-events feed, newest first. Filter by event/user/since."""
|
|
page, page_size, offset = _page()
|
|
event = request.args.get("event") or None
|
|
user_id = request.args.get("user_id") or None
|
|
since = _since_arg(default_days=0) # 0 = all time
|
|
with db_readonly() as conn:
|
|
repo = AuthEventsRepository(conn)
|
|
total = repo.count_all(event=event, user_id=user_id, since=since)
|
|
events = repo.list_all(
|
|
event=event,
|
|
user_id=user_id,
|
|
since=since,
|
|
limit=page_size,
|
|
offset=offset,
|
|
)
|
|
return make_response(
|
|
jsonify(
|
|
{
|
|
"success": True,
|
|
"events": events,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"total": total,
|
|
"has_more": offset + len(events) < total,
|
|
}
|
|
),
|
|
200,
|
|
)
|
|
|
|
|
|
@admin_ns.route("/admin/devices/audit")
|
|
class AdminDeviceAuditResource(Resource):
|
|
@admin_required
|
|
def get(self):
|
|
"""Global remote-device command audit feed. Filter by decision/user/since."""
|
|
page, page_size, offset = _page()
|
|
decision = request.args.get("decision") or None
|
|
user_id = request.args.get("user_id") or None
|
|
since = _since_arg(default_days=0)
|
|
with db_readonly() as conn:
|
|
repo = DeviceAuditLogRepository(conn)
|
|
total = repo.count_global(decision=decision, user_id=user_id, since=since)
|
|
rows = repo.list_global(
|
|
decision=decision,
|
|
user_id=user_id,
|
|
since=since,
|
|
limit=page_size,
|
|
offset=offset,
|
|
)
|
|
return make_response(
|
|
jsonify(
|
|
{
|
|
"success": True,
|
|
"invocations": rows,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"total": total,
|
|
"has_more": offset + len(rows) < total,
|
|
}
|
|
),
|
|
200,
|
|
)
|