208 lines
9.7 KiB
Python
208 lines
9.7 KiB
Python
"""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)
|