fix(security): F03 (Astra P1) — Sitzungswiderruf in beiden Session-Stores durchsetzen

Vorher (Astra-Finding): Widerruf war inkonsistent ueber vier Pfade:
- Deaktivierung invalidierte nur den Berechtigungscache — Sessions
  liefen bis TTL (8h) weiter
- Loeschung invalidierte GAR NICHTS
- Passwortwechsel loeschte nur Redis-Sessions — PostgreSQL-Fallback-
  Sessions ueberlebten jeden Redis-Ausfall
- Fehlende UserTenant-Mitgliedschaft wurde durchgewinkt statt
  abgewiesen

Fix:
- Neuer zentraler Helfer revoke_user_sessions_all_stores (app/core/auth.py):
  Redis-Sessions loeschen UND PostgreSQL-Fallback-Sessions per
  expires_at=now() ablaufen lassen (Audit-Trail bleibt, Zugriff stirbt
  sofort — der DB-Fallback-Pfad prueft expires_at bereits)
- Alle 4 Widerrufsstellen verdrahtet: Deaktivierung + Loeschung
  (routes/users.py), Passwortwechsel (user_service.py), Passwort-Reset
  (auth_service.py)
- Membership-Check in get_current_user fail-closed: None (fehlende
  Mitgliedschaft) wird abgewiesen statt durchgelassen

Abnahme (Astra): Deaktivierung, Austritt und Passwortwechsel wirken
unmittelbar — auch bei Redis-Ausfall (Unit-Test beweist die
DB-Fallback-Abgelaufen-Rejection).

Tests: test_s1_security_guards.py 10/10 (3 neue F03-Tests) +
test_auth.py 11/11 + ruff clean.
This commit is contained in:
Agent Zero
2026-09-18 07:59:31 +02:00
parent b8a556091e
commit 47432651f1
6 changed files with 195 additions and 8 deletions
+12 -2
View File
@@ -130,10 +130,20 @@ async def get_current_user(
membership_row = membership_q.first()
membership_status = membership_row[0] if membership_row else None
role_id = membership_row[1] if membership_row else None
if membership_status is not None and membership_status != "active":
# F03 (Astra): a MISSING tenant membership must be rejected, not waved
# through. Previously `is not None` let membership-less sessions access
# the tenant's data via the RLS context set above.
if membership_status is None or membership_status != "active":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"detail": f"Mitgliedschaft ist {membership_status}, Zugriff verweigert", "code": "membership_suspended"},
detail={
"detail": (
f"Mitgliedschaft ist {membership_status}, Zugriff verweigert"
if membership_status
else "Keine aktive Mandanten-Mitgliedschaft, Zugriff verweigert"
),
"code": "membership_suspended",
},
)
# Cache user principals for this request — avoids N+1 queries in visibility.py