feat: Reseller-Rolle + SUPER_ADMIN-Mandantenübersicht
Security Audit / Python Dependency Audit (push) Has been cancelled
Security Audit / Node.js Dependency Audit (push) Has been cancelled

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>
This commit is contained in:
2026-06-23 01:15:51 +02:00
co-authored by Claude Opus 4.8
parent de35eaa3ea
commit 898a81f499
18 changed files with 1198 additions and 8 deletions
+83
View File
@@ -0,0 +1,83 @@
"""Reseller-Selbstverwaltung: ein Reseller verwaltet ausschließlich die von ihm
angelegten Firmen (Firmen-Ebene, keine personenbezogenen Daten).
RLS (app.reseller_id) erzwingt, dass nur eigene Firmen sichtbar/änderbar sind
ein Zugriff auf eine fremde Firmen-ID läuft ins Leere (404).
"""
from uuid import UUID
from fastapi import APIRouter, Depends
from fastapi import HTTPException
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.models.audit_log import AuditLog
from app.models.company import Company
from app.models.user import User, UserRole
from app.schemas.reseller import TenantCreate, TenantOut, TenantUpdate
from app.services.tenant_service import tenant_service
router = APIRouter(prefix="/reseller", tags=["Reseller"])
_reseller_roles = (UserRole.RESELLER,)
@router.get("/companies", response_model=list[TenantOut])
async def list_my_companies(
current_user: User = require_role(*_reseller_roles),
db: AsyncSession = Depends(get_db),
):
return await tenant_service.list_tenants(db)
@router.post("/companies", response_model=TenantOut, status_code=201)
async def create_company(
data: TenantCreate,
current_user: User = require_role(*_reseller_roles),
db: AsyncSession = Depends(get_db),
):
company = await tenant_service.create_tenant(
data, actor=current_user, db=db, reseller_id=current_user.id,
)
# Antwort aus der frisch erstellten Firma (Kennzahlen 1 Admin, 0 aktiv)
return TenantOut(
id=company.id, name=company.name, slug=company.slug, plan=company.plan,
is_active=company.is_active, country=company.country, reseller_id=company.reseller_id,
reseller_name=f"{current_user.first_name} {current_user.last_name}".strip(),
user_count=1, active_user_count=0, admin_email=data.admin_email,
)
@router.patch("/companies/{company_id}", response_model=TenantOut)
async def update_company(
company_id: UUID,
data: TenantUpdate,
current_user: User = require_role(*_reseller_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, company_id)
# RLS gibt nur eigene Firmen frei → fremde/unbekannte ID = nicht sichtbar
if company is None or company.reseller_id != current_user.id:
raise HTTPException(status_code=404, detail="Firma nicht gefunden.")
changes = data.model_dump(exclude_none=True)
# Eigentümerschaft ist geprüft → Schreiben (inkl. AuditLog) unter kurzem Bypass,
# da der Reseller keine app.company_id besitzt (audit_logs ist company-gefenced).
await db.execute(text("SET LOCAL app.bypass_rls = 'on'"))
try:
for field, value in changes.items():
setattr(company, field, value)
if changes:
db.add(AuditLog(
company_id=company.id, user_id=current_user.id,
action="tenant_updated", entity_type="company", entity_id=company.id,
new_value=changes,
))
await db.flush()
finally:
await db.execute(text("SET LOCAL app.bypass_rls = 'off'"))
tenants = {t.id: t for t in await tenant_service.list_tenants(db)}
return tenants[company.id]