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:
@@ -1,6 +1,6 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
@@ -375,20 +375,46 @@ async def mark_certificate_received(
|
||||
async def update_balance(
|
||||
user_id: UUID,
|
||||
data: VacationBalanceUpdate,
|
||||
current_user: User = require_role(*_manager_roles),
|
||||
request: Request,
|
||||
current_user: User = require_role(UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
year: int = Query(...),
|
||||
):
|
||||
"""Grundurlaub, Sondertage und Resturlaub eines Mitarbeiters anpassen."""
|
||||
from app.models.vacation_balance import VacationBalance
|
||||
from app.models.audit_log import AuditLog
|
||||
target = await db.get(User, user_id)
|
||||
if target is None or target.company_id != current_user.company_id:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(404, "Mitarbeiter nicht gefunden")
|
||||
|
||||
balance = await absence_service.get_balance(user_id, year, db)
|
||||
|
||||
# Alte Werte für AuditLog sichern
|
||||
old_base = balance.base_days
|
||||
old_special = balance.special_days
|
||||
old_carried = balance.carried_over_days
|
||||
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(balance, field, value)
|
||||
|
||||
# AuditLog schreiben
|
||||
db.add(AuditLog(
|
||||
user_id=current_user.id,
|
||||
action="update_vacation_balance",
|
||||
entity_type="vacation_balance",
|
||||
entity_id=balance.id,
|
||||
old_value={"base_days": old_base, "special_days": old_special, "carried_over_days": old_carried},
|
||||
new_value={
|
||||
"base_days": balance.base_days,
|
||||
"special_days": balance.special_days,
|
||||
"carried_over_days": balance.carried_over_days,
|
||||
"target_user_id": str(user_id),
|
||||
"year": year,
|
||||
},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
pending = await absence_service.get_pending_days(user_id, year, db)
|
||||
company = await db.get(Company, current_user.company_id)
|
||||
|
||||
Reference in New Issue
Block a user