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
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_idaus einem Header - Audit-Logging jeder Delegation
except (ValueError, Exception): passersetzen durch spezifisches Exception-Handling mit Logging
Aufwand: 2-4 Stunden
P0-2: Destruktive Migrationen ersetzen
Problem:
alembic/versions/0021_unified_contacts.py:DROP TABLEohne Datenübernahmealembic/versions/0027_unify_company_to_contact.py:company_idwird gelöscht ohne Datenübernahme; Downgrade ändert pauschal alleentity_type='contact'zurück zu'company'migration_0021.sqlim Projekt-Root: konkurrierender Migrationsweg, manipuliertalembic_versiondirekt
Dateien:
alembic/versions/0021_unified_contacts.pyalembic/versions/0027_unify_company_to_contact.pymigration_0021.sql(löschen)
Maßnahme:
migration_0021.sqllöschen- Migration 0021 durch echte Transformationsmigration ersetzen:
- Alte Tabellen umbenennen (
_oldsuffix), 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
- Alte Tabellen umbenennen (
- Migration 0027 korrigieren:
company_idWerte vor Drop incontact_idübertragen- Downgrade: nur Datensätze zurückändern, die ursprünglich
'company'waren (Tracking-Spalte oder separate Tabelle)
- Automatisierten Upgrade-Test von jeder unterstützten Version auf
headeinführen - 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:
- Sofort: Upload- und URL-Installationsendpunkte (
/upload,/install-url) deaktivieren oder entfernen - Langfristig — Vertrauensmodell:
- Nur signierte Plugin-Artefakte aus einer Allowlist
- Plugin-Code wird vor der Ausführung auf Signatur geprüft
- Langfristig — Isolationsmodell:
- Plugin-Ausführung in separaten Containern mit minimalen Rechten
- Versionierte Plugin-API
- ZIP-Traversal-Prüfung korrigieren:
os.path.abspathgegen Base-Dir prüfen nach Extraction - Symlink-Check hinzufügen
- Entpackungsgrößen-Limit (Anzahl Dateien + Gesamtgröße)
- 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: KeinFORCE ROW LEVEL SECURITY, keinWITH CHECK- Tabellen-Owner umgeht RLS
- Plugin-Tabellen nicht in RLS-Liste
TenantMixinDocstring behauptet ORM-Autofilterung, die nicht existiertapp/core/tenant.pyhat nur manuelleapply_tenant_filter()Funktioncontactpersonshattenant_idaber FK aufcontacts.idohne Tenant-Bedingung → Cross-Tenant-FK möglich
Dateien:
alembic/versions/0015_rls_policies.pyapp/core/db/__init__.py(TenantMixin Docstring)app/core/tenant.py- Neue Migration für FORCE + WITH CHECK
Maßnahme:
- Neue Migration:
ALTER TABLE ... FORCE ROW LEVEL SECURITYfür alle Tenant-Tabellen - Policies mit
USINGundWITH CHECKneu erstellen - Separater DB-Migrationsowner; Runtime-User ohne Owner- oder Bypass-RLS-Rechte
- RLS für alle mandantenbezogenen Tabellen, einschließlich Plugin-Tabellen
- CI-Test: Cross-Tenant-Lese- und Schreibversuche
- Composite-Integrität: eindeutiges
(tenant_id, id)und FK auf(tenant_id, contact_id) TenantMixinDocstring korrigieren: Autofilterung existiert nicht- Zentralen Query-/Repository-Mechanismus einführen statt freiwilliger Tenant-Filter
- 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.pycreate_app()registriert alle Plugin-Routen unabhängig vom Aktivierungsstatuslifespan()registriert dieselben Routen nochmal → Doppelregistrierunglifespan()auto-installiert und auto-aktiviert alle Builtins bei jedem Start- Deaktivierte Plugins werden reaktiviert
registry._pluginswird 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:
- Routen einmalig beim Prozessstart registrieren — entweder in
create_app()ODER inlifespan(), nicht beides - Aktivierungsstatus vor dem Router-Aufbau laden und respektieren
- Keine dynamische Änderung von FastAPI-Routen während des Betriebs
- Aktivierung/Deaktivierung erfordert kontrollierten Neustart
- Fehlgeschlagene Migration blockiert den Start (nicht nur loggen)
- Core-Module und optionale Module klar trennen
- Kein Zugriff auf
registry._plugins— öffentliche API verwenden - Plugin-Abhängigkeiten über deklarierte Contracts prüfen
- 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:
- In Coolify persistentes Volume für
/data/storagekonfigurieren - Alternativ: S3-kompatiblen Object Storage verwenden (
.env.examplehat bereitsSTORAGE_BACKEND=s3Support) - 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:
- Sofort: App von öffentlicher Domain nehmen oder IP-Whitelist/Basic Auth vorschalten
- Mindestens P0-1 (Auth-Bypass) und P0-3 (Plugin-Upload) beheben bevor wieder öffentlich
- Alternativ: VPN/Tunnel-Zugang statt öffentliche Domain
Aufwand: 30 Minuten
P1 — Vor Nutzung realer Kundendaten
P1-1: Benutzer- und Mandantenmodell bereinigen
Problem:
Userhattenant_id,role,role_id— gleichzeitig existiertUserTenantmittenant_id,role_id,is_default- Zwei Quellen der Wahrheit für Mandantenzugehörigkeit und Rollen
login()sucht nur nachemailmitscalar_one_or_none()→ crasht bei mehreren Treffern (gleiche E-Mail in mehreren Mandanten)tenant_slugParameter inlogin()wird von Login-Route nicht übergebenTenantService.list_tenant_users()sucht überUser.tenant_idund ignoriert N:M-Mitgliedschaften
Dateien:
app/models/user.pyapp/services/auth_service.py:30-80app/routes/auth.py
Maßnahme:
users.emailglobal eindeutig machen (nicht(tenant_id, email))User.tenant_idundUser.role/User.role_identfernentenant_membershipsals einzige Quelle:tenant_id,user_id,role_id,status,is_defaultlogin()mittenant_slugverknüpfen oder Default-Tenant verwendenTenantService.list_tenant_users()überUserTenantsuchen
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:
- Redis-Client einmal im Application-Lifespan initialisieren
- Bei Shutdown schließen
- Über Dependency Injection verteilen
- 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:
- Worker in separaten Container auslagern
- Scheduler in separaten Container mit verteilter Lock-/Leader-Election
- Idempotente Jobs
- Heartbeat mit Zeitstempel
- Dead-Letter-/Failed-Job-Strategie
- Retry-Policy pro Jobtyp
- 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:
- Transactional Outbox in PostgreSQL
- Worker verarbeitet Outbox-Einträge
- Inbox/Idempotency-Key auf Konsumentenseite
- Retry und Dead Letter
- Events versionieren
- 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 sanitizenSignatureManager.tsx:201:dangerouslySetInnerHTML={{ __html: sig.body_html }}ohne jegliche SanitizationActionCardBlock.tsx:21-28:window.open(action.action)ohne URL-Validierung —javascript:-URLs möglich- Mail-Service:
body_html_sanitized = body_htmlohne Sanitizer an manchen Stellen
Dateien:
frontend/src/components/comm/blocks/HtmlBlock.tsxfrontend/src/components/mail/SignatureManager.tsxfrontend/src/components/comm/blocks/ActionCardBlock.tsx- Mail-Service (body_html_sanitized)
Maßnahme:
- Serverseitig konsequent
nh3verwenden - Frontend zusätzlich
DOMPurifyals zweite Barriere - Keine selbst gebauten Regex-Sanitizer
- Nur
https:und kontrollierte interne Pfade erlauben - Strikte Content Security Policy ohne
unsafe-inline - 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:
- Chunked Streaming direkt in Object Storage
- Maximale Größe auf Proxy- und Anwendungsebene
- SHA-256 Content-Hash
- Malware-Scan
- Quotas pro Tenant
- Versionierte Metadaten
- Garbage Collector für physische Dateien nach Soft Delete
storage_pathnicht an Frontend ausgeben- Benutzerdateiname sanitizen vor Content-Disposition
- Synchronen MinIO-Client aus
async defentfernen
Aufwand: 1-2 Tage
P1-7: Berechtigungssystem vereinheitlichen
Problem:
- Legacy-Rollenstrings (
admin/editor/viewer) + neue Rollen mitrole_id+ Gruppen + Allow/Deny + Feldrechte +is_system_admin+ globale Write-Hilfsrechte permission_versionwird 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) viewererhältuser_preferences:writerequire_write()erlaubt*:writeoder*:create(zu breit)db.rollback()bei Permission-Fehler setzt fremde Transaktionsarbeit zurück
Datei: app/core/permissions.py, app/deps.py
Maßnahme:
- Nur noch Capability-basierte Berechtigungen (
contacts.read,contacts.create, etc.) - Keine generische
require_write-Freigabe - Alte Rollenlogik entfernen
- Feldrechte deterministisch nach "strengstes Recht gewinnt" zusammenführen
permission_versionbeim Cache-Lesen prüfenredis.keys()ersetzen durchredis.scan()oder gezielte Cache-Key-Invalidierungdb.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:
- Reset-Mail über echte Queue verschicken (ARQ-Worker)
- Token nur einmal verwendbar
- Alle Sessions des Benutzers nach Passwortänderung widerrufen
- Sicherheitsereignis protokollieren
- 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:
require_adminoderrequire_permission("system:metrics")verwenden- Alternativ: internes Netzwerk, Reverse-Proxy-Allowlist, dedizierten Monitoring-Token oder mTLS
Aufwand: 30 Minuten
P1-10: Coolify-Dokumentation korrigieren
Problem:
COOLIFY_SETUP.mdAbschnitt 6 dokumentiert/healthals Healthcheck-Pfad — die App hat nur/api/v1/health./healthliefert nur die SPAindex.html(Catch-All).COOLIFY_SETUP.mdlistetJWT_ALGORITHMundJWT_EXPIRY_HOURS— werden von der App nicht verwendet.CORS_ORIGINSin Coolify ohne:443—COOLIFY_SETUP.mdsagt explizit Port ist mandatory.
Dateien: COOLIFY_SETUP.md, docs/deployment-guide.md
Maßnahme:
- Healthcheck-Pfad in Doku auf
/api/v1/healthkorrigieren - JWT-Variablen aus Doku entfernen oder App auf JWT umstellen
CORS_ORIGINSin Coolify aufhttps://crm.media-on.de:443setzendocker-compose.ymlHealthcheck auf/api/v1/healthkorrigierendocker-compose.ymlRedis-Service hinzufügendocker-compose.ymlREDIS_URLsetzendocker-compose.ymlpersistentes Volume für/data/storagedocker-compose.ymlSESSION_COOKIE_SECURE=truefür Productiondocker-compose.ymlSTORAGE_PATH=/data/storagesetzenconfig.pyDefaultstorage_pathvon/tmpauf/data/storageändernconfig.pyDefaultsession_cookie_secureaufTrueändern (Production-Default)config.pyStartup-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:
- Composite-FK:
(tenant_id, contact_id)referenziert(tenant_id, id)aufcontacts - Eindeutiges
(tenant_id, id)aufcontacts - 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
FloatstattNumeric/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 TippfehlerJSONstattJSONB- 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:
- Adressen in separate Tabelle auslagern (bereits vorhanden — nutzen)
- Bankdaten in separate Tabelle (bereits vorhanden — nutzen)
- Tags als Relation (bereits vorhanden — nutzen)
- Custom Fields als Relation (bereits vorhanden — nutzen)
- Rabatte:
Numeric(5,2)stattFloat - DB-Check:
discount_* BETWEEN 0 AND 100 - Eindeutige
(tenant_id, code)und(tenant_id, accounting_code) surfix→suffix(Migration mit Rename)JSON→JSONB- Suchkonfiguration pro Mandant konfigurierbar
- Normalisierte Suchschlüssel (lowercase, trimmed) für E-Mail und Telefon
- 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:
- Öffentliche Schnittstellen (Contracts) für jedes Modul definieren
- Direkte Imports fremder Plugin-Modelle verbieten
- Kommunikation nur über Events oder öffentliche Service-API
- 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:
Route → Command → Authorization → Domain Operation → Transaction → Audit → Outbox Events → Commit- Explizite Statusmaschinen für Angebote, Aufträge, Rechnungen
- Ü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:
os.path.abspathgegenfrontend_distprüfen nach Join- 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
- P0-7: App von öffentlicher Domain nehmen (30 Min)
- P0-1: Auth-Bypass entfernen (2-4h)
- P0-3: Plugin-Upload deaktivieren (30 Min Sofort, langfristig später)
- P0-6: Persistent Volume in Coolify (1-2h)
- P0-2: Migrationen ersetzen (4-8h)
- P0-4: RLS reparieren (1-2 Tage)
- P0-5: Plugin-Doppelregistrierung beheben (1 Tag)
Woche 2-3: P1 Fundament
- P1-9: Metrics absichern (30 Min)
- P1-8: Password Reset (2-4h)
- P1-10: Coolify-Doku & Config korrigieren (2-3h)
- P1-2: Redis zentralisieren (2-4h)
- P1-5: XSS schließen (4-6h)
- P1-11: Cross-Tenant FK (2-4h)
- P1-1: User/Tenant-Modell (1 Tag)
- P1-7: Permission-System (1-2 Tage)
- P1-6: DMS lastfest (1-2 Tage)
- P1-3: Worker auslagern (1-2 Tage)
- P1-4: Transactional Outbox (2-3 Tage)
Woche 4-6: P2 Architektur
- P2-4: SPA Path-Traversal (30 Min)
- P2-1: Contact Model normalisieren (2-3 Tage)
- P2-2: Cross-Imports reduzieren (1-2 Wochen)
- 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