Files
leocrm/FIX-PLAN.md
T
Agent Zero 727d86614e Security fixes: P0-P2 complete (22 fixes)
P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed
P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK
P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal

8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
2026-07-25 21:03:46 +02:00

21 KiB

LeoCRM — Umfassender Fix-Plan

Erstellt: 2026-07-25 Quellen: Externes Audit (geprüft), eigene Code-Inspektion, Coolify-Deployment-Prüfung


P0 — Sofort blockierend (vor jeder Nutzung)

P0-1: Authentifizierungs-Bypass entfernen

Problem: app/deps.py akzeptiert X-Internal-Call: true mit X-Tenant-Id und X-User-Id Headern. Keine Signatur, kein Token, keine IP-Beschränkung. except (ValueError, Exception): pass verschleiert Fehler.

Datei: app/deps.py:37-58

Maßnahme:

  • Header-Authentifizierung komplett entfernen
  • Für interne Service-Kommunikation: dedizierte Service-Accounts mit kurzlebigen signierten Tokens (JWT mit aud, iss, sub, tenant_id, exp)
  • Separate interne API oder mTLS
  • Keine Übernahme beliebiger user_id aus einem Header
  • Audit-Logging jeder Delegation
  • except (ValueError, Exception): pass ersetzen durch spezifisches Exception-Handling mit Logging

Aufwand: 2-4 Stunden


P0-2: Destruktive Migrationen ersetzen

Problem:

  • alembic/versions/0021_unified_contacts.py: DROP TABLE ohne Datenübernahme
  • alembic/versions/0027_unify_company_to_contact.py: company_id wird gelöscht ohne Datenübernahme; Downgrade ändert pauschal alle entity_type='contact' zurück zu 'company'
  • migration_0021.sql im Projekt-Root: konkurrierender Migrationsweg, manipuliert alembic_version direkt

Dateien:

  • alembic/versions/0021_unified_contacts.py
  • alembic/versions/0027_unify_company_to_contact.py
  • migration_0021.sql (löschen)

Maßnahme:

  1. migration_0021.sql löschen
  2. Migration 0021 durch echte Transformationsmigration ersetzen:
    • Alte Tabellen umbenennen (_old suffix), nicht löschen
    • Daten mit INSERT ... SELECT übertragen
    • Anzahl, Checksummen und Plausibilität vor/nach der Migration vergleichen
    • Alttabellen erst in späterer Migration entfernen
  3. Migration 0027 korrigieren:
    • company_id Werte vor Drop in contact_id übertragen
    • Downgrade: nur Datensätze zurückändern, die ursprünglich 'company' waren (Tracking-Spalte oder separate Tabelle)
  4. Automatisierten Upgrade-Test von jeder unterstützten Version auf head einführen
  5. Migrationen gegen reale anonymisierte DB-Kopien testen

Aufwand: 4-8 Stunden


P0-3: Plugin-Upload und URL-Installation deaktivieren

Problem: app/routes/plugins.py führt spec.loader.exec_module(module) aus bevor die Sicherheitsprüfung läuft. Das ist Remote Code Execution. Weitere Probleme: unzureichende ZIP-Traversal-Prüfung, kein Symlink-Check, keine ZIP-Bomb-Prävention, SSRF bei URL-Installation, Plugin wird in laufenden Container kopiert.

Datei: app/routes/plugins.py:347-354 (_extract_plugin_from_zip)

Maßnahme:

  1. Sofort: Upload- und URL-Installationsendpunkte (/upload, /install-url) deaktivieren oder entfernen
  2. Langfristig — Vertrauensmodell:
    • Nur signierte Plugin-Artefakte aus einer Allowlist
    • Plugin-Code wird vor der Ausführung auf Signatur geprüft
  3. Langfristig — Isolationsmodell:
    • Plugin-Ausführung in separaten Containern mit minimalen Rechten
    • Versionierte Plugin-API
  4. ZIP-Traversal-Prüfung korrigieren: os.path.abspath gegen Base-Dir prüfen nach Extraction
  5. Symlink-Check hinzufügen
  6. Entpackungsgrößen-Limit (Anzahl Dateien + Gesamtgröße)
  7. URL-Download: Redirects verbieten, interne IP-Ranges blockieren, Streaming statt RAM

Aufwand: Sofort-Deaktivierung 30 Min; Langfristig 2-3 Tage


P0-4: Mandantentrennung (RLS) reparieren

