- docs/api.md: komplette API-Referenz (1375 Zeilen, alle Endpunkte) - docs/architecture.md: Tech-Stack, DB-Schema, RLS-Architektur, Auth-Flow - docs/deployment.md: Setup, nginx, systemd, update.sh, Backup/Rollback - docs/development.md: Dev-Setup, Test-Workflow, Code-Konventionen, Fallstricke Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
19 KiB
TimeMaster – Architektur-Dokumentation
Stand: 2026-05-24
Tech-Stack
| Schicht | Technologie | Version |
|---|---|---|
| Backend | Python / FastAPI | 3.12 / 0.115+ |
| ORM | SQLAlchemy async | 2.x |
| Datenbank | PostgreSQL | 16 |
| Cache / Sessions | Redis | 7 |
| Frontend | React + TypeScript | 18 / 5 |
| CSS-Framework | Tailwind CSS | 3 |
| Build-Tool | Vite | 5 |
| Prozess-Manager | systemd | – |
| Reverse Proxy | nginx | 1.24+ |
| Migrationen | Alembic | 1.x |
| Authentifizierung | JWT (python-jose) + bcrypt | – |
| 2FA | pyotp (TOTP / RFC 6238) | – |
| Resend.com HTTP API | – | |
| Tests | pytest + pytest-asyncio + httpx | – |
Verzeichnisstruktur
/opt/timemaster/
├── backend/
│ ├── app/
│ │ ├── core/
│ │ │ ├── config.py # Pydantic Settings (liest .env)
│ │ │ ├── database.py # Engine, AsyncSessionLocal, Base
│ │ │ ├── dependencies.py # FastAPI Depends: get_current_user, require_role
│ │ │ ├── limiter.py # slowapi Rate-Limiter
│ │ │ └── security.py # JWT, Passwort-Hash, Token-Utilities
│ │ ├── models/
│ │ │ ├── company.py # Company, Department
│ │ │ ├── user.py # User, UserRole, AuthProvider
│ │ │ ├── session.py # Refresh-Token-Sessions
│ │ │ ├── password_reset.py # Passwort-Reset-Tokens
│ │ │ ├── audit_log.py # AuditLog
│ │ │ ├── absence.py # Absence, AbsenceStatus
│ │ │ ├── absence_type.py # AbsenceType
│ │ │ ├── vacation_balance.py
│ │ │ ├── public_holiday.py # Feiertage
│ │ │ ├── overtime_balance.py
│ │ │ ├── time_entry.py # TimeEntry, WorkSchedule
│ │ │ ├── work_schedule.py
│ │ │ ├── kiosk_device.py
│ │ │ ├── caldav_config.py # CalDAV-Konfig (Firma + User)
│ │ │ ├── ldap_config.py
│ │ │ ├── smtp_config.py
│ │ │ └── project.py # (derzeit nicht aktiv, Migration 0014 entfernt)
│ │ ├── schemas/ # Pydantic v2 Ein-/Ausgabe-Schemas
│ │ │ ├── auth.py
│ │ │ ├── user.py
│ │ │ ├── absence.py
│ │ │ ├── time_entry.py
│ │ │ └── ...
│ │ ├── routers/ # FastAPI APIRouter – ein File pro Modul
│ │ │ ├── auth.py # /api/v1/auth/*
│ │ │ ├── users.py # /api/v1/users/*
│ │ │ ├── companies.py # /api/v1/companies/*
│ │ │ ├── absences.py # /api/v1/absences/*
│ │ │ ├── time_entries.py # /api/v1/time/*
│ │ │ ├── kiosk.py # /api/v1/kiosk/*
│ │ │ ├── reports.py # /api/v1/reports/*
│ │ │ ├── audit.py # /api/v1/audit-logs/
│ │ │ ├── caldav.py
│ │ │ ├── ldap.py
│ │ │ ├── smtp.py
│ │ │ └── busylight.py
│ │ └── services/ # Business-Logik – kein HTTP-Wissen
│ │ ├── auth_service.py
│ │ ├── user_service.py
│ │ ├── absence_service.py
│ │ ├── time_service.py # inkl. ArbZG-Checks
│ │ ├── email_service.py
│ │ ├── ldap_service.py
│ │ └── caldav_service.py
│ ├── migrations/
│ │ ├── env.py # Alembic-Konfiguration
│ │ └── versions/ # 0001–0024 Migrationsdateien
│ ├── tests/
│ │ ├── conftest.py # pytest-Fixtures (session-scope, RLS-Setup)
│ │ ├── test_auth.py
│ │ ├── test_users.py
│ │ ├── test_absences.py
│ │ └── ...
│ ├── .env # Umgebungsvariablen (nie committen)
│ ├── alembic.ini
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── api/ # Axios-Client-Funktionen pro Ressource
│ │ ├── components/
│ │ │ ├── Layout.tsx # Haupt-Layout mit Navigation
│ │ │ ├── Modal.tsx
│ │ │ ├── ProtectedRoute.tsx
│ │ │ ├── Spinner.tsx
│ │ │ └── absences/ # Abwesenheits-Unterkomponenten
│ │ ├── context/ # React Context: AuthContext
│ │ ├── hooks/ # Custom Hooks (useAuth, useAbsences, ...)
│ │ ├── pages/ # Eine Datei pro Seite/Route
│ │ │ ├── LoginPage.tsx
│ │ │ ├── DashboardPage.tsx
│ │ │ ├── TimeTrackingPage.tsx
│ │ │ ├── AbsencesPage.tsx
│ │ │ ├── UsersPage.tsx
│ │ │ ├── ReportsPage.tsx
│ │ │ ├── ProfilePage.tsx
│ │ │ ├── CompanySettingsPage.tsx
│ │ │ ├── KioskDevicesPage.tsx
│ │ │ ├── AuditLogPage.tsx
│ │ │ └── ...
│ │ ├── types/ # TypeScript-Interfaces
│ │ ├── utils/ # Hilfsfunktionen
│ │ ├── App.tsx # Router-Konfiguration
│ │ └── main.tsx
│ ├── dist/ # Build-Output (von nginx ausgeliefert)
│ └── package.json
├── nginx.conf
├── timemaster.service
├── setup_server.sh
└── update.sh
Datenbankschema
Tabellenübersicht und Beziehungen
companies
├──< departments (company_id)
├──< users (company_id)
│ ├──< sessions (user_id) -- Refresh-Token-Sessions
│ ├──< password_resets (user_id)
│ ├──< time_entries (user_id)
│ ├──< absences (user_id)
│ │ └──> absence_types (type_id)
│ ├──< vacation_balances (user_id)
│ └── work_schedule_id → work_schedules
├──< work_schedules (company_id)
├──< kiosk_devices (company_id)
├──< audit_logs (company_id)
├──< overtime_balances (company_id)
├── ldap_configs
├── smtp_configs
└── caldav_company_configs
Wichtige Tabellen
companies – Mandanten-Stammdaten
idUUID PKname,slug(URL-freundlich, unique)sick_note_required_after_daysINTEGER DEFAULT 3personnel_number_requiredBOOLEAN DEFAULT FALSEpersonnel_number_modeVARCHAR(10) DEFAULT 'manual'personnel_number_nextINTEGER DEFAULT 1
users
id,company_id,department_idemailunique,password_hash,first_name,last_nameroleENUM: SUPER_ADMIN | COMPANY_ADMIN | HR | MANAGER | EMPLOYEEauth_providerENUM: local | ldappersonnel_numberVARCHAR(50) – nur Ziffern, partial unique per Firmatotp_secret,totp_enabled– TOTP 2FAkiosk_pin_hash,kiosk_qr_tokencan_manual_time_entryBOOLEAN DEFAULT FALSEis_activeBOOLEAN – kein Hard-Deletecreated_at
sessions – Refresh-Token-Verwaltung
id,user_id,token_hash(gehashter Refresh-Token)expires_at,created_at,ip_address,user_agent- Bei Rotation: altes Token wird gelöscht, neues wird angelegt
absence_types
company_id,name,codeaffects_vacationBOOLEAN – zieht vom Urlaubskonto abrequires_approvalBOOLEANcertificate_after_daysINTEGER NULL – per-Typ-Override für AU-Pflichtcolor– UI-Darstellung
absences
user_id,type_idstart_date,end_date,half_day_start,half_day_endworking_daysNUMERIC(5,1)statusENUM: pending | approved | rejected | cancelledapproved_by,substitute_idcertificate_required_byDATE – auto-berechnet bei SICK-Absencescertificate_receivedBOOLEAN DEFAULT FALSEmetaJSONB – flexible Zusatzdaten (Weiterbildung, Dienstreise etc.)
vacation_balances
user_id,yearbase_days,special_days,carried_over_days,used_dayscomment
time_entries
user_id,datestart_time,end_time,break_minutesworking_minutesCOMPUTEDstatusENUM: draft | submitted | approved | rejectedsourceENUM: manual | stamp | importarbzg_warning– ArbZG-Verletzungs-Flag
work_schedules
company_id,namedays_per_week, wöchentliche Soll-Stunden- Für ArbZG-Berechnung und Überstunden-Balance
kiosk_devices
company_id,name,locationdevice_token_hash– aktuell TOKEN-basiert (agent-08: Ed25519-Upgrade geplant)is_active– wird in 0021 durchstatusENUM ersetzt
audit_logs
company_id,user_idaction,entity_type,entity_idold_value,new_valueJSONBip_address,created_at
public_holidays – global (keine RLS), pro Bundesland/Datum
Alembic-Migrationskette
0001_initial companies, departments, users, sessions, password_resets, audit_logs
0002_time_entries time_entries, work_schedules
0003_absences absence_types, absences, vacation_balances, public_holidays
0004_ldap ldap_configs
0005_extensions Erweiterungsfelder (company/user)
0006_smtp smtp_configs
0007_caldav_and_fixes caldav_company_configs, caldav_user_configs; Bugfixes
0008_ldap_tls_verify ldap_configs.tls_verify Spalte
0009_absence_correction absences.correction_note
0010_public_holidays public_holidays.federal_state
0011_caldav_name_format caldav Name-Format-Felder
0012_caldav_template CalDAV-Template + users.kuerzel
0013_projects projects Tabelle (temporär)
0014_remove_projects projects Tabelle entfernt (Feature verschoben)
0015_totp users.totp_secret, totp_enabled
0016_vacation_special vacation_balances.special_days + Feiertagskalender-Erweiterungen
[0017 übersprungen] Nummer wurde nie in die Alembic-Kette aufgenommen
0018_kiosk_devices kiosk_devices Tabelle
0019_manual_time_entry users.can_manual_time_entry
0020_personnel_number users.personnel_number, companies.personnel_number_*
0021_kiosk_security TODO (agent-08): Ed25519 Public-Key, Enrollment, Heartbeat
0022_sick_note_config companies.sick_note_required_after_days
0023_busylight_pull companies.busylight_pull_token
0024_row_level_security PostgreSQL RLS auf allen Tabellen
Hinweis: Migration 0017 wurde übersprungen und existiert nicht in der Kette. Neuen Migrationen folgen auf 0024.
Row Level Security (RLS)
Seit Migration 0024 setzt TimeMaster PostgreSQL Row Level Security für vollständige Mandanten-Isolation auf Datenbankebene ein. Selbst bei einem Fehler in der Applikationsschicht kann kein User Daten einer anderen Firma sehen.
Prinzip
Jede Tabelle hat vier Policies (SELECT, INSERT, UPDATE, DELETE). Die Policies prüfen zwei PostgreSQL-Session-Variablen:
-- Bypass-Flag: 'on' = alle Zeilen sichtbar (für unauthentifizierte Endpunkte)
app.bypass_rls
-- Company-ID des eingeloggten Users (UUID als Text)
app.company_id
Policy-Typen
COMPANY_COL – Tabellen mit direkter company_id-Spalte:
absence_types, audit_logs, caldav_company_configs, departments, kiosk_devices, ldap_configs, overtime_balances, smtp_configs, users, work_schedules
(COALESCE(current_setting('app.bypass_rls', true), 'off') = 'on')
OR company_id = NULLIF(current_setting('app.company_id', true), '')::uuid
USER_JOIN – Tabellen ohne direkte company_id, verknüpft über user_id:
absences, caldav_user_configs, password_resets, sessions, time_entries, vacation_balances
(bypass) OR user_id IN (SELECT id FROM users WHERE company_id = ...)
companies selbst – Policy auf id (die Firma darf nur sich selbst sehen):
(bypass) OR id = NULLIF(current_setting('app.company_id', true), '')::uuid
Ablauf pro HTTP-Request
get_db()öffnet eine AsyncSession, setztSET LOCAL app.bypass_rls = 'on'get_current_user()Dependency lädt den User (bypass noch aktiv)- Falls der User kein SUPER_ADMIN ist:
SET LOCAL app.company_id = '<uuid>'SET LOCAL app.bypass_rls = 'off'
- Alle folgenden Queries in dieser Transaktion sind automatisch auf die Firma des Users gefiltert
- SUPER_ADMIN behält bypass aktiv – kann alle Firmen sehen
expire_on_commit=False
Die AsyncSessionLocal ist mit expire_on_commit=False konfiguriert. Das ist zwingend notwendig: Nach einem db.commit() würde SQLAlchemy sonst alle Attribute als "expired" markieren und beim nächsten Zugriff einen neuen SELECT auslösen. Da der RLS-Kontext (SET LOCAL) transaktionsgebunden ist, wäre dieser zweite SELECT ggf. ohne Kontext – und würde entweder leer zurückkehren oder scheitern.
Konsequenz: db.refresh(obj) nach einem commit() ist verboten. Stattdessen beim flush() vor dem Commit die benötigten Werte sichern.
Authentifizierungs-Flow
Standard-Login (JWT + Refresh-Token)
Client FastAPI PostgreSQL / Redis
│ │ │
│── POST /api/v1/auth/login ─────>│ │
│ { email, password } │── SELECT user WHERE email ────>│
│ │<─ User-Objekt ─────────────────│
│ │── verify_password() │
│ │── INSERT sessions (token_hash)─>│
│<── { access_token, refresh_token } │
│ (JWT 30min, opaque 30 Tage) │
│ │ │
│── GET /api/v1/... (Bearer)─────>│ │
│ │── decode_access_token() │
│ │── db.get(User, user_id) │
│ │── SET LOCAL app.company_id │
│ │── SET LOCAL app.bypass_rls=off │
│<── Response ───────────────────│ │
│ │ │
│── POST /api/v1/auth/refresh────>│ │
│ { refresh_token } │── hash_token(refresh_token) │
│ │── SELECT sessions WHERE hash──>│
│ │── DELETE old session │
│ │── INSERT new session │
│<── { neue access_token, refresh_token } │
- Access Token: JWT (HS256), Payload:
sub= user_id (UUID),exp - Refresh Token: 64-byte-Zufallsstring, in DB nur als SHA-256-Hash gespeichert
- Rotation: Jedes Refresh erzeugt ein neues Token-Paar. Das alte Refresh-Token wird sofort invalidiert.
TOTP / 2FA-Flow
Wenn user.totp_enabled = True:
POST /auth/login → { totp_required: true, partial_token: "<JWT>" }
↓
POST /auth/login/totp { partial_token, totp_code }
↓ pyotp.TOTP(secret).verify(code, valid_window=1)
↓
{ access_token, refresh_token } (vollständige Session)
TOTP-Setup (Profil-Seite):
GET /auth/totp/setup→{ secret, qr_code_url }(base32-Secret, otpauth:// URI)- User scannt QR in Authenticator-App
POST /auth/totp/confirm { code }– verifiziert ersten Code, setzttotp_enabled = TruePOST /auth/totp/disable { password, code }– deaktiviert 2FA nach Bestätigung
Rollen-Hierarchie
SUPER_ADMIN
└── COMPANY_ADMIN (Vollzugriff eigene Firma, sieht ALLE User der Firma)
├── HR (Personalakten, Reports, Attest-Verwaltung)
├── MANAGER (Genehmigungen für eigenes Team)
└── EMPLOYEE (Eigene Daten, eigene Anträge)
SUPER_ADMIN ist der Plattform-Betreiber und hat RLS-Bypass. COMPANY_ADMIN registriert sich selbst beim /auth/register-Endpunkt und legt damit automatisch eine neue Firma an.
Datenfluss: Zeiterfassung
POST /time/stamp-in
→ time_service.stamp_in(user_id, db)
→ Prüfe: kein offener Eintrag für heute
→ INSERT time_entries(user_id, date, start_time, status='draft')
POST /time/stamp-out
→ time_service.stamp_out(user_id, db)
→ Hole offenen Eintrag
→ Berechne working_minutes = end - start - break_minutes
→ ArbZG-Check:
if working_minutes > 480: warn (max 8h; bis 600 möglich mit Warnung)
if working_minutes > 360 and break_minutes < 30: warn (Pause fehlt ab 6h)
if working_minutes > 540 and break_minutes < 45: warn (Pause fehlt ab 9h)
→ UPDATE time_entries(end_time, working_minutes, arbzg_warning)
PATCH /time/entries/{id}/approve [MANAGER/HR/ADMIN]
→ UPDATE status = 'approved'
→ INSERT audit_log
PATCH /time/entries/{id}/reject [MANAGER/HR/ADMIN]
→ UPDATE status = 'rejected', rejection_reason
Datenfluss: Abwesenheiten
POST /absences (Antrag stellen)
→ absence_service.create(data, current_user, db)
→ Berechne working_days (Wochentage - Feiertage)
→ Prüfe Urlaubskonto falls affects_vacation = True
→ Auto-calculate certificate_required_by falls AbsenceType.code = 'SICK'
(start_date + sick_note_required_after_days - 1)
→ INSERT absences(status='pending')
→ INSERT audit_log
POST /absences/{id}/approve [MANAGER/HR/ADMIN]
→ UPDATE absences.status = 'approved', approved_by = current_user.id
→ Falls affects_vacation: UPDATE vacation_balances.used_days += working_days
→ INSERT audit_log
POST /absences/quick-sick [EMPLOYEE]
→ Holt ersten aktiven SICK-AbsenceType der Firma
→ Legt Absence mit status='approved' an (keine Genehmigung nötig)
→ Berechnet certificate_required_by
PATCH /absences/{id}/certificate [HR/ADMIN]
→ UPDATE absences.certificate_received = True
→ INSERT audit_log
GET /absences/sick-stats?user_id=&ref_date=
→ Berechnet Bradford-Faktor: S² × D
S = Episoden (Krankheitsepisoden im rollenden 12-Monats-Fenster)
D = Tage (Gesamtkranktage im selben Fenster)
Mandanten-Isolation
Mandanten-Isolation ist in TimeMaster dreischichtig implementiert:
-
Applikationsschicht: Jeder Endpunkt, der Daten schreibt oder liest, wird über
CurrentUserDependency abgesichert. Beim Anlegen von Objekten wird immercompany_id = current_user.company_idgesetzt. Beim Lesen filtert der Service aktiv nachcompany_id. -
Datenbankschicht (RLS): PostgreSQL Row Level Security (Migration 0024) verhindert Datenzugriff über Firmengrenzen auf DB-Ebene. Auch direkte psql-Verbindungen mit dem App-User werden gefiltert (FORCE ROW LEVEL SECURITY).
-
Session-Kontext:
SET LOCAL app.company_idgilt nur für die aktuelle Transaktion. Jede neue Anfrage beginnt mit einem frischen Kontext.
SUPER_ADMIN ist die einzige Rolle mit bypass_rls = 'on' und kann alle Firmen sehen. Diese Rolle ist ausschließlich für den Plattform-Betreiber gedacht.