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:
@@ -205,6 +205,7 @@ class AbsenceService:
|
||||
half_day_start=data.half_day_start,
|
||||
half_day_end=data.half_day_end,
|
||||
working_days=working_days,
|
||||
fza_hours=data.fza_hours if hasattr(data, "fza_hours") else None,
|
||||
status=status,
|
||||
approved_by=approved_by,
|
||||
substitute_id=data.substitute_id,
|
||||
@@ -314,7 +315,9 @@ class AbsenceService:
|
||||
# Überstunden zurückbuchen wenn Freizeitausgleich
|
||||
absence_type = await db.get(AbsenceType, absence.type_id)
|
||||
if absence_type and absence_type.affects_overtime_balance:
|
||||
await self._refund_overtime(absence.user_id, absence.working_days, db)
|
||||
await self._refund_overtime(
|
||||
absence.user_id, absence.working_days, db, fza_hours=absence.fza_hours
|
||||
)
|
||||
elif absence.status != AbsenceStatus.PENDING:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
@@ -380,7 +383,9 @@ class AbsenceService:
|
||||
# Überstundenkonto abziehen wenn Freizeitausgleich
|
||||
fza_warnings: list[str] = []
|
||||
if absence_type and absence_type.affects_overtime_balance:
|
||||
fza_warnings = await self._deduct_overtime(absence.user_id, absence.working_days, db)
|
||||
fza_warnings = await self._deduct_overtime(
|
||||
absence.user_id, absence.working_days, db, fza_hours=absence.fza_hours
|
||||
)
|
||||
|
||||
# Audit-Log (DSGVO)
|
||||
db.add(AuditLog(
|
||||
@@ -601,13 +606,17 @@ class AbsenceService:
|
||||
return daily_hours
|
||||
|
||||
async def _deduct_overtime(
|
||||
self, user_id: UUID, working_days: float, db: AsyncSession
|
||||
self, user_id: UUID, working_days: float, db: AsyncSession,
|
||||
fza_hours: "Decimal | None" = None,
|
||||
) -> list[str]:
|
||||
"""Zieht working_days × tägliche Stunden vom Überstundenkonto ab.
|
||||
"""Zieht working_days × tägliche Stunden (oder direkt fza_hours) vom Überstundenkonto ab.
|
||||
Gibt Warnungen zurück. Wirft HTTPException wenn Überziehen verboten ist."""
|
||||
user = await db.get(User, user_id)
|
||||
daily_hours = await self._calc_daily_hours(user_id, db)
|
||||
hours_to_deduct = Decimal(str(working_days)) * daily_hours
|
||||
if fza_hours is not None:
|
||||
hours_to_deduct = Decimal(str(fza_hours))
|
||||
else:
|
||||
daily_hours = await self._calc_daily_hours(user_id, db)
|
||||
hours_to_deduct = Decimal(str(working_days)) * daily_hours
|
||||
|
||||
ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id))
|
||||
if ob is None:
|
||||
@@ -647,11 +656,15 @@ class AbsenceService:
|
||||
return warnings
|
||||
|
||||
async def _refund_overtime(
|
||||
self, user_id: UUID, working_days: float, db: AsyncSession
|
||||
self, user_id: UUID, working_days: float, db: AsyncSession,
|
||||
fza_hours: "Decimal | None" = None,
|
||||
) -> None:
|
||||
"""Rückbuchung von Freizeitausgleichs-Stunden (z.B. bei Stornierung)."""
|
||||
daily_hours = await self._calc_daily_hours(user_id, db)
|
||||
hours_to_refund = Decimal(str(working_days)) * daily_hours
|
||||
if fza_hours is not None:
|
||||
hours_to_refund = Decimal(str(fza_hours))
|
||||
else:
|
||||
daily_hours = await self._calc_daily_hours(user_id, db)
|
||||
hours_to_refund = Decimal(str(working_days)) * daily_hours
|
||||
|
||||
ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id))
|
||||
if ob is not None:
|
||||
|
||||
Reference in New Issue
Block a user