Files
timemaster/backend/app/routers/import_kimai.py
T
patrick 62c4e742ab security: 9 Findings aus Security-Audit behoben (CRITICAL + HIGH + MEDIUM)
CRITICAL:
- C-1: LDAP tls_verify Default False → True (MITM-Schutz)
- C-2: TOTP-Secret Fernet-verschlüsselt in DB (statt Plaintext)
  - core/crypto.py: encrypt_value() / decrypt_value() helper
  - Migration 0026: totp_secret VARCHAR(64→500), ldap tls_verify default=true
  - _totp_plain() helper mit Legacy-Fallback für bestehende Werte

HIGH:
- H-1: Kiosk Nonce-Cache asyncio.Lock (Race Condition behoben)
- H-2: File-Upload-Limit 10 MB (import_kimai.py + users.py CSV-Import)
- H-3: CORS allow_methods/allow_headers explizit eingeschränkt (war *)
- H-4: TrustedHostMiddleware aktiviert wenn ALLOWED_HOSTS gesetzt

MEDIUM:
- M-1: IP-Logging nutzt X-Forwarded-For hinter nginx-Proxy
- M-4: Audit-Log für password_changed, totp_enabled, totp_disabled
- M-5: CalDAV verify_ssl in Production erzwungen (_effective_verify_ssl)

152/152 Tests grün

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:45:09 +02:00

101 lines
3.1 KiB
Python

"""Router: Kimai CSV Import (nur HR / Admin)."""
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_db, require_role
from app.models.user import User, UserRole
from app.services.kimai_import_service import (
ImportPreviewEntry,
ImportResult,
preview_kimai_import,
run_kimai_import,
)
router = APIRouter(prefix="/import", tags=["import"])
_allowed_roles = [UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN]
_MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
async def _read_upload(file: UploadFile) -> bytes:
"""Liest eine UploadFile mit Größenbegrenzung (max 10 MB)."""
content = await file.read(_MAX_UPLOAD_BYTES + 1)
if len(content) > _MAX_UPLOAD_BYTES:
raise HTTPException(
status_code=413,
detail=f"Datei zu groß. Maximale Upload-Größe: {_MAX_UPLOAD_BYTES // (1024 * 1024)} MB.",
)
return content
class ImportPreviewResponse(BaseModel):
preview: list[ImportPreviewEntry]
time_count: int
absence_count: int
skip_count: int
errors: list[str]
class ImportRunResponse(BaseModel):
time_imported: int
absence_imported: int
skipped: int
errors: list[str]
@router.post("/kimai/preview", response_model=ImportPreviewResponse)
async def kimai_preview(
user_id: Annotated[str, Form()],
file: Annotated[UploadFile, File()],
current_user: User = require_role(*_allowed_roles),
db: AsyncSession = Depends(get_db),
):
"""Vorschau des Kimai-Imports (keine DB-Änderungen)."""
try:
target_id = uuid.UUID(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Ungültige user_id")
content = await _read_upload(file)
result: ImportResult = await preview_kimai_import(content, target_id, db)
time_count = sum(1 for p in result.preview if p.kind == "time" and not p.skipped)
abs_count = sum(1 for p in result.preview if p.kind == "absence" and not p.skipped)
return ImportPreviewResponse(
preview=result.preview,
time_count=time_count,
absence_count=abs_count,
skip_count=result.skipped,
errors=result.errors,
)
@router.post("/kimai/run", response_model=ImportRunResponse)
async def kimai_run(
user_id: Annotated[str, Form()],
file: Annotated[UploadFile, File()],
current_user: User = require_role(*_allowed_roles),
db: AsyncSession = Depends(get_db),
):
"""Führt den Kimai-Import durch (schreibt in DB)."""
try:
target_id = uuid.UUID(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Ungültige user_id")
content = await _read_upload(file)
result: ImportResult = await run_kimai_import(content, target_id, current_user.id, db)
return ImportRunResponse(
time_imported=result.time_imported,
absence_imported=result.absence_imported,
skipped=result.skipped,
errors=result.errors,
)