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.
This commit is contained in:
Agent Zero
2026-09-17 22:58:38 +02:00
parent f2a7206c7d
commit 824686c673
2 changed files with 176 additions and 11 deletions
+40 -2
View File
@@ -7,7 +7,7 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
@@ -16,7 +16,7 @@ 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.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
@@ -219,6 +219,44 @@ async def update_user(
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