"""Verwaltung von Nutzern und Teams durch den Plattform-Admin (→ Konzept 5.4, 14 Phase 1). Später (Phase 5) kommen Nutzer und Teams aus Keycloak; dann werden diese Funktionen zu einer Synchronisierung, die Regeln bleiben dieselben. """ import re from sqlalchemy import func, select from sqlalchemy.orm import Session from app import audit from app.core.errors import Forbidden, Invalid, NotFound from app.identity import Principal, passwords from app.identity import service as identity from app.identity.models import Team, TeamMembership, User from app.notify.service import check_email from app.workspaces.models import Workspace, WorkspaceMembership USERNAME = re.compile(r"^[a-z0-9][a-z0-9._-]{1,63}$") SLUG = re.compile(r"^[a-z0-9][a-z0-9-]{1,63}$") def require(principal: Principal) -> None: if not principal.is_admin: raise Forbidden # --- Nutzer --- def users(session: Session) -> list[User]: return list(session.scalars(select(User).order_by(User.active.desc(), User.display_name))) def teams_by_user(session: Session) -> dict[int, list[str]]: result: dict[int, list[str]] = {} for user_id, name in session.execute(select(TeamMembership.user_id, Team.name) .join(Team, Team.id == TeamMembership.team_id).order_by(Team.name)): result.setdefault(user_id, []).append(name) return result def create_user(session: Session, principal: Principal, username: str, display_name: str, is_admin: bool = False, email: str = "") -> tuple[User, str]: """Legt den Nutzer mit Einmal-Passwort an; das Passwort wird nur hier zurückgegeben.""" require(principal) username, display_name = username.strip().lower(), display_name.strip() if not USERNAME.match(username): raise Invalid("Nutzername: 2–64 Zeichen, a–z, 0–9, Punkt, - und _.") if session.scalar(select(User.id).where(User.username == username)): raise Invalid("Diesen Nutzernamen gibt es schon.") secret = passwords.one_time() user = User(username=username, display_name=display_name or username, is_admin=is_admin, password_hash=passwords.hash_password(secret), must_change_password=True, email=check_email(email)) session.add(user) session.flush() audit.record(session, "user.created", user_id=principal.user_id, target_kind="user", target_id=user.id, username=username, is_admin=is_admin) return user, secret def reset_password(session: Session, principal: Principal, user_id: int) -> tuple[User, str]: """Neues Einmal-Passwort; entsperrt und beendet alle Sitzungen des Nutzers.""" require(principal) user = get_user(session, user_id) secret = passwords.one_time() user.password_hash, user.must_change_password = passwords.hash_password(secret), True user.failed_logins, user.locked_until = 0, None identity.revoke_sessions(session, user.id) audit.record(session, "user.password_reset", user_id=principal.user_id, target_kind="user", target_id=user.id) return user, secret def get_user(session: Session, user_id: int) -> User: user = session.get(User, user_id) if user is None: raise NotFound return user def _active_admins(session: Session) -> int: return session.scalar(select(func.count()).select_from(User).where(User.is_admin, User.active)) def update_user(session: Session, principal: Principal, user_id: int, *, display_name: str, is_admin: bool, active: bool, is_global_approver: bool = False, email: str | None = None) -> User: require(principal) user = get_user(session, user_id) losing_admin = user.is_admin and user.active and (not is_admin or not active) if losing_admin and _active_admins(session) == 1: raise Invalid("Der letzte Plattform-Admin kann nicht herabgestuft oder deaktiviert werden.") user.display_name = display_name.strip() or user.username user.is_admin, user.active, user.is_global_approver = is_admin, active, is_global_approver if email is not None: user.email = check_email(email) if not active: # Laufende Sitzungen sofort beenden identity.revoke_sessions(session, user.id) audit.record(session, "user.updated", user_id=principal.user_id, target_kind="user", target_id=user.id, is_admin=is_admin, active=active, is_global_approver=is_global_approver) return user # --- Teams --- def teams(session: Session) -> list[tuple[Team, int, int]]: """Teams mit Zahl der Mitglieder und Workspaces.""" members = (select(func.count()).select_from(TeamMembership) .where(TeamMembership.team_id == Team.id).scalar_subquery()) spaces = select(func.count()).select_from(Workspace).where(Workspace.team_id == Team.id).scalar_subquery() return [tuple(row) for row in session.execute(select(Team, members, spaces).order_by(Team.name))] def get_team(session: Session, team_id: int) -> Team: team = session.get(Team, team_id) if team is None: raise NotFound return team def save_team(session: Session, principal: Principal, team_id: int | None, *, slug: str, name: str) -> Team: require(principal) slug, name = slug.strip().lower(), name.strip() if not SLUG.match(slug): raise Invalid("Kürzel: 2–64 Zeichen, a–z, 0–9 und -.") if not name: raise Invalid("Bitte einen Namen angeben.") if session.scalar(select(Team.id).where(Team.slug == slug, Team.id != (team_id or 0))): raise Invalid("Dieses Kürzel gibt es schon.") team = get_team(session, team_id) if team_id else Team() team.slug, team.name = slug, name session.add(team) session.flush() audit.record(session, "team.saved", user_id=principal.user_id, target_kind="team", target_id=team.id, slug=slug, name=name) return team def delete_team(session: Session, principal: Principal, team_id: int) -> str: """Team ohne Workspaces löschen; Mitgliedschaften und Team-Keys gehen mit, der Verbrauch bleibt.""" require(principal) team = get_team(session, team_id) if session.scalar(select(Workspace.id).where(Workspace.team_id == team_id).limit(1)): raise Invalid("Das Team hat noch Workspaces (auch im Papierkorb). Erst diese löschen.") audit.record(session, "team.deleted", user_id=principal.user_id, target_kind="team", target_id=team.id, slug=team.slug, name=team.name) name = team.name session.delete(team) return name def team_members(session: Session, team_id: int) -> list[User]: return list(session.scalars(select(User).join(TeamMembership, TeamMembership.user_id == User.id) .where(TeamMembership.team_id == team_id).order_by(User.display_name))) def team_workspaces(session: Session, team_id: int) -> list[Workspace]: return list(session.scalars(select(Workspace).where(Workspace.team_id == team_id).order_by(Workspace.name))) def set_storage_limit(session: Session, principal: Principal, workspace_id: int, limit_mb: str) -> Workspace: """Speicherkontingent eines Workspace in MB; leer = Standard (AIW_WORKSPACE_STORAGE_MB).""" require(principal) workspace = session.get(Workspace, workspace_id) if workspace is None: raise NotFound value = limit_mb.strip() if value and (not value.isdigit() or not 1 <= int(value) <= 1_000_000): raise Invalid("Das Kontingent ist eine ganze Zahl in MB zwischen 1 und 1000000, oder leer für den Standard.") workspace.storage_limit_mb = int(value) if value else None audit.record(session, "workspace.storage_limit_set", user_id=principal.user_id, workspace_id=workspace_id, target_kind="workspace", target_id=workspace_id, limit_mb=workspace.storage_limit_mb) return workspace def add_member(session: Session, principal: Principal, team_id: int, username: str) -> None: require(principal) get_team(session, team_id) user = session.scalar(select(User).where(User.username == username.strip().lower())) if user is None: raise Invalid("Diesen Nutzer gibt es nicht. Erst unter Nutzer anlegen.") if session.get(TeamMembership, (team_id, user.id)) is None: session.add(TeamMembership(team_id=team_id, user_id=user.id)) audit.record(session, "team.member_added", user_id=principal.user_id, target_kind="team", target_id=team_id, member=user.id) def remove_member(session: Session, principal: Principal, team_id: int, user_id: int) -> None: """Aus dem Team entfernen; damit endet auch der Zugriff auf alle Workspaces des Teams.""" require(principal) membership = session.get(TeamMembership, (team_id, user_id)) if membership is None: raise NotFound spaces = select(Workspace.id).where(Workspace.team_id == team_id) for workspace in session.scalars( select(Workspace).join(WorkspaceMembership, WorkspaceMembership.workspace_id == Workspace.id) .where(Workspace.team_id == team_id, WorkspaceMembership.user_id == user_id, WorkspaceMembership.role == "admin")): admins = session.scalar(select(func.count()).select_from(WorkspaceMembership).where( WorkspaceMembership.workspace_id == workspace.id, WorkspaceMembership.role == "admin")) if admins == 1: raise Invalid(f"Ist letzter Admin im Workspace „{workspace.name}“. Erst einen anderen Admin ernennen.") removed = session.execute(WorkspaceMembership.__table__.delete().where( WorkspaceMembership.user_id == user_id, WorkspaceMembership.workspace_id.in_(spaces))).rowcount session.delete(membership) audit.record(session, "team.member_removed", user_id=principal.user_id, target_kind="team", target_id=team_id, member=user_id, workspace_memberships_removed=removed)