Files
leocrm/UMBAU_PLAN.md
T

34 KiB

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:

Tatsächlich sichtbare Daten
=
Workspace-Konfiguration
∩
Berechtigungen des Benutzers
∩
Objektzugriff
∩
Tenant-Isolation

Ein Workspace darf niemals Rechte erteilen oder bestehende Rechte erweitern.

Beispiel Kalender:

Im Workspace konfigurierte Kalender
∩
Kalender, die der Benutzer lesen darf
=
im Kalender-Modul angezeigte Kalender

Beispiel Kontakte:

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:

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:
X-Workspace-ID: <uuid>
  • 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:

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:

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)

crm_platform_admin

Darf: Datenbank und Schema initialisieren, PostgreSQL-Erweiterungen installieren, Rollen erzeugen. Zugangsdaten stehen nicht dauerhaft in API- oder Worker-Containern.

Migrationsrolle

crm_migration
  • Owner des Anwendungsschemas
  • führt Alembic aus
  • darf DDL innerhalb des Anwendungsschemas
  • kein Superuser
  • kein API-Login

Auth-Rolle

crm_auth
  • minimaler Zugriff auf Benutzer, Tenants und aktive Memberships
  • keine allgemeinen Fachdatenrechte

API-Rolle

crm_api
  • NOSUPERUSER
  • NOBYPASSRLS
  • kein Tabellenowner
  • fachlicher Zugriff nur unter gesetztem Tenant-Kontext

Worker-Rolle

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)

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)

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;
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);
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;
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

{
  "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

{
  "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:

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:

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)

{
  "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)

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)

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)

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:

/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

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