Files
leocrm/app/routes/users.py
T

400 lines
13 KiB
Python

"""User management routes."""
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.auth import get_redis, invalidate_all_user_sessions
from app.core.db import get_db
from app.core.notifications import post_system_message
from app.core.permissions import invalidate_permission_cache
from app.deps import get_current_user, require_permission
from app.models.user import User
from app.schemas.user import PaginatedUsers, UserCreate, UserResponse, UserUpdate
from app.services.owner_transfer_service import transfer_ownership
from app.services.user_service import _UNSET, user_service
router = APIRouter(prefix="/api/v1/users", tags=["users"])
class MenuOrderRequest(BaseModel):
"""Update the current user's menu order preference."""
menu_order: list[str] = Field(..., min_length=0)
def _parse_role_id(raw: str | None) -> uuid.UUID | None:
"""Convert a string body value into a UUID or None.
Empty string and None are both treated as "clear role_id".
"""
if raw is None or raw == "":
return None
try:
return uuid.UUID(raw)
except (ValueError, AttributeError):
raise HTTPException(
400,
detail={"detail": "Invalid role_id", "code": "invalid_role_id"},
) from None
@router.get("", response_model=PaginatedUsers)
async def list_users(
page: int = Query(1, ge=1),
page_size: int = Query(25, ge=1, le=100),
search: str | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("users:read")),
):
"""List users (admin only, paginated)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
return await user_service.list_users(db, tenant_id, page, page_size, search)
@router.post("", status_code=status.HTTP_201_CREATED, response_model=UserResponse)
async def create_user(
body: UserCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("users:write")),
):
"""Create a new user (admin only)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
role_id = _parse_role_id(body.role_id)
# Mass-Assignment protection: only system admin can create admin users
role = body.role
if role == "admin" and not current_user.get("is_system_admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"detail": "Only system admin can create admin users", "code": "role_escalation_forbidden"},
)
try:
user = await user_service.create_user(
db,
tenant_id,
body.email,
body.name,
body.password,
role,
role_id,
body.is_active,
)
except Exception as exc:
from sqlalchemy.exc import IntegrityError
if isinstance(exc, IntegrityError):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"detail": "User with this email already exists", "code": "duplicate_email"},
) from exc
raise
# Audit log
await log_audit(
db,
tenant_id,
user_id,
"create",
"user",
user.id,
changes={"email": body.email, "name": body.name, "role": body.role, "role_id": body.role_id},
)
# Notification
await post_system_message(
db,
tenant_id,
user.id,
"info",
"Account created",
f"Your account has been created by {current_user['name']}.",
)
# Publish user.created event
from app.core.event_bus import get_event_bus
event_bus = get_event_bus()
await event_bus.publish('user.created', {
'user_id': str(user.id),
'tenant_id': str(tenant_id),
'email': body.email,
'name': body.name,
'role': body.role,
})
return {
"id": str(user.id),
"email": user.email,
"name": user.name,
"role": body.role,
"role_id": str(role_id) if role_id else None,
"is_active": user.is_active,
"tenant_id": str(tenant_id),
}
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("users:read")),
):
"""Get a single user."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
uid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(
400, detail={"detail": "Invalid user_id", "code": "invalid_id"}
) from None
result = await user_service.get_user(db, tenant_id, uid)
if result is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
user, user_tenant = result
return {
"id": str(user.id),
"email": user.email,
"name": user.name,
"role": user_tenant.role,
"role_id": str(user_tenant.role_id) if user_tenant.role_id else None,
"is_active": user.is_active,
"tenant_id": str(user_tenant.tenant_id),
}
@router.patch("/{user_id}")
async def update_user(
user_id: str,
body: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("users:write")),
):
"""Update a user (admin only).
Uses ``model_fields_set`` to detect whether ``role_id`` was explicitly
present in the request body (even if sent as ``null``). This allows
the caller to clear the FK by sending ``role_id: null``.
Self-modification prevention: a user cannot change their own role,
is_active status, or system_admin flag.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
acting_user_id = uuid.UUID(current_user["user_id"])
try:
uid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(
400, detail={"detail": "Invalid user_id", "code": "invalid_id"}
) from None
# Self-modification prevention: cannot change own role or active status
if uid == acting_user_id:
if body.role is not None or body.is_active is not None or "role_id" in body.model_fields_set:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"detail": "Cannot modify your own role or active status", "code": "self_modification_forbidden"},
)
# Mass-Assignment protection: only system admin can change roles to admin
if body.role == "admin" and not current_user.get("is_system_admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"detail": "Only system admin can assign admin role", "code": "role_escalation_forbidden"},
)
# Only system admin can change is_system_admin flag
if body.is_system_admin is not None and not current_user.get("is_system_admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"detail": "Only system admin can change system admin flag", "code": "admin_flag_forbidden"},
)
# Determine if role_id was explicitly sent (Pydantic v2)
role_id_sent = "role_id" in body.model_fields_set
changes: dict[str, Any] = {}
if body.name is not None:
changes["name"] = body.name
if body.role is not None:
changes["role"] = body.role
if role_id_sent:
changes["role_id"] = body.role_id
if body.is_active is not None:
changes["is_active"] = body.is_active
if body.is_system_admin is not None:
changes["is_system_admin"] = body.is_system_admin
# Pass _UNSET sentinel when role_id was not in the request body
# so the service leaves the existing value untouched.
role_id: uuid.UUID | None | Any
if role_id_sent:
role_id = _parse_role_id(body.role_id)
else:
role_id = _UNSET
# Handle profile fields
if body.first_name is not None:
changes["first_name"] = body.first_name
if body.last_name is not None:
changes["last_name"] = body.last_name
if body.email is not None:
changes["email"] = body.email
if body.avatar_url is not None:
changes["avatar_url"] = body.avatar_url
if body.new_password is not None:
changes["password_changed"] = True
try:
result = await user_service.update_user(
db,
tenant_id,
uid,
body.name,
body.role,
role_id,
body.is_active,
body.first_name,
body.last_name,
body.avatar_url,
body.email,
body.current_password,
body.new_password,
body.is_system_admin,
)
except ValueError as exc:
raise HTTPException(400, detail={"detail": str(exc), "code": "invalid_password"}) from None
if result is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
user, user_tenant = result
# Auto-transfer ownership when user is deactivated
if body.is_active is False:
await transfer_ownership(
db,
tenant_id,
from_user_id=uid,
to_user_id=acting_user_id,
)
await log_audit(db, tenant_id, acting_user_id, "update", "user", uid, changes=changes)
# Invalidate permission cache for the updated user
redis = get_redis()
await invalidate_permission_cache(redis, uid, tenant_id)
# If is_system_admin was changed, invalidate ALL sessions for this user
# so the stale admin flag doesn't persist in Redis until TTL (8h)
if body.is_system_admin is not None:
try:
await invalidate_all_user_sessions(redis, uid)
except Exception:
pass # Best-effort — don't fail the update if Redis is down
return {
"id": str(user.id),
"email": user.email,
"name": user.name,
"first_name": user.first_name,
"last_name": user.last_name,
"avatar_url": user.avatar_url,
"role": user_tenant.role,
"role_id": str(user_tenant.role_id) if user_tenant.role_id else None,
"is_active": user.is_active,
"tenant_id": str(user_tenant.tenant_id),
}
@router.delete("/{user_id}")
async def delete_user(
user_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("users:write")),
):
"""Delete a user (admin only)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
acting_user_id = uuid.UUID(current_user["user_id"])
try:
uid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(
400, detail={"detail": "Invalid user_id", "code": "invalid_id"}
) from None
# Get user snapshot for audit before deletion
result = await user_service.get_user(db, tenant_id, uid)
if result is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
user, user_tenant = result
success = await user_service.delete_user(db, tenant_id, uid)
if not success:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
await log_audit(
db,
tenant_id,
acting_user_id,
"delete",
"user",
uid,
changes={"email": user.email, "name": user.name},
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/me/menu-order")
async def get_menu_order(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get the current user's menu order preference."""
user_id = uuid.UUID(current_user["user_id"])
result = await db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
menu_order = user.preferences.get("menu_order", [])
return {"menu_order": menu_order}
@router.put("/me/menu-order")
async def update_menu_order(
body: MenuOrderRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update the current user's menu order preference."""
user_id = uuid.UUID(current_user["user_id"])
menu_order = body.menu_order
result = await db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
prefs = dict(user.preferences) if user.preferences else {}
prefs["menu_order"] = menu_order
user.preferences = prefs
await db.commit()
return {"menu_order": menu_order}