Problem:

  • alembic/versions/0015_rls_policies.py: Kein FORCE ROW LEVEL SECURITY, kein WITH CHECK
  • Tabellen-Owner umgeht RLS
  • Plugin-Tabellen nicht in RLS-Liste
  • TenantMixin Docstring behauptet ORM-Autofilterung, die nicht existiert
  • app/core/tenant.py hat nur manuelle apply_tenant_filter() Funktion
  • contactpersons hat tenant_id aber FK auf contacts.id ohne Tenant-Bedingung → Cross-Tenant-FK möglich

Dateien:

  • alembic/versions/0015_rls_policies.py
  • app/core/db/__init__.py (TenantMixin Docstring)
  • app/core/tenant.py
  • Neue Migration für FORCE + WITH CHECK

Maßnahme:

  1. Neue Migration: ALTER TABLE ... FORCE ROW LEVEL SECURITY für alle Tenant-Tabellen
  2. Policies mit USING und WITH CHECK neu erstellen
  3. Separater DB-Migrationsowner; Runtime-User ohne Owner- oder Bypass-RLS-Rechte
  4. RLS für alle mandantenbezogenen Tabellen, einschließlich Plugin-Tabellen
  5. CI-Test: Cross-Tenant-Lese- und Schreibversuche
  6. Composite-Integrität: eindeutiges (tenant_id, id) und FK auf (tenant_id, contact_id)
  7. TenantMixin Docstring korrigieren: Autofilterung existiert nicht
  8. Zentralen Query-/Repository-Mechanismus einführen statt freiwilliger Tenant-Filter
  9. Später neu erstellte Tabellen automatisch erfassen (Event-Listener oder CI-Check)

Aufwand: 1-2 Tage


P0-5: Plugin-System Doppelregistrierung beheben

Problem:

  • app/main.py create_app() registriert alle Plugin-Routen unabhängig vom Aktivierungsstatus
  • lifespan() registriert dieselben Routen nochmal → Doppelregistrierung
  • lifespan() auto-installiert und auto-aktiviert alle Builtins bei jedem Start
  • Deaktivierte Plugins werden reaktiviert
  • registry._plugins wird direkt zugegriffen (private Feld)
  • Migrationsfehler werden nur geloggt, Aktivierung wird trotzdem versucht
  • 204 direkte Cross-Imports zwischen Built-in-Plugins

Datei: app/main.py:317-330 und app/main.py:112-165

Maßnahme:

  1. Routen einmalig beim Prozessstart registrieren — entweder in create_app() ODER in lifespan(), nicht beides
  2. Aktivierungsstatus vor dem Router-Aufbau laden und respektieren
  3. Keine dynamische Änderung von FastAPI-Routen während des Betriebs
  4. Aktivierung/Deaktivierung erfordert kontrollierten Neustart
  5. Fehlgeschlagene Migration blockiert den Start (nicht nur loggen)
  6. Core-Module und optionale Module klar trennen
  7. Kein Zugriff auf registry._plugins — öffentliche API verwenden
  8. Plugin-Abhängigkeiten über deklarierte Contracts prüfen
  9. Langfristig: Cross-Imports reduzieren — öffentliche Schnittstellen statt direkter Modell-Imports

Aufwand: 1 Tag für Doppelregistrierung; Cross-Import-Reduktion 1-2 Wochen


P0-6: Persistent Volume für Coolify-Deployment

Problem: Der laufende Container hat keine Volume-Mounts ([]). /data/storage ist nicht persistent. Alle hochgeladenen Dateien (DMS, Attachments, Bilder) gehen bei jedem Redeployment verloren. Plugin-Dateien in app/plugins/builtins/ überleben keinen Neustart.

Gefunden in: Coolify-Container-Inspect (live)

Maßnahme:

  1. In Coolify persistentes Volume für /data/storage konfigurieren
  2. Alternativ: S3-kompatiblen Object Storage verwenden (.env.example hat bereits STORAGE_BACKEND=s3 Support)
  3. Plugin-Dateien nicht in Container-Filesystem kopieren — separate Plugin-Registry mit DB-basierter Konfiguration

Aufwand: 1-2 Stunden (Volume in Coolify konfigurieren)


P0-7: App von öffentlicher Domain nehmen

Problem: Die App läuft unter https://crm.media-on.de und ist öffentlich erreichbar — mit allen P0-Schwachstellen (Auth-Bypass, Plugin-RCE, XSS, etc.).

