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:
@@ -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
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -18,9 +18,16 @@ TestSessionLocal = async_sessionmaker(test_engine, class_=AsyncSession, expire_o
|
||||
_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"
|
||||
# Reseller darf User seiner eigenen Firmen verwalten (vgl. Migration 0034)
|
||||
_USER_RESELLER = (
|
||||
"company_id IN (SELECT id FROM companies WHERE "
|
||||
"reseller_id = NULLIF(current_setting('app.reseller_id', true), '')::uuid)"
|
||||
)
|
||||
|
||||
def _rls_using_cid(): return f"({_BYPASS} OR {_CID})"
|
||||
def _rls_using_iid(): return f"({_BYPASS} OR {_IID})"
|
||||
def _rls_using_cid(): return f"({_BYPASS} OR {_CID})"
|
||||
def _rls_using_iid(): return f"({_BYPASS} OR {_IID} OR {_RID})"
|
||||
def _rls_using_users(): return f"({_BYPASS} OR {_CID} OR {_USER_RESELLER})"
|
||||
def _rls_using_join(): return (
|
||||
f"({_BYPASS} OR user_id IN (SELECT id FROM users WHERE {_CID}))"
|
||||
)
|
||||
@@ -55,7 +62,8 @@ async def _apply_rls(conn) -> None:
|
||||
for sql in enable("companies", _rls_using_iid()):
|
||||
await conn.execute(text(sql))
|
||||
for table in _COMPANY_COL_TABLES:
|
||||
for sql in enable(table, _rls_using_cid()):
|
||||
using = _rls_using_users() if table == "users" else _rls_using_cid()
|
||||
for sql in enable(table, using):
|
||||
await conn.execute(text(sql))
|
||||
for table in _USER_JOIN_TABLES:
|
||||
for sql in enable(table, _rls_using_join()):
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tests für Reseller-Rolle + Mandantenverwaltung + RLS-Isolation."""
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.security import hash_password
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
|
||||
async def _make_user(db, *, email, role, company_id=None, password="Secret123"):
|
||||
await db.execute(text("SET LOCAL app.bypass_rls = 'on'"))
|
||||
user = User(
|
||||
id=uuid.uuid4(), company_id=company_id, email=email,
|
||||
password_hash=hash_password(password),
|
||||
first_name=role.title(), last_name="User",
|
||||
role=UserRole(role), is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
return user.id
|
||||
|
||||
|
||||
async def _login(client, email, password="Secret123"):
|
||||
r = await client.post("/api/v1/auth/login", json={"email": email, "password": password})
|
||||
assert r.status_code == 200, r.text
|
||||
return {"Authorization": f"Bearer {r.json()['access_token']}"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_superadmin_creates_reseller(client: AsyncClient, db_session):
|
||||
await _make_user(db_session, email="sa@platform.de", role="SUPER_ADMIN")
|
||||
h = await _login(client, "sa@platform.de")
|
||||
|
||||
r = await client.post("/api/v1/admin/resellers", json={
|
||||
"email": "reseller-a@partner.de", "first_name": "Rita", "last_name": "Reseller",
|
||||
}, headers=h)
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["company_count"] == 0
|
||||
|
||||
lst = await client.get("/api/v1/admin/resellers", headers=h)
|
||||
assert any(x["email"] == "reseller-a@partner.de" for x in lst.json())
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_reseller_creates_and_sees_only_own_company(client: AsyncClient, db_session):
|
||||
rid_a = await _make_user(db_session, email="res-a@p.de", role="RESELLER")
|
||||
rid_b = await _make_user(db_session, email="res-b@p.de", role="RESELLER")
|
||||
|
||||
ha = await _login(client, "res-a@p.de")
|
||||
hb = await _login(client, "res-b@p.de")
|
||||
|
||||
# Reseller A legt eine Firma an
|
||||
r = await client.post("/api/v1/reseller/companies", json={
|
||||
"name": "Alpha GmbH", "admin_email": "admin@alpha.de",
|
||||
"admin_first_name": "Al", "admin_last_name": "Pha",
|
||||
}, headers=ha)
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["name"] == "Alpha GmbH"
|
||||
|
||||
# Reseller B legt eine andere Firma an
|
||||
r2 = await client.post("/api/v1/reseller/companies", json={
|
||||
"name": "Beta GmbH", "admin_email": "admin@beta.de",
|
||||
"admin_first_name": "Be", "admin_last_name": "Ta",
|
||||
}, headers=hb)
|
||||
assert r2.status_code == 201, r2.text
|
||||
|
||||
# A sieht nur Alpha, NICHT Beta (RLS-Isolation)
|
||||
la = await client.get("/api/v1/reseller/companies", headers=ha)
|
||||
names_a = {c["name"] for c in la.json()}
|
||||
assert "Alpha GmbH" in names_a
|
||||
assert "Beta GmbH" not in names_a
|
||||
|
||||
lb = await client.get("/api/v1/reseller/companies", headers=hb)
|
||||
names_b = {c["name"] for c in lb.json()}
|
||||
assert "Beta GmbH" in names_b
|
||||
assert "Alpha GmbH" not in names_b
|
||||
assert rid_a != rid_b
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_reseller_cannot_access_admin_endpoints(client: AsyncClient, db_session):
|
||||
await _make_user(db_session, email="res-c@p.de", role="RESELLER")
|
||||
h = await _login(client, "res-c@p.de")
|
||||
r = await client.get("/api/v1/admin/tenants", headers=h)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_deactivated_tenant_blocks_login(client: AsyncClient, db_session):
|
||||
rid = await _make_user(db_session, email="res-d@p.de", role="RESELLER")
|
||||
h = await _login(client, "res-d@p.de")
|
||||
create = await client.post("/api/v1/reseller/companies", json={
|
||||
"name": "Gamma GmbH", "admin_email": "admin@gamma.de",
|
||||
"admin_first_name": "Ga", "admin_last_name": "Mma",
|
||||
}, headers=h)
|
||||
company_id = create.json()["id"]
|
||||
|
||||
# Direkt einen aktiven Mitarbeiter in Gamma anlegen (per DB) und Login testen
|
||||
await _make_user(db_session, email="emp@gamma.de", role="EMPLOYEE", company_id=company_id)
|
||||
ok = await client.post("/api/v1/auth/login", json={"email": "emp@gamma.de", "password": "Secret123"})
|
||||
assert ok.status_code == 200
|
||||
|
||||
# Firma deaktivieren → Login gesperrt
|
||||
await client.patch(f"/api/v1/reseller/companies/{company_id}", json={"is_active": False}, headers=h)
|
||||
blocked = await client.post("/api/v1/auth/login", json={"email": "emp@gamma.de", "password": "Secret123"})
|
||||
assert blocked.status_code == 403
|
||||
assert rid is not None
|
||||
Reference in New Issue
Block a user