fix(users): roles/is_admin null -> 422, admin non si toglie da solo (#36 punto 4)

roles e is_admin sono NOT NULL nel DB ma Optional nello schema: {"roles":
null} o {"is_admin": null} passava la validazione Pydantic e falliva al
flush con un 500 non gestito. Ora 422 esplicito. Aggiunta anche la
protezione sull'ultimo admin: un admin non puo' togliersi is_admin da solo
(409) - unico caso raggiungibile via API dato che solo un admin puo'
chiamare l'endpoint, quindi rimuovere il flag a qualcun altro lascia sempre
il chiamante; il controllo sul conteggio resta comunque come difesa in
profondita'.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:45:43 +02:00
parent 8864b2021b
commit 3e99b64fc4
2 changed files with 92 additions and 1 deletions
+37 -1
View File
@@ -1,6 +1,6 @@
"""Users router - CRUD operations (admin only)."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
@@ -78,6 +78,21 @@ async def update_user(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
update_data = data.model_dump(exclude_unset=True)
# roles and is_admin are NOT NULL columns: an explicit null passed the
# Optional[...] schema check but blew up at flush time as an unhandled
# 500 (#36 point 4).
if "roles" in update_data and update_data["roles"] is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="roles cannot be null",
)
if "is_admin" in update_data and update_data["is_admin"] is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="is_admin cannot be null",
)
# Validate roles if provided
if "roles" in update_data:
for role in update_data["roles"]:
@@ -86,6 +101,27 @@ async def update_user(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid role '{role}'. Valid roles: {VALID_ROLES}",
)
# Losing the last admin locks everyone out of user management; losing
# your own admin flag while acting as admin is the same mistake aimed
# at yourself. Neither is reversible without direct DB access.
if update_data.get("is_admin") is False and user.is_admin:
if user.id == admin.id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot remove your own admin privilege",
)
other_admins = await db.execute(
select(func.count()).select_from(User).where(
User.is_admin == True, User.id != user.id # noqa: E712
)
)
if other_admins.scalar_one() == 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot remove admin privilege from the last admin",
)
for field, value in update_data.items():
setattr(user, field, value)
await db.flush()