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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user