apac-ops-bot/backend/app/api/v1/endpoints/admin.py
SamoilenkoVadym b284cadb86 Add test user authentication and RBAC admin panel
Implemented simple authentication for testing and admin panel for user management:

Backend:
- Add simple email/password login for test users (admin@test.local, user@test.local)
- Implement RBAC (Role-Based Access Control) with Permission enum
- Create admin endpoints for user management and system analytics
- Add bcrypt password hashing for test users
- Create script to generate test users in database

Frontend:
- Add SimpleLogin component for test authentication
- Create AdminPanel with user management and system analytics
- Add role-based navigation (Admin tab visible only for admins)
- Update AuthContext to support both MSAL and simple login
- Add API methods for admin operations

Features:
- Admins can view all users, manage roles, activate/deactivate accounts
- Admins can view system-wide analytics (users, conversations, tokens, costs)
- Regular users only see their own chats and usage
- Role badges in UI show user role (user/admin/superadmin)

Note: Simple authentication is for testing only. Production uses Azure AD MSAL.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-27 20:05:54 +00:00

447 lines
12 KiB
Python

"""
Admin Endpoints
API endpoints for administrative functions (user management, analytics, system settings)
"""
import logging
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from datetime import datetime, timedelta
from app.database import get_db
from app.core.middleware import get_current_active_user
from app.core.rbac import require_role, UserRole, Permission, require_permission
from app.models.user import User
from app.models.conversation import Conversation
from app.models.message import Message
from app.models.token_usage import TokenUsage
from pydantic import BaseModel, EmailStr
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin", tags=["Admin"])
# Schemas
class UserListItem(BaseModel):
"""User list item for admin"""
id: str
email: EmailStr
display_name: str
role: str
is_active: bool
created_at: datetime
last_login_at: datetime | None
total_conversations: int
total_tokens: int
class Config:
from_attributes = True
class UpdateUserRoleRequest(BaseModel):
"""Request to update user role"""
role: str
class SystemAnalytics(BaseModel):
"""System-wide analytics"""
total_users: int
active_users: int
total_conversations: int
total_messages: int
total_tokens_used: int
total_cost_usd: float
avg_tokens_per_user: float
avg_messages_per_conversation: float
class UserAnalytics(BaseModel):
"""Per-user analytics"""
user_id: str
email: str
display_name: str
conversations_count: int
messages_count: int
tokens_used: int
cost_usd: float
last_activity: datetime | None
# Endpoints
@router.get("/users", response_model=List[UserListItem])
async def list_all_users(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
skip: int = 0,
limit: int = 100
):
"""
List all users (admin only)
Args:
current_user: Current authenticated user
db: Database session
skip: Number of users to skip
limit: Max number of users to return
Returns:
List of users with stats
Raises:
HTTPException: If user is not admin
"""
require_permission(current_user.role, Permission.READ_ALL_USERS)
# Query users with aggregated stats
query = (
select(
User,
func.count(Conversation.id).label("total_conversations"),
func.sum(TokenUsage.total_tokens).label("total_tokens")
)
.outerjoin(Conversation, User.id == Conversation.user_id)
.outerjoin(TokenUsage, User.id == TokenUsage.user_id)
.group_by(User.id)
.order_by(User.created_at.desc())
.offset(skip)
.limit(limit)
)
result = await db.execute(query)
users_with_stats = result.all()
return [
UserListItem(
id=str(user.id),
email=user.email,
display_name=user.display_name,
role=user.role,
is_active=user.is_active,
created_at=user.created_at,
last_login_at=user.last_login_at,
total_conversations=total_conversations or 0,
total_tokens=int(total_tokens or 0)
)
for user, total_conversations, total_tokens in users_with_stats
]
@router.put("/users/{user_id}/role")
async def update_user_role(
user_id: str,
request: UpdateUserRoleRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
Update user role (admin only)
Args:
user_id: User ID to update
request: New role
current_user: Current authenticated user
db: Database session
Returns:
Updated user info
Raises:
HTTPException: If user is not admin or user not found
"""
require_permission(current_user.role, Permission.UPDATE_USER_ROLE)
# Validate role
try:
new_role = UserRole(request.role)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid role. Must be one of: {[r.value for r in UserRole]}"
)
# Get user
result = await db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Update role
user.role = new_role.value
await db.commit()
logger.info(f"Admin {current_user.id} updated user {user_id} role to {new_role.value}")
return {
"success": True,
"user_id": str(user.id),
"email": user.email,
"new_role": user.role
}
@router.put("/users/{user_id}/activate")
async def activate_user(
user_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
Activate user account (admin only)
Args:
user_id: User ID to activate
current_user: Current authenticated user
db: Database session
Returns:
Success message
Raises:
HTTPException: If user is not admin or user not found
"""
require_permission(current_user.role, Permission.READ_ALL_USERS)
result = await db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
user.is_active = True
await db.commit()
logger.info(f"Admin {current_user.id} activated user {user_id}")
return {"success": True, "message": f"User {user.email} activated"}
@router.put("/users/{user_id}/deactivate")
async def deactivate_user(
user_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
Deactivate user account (admin only)
Args:
user_id: User ID to deactivate
current_user: Current authenticated user
db: Database session
Returns:
Success message
Raises:
HTTPException: If user is not admin or user not found
"""
require_permission(current_user.role, Permission.READ_ALL_USERS)
result = await db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Can't deactivate self
if user.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot deactivate your own account"
)
user.is_active = False
await db.commit()
logger.info(f"Admin {current_user.id} deactivated user {user_id}")
return {"success": True, "message": f"User {user.email} deactivated"}
@router.get("/analytics/system", response_model=SystemAnalytics)
async def get_system_analytics(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
Get system-wide analytics (admin only)
Args:
current_user: Current authenticated user
db: Database session
Returns:
System analytics
Raises:
HTTPException: If user is not admin
"""
require_permission(current_user.role, Permission.VIEW_ANALYTICS)
# Get counts
total_users = await db.scalar(select(func.count(User.id)))
active_users = await db.scalar(
select(func.count(User.id)).where(User.is_active == True)
)
total_conversations = await db.scalar(select(func.count(Conversation.id)))
total_messages = await db.scalar(select(func.count(Message.id)))
# Get token usage stats
token_stats = await db.execute(
select(
func.sum(TokenUsage.total_tokens).label("total_tokens"),
func.sum(TokenUsage.cost_usd).label("total_cost")
)
)
token_row = token_stats.first()
total_tokens = int(token_row.total_tokens or 0)
total_cost = float(token_row.total_cost or 0.0)
# Calculate averages
avg_tokens_per_user = total_tokens / active_users if active_users > 0 else 0
avg_messages_per_conv = total_messages / total_conversations if total_conversations > 0 else 0
return SystemAnalytics(
total_users=total_users or 0,
active_users=active_users or 0,
total_conversations=total_conversations or 0,
total_messages=total_messages or 0,
total_tokens_used=total_tokens,
total_cost_usd=total_cost,
avg_tokens_per_user=avg_tokens_per_user,
avg_messages_per_conversation=avg_messages_per_conv
)
@router.get("/analytics/users", response_model=List[UserAnalytics])
async def get_users_analytics(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
days: int = 30,
limit: int = 50
):
"""
Get per-user analytics (admin only)
Args:
current_user: Current authenticated user
db: Database session
days: Number of days to analyze
limit: Max number of users to return
Returns:
List of user analytics
Raises:
HTTPException: If user is not admin
"""
require_permission(current_user.role, Permission.VIEW_ANALYTICS)
since_date = datetime.utcnow() - timedelta(days=days)
query = (
select(
User,
func.count(Conversation.id).label("conversations_count"),
func.count(Message.id).label("messages_count"),
func.sum(TokenUsage.total_tokens).label("tokens_used"),
func.sum(TokenUsage.cost_usd).label("cost_usd"),
func.max(Conversation.last_message_at).label("last_activity")
)
.outerjoin(Conversation, User.id == Conversation.user_id)
.outerjoin(Message, Conversation.id == Message.conversation_id)
.outerjoin(TokenUsage, User.id == TokenUsage.user_id)
.where(
(Conversation.created_at >= since_date) | (Conversation.id == None)
)
.group_by(User.id)
.order_by(func.sum(TokenUsage.total_tokens).desc().nullslast())
.limit(limit)
)
result = await db.execute(query)
users_analytics = result.all()
return [
UserAnalytics(
user_id=str(user.id),
email=user.email,
display_name=user.display_name,
conversations_count=conversations_count or 0,
messages_count=messages_count or 0,
tokens_used=int(tokens_used or 0),
cost_usd=float(cost_usd or 0.0),
last_activity=last_activity
)
for user, conversations_count, messages_count, tokens_used, cost_usd, last_activity in users_analytics
]
@router.get("/conversations/all")
async def get_all_conversations(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
skip: int = 0,
limit: int = 50
):
"""
Get all conversations across all users (admin only)
Args:
current_user: Current authenticated user
db: Database session
skip: Number to skip
limit: Max number to return
Returns:
List of conversations
Raises:
HTTPException: If user is not admin
"""
require_permission(current_user.role, Permission.READ_ALL_CHATS)
query = (
select(Conversation, User)
.join(User, Conversation.user_id == User.id)
.order_by(Conversation.created_at.desc())
.offset(skip)
.limit(limit)
)
result = await db.execute(query)
conversations = result.all()
return [
{
"id": str(conv.id),
"title": conv.title,
"user_email": user.email,
"user_display_name": user.display_name,
"created_at": conv.created_at,
"last_message_at": conv.last_message_at,
"is_archived": conv.is_archived
}
for conv, user in conversations
]