Files
arc53--docsgpt/application/api/admin/routes.py
T
wehub-resource-sync fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

376 lines
14 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 application.api.oidc import denylist
from application.api.user.authz import ROLE_ADMIN, admin_required
from application.storage.db.repositories.admin_stats import AdminStatsRepository
from application.storage.db.repositories.auth_events import AuthEventsRepository
from application.storage.db.repositories.device_audit_log import DeviceAuditLogRepository
from application.storage.db.repositories.token_usage import TokenUsageRepository
from application.storage.db.repositories.user_roles import UserRolesRepository
from application.storage.db.repositories.users import UsersRepository
from application.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
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),
"recent_events": AuthEventsRepository(conn).list_recent(
user_id, limit=20
),
"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"},
)
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",
},
)
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",
},
)
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:
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},
)
return make_response(jsonify({"success": True, "revoked": ok}), 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 token usage: time-bucketed series + total + top users."""
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)
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)
)
top_users = AdminStatsRepository(conn).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),
"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,
)