GET /portal/summary liefert Resturlaub und Überstundenguthaben als Firmensummen, keine Einzelpersonen und keine
Krankheitsdaten. Firmen-Token (SHA-256) wie beim Busylight, Verwaltung unter /companies/me/portal-token, Migration 0049.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Security-Audit 2026-09-10 (siehe Ed25519-Reinstall-Commits):
- Rate-Limit 10/min auf POST /kiosk/my-qr-token (Q-1) – kompromittiertes
Employee-JWT konnte sonst unbegrenzt QR-Login-Tokens erzeugen.
- _get_client_ip vertraut X-Real-IP nur noch, wenn die Verbindung selbst
von 127.0.0.1 kommt (I-1) – sonst könnte ein Client den Header selbst
setzen und die Kiosk-IP-Whitelist umgehen.
- Migration 0047: Unique-Index auf kiosk_devices.device_token_hash (B-2,
war Seq-Scan pro Bearer-Request), Index auf time_entries.kiosk_device_id
(K-1). RLS auf kiosk_devices bereits seit Migration 0024 vorhanden,
K-1-Verdacht einer RLS-Lücke damit ausgeräumt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
Bisher wurde bei Kiosk-Stempelungen nur die grobe Quelle ("kiosk")
gespeichert, nicht welches Gerät/welcher Standort. Bei mehreren Eingängen/
Filialen war nicht nachvollziehbar, wo ein Mitarbeiter sich eingestempelt
hat. Migration 0046: time_entries.kiosk_device_id (FK kiosk_devices,
SET NULL). Wird nur beim Einstempeln gesetzt (Ursprung des Eintrags), nicht
bei Aus-/Pause-Aktionen überschrieben. TimeEntryOut.kiosk_device_id neu.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
POST /kiosk/my-qr-token (normale JWT-Auth) – Mitarbeiter fordert einen 5min
gültigen, einmalig einlösbaren QR-Token an, den die Employee-App als QR-Code
zeigt. Kiosk scannt ihn (POST /kiosk/auth/qr, bereits vorhanden) und meldet
den Mitarbeiter an. Backend-Funktion (generate_qr_token) existierte schon,
war aber nie über einen Endpunkt erreichbar. Test für den kompletten Ablauf
inkl. Einmaligkeit (zweiter Versuch mit selbem Token → 401).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
Neuer Endpunkt POST /kiosk/devices/bearer (Admin-JWT statt CLI) – erzeugt
Token serverseitig, gibt ihn nur in dieser einen Antwort im Klartext
zurück (KioskBearerDeviceCreated{device, token}), DB speichert nur den
Hash. Ermöglicht QR-Code-Einrichtung direkt aus dem Portal statt CLI-only.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
session.refresh() nach commit scheitert an RLS (SET LOCAL app.bypass_rls
verfällt mit dem commit, siehe project_rls_bypass_commit_pitfall) – trat
beim ersten echten Bearer-Geräte-Test auf ("Could not refresh instance").
Beide refresh()-Aufrufe entfernt, nicht nötig: alle in der Erfolgsmeldung
angezeigten Felder sind bereits im Python-Objekt gesetzt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
Direkte DB-Inserts über db_session brauchen SET LOCAL app.bypass_rls='on'
pro Transaktion (verfällt nach commit, siehe [[project_rls_bypass_commit_pitfall]]).
db_session.refresh() nach commit schlug aus demselben Grund fehl – entfernt,
nicht nötig. Bestehender test_heartbeat_missing_headers erwartete 422,
liefert jetzt korrekt 401 (Header sind seit Bearer-Auth-Modus optional).
229/229 Tests grün auf 137.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
Neuer, einfacherer Geräte-Auth-Modus (key_algorithm="bearer_vpn") als
Alternative zum bestehenden Ed25519-Request-Signing. Bestehende PC-Terminals
(key_algorithm="ed25519") bleiben unverändert – verify_kiosk_request()
verzweigt jetzt am Authorization-Header: "Bearer <token>" → einfacher Pfad
(nur Token-Hash + Status + IP-Whitelist, kein Timestamp/Nonce/Signatur),
sonst weiterhin die 4 X-Kiosk-*-Header wie bisher.
Hintergrund: Terminals+Tablets laufen jetzt hinter WireGuard-VPN, die
ursprüngliche MITM-Härtung (Ed25519-Signing) ist für Tablets keine
Kernanforderung mehr. Ermöglicht ein deutlich einfacheres Flutter-Kiosk-App-
Projekt ohne Client-seitige Krypto.
- Migration 0045: kiosk_devices.device_token_hash
- backend/cli.py: `kiosk add --auth-mode bearer` generiert Token, gibt es
einmalig aus (analog Enrollment-Setup-URL bisher)
- 4 neue Tests in test_kiosk_security.py (gültig/ungültig/IP-Whitelist/revoked)
Vollständiger Plan: /home/sysops/.claude/plans/staged-marinating-dove.md
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
login_qr() las den Redis-Token per GET und löschte ihn danach separat per
DELETE – zwei parallele Requests mit demselben QR-Token konnten beide vor
dem Löschen lesen und je eine Session erzeugen (Doppel-Login statt echtem
Einmal-Token). Fix: GETDEL (atomar seit Redis 6.2, Server läuft 8.0.2).
K-2 (Cross-Tenant-Redeem) aus demselben Audit betrifft keinen realen
Code-Pfad – company_id-Vergleich in login_qr() war bereits korrekt, und die
Kiosk-Geräte-Enrollment "QR-Redeem"-Annahme existiert nicht als HTTP-Flow
(Enrollment läuft ausschließlich über authentifizierte CLI). Verifiziert
vor Weiterplanung der Android-Tablet-Kiosk-Architektur.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
start_time/end_time/break_start wurden mit vollen Mikrosekunden gespeichert
(datetime.now(timezone.utc).time() ohne microsecond=0), was Clients ohne
Mikrosekunden-Parsing brechen konnte (siehe Mobile-App-Crash). Sekunden-
Genauigkeit reicht für Zeiterfassung.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
get_async_redis() hatte keine socket_connect_timeout/socket_timeout/
max_connections – ein gehängtes Redis hätte Requests unbegrenzt
blockiert statt schnell zu failen. Zusätzlich einmaliger Ping im
Lifespan-Startup, nur zur Log-Sichtbarkeit (blockiert Boot nicht).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
Login-, TOTP- und Kiosk-PIN-Lockout zählten Fehlversuche mit separaten
INCR/EXPIRE-Calls. Zwischen beiden konnte der Fail-Counter-Key kurzzeitig
ohne TTL bestehen bleiben (Crash-Fenster) - nicht atomar. Jetzt per
redis.pipeline(transaction=True) als MULTI/EXEC.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
Redis-Review deckte auf, dass der Pool-Fix vom letzten Commit nur
totp_login/kiosk_security erreichte. Login/Refresh (auth_service.py) und
PIN/NFC/QR-Kiosk-Login (kiosk_auth_service.py) öffneten weiterhin pro
Request eine neue aioredis-Verbindung. Zusätzlich nutzten
kiosk_session_service.py und public_stamp_session_service.py den
*sync* Redis-Client aus async-Code – blockierender Socket-Call im
Event-Loop bei jedem Kiosk-/Stempel-Request.
Alle auf get_async_redis() umgestellt. TOTP-Lockout wirft jetzt 503
statt eines ungefangenen 500 bei Redis-Ausfall (RedisError explizit
gefangen, eigene HTTPExceptions unberührt).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
create_absence() gibt (absence, warnings) zurück, der Router verwarf die
Warnungen bisher (im Unterschied zu approve_absence, das sie via
AbsenceApproveOut.warnings durchreicht). Neues AbsenceCreateOut-Schema
analog dazu. Regressionstest über Überschneidungs-Warnung.
Gefunden während Coverage-Arbeit an absence_service.py.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
_get_holiday_dates() fragte nur PublicHoliday für data.start_date.year ab.
Ein Antrag über den Jahreswechsel (z.B. 28.12.-01.01.) rechnete den
Neujahrsfeiertag im Folgejahr daher nicht heraus -> Mitarbeiter verlor
einen Urlaubstag, den er nicht verbraucht hat. Neuer year_to-Parameter
deckt den Jahres-Range ab, beide Aufrufstellen (create_absence,
update_absence) angepasst. Regressionstest ergänzt.
Gefunden während Coverage-Arbeit an absence_service.py/report_service.py.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
report_service.datev_monthly_report compares user.company_id (uuid.UUID)
against the passed company_id with != — a plain str from the JSON API
response never equals the UUID, so it always looked like a mismatch.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
SET LOCAL app.bypass_rls only holds for the current transaction; a prior
commit() silently re-enabled RLS, causing db.get(User/Company, ...) to
return None on the raw db_session (no app.company_id context).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
create_absence's warnings are discarded by the router (only approve_absence
passes them through AbsenceOut.warnings) — test now calls the service
directly and documents this as a separate finding. DATEV test kept within
a single year since absence_service._get_holiday_dates only looks up
holidays for start_date.year (cross-year ranges miss the boundary holiday).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
/docs blieb leer wenn das CDN vom Client-Netzwerk aus nicht erreichbar
war (Firewall/Proxy) – Backend lieferte korrektes HTML, aber Swagger-UI-
JS/CSS und ReDoc-JS kamen nicht an. Jetzt unter app/static/swagger-ui/
gebündelt und über eigene /docs+/redoc-Routen ausgeliefert (nur Dev,
Production weiterhin ohne interaktive Docs).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
Baseline liegt bei 55% (verifiziert auf 137). Gate mit 5%-Puffer,
kein CI-Job dafür – Tests brauchen echtes Postgres+Redis mit RLS-Setup,
läuft nur remote (SSH), nicht in Gitea Actions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
TOTP-Login und Kiosk-Nonce-Check öffneten/schlossen bisher pro Request eine
neue aioredis-Verbindung. Neuer get_async_redis()-Pool in core/redis.py wird
von beiden Stellen genutzt, sauberer Shutdown im FastAPI-Lifespan.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
Login (PIN/NFC/QR/Liste) lieferte einen session_token, aber es gab keinen
Endpunkt der ihn tatsächlich zum Stempeln nutzt (X-Kiosk-Session-Token war
nur als Konzept in kiosk_session_service dokumentiert, nirgends verdrahtet).
Neu: POST /kiosk/stamp/{in,out,break-start,break-end,status}. Gerät wird
weiterhin per Ed25519 verifiziert (verify_kiosk_request), zusätzlich validiert
_user_from_session() dass der session_token zu genau diesem Gerät gehört und
der User noch aktiv/in der richtigen Firma ist. Reuse von time_service
(stamp_in/out/break_start/break_end/get_today), source=KIOSK.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gis16MnuwkYcivLrSxK1pD
Bisher genügte die reine NFC-UID zum Einstempeln. Getestete Reader-Hardware
(günstiger USB-HID-RFID-Leser, EM4100 125kHz) liefert nur eine unverschlüsselte,
trivial klonbare Chip-ID - identisch zum in security_audit_kiosk_qr_nfc_2026_05_26
(K-3) beschriebenen Risiko, das bisher offen war.
- login_nfc() verlangt jetzt PIN, nutzt denselben Brute-Force-Lockout wie
login_pin (keyed auf nfc_uid statt Personalnummer)
- Neuer Endpunkt POST /users/{id}/kiosk-nfc (Admin/HR) zum Zuordnen einer
Karte zu einem Mitarbeiter - existierte bisher gar nicht, kiosk_nfc_uid
war nur im Model vorhanden, nirgends setzbar
- Company-interner Unique-Check (eine Karte = ein Mitarbeiter)
Kein bestehendes Frontend nutzt NFC-Login bisher, daher kein Breaking Change.
Höhere Sicherheitsstufe (NTAG424 SUN, klon-resistent) bleibt vorgemerkt für
späteren Hardware-Wechsel (aktueller Reader kann keine Kryptografie).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gis16MnuwkYcivLrSxK1pD
Gleiches Muster wie hours_payouts.status (7166bbb): Model hatte nur
Python-default, kein server_default -> Migration 0044 zieht DB-Default
nach. Retention-Tests nutzten fehlenden Wert bei Raw-SQL-Insert und
fehlenden RLS-Bypass zwischen den Transaktionsgrenzen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gis16MnuwkYcivLrSxK1pD
Neuer retention_service.py: Lohn-/zeitrelevante Daten (time_entries,
hours_payouts) werden nach konfigurierbarer Frist gelöscht
(company.settings.retention_lohn_years, Default 10 Jahre). Technische
Tabellen mit fester Frist: audit_logs (3 Jahre), abgelaufene
sessions/password_resets (sofort).
Täglicher Scheduler-Job (03:00 Uhr, Redis-Tageslock analog Reminder-Jobs)
plus manuelle Trigger: POST /companies/me/run-retention-purge
(COMPANY_ADMIN/HR, nur eigene Firma) und POST /admin/run-retention-purge
(SUPER_ADMIN, global inkl. technischer Tabellen).
Letzter offener Punkt aus dem DSGVO-Löschkonzept (Art. 15/17 bereits erledigt).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gis16MnuwkYcivLrSxK1pD
Model hatte nur default= (ORM-seitig), kein server_default. Test-DB nutzt
Base.metadata.create_all statt Alembic-Migrationen -> Spalte war dort NOT
NULL ohne DB-Default, raw-SQL-Inserts (test_rls.py) schlugen fehl. Prod-DB
hat den Default bereits aus Migration 0042 - hier nur Model nachgezogen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gis16MnuwkYcivLrSxK1pD
special_assignments (Migration 0029) hatte keine Row-Level-Security in
Produktion, obwohl company_id/user_id geführt werden - Tests liefen
trotzdem grün, da conftest.py die Policy bereits simuliert hatte
(gefunden durch postgres-expert-Review). Analog 0039 (hours_payouts)
nachgezogen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gis16MnuwkYcivLrSxK1pD
GET /users/{id}/export-data liefert alle personenbezogenen Daten
(Profil, Zeiterfassung, Abwesenheiten, Kommentare, Urlaubs-/Überstunden-
konto, Auszahlungen, AuditLog-Einträge über den User) als JSON-Download.
Eigenes Konto immer erlaubt, fremde Konten nur HR/Admin/Manager der
eigenen Firma. Ergänzt die bestehende Art.-17-Anonymisierung.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CviFgc739S89xS97pvjszj
Neuer PDF-Export GET /reports/datev-monthly/export: ein Blatt pro
Mitarbeiter/Monat im Layout der DATEV-Stundenaufzeichnungs-Vorlage
(Beginn/Pause/Ende/Dauer + K/U/UU/F/SA/SU-Kürzel + Bemerkungen +
Summe + Unterschriftsfelder). Führt time_entries, Absences und
Feiertage pro Kalendertag zusammen.
Außerdem: Mustervorlagen für neue Backend-Module (Model/Schema/Router/
Migration/Test) und eine Frontend-Page-Vorlage, abgeleitet vom
hours_payouts-Modul als aktuellstem sauberen Muster.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CviFgc739S89xS97pvjszj