mirror of
https://github.com/aljazceru/enclava.git
synced 2025-12-17 15:34:36 +01:00
473 lines
14 KiB
Python
473 lines
14 KiB
Python
"""
|
|
User management API endpoints
|
|
"""
|
|
|
|
from typing import List, Optional
|
|
from datetime import datetime
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
|
from pydantic import BaseModel, EmailStr
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, update, delete
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.db.database import get_db
|
|
from app.models.user import User
|
|
from app.models.api_key import APIKey
|
|
from app.models.budget import Budget
|
|
from app.core.security import get_current_user, get_password_hash, verify_password
|
|
from app.services.permission_manager import require_permission
|
|
from app.services.audit_service import log_audit_event
|
|
from app.core.logging import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# Pydantic models
|
|
class UserCreate(BaseModel):
|
|
username: str
|
|
email: EmailStr
|
|
full_name: Optional[str] = None
|
|
password: str
|
|
role: str = "user"
|
|
is_active: bool = True
|
|
|
|
|
|
class UserUpdate(BaseModel):
|
|
username: Optional[str] = None
|
|
email: Optional[EmailStr] = None
|
|
full_name: Optional[str] = None
|
|
role: Optional[str] = None
|
|
is_active: Optional[bool] = None
|
|
is_verified: Optional[bool] = None
|
|
|
|
|
|
class UserResponse(BaseModel):
|
|
id: int
|
|
username: str
|
|
email: str
|
|
full_name: Optional[str] = None
|
|
role: str
|
|
is_active: bool
|
|
is_verified: bool
|
|
is_superuser: bool
|
|
created_at: datetime
|
|
updated_at: Optional[datetime] = None
|
|
last_login: Optional[datetime] = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class UserListResponse(BaseModel):
|
|
users: List[UserResponse]
|
|
total: int
|
|
page: int
|
|
size: int
|
|
|
|
|
|
class PasswordChangeRequest(BaseModel):
|
|
current_password: str
|
|
new_password: str
|
|
|
|
|
|
class PasswordResetRequest(BaseModel):
|
|
new_password: str
|
|
|
|
|
|
# User CRUD endpoints
|
|
@router.get("/", response_model=UserListResponse)
|
|
async def list_users(
|
|
page: int = Query(1, ge=1),
|
|
size: int = Query(10, ge=1, le=100),
|
|
role: Optional[str] = Query(None),
|
|
is_active: Optional[bool] = Query(None),
|
|
search: Optional[str] = Query(None),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""List all users with pagination and filtering"""
|
|
|
|
# Check permissions
|
|
require_permission(current_user.get("permissions", []), "platform:users:read")
|
|
|
|
# Build query
|
|
query = select(User)
|
|
|
|
# Apply filters
|
|
if role:
|
|
query = query.where(User.role == role)
|
|
if is_active is not None:
|
|
query = query.where(User.is_active == is_active)
|
|
if search:
|
|
query = query.where(
|
|
(User.username.ilike(f"%{search}%")) |
|
|
(User.email.ilike(f"%{search}%")) |
|
|
(User.full_name.ilike(f"%{search}%"))
|
|
)
|
|
|
|
# Get total count
|
|
total_query = select(User.id).select_from(query.subquery())
|
|
total_result = await db.execute(total_query)
|
|
total = len(total_result.fetchall())
|
|
|
|
# Apply pagination
|
|
offset = (page - 1) * size
|
|
query = query.offset(offset).limit(size)
|
|
|
|
# Execute query
|
|
result = await db.execute(query)
|
|
users = result.scalars().all()
|
|
|
|
# Log audit event
|
|
await log_audit_event(
|
|
db=db,
|
|
user_id=current_user['id'],
|
|
action="list_users",
|
|
resource_type="user",
|
|
details={"page": page, "size": size, "filters": {"role": role, "is_active": is_active, "search": search}}
|
|
)
|
|
|
|
return UserListResponse(
|
|
users=[UserResponse.model_validate(user) for user in users],
|
|
total=total,
|
|
page=page,
|
|
size=size
|
|
)
|
|
|
|
|
|
@router.get("/{user_id}", response_model=UserResponse)
|
|
async def get_user(
|
|
user_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get user by ID"""
|
|
|
|
# Check permissions (users can view their own profile)
|
|
if int(user_id) != current_user['id']:
|
|
require_permission(current_user.get("permissions", []), "platform:users:read")
|
|
|
|
# Get user
|
|
query = select(User).where(User.id == int(user_id))
|
|
result = await db.execute(query)
|
|
user = result.scalar_one_or_none()
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found"
|
|
)
|
|
|
|
# Log audit event
|
|
await log_audit_event(
|
|
db=db,
|
|
user_id=current_user['id'],
|
|
action="get_user",
|
|
resource_type="user",
|
|
resource_id=user_id
|
|
)
|
|
|
|
return UserResponse.model_validate(user)
|
|
|
|
|
|
@router.post("/", response_model=UserResponse)
|
|
async def create_user(
|
|
user_data: UserCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Create a new user"""
|
|
|
|
# Check permissions
|
|
require_permission(current_user.get("permissions", []), "platform:users:create")
|
|
|
|
# Check if user already exists
|
|
query = select(User).where(
|
|
(User.username == user_data.username) | (User.email == user_data.email)
|
|
)
|
|
result = await db.execute(query)
|
|
existing_user = result.scalar_one_or_none()
|
|
|
|
if existing_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="User with this username or email already exists"
|
|
)
|
|
|
|
# Create user
|
|
hashed_password = get_password_hash(user_data.password)
|
|
new_user = User(
|
|
username=user_data.username,
|
|
email=user_data.email,
|
|
full_name=user_data.full_name,
|
|
hashed_password=hashed_password,
|
|
role=user_data.role,
|
|
is_active=user_data.is_active
|
|
)
|
|
|
|
db.add(new_user)
|
|
await db.commit()
|
|
await db.refresh(new_user)
|
|
|
|
# Log audit event
|
|
await log_audit_event(
|
|
db=db,
|
|
user_id=current_user['id'],
|
|
action="create_user",
|
|
resource_type="user",
|
|
resource_id=str(new_user.id),
|
|
details={"username": user_data.username, "email": user_data.email, "role": user_data.role}
|
|
)
|
|
|
|
logger.info(f"User created: {new_user.username} by {current_user['username']}")
|
|
|
|
return UserResponse.model_validate(new_user)
|
|
|
|
|
|
@router.put("/{user_id}", response_model=UserResponse)
|
|
async def update_user(
|
|
user_id: str,
|
|
user_data: UserUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Update user"""
|
|
|
|
# Check permissions (users can update their own profile with limited fields)
|
|
is_self_update = int(user_id) == current_user['id']
|
|
if not is_self_update:
|
|
require_permission(current_user.get("permissions", []), "platform:users:update")
|
|
|
|
# Get user
|
|
query = select(User).where(User.id == int(user_id))
|
|
result = await db.execute(query)
|
|
user = result.scalar_one_or_none()
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found"
|
|
)
|
|
|
|
# For self-updates, restrict what can be changed
|
|
if is_self_update:
|
|
allowed_fields = {"username", "email", "full_name"}
|
|
update_data = user_data.model_dump(exclude_unset=True)
|
|
restricted_fields = set(update_data.keys()) - allowed_fields
|
|
if restricted_fields:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=f"Cannot update fields: {restricted_fields}"
|
|
)
|
|
|
|
# Store original values for audit
|
|
original_values = {
|
|
"username": user.username,
|
|
"email": user.email,
|
|
"role": user.role,
|
|
"is_active": user.is_active
|
|
}
|
|
|
|
# Update user fields
|
|
update_data = user_data.model_dump(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
setattr(user, field, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
|
|
# Log audit event
|
|
await log_audit_event(
|
|
db=db,
|
|
user_id=current_user['id'],
|
|
action="update_user",
|
|
resource_type="user",
|
|
resource_id=user_id,
|
|
details={
|
|
"updated_fields": list(update_data.keys()),
|
|
"before_values": original_values,
|
|
"after_values": {k: getattr(user, k) for k in update_data.keys()}
|
|
}
|
|
)
|
|
|
|
logger.info(f"User updated: {user.username} by {current_user['username']}")
|
|
|
|
return UserResponse.model_validate(user)
|
|
|
|
|
|
@router.delete("/{user_id}")
|
|
async def delete_user(
|
|
user_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Delete user (soft delete by deactivating)"""
|
|
|
|
# Check permissions
|
|
require_permission(current_user.get("permissions", []), "platform:users:delete")
|
|
|
|
# Prevent self-deletion
|
|
if int(user_id) == current_user['id']:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot delete your own account"
|
|
)
|
|
|
|
# Get user
|
|
query = select(User).where(User.id == int(user_id))
|
|
result = await db.execute(query)
|
|
user = result.scalar_one_or_none()
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found"
|
|
)
|
|
|
|
# Soft delete by deactivating
|
|
user.is_active = False
|
|
await db.commit()
|
|
|
|
# Log audit event
|
|
await log_audit_event(
|
|
db=db,
|
|
user_id=current_user['id'],
|
|
action="delete_user",
|
|
resource_type="user",
|
|
resource_id=user_id,
|
|
details={"username": user.username, "email": user.email}
|
|
)
|
|
|
|
logger.info(f"User deleted: {user.username} by {current_user['username']}")
|
|
|
|
return {"message": "User deleted successfully"}
|
|
|
|
|
|
@router.post("/{user_id}/change-password")
|
|
async def change_password(
|
|
user_id: str,
|
|
password_data: PasswordChangeRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Change user password"""
|
|
|
|
# Users can only change their own password, or admins can change any password
|
|
is_self_update = int(user_id) == current_user['id']
|
|
if not is_self_update:
|
|
require_permission(current_user.get("permissions", []), "platform:users:update")
|
|
|
|
# Get user
|
|
query = select(User).where(User.id == int(user_id))
|
|
result = await db.execute(query)
|
|
user = result.scalar_one_or_none()
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found"
|
|
)
|
|
|
|
# For self-updates, verify current password
|
|
if is_self_update:
|
|
if not verify_password(password_data.current_password, user.hashed_password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Current password is incorrect"
|
|
)
|
|
|
|
# Update password
|
|
user.hashed_password = get_password_hash(password_data.new_password)
|
|
await db.commit()
|
|
|
|
# Log audit event
|
|
await log_audit_event(
|
|
db=db,
|
|
user_id=current_user['id'],
|
|
action="change_password",
|
|
resource_type="user",
|
|
resource_id=user_id,
|
|
details={"target_user": user.username}
|
|
)
|
|
|
|
logger.info(f"Password changed for user: {user.username} by {current_user['username']}")
|
|
|
|
return {"message": "Password changed successfully"}
|
|
|
|
|
|
@router.post("/{user_id}/reset-password")
|
|
async def reset_password(
|
|
user_id: str,
|
|
password_data: PasswordResetRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Reset user password (admin only)"""
|
|
|
|
# Check permissions
|
|
require_permission(current_user.get("permissions", []), "platform:users:update")
|
|
|
|
# Get user
|
|
query = select(User).where(User.id == int(user_id))
|
|
result = await db.execute(query)
|
|
user = result.scalar_one_or_none()
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found"
|
|
)
|
|
|
|
# Reset password
|
|
user.hashed_password = get_password_hash(password_data.new_password)
|
|
await db.commit()
|
|
|
|
# Log audit event
|
|
await log_audit_event(
|
|
db=db,
|
|
user_id=current_user['id'],
|
|
action="reset_password",
|
|
resource_type="user",
|
|
resource_id=user_id,
|
|
details={"target_user": user.username}
|
|
)
|
|
|
|
logger.info(f"Password reset for user: {user.username} by {current_user['username']}")
|
|
|
|
return {"message": "Password reset successfully"}
|
|
|
|
|
|
@router.get("/{user_id}/api-keys", response_model=List[dict])
|
|
async def get_user_api_keys(
|
|
user_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get API keys for a user"""
|
|
|
|
# Check permissions (users can view their own API keys)
|
|
is_self_request = int(user_id) == current_user['id']
|
|
if not is_self_request:
|
|
require_permission(current_user.get("permissions", []), "platform:api-keys:read")
|
|
|
|
# Get API keys
|
|
query = select(APIKey).where(APIKey.user_id == int(user_id))
|
|
result = await db.execute(query)
|
|
api_keys = result.scalars().all()
|
|
|
|
# Return safe representation (no key values)
|
|
return [
|
|
{
|
|
"id": str(api_key.id),
|
|
"name": api_key.name,
|
|
"key_prefix": api_key.key_prefix,
|
|
"scopes": api_key.scopes,
|
|
"is_active": api_key.is_active,
|
|
"created_at": api_key.created_at.isoformat(),
|
|
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
|
|
"last_used_at": api_key.last_used_at.isoformat() if api_key.last_used_at else None
|
|
}
|
|
for api_key in api_keys
|
|
] |