Files
sapl/backend/routers/users.py
T
Ajay SableandClaude Opus 4.8 7702110686 feat(admin): quick-assign a role by email from the Roles tab
Each role's detail panel gains an "Add someone to <role>" box: type an
email and they're placed in that role. Existing users are switched to the
role; new company SSO (@saplind.com) emails are auto-created (no password,
Google sign-in). Non-SSO new emails are directed to the Users tab. Backend
POST /admin/roles/{role}/members upserts with the last-active-admin guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:09:38 +05:30

375 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Granular user & access management — admin surface.
Mounted at /api/admin; every route requires the `users:manage` permission.
Manages: user accounts (create / edit / activate / reset password), roles (with an
editable module×level permission matrix + data scope), and departments.
"""
import json
import logging
import secrets
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from config import settings
from models.user import User
from models.rbac import Role, Department
from routers.auth import current_user, get_db, require_permission
from services.auth_service import hash_password
from services.rbac import MODULES, LEVELS, ADMIN_ROLE, is_admin
def _is_sso_email(email: str) -> bool:
"""True when Google SSO is enabled and this email is on the allowed company
domain — such accounts sign in with Google, so an initial password is optional."""
domain = (settings.google_allowed_domain or "").strip().lower()
return bool(settings.google_client_id) and bool(domain) and email.endswith("@" + domain)
logger = logging.getLogger(__name__)
router = APIRouter()
# Every endpoint here is admin-only (users:manage).
admin_guard = Depends(require_permission("users", "manage"))
# ── serialization ─────────────────────────────────────────────────────────────
def _dept_name(db: Session, dept_id: str | None) -> str | None:
if not dept_id:
return None
d = db.query(Department).filter(Department.id == dept_id).first()
return d.name if d else None
def _user_row(db: Session, u: User) -> dict:
return {
"id": u.id, "email": u.email, "name": u.name, "role": u.role,
"department_id": u.department_id, "department": _dept_name(db, u.department_id),
"is_active": bool(u.is_active),
}
def _role_row(db: Session, r: Role) -> dict:
try:
perms = json.loads(r.permissions) if r.permissions else {}
except (ValueError, TypeError):
perms = {}
return {
"id": r.id, "name": r.name, "description": r.description,
"is_system": bool(r.is_system), "scope": r.scope,
"permissions": {m: (perms.get(m) if perms.get(m) in LEVELS else "none") for m in MODULES},
"user_count": db.query(User).filter(User.role == r.name).count(),
}
def _validate_matrix(perms: dict) -> dict:
"""Keep only known modules/levels; default unknowns to 'none'."""
perms = perms or {}
return {m: (perms.get(m) if perms.get(m) in LEVELS else "none") for m in MODULES}
def _active_admin_count(db: Session) -> int:
return db.query(User).filter(User.role == ADMIN_ROLE, User.is_active == True).count() # noqa: E712
# ── meta ──────────────────────────────────────────────────────────────────────
@router.get("/meta", dependencies=[admin_guard])
def get_meta():
"""Module + level vocabulary for the matrix editor."""
return {"modules": MODULES, "levels": LEVELS}
# ── users ─────────────────────────────────────────────────────────────────────
class UserCreate(BaseModel):
email: str
name: str
role: str
department_id: str | None = None
password: str | None = None # optional for company SSO accounts (see _is_sso_email)
class UserUpdate(BaseModel):
name: str | None = None
email: str | None = None
role: str | None = None
department_id: str | None = None
is_active: bool | None = None
class PasswordReset(BaseModel):
password: str
@router.get("/users", dependencies=[admin_guard])
def list_users(db: Session = Depends(get_db)):
users = db.query(User).order_by(User.created_at).all()
return {"users": [_user_row(db, u) for u in users]}
@router.post("/users", dependencies=[admin_guard])
def create_user(req: UserCreate, db: Session = Depends(get_db)):
email = (req.email or "").strip().lower()
if not email or not req.name.strip():
raise HTTPException(status_code=400, detail="email and name are required")
# Password is optional for company SSO accounts (they sign in with Google);
# for everyone else it's still required.
if req.password:
password_hash = hash_password(req.password)
elif _is_sso_email(email):
password_hash = hash_password(secrets.token_urlsafe(24)) # unusable — Google-only sign-in
else:
raise HTTPException(status_code=400, detail="A password is required for non-SSO accounts")
if db.query(User).filter(User.email == email).first():
raise HTTPException(status_code=409, detail="A user with that email already exists")
if not db.query(Role).filter(Role.name == req.role).first():
raise HTTPException(status_code=400, detail=f"Unknown role '{req.role}'")
if req.department_id and not db.query(Department).filter(Department.id == req.department_id).first():
raise HTTPException(status_code=400, detail="Unknown department")
u = User(email=email, name=req.name.strip(), role=req.role,
department_id=req.department_id, is_active=True,
password_hash=password_hash)
db.add(u)
db.commit()
db.refresh(u)
logger.info(f"User created: {email} ({req.role})")
return _user_row(db, u)
class RoleMemberAdd(BaseModel):
email: str
name: str | None = None
@router.post("/roles/{role_name}/members", dependencies=[admin_guard])
def add_role_member(role_name: str, req: RoleMemberAdd, db: Session = Depends(get_db)):
"""Quick-assign a role by email from the Roles tab. If the user exists, switch
them to this role; otherwise auto-create them (company SSO emails need no
password). Returns the user row + whether it was newly created."""
role = db.query(Role).filter(Role.name == role_name).first()
if not role:
raise HTTPException(status_code=404, detail=f"Unknown role '{role_name}'")
email = (req.email or "").strip().lower()
if not email:
raise HTTPException(status_code=400, detail="email is required")
user = db.query(User).filter(User.email == email).first()
if user:
# Reassigning an existing user — keep the last-active-admin safety net.
if is_admin(user) and role.name != ADMIN_ROLE and _active_admin_count(db) <= 1:
raise HTTPException(status_code=400, detail="Can't remove the last active admin")
user.role = role.name
db.commit()
db.refresh(user)
logger.info(f"Role assigned: {email} -> {role.name}")
return {"user": _user_row(db, user), "created": False}
# New user: quick-add only supports company SSO emails (no password to set here).
if not _is_sso_email(email):
raise HTTPException(status_code=400, detail="Add non-SSO users from the Users tab (they need a password)")
user = User(email=email, name=(req.name or email.split("@")[0]).strip(), role=role.name,
department_id=None, is_active=True,
password_hash=hash_password(secrets.token_urlsafe(24)))
db.add(user)
db.commit()
db.refresh(user)
logger.info(f"Role member created: {email} ({role.name})")
return {"user": _user_row(db, user), "created": True}
@router.patch("/users/{user_id}", dependencies=[admin_guard])
def update_user(user_id: str, req: UserUpdate, db: Session = Depends(get_db),
me: User = Depends(current_user)):
u = db.query(User).filter(User.id == user_id).first()
if not u:
raise HTTPException(status_code=404, detail="User not found")
# Guard: don't strip the last active admin, and don't lock yourself out.
losing_admin = is_admin(u) and (
(req.role is not None and req.role != ADMIN_ROLE) or req.is_active is False
)
if losing_admin and _active_admin_count(db) <= 1:
raise HTTPException(status_code=400, detail="Can't remove the last active admin")
if req.is_active is False and u.id == me.id:
raise HTTPException(status_code=400, detail="You can't deactivate your own account")
if req.email is not None:
new_email = req.email.strip().lower()
clash = db.query(User).filter(User.email == new_email, User.id != u.id).first()
if clash:
raise HTTPException(status_code=409, detail="That email is already in use")
u.email = new_email
if req.name is not None:
u.name = req.name.strip()
if req.role is not None:
if not db.query(Role).filter(Role.name == req.role).first():
raise HTTPException(status_code=400, detail=f"Unknown role '{req.role}'")
u.role = req.role
if req.department_id is not None:
if req.department_id and not db.query(Department).filter(Department.id == req.department_id).first():
raise HTTPException(status_code=400, detail="Unknown department")
u.department_id = req.department_id or None
if req.is_active is not None:
u.is_active = req.is_active
db.commit()
db.refresh(u)
return _user_row(db, u)
@router.post("/users/{user_id}/reset-password", dependencies=[admin_guard])
def reset_password(user_id: str, req: PasswordReset, db: Session = Depends(get_db)):
u = db.query(User).filter(User.id == user_id).first()
if not u:
raise HTTPException(status_code=404, detail="User not found")
if not req.password or len(req.password) < 6:
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
u.password_hash = hash_password(req.password)
db.commit()
return {"ok": True}
# ── roles ─────────────────────────────────────────────────────────────────────
class RoleCreate(BaseModel):
name: str
description: str | None = None
scope: str = "own"
permissions: dict = {}
class RoleUpdate(BaseModel):
name: str | None = None
description: str | None = None
scope: str | None = None
permissions: dict | None = None
@router.get("/roles", dependencies=[admin_guard])
def list_roles(db: Session = Depends(get_db)):
roles = db.query(Role).order_by(Role.created_at).all()
return {"roles": [_role_row(db, r) for r in roles]}
@router.post("/roles", dependencies=[admin_guard])
def create_role(req: RoleCreate, db: Session = Depends(get_db)):
name = (req.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Role name is required")
if db.query(Role).filter(Role.name == name).first():
raise HTTPException(status_code=409, detail="A role with that name already exists")
if req.scope not in ("all", "own"):
raise HTTPException(status_code=400, detail="scope must be 'all' or 'own'")
r = Role(name=name, description=req.description, is_system=False, scope=req.scope,
permissions=json.dumps(_validate_matrix(req.permissions)))
db.add(r)
db.commit()
db.refresh(r)
return _role_row(db, r)
@router.patch("/roles/{role_id}", dependencies=[admin_guard])
def update_role(role_id: str, req: RoleUpdate, db: Session = Depends(get_db)):
r = db.query(Role).filter(Role.id == role_id).first()
if not r:
raise HTTPException(status_code=404, detail="Role not found")
# The Admin role's name is load-bearing (hardcoded all-manage safety) — keep it.
if r.name == ADMIN_ROLE and req.name is not None and req.name != ADMIN_ROLE:
raise HTTPException(status_code=400, detail="The Admin role can't be renamed")
if req.name is not None and req.name.strip() != r.name:
new_name = req.name.strip()
if db.query(Role).filter(Role.name == new_name).first():
raise HTTPException(status_code=409, detail="A role with that name already exists")
# keep assigned users pointed at the role (we link by name)
db.query(User).filter(User.role == r.name).update({User.role: new_name})
r.name = new_name
if req.description is not None:
r.description = req.description
if req.scope is not None:
if req.scope not in ("all", "own"):
raise HTTPException(status_code=400, detail="scope must be 'all' or 'own'")
r.scope = req.scope
if req.permissions is not None:
r.permissions = json.dumps(_validate_matrix(req.permissions))
db.commit()
db.refresh(r)
return _role_row(db, r)
@router.delete("/roles/{role_id}", dependencies=[admin_guard])
def delete_role(role_id: str, db: Session = Depends(get_db)):
r = db.query(Role).filter(Role.id == role_id).first()
if not r:
raise HTTPException(status_code=404, detail="Role not found")
if r.is_system:
raise HTTPException(status_code=400, detail="Built-in roles can't be deleted")
in_use = db.query(User).filter(User.role == r.name).count()
if in_use:
raise HTTPException(status_code=400, detail=f"{in_use} user(s) still have this role")
db.delete(r)
db.commit()
return {"ok": True}
# ── departments ───────────────────────────────────────────────────────────────
class DeptCreate(BaseModel):
name: str
description: str | None = None
class DeptUpdate(BaseModel):
name: str | None = None
description: str | None = None
@router.get("/departments", dependencies=[admin_guard])
def list_departments(db: Session = Depends(get_db)):
depts = db.query(Department).order_by(Department.name).all()
return {"departments": [{
"id": d.id, "name": d.name, "description": d.description,
"user_count": db.query(User).filter(User.department_id == d.id).count(),
} for d in depts]}
@router.post("/departments", dependencies=[admin_guard])
def create_department(req: DeptCreate, db: Session = Depends(get_db)):
name = (req.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Department name is required")
if db.query(Department).filter(Department.name == name).first():
raise HTTPException(status_code=409, detail="A department with that name already exists")
d = Department(name=name, description=req.description)
db.add(d)
db.commit()
db.refresh(d)
return {"id": d.id, "name": d.name, "description": d.description, "user_count": 0}
@router.patch("/departments/{dept_id}", dependencies=[admin_guard])
def update_department(dept_id: str, req: DeptUpdate, db: Session = Depends(get_db)):
d = db.query(Department).filter(Department.id == dept_id).first()
if not d:
raise HTTPException(status_code=404, detail="Department not found")
if req.name is not None and req.name.strip() != d.name:
new_name = req.name.strip()
if db.query(Department).filter(Department.name == new_name).first():
raise HTTPException(status_code=409, detail="A department with that name already exists")
d.name = new_name
if req.description is not None:
d.description = req.description
db.commit()
db.refresh(d)
return {"id": d.id, "name": d.name, "description": d.description,
"user_count": db.query(User).filter(User.department_id == d.id).count()}
@router.delete("/departments/{dept_id}", dependencies=[admin_guard])
def delete_department(dept_id: str, db: Session = Depends(get_db)):
d = db.query(Department).filter(Department.id == dept_id).first()
if not d:
raise HTTPException(status_code=404, detail="Department not found")
in_use = db.query(User).filter(User.department_id == d.id).count()
if in_use:
raise HTTPException(status_code=400, detail=f"{in_use} user(s) still belong to this department")
db.delete(d)
db.commit()
return {"ok": True}