Files
timemaster/backend/app/core/dependencies.py
T
patrickandClaude Opus 4.8 fcda813ba6 feat: Reseller-Rolle + SUPER_ADMIN-Mandantenübersicht
Mandantenfähigkeit ausgebaut:
- Neue Rolle RESELLER (company_id NULL); companies.reseller_id + is_active
- RLS-Erweiterung (Migration 0034): companies/users zusätzlich auf app.reseller_id
  gefenced → Reseller sieht/verwaltet DB-seitig nur eigene Firmen, keine
  personenbezogenen Zeit-/Abwesenheitsdaten (DSGVO: nur Verwaltung)
- get_current_user setzt app.reseller_id + Bypass aus für RESELLER
- tenant_service: Firma + Erst-Admin (Einladung), Übersicht mit Kennzahlen
- Router /reseller/* (Self-Service) und /admin/* (SUPER_ADMIN: Mandanten + Reseller)
- Login-Sperre bei deaktiviertem Mandanten
- Frontend: TenantsPage (/admin/tenants), eigene ResellerCompaniesPage (/reseller),
  rollenbasierte Login-Weiterleitung, Nav "Mandanten" für SUPER_ADMIN
- 4 neue Tests inkl. Cross-Reseller-RLS-Isolation; 172/172 grün

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:15:51 +02:00

110 lines
4.6 KiB
Python

import uuid as _uuid
from typing import Annotated
from uuid import UUID
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import decode_access_token
from app.models.user import User, UserRole
bearer_scheme = HTTPBearer()
def get_client_ip(request: Request) -> str:
"""Liest die echte Client-IP auch hinter nginx-Proxy.
nginx setzt X-Real-IP auf die ursprüngliche Client-IP.
Ohne diesen Header würde request.client.host hinter nginx immer
127.0.0.1 zurückgeben, womit AuditLog-Einträge wertlos wären.
"""
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip.strip()
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
return forwarded_for.split(",")[0].strip()
return request.client.host if request.client else "unknown"
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_access_token(credentials.credentials)
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
# User lookup happens while bypass_rls = 'on' (set in get_db), so the
# SELECT on users is unrestricted — necessary because we don't yet know
# the company_id at this point.
user = await db.get(User, UUID(user_id))
if user is None or not user.is_active:
raise credentials_exception
# ── RLS context ────────────────────────────────────────────────────────
# SUPER_ADMIN can see all companies → keep bypass_rls = 'on'.
# Every other role gets the RLS fence applied: set company_id and disable
# bypass so subsequent queries in the same transaction are automatically
# filtered to the user's company.
if user.role == UserRole.RESELLER:
# Reseller ist an keine Firma gebunden (company_id IS NULL), darf aber
# NICHT alles sehen. Statt company_id setzen wir app.reseller_id und
# deaktivieren den Bypass → RLS-Policies geben nur Firmen frei, deren
# reseller_id == diesem User entspricht (plus deren User zum Verwalten).
safe_reseller_id = str(_uuid.UUID(str(user.id)))
await db.execute(text(f"SET LOCAL app.reseller_id = '{safe_reseller_id}'"))
await db.execute(text("SET LOCAL app.bypass_rls = 'off'"))
elif user.role != UserRole.SUPER_ADMIN and user.company_id is not None:
# Sicherheits-Invariante: safe_company_id muss eine valide UUID sein.
# Der _uuid.UUID()-Round-Trip verhindert SQL-Injection auch bei zukünftigen
# Refactorings (z.B. falls user.company_id einmal ein String aus einem
# anderen Pfad käme). SET LOCAL akzeptiert keine Bind-Parameter in
# PostgreSQL, daher ist String-Interpolation hier unvermeidlich —
# der UUID-Round-Trip ist die kryptographische Absicherung dagegen.
safe_company_id = str(_uuid.UUID(str(user.company_id)))
await db.execute(text(f"SET LOCAL app.company_id = '{safe_company_id}'"))
await db.execute(text("SET LOCAL app.bypass_rls = 'off'"))
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
def require_role(*roles: UserRole):
"""Dependency factory: require_role(UserRole.MANAGER, UserRole.COMPANY_ADMIN)"""
async def checker(current_user: CurrentUser) -> User:
if current_user.role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions",
)
return current_user
return Depends(checker)
def require_same_company(target_company_id: UUID, current_user: User) -> None:
"""Raise 403 if user tries to access another company's data."""
if (
current_user.role != UserRole.SUPER_ADMIN
and current_user.company_id != target_company_id
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access to this resource is not allowed",
)