feat: FZA Einzelstunden + Security-Fixes (K-1–K-5, H-2–H-4, M-1/M-3/M-6)

FZA Einzelstunden:
- Absence.fza_hours (Numeric 5,2) — FZA in Stunden statt Tagen
- Migration 0032: fza_hours Spalte in absences
- AbsenceCreate/AbsenceOut Schema um fza_hours erweitert
- absence_service: _deduct/_refund_overtime nutzt fza_hours direkt wenn gesetzt
- Frontend: Tage/Stunden-Toggle im FZA-Antrag-Modal

Security K-1: Privilege Escalation via PATCH /users/{id}.role
- user_service: Whitelist für Rollenänderungen, SUPER_ADMIN nur durch SUPER_ADMIN
- Letzter COMPANY_ADMIN gegen Selbst-Demotion gesichert

Security K-2: Kiosk-IP-Whitelist hinter nginx
- kiosk_security: _get_client_ip() liest X-Real-IP statt request.client.host

Security K-3: Kiosk-PIN Brute-Force-Schutz
- kiosk_auth_service: Redis-Lockout nach 5 Fehlversuchen (15 min)

Security K-4: TOTP-Setup-Hijacking
- auth router: /totp/setup abgelehnt wenn TOTP bereits aktiv

Security K-5: Separater Fernet-Key
- config: SECRET_KEY_DATA Feld (optional, Fallback auf SECRET_KEY)
- crypto: get_fernet_key() mit Warning bei fehlendem SECRET_KEY_DATA

Security H-2: Vacation Balance nur HR/Admin
- absences router: PATCH /balance nur noch HR/COMPANY_ADMIN/SUPER_ADMIN + AuditLog

Security H-3: Rate-Limits auf /auth/refresh + /auth/logout
- auth router: 30/min auf refresh, 60/min auf logout

Security H-4: Login-Failure-Logging + Lockout
- auth_service: Redis-Counter, Lockout nach 10 Versuchen (15 min)
- AuditLog für login_success und login_failed

Security M-1: Nginx Security-Header
- nginx.conf: X-Frame-Options, X-Content-Type-Options, CSP, Referrer-Policy, X-XSS-Protection, Permissions-Policy

Security M-3: AuditLog bei Rollenänderungen
- user_service: action=role_changed mit old/new role

Security M-6: create_all nur in Development
- main.py: Base.metadata.create_all nur wenn not settings.is_production

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 11:13:42 +02:00
parent c9cb6d7459
commit 06bb1c1664
19 changed files with 693 additions and 109 deletions
+42
View File
@@ -268,6 +268,48 @@ class UserService:
detail="Personalnummer kann nicht gelöscht werden (Reservierung).",
)
# Rolle-Änderung nur mit expliziter Berechtigung (Fix K-1: Privilege Escalation)
if "role" in changes and changes["role"] != user.role:
new_role = changes["role"]
# SUPER_ADMIN-Zuteilung: nur SUPER_ADMIN selbst darf das
if new_role == UserRole.SUPER_ADMIN and current_user.role != UserRole.SUPER_ADMIN:
raise HTTPException(
status_code=403,
detail="Nur SUPER_ADMIN darf die Rolle SUPER_ADMIN vergeben",
)
# COMPANY_ADMIN darf nur Rollen <= COMPANY_ADMIN vergeben (nicht SUPER_ADMIN)
allowed_roles_by_admin = {
UserRole.EMPLOYEE, UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN
}
if current_user.role == UserRole.COMPANY_ADMIN and new_role not in allowed_roles_by_admin:
raise HTTPException(status_code=403, detail="Ungültige Rollenzuteilung")
# Letzten COMPANY_ADMIN nicht demoten
if user.role == UserRole.COMPANY_ADMIN and new_role != UserRole.COMPANY_ADMIN:
from sqlalchemy import select, func
count_result = await db.execute(
select(func.count()).where(
User.company_id == user.company_id,
User.role == UserRole.COMPANY_ADMIN,
User.is_active == True,
User.id != user.id,
)
)
if count_result.scalar() == 0:
raise HTTPException(
status_code=400,
detail="Kann letzten COMPANY_ADMIN nicht downgraden",
)
# AuditLog für Rollen-Änderung
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="role_changed",
entity_type="user",
entity_id=user.id,
old_value={"role": user.role.value if hasattr(user.role, "value") else str(user.role)},
new_value={"role": new_role.value if hasattr(new_role, "value") else str(new_role)},
))
for field, value in changes.items():
setattr(user, field, value)