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
@@ -0,0 +1,70 @@
"""Reseller role + tenant ownership + company is_active + RLS extension
Revision ID: 0034
Revises: 0033
Create Date: 2026-06-23
- Neuer Enum-Wert UserRole.RESELLER
- companies.is_active (Mandanten-Login-Sperre)
- companies.reseller_id (Besitz durch Reseller; NULL = Plattform/SUPER_ADMIN)
- RLS-Policies für companies + users so erweitert, dass ein Reseller (app.reseller_id)
genau seine eigenen Firmen und deren User sieht/verwaltet (keine personenbezogenen
Zeit-/Abwesenheitsdaten diese Tabellen bleiben unverändert auf company_id gefenced).
"""
from alembic import op
from sqlalchemy import text
revision = "0034"
down_revision = "0033"
branch_labels = None
depends_on = None
# ── RLS-Ausdrücke (analog 0024) ──────────────────────────────────────────────
_BYPASS = "COALESCE(current_setting('app.bypass_rls', true), 'off') = 'on'"
_CID = "company_id = NULLIF(current_setting('app.company_id', true), '')::uuid"
_IID = "id = NULLIF(current_setting('app.company_id', true), '')::uuid"
_RID = "reseller_id = NULLIF(current_setting('app.reseller_id', true), '')::uuid"
# Firmen, die dem aktuellen Reseller gehören (Subquery unterliegt selbst der
# companies-Policy, liefert dem Reseller also genau seine eigenen Firmen).
_USER_RESELLER = (
"company_id IN (SELECT id FROM companies WHERE "
"reseller_id = NULLIF(current_setting('app.reseller_id', true), '')::uuid)"
)
def _exec(sql: str) -> None:
op.execute(text(sql))
def _recreate_policies(table: str, using: str) -> None:
for op_name in ("select", "insert", "update", "delete"):
_exec(f"DROP POLICY IF EXISTS rls_{table}_{op_name} ON {table}")
_exec(f"CREATE POLICY rls_{table}_select ON {table} FOR SELECT USING {using}")
_exec(f"CREATE POLICY rls_{table}_insert ON {table} FOR INSERT WITH CHECK {using}")
_exec(f"CREATE POLICY rls_{table}_update ON {table} FOR UPDATE USING {using} WITH CHECK {using}")
_exec(f"CREATE POLICY rls_{table}_delete ON {table} FOR DELETE USING {using}")
def upgrade() -> None:
# 1) Enum-Wert RESELLER (ADD VALUE darf in TX laufen, wird hier nicht verwendet)
_exec("ALTER TYPE userrole ADD VALUE IF NOT EXISTS 'RESELLER'")
# 2) Neue Spalten
_exec("ALTER TABLE companies ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE")
_exec("ALTER TABLE companies ADD COLUMN IF NOT EXISTS reseller_id UUID NULL "
"REFERENCES users(id) ON DELETE SET NULL")
_exec("CREATE INDEX IF NOT EXISTS ix_companies_reseller_id ON companies (reseller_id)")
# 3) RLS erweitern
_recreate_policies("companies", f"({_BYPASS} OR {_IID} OR {_RID})")
_recreate_policies("users", f"({_BYPASS} OR {_CID} OR {_USER_RESELLER})")
def downgrade() -> None:
# RLS auf 0024-Stand zurücksetzen
_recreate_policies("companies", f"({_BYPASS} OR {_IID})")
_recreate_policies("users", f"({_BYPASS} OR {_CID})")
_exec("DROP INDEX IF EXISTS ix_companies_reseller_id")
_exec("ALTER TABLE companies DROP COLUMN IF EXISTS reseller_id")
_exec("ALTER TABLE companies DROP COLUMN IF EXISTS is_active")
# Enum-Wert RESELLER bleibt (PostgreSQL kann Enum-Werte nicht entfernen)