# LeoCRM Architektur-Umbauplan — Komplett (V2) ## Grundprinzip Architektur JETZT richtig stellen, bevor ERP-Module darauf aufbauen. Jede architektonische Änderung wird exponential teurer, sobald ERP-Module kommen. ## Rolle der Workspaces Workspaces sind ausschließlich ein UI-, Navigations- und Arbeitskontext. Ein Workspace steuert: * welche Module und Menüpunkte angezeigt werden, * welche Unterbereiche eines Moduls angezeigt werden, * welche Kalender im Kalender-Modul sichtbar sind, * welche Kontaktordner, gespeicherten Ansichten oder Filter angeboten werden, * welche Dashboard-Widgets erscheinen, * deren Reihenfolge und Konfiguration, * den bevorzugten Arbeitskontext eines Benutzers. Ein Workspace verändert niemals: * RBAC-Berechtigungen, * ABAC-Regeln, * Entity Permissions, * Owner- oder Sharing-Rechte, * Tenant Memberships, * RLS-Policies, * tatsächliche Datenzugriffsrechte. Es gilt immer: ```text Tatsächlich sichtbare Daten = Workspace-Konfiguration ∩ Berechtigungen des Benutzers ∩ Objektzugriff ∩ Tenant-Isolation ``` Ein Workspace darf niemals Rechte erteilen oder bestehende Rechte erweitern. Beispiel Kalender: ```text Im Workspace konfigurierte Kalender ∩ Kalender, die der Benutzer lesen darf = im Kalender-Modul angezeigte Kalender ``` Beispiel Kontakte: ```text Im Workspace konfigurierte Kontaktordner/Ansichten ∩ Kontakte, die der Benutzer lesen darf = im Kontakte-Modul dargestellte Inhalte ``` Dasselbe Kontakte-Modul darf gleichzeitig in mehreren Workspaces vorkommen, beispielsweise: * Workspace „Verkauf" * Workspace „Einkauf" Beide Workspaces verwenden dasselbe Kontakte-Modul, aber mit unterschiedlichen: * Kontaktordnern, * gespeicherten Ansichten, * Standardfiltern, * Dashboard-Widgets, * Menükonfigurationen. Die bestehenden Rechte des Benutzers bleiben dabei unverändert. ### Workspace darf kein Backend-Berechtigungsgate werden Ein API-Endpunkt darf nicht allein deshalb `403 Forbidden` liefern, weil ein Modul im aktuellen Workspace nicht angezeigt wird. Der Workspace-Kontext dient nur für: * UI-Konfiguration, * Navigation, * Default-Filter, * Modulansichten, * Kalenderauswahl, * Ordnerauswahl, * Widgetkonfiguration. Die tatsächliche Autorisierung erfolgt weiterhin über: ```python require_permission(...) check_single_entity_access(...) apply_visibility_filter(...) RLS ``` Für workspacefähige Listenendpunkte kann der Workspace-Kontext als zusätzlicher Filter verwendet werden. Er ersetzt aber niemals einen Permission-Check. Direkte Links auf ein berechtigtes Fachobjekt dürfen weiterhin funktionieren, auch wenn das zugehörige Modul im aktuellen Workspace ausgeblendet ist. ### Workspace-Kontext nicht global in Redis speichern Das erzeugt Probleme bei mehreren geöffneten Browser-Tabs. * Tab A arbeitet im Workspace „Verkauf". * Tab B wechselt in „Einkauf". * Durch eine globale Redis-Session würde Tab A ebenfalls ungewollt in „Einkauf" wechseln. Lösung: * Aktueller Workspace wird pro Browser-Tab im Frontend gespeichert (`sessionStorage` oder tablokaler Zustand). * Der Client sendet bei workspacefähigen Requests: ```http X-Workspace-ID: ``` * Der Server validiert: Workspace gehört zum Tenant, Benutzer ist zugewiesen oder Admin, Workspace ist aktiv. * In der Datenbank wird nur der bevorzugte Default-Workspace eines Benutzers gespeichert (`workspace_users.is_default`). * Ein Workspacewechsel verändert keine Sessionberechtigungen. ### Rollenmodell für Workspace-Verwaltung Workspaces haben keine eigenen Datenrechte. Trotzdem braucht ihre Konfiguration einen administrativen Verantwortungsbereich. #### System-Administrator Darf: alle Tenants verwalten, globale Plugins aktivieren/deaktivieren, alle Workspaces aller Tenants verwalten, globale Plattformkonfiguration ändern. #### Tenant-Administrator Darf innerhalb seines Tenants: Workspaces erstellen/ändern/löschen, Workspace-Manager bestimmen, Benutzer Workspaces zuweisen, Module und Widgets konfigurieren, tenantweit verfügbare Ressourcen auswählen. #### Workspace-Manager Keine globale RBAC-Rolle, sondern eine Zuweisung innerhalb eines konkreten Workspaces (`workspace_users.role = 'manager'`). Darf nur für seinen Workspace: Name/Beschreibung/Icon ändern, Module ein-/ausblenden, Reihenfolge ändern, Kalenderauswahl konfigurieren, Kontaktordner und Ansichten konfigurieren, Dashboard-Widgets konfigurieren, Benutzer zuweisen/entfernen (sofern Tenant-Mitglied). Darf nicht: Benutzerrechte ändern, Rollen/Gruppen ändern, RBAC/ABAC/Entity Permissions vergeben, Plugins aktivieren, Tenant-Einstellungen ändern, auf Daten zugreifen für die er keine normalen Rechte besitzt. #### Workspace-Mitglied Kann den Workspace benutzen, aber nicht konfigurieren. Permissions für Workspace-Verwaltung: ```text workspaces:read workspaces:create workspaces:update workspaces:delete workspaces:assign_users workspaces:configure_modules workspaces:configure_widgets ``` Für Workspace-Manager werden diese Rechte nicht tenantweit vergeben. Der Service prüft zusätzlich, ob der Benutzer im konkreten Workspace als `manager` eingetragen ist. ## Aktueller Stand - P0 Fixes (6): Alle im Code, ungetestet - P1 Fixes (9): Alle im Code, ungetestet - Migrationen 0060-0067: In Produktion - CI/CD Pipeline: 10 Quality Gates - Frontend: canAccess Fallback (Workaround) - RLS: Auf contacts + 30 Tabellen, aber überlappend mit Application Layer ## Was NICHT umgesetzt wird - `security_resources` Tabelle — Aktuelles System (entity_permissions + owner_id + visibility.py) funktioniert. Lieber konsolidieren als neu bauen. - Alle Services in Commands umbauen — Inkrementell, nicht Big-Bang. Neue Module nutzen Commands, alte bei Überarbeitung. - `stored_objects` Tabelle — Stattdessen: Alles im DMS, Referenzen von Objekten. --- ## Phase 0a: Beweise liefern (4h) **Ziel:** Beweisen dass die P0+P1 Fixes funktionieren. ### Cross-Tenant Integrationstests (2h) - Test: User A in Tenant 1 kann keine Daten von Tenant 2 sehen - Test: RLS blockt Cross-Tenant Zugriff auf contacts, addresses, attachments, etc. - Test: entity_permissions funktionieren nur innerhalb des gleichen Tenants - Test: ABAC Policies sind tenant-scoped ### RLS mit unprivilegierter Rolle testen (1h) - Test: App läuft mit crm_runtime Rolle (nicht Superuser) - Test: RLS blockt korrekt mit crm_runtime - Test: set_tenant_context funktioniert mit unprivilegierter Rolle - Test: Login funktioniert (Bootstrap-Zirkel gelöst) ### Test-Suite grün (1h) - pytest --collect-only: 1103 Tests sammelbar - pytest tests/test_entity_permissions.py: Alle grün - pytest tests/test_abac.py: Alle grün - pytest tests/test_permission_performance.py: Alle grün - Bestehende Tests: Soweit möglich grün **Abhängigkeit:** Keine — Sofort startbar --- ## Phase 0b: Backup und Restore (2h) **Ziel:** Beweisen dass Backup und Restore funktionieren. ### Backup Test (1h) - pg_dump der Produktions-DB - DMS/Object-Storage-Backup - Restore in Test-DB - Datensatzanzahlen vergleichen - RLS Policies nach Restore prüfen - entity_permissions nach Restore prüfen ### Restore Test (1h) Restore-Reihenfolge: 1. PostgreSQL-Backup wiederherstellen. 2. DMS/Object Storage wiederherstellen. 3. benötigte Secrets und Verschlüsselungsschlüssel bereitstellen. 4. `alembic current` prüfen. 5. `alembic upgrade head` ausführen. 6. App und Worker starten. 7. Login testen. 8. Datensatzanzahlen prüfen. 9. verwaiste Fremdschlüssel prüfen. 10. Tenant-Verteilung prüfen. 11. RLS- und Cross-Tenant-Tests ausführen. 12. DMS-Dateien stichprobenartig öffnen. `alembic stamp` ist nur für einen separat dokumentierten Sonderfall zulässig, wenn das vorhandene Schema vorher vollständig gegen die Zielrevision validiert wurde. **Abhängigkeit:** Phase 0a --- ## Phase 1: Security Kernel konsolidieren (10h) **Ziel:** Ein eindeutiger Security Kernel mit klarer Verantwortungstrennung. ### 1.1 Verantwortungstabelle (1h) | Schicht | Frage | Mechanismus | |---------|-------|------------| | Auth | Ist der User eingeloggt? | Session/Cookie | | Tenant Membership | Ist User im richtigen Tenant? | UserTenant.status == 'active' | | RBAC (Capabilities) | Darf User grundsätzlich Kontakte lesen? | `contacts:read` Permission | | Objekt-ACL | Darf User DIESEN Kontakt sehen? | owner_id + entity_permissions | | ABAC | Darf User Kontakte mit status=lead sehen? | entity_policies | | RLS | Ist User im richtigen Tenant? (DB-Barriere) | `tenant_id = app.current_tenant_id` | ### 1.2 RLS auf Tenant-Isolation reduzieren (3h) RLS soll NICHT die volle Geschäftsautorisierung übernehmen. Nur `tenant_id` Check. - Migration: Alle RLS Policies auf contacts reduzieren auf `tenant_id` Check - Entfernen: owner_id, sharing, permissions aus RLS Policies - Das macht die Application Layer (visibility.py) - RLS = Sicherheitsgurt, nicht Fahrzeugsteuerung Neue contacts RLS Policies: ```sql CREATE POLICY contacts_tenant_isolation ON contacts FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid) WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid); ``` ### 1.3 visibility.py als SQL-Ausdrücke bestätigen (1h) - `apply_visibility_filter()` liefert SQLAlchemy-Ausdrücke — korrekt - `check_single_entity_access()` für Einzelaktionen — korrekt - Batch-Resolution für Listen — korrekt - Keine Python-Filter, alles in SQL ### 1.4 Überlappungen entfernen (2h) - RLS prüft nur tenant_id (Phase 1.2) - visibility.py prüft owner_id + sharing + permissions - entity_permission_service prüft effective_access - Keine Redundanz mehr ### 1.5 Frontend canAccess Fallback entfernen (3h) - Problem: Permissions werden im Frontend nicht korrekt geladen → canAccess Fallback zeigt alles - Fix: Permissions beim Login laden und im authStore speichern - `usePermission()` nutzt echte Permissions aus authStore - canAccess Fallback wird entfernt — echte Permission-Checks - Backend: `/api/v1/auth/me` returns permissions + field_permissions - Frontend: authStore speichert permissions, usePermission nutzt sie **Abhängigkeit:** Phase 0a + 0b --- ## Phase 2: Datenbankrollen und RLS strikt trennen (4h) **Ziel:** Korrekte DB-Rollen mit klaren Verantwortungen. Direkt nach Security Kernel, damit alle danach neu erstellten Tabellen sofort korrekte Owner, Grants, Default Privileges und RLS-Policies haben. ### 2.1 Rollen definieren (1h) #### Plattformadministrator (nur einmalige Infrastruktur) ```text crm_platform_admin ``` Darf: Datenbank und Schema initialisieren, PostgreSQL-Erweiterungen installieren, Rollen erzeugen. Zugangsdaten stehen nicht dauerhaft in API- oder Worker-Containern. #### Migrationsrolle ```text crm_migration ``` * Owner des Anwendungsschemas * führt Alembic aus * darf DDL innerhalb des Anwendungsschemas * kein Superuser * kein API-Login #### Auth-Rolle ```text crm_auth ``` * minimaler Zugriff auf Benutzer, Tenants und aktive Memberships * keine allgemeinen Fachdatenrechte #### API-Rolle ```text crm_api ``` * `NOSUPERUSER` * `NOBYPASSRLS` * kein Tabellenowner * fachlicher Zugriff nur unter gesetztem Tenant-Kontext #### Worker-Rolle ```text crm_worker ``` Der Worker darf nicht pauschal alle Mandantendaten ohne Kontext lesen. Trennung: * Polling/Claiming von Outbox-Jobs (ohne Tenant-Kontext) * fachliche Verarbeitung eines konkreten Events (mit Tenant-Kontext, RLS erzwungen) ### 2.2 Default Privileges (1h) Vollständige Default Privileges für: * Tabellen * Sequenzen * Funktionen (sofern notwendig) * Schema-Nutzung ### 2.3 docker-compose + prestart.sh anpassen (1h) - API: `DATABASE_URL=postgresql+asyncpg://crm_api:...@postgres:5432/crm_db` - Worker: `DATABASE_URL=postgresql+asyncpg://crm_worker:...@postgres:5432/crm_db` - Migration: `MIGRATION_DATABASE_URL=postgresql+asyncpg://crm_migration:...@postgres:5432/crm_db` - Auth: `AUTH_DATABASE_URL=postgresql+asyncpg://crm_auth:...@postgres:5432/crm_db` - prestart.sh: Alembic mit crm_migration, App mit crm_api ### 2.4 Eine Variable (1h) - `app.current_tenant_id` — einzige Variable - `app.tenant_id` wird nicht mehr gesetzt (Legacy entfernt) - Alle Migrationen die `app.tenant_id` nutzen werden auf `app.current_tenant_id` umgestellt - Setze transaktionslokal: `SELECT set_config('app.current_tenant_id', :tenant_id, true)` - Ohne Tenant-Kontext muss der fachliche Zugriff fehlschlagen **Abhängigkeit:** Phase 1 --- ## Phase 3: Plugin-System vereinfachen (4h) **Ziel:** Router einmal registrieren, Aktivierungsstatus per Gate prüfen. ### 3.1 Statische Registrierung nur in main.py (1h) - Alle Router beim Startup in main.py registrieren, einmalig - PluginRegistry.activate() registriert keine Router mehr - PluginRegistry.activate() ändert nur DB-Status + Permission Registry ### 3.2 require_active_plugin als Gate mit Cache (1h) - Prüft: 1) Global aktiv (Registry), 2) Pro-Tenant aktiv (tenant_plugin_activation) - Fail-closed bei Fehlern (503) - WebSocket-Routen auch geprüft - Cache: `Datenbank = Source of Truth, Redis = Cache` - Cache-Key: `plugin-activation:{tenant_id}:{plugin_key}` - Bei Aktivierung/Deaktivierung: DB aktualisieren, Cache invalidieren, Konfigurationsversion erhöhen - Das Gate darf nicht bei jedem Request zwingend eine zusätzliche DB-Abfrage verursachen ### 3.3 Aktivierung/Deaktivierung ohne Neustart (1h) - Gate prüft DB (über Cache), nicht in-memory Set - Plugin aktivieren → DB Update → Cache invalidieren → Gate sieht es sofort - Plugin deaktivieren → DB Update → Cache invalidieren → Gate blockt sofort ### 3.4 tenant_plugin_activation UI (1h) - Settings → Plugins → pro-Tenant aktivieren/deaktivieren - System-Admin kann global aktivieren - Tenant-Admin kann pro-Tenant aktivieren (nur wenn global aktiv) **Abhängigkeit:** Phase 2 --- ## Phase 4: Dateisysteme vereinheitlichen (6h) **Ziel:** Alles im DMS, Objekte referenzieren dorthin. ### 4.1 entity_attachments Tabelle (1h) ```sql CREATE TABLE entity_attachments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, entity_type VARCHAR(50) NOT NULL, entity_id UUID NOT NULL, dms_file_id UUID NOT NULL REFERENCES dms_files(id) ON DELETE RESTRICT, category VARCHAR(50), display_name VARCHAR(200), owner_id UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX ix_entity_attachments_entity ON entity_attachments(entity_type, entity_id); CREATE INDEX ix_entity_attachments_tenant ON entity_attachments(tenant_id); ``` `ON DELETE RESTRICT` — eine DMS-Datei darf nicht gelöscht werden, solange aktive Fachreferenzen existieren. ### 4.2 Bestehende Attachments migrieren (1h) - Migration: Bestehende attachments → dms_files + entity_attachments - Dateien bleiben im Storage, nur Metadaten werden migriert - DMS File Eintrag pro bestehendem Attachment - entity_attachments Referenz ### 4.3 attachment_service.py umbauen (2h) - `save_attachment()`: Upload über DMS API, dann entity_attachments Eintrag - `get_attachment()`: Lädt DMS File über Referenz - `list_attachments()`: Lädt alle Referenzen für ein Entity - `delete_attachment()`: Entfernt Referenz, DMS File Soft-Delete wenn keine weiteren Referenzen - `download_attachment()`: Über DMS Storage-Backend ### 4.4 DMS erweitern (1h) - Size-Limit: 50MB pro Datei - MIME-Check: Erlaubte MIME-Types - Hash: SHA-256 pro Datei - Deduplikation: Nur tenantlokal — gleicher Hash innerhalb desselben Tenants → physische Deduplikation. Zwischen Tenants: eigener logischer Eintrag, eigener Audit-Trail, keine Offenlegung. - Malware-Scan: Optional (ClamAV Integration später) ### 4.5 Frontend (1h) - Upload: POST /api/v1/dms/files → dms_file_id → POST /api/v1/attachments - Download: GET /api/v1/attachments/{id}/download → DMS File - UI bleibt gleich, nur API-Calls ändern **Abhängigkeit:** Phase 3 --- ## Phase 5: Workspaces Backend (47-81h) **Ziel:** Workspace Model + API + Migration + Modulkonfiguration. ### 5.1 Datenmodell (4-7h) ```sql CREATE TABLE workspaces ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, icon VARCHAR(50) DEFAULT 'LayoutGrid', description TEXT, is_default BOOLEAN NOT NULL DEFAULT FALSE, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_by UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (tenant_id, id), UNIQUE (tenant_id, name) ); CREATE UNIQUE INDEX uq_workspace_default_per_tenant ON workspaces (tenant_id) WHERE is_default = TRUE; ``` ```sql CREATE TABLE workspace_modules ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, workspace_id UUID NOT NULL, module_key VARCHAR(100) NOT NULL, is_visible BOOLEAN NOT NULL DEFAULT TRUE, menu_order INTEGER NOT NULL DEFAULT 0, config JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), FOREIGN KEY (tenant_id, workspace_id) REFERENCES workspaces(tenant_id, id) ON DELETE CASCADE, UNIQUE (tenant_id, workspace_id, module_key) ); CREATE INDEX ix_workspace_modules_workspace ON workspace_modules (tenant_id, workspace_id, menu_order); ``` ```sql CREATE TABLE workspace_users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, workspace_id UUID NOT NULL, user_id UUID NOT NULL, role VARCHAR(20) NOT NULL DEFAULT 'member', is_default BOOLEAN NOT NULL DEFAULT FALSE, assigned_by UUID REFERENCES users(id) ON DELETE SET NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), FOREIGN KEY (tenant_id, workspace_id) REFERENCES workspaces(tenant_id, id) ON DELETE CASCADE, FOREIGN KEY (tenant_id, user_id) REFERENCES user_tenants(tenant_id, user_id) ON DELETE CASCADE, CHECK (role IN ('member', 'manager')), UNIQUE (tenant_id, workspace_id, user_id) ); CREATE UNIQUE INDEX uq_workspace_default_per_user ON workspace_users (tenant_id, user_id) WHERE is_default = TRUE; ``` ```sql CREATE TABLE workspace_widgets ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, workspace_id UUID NOT NULL, widget_key VARCHAR(100) NOT NULL, title VARCHAR(200), position_x INTEGER NOT NULL DEFAULT 0, position_y INTEGER NOT NULL DEFAULT 0, width INTEGER NOT NULL DEFAULT 1, height INTEGER NOT NULL DEFAULT 1, config JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), FOREIGN KEY (tenant_id, workspace_id) REFERENCES workspaces(tenant_id, id) ON DELETE CASCADE, CHECK (position_x >= 0), CHECK (position_y >= 0), CHECK (width > 0), CHECK (height > 0) ); CREATE INDEX ix_workspace_widgets_layout ON workspace_widgets (tenant_id, workspace_id, position_y, position_x); ``` Kein UNIQUE Constraint auf `(workspace_id, widget_key)` — derselbe Widget-Typ muss beliebig oft vorkommen dürfen. ### 5.2 Backend-Service und APIs (6-10h) - `app/models/workspace.py` — Workspace, WorkspaceModule, WorkspaceUser, WorkspaceWidget - `app/services/workspace_service.py` — CRUD, Module-Zuweisung, User-Zuweisung, Workspace-Manager Validierung - Default Workspace bei Tenant-Erstellung API: ``` GET /api/v1/workspaces → Alle Workspaces (Admin) POST /api/v1/workspaces → Workspace erstellen (Admin) GET /api/v1/workspaces/{id} → Workspace Details PUT /api/v1/workspaces/{id} → Workspace aktualisieren DELETE /api/v1/workspaces/{id} → Workspace löschen GET /api/v1/workspaces/my → Meine Workspaces POST /api/v1/workspaces/{id}/modules → Module zuweisen POST /api/v1/workspaces/{id}/users → User zuweisen DELETE /api/v1/workspaces/{id}/users/{uid} → User entfernen POST /api/v1/workspaces/{id}/widgets → Widget konfigurieren ``` ### 5.3 Workspace-Manager und Validierung (4-7h) - `workspace_users.role = 'manager'` für Workspace-Manager - Service prüft: Ist User Manager dieses Workspaces? Oder Tenant-Admin? Oder System-Admin? - Workspace-Manager kann nur seinen Workspace konfigurieren - Workspace-Manager kann keine RBAC/ABAC/Entity Permissions verändern ### 5.4 Modulkonfiguration (5-8h) Jedes workspacefähige Modul definiert ein eigenes validiertes Konfigurationsschema in `workspace_modules.config`. Der Plugin-Code muss die Konfiguration validieren. Ungeprüfte beliebige JSON-Strukturen dürfen nicht direkt verwendet werden. #### Kalender-Beispiel ```json { "visible_calendar_ids": ["uuid-1", "uuid-2"], "default_calendar_id": "uuid-1", "show_unassigned_events": false } ``` Das Kalender-Modul zeigt im Workspace nur: `visible_calendar_ids ∩ Kalender, die der Benutzer lesen darf`. APIs: ``` GET /api/v1/workspaces/{workspace_id}/modules/calendar/config PUT /api/v1/workspaces/{workspace_id}/modules/calendar/config GET /api/v1/workspaces/{workspace_id}/modules/calendar/options ``` `options` liefert nur Kalender die zum Tenant gehören und auswählbar sind. Bei der normalen Abfrage wird zusätzlich der Benutzerzugriff geprüft. #### Kontakte-Beispiel ```json { "visible_folder_ids": ["uuid-vertrieb"], "default_folder_id": "uuid-vertrieb", "saved_filter_ids": ["uuid-offene-leads"], "default_saved_filter_id": "uuid-offene-leads" } ``` Im Workspace „Einkauf" kann dasselbe Kontakte-Modul mit anderer Konfiguration verwendet werden. #### Allgemeine Regel Jedes Plugin kann optional bereitstellen: ```python workspace_config_schema validate_workspace_config() get_workspace_configuration_options() apply_workspace_view_filter() ``` Plugins ohne Workspace-Unterstützung verwenden nur `is_visible` und `menu_order`. ### 5.5 Kalender-Integration (4-7h) - Kalender-Modul nutzt `workspace_modules.config` für sichtbare Kalender - `apply_workspace_view_filter()` filtert Kalender nach Workspace-Konfiguration ∩ Benutzer-Rechten - Workspace-Manager kann Kalenderauswahl konfigurieren ### 5.6 Kontakte-/Ansichten-Integration (3-6h) - Kontakte-Modul nutzt `workspace_modules.config` für sichtbare Ordner und Ansichten - `apply_workspace_view_filter()` filtert Kontakte nach Workspace-Konfiguration ∩ Benutzer-Rechten - Workspace-Manager kann Ordner und Ansichten konfigurieren ### 5.7 Tests und Fehlerkorrekturen (6-10h) Freigabekriterien für Workspaces: 1. Derselbe Benutzer kann in zwei Browser-Tabs unterschiedliche Workspaces verwenden. 2. Ein Workspacewechsel verändert keine Benutzerrechte. 3. Ein ausgeblendetes Modul erscheint nicht in der Sidebar. 4. Ein direkt aufgerufenes berechtigtes Fachobjekt bleibt erreichbar. 5. Ein Modul ohne Benutzerpermission wird auch dann nicht angezeigt, wenn es im Workspace aktiviert ist. 6. Kontakte können in mehreren Workspaces dargestellt werden. 7. Jeder Workspace kann unterschiedliche Kontaktordner und gespeicherte Ansichten verwenden. 8. Das Kalender-Modul zeigt nur konfigurierte und gleichzeitig berechtigte Kalender. 9. Nicht berechtigte Kalender werden durch Workspace-Konfiguration niemals sichtbar. 10. Derselbe Widget-Typ kann mehrfach im selben Workspace vorkommen. 11. Widget-Instanzen besitzen unabhängige Positionen und Konfigurationen. 12. Workspace-Manager können nur ihren Workspace konfigurieren. 13. Workspace-Manager können keine RBAC-, ABAC- oder Entity Permissions verändern. 14. Benutzer anderer Tenants können keinem Workspace zugewiesen werden. 15. RLS schützt alle Workspace-Tabellen tenantübergreifend. 16. Default-Workspace ist pro Benutzer eindeutig. 17. Default-Workspace ist pro Tenant eindeutig. 18. Gelöschte oder deaktivierte Workspaces können nicht mehr ausgewählt werden. **Abhängigkeit:** Phase 4 --- ## Phase 6: Workspaces Frontend (in Phase 5 enthalten) **Ziel:** Workspace Switcher + UI + Sidebar-Filter + Dashboard. ### 6.1 Workspace Switcher in TopBar (4-7h) - Dropdown neben Tenant-Switcher - Zeigt alle Workspaces des Users - Wechseln speichert active_workspace_id in `sessionStorage` (tablokal, nicht global) - Client sendet `X-Workspace-ID` Header bei workspacefähigen Requests ### 6.2 Sidebar-Filter nach Workspace (in 6.1 enthalten) Sidebar-Logik: ```text Menüpunkt sichtbar = Plugin global aktiv UND Plugin im Tenant aktiv UND Modul im Workspace sichtbar UND Benutzer besitzt grundlegende Read-Permission ``` Die Sidebar blendet aus: nicht aktive Plugins, im Workspace deaktivierte Module, Module ohne Benutzerberechtigung, leere Menügruppen. Reihenfolge aus `workspace_modules.menu_order`. ### 6.3 Verwaltungsoberfläche (6-10h) - Settings → Rechte → Workspaces: Liste aller Workspaces - Workspace erstellen/bearbeiten/löschen - Module zuweisen (Checkbox-Liste aller verfügbaren Module) - User zuweisen (Multi-Select) mit Rolle (member/manager) - Modulkonfiguration (Kalenderauswahl, Kontaktordner, Ansichten) - Dashboard-Widgets konfigurieren (Drag & Drop, mehrfach verwendbar) ### 6.4 Dashboard und Mehrfach-Widgets (5-9h) - Dashboard lädt Widgets aus workspace_widgets - Layout pro Workspace speichern - Default-Widgets bei Workspace-Erstellung - Derselbe Widget-Typ kann mehrfach vorkommen (z.B. "Umsatz aktueller Monat" + "Umsatz aktuelles Jahr") - Widget-Instanzen besitzen unabhängige Positionen und Konfigurationen **Abhängigkeit:** Phase 5 --- ## Phase 7: Outbox standardisieren (3h) **Ziel:** Standardisierter Event-Envelope mit Delivery-Tracking. ### 7.1 Event-Envelope (1h) ```json { "event_id": "uuid", "event_type": "crm.contact.created.v1", "tenant_id": "uuid", "aggregate_type": "contact", "aggregate_id": "uuid", "occurred_at": "timestamp", "correlation_id": "uuid", "schema_version": 1, "payload": {} } ``` ### 7.2 outbox_deliveries Tabelle (1h) ```sql CREATE TABLE outbox_deliveries ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), event_id UUID NOT NULL REFERENCES event_outbox(id) ON DELETE CASCADE, consumer_name VARCHAR(150) NOT NULL, status VARCHAR(30) NOT NULL DEFAULT 'pending', attempt_count INTEGER NOT NULL DEFAULT 0, next_attempt_at TIMESTAMPTZ, last_error TEXT, processed_at TIMESTAMPTZ, UNIQUE(event_id, consumer_name) ); ``` Beim Dispatch wird die für dieses Event erwartete Consumerliste festgeschrieben. Ein Event darf erst abgeschlossen werden, wenn alle verpflichtenden Deliveries erfolgreich sind. `consumer_inbox` bleibt zur Idempotenz bestehen. Ein Consumer muss anhand von `consumer_name + event_id` erkennen, ob er das Event bereits verarbeitet hat. ### 7.3 Event-Namen standardisieren + Consumer-Inbox (1h) - Format: `crm.{aggregate}.{action}.v{version}` - `enqueue_outbox_event()` bekommt Parameter für aggregate_type, aggregate_id, correlation_id - Jeder Consumer trägt sich in consumer_inbox ein - Event gilt als 'published' wenn alle Deliveries 'processed' sind - Bei Consumer-Fehler: 'failed' Status, Event bleibt pending für Retry **Abhängigkeit:** Phase 6 --- ## Phase 8: Command- und RequestContext-Grundlage (8h) **Ziel:** Eine Transaktionsgrenze pro Geschäftsoperation. INKREMENTELL. Muss vor den ersten ERP-Modulen stehen. ### 8.1 Command Base Class + UnitOfWork (2h) ```python class Command(ABC): @abstractmethod async def execute(self, context: RequestContext, uow: UnitOfWork) -> Any: ... class UnitOfWork: def __init__(self, db: AsyncSession): self.db = db self.contacts = ContactRepository(db) self.outbox = OutboxRepository(db) self.audit = AuditRepository(db) async def commit(self): await self.db.commit() async def rollback(self): await self.db.rollback() ``` ### 8.2 RequestContext (1h) ```python class RequestContext: user_id: uuid.UUID tenant_id: uuid.UUID workspace_id: uuid.UUID | None permissions: list[str] is_system_admin: bool correlation_id: uuid.UUID def require(self, permission: str): if not self.has_permission(permission): raise PermissionError(permission) ``` ### 8.3 Kern-Module auf Commands umstellen (3h) - CreateContact, UpdateContact, DeleteContact - CreateAddress, UpdateAddress, DeleteAddress - CreateAttachment, DeleteAttachment - Ein Commit pro Operation: Route → Command → Audit + Outbox → Commit ### 8.4 Bestehende Services belassen (2h) - Nicht alle Services gleichzeitig umbauen - Bei Überarbeitung: Schrittweise auf Command Pattern migrieren - Neue ERP-Module nutzen Commands von Anfang an **Abhängigkeit:** Phase 7 --- ## Phase 9: Report-System isolieren (4h) **Ziel:** Reports in isoliertem Worker, nicht im API-Prozess. ### 9.1 Report-Job Queue (1h) - Report-Erstellung als Background-Job (ARQ/Redis) - API erstellt Job, gibt Job-ID zurück - Client pollt Job-Status oder bekommt WebSocket-Notification ### 9.2 Report-Worker (2h) - Separater Prozess für PDF-Erzeugung - Constraints: Kein Shell-Zugriff, kein Docker-Socket, keine Secrets außer Template-Daten, Read-only Dateisystem (nur Output-Verzeichnis), CPU/RAM-Limit, nur freigegebene Templates, URL-Fetching deaktiviert ### 9.3 PDF in Object Storage (1h) - Output in DMS, nicht im API-Container - Job-Status: pending → processing → completed/failed - Download-Link via DMS API **Abhängigkeit:** Phase 8 --- ## Phase 10: Migrationen als Produktbestandteil (3h) **Ziel:** Automatische Tests für Migrationen. ### 10.1 CI Gate: Alembic auf leerer DB (1h) - `alembic upgrade head` auf frischer DB muss funktionieren - `alembic downgrade base` muss funktionieren - CI bricht ab wenn Migration fehlschlägt ### 10.2 Migration-Test-Script (1h) - Datensatzanzahlen vor/nach Migration vergleichen - Verwaiste Fremdschlüssel prüfen - Nullwerte prüfen - Tenant-Verteilung prüfen - RLS-Zugriffstest nach Migration ### 10.3 Regeln (1h) - Veröffentlichte Migrationen nie nachträglich ändern - Neue Revision für Fixes - .gitignore erweitern: .env, dump.rdb, __pycache__, frontend/dist, .pytest_cache **Abhängigkeit:** Phase 9 --- ## Phase 11: CI erweitern (4h) **Ziel:** CI muss architektonische Fehler stoppen. ### 11.1 Fehlende CI Gates (2h) - **Ruff** (Python Linter) — Style + Import-Checks - **Cross-Tenant Security Test** — Test der RLS Tenant-Isolation - **Alembic Upgrade Test** — Auf leerer DB - **Alembic Downgrade Test** — Base → head → base - **Dependency Scan** — pip-audit für Python, npm audit für Frontend - **Container Smoke Test** — App startet, Health-Check grün ### 11.2 Build-Hygiene (1h) - **npm ci** statt `npm ci || npm install` — harter Abbruch bei Fehler - **Python deps pinning** — requirements.txt mit exakten Versionen - **.gitignore** — .env, dump.rdb, __pycache__, frontend/dist, .pytest_cache ### 11.3 Vorhandene Gates bestätigen (1h) - Python Compile ✅, TypeScript ✅, Frontend Build ✅, Test Collection ✅, SQL Injection Check ✅, Jinja2 Sandbox ✅, RLS Variable ✅, Fail-Closed Plugin Gate ✅, Cross-Plugin Imports ✅, Alembic Heads ✅ **Abhängigkeit:** Phase 10 --- ## Phase 12: Monitoring und Logging (3h) **Ziel:** Strukturiertes Monitoring für Pilotbetrieb. Extern abgesichert. ### 12.1 Strukturiertes Logging (1h) - JSON-Logs für alle Requests - Log-Level pro Environment konfigurierbar - Correlation-ID in allen Logs - Log-Rotation konfiguriert ### 12.2 Health Endpoints + Metrics (1h) Trennung: ```text /health/live — Prüft ob der Prozess lebt /health/ready — Prüft PostgreSQL, Redis, Worker, Storage /metrics — Prometheus-kompatible Kennzahlen ``` ### 12.3 Externes Alerting (1h) Interne Webhook-Alarme reichen nicht. Wenn API, Worker oder Redis ausgefallen sind, kann das System keinen eigenen Alarm versenden. Mindestens ein externes Monitoring: Uptime Kuma, Prometheus Alertmanager, Grafana, Sentry, oder Coolify Health Monitoring. Alarme bei: - API nicht erreichbar - Worker-Heartbeat fehlt - Readiness rot - Fehlerrate über Schwellwert - Response-Zeit über Schwellwert - DB-Pool erschöpft - Outbox-Rückstau - fehlgeschlagene Jobs **Abhängigkeit:** Phase 11 --- ## Gesamt-Übersicht | Phase | Inhalt | Aufwand | |-------|--------|---------| | 0a | Cross-Tenant Tests + RLS Tests + Test-Suite grün | 4h | | 0b | Backup/Restore Test | 2h | | 1 | Security Kernel + canAccess Fallback entfernen | 10h | | 2 | DB-Rollen strikt trennen + RLS standardisieren | 4h | | 3 | Plugin-System vereinfachen + Cache | 4h | | 4 | Dateisysteme vereinheitlichen (DMS) | 6h | | 5 | Workspaces Backend + Frontend (komplett) | 47-81h | | 7 | Outbox standardisieren + Deliveries | 3h | | 8 | Command- und RequestContext-Grundlage | 8h | | 9 | Report-System isolieren | 4h | | 10 | Migrationen als Produktbestandteil | 3h | | 11 | CI erweitern + Build-Hygiene | 4h | | 12 | Monitoring und Logging (extern) | 3h | | | **Gesamt** | **ca. 160-240h** | ## Priorität vor ERP-Modulen **Zwingend vor ERP:** Phase 0a-5 (Tests + Security + DB-Rollen + Plugin + DMS + Workspaces) = 77-111h **Danach möglich:** ERP-Module können auf sauberer Architektur + Workspaces aufbauen. **Parallel zu ERP:** Phase 7-12 (Outbox + Commands + Reports + Migrationen + CI + Monitoring) = 25h ## Abdeckung nach Plan-Abschluss | Kategorie | Vor Plan | Nach Plan | |-----------|:---:|:---:| | P0 Befunde (6) | 6/6 gefixt, ungetestet | 6/6 gefixt + getestet | | P1 Befunde (9) | 9/9 gefixt, ungetestet | 9/9 gefixt + getestet | | Architektur 10-Punkte | 2/10 | 10/10 | | Mindestfreigabe 10-Punkte | 2/10 | 10/10 | | Workspaces | 0 | Vollständig | | Monitoring | 0 | Extern abgesichert | | CI/CD | 10 Gates | 16 Gates | ## Architekturbewertung ```text Aktuelle Architektur: ungefähr 6/10 Nach erfolgreicher Umsetzung: ungefähr 8 bis 8,5/10 Nach Pilotbetrieb und mehreren stabilen Releases: potenziell 9/10 ``` Ein Architekturwert von 10/10 ist nicht seriös messbar und vor einem realen Pilotbetrieb nicht belegbar. Der Plan gilt erst als abgeschlossen, wenn die Änderungen nicht nur im Code vorhanden, sondern durch reproduzierbare Integrationstests nachgewiesen sind. ## Bewusst nicht umgesetzt - `security_resources` Tabelle — Aktuelles System funktioniert, konsolidieren statt neu bauen - Alle Services in Commands — Inkrementell, nicht Big-Bang - `stored_objects` Tabelle — Alles im DMS, Referenzen von Objekten