Gefunden in: Coolify-Deployment-Prüfung

Maßnahme:

  1. Sofort: App von öffentlicher Domain nehmen oder IP-Whitelist/Basic Auth vorschalten
  2. Mindestens P0-1 (Auth-Bypass) und P0-3 (Plugin-Upload) beheben bevor wieder öffentlich
  3. Alternativ: VPN/Tunnel-Zugang statt öffentliche Domain

Aufwand: 30 Minuten


P1 — Vor Nutzung realer Kundendaten

P1-1: Benutzer- und Mandantenmodell bereinigen

Problem:

  • User hat tenant_id, role, role_id — gleichzeitig existiert UserTenant mit tenant_id, role_id, is_default
  • Zwei Quellen der Wahrheit für Mandantenzugehörigkeit und Rollen
  • login() sucht nur nach email mit scalar_one_or_none() → crasht bei mehreren Treffern (gleiche E-Mail in mehreren Mandanten)
  • tenant_slug Parameter in login() wird von Login-Route nicht übergeben
  • TenantService.list_tenant_users() sucht über User.tenant_id und ignoriert N:M-Mitgliedschaften

Dateien:

  • app/models/user.py
  • app/services/auth_service.py:30-80
  • app/routes/auth.py

Maßnahme:

  1. users.email global eindeutig machen (nicht (tenant_id, email))
  2. User.tenant_id und User.role/User.role_id entfernen
  3. tenant_memberships als einzige Quelle: tenant_id, user_id, role_id, status, is_default
  4. login() mit tenant_slug verknüpfen oder Default-Tenant verwenden
  5. TenantService.list_tenant_users() über UserTenant suchen

Aufwand: 1 Tag


P1-2: Redis-Verbindungen zentralisieren

Problem: app/core/auth.py:49-51 erstellt pro Aufruf einen neuen Redis-Client. Kein Pool, kein Close. Dasselbe bei enqueue_job() für ARQ-Pools. Folgen: Connection-Lecks, Socket-Erschöpfung, instabiles Verhalten unter Last.

Datei: app/core/auth.py:49-51, app/core/worker.py (enqueue_job)

Maßnahme:

  1. Redis-Client einmal im Application-Lifespan initialisieren
  2. Bei Shutdown schließen
  3. Über Dependency Injection verteilen
  4. ARQ-Pool einmalig erstellen und wiederverwenden

Aufwand: 2-4 Stunden


P1-3: Worker und Scheduler aus API-Container auslagern

Problem: prestart.sh startet ARQ-Worker im Hintergrund und Uvicorn als PID 1. Worker-Tod wird nicht erkannt. Worker und API konkurrieren um Ressourcen. Keine separate Skalierung. Cron-Jobs können bei mehreren Replikas mehrfach ausgeführt werden.

Datei: prestart.sh

Maßnahme:

  1. Worker in separaten Container auslagern
  2. Scheduler in separaten Container mit verteilter Lock-/Leader-Election
  3. Idempotente Jobs
  4. Heartbeat mit Zeitstempel
  5. Dead-Letter-/Failed-Job-Strategie
  6. Retry-Policy pro Jobtyp
  7. Worker-Healthcheck prüft ob Worker lebt, nicht nur ob Redis-Queue lesbar ist

Aufwand: 1-2 Tage


P1-4: Transactional Outbox einführen

Problem: app/core/event_bus.py ist rein speicherbasiert. Events verschwinden bei Prozessabsturz, Neustart, mehreren Replikas, Handler-Fehlern. asyncio.gather(..., return_exceptions=True) sammelt Fehler ohne Behandlung.

Datei: app/core/event_bus.py

Maßnahme:

  1. Transactional Outbox in PostgreSQL
  2. Worker verarbeitet Outbox-Einträge
  3. Inbox/Idempotency-Key auf Konsumentenseite
  4. Retry und Dead Letter
  5. Events versionieren
  6. In-Process-Bus nur für unkritische lokale Benachrichtigungen

Aufwand: 2-3 Tage


P1-5: XSS-Stellen schließen

Problem:

  • HtmlBlock.tsx: Regex-Sanitizer + dangerouslySetInnerHTML — HTML lässt sich nicht sicher mit Regex sanitizen
  • SignatureManager.tsx:201: dangerouslySetInnerHTML={{ __html: sig.body_html }} ohne jegliche Sanitization
  • ActionCardBlock.tsx:21-28: window.open(action.action) ohne URL-Validierung — javascript:-URLs möglich
  • Mail-Service: body_html_sanitized = body_html ohne Sanitizer an manchen Stellen

