"""User and group administration.""" from __future__ import annotations import logging from fastapi import APIRouter, Form, HTTPException, Request, Response, status from fastapi.responses import RedirectResponse from sqlalchemy import func, or_, select from sqlalchemy.orm import Session as DBSession from lembas.api.deps import AdminUser, Db from lembas.db.models import ROLE_ADMIN, ROLE_PENDING, ROLE_USER, Group, Model, User from lembas.security import permissions from lembas.security.passwords import hash_password, validate_password from lembas.security.sessions import revoke_all_for_user from lembas.services import settings_store from lembas.web.templating import render log = logging.getLogger(__name__) router = APIRouter(prefix="/admin", tags=["admin-users"]) ROLES = (ROLE_ADMIN, ROLE_USER, ROLE_PENDING) def _user(db: DBSession, user_id: str) -> User: found = db.get(User, user_id) if found is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "That user no longer exists.") return found def _group(db: DBSession, group_id: str) -> Group: found = db.get(Group, group_id) if found is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "That group no longer exists.") return found def _admin_count(db: DBSession) -> int: return db.scalar( select(func.count()).select_from(User).where(User.role == ROLE_ADMIN, User.active.is_(True)) ) def _would_orphan_the_instance(db: DBSession, user: User) -> bool: """True if changing this user would leave nobody able to administer. An instance with no active administrator can only be recovered from the command line, so every path that could cause it is blocked in the UI. """ return user.role == ROLE_ADMIN and user.active and _admin_count(db) <= 1 # --- Users ------------------------------------------------------------------- @router.get("/users") async def users_page(request: Request, db: Db, user: AdminUser, q: str = "", saved: str = ""): query = select(User).order_by(User.created_at) if q.strip(): pattern = f"%{q.strip()}%" query = query.where(or_(User.name.ilike(pattern), User.email.ilike(pattern))) return render( request, "admin/users.html", { "users": list(db.scalars(query)), "groups": list(db.scalars(select(Group).order_by(Group.name))), "roles": ROLES, "q": q, "saved": saved, "admin_count": _admin_count(db), }, ) @router.post("/users") async def create_user( db: Db, user: AdminUser, name: str = Form(...), email: str = Form(...), password: str = Form(...), role: str = Form(ROLE_USER), ) -> Response: """Create an account directly, without going through registration.""" email = email.strip().lower() if (problem := validate_password(password)) is not None: return RedirectResponse(f"/admin/users?saved={problem}", status_code=303) if db.scalar(select(User).where(User.email == email)) is not None: return RedirectResponse( "/admin/users?saved=That+email+is+already+registered.", status_code=303 ) db.add( User( name=name.strip()[:120] or email, email=email, password_hash=hash_password(password), role=role if role in ROLES else ROLE_USER, ) ) db.commit() log.info("%s created account %s", user.email, email) return RedirectResponse(f"/admin/users?saved=Created+{email}.", status_code=303) @router.post("/users/{user_id}") async def update_user( db: Db, user: AdminUser, user_id: str, name: str = Form(...), role: str = Form(ROLE_USER), active: bool = Form(False), group_ids: list[str] = Form(default=[]), ) -> Response: target = _user(db, user_id) losing_admin = target.role == ROLE_ADMIN and (role != ROLE_ADMIN or not active) if losing_admin and _would_orphan_the_instance(db, target): return RedirectResponse( "/admin/users?saved=That+is+the+only+administrator.+Promote+someone+else+first.", status_code=303, ) target.name = name.strip()[:120] or target.name target.role = role if role in ROLES else target.role target.active = active target.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or [])))) # A deactivated or demoted user must lose their live sessions immediately, # otherwise the change only takes effect when their cookie happens to expire. if not active: revoke_all_for_user(db, target) db.commit() log.info("%s updated account %s (role=%s active=%s)", user.email, target.email, role, active) return RedirectResponse(f"/admin/users?saved=Saved+{target.email}.", status_code=303) @router.post("/users/{user_id}/password") async def reset_password( db: Db, user: AdminUser, user_id: str, password: str = Form(...) ) -> Response: target = _user(db, user_id) if (problem := validate_password(password)) is not None: return RedirectResponse(f"/admin/users?saved={problem}", status_code=303) target.password_hash = hash_password(password) db.commit() # Everywhere that account was signed in is now signed out. An admin reset # usually means the account is compromised or the person is gone. revoke_all_for_user(db, target) log.info("%s reset the password for %s", user.email, target.email) return RedirectResponse( f"/admin/users?saved=Password+reset+for+{target.email}.+Sessions+revoked.", status_code=303, ) @router.post("/users/{user_id}/delete") async def delete_user(db: Db, user: AdminUser, user_id: str) -> Response: target = _user(db, user_id) if target.id == user.id: return RedirectResponse( "/admin/users?saved=You+cannot+delete+your+own+account.", status_code=303 ) if _would_orphan_the_instance(db, target): return RedirectResponse( "/admin/users?saved=That+is+the+only+administrator.", status_code=303 ) email = target.email # Chats and folders cascade; that is the point of deleting an account. db.delete(target) db.commit() log.info("%s deleted account %s", user.email, email) return RedirectResponse(f"/admin/users?saved=Deleted+{email}.", status_code=303) # --- Groups ------------------------------------------------------------------ @router.get("/groups") async def groups_page(request: Request, db: Db, user: AdminUser, saved: str = ""): return render( request, "admin/groups.html", { "groups": list(db.scalars(select(Group).order_by(Group.name))), "users": list(db.scalars(select(User).order_by(User.name))), "models": list(db.scalars(select(Model).order_by(Model.position, Model.model_id))), "permission_groups": permissions.permission_groups(), "baseline": permissions.baseline_permissions(db), "saved": saved, }, ) @router.post("/groups") async def create_group(db: Db, user: AdminUser, name: str = Form(...)) -> Response: name = name.strip()[:120] if not name: return RedirectResponse("/admin/groups?saved=A+group+needs+a+name.", status_code=303) if db.scalar(select(Group).where(Group.name == name)) is not None: return RedirectResponse( "/admin/groups?saved=A+group+with+that+name+already+exists.", status_code=303 ) db.add(Group(name=name)) db.commit() log.info("%s created group %s", user.email, name) return RedirectResponse(f"/admin/groups?saved=Created+{name}.", status_code=303) @router.post("/groups/{group_id}") async def update_group( db: Db, user: AdminUser, group_id: str, name: str = Form(...), description: str = Form(""), permission: list[str] = Form(default=[]), user_ids: list[str] = Form(default=[]), model_ids: list[str] = Form(default=[]), ) -> Response: group = _group(db, group_id) group.name = name.strip()[:120] or group.name group.description = description.strip()[:1000] # The submitted checkbox list is the complete new state; absent means the # group does not grant that permission, not that it denies it. group.permissions_json = {key: True for key in permission if key in permissions.PERMISSION_KEYS} group.users = list(db.scalars(select(User).where(User.id.in_(user_ids or [])))) group.models = list(db.scalars(select(Model).where(Model.id.in_(model_ids or [])))) db.commit() log.info("%s updated group %s", user.email, group.name) return RedirectResponse(f"/admin/groups?saved=Saved+{group.name}.", status_code=303) @router.post("/groups/{group_id}/delete") async def delete_group(db: Db, user: AdminUser, group_id: str) -> Response: group = _group(db, group_id) name = group.name # Members and model links go with it; the users themselves are untouched. db.delete(group) db.commit() log.info("%s deleted group %s", user.email, name) return RedirectResponse(f"/admin/groups?saved=Deleted+{name}.", status_code=303) @router.post("/permissions/defaults") async def save_baseline( db: Db, user: AdminUser, permission: list[str] = Form(default=[]) ) -> Response: """The permissions every user has before any group widens them.""" settings_store.update( db, { "default_permissions": { key: (key in permission) for key in permissions.PERMISSION_KEYS } }, ) log.info("%s changed the baseline permissions", user.email) return RedirectResponse("/admin/groups?saved=Default+permissions+saved.", status_code=303)