wiki/implementation/trustee_feature_rbac_architecture.md
2026-01-24 09:43:35 +01:00

24 KiB

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

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:

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:

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

  • Ist das Gateway bereit? → Ja
  • Sind Feature-spezifische Rollen gleich wie System-Rollen? → Nein, bewusst getrennt
  • Ist zweistufiges RBAC nötig? → Ja, bereits implementiert
  • 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:

// In TrusteeOrganisationsView.tsx
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<TrusteeOrganisation | null>(null);
const { generateCreateFieldsFromAttributes, generateEditFieldsFromAttributes } = useTrusteeOrganisations();
const { handleCreate, handleUpdate } = useTrusteeOrganisationOperations();

// Create-Button
<button onClick={() => { setEditingItem(null); setIsModalOpen(true); }}>
  + Neue Organisation
</button>

// Edit-Button
<button onClick={() => { setEditingItem(org); setIsModalOpen(true); }}>
  ✏️
</button>

// Modal mit EditForm
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
  <EditForm
    fields={editingItem ? generateEditFieldsFromAttributes() : generateCreateFieldsFromAttributes()}
    initialData={editingItem || {}}
    onSave={async (data) => {
      if (editingItem) {
        await handleUpdate(editingItem.id, data);
      } else {
        await handleCreate(data);
      }
      setIsModalOpen(false);
      refetch();
    }}
  />
</Modal>

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:

// 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
<td>{getLabel(userOptions, access.userId)}</td>
<td>{getLabel(orgOptions, access.organisationId)}</td>
<td>{getLabel(roleOptions, access.roleId)}</td>

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:

// 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.

// hooks/useTrusteeOptions.ts
export function useTrusteeOptions() {
  const instanceId = useInstanceId();
  const [options, setOptions] = useState<TrusteeOptionsMap>({});
  
  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

/**
 * 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<Partial<TrusteeOptions>>({});
  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<TrusteeOptions> = {};
      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

@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