Dateien:

  • frontend/src/components/comm/blocks/HtmlBlock.tsx
  • frontend/src/components/mail/SignatureManager.tsx
  • frontend/src/components/comm/blocks/ActionCardBlock.tsx
  • Mail-Service (body_html_sanitized)

Maßnahme:

  1. Serverseitig konsequent nh3 verwenden
  2. Frontend zusätzlich DOMPurify als zweite Barriere
  3. Keine selbst gebauten Regex-Sanitizer
  4. Nur https: und kontrollierte interne Pfade erlauben
  5. Strikte Content Security Policy ohne unsafe-inline
  6. Signatur-, Mail-, KI- und Kommunikationsinhalte als nicht vertrauenswürdig behandeln

Aufwand: 4-6 Stunden


P1-6: DMS Dateiverarbeitung lastfest machen

Problem: app/plugins/builtins/dms/routes.py liest die komplette Datei in RAM (content = await file.read()). Max 100 MB. Bei 10 parallelen Uploads mehrere GB RAM. Kein Virenscan, kein Content-Hash, keine Dublettenerkennung, keine Tenant-Quotas, kein Versionierungsmodell, kein Garbage Collector für physische Dateien nach Soft Delete. storage_path wird an Frontend ausgegeben. Benutzerdateiname direkt in Content-Disposition.

Datei: app/plugins/builtins/dms/routes.py:421-436

Maßnahme:

  1. Chunked Streaming direkt in Object Storage
  2. Maximale Größe auf Proxy- und Anwendungsebene
  3. SHA-256 Content-Hash
  4. Malware-Scan
  5. Quotas pro Tenant
  6. Versionierte Metadaten
  7. Garbage Collector für physische Dateien nach Soft Delete
  8. storage_path nicht an Frontend ausgeben
  9. Benutzerdateiname sanitizen vor Content-Disposition
  10. Synchronen MinIO-Client aus async def entfernen

Aufwand: 1-2 Tage


P1-7: Berechtigungssystem vereinheitlichen

Problem:

  • Legacy-Rollenstrings (admin/editor/viewer) + neue Rollen mit role_id + Gruppen + Allow/Deny + Feldrechte + is_system_admin + globale Write-Hilfsrechte
  • permission_version wird gespeichert, beim Cache-Lesen aber nicht geprüft
  • Cache-Invalidierung verwendet redis.keys() — blockiert Redis bei großen Datenmengen
  • Feldrechte mehrerer Gruppen werden per dict.update() überschrieben (last-write-wins)
  • viewer erhält user_preferences:write
  • require_write() erlaubt *:write oder *:create (zu breit)
  • db.rollback() bei Permission-Fehler setzt fremde Transaktionsarbeit zurück

Datei: app/core/permissions.py, app/deps.py

Maßnahme:

  1. Nur noch Capability-basierte Berechtigungen (contacts.read, contacts.create, etc.)
  2. Keine generische require_write-Freigabe
  3. Alte Rollenlogik entfernen
  4. Feldrechte deterministisch nach "strengstes Recht gewinnt" zusammenführen
  5. permission_version beim Cache-Lesen prüfen
  6. redis.keys() ersetzen durch redis.scan() oder gezielte Cache-Key-Invalidierung
  7. db.rollback() nur in eigenen Transaktionskontext

Aufwand: 1-2 Tage


P1-8: Password Reset funktionsfähig machen

Problem: request_password_reset() erstellt ein Token, speichert es in der DB, sendet es aber nicht. Nicht einmal geloggt. Die Variable raw_token wird nach Erstellung ignoriert. Die Route sagt "a reset link has been sent" — das ist fachlich falsch. Nach Passwortwechsel werden bestehende Sessions nicht widerrufen.

Datei: app/services/auth_service.py:159-200

Maßnahme:

  1. Reset-Mail über echte Queue verschicken (ARQ-Worker)
  2. Token nur einmal verwendbar
  3. Alle Sessions des Benutzers nach Passwortänderung widerrufen
  4. Sicherheitsereignis protokollieren
  5. Optional: Nutzer über Passwortänderung informieren

Aufwand: 2-4 Stunden


P1-9: Metrics-Endpunkt absichern

