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>
375 lines
16 KiB
Python
375 lines
16 KiB
Python
"""
|
||
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}
|