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>
110 lines
4.6 KiB
Python
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",
|
|
)
|