# 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) | – | | E-Mail | 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 - `id` UUID PK - `name`, `slug` (URL-freundlich, unique) - `sick_note_required_after_days` INTEGER DEFAULT 3 - `personnel_number_required` BOOLEAN DEFAULT FALSE - `personnel_number_mode` VARCHAR(10) DEFAULT 'manual' - `personnel_number_next` INTEGER DEFAULT 1 **users** - `id`, `company_id`, `department_id` - `email` unique, `password_hash`, `first_name`, `last_name` - `role` ENUM: SUPER_ADMIN | COMPANY_ADMIN | HR | MANAGER | EMPLOYEE - `auth_provider` ENUM: local | ldap - `personnel_number` VARCHAR(50) – nur Ziffern, partial unique per Firma - `totp_secret`, `totp_enabled` – TOTP 2FA - `kiosk_pin_hash`, `kiosk_qr_token` - `can_manual_time_entry` BOOLEAN DEFAULT FALSE - `is_active` BOOLEAN – kein Hard-Delete - `created_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`, `code` - `affects_vacation` BOOLEAN – zieht vom Urlaubskonto ab - `requires_approval` BOOLEAN - `certificate_after_days` INTEGER NULL – per-Typ-Override für AU-Pflicht - `color` – UI-Darstellung **absences** - `user_id`, `type_id` - `start_date`, `end_date`, `half_day_start`, `half_day_end` - `working_days` NUMERIC(5,1) - `status` ENUM: pending | approved | rejected | cancelled - `approved_by`, `substitute_id` - `certificate_required_by` DATE – auto-berechnet bei SICK-Absences - `certificate_received` BOOLEAN DEFAULT FALSE - `meta` JSONB – flexible Zusatzdaten (Weiterbildung, Dienstreise etc.) **vacation_balances** - `user_id`, `year` - `base_days`, `special_days`, `carried_over_days`, `used_days` - `comment` **time_entries** - `user_id`, `date` - `start_time`, `end_time`, `break_minutes` - `working_minutes` COMPUTED - `status` ENUM: draft | submitted | approved | rejected - `source` ENUM: manual | stamp | import - `arbzg_warning` – ArbZG-Verletzungs-Flag **work_schedules** - `company_id`, `name` - `days_per_week`, wöchentliche Soll-Stunden - Für ArbZG-Berechnung und Überstunden-Balance **kiosk_devices** - `company_id`, `name`, `location` - `device_token_hash` – aktuell TOKEN-basiert (agent-08: Ed25519-Upgrade geplant) - `is_active` – wird in 0021 durch `status` ENUM ersetzt **audit_logs** - `company_id`, `user_id` - `action`, `entity_type`, `entity_id` - `old_value`, `new_value` JSONB - `ip_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: ```sql -- 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` ```sql (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` ```sql (bypass) OR user_id IN (SELECT id FROM users WHERE company_id = ...) ``` **companies** selbst – Policy auf `id` (die Firma darf nur sich selbst sehen): ```sql (bypass) OR id = NULLIF(current_setting('app.company_id', true), '')::uuid ``` ### Ablauf pro HTTP-Request 1. `get_db()` öffnet eine AsyncSession, setzt `SET LOCAL app.bypass_rls = 'on'` 2. `get_current_user()` Dependency lädt den User (bypass noch aktiv) 3. Falls der User kein SUPER_ADMIN ist: - `SET LOCAL app.company_id = ''` - `SET LOCAL app.bypass_rls = 'off'` 4. Alle folgenden Queries in dieser Transaktion sind automatisch auf die Firma des Users gefiltert 5. 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: "" } ↓ 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): 1. `GET /auth/totp/setup` → `{ secret, qr_code_url }` (base32-Secret, otpauth:// URI) 2. User scannt QR in Authenticator-App 3. `POST /auth/totp/confirm { code }` – verifiziert ersten Code, setzt `totp_enabled = True` 4. `POST /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: 1. **Applikationsschicht**: Jeder Endpunkt, der Daten schreibt oder liest, wird über `CurrentUser` Dependency abgesichert. Beim Anlegen von Objekten wird immer `company_id = current_user.company_id` gesetzt. Beim Lesen filtert der Service aktiv nach `company_id`. 2. **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). 3. **Session-Kontext**: `SET LOCAL app.company_id` gilt 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.