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>
This commit is contained in:
2026-06-23 01:15:51 +02:00
co-authored by Claude Opus 4.8
parent da4745013e
commit fcda813ba6
18 changed files with 1198 additions and 8 deletions
+7
View File
@@ -144,6 +144,13 @@ class AuthService:
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is deactivated")
# Mandanten-Sperre: deaktivierte Firma → kein Login (Reseller/SUPER_ADMIN
# haben company_id IS NULL und sind davon nicht betroffen).
if user.company_id is not None:
company = await db.get(Company, user.company_id)
if company is not None and not company.is_active:
raise HTTPException(status_code=403, detail="Dieser Mandant ist deaktiviert.")
auth_ok = False
if user.auth_provider == AuthProvider.LDAP:
ldap_cfg = await ldap_service.get_config(user.company_id, db)
+154
View File
@@ -0,0 +1,154 @@
"""Mandanten-Verwaltung: Firmen anlegen/auflisten inkl. Kennzahlen.
Genutzt von:
- routers/reseller.py Reseller verwaltet seine EIGENEN Firmen
- routers/tenants.py SUPER_ADMIN verwaltet ALLE Firmen + Reseller
RLS sorgt für die Sichtbarkeitsgrenze: ein Reseller (app.reseller_id) sieht via
companies-/users-Policy automatisch nur seine eigenen Firmen. Das Anlegen einer
Firma bootstrappt unter kurzzeitigem Bypass (privilegierte, auditierte Operation)
und stellt danach den RLS-Kontext des Actors wieder her.
"""
import re
from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import generate_invite_token, hash_password
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
def _slugify(name: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:80]
class TenantService:
async def _unique_slug(self, name: str, db: AsyncSession) -> str:
base = _slugify(name) or "firma"
slug, counter = base, 1
while await db.scalar(select(Company).where(Company.slug == slug)):
slug = f"{base}-{counter}"
counter += 1
return slug
async def create_tenant(
self, data: TenantCreate, actor: User, db: AsyncSession,
reseller_id: UUID | None,
) -> Company:
# E-Mail global eindeutig (User.email ist unique)
if await db.scalar(select(User).where(User.email == data.admin_email)):
raise HTTPException(status_code=400, detail="E-Mail ist bereits vergeben.")
slug = await self._unique_slug(data.name, db)
# Bootstrap unter Bypass neue, leere Firma; danach Kontext wiederherstellen.
await db.execute(text("SET LOCAL app.bypass_rls = 'on'"))
try:
company = Company(
name=data.name, slug=slug, plan=data.plan or "trial",
country=data.country or "DE", reseller_id=reseller_id, is_active=True,
)
db.add(company)
await db.flush()
raw_token, token_hash = generate_invite_token()
admin = User(
company_id=company.id,
email=data.admin_email,
first_name=data.admin_first_name,
last_name=data.admin_last_name,
role=UserRole.COMPANY_ADMIN,
password_hash=hash_password(raw_token), # temporär, beim Accept überschrieben
invite_token_hash=token_hash,
invite_expires=datetime.now(timezone.utc) + timedelta(days=7),
is_active=False,
)
db.add(admin)
await db.flush()
from app.services.absence_service import absence_service
await absence_service.create_defaults_for_company(company.id, db)
db.add(AuditLog(
company_id=company.id,
user_id=actor.id,
action="tenant_created",
entity_type="company",
entity_id=company.id,
new_value={
"name": company.name, "reseller_id": str(reseller_id) if reseller_id else None,
"admin_email": admin.email,
},
))
await db.flush()
from app.services.email_service import email_service
await email_service.send_invite(admin, actor, raw_token, db)
finally:
# RLS-Kontext des Actors wiederherstellen
if actor.role == UserRole.RESELLER:
await db.execute(text("SET LOCAL app.bypass_rls = 'off'"))
# SUPER_ADMIN: Bypass bleibt aktiv (Normalzustand)
return company
async def list_tenants(self, db: AsyncSession) -> list[TenantOut]:
"""Alle für den aktuellen RLS-Kontext sichtbaren Firmen + Kennzahlen."""
companies = (await db.scalars(select(Company).order_by(Company.name))).all()
if not companies:
return []
ids = [c.id for c in companies]
# Nutzeranzahl pro Firma
counts: dict[UUID, tuple[int, int]] = {}
rows = await db.execute(
select(
User.company_id,
func.count(User.id),
func.count(User.id).filter(User.is_active.is_(True)),
).where(User.company_id.in_(ids)).group_by(User.company_id)
)
for cid, total, active in rows:
counts[cid] = (total, active)
# Erst-Admin-E-Mail pro Firma
admin_email: dict[UUID, str] = {}
arows = await db.execute(
select(User.company_id, User.email)
.where(User.company_id.in_(ids), User.role == UserRole.COMPANY_ADMIN)
.order_by(User.company_id, User.created_at)
)
for cid, email in arows:
admin_email.setdefault(cid, email)
# Reseller-Namen
reseller_ids = [c.reseller_id for c in companies if c.reseller_id]
reseller_name: dict[UUID, str] = {}
if reseller_ids:
rrows = await db.execute(
select(User.id, User.first_name, User.last_name).where(User.id.in_(reseller_ids))
)
for rid, fn, ln in rrows:
reseller_name[rid] = f"{fn} {ln}".strip()
out: list[TenantOut] = []
for c in companies:
total, active = counts.get(c.id, (0, 0))
out.append(TenantOut(
id=c.id, name=c.name, slug=c.slug, plan=c.plan, is_active=c.is_active,
country=c.country, reseller_id=c.reseller_id,
reseller_name=reseller_name.get(c.reseller_id) if c.reseller_id else None,
user_count=total, active_user_count=active,
admin_email=admin_email.get(c.id),
))
return out
tenant_service = TenantService()