bifroest/app/identity/admin.py
2026-10-27 08:34:17 +01:00

208 lines
9.7 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.

"""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)