# Trustee Feature - RBAC Architektur & Gateway-Readiness ## Übersicht Dieses Dokument beschreibt die **vereinfachte RBAC-Architektur** für das Trustee Feature. **Erstellungsdatum**: 2026-01-23 **Letzte Änderung**: 2026-01-24 **Status**: Aktuell --- ## WICHTIG: Architektur-Änderung (2026-01-24) ### Feature-Instanz = Organisation Die ursprüngliche Architektur sah vor, dass innerhalb einer Feature-Instanz mehrere "Organisationen" (Kunden) verwaltet werden. **Dies wurde geändert:** - **Neu**: Eine Feature-Instanz repräsentiert **eine** Organisation (z.B. "SOHA Treuhand") - **Entfernt**: `TrusteeOrganisation`, `TrusteeContract`, `TrusteeRole`, `TrusteeAccess` - **Beibehalten**: `TrusteePosition`, `TrusteeDocument`, `TrusteePositionDocument` ### Vereinfachtes Datenmodell ``` Feature-Instanz (= Organisation, z.B. "SOHA Treuhand") └── TrusteePosition (Buchungspositionen, Konten) └── TrusteeDocument (Belege, Berichte) └── TrusteePositionDocument (Zuordnung Position ↔ Dokument) ``` --- ## 1. Gateway-Status ### 1.1 Ist das Gateway bereit für UI-Arbeit? **Ja, das Gateway ist vollständig bereit.** | Komponente | Datei | Status | |------------|-------|--------| | **Routes** | `routeFeatureTrustee.py` | ✅ Komplett | | **Interface** | `interfaceFeatureTrustee.py` | ✅ Komplett | | **Datamodel** | `datamodelFeatureTrustee.py` | ✅ Komplett | | **RBAC** | Via System-Level AccessRules | ✅ Implementiert | ### 1.2 Verfügbare API-Endpoints Alle Endpoints verwenden das URL-Pattern: `/api/trustee/{instanceId}/...` | Endpoint-Gruppe | Basis-Route | CRUD | Options | |-----------------|-------------|------|---------| | Documents | `/api/trustee/{instanceId}/documents` | ✅ | ✅ | | Positions | `/api/trustee/{instanceId}/positions` | ✅ | ✅ | | Position-Documents | `/api/trustee/{instanceId}/position-documents` | ✅ | - | | **Instance-Roles** | `/api/trustee/{instanceId}/instance-roles` | ✅ | - | | **User Options** | `/api/users/options` | - | ✅ | ### 1.3 Frontend Views | View | Pfad | Beschreibung | |------|------|--------------| | Dashboard | `/dashboard` | Übersicht | | Positionen | `/positions` | Buchungspositionen verwalten | | Dokumente | `/documents` | Belege/Berichte verwalten | | Zuordnungen | `/position-documents` | Position ↔ Dokument Zuordnungen | | Rollen & Rechte | `/instance-roles` | Instanz-Rollen verwalten (nur Admin) | --- ## 2. RBAC-Architektur (Vereinfacht) ### 2.1 Nur System-Level RBAC Da die Feature-Instanz selbst die Organisation repräsentiert, ist **kein Feature-internes RBAC** mehr nötig. ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ SYSTEM-RBAC (Basis-Level) │ │ (Mandate + Feature Instance Zugang) │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ User ──► UserMandate ──► Mandate │ │ │ │ │ └──► FeatureAccess ──► FeatureInstance (= Organisation) │ │ │ │ │ └── Role (trustee-admin, trustee-accountant, trustee-client)│ │ │ │ │ └── AccessRules (UI, DATA, RESOURCE) │ │ │ │ Frage: Hat der User überhaupt Zugang zu diesem Feature? │ │ │ └─────────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────────┐ │ STUFE 2: FEATURE-SPEZIFISCHES RBAC │ │ (Isolation innerhalb des Features) │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ TrusteeAccess │ │ ├── userId → Welcher User │ │ ├── organisationId → Welcher Kunde (Isolationsobjekt) │ │ ├── roleId → Welche Feature-Rolle (admin, operate, userreport)│ │ └── contractId → Optional: Einschränkung auf bestimmten Vertrag │ │ │ │ Frage: Welche Kunden darf der User sehen? Mit welchen Rechten? │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### 2.3 Vergleich: System-RBAC vs. Feature-RBAC | Aspekt | System-RBAC | Feature-RBAC (Trustee) | |--------|-------------|------------------------| | **Tabelle** | `AccessRule` | `TrusteeAccess` | | **Scope** | Global / Mandate | Feature-Instanz | | **Objekte** | Tables, UI-Items, API-Resources | Organisationen, Contracts | | **Verwaltung** | SysAdmin | Feature-Admin | | **Zweck** | "Was darf der User technisch?" | "Welche Daten darf der User sehen?" | ### 2.4 Feature-spezifische Rollen Das Trustee-Feature definiert drei Rollen: | Rolle | Beschreibung | Berechtigungen | |-------|--------------|----------------| | **admin** | Feature-Administrator | Voller CRUD auf Organisation + alle Contracts | | **operate** | Sachbearbeiter | CRUD für Contracts, Documents, Positions | | **userreport** | Endbenutzer | Nur eigene erstellte Records (Documents, Positions) | ### 2.5 Contract-Level Isolation (optional) `TrusteeAccess.contractId` ermöglicht feinere Isolation: - **Leer/NULL**: Zugriff auf alle Contracts der Organisation - **Gesetzt**: Zugriff nur auf diesen spezifischen Contract ``` Beispiel: - User A hat Zugriff auf Organisation "Kunde-X" mit contractId=NULL → Sieht alle Contracts von Kunde-X - User B hat Zugriff auf Organisation "Kunde-X" mit contractId="contract-123" → Sieht nur Contract "contract-123" von Kunde-X ``` --- ## 3. Implementierung im Gateway ### 3.1 Datenmodell: TrusteeAccess ```python class TrusteeAccess(BaseModel): id: str # UUID organisationId: str # FK zu TrusteeOrganisation roleId: str # FK zu TrusteeRole (admin, operate, userreport) userId: str # FK zu User contractId: Optional[str] # Optional: FK zu TrusteeContract mandateId: Optional[str] # System-Kontext featureInstanceId: Optional[str] # Feature-Instanz ``` ### 3.2 Kombinierte Berechtigungsprüfung Die Methode `checkCombinedPermission()` in `interfaceFeatureTrustee.py` kombiniert beide Stufen: ```python def checkCombinedPermission(self, modelClass, operation, organisationId, contractId, recordCreatedBy): """ Kombiniert System-RBAC + Feature-Level RBAC. 1. Prüft System-RBAC (AccessLevel: ALL, GROUP, MY, NONE) 2. Bei ALL: Bypass Feature-Level (SysAdmin) 3. Sonst: Prüft TrusteeAccess für User + Organisation + Contract """ # Stufe 1: System-RBAC accessLevel = self.getRbacAccessLevel(modelClass, operation) if accessLevel == AccessLevel.NONE: return False if accessLevel == AccessLevel.ALL: return True # SysAdmin bypass # Stufe 2: Feature-RBAC via TrusteeAccess roles = self.getUserTrusteeRoles(self.userId, organisationId, contractId) if "admin" in roles: return True if "operate" in roles and modelClass in (TrusteeContract, TrusteeDocument, ...): return True if "userreport" in roles and recordCreatedBy == self.userId: return True return False ``` ### 3.3 Automatische Filterung Die Methode `filterRecordsByTrusteeAccess()` filtert Ergebnisse basierend auf User-Zugriff: ```python def filterRecordsByTrusteeAccess(self, records, modelClass): """ Filtert Records basierend auf TrusteeAccess des Users. - admin: Sieht alle Records der Organisation - operate: Sieht alle Records der Organisation - userreport: Sieht nur eigene Records (_createdBy = userId) """ ``` --- ## 4. Generisches Muster für andere Features ### 4.1 Feature-Isolation-Pattern Jedes Feature kann sein eigenes Isolationsmodell definieren: ``` {Feature}Access: User → {Isolationsobjekt} → Role [→ Unter-Objekt] ``` ### 4.2 Beispiele für andere Features | Feature | Access-Tabelle | Isolationsobjekt | Unter-Objekt | |---------|----------------|------------------|--------------| | **Trustee** | `TrusteeAccess` | Organisation (Kunde) | Contract | | **RealEstate** | `RealEstateAccess` | Property (Liegenschaft) | Unit (Wohnung) | | **ProjectMgmt** | `ProjectAccess` | Project | - | | **Accounting** | `AccountingAccess` | Company | FiscalYear | ### 4.3 Implementierungsvorlage Für ein neues Feature mit Isolation: 1. **Datamodel**: `{Feature}Access` mit `userId`, `{isolationObject}Id`, `roleId` 2. **Interface**: `checkCombinedPermission()` und `filterRecordsByAccess()` 3. **Routes**: CRUD für `{Feature}Access` 4. **Roles**: Feature-spezifische Rollen definieren --- ## 5. API-Referenz für UI-Entwicklung ### 5.1 Access-Verwaltung (User-Zuweisung zu Kunden) ``` # Alle Access-Records abrufen GET /api/trustee/{instanceId}/access # Access für bestimmte Organisation GET /api/trustee/{instanceId}/access/organisation/{orgId} # Access für bestimmten User GET /api/trustee/{instanceId}/access/user/{userId} # Neuen Access erstellen (User zu Kunde zuweisen) POST /api/trustee/{instanceId}/access Body: { "userId": "user-uuid", "organisationId": "kunde-id", "roleId": "operate", "contractId": null // Optional } # Access aktualisieren PUT /api/trustee/{instanceId}/access/{accessId} # Access löschen DELETE /api/trustee/{instanceId}/access/{accessId} ``` ### 5.2 Options-Endpoints für Dropdowns ``` # Organisationen für Dropdown GET /api/trustee/{instanceId}/organisations/options → [{ "value": "org-id", "label": "Kunde AG" }, ...] # Rollen für Dropdown GET /api/trustee/{instanceId}/roles/options → [{ "value": "admin", "label": "Administrator" }, ...] # Contracts für Dropdown (dynamisch nach Organisation) GET /api/trustee/{instanceId}/contracts/options → [{ "value": "contract-uuid", "label": "Vertrag 2026" }, ...] # Users für Dropdown (aus aktuellem Mandate) GET /api/users/options → [{ "value": "user-uuid", "label": "Max Muster" }, ...] ``` --- ## 6. UI-Anforderungen ### 6.1 Access-View (User-Verwaltung) Eine View zur Verwaltung der User-Zuweisungen: | Spalte | Typ | Quelle | |--------|-----|--------| | User | Select | `/api/users/options` | | Organisation | Select | `/api/trustee/{instanceId}/organisations/options` | | Rolle | Select | `/api/trustee/{instanceId}/roles/options` | | Contract | Select (optional) | `/api/trustee/{instanceId}/contracts/options` | **Hinweis**: Das `contractId` Dropdown sollte dynamisch nach Auswahl der Organisation gefiltert werden. ### 6.2 Berechtigungen für Access-View - **Sichtbar für**: Feature-Admins (`admin` Rolle in mindestens einer Organisation) - **Create/Update/Delete**: Nur für Organisationen, in denen User Admin ist --- ## 7. Offene Punkte / Entscheidungen ### 7.1 Geklärt - [x] Ist das Gateway bereit? → **Ja** - [x] Sind Feature-spezifische Rollen gleich wie System-Rollen? → **Nein, bewusst getrennt** - [x] Ist zweistufiges RBAC nötig? → **Ja, bereits implementiert** - [x] Existiert der Users-Options-Endpoint? → **Ja, `/api/users/options`** ### 7.2 Offen für UI-Review - [ ] Soll die Access-View eine eigene Seite sein oder Tab in Organisation? - [ ] Wie wird Contract-Dropdown bei organisationId-Änderung aktualisiert? - [ ] Braucht es eine "Bulk-Zuweisung" (User zu mehreren Orgs gleichzeitig)? --- ## 8. Zusammenfassung ### Das Gateway bietet: 1. **Vollständige CRUD-Routes** für alle Trustee-Entities 2. **Zwei-Stufen-RBAC** (System + Feature-Level) 3. **Automatische Filterung** basierend auf User-Access 4. **Options-Endpoints** für alle Dropdowns ### Das UI muss implementieren: 1. **Access-View** zur Verwaltung von User-Zuweisungen 2. **Dynamische Dropdowns** mit Abhängigkeiten (Contract nach Organisation) 3. **instanceId** in allen API-Calls verwenden ### Architektur-Prinzip: ``` System-RBAC → Zugang zum Feature Feature-RBAC → Isolation innerhalb des Features ``` --- ## 9. Implementierungsplan: Fehlende Elemente ### 9.1 Aktueller Stand (Analyse) | Komponente | Status | Details | |------------|--------|---------| | **Gateway (Backend)** | ✅ Komplett | Routes, Interface, Datamodel, RBAC | | **API Hooks** (`useTrustee.ts`) | ✅ Komplett | CRUD-Hooks für alle Entities | | **API Client** (`trusteeApi.ts`) | ✅ Komplett | Alle Endpoints implementiert | | **Views (Grundgerüst)** | ⚠️ Teilweise | Tabellen existieren, aber ohne Forms | | **Create/Edit Formulare** | ❌ Fehlt | Buttons vorhanden, aber ohne Funktion | | **Label-Auflösung** | ❌ Fehlt | Zeigt IDs statt Labels (userId, orgId) | | **Dynamische Dropdowns** | ❌ Fehlt | Contract abhängig von Organisation | ### 9.2 Fehlende Implementierungen #### P1: Create/Edit Formulare für alle Views **Problem:** Die "Neu erstellen" und "Bearbeiten" Buttons existieren, aber öffnen kein Formular. **Betroffene Views:** - `TrusteeOrganisationsView.tsx` - `TrusteeRolesView.tsx` - `TrusteeAccessView.tsx` - `TrusteeContractsView.tsx` - `TrusteeDocumentsView.tsx` - `TrusteePositionsView.tsx` **Lösung:** Pro View ein Modal mit EditForm implementieren, das die automatisch generierten Felder aus `generateEditFieldsFromAttributes()` verwendet. **Beispiel-Pattern:** ```tsx // In TrusteeOrganisationsView.tsx const [isModalOpen, setIsModalOpen] = useState(false); const [editingItem, setEditingItem] = useState(null); const { generateCreateFieldsFromAttributes, generateEditFieldsFromAttributes } = useTrusteeOrganisations(); const { handleCreate, handleUpdate } = useTrusteeOrganisationOperations(); // Create-Button // Edit-Button // Modal mit EditForm setIsModalOpen(false)}> { if (editingItem) { await handleUpdate(editingItem.id, data); } else { await handleCreate(data); } setIsModalOpen(false); refetch(); }} /> ``` --- #### P2: Label-Auflösung für Foreign Keys **Problem:** `TrusteeAccessView` zeigt: - `userId: "uuid-123"` statt `"Max Muster"` - `organisationId: "org-1"` statt `"Kunde AG"` - `roleId: "admin"` statt `"Administrator"` **Lösung:** Options aus den entsprechenden Endpoints laden und als Lookup-Map verwenden. **Implementierung:** ```tsx // In TrusteeAccessView.tsx const [userOptions, setUserOptions] = useState<{value: string, label: string}[]>([]); const [orgOptions, setOrgOptions] = useState<{value: string, label: string}[]>([]); const [roleOptions, setRoleOptions] = useState<{value: string, label: string}[]>([]); useEffect(() => { const loadOptions = async () => { const [users, orgs, roles] = await Promise.all([ api.get('/api/users/options'), api.get(`/api/trustee/${instanceId}/organisations/options`), api.get(`/api/trustee/${instanceId}/roles/options`) ]); setUserOptions(users.data); setOrgOptions(orgs.data); setRoleOptions(roles.data); }; loadOptions(); }, [instanceId]); // Lookup-Funktion const getLabel = (options: {value: string, label: string}[], value: string) => options.find(o => o.value === value)?.label || value; // In der Tabelle {getLabel(userOptions, access.userId)} {getLabel(orgOptions, access.organisationId)} {getLabel(roleOptions, access.roleId)} ``` --- #### P3: Dynamische Dropdown-Filterung **Problem:** Im AccessView/ContractView soll das Contract-Dropdown nur Contracts der ausgewählten Organisation zeigen. **Lösung:** `dependsOn`-Attribut im Datamodel nutzen + dynamisches Nachladen. **Implementierung im EditForm:** ```tsx // Wenn organisationId geändert wird, Contracts neu laden const handleFieldChange = async (fieldKey: string, value: any) => { setFormData(prev => ({ ...prev, [fieldKey]: value })); // Dynamische Abhängigkeiten prüfen const dependentFields = fields.filter(f => f.dependsOn === fieldKey); for (const depField of dependentFields) { if (depField.optionsReference?.includes('contracts')) { // Contracts für diese Organisation laden const contracts = await api.get( `/api/trustee/${instanceId}/contracts/options?organisationId=${value}` ); // Options aktualisieren... } } }; ``` **Backend-Erweiterung nötig:** Der `/contracts/options` Endpoint muss einen `organisationId` Query-Parameter unterstützen. --- #### P4: Generischer Options-Hook **Problem:** Options-Laden ist in jeder View dupliziert. **Lösung:** Zentraler Hook für Trustee-Options. ```tsx // hooks/useTrusteeOptions.ts export function useTrusteeOptions() { const instanceId = useInstanceId(); const [options, setOptions] = useState({}); const loadOptions = useCallback(async (entity: 'organisations' | 'roles' | 'contracts' | 'users') => { if (entity === 'users') { const res = await api.get('/api/users/options'); setOptions(prev => ({ ...prev, users: res.data })); } else { const res = await api.get(`/api/trustee/${instanceId}/${entity}/options`); setOptions(prev => ({ ...prev, [entity]: res.data })); } }, [instanceId]); const getLabel = useCallback((entity: string, value: string) => { return options[entity]?.find(o => o.value === value)?.label || value; }, [options]); return { options, loadOptions, getLabel }; } ``` --- ### 9.3 Implementierungsreihenfolge | Phase | Aufgabe | Aufwand | Priorität | |-------|---------|---------|-----------| | **1** | `useTrusteeOptions` Hook erstellen | 2h | Hoch | | **2** | Label-Auflösung in allen Views | 3h | Hoch | | **3** | Create/Edit Modal in `TrusteeOrganisationsView` | 3h | Hoch | | **4** | Create/Edit Modal für alle anderen Views kopieren | 4h | Mittel | | **5** | Dynamische Contract-Filterung | 2h | Mittel | | **6** | Backend: `/contracts/options?organisationId=` | 1h | Mittel | | **7** | Position-Document Verknüpfungs-UI | 4h | Niedrig | | **8** | Testing & Bugfixes | 4h | Hoch | **Geschätzter Gesamtaufwand:** ~23h (3-4 Arbeitstage) --- ### 9.4 Detailplan Phase 1-3 (MVP) #### Schritt 1: `useTrusteeOptions` Hook **Datei:** `frontend_nyla/src/hooks/useTrusteeOptions.ts` ```typescript /** * Hook für Trustee-Options (Dropdowns, Label-Auflösung) */ import { useState, useCallback, useEffect } from 'react'; import api from '../api'; import { useInstanceId } from './useCurrentInstance'; interface Option { value: string; label: string; } interface TrusteeOptions { users: Option[]; organisations: Option[]; roles: Option[]; contracts: Option[]; documents: Option[]; positions: Option[]; } export function useTrusteeOptions(autoLoad: (keyof TrusteeOptions)[] = []) { const instanceId = useInstanceId(); const [options, setOptions] = useState>({}); const [loading, setLoading] = useState(false); const loadOptions = useCallback(async ( entities: (keyof TrusteeOptions)[], filters?: { organisationId?: string } ) => { if (!instanceId) return; setLoading(true); try { const promises = entities.map(async (entity) => { let url: string; if (entity === 'users') { url = '/api/users/options'; } else { url = `/api/trustee/${instanceId}/${entity}/options`; if (filters?.organisationId && entity === 'contracts') { url += `?organisationId=${filters.organisationId}`; } } const res = await api.get(url); return { entity, data: res.data }; }); const results = await Promise.all(promises); const newOptions: Partial = {}; results.forEach(({ entity, data }) => { newOptions[entity] = data; }); setOptions(prev => ({ ...prev, ...newOptions })); } finally { setLoading(false); } }, [instanceId]); const getLabel = useCallback((entity: keyof TrusteeOptions, value: string): string => { return options[entity]?.find(o => o.value === value)?.label || value; }, [options]); // Auto-Load bei Mount useEffect(() => { if (autoLoad.length > 0 && instanceId) { loadOptions(autoLoad); } }, [instanceId, autoLoad.join(',')]); return { options, loadOptions, getLabel, loading }; } ``` #### Schritt 2: TrusteeAccessView mit Labels **Datei:** `frontend_nyla/src/pages/views/trustee/TrusteeAccessView.tsx` - Import `useTrusteeOptions` - Auto-Load: `['users', 'organisations', 'roles']` - Tabelle: `getLabel()` für userId, organisationId, roleId #### Schritt 3: Create/Edit Modal **Ansatz:** Bestehende `EditPopup`-Komponente aus anderen Views (z.B. Prompts) wiederverwenden. **Dateien:** - `TrusteeOrganisationsView.tsx` - Modal hinzufügen - Nutzt `generateCreateFieldsFromAttributes()` für Feld-Definitionen - `handleCreate()` / `handleUpdate()` für API-Calls --- ### 9.5 Backend-Erweiterung (optional) Für dynamische Contract-Filterung nach Organisation: **Datei:** `gateway/modules/features/trustee/routeFeatureTrustee.py` ```python @router.get("/{instanceId}/contracts/options", response_model=List[Dict[str, Any]]) async def getContractOptions( request: Request, instanceId: str = Path(...), organisationId: Optional[str] = Query(None), # NEU context: RequestContext = Depends(getRequestContext) ) -> List[Dict[str, Any]]: """Get contract options, optionally filtered by organisation.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) if organisationId: contracts = interface.getContractsByOrganisation(organisationId) else: result = interface.getAllContracts(None) contracts = result.items if hasattr(result, 'items') else result return [{"value": c.id, "label": c.label or c.name or c.id} for c in contracts] ``` --- ### 9.6 Offene Entscheidungen für Review | # | Frage | Optionen | |---|-------|----------| | 1 | **Modal vs. Inline-Edit** | A) Modal-Dialog / B) Inline in Tabelle / C) Separate Seite | | 2 | **Bulk-Operationen** | A) Ja, Multi-Select + Batch-Aktionen / B) Nein, nur einzeln | | 3 | **Position-Document UI** | A) Separate View / B) Inline in Position-View / C) Beides | | 4 | **Validierung** | A) Client-only / B) Server-only / C) Beides | --- **Dokumentversion**: 1.1 **Letzte Aktualisierung**: 2026-01-23 **Autor**: Claude (AI-Assistent) **Status**: Zur Überprüfung