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:
- Datamodel:
{Feature}AccessmituserId,{isolationObject}Id,roleId - Interface:
checkCombinedPermission()undfilterRecordsByAccess() - Routes: CRUD für
{Feature}Access - 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 (
adminRolle 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:
- Vollständige CRUD-Routes für alle Trustee-Entities
- Zwei-Stufen-RBAC (System + Feature-Level)
- Automatische Filterung basierend auf User-Access
- Options-Endpoints für alle Dropdowns
Das UI muss implementieren:
- Access-View zur Verwaltung von User-Zuweisungen
- Dynamische Dropdowns mit Abhängigkeiten (Contract nach Organisation)
- 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.tsxTrusteeRolesView.tsxTrusteeAccessView.tsxTrusteeContractsView.tsxTrusteeDocumentsView.tsxTrusteePositionsView.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