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
+9 -1
View File
@@ -60,7 +60,15 @@ async def get_current_user(
# 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.SUPER_ADMIN and user.company_id is not None:
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
+3
View File
@@ -18,6 +18,7 @@ from app.routers import audit
from app.routers import special_assignments
from app.routers import hours_payouts
from app.routers import public_stamp
from app.routers import reseller, tenants
@asynccontextmanager
@@ -98,6 +99,8 @@ app.include_router(busylight.router, prefix=API_PREFIX)
app.include_router(audit.router, prefix=API_PREFIX)
app.include_router(special_assignments.router, prefix=API_PREFIX)
app.include_router(hours_payouts.router, prefix=API_PREFIX)
app.include_router(reseller.router, prefix=API_PREFIX)
app.include_router(tenants.router, prefix=API_PREFIX)
# ── Health ────────────────────────────────────────────────────────────────────
+16 -2
View File
@@ -3,7 +3,7 @@ import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, Integer, String, Text
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -26,6 +26,17 @@ class Company(Base):
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
plan: Mapped[str] = mapped_column(String(50), default="trial")
# Mandanten-Status: deaktivierte Firmen können sich nicht mehr einloggen.
# Von Reseller (eigene Firmen) bzw. SUPER_ADMIN umschaltbar.
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Reseller-Besitz: Firma wurde von diesem Reseller angelegt und wird von ihm
# verwaltet (nur Firmen-Ebene, kein Zugriff auf personenbezogene Daten).
# NULL = direkt von der Plattform (SUPER_ADMIN) verwaltet.
reseller_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
logo_url: Mapped[str | None] = mapped_column(Text)
country: Mapped[str] = mapped_column(String(10), default="DE")
state: Mapped[str | None] = mapped_column(String(10))
@@ -71,7 +82,10 @@ class Company(Base):
overtime_max_carryover_hours: Mapped[int | None] = mapped_column(Integer, nullable=True) # None = alles
# Relationships
users: Mapped[list["User"]] = relationship("User", back_populates="company", lazy="noload")
users: Mapped[list["User"]] = relationship(
"User", back_populates="company", lazy="noload",
foreign_keys="User.company_id",
)
departments: Mapped[list["Department"]] = relationship("Department", back_populates="company", lazy="noload")
def __repr__(self) -> str:
+4 -1
View File
@@ -17,6 +17,7 @@ if TYPE_CHECKING:
class UserRole(str, enum.Enum):
SUPER_ADMIN = "SUPER_ADMIN"
RESELLER = "RESELLER"
COMPANY_ADMIN = "COMPANY_ADMIN"
HR = "HR"
MANAGER = "MANAGER"
@@ -77,7 +78,9 @@ class User(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Relationships
company: Mapped["Company"] = relationship("Company", back_populates="users")
company: Mapped["Company"] = relationship(
"Company", back_populates="users", foreign_keys="User.company_id",
)
department: Mapped["Department | None"] = relationship(
"Department",
primaryjoin="User.department_id == Department.id",
+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]
+201
View File
@@ -0,0 +1,201 @@
"""SUPER_ADMIN: Mandantenübersicht (alle Firmen) + Reseller-Verwaltung.
SUPER_ADMIN läuft mit aktivem RLS-Bypass und sieht daher alle Firmen.
Nur SUPER_ADMIN darf Reseller anlegen und Firmen einem Reseller zuordnen.
"""
from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
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 (
ResellerCreate,
ResellerOut,
TenantAssignReseller,
TenantCreate,
TenantOut,
TenantUpdate,
)
from app.services.tenant_service import tenant_service
router = APIRouter(prefix="/admin", tags=["Admin · Tenants"])
_sa = (UserRole.SUPER_ADMIN,)
# ── Mandanten ────────────────────────────────────────────────────────────────
@router.get("/tenants", response_model=list[TenantOut])
async def list_tenants(
current_user: User = require_role(*_sa),
db: AsyncSession = Depends(get_db),
):
return await tenant_service.list_tenants(db)
@router.post("/tenants", response_model=TenantOut, status_code=201)
async def create_tenant(
data: TenantCreate,
reseller_id: UUID | None = None,
current_user: User = require_role(*_sa),
db: AsyncSession = Depends(get_db),
):
if reseller_id is not None:
reseller = await db.get(User, reseller_id)
if reseller is None or reseller.role != UserRole.RESELLER:
raise HTTPException(status_code=400, detail="Ungültiger Reseller.")
company = await tenant_service.create_tenant(
data, actor=current_user, db=db, reseller_id=reseller_id,
)
tenants = {t.id: t for t in await tenant_service.list_tenants(db)}
return tenants[company.id]
@router.patch("/tenants/{company_id}", response_model=TenantOut)
async def update_tenant(
company_id: UUID,
data: TenantUpdate,
current_user: User = require_role(*_sa),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, company_id)
if company is None:
raise HTTPException(status_code=404, detail="Firma nicht gefunden.")
changes = data.model_dump(exclude_none=True)
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()
tenants = {t.id: t for t in await tenant_service.list_tenants(db)}
return tenants[company.id]
@router.patch("/tenants/{company_id}/reseller", response_model=TenantOut)
async def assign_reseller(
company_id: UUID,
data: TenantAssignReseller,
current_user: User = require_role(*_sa),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, company_id)
if company is None:
raise HTTPException(status_code=404, detail="Firma nicht gefunden.")
if data.reseller_id is not None:
reseller = await db.get(User, data.reseller_id)
if reseller is None or reseller.role != UserRole.RESELLER:
raise HTTPException(status_code=400, detail="Ungültiger Reseller.")
company.reseller_id = data.reseller_id
db.add(AuditLog(
company_id=company.id, user_id=current_user.id,
action="tenant_reseller_assigned", entity_type="company", entity_id=company.id,
new_value={"reseller_id": str(data.reseller_id) if data.reseller_id else None},
))
await db.flush()
tenants = {t.id: t for t in await tenant_service.list_tenants(db)}
return tenants[company.id]
# ── Reseller ─────────────────────────────────────────────────────────────────
async def _reseller_company_counts(db: AsyncSession) -> dict[UUID, int]:
rows = await db.execute(
select(Company.reseller_id, func.count(Company.id))
.where(Company.reseller_id.isnot(None))
.group_by(Company.reseller_id)
)
return {rid: cnt for rid, cnt in rows}
@router.get("/resellers", response_model=list[ResellerOut])
async def list_resellers(
current_user: User = require_role(*_sa),
db: AsyncSession = Depends(get_db),
):
resellers = (await db.scalars(
select(User).where(User.role == UserRole.RESELLER).order_by(User.last_name, User.first_name)
)).all()
counts = await _reseller_company_counts(db)
return [
ResellerOut(
id=r.id, email=r.email, first_name=r.first_name, last_name=r.last_name,
is_active=r.is_active, created_at=r.created_at,
company_count=counts.get(r.id, 0),
)
for r in resellers
]
@router.post("/resellers", response_model=ResellerOut, status_code=201)
async def create_reseller(
data: ResellerCreate,
current_user: User = require_role(*_sa),
db: AsyncSession = Depends(get_db),
):
if await db.scalar(select(User).where(User.email == data.email)):
raise HTTPException(status_code=400, detail="E-Mail ist bereits vergeben.")
raw_token, token_hash = generate_invite_token()
reseller = User(
company_id=None,
email=data.email,
first_name=data.first_name,
last_name=data.last_name,
role=UserRole.RESELLER,
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(reseller)
await db.flush()
db.add(AuditLog(
company_id=None, user_id=current_user.id,
action="reseller_created", entity_type="user", entity_id=reseller.id,
new_value={"email": reseller.email},
))
await db.flush()
from app.services.email_service import email_service
await email_service.send_invite(reseller, current_user, raw_token, db)
return ResellerOut(
id=reseller.id, email=reseller.email, first_name=reseller.first_name,
last_name=reseller.last_name, is_active=reseller.is_active,
created_at=reseller.created_at, company_count=0,
)
@router.patch("/resellers/{reseller_id}", response_model=ResellerOut)
async def update_reseller(
reseller_id: UUID,
is_active: bool,
current_user: User = require_role(*_sa),
db: AsyncSession = Depends(get_db),
):
reseller = await db.get(User, reseller_id)
if reseller is None or reseller.role != UserRole.RESELLER:
raise HTTPException(status_code=404, detail="Reseller nicht gefunden.")
reseller.is_active = is_active
db.add(AuditLog(
company_id=None, user_id=current_user.id,
action="reseller_updated", entity_type="user", entity_id=reseller.id,
new_value={"is_active": is_active},
))
await db.flush()
counts = await _reseller_company_counts(db)
return ResellerOut(
id=reseller.id, email=reseller.email, first_name=reseller.first_name,
last_name=reseller.last_name, is_active=reseller.is_active,
created_at=reseller.created_at, company_count=counts.get(reseller.id, 0),
)
+69
View File
@@ -0,0 +1,69 @@
"""Schemas für Reseller-Selbstverwaltung und SUPER_ADMIN-Mandantenübersicht.
Wichtig (DSGVO): Reseller sehen ausschließlich Firmen-Stammdaten und Kennzahlen
(z.B. Nutzeranzahl) KEINE personenbezogenen Zeit-/Abwesenheitsdaten.
"""
import uuid
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field
class TenantOut(BaseModel):
"""Eine Firma als Mandant für Reseller- und SUPER_ADMIN-Übersicht."""
model_config = {"from_attributes": True}
id: uuid.UUID
name: str
slug: str
plan: str
is_active: bool
country: str
reseller_id: uuid.UUID | None = None
reseller_name: str | None = None
user_count: int = 0
active_user_count: int = 0
admin_email: str | None = None
created_at: datetime | None = None
class TenantCreate(BaseModel):
"""Neue Firma anlegen (durch Reseller oder SUPER_ADMIN).
Es wird automatisch ein erster COMPANY_ADMIN per Einladung angelegt.
"""
name: str = Field(min_length=2, max_length=255)
country: str = Field(default="DE", max_length=10)
plan: str = Field(default="trial", max_length=50)
admin_email: EmailStr
admin_first_name: str = Field(min_length=1, max_length=100)
admin_last_name: str = Field(min_length=1, max_length=100)
class TenantUpdate(BaseModel):
name: str | None = Field(None, min_length=2, max_length=255)
plan: str | None = Field(None, max_length=50)
is_active: bool | None = None
class TenantAssignReseller(BaseModel):
"""SUPER_ADMIN: Firma einem Reseller zuordnen (oder lösen mit null)."""
reseller_id: uuid.UUID | None = None
class ResellerOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
email: str
first_name: str
last_name: str
is_active: bool
created_at: datetime | None = None
company_count: int = 0
class ResellerCreate(BaseModel):
email: EmailStr
first_name: str = Field(min_length=1, max_length=100)
last_name: str = Field(min_length=1, max_length=100)
+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()