Problem: app/routes/metrics.py sagt "admin-only" im Docstring, verwendet aber nur get_current_user statt require_admin. Jeder angemeldete Benutzer kann Prometheus-Metriken abrufen.

Datei: app/routes/metrics.py

Maßnahme:

  1. require_admin oder require_permission("system:metrics") verwenden
  2. Alternativ: internes Netzwerk, Reverse-Proxy-Allowlist, dedizierten Monitoring-Token oder mTLS

Aufwand: 30 Minuten


P1-10: Coolify-Dokumentation korrigieren

Problem:

  • COOLIFY_SETUP.md Abschnitt 6 dokumentiert /health als Healthcheck-Pfad — die App hat nur /api/v1/health. /health liefert nur die SPA index.html (Catch-All).
  • COOLIFY_SETUP.md listet JWT_ALGORITHM und JWT_EXPIRY_HOURS — werden von der App nicht verwendet.
  • CORS_ORIGINS in Coolify ohne :443COOLIFY_SETUP.md sagt explizit Port ist mandatory.

Dateien: COOLIFY_SETUP.md, docs/deployment-guide.md

Maßnahme:

  1. Healthcheck-Pfad in Doku auf /api/v1/health korrigieren
  2. JWT-Variablen aus Doku entfernen oder App auf JWT umstellen
  3. CORS_ORIGINS in Coolify auf https://crm.media-on.de:443 setzen
  4. docker-compose.yml Healthcheck auf /api/v1/health korrigieren
  5. docker-compose.yml Redis-Service hinzufügen
  6. docker-compose.yml REDIS_URL setzen
  7. docker-compose.yml persistentes Volume für /data/storage
  8. docker-compose.yml SESSION_COOKIE_SECURE=true für Production
  9. docker-compose.yml STORAGE_PATH=/data/storage setzen
  10. config.py Default storage_path von /tmp auf /data/storage ändern
  11. config.py Default session_cookie_secure auf True ändern (Production-Default)
  12. config.py Startup-Validierung: ENVIRONMENT=production + session_cookie_secure=False → harter Abbruch

Aufwand: 2-3 Stunden


P1-11: Cross-Tenant referenzielle Integrität

Problem: contactpersons hat tenant_id aber contact_id FK referenziert nur contacts.id ohne Tenant-Bedingung. Die DB verhindert nicht, dass ein Contactperson-Datensatz aus Mandant A auf einen Kontakt aus Mandant B zeigt.

Datei: alembic/versions/0021_unified_contacts.py (contactpersons Tabelle)

Maßnahme:

  1. Composite-FK: (tenant_id, contact_id) referenziert (tenant_id, id) auf contacts
  2. Eindeutiges (tenant_id, id) auf contacts
  3. Dasselbe für alle mandantenbezogenen FK-Beziehungen

Aufwand: 2-4 Stunden


P2 — Architektonische Konsolidierung

P2-1: Unified Contact Model normalisieren

Problem: Eine Tabelle enthält Unternehmen, Personen, 3 Adressarten, Bankdaten, Steuernummern, Rabatte, Projektinformationen, Warnungen, Tags, Custom Fields, Suchindex. Dubletten zu vorhandenen Modellen für Adressen, Bankkonten, Tags, Custom Fields.

Weitere Probleme:

  • Rabatte als Float statt Numeric/Decimal
  • Keine DB-Checks für Werte 0-100
  • Keine eindeutigen Kontakt-/Buchhaltungscodes pro Mandant
  • Keine klare Validierung welche Felder bei Person/Firma erlaubt sind
  • surfix — dauerhaft übernommener Tippfehler
  • JSON statt JSONB
  • Suche fest auf Deutsch eingestellt
  • Keine normalisierten Suchschlüssel für E-Mail und Telefonnummer
  • CSV-Import ohne Dubletten-/Encoding-/Dezimal-/Rollback-Strategie

Maßnahme:

  1. Adressen in separate Tabelle auslagern (bereits vorhanden — nutzen)
  2. Bankdaten in separate Tabelle (bereits vorhanden — nutzen)
  3. Tags als Relation (bereits vorhanden — nutzen)
  4. Custom Fields als Relation (bereits vorhanden — nutzen)
  5. Rabatte: Numeric(5,2) statt Float
  6. DB-Check: discount_* BETWEEN 0 AND 100
  7. Eindeutige (tenant_id, code) und (tenant_id, accounting_code)
  8. surfixsuffix (Migration mit Rename)
  9. JSONJSONB
  10. Suchkonfiguration pro Mandant konfigurierbar
  11. Normalisierte Suchschlüssel (lowercase, trimmed) für E-Mail und Telefon
  12. CSV-Import: Dubletten-Erkennung, Encoding-Detection, Decimal-Parsing, Transaction-Rollback

