| """ |
| RMI Admin User Management API |
| ============================= |
| Admin endpoints for managing users: |
| - List all users with filters (tier, role, status, date range) |
| - Get user details |
| - Warn user (add warning note) |
| - Flag user as suspicious |
| - Ban / unban user |
| - Delete user (soft delete) |
| - Update user tier/role |
| - Bulk actions |
| |
| All endpoints require ADMIN or SUPERADMIN role. |
| """ |
|
|
| import json |
| import logging |
| from datetime import datetime, timedelta |
| from enum import StrEnum |
| from typing import Any |
|
|
| from fastapi import APIRouter, HTTPException, Query, Request |
| from pydantic import BaseModel, Field |
|
|
| logger = logging.getLogger("rmi_admin_users") |
| router = APIRouter(tags=["admin-users"]) |
|
|
|
|
| |
| async def require_admin(request: Request, min_role: str = "ADMIN"): |
| """Require admin authentication.""" |
| from app.auth import get_current_user |
|
|
| user = await get_current_user(request) |
| if not user: |
| raise HTTPException(status_code=401, detail="Authentication required") |
|
|
| role = user.get("role", "USER") |
| user.get("tier", "FREE") |
|
|
| |
| role_levels = {"USER": 0, "MODERATOR": 1, "ADMIN": 2, "SUPERADMIN": 3} |
| required_level = role_levels.get(min_role, 2) |
| user_level = role_levels.get(role, 0) |
|
|
| if user_level < required_level: |
| raise HTTPException(status_code=403, detail="Admin access required") |
|
|
| return user |
|
|
|
|
| |
| class UserStatus(StrEnum): |
| ACTIVE = "active" |
| WARNED = "warned" |
| SUSPECT = "suspect" |
| BANNED = "banned" |
| DELETED = "deleted" |
|
|
|
|
| class UserListFilters(BaseModel): |
| tier: str | None = None |
| role: str | None = None |
| status: UserStatus | None = None |
| search: str | None = None |
| date_from: str | None = None |
| date_to: str | None = None |
| limit: int = Field(50, ge=1, le=500) |
| offset: int = Field(0, ge=0) |
|
|
|
|
| class WarnUserRequest(BaseModel): |
| reason: str = Field(..., min_length=1, max_length=500) |
| severity: str = Field("medium", pattern="^(low|medium|high|critical)$") |
|
|
|
|
| class UpdateUserRequest(BaseModel): |
| tier: str | None = None |
| role: str | None = None |
| status: UserStatus | None = None |
| display_name: str | None = None |
| scans_remaining: int | None = None |
| notes: str | None = None |
|
|
|
|
| class BulkActionRequest(BaseModel): |
| user_ids: list[str] |
| action: str = Field(..., pattern="^(warn|ban|unban|flag_suspect|clear_suspect|delete|restore|update_tier)$") |
| params: dict[str, Any] | None = None |
|
|
|
|
| class UserAdminResponse(BaseModel): |
| id: str |
| email: str |
| display_name: str |
| wallet_address: str | None |
| wallet_chain: str | None |
| tier: str |
| role: str |
| status: str |
| created_at: str |
| last_login: str | None |
| xp: int |
| level: int |
| scans_used: int |
| scans_remaining: int |
| warnings: list[dict] |
| notes: str | None |
| login_methods: list[str] |
|
|
|
|
| |
| def _get_all_users() -> list[dict]: |
| """Fetch all users from Redis.""" |
| r = get_redis() |
| users_raw = r.hgetall("rmi:users") |
| users = [] |
| for user_id, data in users_raw.items(): |
| try: |
| user = json.loads(data) |
| user["id"] = user_id |
| users.append(user) |
| except json.JSONDecodeError: |
| continue |
| return users |
|
|
|
|
| def _get_user_full(user_id: str) -> dict | None: |
| """Get user with enriched data.""" |
| from app.auth import _get_user |
|
|
| user = _get_user(user_id) |
| if not user: |
| return None |
|
|
| r = get_redis() |
|
|
| |
| warnings_raw = r.hget("rmi:user_warnings", user_id) |
| user["warnings"] = json.loads(warnings_raw) if warnings_raw else [] |
|
|
| |
| user["notes"] = r.hget("rmi:user_notes", user_id) or "" |
|
|
| |
| user["status"] = r.hget("rmi:user_status", user_id) or "active" |
|
|
| |
| user["last_login"] = r.hget("rmi:user_last_login", user_id) |
|
|
| |
| wallets_raw = r.hget("rmi:user_wallets", user_id) |
| wallets = json.loads(wallets_raw) if wallets_raw else [] |
| login_methods = ["email"] if user.get("password_hash") else [] |
| if user.get("wallet_address"): |
| login_methods.append("wallet") |
| if "google" in user.get("email", ""): |
| login_methods.append("google") |
|
|
| user["login_methods"] = login_methods |
| user["wallet_count"] = len(wallets) |
|
|
| return user |
|
|
|
|
| |
|
|
|
|
| @router.get("/admin/users") |
| async def list_users( |
| request: Request, |
| tier: str | None = Query(None), |
| role: str | None = Query(None), |
| status: str | None = Query(None), |
| search: str | None = Query(None), |
| date_from: str | None = Query(None), |
| date_to: str | None = Query(None), |
| limit: int = Query(50, ge=1, le=500), |
| offset: int = Query(0, ge=0), |
| sort_by: str = Query("created_at"), |
| sort_order: str = Query("desc", pattern="^(asc|desc)$"), |
| ): |
| """List all users with filtering and pagination.""" |
| await require_admin(request) |
|
|
| users = _get_all_users() |
|
|
| |
| filtered = [] |
| for u in users: |
| |
| user_status = get_redis().hget("rmi:user_status", u["id"]) or "active" |
| if user_status == "deleted" and status != "deleted": |
| continue |
|
|
| if tier and u.get("tier", "FREE") != tier.upper(): |
| continue |
| if role and u.get("role", "USER") != role.upper(): |
| continue |
| if status and user_status != status: |
| continue |
| if search: |
| search_lower = search.lower() |
| match = ( |
| search_lower in u.get("email", "").lower() |
| or search_lower in u.get("display_name", "").lower() |
| or search_lower in u.get("wallet_address", "").lower() |
| or search_lower in u.get("id", "").lower() |
| ) |
| if not match: |
| continue |
| if date_from and u.get("created_at", "") < date_from: |
| continue |
| if date_to and u.get("created_at", "") > date_to: |
| continue |
|
|
| |
| u["status"] = user_status |
| u["warnings_count"] = len(json.loads(get_redis().hget("rmi:user_warnings", u["id"]) or "[]")) |
| filtered.append(u) |
|
|
| |
| reverse = sort_order == "desc" |
| filtered.sort(key=lambda x: x.get(sort_by, ""), reverse=reverse) |
|
|
| total = len(filtered) |
| paginated = filtered[offset : offset + limit] |
|
|
| |
| results = [] |
| for u in paginated: |
| results.append( |
| { |
| "id": u["id"], |
| "email": u.get("email", ""), |
| "display_name": u.get("display_name", u.get("email", "")), |
| "wallet_address": u.get("wallet_address"), |
| "wallet_chain": u.get("wallet_chain"), |
| "tier": u.get("tier", "FREE"), |
| "role": u.get("role", "USER"), |
| "status": u.get("status", "active"), |
| "created_at": u.get("created_at"), |
| "last_login": u.get("last_login"), |
| "xp": u.get("xp", 0), |
| "level": u.get("level", 1), |
| "scans_used": u.get("scans_used", 0), |
| "scans_remaining": u.get("scans_remaining", 5), |
| "warnings_count": u.get("warnings_count", 0), |
| "login_methods": ["email"] if u.get("password_hash") else ["wallet"] if u.get("wallet_address") else [], |
| } |
| ) |
|
|
| return { |
| "users": results, |
| "total": total, |
| "limit": limit, |
| "offset": offset, |
| "filters_applied": { |
| "tier": tier, |
| "role": role, |
| "status": status, |
| "search": search, |
| }, |
| } |
|
|
|
|
| @router.get("/admin/users/{user_id}") |
| async def get_user_detail(user_id: str, request: Request): |
| """Get detailed info about a specific user.""" |
| await require_admin(request) |
|
|
| user = _get_user_full(user_id) |
| if not user: |
| raise HTTPException(status_code=404, detail="User not found") |
|
|
| return user |
|
|
|
|
| @router.post("/admin/users/{user_id}/warn") |
| async def warn_user(user_id: str, req: WarnUserRequest, request: Request): |
| """Add a warning to a user.""" |
| admin = await require_admin(request, min_role="MODERATOR") |
|
|
| from app.auth import _get_user |
|
|
| user = _get_user(user_id) |
| if not user: |
| raise HTTPException(status_code=404, detail="User not found") |
|
|
| r = get_redis() |
| warnings_raw = r.hget("rmi:user_warnings", user_id) |
| warnings = json.loads(warnings_raw) if warnings_raw else [] |
|
|
| warning = { |
| "id": secrets.token_hex(8), |
| "reason": req.reason, |
| "severity": req.severity, |
| "issued_by": admin["id"], |
| "issued_at": datetime.utcnow().isoformat(), |
| "acknowledged": False, |
| } |
| warnings.append(warning) |
| r.hset("rmi:user_warnings", user_id, json.dumps(warnings)) |
|
|
| |
| if req.severity in ("high", "critical"): |
| r.hset("rmi:user_status", user_id, "warned") |
|
|
| logger.info(f"[ADMIN] User {user_id} warned by {admin['id']}: {req.reason}") |
|
|
| return {"status": "ok", "warning": warning} |
|
|
|
|
| @router.post("/admin/users/{user_id}/flag-suspect") |
| async def flag_suspect(user_id: str, request: Request, reason: str | None = Query(None)): |
| """Flag a user as suspicious.""" |
| admin = await require_admin(request, min_role="MODERATOR") |
|
|
| from app.auth import _get_user |
|
|
| user = _get_user(user_id) |
| if not user: |
| raise HTTPException(status_code=404, detail="User not found") |
|
|
| r = get_redis() |
| r.hset("rmi:user_status", user_id, "suspect") |
|
|
| |
| if reason: |
| existing = r.hget("rmi:user_notes", user_id) or "" |
| note = f"[{datetime.utcnow().isoformat()}] FLAGGED SUSPECT by {admin['id']}: {reason}\n" |
| r.hset("rmi:user_notes", user_id, existing + note) |
|
|
| logger.info(f"[ADMIN] User {user_id} flagged as suspect by {admin['id']}") |
|
|
| return {"status": "ok", "message": "User flagged as suspect"} |
|
|
|
|
| @router.post("/admin/users/{user_id}/clear-suspect") |
| async def clear_suspect(user_id: str, request: Request): |
| """Clear suspect flag from a user.""" |
| admin = await require_admin(request, min_role="MODERATOR") |
|
|
| r = get_redis() |
| current = r.hget("rmi:user_status", user_id) |
| if current == "suspect": |
| r.hset("rmi:user_status", user_id, "active") |
|
|
| logger.info(f"[ADMIN] User {user_id} suspect flag cleared by {admin['id']}") |
|
|
| return {"status": "ok", "message": "Suspect flag cleared"} |
|
|
|
|
| @router.post("/admin/users/{user_id}/ban") |
| async def ban_user(user_id: str, request: Request, reason: str | None = Query(None)): |
| """Ban a user.""" |
| admin = await require_admin(request, min_role="ADMIN") |
|
|
| from app.auth import _get_user |
|
|
| user = _get_user(user_id) |
| if not user: |
| raise HTTPException(status_code=404, detail="User not found") |
|
|
| |
| if user_id == admin["id"]: |
| raise HTTPException(status_code=400, detail="Cannot ban yourself") |
|
|
| r = get_redis() |
| r.hset("rmi:user_status", user_id, "banned") |
|
|
| if reason: |
| existing = r.hget("rmi:user_notes", user_id) or "" |
| note = f"[{datetime.utcnow().isoformat()}] BANNED by {admin['id']}: {reason}\n" |
| r.hset("rmi:user_notes", user_id, existing + note) |
|
|
| |
| logger.info(f"[ADMIN] User {user_id} banned by {admin['id']}: {reason}") |
|
|
| return {"status": "ok", "message": "User banned"} |
|
|
|
|
| @router.post("/admin/users/{user_id}/unban") |
| async def unban_user(user_id: str, request: Request): |
| """Unban a user.""" |
| admin = await require_admin(request, min_role="ADMIN") |
|
|
| r = get_redis() |
| r.hset("rmi:user_status", user_id, "active") |
|
|
| logger.info(f"[ADMIN] User {user_id} unbanned by {admin['id']}") |
|
|
| return {"status": "ok", "message": "User unbanned"} |
|
|
|
|
| @router.post("/admin/users/{user_id}/delete") |
| async def delete_user(user_id: str, request: Request, reason: str | None = Query(None)): |
| """Soft delete a user.""" |
| admin = await require_admin(request, min_role="ADMIN") |
|
|
| from app.auth import _get_user, _save_user |
|
|
| user = _get_user(user_id) |
| if not user: |
| raise HTTPException(status_code=404, detail="User not found") |
|
|
| if user_id == admin["id"]: |
| raise HTTPException(status_code=400, detail="Cannot delete yourself") |
|
|
| |
| user["deleted_at"] = datetime.utcnow().isoformat() |
| user["deleted_by"] = admin["id"] |
| user["delete_reason"] = reason or "" |
| _save_user(user) |
|
|
| r = get_redis() |
| r.hset("rmi:user_status", user_id, "deleted") |
|
|
| logger.info(f"[ADMIN] User {user_id} soft-deleted by {admin['id']}: {reason}") |
|
|
| return {"status": "ok", "message": "User deleted"} |
|
|
|
|
| @router.post("/admin/users/{user_id}/restore") |
| async def restore_user(user_id: str, request: Request): |
| """Restore a soft-deleted user.""" |
| admin = await require_admin(request, min_role="ADMIN") |
|
|
| from app.auth import _get_user, _save_user |
|
|
| user = _get_user(user_id) |
| if not user: |
| raise HTTPException(status_code=404, detail="User not found") |
|
|
| user.pop("deleted_at", None) |
| user.pop("deleted_by", None) |
| user.pop("delete_reason", None) |
| _save_user(user) |
|
|
| r = get_redis() |
| r.hset("rmi:user_status", user_id, "active") |
|
|
| logger.info(f"[ADMIN] User {user_id} restored by {admin['id']}") |
|
|
| return {"status": "ok", "message": "User restored"} |
|
|
|
|
| @router.patch("/admin/users/{user_id}") |
| async def update_user(user_id: str, req: UpdateUserRequest, request: Request): |
| """Update user properties (tier, role, status, etc.).""" |
| admin = await require_admin(request, min_role="ADMIN") |
|
|
| from app.auth import _get_user, _save_user |
|
|
| user = _get_user(user_id) |
| if not user: |
| raise HTTPException(status_code=404, detail="User not found") |
|
|
| changes = [] |
|
|
| if req.tier is not None: |
| old = user.get("tier", "FREE") |
| user["tier"] = req.tier.upper() |
| changes.append(f"tier: {old} -> {req.tier.upper()}") |
|
|
| if req.role is not None: |
| old = user.get("role", "USER") |
| |
| if req.role.upper() == "SUPERADMIN" and admin.get("role") != "SUPERADMIN": |
| raise HTTPException(status_code=403, detail="Only superadmin can assign superadmin role") |
| user["role"] = req.role.upper() |
| changes.append(f"role: {old} -> {req.role.upper()}") |
|
|
| if req.status is not None: |
| r = get_redis() |
| r.hset("rmi:user_status", user_id, req.status.value) |
| changes.append(f"status: -> {req.status.value}") |
|
|
| if req.display_name is not None: |
| user["display_name"] = req.display_name[:50] |
| changes.append("display_name updated") |
|
|
| if req.scans_remaining is not None: |
| user["scans_remaining"] = req.scans_remaining |
| changes.append(f"scans_remaining: -> {req.scans_remaining}") |
|
|
| if req.notes is not None: |
| r = get_redis() |
| existing = r.hget("rmi:user_notes", user_id) or "" |
| note = f"[{datetime.utcnow().isoformat()}] {admin['id']}: {req.notes}\n" |
| r.hset("rmi:user_notes", user_id, existing + note) |
| changes.append("notes added") |
|
|
| user["updated_at"] = datetime.utcnow().isoformat() |
| user["updated_by"] = admin["id"] |
| _save_user(user) |
|
|
| logger.info(f"[ADMIN] User {user_id} updated by {admin['id']}: {', '.join(changes)}") |
|
|
| return {"status": "ok", "changes": changes} |
|
|
|
|
| @router.post("/admin/users/bulk") |
| async def bulk_action(req: BulkActionRequest, request: Request): |
| """Perform bulk actions on multiple users.""" |
| admin = await require_admin(request, min_role="ADMIN") |
|
|
| results = {"success": [], "failed": []} |
|
|
| for user_id in req.user_ids: |
| try: |
| if req.action == "ban": |
| r = get_redis() |
| r.hset("rmi:user_status", user_id, "banned") |
| results["success"].append({"id": user_id, "action": "banned"}) |
| elif req.action == "unban": |
| r = get_redis() |
| r.hset("rmi:user_status", user_id, "active") |
| results["success"].append({"id": user_id, "action": "unbanned"}) |
| elif req.action == "flag_suspect": |
| r = get_redis() |
| r.hset("rmi:user_status", user_id, "suspect") |
| results["success"].append({"id": user_id, "action": "flagged_suspect"}) |
| elif req.action == "clear_suspect": |
| r = get_redis() |
| r.hset("rmi:user_status", user_id, "active") |
| results["success"].append({"id": user_id, "action": "cleared_suspect"}) |
| elif req.action == "delete": |
| from app.auth import _get_user, _save_user |
|
|
| user = _get_user(user_id) |
| if user: |
| user["deleted_at"] = datetime.utcnow().isoformat() |
| user["deleted_by"] = admin["id"] |
| _save_user(user) |
| r = get_redis() |
| r.hset("rmi:user_status", user_id, "deleted") |
| results["success"].append({"id": user_id, "action": "deleted"}) |
| elif req.action == "restore": |
| from app.auth import _get_user, _save_user |
|
|
| user = _get_user(user_id) |
| if user: |
| user.pop("deleted_at", None) |
| user.pop("deleted_by", None) |
| _save_user(user) |
| r = get_redis() |
| r.hset("rmi:user_status", user_id, "active") |
| results["success"].append({"id": user_id, "action": "restored"}) |
| elif req.action == "update_tier": |
| tier = req.params.get("tier", "FREE") if req.params else "FREE" |
| from app.auth import _get_user, _save_user |
|
|
| user = _get_user(user_id) |
| if user: |
| user["tier"] = tier |
| _save_user(user) |
| results["success"].append({"id": user_id, "action": f"tier_updated_to_{tier}"}) |
| except Exception as e: |
| results["failed"].append({"id": user_id, "error": str(e)}) |
|
|
| logger.info( |
| f"[ADMIN] Bulk action {req.action} by {admin['id']}: {len(results['success'])} success, {len(results['failed'])} failed" |
| ) |
|
|
| return results |
|
|
|
|
| @router.get("/admin/stats") |
| async def get_admin_stats(request: Request): |
| """Get user statistics for admin dashboard.""" |
| await require_admin(request, min_role="VIEWER") |
|
|
| users = _get_all_users() |
| r = get_redis() |
|
|
| stats = { |
| "total_users": len(users), |
| "by_tier": {}, |
| "by_role": {}, |
| "by_status": {"active": 0, "warned": 0, "suspect": 0, "banned": 0, "deleted": 0}, |
| "new_today": 0, |
| "new_this_week": 0, |
| "new_this_month": 0, |
| } |
|
|
| today = datetime.utcnow().date().isoformat() |
| week_ago = (datetime.utcnow() - timedelta(days=7)).isoformat() |
| month_ago = (datetime.utcnow() - timedelta(days=30)).isoformat() |
|
|
| for u in users: |
| tier = u.get("tier", "FREE") |
| role = u.get("role", "USER") |
| stats["by_tier"][tier] = stats["by_tier"].get(tier, 0) + 1 |
| stats["by_role"][role] = stats["by_role"].get(role, 0) + 1 |
|
|
| status = r.hget("rmi:user_status", u["id"]) or "active" |
| stats["by_status"][status] = stats["by_status"].get(status, 0) + 1 |
|
|
| created = u.get("created_at", "") |
| if created.startswith(today): |
| stats["new_today"] += 1 |
| if created >= week_ago: |
| stats["new_this_week"] += 1 |
| if created >= month_ago: |
| stats["new_this_month"] += 1 |
|
|
| return stats |
|
|