Files
leocrm/app/routes/users.py
T
Agent Zero 824686c673 fix(security): F02 (Astra P0) — globale Login-Identitaet von Mandantenverwaltung trennen
Vorher: Ein Mandanten-Admin (users:write) konnte die globale User.email
und das Passwort JEDES Mitglieds seines Mandanten aendern. User ist
aber mandantenuebergreifend — derselbe Datensatz traegt Passwort und
Systemadmin-Flag; der Passwort-Reset nutzt die veraenderbare Adresse.
Ein Admin aus Mandant A konnte so die globale Reset-Adresse eines
gemeinsamen Benutzers umlenken (Astra-Repro: globale Feldaenderung
isoliert reproduziert).

Fix (routes/users.py update_user):
- email/new_password fuer FREMDE User -> 403 global_identity_forbidden
  (nur Selbstservice oder echter System-Admin)
- is_active fuer MEHRMANDANTEN-User durch Tenant-Admin -> 403
  multi_tenant_status_forbidden (Deaktivierung waere global sperrend;
  Single-Mandanten-Mitglieder duerfen wie bisher deaktiviert werden)
- is_system_admin-Eskalationscheck unberuehrt (war schon korrekt)

Abnahme (Astra): Ein Tenant-Verwalter kann weder die globale E-Mail-
Adresse noch den globalen Aktivstatus eines gemeinsamen Benutzers
veraendern — erfuellt.

Tests: test_user_service.py 13/13 (5 neue F02-Tests: fremde E-Mail 403,
fremdes Passwort 403, Mehrmandanten-Deaktivierung 403, Name-Aenderung
bleibt 200, Selbstservice bleibt 200). ruff clean.
2026-09-17 22:58:38 +02:00

438 lines
15 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 func, 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, UserTenant
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"},
)
# F02 (Astra P0): global identity fields vs. tenant administration.
# User.email, password and is_active live on the GLOBAL user record
# (shared across tenants). A tenant admin (users:write) must not change
# another member's global login identity: that would change the
# password-reset address / login credentials of a user who may also
# belong to other tenants. Allowed only as verified self-service or
# by a real system admin.
acting_is_system_admin = bool(current_user.get("is_system_admin"))
is_self = uid == acting_user_id
if not is_self and not acting_is_system_admin:
if body.email is not None or body.new_password is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"detail": "Global identity (email/password) can only be changed by the user themselves or a system admin",
"code": "global_identity_forbidden",
},
)
if body.is_active is not None:
# Global activation status: a tenant admin may deactivate a
# member of THEIR tenant, but only if the user belongs solely
# to this tenant. For multi-tenant users, deactivation here
# would lock them out of every other tenant too.
membership_q = await db.execute(
select(func.count()).select_from(UserTenant).where(
UserTenant.user_id == uid
)
)
membership_count = membership_q.scalar() or 0
if membership_count > 1:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"detail": "User belongs to multiple tenants — global activation status can only be changed by a system admin",
"code": "multi_tenant_status_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}