umbau 1.0

This commit is contained in:
2026-09-02 20:30:44 +02:00
parent afe6719f51
commit 5c95b21be7
77 changed files with 10733 additions and 1914 deletions

View File

@ -2,7 +2,7 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime, timezone
from fastapi import Cookie, Depends, Header, HTTPException, Request, Response, WebSocket, status
@ -22,25 +22,21 @@ from app.security.sessions import (
class CurrentUser:
id: int
username: str
is_admin: bool # Super-Admin (siehe Migration 0006, Mandantenfaehigkeit):
# sieht/verwaltet ALLE Mandanten. Ein Token ist NIE is_admin=True, auch
# wenn der zugehoerige User selbst Super-Admin ist (siehe unten).
tenant_admin_ids: frozenset[int] = field(default_factory=frozenset)
# Nur bei Token-Auth gesetzt: das Token ist zusaetzlich auf GENAU diesen
# einen Mandanten eingeschraenkt (siehe app/db/migrations/0006_tenants.sql:
# api_tokens.tenant_id ist NOT NULL -- jedes Token gehoert einem Mandanten).
token_tenant_id: int | None = None
@property
def is_any_admin(self) -> bool:
"""Super-Admin ODER Mandanten-Admin (mind. eines Mandanten)."""
return self.is_admin or bool(self.tenant_admin_ids)
async def _load_tenant_admin_ids(conn, user_id: int) -> frozenset[int]:
cursor = await conn.execute("SELECT tenant_id FROM tenant_admins WHERE user_id = ?", (user_id,))
rows = await cursor.fetchall()
return frozenset(r[0] for r in rows)
is_admin: bool # Admin: sieht/verwaltet alle Ressourcen. Ein Token ist NIE
# is_admin=True, auch wenn der zugehoerige User selbst Admin ist (siehe unten).
is_token: bool = False
# Teil D Schritt 5 (S4-Loesung): unterscheidet einen echten Nicht-Admin-
# Session-User (is_admin=False, is_token=False -- kommt ueber eine
# Hostgruppen-Rolle durch require_admin_scope_or_host_role) von einem
# API-Token mit passendem Scope (is_admin=False, is_token=True --
# laut Docstring dort admin-aequivalent FUER SEINEN SCOPE, "kein Token
# traegt je eine Hostgruppen-Rolle"). Vor dieser Unterscheidung liessen
# sich beide Faelle in einer Dependency nicht auseinanderhalten -- siehe
# FORTSETZUNG_Teil_D.md Abschnitt "Bewusst NICHT in Schritt 1 erledigt:
# S4" fuer die damals bewusst aufgeschobene Begruendung. Wird in
# app/admin/routes.py (map_ssh_key_to_host/assign_rdp_credential_to_host)
# verwendet, um die Achse-B-Freigabepruefung NUR fuer echte Nicht-Admin-
# Session-User zu erzwingen, nicht fuer Tokens.
async def get_current_user(
@ -78,8 +74,7 @@ async def get_current_user(
path="/",
)
tenant_admin_ids = await _load_tenant_admin_ids(conn, row[0])
return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2]), tenant_admin_ids=tenant_admin_ids)
return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2]))
async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
@ -106,18 +101,20 @@ async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
async def require_global_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
"""NUR Super-Admin (Session, kein Token). Fuer die maechtigsten Aktionen:
Mandanten anlegen/loeschen, Mandanten-Admins ernennen, API-Dokumentation."""
"""Nur Admin-Session (kein Token). Fuer die maechtigsten Aktionen:
Sitzungsverwaltung (/admin/sessions*), API-Dokumentation (/docs)."""
if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Super-Admin-Rechte erforderlich")
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
return user
async def require_admin_session(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
"""Super-Admin ODER Mandanten-Admin, aber ausschliesslich per Session
(NIE per Token) -- fuer Aktionen, die bewusst keinen Token-Bypass erlauben
sollen (z.B. Token-Verwaltung selbst, Mandanten-Admin-Zuweisung lesen)."""
if not user.is_any_admin:
"""Admin, aber ausschliesslich per Session (NIE per Token) -- fuer
Aktionen, die bewusst keinen Token-Bypass erlauben sollen (z.B. die
Token-Verwaltung selbst). Das ist der Privilege-Escalation-Schutz der
Token-Verwaltung: ein geleaktes Token darf sich damit nicht selbst
weitere/staerkere Tokens ausstellen."""
if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
return user
@ -163,12 +160,11 @@ def require_host_role(role_name: str):
# User, Rollen, ...), NICHT fuer SSH/RDP/SFTP-Sitzungen selbst -- die bleiben
# ausschliesslich an eine eingeloggte, TOTP-verifizierte Browser-Session
# gebunden. Ein Token ist nie maechtiger als sein explizit vergebener Scope,
# auch wenn der zugehoerige User Admin ist (kein automatisches is_admin), UND
# ist seit Migration 0006 zusaetzlich auf GENAU EINEN Mandanten eingeschraenkt
# (token_tenant_id). Die Tokenverwaltung selbst (/admin/tokens/*) laeuft
# ausschliesslich ueber require_admin_session (Session, Super- ODER
# Mandanten-Admin), niemals ueber ein Token -- ein geleaktes Token darf sich
# damit nicht selbst weitere/staerkere Tokens ausstellen.
# auch wenn der zugehoerige User Admin ist (kein automatisches is_admin). Die
# Tokenverwaltung selbst (/admin/tokens/*) laeuft ausschliesslich ueber
# require_admin_session (reine Session-Aktion), niemals ueber ein Token -- ein
# geleaktes Token darf sich damit nicht selbst weitere/staerkere Tokens
# ausstellen.
async def _validate_api_token(token: str, *, resource: str, action: str) -> CurrentUser | None:
@ -177,7 +173,7 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr
conn = get_db()
token_hash = hash_token(token)
cursor = await conn.execute(
"SELECT at.id, at.user_id, at.scopes_json, at.expires_at, at.revoked_at, at.tenant_id, "
"SELECT at.id, at.user_id, at.scopes_json, at.expires_at, at.revoked_at, "
"u.username, u.is_active "
"FROM api_tokens at JOIN users u ON u.id = at.user_id "
"WHERE at.token_hash = ? AND u.deleted_at IS NULL",
@ -186,7 +182,7 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr
row = await cursor.fetchone()
if row is None:
return None
token_id, user_id, scopes_json, expires_at, revoked_at, tenant_id, username, is_active = row
token_id, user_id, scopes_json, expires_at, revoked_at, username, is_active = row
if revoked_at is not None or not is_active:
return None
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
@ -201,7 +197,7 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr
(token_id,),
)
await conn.commit()
return CurrentUser(id=user_id, username=username, is_admin=False, token_tenant_id=tenant_id)
return CurrentUser(id=user_id, username=username, is_admin=False, is_token=True)
def require_admin_scope_or_host_role(resource: str, action: str, role_names: tuple[str, ...]):
@ -230,7 +226,7 @@ def require_admin_scope_or_host_role(resource: str, action: str, role_names: tup
return principal
user = await get_current_user(response, jh_session)
if user.is_any_admin:
if user.is_admin:
return user
conn = get_db()
for role_name in role_names:
@ -243,12 +239,9 @@ def require_admin_scope_or_host_role(resource: str, action: str, role_names: tup
def require_admin_or_scope(resource: str, action: str):
"""Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber
eine eingeloggte Admin-Session (Super- ODER Mandanten-Admin, Cookie) ODER
ueber ein API-Token mit passendem Scope (Header 'Authorization: Bearer
<token>'). 'write' schliesst 'read' auf derselben Ressource ein (siehe
token_has_scope). Mandanten-Scoping selbst erfolgt NICHT hier, sondern im
jeweiligen Endpunkt ueber app/tenancy.py::TenantScope, basierend auf
effective_tenant_ids() des zurueckgegebenen CurrentUser."""
eine eingeloggte Admin-Session (Cookie) ODER ueber ein API-Token mit
passendem Scope (Header 'Authorization: Bearer <token>'). 'write'
schliesst 'read' auf derselben Ressource ein (siehe token_has_scope)."""
async def _dep(
response: Response,
@ -266,25 +259,8 @@ def require_admin_or_scope(resource: str, action: str):
return principal
user = await get_current_user(response, jh_session)
if not user.is_any_admin:
if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
return user
return _dep
def effective_tenant_ids(admin: CurrentUser) -> set[int] | None:
"""None bedeutet 'alle Mandanten' (Super-Admin-Session, nicht ueber ein
mandantengebundenes Token authentifiziert). Sonst ein Set mit genau den
Mandanten-IDs, auf die der Principal beschraenkt ist:
- Token-Auth: immer genau {token_tenant_id} (Tokens sind nie
Mandanten-UEBERGREIFEND einsetzbar, siehe Migration 0006).
- Mandanten-Admin-Session: die Menge ihrer tenant_admin_ids.
- Ein regulaerer (Nicht-Admin-)User wuerde require_admin_or_scope /
require_admin_session ohnehin nie passieren -- kommt hier praktisch
nicht an."""
if admin.token_tenant_id is not None:
return {admin.token_tenant_id}
if admin.is_admin:
return None
return set(admin.tenant_admin_ids)

View File

@ -272,6 +272,13 @@ async def logout(request: Request, response: Response, user: CurrentUser = Depen
conn, event_type="logout", user_id=user.id, client_ip=_client_ip(request), details={}
)
await conn.commit()
# Siehe change_password() unten fuer die ausfuehrliche Begruendung: die
# get_current_user()-Dependency hat bereits ein Refresh-Set-Cookie
# geschrieben, das hier entfernte werden muss, bevor delete_cookie()
# selbst noch ein Set-Cookie (mit Max-Age=0) anhaengt -- sonst zwei
# Set-Cookie-Kopfzeilen in einer Antwort.
if "set-cookie" in response.headers:
del response.headers["set-cookie"]
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
return {"status": "ok"}
@ -289,6 +296,9 @@ async def logout_everywhere(
conn, event_type="logout_everywhere", user_id=user.id, client_ip=_client_ip(request), details={}
)
await conn.commit()
# Siehe change_password() unten fuer die ausfuehrliche Begruendung.
if "set-cookie" in response.headers:
del response.headers["set-cookie"]
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
return {"status": "ok"}
@ -318,6 +328,27 @@ async def change_password(
)
await conn.commit()
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 6): get_current_user()
# (siehe app/auth/deps.py) hat als Dependency VOR dieser Routenfunktion
# bereits ein eigenes Set-Cookie in `response` geschrieben (gleitender
# Idle-Timeout-Refresh) -- MIT DER ALTEN session_version, weil die DB zu
# dem Zeitpunkt noch nicht aktualisiert war. Ohne diese Zeile wuerden
# ZWEI "Set-Cookie: jh_session=..."-Header in derselben Antwort stehen
# (die alte Refresh-Cookie UND die neue unten) -- welche davon ein Client
# am Ende tatsaechlich speichert, ist zwischen Browsern/HTTP-Bibliotheken
# nicht einheitlich definiert (in der Praxis eher "die letzte gewinnt",
# aber verlassen sollte man sich darauf nicht). Ein per-Request frischer
# Client akzeptiert die alte Sitzung danach zwar bereits korrekt nicht
# mehr (session_version-Pruefung in deps.py greift unabhaengig davon),
# aber die doppelte Kopfzeile bleibt unsauber und macht Tests, die den
# Cookie-Jar wiederverwenden, unnoetig fragil (siehe der dadurch lange
# falsch als "Sicherheitsluecke" vermutete Testfehlschlag). Die alte
# Set-Cookie-Kopfzeile wird deshalb hier explizit entfernt, bevor die
# neue, autoritative gesetzt wird -- `del response.headers["set-cookie"]`
# entfernt ALLE bisherigen Vorkommen dieses Headers (Starlette
# MutableHeaders.__delitem__), nicht nur das erste.
if "set-cookie" in response.headers:
del response.headers["set-cookie"]
token = create_session_token(user.id, new_version)
response.set_cookie(
SESSION_COOKIE_NAME, token, httponly=True, secure=True, samesite="strict", path="/"
@ -327,19 +358,8 @@ async def change_password(
@router.get("/me")
async def me(user: CurrentUser = Depends(get_current_user)):
tenant_admin_of = []
if user.tenant_admin_ids:
conn = get_db()
placeholders = ",".join("?" for _ in user.tenant_admin_ids)
cursor = await conn.execute(
f"SELECT id, name FROM tenants WHERE id IN ({placeholders}) ORDER BY name",
tuple(user.tenant_admin_ids),
)
tenant_admin_of = [{"id": r[0], "name": r[1]} for r in await cursor.fetchall()]
return {
"id": user.id,
"username": user.username,
"is_admin": user.is_admin,
"is_any_admin": user.is_any_admin,
"tenant_admin_of": tenant_admin_of,
}