Aufwand: 2-3 Tage


P2-2: Plugin-Cross-Imports reduzieren

Problem: 204 direkte from app.plugins.builtins Imports zwischen Plugins. Automatisierung importiert Modelle/Services von Kommunikation, Mail, Kalender. Verteilter Monolith ohne Modulgrenzen.

Maßnahme:

  1. Öffentliche Schnittstellen (Contracts) für jedes Modul definieren
  2. Direkte Imports fremder Plugin-Modelle verbieten
  3. Kommunikation nur über Events oder öffentliche Service-API
  4. CI-Check: keine direkten Cross-Plugin-Imports

Aufwand: 1-2 Wochen


P2-3: Commands und Statusmaschinen

Problem: Geschäftsoperationen als Route → Service → mehrere flush/commit statt als zentrale Commands. Statusstrings frei beschreibbar statt Statusmaschinen.

Maßnahme:

  1. Route → Command → Authorization → Domain Operation → Transaction → Audit → Outbox Events → Commit
  2. Explizite Statusmaschinen für Angebote, Aufträge, Rechnungen
  3. Übergänge validiert und auditiert

Aufwand: 1-2 Wochen


P2-4: SPA Path-Traversal-Schutz vervollständigen

Problem: app/main.py SPA-Catch-All blockiert .. nur in bestimmten Positionen. .. in anderen Positionen wird nicht erfasst.

Datei: app/main.py (spa_spa Funktion)

Maßnahme:

  1. os.path.abspath gegen frontend_dist prüfen nach Join
  2. Kein .. in irgendeiner Position erlauben

Aufwand: 30 Minuten


Zusammenfassung

Priorität Anzahl Geschätzter Aufwand
P0 (sofort) 7 ~5-7 Tage
P1 (vor Kundendaten) 11 ~7-10 Tage
P2 (architektonisch) 4 ~2-4 Wochen
Total 22 ~4-6 Wochen

Reihenfolge

Woche 1: P0 absichern

  1. P0-7: App von öffentlicher Domain nehmen (30 Min)
  2. P0-1: Auth-Bypass entfernen (2-4h)
  3. P0-3: Plugin-Upload deaktivieren (30 Min Sofort, langfristig später)
  4. P0-6: Persistent Volume in Coolify (1-2h)
  5. P0-2: Migrationen ersetzen (4-8h)
  6. P0-4: RLS reparieren (1-2 Tage)
  7. P0-5: Plugin-Doppelregistrierung beheben (1 Tag)

Woche 2-3: P1 Fundament

  1. P1-9: Metrics absichern (30 Min)
  2. P1-8: Password Reset (2-4h)
  3. P1-10: Coolify-Doku & Config korrigieren (2-3h)
  4. P1-2: Redis zentralisieren (2-4h)
  5. P1-5: XSS schließen (4-6h)
  6. P1-11: Cross-Tenant FK (2-4h)
  7. P1-1: User/Tenant-Modell (1 Tag)
  8. P1-7: Permission-System (1-2 Tage)
  9. P1-6: DMS lastfest (1-2 Tage)
  10. P1-3: Worker auslagern (1-2 Tage)
  11. P1-4: Transactional Outbox (2-3 Tage)

Woche 4-6: P2 Architektur

  1. P2-4: SPA Path-Traversal (30 Min)
  2. P2-1: Contact Model normalisieren (2-3 Tage)
  3. P2-2: Cross-Imports reduzieren (1-2 Wochen)
  4. P2-3: Commands & Statusmaschinen (1-2 Wochen)

Validierung nach jedem Fix

  • Python-Syntax-Check: python -m py_compile app/**/*.py
  • pytest: pytest tests/ -x
  • Frontend-Typecheck: cd frontend && npx tsc --noEmit
  • Frontend-Build: cd frontend && npx vite build
  • Manueller Smoke-Test: Login, Kontakt erstellen, DMS-Upload
  • Cross-Tenant-Test: Datensatz aus Mandant A kann nicht aus Mandant B gelesen werden
  • Deployment: Coolify Deploy + Healthcheck prüfen