Files
patrickandClaude Sonnet 5 5a9e432a80 feat(dsgvo): Löschkonzept/Aufbewahrungsfristen (Auto-Purge)
Neuer retention_service.py: Lohn-/zeitrelevante Daten (time_entries,
hours_payouts) werden nach konfigurierbarer Frist gelöscht
(company.settings.retention_lohn_years, Default 10 Jahre). Technische
Tabellen mit fester Frist: audit_logs (3 Jahre), abgelaufene
sessions/password_resets (sofort).

Täglicher Scheduler-Job (03:00 Uhr, Redis-Tageslock analog Reminder-Jobs)
plus manuelle Trigger: POST /companies/me/run-retention-purge
(COMPANY_ADMIN/HR, nur eigene Firma) und POST /admin/run-retention-purge
(SUPER_ADMIN, global inkl. technischer Tabellen).

Letzter offener Punkt aus dem DSGVO-Löschkonzept (Art. 15/17 bereits erledigt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gis16MnuwkYcivLrSxK1pD
2026-08-27 09:04:27 +02:00

235 lines
8.9 KiB
Python

"""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.
"""
import secrets
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, admin, initial_password = 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)}
result = tenants[company.id]
result.initial_password = initial_password
return result
@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 data.email and await db.scalar(select(User).where(User.email == data.email)):
raise HTTPException(status_code=400, detail="E-Mail ist bereits vergeben.")
initial_password: str | None = None
if data.email:
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,
)
else:
# Kein Mailversand: interne Kennung + sofort nutzbares Temp-Passwort
login = await tenant_service._unique_email(
f"{data.first_name}.{data.last_name}", "reseller", db
)
initial_password = secrets.token_urlsafe(9)
reseller = User(
company_id=None, email=login,
first_name=data.first_name, last_name=data.last_name,
role=UserRole.RESELLER,
password_hash=hash_password(initial_password),
is_active=True,
)
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, "invited": bool(data.email)},
))
await db.flush()
if data.email:
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,
initial_password=initial_password,
)
@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),
)
@router.post("/run-retention-purge")
async def run_global_retention_purge(
current_user: User = require_role(*_sa),
db: AsyncSession = Depends(get_db),
):
"""Globaler DSGVO-Purge über alle Firmen: Zeiterfassung/Auszahlungen je Firma
(Frist konfigurierbar) plus firmenübergreifende technische Tabellen
(audit_logs > 3 Jahre, abgelaufene sessions/password_resets). Gleiche Logik
wie der tägliche Scheduler-Job."""
from app.services.retention_service import run_retention_purge
result = await run_retention_purge(db, company_id=None)
await db.commit()
return {"deleted": result}