From a70581d9f8b9811b2908d41858089304d4f9247e Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Fri, 16 Jan 2026 22:14:01 +0100 Subject: [PATCH] mandate refactory --- .../implementation_concept.md | 827 ----- .../mandate_concept.md | 200 +- .../mandate_implementation_gateway.md | 3105 +++++++++++++++++ .../mandate_implementation_ui_myla.md | 1925 ++++++++++ .../ui_concept_nyla.md | 880 ----- 5 files changed, 5160 insertions(+), 1777 deletions(-) delete mode 100644 implementation/Saas Multi Tenant Mandate/implementation_concept.md create mode 100644 implementation/Saas Multi Tenant Mandate/mandate_implementation_gateway.md create mode 100644 implementation/Saas Multi Tenant Mandate/mandate_implementation_ui_myla.md delete mode 100644 implementation/Saas Multi Tenant Mandate/ui_concept_nyla.md diff --git a/implementation/Saas Multi Tenant Mandate/implementation_concept.md b/implementation/Saas Multi Tenant Mandate/implementation_concept.md deleted file mode 100644 index 2e40ab0..0000000 --- a/implementation/Saas Multi Tenant Mandate/implementation_concept.md +++ /dev/null @@ -1,827 +0,0 @@ -# Multi-Tenant SaaS Plattform - Implementierungskonzept - -## Technische Umsetzung für PowerOn Gateway - -**Version:** 1.0 -**Datum:** 14. Januar 2026 -**Status:** Entwurf -**Basis:** [mandate_concept.md](./mandate_concept.md) - ---- - -## 1. Übersicht - -### 1.1 Grundprinzip - -**Kernänderung:** User gehören grundsätzlich **keinem** Mandanten an. Die Zugehörigkeit wird über eine separate Membership-Tabelle (`UserMandate`) geregelt. - -``` -AKTUELL (IST): -User ──── mandateId ──→ Mandate (1:1) - -NEU (SOLL): -User ◄──── UserMandate ────► Mandate (m:n) - └─ roleLabels (mandantsspezifisch) -``` - -### 1.2 Konsequenzen - -| Aspekt | Aktuell | Neu | -|--------|---------|-----| -| User.mandateId | Pflichtfeld | Optional/Entfällt | -| Rollen | Global pro User | Pro Membership | -| Auth-Token | Enthält mandateId | Enthält nur userId | -| API-Calls | Implizit aus Token | Explizit pro Request | -| RBAC GROUP | User.mandateId | Request-Parameter | - ---- - -## 2. Feature-Analyse (Code-Review) - -### 2.1 Identifizierte Business-Features - -Basierend auf Code-Analyse (`gateway/modules/`) wurden folgende Features identifiziert, die in das neue Multi-Tenant-Modell überführt werden müssen: - -| Feature-Code | Name | Datenmodelle | Routes | Feature-Code | -|--------------|------|--------------|--------|--------------| -| `trustee` | **Treuhand** | `TrusteeOrganisation`, `TrusteeRole`, `TrusteeAccess`, `TrusteeContract`, `TrusteeDocument`, `TrusteePosition` | `routeDataTrustee.py` | `interfaceDbTrusteeObjects.py` | -| `chatbot` | **Chatbot** | `ChatWorkflow` (mode=Chatbot), `ChatMessage`, `ChatDocument` | `routeChatbot.py` | `features/chatbot/` | -| `workflow-dynamic` | **Dynamic Workflow** | `ChatWorkflow` (mode=Dynamic), `ChatMessage`, `ChatDocument`, `ChatLog`, `ChatStat` | `routeChatPlayground.py`, `routeWorkflows.py` | `features/workflow/` + `WorkflowManager` | -| `workflow-automation` | **Automation** | `AutomationDefinition`, `ChatWorkflow` (mode=Automation) | `routeDataAutomation.py`, `routeAdminAutomationEvents.py` | `features/workflow/` + `WorkflowManager` | -| `voice-center` | **Voice Center** | `VoiceSettings` | `routeVoiceGoogle.py` | `interfaceVoiceObjects.py` | -| `realestate` | **Immobilien** | `Projekt`, `Parzelle`, `Land`, `Kanton`, `Gemeinde`, `Dokument` | `routeRealEstate.py` | `interfaceDbRealEstateObjects.py` | - -### 2.2 Workflow-Modes und Feature-Zuordnung - -Der `WorkflowModeEnum` definiert verschiedene Modi: - -```python -# datamodelChat.py - Zeile 277-281 -class WorkflowModeEnum(str, Enum): - WORKFLOW_DYNAMIC = "Dynamic" # → Feature: workflow-dynamic (WorkflowManager) - WORKFLOW_AUTOMATION = "Automation" # → Feature: workflow-automation (WorkflowManager + Templates) - WORKFLOW_CHATBOT = "Chatbot" # → Feature: chatbot (eigener Code in features/chatbot/) - WORKFLOW_REACT = "React" # Legacy - kann entfallen -``` - -**Code-Struktur:** - -| Feature | Code-Basis | Beschreibung | -|---------|-----------|--------------| -| `chatbot` | `features/chatbot/mainChatbot.py` → `chatProcess()` | Einfacher Chat mit direkten AI-Calls | -| `workflow-dynamic` | `features/workflow/mainWorkflow.py` → `chatStart()` | Interaktiver KI-Workflow mit Task-Planning | -| `workflow-automation` | `features/workflow/mainWorkflow.py` → `executeAutomation()` | Zeitgesteuerte Workflows mit Templates | - -**Hinweis:** `routeChatPlayground.py` ist **keine eigenes Feature**, sondern eine Test-Route, die sowohl `workflow-dynamic` als auch `workflow-automation` aufrufen kann (via Query-Parameter `workflowMode`). - -**Begründung für Feature-Trennung:** -- Unterschiedliche Berechtigungen pro Mode möglich -- Unterschiedliche Instanzen pro Mandant (z.B. Mandant A hat Automation, Mandant B nicht) -- Unterschiedliche Pricing-Modelle - -### 2.3 System-Features (ohne Mandant) - -Diese Features sind **global** und erfordern **keinen Mandanten**: - -| Feature | Beschreibung | Routes | -|---------|--------------|--------| -| `user-profile` | Eigenes Profil bearbeiten | `routeDataUsers.py` | -| `user-settings` | Persönliche Einstellungen | `routeOptions.py` | -| `file-management` | Dateiverwaltung (mandantenübergreifend) | `routeDataFiles.py` | -| `connections` | Verbindungen (OAuth, APIs) | `routeDataConnections.py` | - -### 2.4 Admin-Features (nur sysadmin) - -Diese Features sind nur für System-Administratoren: - -| Feature | Beschreibung | Routes | -|---------|--------------|--------| -| `admin-mandates` | Mandanten verwalten | `routeDataMandates.py` | -| `admin-users` | Alle User verwalten | `routeDataUsers.py` | -| `admin-rbac` | RBAC-Regeln verwalten | `routeRbac.py`, `routeAdminRbacRoles.py` | -| `admin-security` | Security-Einstellungen | `routeSecurityAdmin.py` | - -### 2.5 Mandant-spezifische Felder in Datamodels - -Alle Business-Feature-Models haben ein `mandateId`-Feld: - -```python -# Beispiele aus dem Code: - -# datamodelChat.py - ChatWorkflow -mandateId: str = Field(description="ID of the mandate this workflow belongs to") - -# datamodelVoice.py - VoiceSettings -mandateId: str = Field(description="ID of the mandate these settings belong to") - -# datamodelTrustee.py - TrusteeOrganisation -mandateId: Optional[str] = Field(default=None, description="Mandate ID") - -# datamodelRealEstate.py - Projekt, Parzelle, etc. -mandateId: str = Field(description="ID of the mandate") -``` - -### 2.6 Zu ergänzen: Feature-Definition Model - -Neues Model für Feature-Registrierung: - -```python -# modules/datamodels/datamodelFeatures.py (NEU) - -class FeatureDefinition(BaseModel): - """Definition eines Features im System.""" - code: str = Field(description="Eindeutiger Feature-Code (z.B. 'trustee')") - label: Dict[str, str] = Field(description="I18n-Labels {'en': '...', 'de': '...'}") - icon: str = Field(description="Material Icon Name") - category: str = Field(description="Kategorie: 'business', 'system', 'admin'") - tables: List[str] = Field(description="Zugehörige DB-Tabellen") - routes: List[str] = Field(description="API-Route-Prefixe") - defaultRoles: List[str] = Field(description="Standard-Rollen für dieses Feature") - enabled: bool = Field(default=True) - - -class FeatureInstance(BaseModel): - """Instanz eines Features für einen Mandanten.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - featureCode: str = Field(description="Referenz auf FeatureDefinition.code") - mandateId: str = Field(description="Referenz auf Mandate.id") - instanceLabel: str = Field(description="Instanz-Name (z.B. 'Buchhaltung 2025')") - enabled: bool = Field(default=True) - settings: Dict[str, Any] = Field(default_factory=dict, description="Feature-spezifische Einstellungen") - - -class FeatureAccess(BaseModel): - """Zugriff eines Users auf eine Feature-Instanz.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - userId: str = Field(description="Referenz auf User.id") - featureInstanceId: str = Field(description="Referenz auf FeatureInstance.id") - roleLabel: str = Field(description="Rolle in dieser Feature-Instanz") - enabled: bool = Field(default=True) -``` - ---- - -## 3. Datenmodell-Änderungen - -### 3.1 Neues Model: `UserMandate` - -```python -# modules/datamodels/datamodelUam.py - -class UserMandate(BaseModel): - """Membership: Verknüpft User mit Mandanten und deren Rollen.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - userId: str = Field(description="Referenz auf User.id") - mandateId: str = Field(description="Referenz auf Mandate.id") - roleLabels: List[str] = Field( - default_factory=lambda: ["user"], - description="Rollen des Users in diesem Mandanten" - ) - enabled: bool = Field(default=True) - invitedBy: Optional[str] = Field(None, description="User-ID des Einladenden") - invitedAt: Optional[float] = Field(None, description="Zeitpunkt der Einladung") - acceptedAt: Optional[float] = Field(None, description="Zeitpunkt der Annahme") - # System-Felder: _createdAt, _modifiedAt, _createdBy, _modifiedBy -``` - -### 3.2 Änderung: `User` Model - -```python -# modules/datamodels/datamodelUam.py - -class User(BaseModel): - id: str = Field(...) - username: str = Field(...) - email: Optional[EmailStr] = Field(None) - fullName: Optional[str] = Field(None) - language: str = Field(default="en") - enabled: bool = Field(default=True) - # ENTFERNT: mandateId - User gehören keinem Mandanten - # ENTFERNT: roleLabels - Rollen sind mandantsspezifisch - authenticationAuthority: AuthAuthority = Field(default=AuthAuthority.LOCAL) - - # NEU: Transiente Felder (nicht in DB, werden zur Laufzeit befüllt) - currentMandateId: Optional[str] = Field(None, exclude=True) - currentRoleLabels: List[str] = Field(default_factory=list, exclude=True) -``` - -### 3.3 Änderung: `AccessRule` Model - -```python -# modules/datamodels/datamodelRbac.py - -class AccessRule(BaseModel): - id: str = Field(...) - roleLabel: str = Field(...) - context: AccessRuleContext = Field(...) - item: Optional[str] = Field(None) - view: bool = Field(False) - read: Optional[AccessLevel] = Field(None) - create: Optional[AccessLevel] = Field(None) - update: Optional[AccessLevel] = Field(None) - delete: Optional[AccessLevel] = Field(None) - # NEU: Mandant-spezifische Regeln - mandateId: Optional[str] = Field( - None, - description="Wenn gesetzt, gilt Regel nur für diesen Mandanten. None = Global." - ) -``` - ---- - -## 4. Authentifizierung & Kontext - -### 4.1 JWT Token (vereinfacht) - -**Aktuell:** -```json -{ - "sub": "admin", - "userId": "uuid-123", - "mandateId": "uuid-456", - "exp": 1234567890 -} -``` - -**Neu:** -```json -{ - "sub": "admin", - "userId": "uuid-123", - "exp": 1234567890 -} -``` - -Der Token enthält **kein mandateId** mehr, da der User keinem Mandanten angehört. - -### 4.2 Mandanten-Kontext per Request - -Der Mandanten-Kontext wird **pro API-Call** bestimmt: - -**Option A: Query-Parameter** -``` -GET /api/trustee/organisations?mandateId=uuid-456 -``` - -**Option B: Header** -``` -X-Mandate-Id: uuid-456 -``` - -**Option C: Implizit aus Daten** -``` -POST /api/trustee/contracts -Body: { "organisationId": "org-123" } -→ Backend ermittelt mandateId aus Organisation -``` - -### 4.3 Authentifizierung Flow - -``` -1. Login - └─ User authentifiziert sich - └─ Token enthält nur userId - └─ Response enthält Liste der Memberships - -2. API-Call - └─ Token wird validiert (userId) - └─ mandateId wird aus Request extrahiert - └─ Membership wird geprüft (userId + mandateId) - └─ Rollen werden aus UserMandate geladen - └─ RBAC wird mit diesen Rollen ausgeführt -``` - ---- - -## 5. RBAC-Anpassungen - -### 5.1 Regel-Auflösung (Mandant-aware) - -```python -# modules/security/rbac.py - -def _getRulesForRole(self, roleLabel: str, context: AccessRuleContext, mandateId: Optional[str] = None) -> List[AccessRule]: - """ - Lädt Regeln mit Mandant-Priorisierung: - 1. Mandant-spezifische Regeln (mandateId = X) - 2. Globale Regeln (mandateId = None) - - Spezifische überschreiben globale. - """ - allRules = self.dbApp.getRecordset( - AccessRule, - recordFilter={"roleLabel": roleLabel, "context": context.value} - ) - - # Sortiere: mandateId-spezifisch vor global - mandateRules = [r for r in allRules if r.get("mandateId") == mandateId] - globalRules = [r for r in allRules if r.get("mandateId") is None] - - # Spezifische haben Vorrang - rulesByItem = {} - for rule in globalRules: - rulesByItem[rule.get("item")] = rule - for rule in mandateRules: - rulesByItem[rule.get("item")] = rule # Überschreibt global - - return list(rulesByItem.values()) -``` - -### 5.2 GROUP-Filter anpassen - -```python -# modules/interfaces/interfaceRbac.py - -def buildRbacWhereClause( - permissions: UserPermissions, - currentUser: User, - table: str, - connector, - mandateId: str # NEU: Expliziter Parameter -) -> Optional[Dict[str, Any]]: - """ - GROUP filtert jetzt nach übergebenem mandateId, - nicht mehr nach currentUser.mandateId. - """ - if permissions.read == AccessLevel.GROUP: - if not mandateId: - return {"condition": "1 = 0", "values": []} - - return { - "condition": '"mandateId" = %s', - "values": [mandateId] - } - # ... rest bleibt gleich -``` - ---- - -## 6. Interface-Anpassungen - -### 6.1 Kontext ohne Pflicht-Mandat - -```python -# modules/interfaces/interfaceDbAppObjects.py - -def setUserContext(self, currentUser: User, mandateId: Optional[str] = None): - """ - Setzt User-Kontext. mandateId ist optional. - Wenn mandateId gesetzt, wird Membership geprüft. - """ - self.currentUser = currentUser - self.userId = currentUser.id - self.currentMandateId = mandateId - - if mandateId: - # Membership prüfen und Rollen laden - membership = self.getUserMandate(currentUser.id, mandateId) - if not membership: - raise PermissionError(f"User {currentUser.id} ist nicht Mitglied in Mandant {mandateId}") - - # Rollen aus Membership für RBAC verwenden - currentUser.currentRoleLabels = membership.roleLabels - else: - # Ohne Mandat: System-Rollen (sysadmin) oder leer - currentUser.currentRoleLabels = ["sysadmin"] if self._isSysAdmin(currentUser) else [] -``` - -### 6.2 Neue Membership-Methoden - -```python -# modules/interfaces/interfaceDbAppObjects.py - -def getUserMandates(self, userId: str) -> List[UserMandate]: - """Alle Mandanten-Mitgliedschaften eines Users.""" - records = self.db.getRecordset(UserMandate, recordFilter={"userId": userId}) - return [UserMandate(**r) for r in records] - -def getUserMandate(self, userId: str, mandateId: str) -> Optional[UserMandate]: - """Spezifische Mitgliedschaft prüfen.""" - records = self.db.getRecordset( - UserMandate, - recordFilter={"userId": userId, "mandateId": mandateId} - ) - return UserMandate(**records[0]) if records else None - -def getMandateMembers(self, mandateId: str) -> List[UserMandate]: - """Alle Mitglieder eines Mandanten.""" - records = self.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId}) - return [UserMandate(**r) for r in records] - -def addUserToMandate(self, userId: str, mandateId: str, roleLabels: List[str], invitedBy: str) -> UserMandate: - """Fügt User zu Mandant hinzu.""" - membership = UserMandate( - userId=userId, - mandateId=mandateId, - roleLabels=roleLabels, - invitedBy=invitedBy, - invitedAt=getUtcTimestamp() - ) - result = self.db.recordCreate(UserMandate, membership) - return UserMandate(**result) - -def removeUserFromMandate(self, userId: str, mandateId: str) -> bool: - """Entfernt User aus Mandant.""" - memberships = self.db.getRecordset( - UserMandate, - recordFilter={"userId": userId, "mandateId": mandateId} - ) - if memberships: - return self.db.recordDelete(UserMandate, memberships[0]["id"]) - return False - -def updateMembershipRoles(self, membershipId: str, roleLabels: List[str]) -> UserMandate: - """Aktualisiert Rollen einer Mitgliedschaft.""" - result = self.db.recordModify(UserMandate, membershipId, {"roleLabels": roleLabels}) - return UserMandate(**result) -``` - ---- - -## 6. API-Routen - -### 6.1 Neue Membership-Routen - -```python -# modules/routes/routeMandateMemberships.py - -router = APIRouter(prefix="/api/mandates", tags=["Mandate Memberships"]) - -@router.get("/my", response_model=List[MandateMembershipResponse]) -async def getMyMandates(currentUser: User = Depends(getCurrentUser)): - """Alle Mandanten des eingeloggten Users.""" - interface = getInterface(currentUser) - memberships = interface.getUserMandates(currentUser.id) - - result = [] - for m in memberships: - mandate = interface.getMandate(m.mandateId) - result.append({ - "membershipId": m.id, - "mandateId": m.mandateId, - "mandateName": mandate.name if mandate else "Unknown", - "roleLabels": m.roleLabels, - "enabled": m.enabled - }) - return result - -@router.get("/{mandateId}/members", response_model=List[MemberResponse]) -async def getMandateMembers( - mandateId: str, - currentUser: User = Depends(getCurrentUser) -): - """Alle Mitglieder eines Mandanten (nur für Admins).""" - interface = getInterface(currentUser, mandateId) - # Prüft automatisch Membership und admin-Rolle - return interface.getMandateMembers(mandateId) - -@router.post("/{mandateId}/members") -async def addMember( - mandateId: str, - data: AddMemberRequest, - currentUser: User = Depends(getCurrentUser) -): - """Fügt User zu Mandant hinzu (nur sysadmin oder mandant-admin).""" - interface = getInterface(currentUser, mandateId) - return interface.addUserToMandate( - userId=data.userId, - mandateId=mandateId, - roleLabels=data.roleLabels, - invitedBy=currentUser.id - ) - -@router.delete("/{mandateId}/members/{userId}") -async def removeMember( - mandateId: str, - userId: str, - currentUser: User = Depends(getCurrentUser) -): - """Entfernt User aus Mandant.""" - interface = getInterface(currentUser, mandateId) - return interface.removeUserFromMandate(userId, mandateId) - -@router.put("/{mandateId}/members/{userId}/roles") -async def updateMemberRoles( - mandateId: str, - userId: str, - data: UpdateRolesRequest, - currentUser: User = Depends(getCurrentUser) -): - """Aktualisiert Rollen eines Mitglieds.""" - interface = getInterface(currentUser, mandateId) - membership = interface.getUserMandate(userId, mandateId) - return interface.updateMembershipRoles(membership.id, data.roleLabels) -``` - -### 7.2 Anpassung bestehender Routen - -```python -# modules/routes/routeDataUsers.py - -@router.get("/") -async def getUsers( - mandateId: Optional[str] = Query(None), # Explizit, nicht aus Token - currentUser: User = Depends(getCurrentUser) -): - """ - Users abrufen. - - Ohne mandateId: Nur sysadmin sieht alle - - Mit mandateId: Mitglieder des Mandanten - """ - if mandateId: - interface = getInterface(currentUser, mandateId) - memberships = interface.getMandateMembers(mandateId) - userIds = [m.userId for m in memberships] - return interface.getUsersByIds(userIds) - else: - # Nur sysadmin - if not _isSysAdmin(currentUser): - raise HTTPException(403, "mandateId required for non-sysadmin") - interface = getInterface(currentUser) - return interface.getAllUsers() -``` - ---- - -## 8. RBAC Import/Export - -### 8.1 Export-Format (JSON) - -```json -{ - "version": "1.0", - "exportedAt": "2026-01-14T10:00:00Z", - "exportedBy": "admin", - "scope": { - "mandateId": "uuid-456", - "description": "Althaus Consulting - Custom Policies" - }, - "roles": [ - { - "roleLabel": "trustee-admin", - "description": {"en": "Trustee Administrator", "de": "Treuhand-Administrator"}, - "isSystemRole": false - } - ], - "rules": [ - { - "roleLabel": "trustee-admin", - "context": "DATA", - "item": "TrusteeContract", - "view": true, - "read": "g", - "create": "g", - "update": "g", - "delete": "g", - "mandateId": "uuid-456" - } - ] -} -``` - -### 8.2 Export-Endpoint - -```python -# modules/routes/routeRbac.py - -@router.get("/export") -async def exportRbac( - mandateId: Optional[str] = Query(None), - currentUser: User = Depends(getCurrentUser) -): - """ - Exportiert RBAC-Konfiguration. - - mandateId=None: Globale Regeln - - mandateId=X: Regeln für Mandant X - """ - interface = getInterface(currentUser, mandateId) - - roles = interface.getAllRoles() - rules = interface.getAccessRules(mandateId=mandateId) - - return { - "version": "1.0", - "exportedAt": getUtcTimestamp(), - "exportedBy": currentUser.username, - "scope": { - "mandateId": mandateId, - "description": "Global" if not mandateId else f"Mandate: {mandateId}" - }, - "roles": [r.model_dump() for r in roles], - "rules": [r.model_dump() for r in rules] - } -``` - -### 8.3 Import-Endpoint - -```python -@router.post("/import") -async def importRbac( - data: RbacImportRequest, - mode: str = Query("merge", description="merge oder replace"), - currentUser: User = Depends(getCurrentUser) -): - """ - Importiert RBAC-Konfiguration. - - mode=merge: Upsert (bestehende werden aktualisiert) - - mode=replace: Alle bestehenden Regeln des Scope löschen, dann importieren - """ - interface = getInterface(currentUser) - mandateId = data.scope.get("mandateId") - - # Nur sysadmin darf importieren - if not _isSysAdmin(currentUser): - raise HTTPException(403, "Only sysadmin can import RBAC") - - if mode == "replace" and mandateId: - # Lösche alle bestehenden Regeln für diesen Mandanten - interface.deleteAccessRulesByMandate(mandateId) - - imported = {"roles": 0, "rules": 0} - - # Rollen importieren - for roleData in data.roles: - existing = interface.getRoleByLabel(roleData["roleLabel"]) - if existing: - interface.updateRole(existing.id, Role(**roleData)) - else: - interface.createRole(Role(**roleData)) - imported["roles"] += 1 - - # Regeln importieren - for ruleData in data.rules: - ruleData["mandateId"] = mandateId # Scope setzen - - # Eindeutigkeit: roleLabel + context + item + mandateId - existing = interface.findAccessRule( - roleLabel=ruleData["roleLabel"], - context=ruleData["context"], - item=ruleData.get("item"), - mandateId=mandateId - ) - - if existing: - interface.updateAccessRule(existing.id, AccessRule(**ruleData)) - else: - interface.createAccessRule(AccessRule(**ruleData)) - imported["rules"] += 1 - - return {"status": "success", "imported": imported} -``` - ---- - -## 9. Migration - -### 9.1 Datenbank-Migration - -```python -# migration_001_user_mandate.py - -def migrate(): - """ - Migriert bestehende User.mandateId zu UserMandate-Einträgen. - """ - db = getRootDbConnector() - - # 1. UserMandate-Tabelle erstellen - db._ensureTableExists(UserMandate) - - # 2. Bestehende User migrieren - users = db.getRecordset(UserInDB) - - for user in users: - mandateId = user.get("mandateId") - if mandateId: - # Prüfen ob Membership existiert - existing = db.getRecordset( - UserMandate, - recordFilter={"userId": user["id"], "mandateId": mandateId} - ) - - if not existing: - # Membership erstellen - membership = UserMandate( - userId=user["id"], - mandateId=mandateId, - roleLabels=user.get("roleLabels", ["user"]), - enabled=True, - acceptedAt=getUtcTimestamp() - ) - db.recordCreate(UserMandate, membership) - print(f"Created membership for user {user['id']} in mandate {mandateId}") - - # 3. Optional: mandateId und roleLabels aus User entfernen - # (erst nach vollständigem Test der neuen Logik) - - print("Migration completed") -``` - -### 9.2 Abwärtskompatibilität - -Während der Übergangsphase: - -```python -def getCurrentUser(token: str = Depends(cookieAuth)) -> User: - """ - Lädt User und setzt Legacy-Kompatibilität. - """ - user = _loadUserFromToken(token) - - # Legacy: Falls Code noch user.mandateId erwartet - # Erstes Mandat aus Memberships als Default - memberships = getUserMandates(user.id) - if memberships: - user.mandateId = memberships[0].mandateId # Deprecated - user.roleLabels = memberships[0].roleLabels # Deprecated - - return user -``` - ---- - -## 9. Schrittweise Umsetzung - -### Phase 1: Datenmodell (Woche 1-2) -- [ ] `UserMandate` Model erstellen -- [ ] `AccessRule.mandateId` hinzufügen -- [ ] `User` Model anpassen (transiente Felder) -- [ ] Migrations-Script erstellen - -### Phase 2: Backend-Core (Woche 2-3) -- [ ] `interfaceDbAppObjects` Membership-Methoden -- [ ] `interfaceRbac` mandateId-Parameter -- [ ] `RbacClass` mandatsspezifische Regeln -- [ ] Auth-Token ohne mandateId - -### Phase 3: API-Routen (Woche 3-4) -- [ ] Membership-Routen erstellen -- [ ] Bestehende Routen anpassen (mandateId explizit) -- [ ] RBAC Import/Export Endpoints - -### Phase 4: Tests & Migration (Woche 4-5) -- [ ] Unit-Tests für Memberships -- [ ] Integration-Tests für RBAC -- [ ] Migration bestehender Daten -- [ ] Abwärtskompatibilität testen - -### Phase 5: Frontend (Woche 5-6) -- [ ] Mandanten-Auswahl UI -- [ ] Membership-Verwaltung -- [ ] RBAC Import/Export UI - ---- - -## 11. Betroffene Dateien - -### 11.1 Zu ändern - -| Datei | Änderungen | -|-------|------------| -| `datamodels/datamodelUam.py` | `UserMandate` Model, `User` anpassen | -| `datamodels/datamodelRbac.py` | `AccessRule.mandateId` | -| `auth/authentication.py` | Token ohne mandateId | -| `security/rbac.py` | Mandant-aware Regelauflösung | -| `interfaces/interfaceRbac.py` | mandateId-Parameter | -| `interfaces/interfaceDbAppObjects.py` | Membership-Methoden, Kontext-Logik | -| `interfaces/interfaceDbTrusteeObjects.py` | Mandant aus Request statt User | -| `interfaces/interfaceBootstrap.py` | Initiale Memberships | -| `routes/routeDataUsers.py` | mandateId explizit | -| `routes/routeDataMandates.py` | Membership-Filter | -| `routes/routeRbac.py` | Import/Export Endpoints | - -### 11.2 Neu zu erstellen - -| Datei | Beschreibung | -|-------|--------------| -| `routes/routeMandateMemberships.py` | Membership-API | -| `migrations/001_user_mandate.py` | Datenmigration | - -### 11.3 Tests anzupassen - -- `tests/unit/rbac/*` -- `tests/integration/rbac/*` -- `tests/functional/test*` - ---- - -## 12. Risiken & Mitigationen - -| Risiko | Mitigation | -|--------|------------| -| Breaking Changes | Übergangsphase mit Legacy-Kompatibilität | -| Datenverlust | Migration erstellt Memberships, löscht nichts | -| Performance | Membership-Cache pro Request | -| Komplexität | Schrittweise Umsetzung, Feature-Flags | - ---- - -## 13. Geklärte Design-Entscheidungen - -1. **Frontend-Handling:** Features als Objekte/Gruppen organisiert, generisches Handling für Instanzen. Berechtigungen werden **summarisch** pro Feature-Instanz geladen (ein Request), nicht pro Objekt/Feld. → Siehe [ui_concept_nyla.md](./ui_concept_nyla.md) - -2. **Default-Mandant:** User hat **keinen** Default-Mandanten. User ohne Mandant-Zugehörigkeit kann System-Basics nutzen (Login, Profil, Settings). Ein sysadmin hat typischerweise keinen Mandanten. - -3. **Einladungs-Flow:** Email-Link mit überwachtem Token. Admin kann Token jederzeit widerrufen/löschen. Token hat Ablaufzeit. - -4. **System-Rollen (sysadmin):** Global, nicht mandantsspezifisch. sysadmin wird direkt am User-Objekt markiert, nicht über Membership. diff --git a/implementation/Saas Multi Tenant Mandate/mandate_concept.md b/implementation/Saas Multi Tenant Mandate/mandate_concept.md index e187bf5..dd5e696 100644 --- a/implementation/Saas Multi Tenant Mandate/mandate_concept.md +++ b/implementation/Saas Multi Tenant Mandate/mandate_concept.md @@ -129,31 +129,6 @@ Ein Mandant kann **mehrere Instanzen** desselben Features haben: Jede Instanz hat eigene Daten und Benutzer-Berechtigungen. -### 3.4 Benutzer-Zuordnung: Das "Ein Account, viele Rollen"-Prinzip - -``` -Benutzerin: Shelly (shelly@universe.com) -│ -├─ Mandant: Althaus -│ ├─ Rolle im Mandant: Standard-Benutzer -│ └─ Zugriff auf: -│ ├─ Chatbot "Produkt-Support" → Rolle: Administrator -│ └─ Chatbot "HR-Assistent" → Rolle: Benutzer -│ -├─ Mandant: Soha Treuhand -│ ├─ Rolle im Mandant: Standard-Benutzer -│ └─ Zugriff auf: -│ └─ Treuhand "Buchhaltung 2025" → Rolle: Kunde (nur eigene Daten) -│ -└─ Mandant: Universe Corp - ├─ Rolle im Mandant: Mandanten-Administrator - └─ Zugriff auf: - └─ Alle Features im Mandant - -``` - -**Ergebnis:** Shelly hat **einen Account**, aber **verschiedene Rollen** in **drei Mandanten**. - --- ## 4. Benutzerrollen & Verantwortlichkeiten @@ -166,13 +141,25 @@ Benutzerin: Shelly (shelly@universe.com) - ✅ Erstellt und verwaltet Mandanten - ✅ Schaltet Features für Mandanten frei -- ✅ Weist Benutzer zu Mandanten zu -- ✅ Definiert und verwaltet Berechtigungs-Policies -- ✅ Hat Zugriff auf alle Daten (für Support/Wartung) +- ✅ Definiert und verwaltet globale Berechtigungs-Templates +- ✅ Verwaltet User-Accounts global (aktivieren/deaktivieren) +- ✅ Überwacht System-Health und Audit-Logs **Kann NICHT:** -- ❌ Automatisch in Feature-Instanzen arbeiten (muss sich explizit Zugriff geben) +- ❌ Mandant-Daten einsehen (keine Daten von Kunden, Verträgen, etc.) +- ❌ Sich selbst zu einem Mandanten hinzufügen (4-Augen-Prinzip) +- ❌ RBAC-Regeln eines spezifischen Mandanten ändern + +**Wichtig: Strikte Trennung System vs. Daten** + +Der SysAdmin hat **keinen Zugriff auf Mandant-Daten**. Dies ist eine bewusste Sicherheitsentscheidung: + +- 🔒 **Bank-konform:** Datenzugriff nur über explizite Rollen im Mandanten +- 📊 **4-Augen-Prinzip:** Wenn SysAdmin Datenzugriff braucht, muss ein Mandate-Admin ihn einladen +- 🔍 **Audit-Trail:** Alle Zugriffserteilungen sind nachvollziehbar + +**Beispiel:** SysAdmin "Patrick" muss Support für Mandant "Althaus" leisten. Der Mandate-Admin von "Althaus" fügt Patrick als regulären User hinzu. Patrick unterliegt dann der normalen RBAC-Prüfung und alle Aktionen werden auditiert. ### 4.2 Mandanten-Administrator @@ -182,15 +169,17 @@ Benutzerin: Shelly (shelly@universe.com) - ✅ Erstellt Feature-Instanzen im eigenen Mandant - ✅ Erstellt Einladungslinks für neue Benutzer +- ✅ Fügt bestehende User zum Mandanten hinzu - ✅ Vergibt Rollen innerhalb des Mandanten - ✅ Sieht alle Benutzer des eigenen Mandanten - ✅ Verwaltet Feature-Instanzen +- ✅ Exportiert/Importiert RBAC-Regeln für den eigenen Mandanten **Kann NICHT:** -- ❌ Benutzer zum Mandanten hinzufügen (nur System-Admin) - ❌ Features für den Mandanten freischalten (nur System-Admin) - ❌ Andere Mandanten sehen oder darauf zugreifen +- ❌ Globale RBAC-Templates ändern (nur System-Admin) **Isolation:** Ein Mandanten-Administrator sieht nur die Benutzer und Daten seines eigenen Mandanten. @@ -215,10 +204,11 @@ Jede Feature-Instanz hat eigene Rollen: | Rolle | Typische Berechtigungen | Beispiel | | --- | --- | --- | -| **Administrator** | Voller Zugriff auf alle Daten der Instanz | Chatbot-Admin sieht alle Konversationen | -| **Benutzer** | Zugriff auf Instanz-Daten, kann eigene Daten erstellen | Marketing-Mitarbeiter nutzt Chatbot | -| **Kunde** | Nur Zugriff auf eigene Daten | Treuhand-Kunde sieht nur eigene Buchhaltung | -| **Betrachter** | Nur Lesezugriff | Externer Berater sieht Berichte, kann nichts ändern | +| **Administrator** | Voller Zugriff auf alle Daten der Instanz (Scope "g") | Chatbot-Admin sieht alle Konversationen | +| **Benutzer** | Zugriff gemäss konfiguriertem Scope ("m" oder "g") | Scope "m": Nur eigene Daten; Scope "g": Alle Instanz-Daten | +| **Betrachter** | Nur Lesezugriff (Scope "m" oder "g") | Externer Berater sieht Berichte, kann nichts ändern | + +**Hinweis:** Der Datenzugriff wird über den **Scope** gesteuert (siehe Kap. 6.2), nicht über separate Rollen. Ein "Benutzer" mit Scope "m" sieht nur eigene Daten, mit Scope "g" alle Daten der Instanz. --- @@ -232,15 +222,15 @@ Patrick hat 3 eigene Firmen und nutzt für jede ein anderes Treuhandbüro. Gleic **Lösung:** ``` -Benutzer: Patrick (patrick@example.com) +Benutzer: Patrick ([patrick@example.com](mailto:patrick@example.com)) │ ├─ Mandant: Soha Treuhand (Treuhandbüro 1) -│ └─ Treuhand-Instanz "PamoCreate AG" → Rolle: Kunde -│ └─ Treuhand-Instanz "ValueOn AG" → Rolle: Kunde -│ └─ Treuhand-Instanz "PowerOn GmbH" → Rolle: Kunde +│ └─ Treuhand-Instanz "PamoCreate AG" → Rolle: Benutzer +│ └─ Treuhand-Instanz "ValueOn AG" → Rolle: Benutzer +│ └─ Treuhand-Instanz "PowerOn GmbH" → Rolle: Benutzer │ ├─ Mandant: SwissTreu (Treuhandbüro 2) -│ └─ Treuhand-Instanz "Firma X" → Rolle: Kunde +│ └─ Treuhand-Instanz "Firma X" → Rolle: Benutzer │ └─ Mandant: Althaus (Beratungsfirma) └─ Chatbot-Instanz "Management-Tool" → Rolle: Administrator @@ -262,7 +252,7 @@ Shelly arbeitet für ihre eigene Firma "Universe Corp", unterstützt aber auch a **Lösung:** ``` -Benutzerin: Shelly (shelly@universe.com) +Benutzerin: Shelly ([shelly@universe.com](mailto:shelly@universe.com)) │ ├─ Mandant: Universe Corp (eigene Firma) │ ├─ Rolle: Mandanten-Administrator @@ -270,10 +260,10 @@ Benutzerin: Shelly (shelly@universe.com) │ ├─ Mandant: Althaus (Kunde als Freelancerin) │ └─ Chatbot-Instanz "Produkt-Support" → Rolle: Administrator -│ └─ Chatbot-Instanz "HR-Assistent" → Rolle: Benutzer (nur für einen spezifischen Bot) +│ └─ Chatbot-Instanz "HR-Assistent" → Rolle: Benutzer │ └─ Mandant: Soha Treuhand (nutzt deren Treuhand-Service) - └─ Treuhand-Instanz "Universe Corp 2025" → Rolle: Kunde + └─ Treuhand-Instanz "Universe Corp 2025" → Rolle: Benutzer ``` @@ -287,34 +277,41 @@ Benutzerin: Shelly (shelly@universe.com) ### 5.3 Use Case 3: Treuhandbüro mit vielen Kunden **Situation:** -Das Treuhandbüro "Soha Treuhand" betreut 50 kleine Firmen. Jede Firma soll nur ihre eigenen Buchhaltungsdaten sehen. +Das Treuhandbüro "Soha Treuhand" betreut mehrere Firmen. Jede Firma soll nur ihre eigenen Buchhaltungsdaten sehen. -**Lösung:** +**Flexibilität bei der Instanz-Strukturierung:** +Das Treuhandbüro kann frei wählen, wie es die Feature-Instanzen ("Buchhaltungsinstanzen") strukturiert: + +- **Option A:** Eine Buchhaltungsinstanz pro Firma (z.B. "PamoCreate AG 2025") +- **Option B:** Eine Buchhaltungsinstanz pro Jahr mit mehreren Firmen (z.B. "Jahresbuchhaltung 2025") + +**Lösung (Option A – empfohlen für Datenisolation):** ``` Mandant: Soha Treuhand │ ├─ Feature: Treuhand (aktiviert) │ -├─ Feature-Instanz: "Buchhaltung 2025" -│ │ -│ ├─ Mitarbeiter "Anna" → Rolle: Administrator (sieht alle Kunden) -│ ├─ Mitarbeiter "Tom" → Rolle: Benutzer (sieht alle Kunden) -│ │ -│ ├─ Kunde "Patrick (PamoCreate AG)" → Rolle: Kunde (sieht nur PamoCreate) -│ ├─ Kunde "Shelly (Universe Corp)" → Rolle: Kunde (sieht nur Universe) -│ └─ ... 48 weitere Kunden ... +├─ Buchhaltungsinstanz: "PamoCreate AG 2025" +│ ├─ Mitarbeiter "Anna" → Rolle: Administrator +│ ├─ Patrick (Inhaber) → Rolle: Benutzer +│ └─ Lisa (Mitarbeiterin) → Rolle: Benutzer +│ +├─ Buchhaltungsinstanz: "Universe Corp 2025" +│ ├─ Mitarbeiter "Tom" → Rolle: Administrator +│ └─ Shelly (Inhaberin) → Rolle: Benutzer +│ +└─ ... weitere Buchhaltungsinstanzen ... ``` -**Datenzugriff:** +**Datenzugriff (Option A):** -- **Anna** (Administrator): Sieht alle 50 Firmen und deren Daten -- **Tom** (Benutzer): Sieht alle 50 Firmen und deren Daten -- **Patrick** (Kunde): Sieht **nur** Daten von PamoCreate AG -- **Shelly** (Kunde): Sieht **nur** Daten von Universe Corp +- **Anna** (Administrator): Sieht alle Daten der Instanz "PamoCreate AG 2025" +- **Patrick & Lisa** (Benutzer): Sehen alle Daten ihrer gemeinsamen Buchhaltungsinstanz (Scope "g") +- **Shelly** (Benutzer): Sieht nur Daten von "Universe Corp 2025" – keine Einsicht in andere Instanzen -**Technische Umsetzung:** Automatische Filterung basierend auf "Eigentümer"-Feld in der Datenbank. +**Vorteil:** Mehrere Mitarbeiter einer Firma können in der **gleichen Buchhaltungsinstanz** arbeiten und sehen automatisch die gemeinsamen Firmendaten. --- @@ -338,8 +335,8 @@ Für jede Rolle in einer Feature-Instanz wird der **Datenzugriff** definiert: | Scope | Name | Beschreibung | Beispiel | | --- | --- | --- | --- | -| **n** | None | Kein Zugriff | Kunde sieht keine Admin-Einstellungen | -| **m** | My | Nur eigene Daten | Kunde sieht nur eigene Rechnungen | +| **n** | None | Kein Zugriff | Benutzer sieht keine Admin-Einstellungen | +| **m** | My | Nur eigene Daten | Benutzer (Scope m) sieht nur eigene Rechnungen | | **g** | Group | Alle Daten der Instanz | Mitarbeiter sieht alle Kunden der Instanz | | **a** | All | Alle Daten des Features | Super-Admin sieht alle Instanzen | @@ -349,16 +346,20 @@ Für jede Rolle in einer Feature-Instanz wird der **Datenzugriff** definiert: Feature: Chatbot Instanz: "Produkt-Support" -Rolle "Kunde": +Rolle "Benutzer (eingeschränkt)": - Konversationen: Scope "m" (my) → Nur eigene Chats + - Connector-Daten: Scope "n" (none) → Kein Zugriff auf gemeinsame Daten - Einstellungen: Scope "n" (none) → Kein Zugriff Rolle "Benutzer": - - Konversationen: Scope "g" (group) → Alle Chats der Instanz + - Konversationen: Scope "m" (my) → Nur eigene Chats (Privacy by Default) + - Connector-Daten: Scope "g" (group) → Zugriff auf gemeinsame Datenquellen - Einstellungen: Scope "n" (none) → Kein Zugriff + - Hinweis: Chat-Freigabe für andere nur durch expliziten User-Consent (RBAC-Regel) Rolle "Administrator": - - Konversationen: Scope "g" (group) → Alle Chats der Instanz + - Konversationen: Scope "g" (group) → Alle Chats der Instanz (für Support) + - Connector-Daten: Scope "g" (group) → Voller Zugriff - Einstellungen: Scope "g" (group) → Voller Zugriff auf Einstellungen ``` @@ -383,11 +384,11 @@ Zusätzlich zur Tabellen-Ebene können einzelne **Datenfelder** geschützt werde **Was sind Policies?** Policies sind **konfigurierbare Regelsets**, die definieren, wer auf welche Daten zugreifen kann. -**Beispiel-Policy: "Chatbot Kunde"** +**Beispiel-Policy: "Chatbot Benutzer (Scope m)"** ``` -Policy-Name: chatbot_customer_policy -Gilt für: Rolle "Kunde" im Feature "Chatbot" +Policy-Name: chatbot_user_scope_m_policy +Gilt für: Rolle "Benutzer" mit Scope "m" im Feature "Chatbot" Regeln: 1. Tabelle "Konversationen" → Lesen erlaubt (Scope: my) @@ -429,6 +430,13 @@ Regeln: ``` + + ### 7.2 Neuer Benutzer wird eingeladen (Self-Service) ``` @@ -458,13 +466,20 @@ Regeln: ``` 1. System-Administrator weist Shelly zu neuem Mandanten zu - └─ User: shelly@universe.com (besteht bereits) + └─ User: +``` + +**Code-Block 7.3 (NEU):** + +``` +1. System-Administrator weist Shelly zu neuem Mandanten zu + └─ User: [shelly@universe.com](mailto:shelly@universe.com) (besteht bereits) └─ Neuer Mandant: "Soha Treuhand" └─ Rolle: "Standard-Benutzer" 2. Mandanten-Admin von "Soha Treuhand" vergibt Feature-Rolle └─ Feature-Instanz: "Buchhaltung 2025" - └─ Rolle: "Kunde" + └─ Rolle: "Benutzer" 3. Shelly erhält Email-Benachrichtigung @@ -533,7 +548,7 @@ Regeln: ### F: Kann ein Benutzer wirklich mit einem Account in mehreren Mandanten arbeiten? -**A:** Ja, das ist ein Kernfeature. Ein Benutzer hat einen globalen Account und kann in beliebig vielen Mandanten Mitglied sein. In jedem Mandanten kann er unterschiedliche Rollen haben. +**A:** Ja, das ist ein Kernfeature. Ein Benutzer hat einen globalen Account und kann in beliebig vielen Mandanten Mitglied sein. In jedem Mandanten kann er unterschiedliche Rollen haben. Ein User kann auch mit 0 Mandanten starten, dann ist er einfach registriert im System. ### F: Was passiert, wenn zwei Mandanten den gleichen Benutzer einladen wollen? @@ -592,6 +607,51 @@ Ein Benutzer kann beides gleichzeitig sein. 4. Benutzer, die nur in diesem Mandanten waren, verlieren Zugriff 5. Benutzer, die in anderen Mandanten sind, behalten diese Zugriffe +### F: Gibt es "Einzellizenzen" – kann ich mich ohne Mandant registrieren? + +**A:** Ja, das ist ein Kernfeature. Ein Benutzer hat grundsätzlich **keinen** Mandanten zugeordnet – er kann in beliebig viele Mandanten eingeladen werden (auch 0). Das Prinzip: + +- **Globaler Account:** Benutzer registriert sich auf der Plattform (ohne Mandantenzuordnung) +- **Einladung:** Benutzer wird von beliebigen Mandanten eingeladen und erhält dort Rollen +- **Flexibilität:** Ein Benutzer kann mit 0, 1 oder vielen Mandanten arbeiten + +Dies entspricht dem Discord-Prinzip: Ein Discord-Account existiert unabhängig von Servern – man kann Mitglied in 0 bis n Servern sein. + +### F: Ermöglicht das Mandantenmanagement später eine Chat-Funktion zwischen Benutzern? + +**A:** Ja, das ist eine mögliche Erweiterung. Das strukturierte Mandantenmanagement schafft die Grundlage für: + +- **Intra-Mandant-Kommunikation:** Chat zwischen Benutzern innerhalb eines Mandanten +- **Feature-Instanz-Kommunikation:** Austausch innerhalb einer Feature-Instanz (z.B. Team-Chat im Chatbot-Support) +- **Cross-Mandant-Messaging:** Optional für Benutzer, die in mehreren Mandanten aktiv sind + +Dies ist als Roadmap-Feature für spätere Phasen vorgesehen. + +### F: Wo finde ich eine detaillierte Liste aller Rollen mit Hierarchien und Berechtigungen? + +**A:** Eine vollständige Berechtigungsmatrix wird vor der Implementierung erstellt. Diese enthält: + +- Alle Rollen (System-Admin, Mandanten-Admin, Feature-Admin, Benutzer, Betrachter) +- Hierarchie-Ebenen und Vererbung +- Konkrete Berechtigungen pro Datentabelle und Feld +- Scope-Definitionen (n/m/g/a) pro Rolle und Feature + +**Hinweis:** Kapitel 4 (Benutzerrollen) und 6.2 (n/m/g/a-System) bieten bereits einen Überblick. Die detaillierte Matrix wird als separates Dokument vor dem Implementierungsstart finalisiert. + +### F: Gibt es ein gutes Praxisbeispiel für dieses Konzept? + +**A:** Ja, **Discord** ist ein hervorragendes Beispiel für exakt dieses Mandantenmanagement: + +| Unser Konzept | Discord-Äquivalent | +| --- | --- | +| Benutzer-Account (global) | Discord-Account | +| Mandant | Discord-Server | +| Feature-Instanz | Channel im Server | +| Mandanten-Admin | Server-Owner/Admin | +| Einladungslink | Server-Einladungslink | + +Bei Discord kann ein Benutzer mit einem Account in beliebig vielen Servern Mitglied sein – mit unterschiedlichen Rollen pro Server. Genau dieses Prinzip setzen wir um. + --- ## 10. Glossar @@ -606,4 +666,4 @@ Ein Benutzer kann beides gleichzeitig sein. | **Policy** | Konfigurierbare Berechtigungsregel | | **Scope (n/m/g/a)** | Datenzugriffs-Bereich (None/My/Group/All) | | **Isolation** | Strikte Trennung zwischen Mandanten | -| **Onboarding** | Prozess der Neukundenaufnahm | \ No newline at end of file +| **Onboarding** | Prozess der Neukundenaufnahme | \ No newline at end of file diff --git a/implementation/Saas Multi Tenant Mandate/mandate_implementation_gateway.md b/implementation/Saas Multi Tenant Mandate/mandate_implementation_gateway.md new file mode 100644 index 0000000..12ad12e --- /dev/null +++ b/implementation/Saas Multi Tenant Mandate/mandate_implementation_gateway.md @@ -0,0 +1,3105 @@ +# Multi-Tenant Gateway - Implementierungskonzept + +**Version:** 3.3 +**Datum:** 16. Januar 2026 +**Status:** Entwurf (Review-Feedback eingearbeitet) +**Basis:** [mandate_concept.md](./mandate_concept.md) + +> ⚠️ **GREENFIELD-Implementierung:** Keine Datenmigration, keine Backwards Compatibility, keine Fallback-Logik. Bestehende User-Mandate-Struktur wird vollständig ersetzt. + +### Scope dieses Konzepts + +Dieses Konzept definiert die **Code-Architektur und Datenmodelle** für Multi-Mandantenfähigkeit. + +**Enthalten:** +- Datenmodelle und Beziehungen +- RBAC-System und Berechtigungslogik +- API-Endpoints und Kontext-Handling +- Sicherheitsaspekte auf Code-Ebene + +**Nicht enthalten (out of scope):** +- **Testing:** Testkonzepte und Teststrategie werden separat dokumentiert +- **Datenbank-Performance:** Wird über DB-Skalierung (Read Replicas, Connection Pooling, etc.) auf Infrastruktur-Ebene gelöst, nicht im Code. Keine Limits für Rollen pro User, keine Archivierungsstrategie für Junction Tables - dies sind DB-Infrastruktur-Themen. +- **Organisatorische DSGVO-Themen:** Daten-Anonymisierung bei Export ist Verantwortung des Users/Mandanten, kein Code-Problem. Der generische Gateway stellt nur die technischen Endpoints bereit - Feature-spezifische Export-Logik (z.B. Trustee-Contracts mit Kundendaten) wird in den jeweiligen Features implementiert. +- **Consent-Tracking (DSGVO Art. 7):** Einwilligungs-Management ist nicht Scope des Gateway - wird separat in den Features behandelt, die personenbezogene Daten verarbeiten. + +**Breaking Changes (gewollt):** +- `User.mandateId` und `User.roleLabels` werden **entfernt** - dies ist bewusste Design-Entscheidung für das neue Multi-Mandant-Modell +- Alle bestehenden Code-Referenzen auf diese Felder müssen migriert werden + +--- + +## 1. Architektur-Übersicht + +### 1.1 Kernprinzip + +``` +User (global) + │ + ├── UserMandate ──► Mandate + │ └── roleIds: List[str] → Role.id[] + │ + └── FeatureAccess ──► FeatureInstance ──► Mandate + └── roleIds: List[str] → Role.id[] +``` + +**Kein User gehört zu einem Mandanten.** Zugehörigkeit wird über `UserMandate` und `FeatureAccess` gesteuert. + +### 1.2 Drei Ebenen von Funktionen + +| Ebene | Beschreibung | Beispiele | Berechtigung | +|-------|--------------|-----------|--------------| +| **System** | Grundfunktionen ohne Mandant | Login, Logout, Profil, Settings | Jeder eingeloggte User | +| **Business** | Features in Mandanten | Trustee, Chatbot, Workflow | Via FeatureAccess | +| **Admin** | Plattform-Verwaltung | User-, Mandate-, RBAC-Management | `isSysAdmin=true` am User | + +### 1.3 Bestehende Security-Infrastruktur (Gateway) + +| Feature | Status | Implementierung | +|---------|--------|-----------------| +| **Password Hashing** | ✅ Vorhanden | Argon2 via `passlib` (`CryptContext(schemes=["argon2"])`) | +| **JWT Token** | ✅ Vorhanden | HS256/HS384, httpOnly Cookies, SameSite=strict | +| **Token Revocation** | ✅ Vorhanden | DB-backed mit `sessionId`, Status active/revoked | +| **Rate Limiting** | ✅ Vorhanden | `slowapi.Limiter` auf allen Auth-Endpoints | +| **CSRF Protection** | ✅ Vorhanden | `CSRFMiddleware` in `app.py` | +| **Audit Logging** | ✅ Vorhanden | `auditLogger.py` mit daily rotation | + +--- + +## 2. Datenmodell + +### 2.1 Core Models + +```python +class User(BaseModel): + id: str + username: str + email: Optional[str] + isSysAdmin: bool = False # Globales Admin-Flag + enabled: bool = True + # KEIN mandateId, KEINE roleLabels direkt am User! + +class Mandate(BaseModel): + id: str + name: str + enabled: bool = True + # Kein rbacCacheVersion - Stateless Design, kein Cache +``` + +### 2.2 Role Model (Kontextgebunden) + +**Kernkonzept:** Eine Rolle existiert immer in einem spezifischen Kontext. Der Kontext ist **IMMUTABLE** nach Erstellung. + +```python +class Role(BaseModel): + """Rolle mit unveränderlichem Kontext""" + id: str + roleLabel: str # z.B. "admin", "user", "viewer" + description: Dict[str, str] # I18n + + # KONTEXT - IMMUTABLE nach Create (nur Create/Delete, kein Update!) + mandateId: Optional[str] # FK → Mandate (CASCADE DELETE) + featureInstanceId: Optional[str] # FK → FeatureInstance (CASCADE DELETE) + featureCode: Optional[str] # z.B. "trustee" - für Template-Rollen + + # Kontext-Typ (abgeleitet, für Klarheit) + # - mandateId=None, featureInstanceId=None → GLOBAL (Template) + # - mandateId=X, featureInstanceId=None → MANDATE + # - mandateId=X, featureInstanceId=Y → INSTANCE + + isSystemRole: bool = False # System-Rollen können nicht gelöscht werden +``` + +**RBAC-Enforcement für Role:** +```python +# Role.context-Felder (mandateId, featureInstanceId) haben NUR: +# - CREATE: Ja (beim Erstellen setzen) +# - READ: Ja +# - UPDATE: NEIN! → Wird vom RBAC-System blockiert +# - DELETE: Ja (ganze Role löschen) +``` + +### 2.3 Membership Models (Junction Tables für CASCADE DELETE) + +**Architektur:** Rollen werden über Junction Tables verknüpft, nicht als Array-Felder. Dies ermöglicht saubere CASCADE DELETE auf Datenbankebene. + +```python +class UserMandate(BaseModel): + """User-Mitgliedschaft in Mandant""" + id: str + userId: str # FK → User (CASCADE DELETE) + mandateId: str # FK → Mandate (CASCADE DELETE) + enabled: bool = True + # Rollen via Junction Table UserMandateRole + +class FeatureAccess(BaseModel): + """User-Zugriff auf Feature-Instanz""" + id: str + userId: str # FK → User (CASCADE DELETE) + featureInstanceId: str # FK → FeatureInstance (CASCADE DELETE) + enabled: bool = True + # Rollen via Junction Table FeatureAccessRole + +class UserMandateRole(BaseModel): + """Junction: UserMandate zu Role""" + id: str + userMandateId: str # FK → UserMandate (CASCADE DELETE) + roleId: str # FK → Role (CASCADE DELETE) + +class FeatureAccessRole(BaseModel): + """Junction: FeatureAccess zu Role""" + id: str + featureAccessId: str # FK → FeatureAccess (CASCADE DELETE) + roleId: str # FK → Role (CASCADE DELETE) +``` + +**Begründung für Junction Tables (statt Array-Felder):** +- PostgreSQL CASCADE DELETE funktioniert auf Foreign Keys, nicht auf Array-Elemente +- Saubere referentielle Integrität +- Performante Queries mit JOINs +- Standard-Pattern für m:n Beziehungen + +### 2.4 Feature Models + +```python +class Feature(BaseModel): + """Feature-Definition (global, z.B. 'trustee')""" + code: str # PK, z.B. "trustee" + label: Dict[str, str] # I18n + icon: str + +class FeatureInstance(BaseModel): + """Instanz eines Features in einem Mandanten""" + id: str + featureCode: str # FK → Feature + mandateId: str # FK → Mandate (CASCADE DELETE) + label: str # z.B. "Buchhaltung 2025" + enabled: bool = True + # Kein rbacCacheVersion - Stateless Design, kein Cache +``` + +### 2.5 AccessRule Model + +```python +class AccessRule(BaseModel): + """Berechtigungsregel für eine Rolle""" + id: str + roleId: str # FK → Role.id (CASCADE DELETE!) + context: str # "DATA" | "UI" | "RESOURCE" - IMMUTABLE! + item: Optional[str] # Dot-Notation (siehe 3.1) + view: bool = False + read: Optional[str] # "n" | "m" | "g" | "a" + create: Optional[str] + update: Optional[str] + delete: Optional[str] +``` + +**RBAC-Enforcement für AccessRule:** +```python +# AccessRule.context und roleId sind IMMUTABLE: +# - CREATE: Ja +# - READ: Ja +# - UPDATE auf context/roleId: NEIN! → Blockiert +# - UPDATE auf permissions (view, read, etc.): Ja +# - DELETE: Ja +``` + +### 2.6 Kontext-Vererbung + +``` +Role definiert den Scope: +├── Global Role (mandateId=None) → AccessRules gelten überall +├── Mandate Role (mandateId=X) → AccessRules gelten nur in Mandant X +└── Instance Role (mandateId=X, instanceId=Y) → AccessRules gelten nur in Instanz Y +``` + +**AccessRule erbt Scope von Role** - kein separates `mandateId`/`featureInstanceId` auf AccessRule nötig! + +--- + +## 3. RBAC-System + +### 3.1 Item-Notation (Dot-Separated) + +Alle berechtigungspflichtigen Elemente werden mit **Dot-Notation** definiert: + +| Context | Format | Beispiele | +|---------|--------|-----------| +| `DATA` | `table` oder `table.field` | `ChatWorkflow`, `User.email`, `TrusteeContract.amount` | +| `UI` | `component.group.name...` | `nav.trustee`, `dialog.settings.voice`, `button.export` | +| `RESOURCE` | `resource.category.name...` | `ai.model.anthropic`, `action.email.send`, `connector.sharepoint` | + +**Hierarchie:** Längster Match gewinnt. `trustee.contract` überschreibt `trustee`. + +### 3.2 IMMUTABLE Felder (Enforcement) + +**Kritisch:** Bestimmte Felder dürfen nach Erstellung NICHT mehr geändert werden: + +| Model | Feld | Create | Read | Update | Delete | +|-------|------|--------|------|--------|--------| +| `Role` | `mandateId` | ✅ | ✅ | ❌ | ✅ (ganzes Objekt) | +| `Role` | `featureInstanceId` | ✅ | ✅ | ❌ | ✅ (ganzes Objekt) | +| `AccessRule` | `context` | ✅ | ✅ | ❌ | ✅ (ganzes Objekt) | +| `AccessRule` | `roleId` | ✅ | ✅ | ❌ | ✅ (ganzes Objekt) | + +**Implementation auf zwei Ebenen:** + +**1. Application Level (erste Verteidigung):** +```python +# Im RBAC-System: Update auf immutable Felder blockieren +IMMUTABLE_FIELDS = { + "Role": ["mandateId", "featureInstanceId", "featureCode"], + "AccessRule": ["context", "roleId"] +} + +def validateUpdate(model: str, updateData: dict): + forbidden = IMMUTABLE_FIELDS.get(model, []) + for field in forbidden: + if field in updateData: + raise PermissionError(f"Field '{field}' is immutable on {model}") +``` + +**2. Database Level (zweite Verteidigung - verhindert Bypass):** +```sql +-- Trigger: Verhindert Änderung von immutable Feldern auf Role +CREATE OR REPLACE FUNCTION prevent_role_context_update() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD."mandateId" IS DISTINCT FROM NEW."mandateId" THEN + RAISE EXCEPTION 'mandateId is immutable on Role'; + END IF; + IF OLD."featureInstanceId" IS DISTINCT FROM NEW."featureInstanceId" THEN + RAISE EXCEPTION 'featureInstanceId is immutable on Role'; + END IF; + IF OLD."featureCode" IS DISTINCT FROM NEW."featureCode" THEN + RAISE EXCEPTION 'featureCode is immutable on Role'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER tr_role_immutable + BEFORE UPDATE ON "Role" + FOR EACH ROW EXECUTE FUNCTION prevent_role_context_update(); + +-- Trigger: Verhindert Änderung von immutable Feldern auf AccessRule +CREATE OR REPLACE FUNCTION prevent_accessrule_context_update() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD."context" IS DISTINCT FROM NEW."context" THEN + RAISE EXCEPTION 'context is immutable on AccessRule'; + END IF; + IF OLD."roleId" IS DISTINCT FROM NEW."roleId" THEN + RAISE EXCEPTION 'roleId is immutable on AccessRule'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER tr_accessrule_immutable + BEFORE UPDATE ON "AccessRule" + FOR EACH ROW EXECUTE FUNCTION prevent_accessrule_context_update(); +``` + +### 3.3 Regel-Ebenen und Priorisierung + +``` +Priorisierung (höher überschreibt niedriger): + +1. Instance Role (Role.featureInstanceId gesetzt) → höchste Priorität +2. Mandate Role (Role.mandateId gesetzt, featureInstanceId=None) +3. Global Role (Role.mandateId=None) → niedrigste Priorität +``` + +### 3.4 Standard-Rollen pro Feature + +Beim Erstellen einer FeatureInstance werden **Rollen und deren AccessRules kopiert**: + +```python +def createFeatureInstance(featureCode: str, mandateId: str, label: str): + """ + Erstellt eine neue Feature-Instanz und kopiert Template-Rollen. + + WICHTIG: Templates werden NUR bei Erstellung kopiert. + Spätere Template-Änderungen werden NICHT automatisch propagiert. + Für manuelle Nachsynchronisation siehe syncRolesFromTemplate(). + """ + instance = FeatureInstance(featureCode=featureCode, mandateId=mandateId, label=label) + db.create(instance) + + # Globale Template-Rollen für dieses Feature finden + globalRoles = db.getRoles(featureCode=featureCode, mandateId=None) + templateRoleIds = [r.id for r in globalRoles] + + if not templateRoleIds: + return instance + + # BULK: Alle Template-AccessRules in einem Query laden + allTemplateRules = db.execute( + 'SELECT * FROM "AccessRule" WHERE "roleId" = ANY(:roleIds)', + {"roleIds": templateRoleIds} + ) + + # Index für schnellen Lookup: roleId -> rules + rulesByRoleId = {} + for rule in allTemplateRules: + roleId = rule["roleId"] + if roleId not in rulesByRoleId: + rulesByRoleId[roleId] = [] + rulesByRoleId[roleId].append(rule) + + # Neue Rollen und AccessRules sammeln für Bulk-Insert + newRoles = [] + newAccessRules = [] + + for templateRole in globalRoles: + newRoleId = str(uuid.uuid4()) + + newRoles.append({ + "id": newRoleId, + "roleLabel": templateRole.roleLabel, + "description": templateRole.description, + "featureCode": templateRole.featureCode, + "mandateId": mandateId, + "featureInstanceId": instance.id, + "isSystemRole": False + }) + + # AccessRules für diese Rolle vorbereiten + templateRulesForRole = rulesByRoleId.get(templateRole.id, []) + for rule in templateRulesForRole: + newAccessRules.append({ + "id": str(uuid.uuid4()), + "roleId": newRoleId, + "context": rule["context"], + "item": rule["item"], + "view": rule["view"], + "read": rule["read"], + "create": rule["create"], + "update": rule["update"], + "delete": rule["delete"] + }) + + # BULK INSERT: Alle neuen Rollen und AccessRules + if newRoles: + db.bulkInsert("Role", newRoles) + if newAccessRules: + db.bulkInsert("AccessRule", newAccessRules) + + return instance +``` + +### 3.5 Template-Rollen Synchronisation + +**Problem:** Wenn globale Template-Rollen aktualisiert werden, sind bestehende Feature-Instanzen nicht automatisch aktuell. + +**Lösung:** Explizite Sync-Funktion für Mandant-Admins oder bei Template-Updates. + +```python +async def syncRolesFromTemplate(featureInstanceId: str, addOnly: bool = True): + """ + Synchronisiert Rollen einer Feature-Instanz mit den aktuellen Templates. + + WICHTIG: Templates werden NUR bei Erstellung einer neuen FeatureInstance kopiert. + Diese Sync-Funktion ist für manuelle Nachsynchronisation gedacht, nicht für + automatische Propagation von Template-Änderungen. + + Args: + featureInstanceId: ID der zu synchronisierenden Instanz + addOnly: Wenn True, werden nur fehlende Rollen hinzugefügt. + Wenn False, werden auch überzählige Rollen entfernt. + + Returns: + SyncResult mit added/removed/unchanged Counts + """ + instance = db.getFeatureInstance(featureInstanceId) + if not instance: + raise ValueError(f"FeatureInstance {featureInstanceId} not found") + + # Aktuelle Template-Rollen für dieses Feature + templateRoles = db.getRoles(featureCode=instance.featureCode, mandateId=None) + templateLabels = {r.roleLabel for r in templateRoles} + templateRoleIds = [r.id for r in templateRoles] + + # Aktuelle Instanz-Rollen + instanceRoles = db.getRoles(featureInstanceId=featureInstanceId) + instanceLabels = {r.roleLabel for r in instanceRoles} + + result = {"added": 0, "removed": 0, "unchanged": 0} + + async with db.transaction() as tx: + # BULK: Alle Template-AccessRules in einem Query laden + allTemplateRules = [] + if templateRoleIds: + allTemplateRules = await tx.execute( + 'SELECT * FROM "AccessRule" WHERE "roleId" = ANY(:roleIds)', + {"roleIds": templateRoleIds} + ) + + # Index für schnellen Lookup: roleId -> rules + rulesByRoleId = {} + for rule in allTemplateRules: + roleId = rule["roleId"] + if roleId not in rulesByRoleId: + rulesByRoleId[roleId] = [] + rulesByRoleId[roleId].append(rule) + + # Neue Rollen und AccessRules sammeln für Bulk-Insert + newRoles = [] + newAccessRules = [] + roleIdMapping = {} # templateRoleId -> newRoleId + + for templateRole in templateRoles: + if templateRole.roleLabel not in instanceLabels: + # Neue Rolle vorbereiten + newRoleId = str(uuid.uuid4()) + roleIdMapping[templateRole.id] = newRoleId + + newRoles.append({ + "id": newRoleId, + "roleLabel": templateRole.roleLabel, + "description": templateRole.description, + "featureCode": templateRole.featureCode, + "mandateId": instance.mandateId, + "featureInstanceId": featureInstanceId, + "isSystemRole": False + }) + + # AccessRules für diese Rolle vorbereiten + templateRulesForRole = rulesByRoleId.get(templateRole.id, []) + for rule in templateRulesForRole: + newAccessRules.append({ + "id": str(uuid.uuid4()), + "roleId": newRoleId, + "context": rule["context"], + "item": rule["item"], + "view": rule["view"], + "read": rule["read"], + "create": rule["create"], + "update": rule["update"], + "delete": rule["delete"] + }) + + result["added"] += 1 + else: + result["unchanged"] += 1 + + # BULK INSERT: Alle neuen Rollen + if newRoles: + await tx.bulkInsert("Role", newRoles) + + # BULK INSERT: Alle neuen AccessRules + if newAccessRules: + await tx.bulkInsert("AccessRule", newAccessRules) + + # Überzählige Rollen entfernen (optional) + if not addOnly: + for instanceRole in instanceRoles: + if instanceRole.roleLabel not in templateLabels: + # Prüfen ob Rolle noch verwendet wird + usages = await tx.getRecordset( + FeatureAccessRole, + recordFilter={"roleId": instanceRole.id} + ) + if not usages: + await tx.delete(Role, instanceRole.id) + result["removed"] += 1 + + await tx.commit() + + logger.info(f"Synced roles for instance {featureInstanceId}: {result}") + return result + + +# Endpoint für Mandate-Admin +@router.post("/api/features/instances/{instanceId}/sync-roles") +@limiter.limit("10/minute") +async def syncInstanceRoles( + instanceId: str, + addOnly: bool = Query(True, description="Only add missing roles, don't remove extras"), + currentUser: User = Depends(getCurrentUser), + ctx: RequestContext = Depends(getRequestContext) +): + """Synchronisiert Rollen einer Feature-Instanz mit Templates.""" + # Nur Mandate-Admin oder Feature-Admin darf synchronisieren + if not hasAdminRole(ctx, instanceId): + raise HTTPException(403, "Admin role required") + + result = await syncRolesFromTemplate(instanceId, addOnly) + return result +``` + +### 3.6 RBAC - Stateless Design (Kein Cache) + +**Prinzip:** Gateway ist vollständig **stateless** - wie JWT Session-Handling. + +**Warum kein Cache:** +- Gateway läuft mit **Load Balancing** (mehrere Cloud-Instanzen) +- Horizontale Skalierung erfolgt über Cloud-Infrastruktur, nicht im Code +- Keine zusätzliche Komponente (Redis) nötig +- PostgreSQL ist die einzige Source of Truth + +**Performance durch optimierte Queries:** +- Bulk-Query mit JOIN (siehe 3.7) statt N+1 Queries +- Richtige Indizes auf Foreign Keys +- Query-Zeit typisch <10ms +- **Datenbank-Skalierung ist Infrastruktur-Aufgabe**, nicht Code-Aufgabe (Read Replicas, Connection Pooling, etc.) + +```python +# KEIN globaler Cache! +# Jeder Request lädt RBAC-Regeln frisch aus der Datenbank. +# Das ist by design: Stateless = Cloud-Ready + +# Optional: Request-Scoped Cache (nur innerhalb EINES Requests) +class RequestContext: + """Kontext für einen einzelnen Request - wird nicht persistiert.""" + + def __init__(self): + self.user: Optional[User] = None + self.mandateId: Optional[str] = None + self.featureInstanceId: Optional[str] = None + self.roleIds: List[str] = [] + + # Request-Scoped: Regeln nur einmal pro Request laden + self._cachedRules: Optional[List[AccessRule]] = None + + def getRules(self) -> List[AccessRule]: + """Lädt Regeln einmal pro Request (nicht über Requests hinweg).""" + if self._cachedRules is None: + self._cachedRules = getRulesForUserBulk( + self.user.id, + self.mandateId, + self.featureInstanceId + ) + return self._cachedRules +``` + +**Vorteile des Stateless-Designs:** +- ✅ Keine Cache-Invalidierung nötig +- ✅ Keine Inkonsistenzen zwischen Instanzen +- ✅ Keine zusätzliche Infrastruktur (Redis) +- ✅ Einfaches Deployment (nur Gateway + PostgreSQL) +- ✅ RBAC-Änderungen sind sofort wirksam + +**Read Replica Handling:** +Bei Verwendung von Read Replicas kann es zu Replication Lag kommen. +Kritische RBAC-Reads (nach Berechtigungsänderungen) müssen vom **Primary** erfolgen: + +```python +# RBAC-Queries immer vom Primary lesen +# Verhindert stale reads nach Berechtigungsänderungen +def getRulesForUserBulk(userId: str, mandateId: str, + featureInstanceId: Optional[str] = None, + usePrimary: bool = True) -> List[Tuple[int, AccessRule]]: + """ + Lädt alle relevanten Regeln für einen User. + + Args: + usePrimary: Wenn True, wird Primary-DB verwendet (verhindert Replication Lag). + Default True für sicherheitskritische RBAC-Queries. + """ + db = getPrimaryConnection() if usePrimary else getReplicaConnection() + # ... rest of implementation +``` + +**Wichtig: DB-Indizes für Performance** +```sql +-- ============================================ +-- CORE RBAC INDIZES +-- ============================================ + +-- UserMandate: Lookup by User (alle Mandanten eines Users) +CREATE INDEX idx_usermandate_user ON "UserMandate"("userId"); +-- UserMandate: Lookup by User+Mandate (Mitgliedschaft prüfen) +CREATE INDEX idx_usermandate_user_mandate ON "UserMandate"("userId", "mandateId"); +-- UserMandate: Lookup by Mandate (alle User eines Mandanten) +CREATE INDEX idx_usermandate_mandate ON "UserMandate"("mandateId"); +-- UserMandate: Partial Index für aktive Memberships (häufigster Query-Fall) +CREATE INDEX idx_usermandate_user_enabled ON "UserMandate"("userId") WHERE "enabled" = true; + +-- UserMandateRole: Lookup by UserMandate (Rollen laden) +CREATE INDEX idx_usermandaterole_usermandate ON "UserMandateRole"("userMandateId"); +-- UserMandateRole: Lookup by Role (wer hat diese Rolle?) +CREATE INDEX idx_usermandaterole_role ON "UserMandateRole"("roleId"); + +-- FeatureAccess: Lookup by User+Instance (Zugang prüfen) +CREATE INDEX idx_featureaccess_user_instance ON "FeatureAccess"("userId", "featureInstanceId"); +-- FeatureAccess: Lookup by User (alle Instanzen eines Users) +CREATE INDEX idx_featureaccess_user ON "FeatureAccess"("userId"); +-- FeatureAccess: Lookup by Instance (alle User einer Instanz) +CREATE INDEX idx_featureaccess_instance ON "FeatureAccess"("featureInstanceId"); + +-- FeatureAccessRole: Lookup by FeatureAccess (Rollen laden) +CREATE INDEX idx_featureaccessrole_featureaccess ON "FeatureAccessRole"("featureAccessId"); +-- FeatureAccessRole: Lookup by Role (wer hat diese Rolle?) +CREATE INDEX idx_featureaccessrole_role ON "FeatureAccessRole"("roleId"); + +-- ============================================ +-- ACCESSRULE INDIZES +-- ============================================ + +-- AccessRule: Bulk-Load by Role (Hauptquery) +CREATE INDEX idx_accessrule_roleid ON "AccessRule"("roleId"); +-- AccessRule: Lookup by Context+Role (gefilterte Queries) +CREATE INDEX idx_accessrule_context_roleid ON "AccessRule"("context", "roleId"); + +-- ============================================ +-- ROLE INDIZES +-- ============================================ + +-- Role: Lookup by Mandate+Instance (Instanz-Rollen) +CREATE INDEX idx_role_mandate_instance ON "Role"("mandateId", "featureInstanceId"); +-- Role: Lookup by FeatureCode (Template-Rollen finden) +CREATE INDEX idx_role_featurecode ON "Role"("featureCode") WHERE "mandateId" IS NULL; +-- Role: Lookup by Label (Rolle nach Name finden) +CREATE INDEX idx_role_label ON "Role"("roleLabel"); + +-- ============================================ +-- FEATUREINSTANCE INDIZES +-- ============================================ + +-- FeatureInstance: Lookup by Mandate (alle Instanzen eines Mandanten) +CREATE INDEX idx_featureinstance_mandate ON "FeatureInstance"("mandateId"); +-- FeatureInstance: Lookup by Mandate+FeatureCode (gefiltert) +CREATE INDEX idx_featureinstance_mandate_code ON "FeatureInstance"("mandateId", "featureCode"); +``` + +def onRbacChange(mandateId: str, featureInstanceId: Optional[str] = None): + """ + Wird aufgerufen wenn RBAC-Regeln geändert werden. + + Bei Stateless-Design: Keine Aktion nötig! + Änderungen sind sofort wirksam, da jeder Request frisch aus DB lädt. + + Diese Funktion existiert als Hook für: + - Audit-Logging von RBAC-Änderungen + - Zukünftige Erweiterungen (z.B. Notifications) + """ + from modules.shared.auditLogger import audit_logger + audit_logger.logSecurityEvent( + userId="system", + mandateId=mandateId, + action="rbac_change", + details=f"featureInstanceId={featureInstanceId}" if featureInstanceId else "mandate-level" + ) +``` + +### 3.7 Optimierte Regel-Auflösung (Bulk Query) + +**Problem:** N+1 Queries bei vielen Rollen. +**Lösung:** Bulk-Query mit JOIN. + +```python +def getRulesForUserBulk(userId: str, mandateId: str, + featureInstanceId: Optional[str] = None) -> List[Tuple[int, AccessRule]]: + """ + Lädt alle relevanten Regeln für einen User in EINEM Query. + Stateless: Kein Cache, direkt aus DB. + + Returns: + Liste von (priority, AccessRule) Tupeln + """ + # 1. Alle roleIds für User sammeln via Junction Tables + roleIds = set() + + # Mandant-Rollen via UserMandate → UserMandateRole + query = """ + SELECT umr."roleId" + FROM "UserMandate" um + JOIN "UserMandateRole" umr ON umr."userMandateId" = um.id + WHERE um."userId" = :userId AND um."mandateId" = :mandateId AND um."enabled" = true + """ + mandateRoles = db.execute(query, {"userId": userId, "mandateId": mandateId}) + roleIds.update(r["roleId"] for r in mandateRoles) + + # Instanz-Rollen via FeatureAccess → FeatureAccessRole + if featureInstanceId: + query = """ + SELECT far."roleId" + FROM "FeatureAccess" fa + JOIN "FeatureAccessRole" far ON far."featureAccessId" = fa.id + WHERE fa."userId" = :userId AND fa."featureInstanceId" = :instanceId AND fa."enabled" = true + """ + instanceRoles = db.execute(query, {"userId": userId, "instanceId": featureInstanceId}) + roleIds.update(r["roleId"] for r in instanceRoles) + + if not roleIds: + return [] + + # 2. BULK Query: Alle Regeln für alle Rollen + zugehörige Role-Daten + # SINGLE Query mit JOIN statt N+1 + query = """ + SELECT ar.*, r."mandateId" as "roleMandateId", + r."featureInstanceId" as "roleInstanceId" + FROM "AccessRule" ar + JOIN "Role" r ON ar."roleId" = r.id + WHERE ar."roleId" = ANY(:roleIds) + """ + allRulesWithContext = db.execute(query, {"roleIds": list(roleIds)}) + + # 3. Priorität zuweisen basierend auf Role-Scope + rulesWithPriority = [] + for rule in allRulesWithContext: + if rule["roleInstanceId"]: + priority = 3 # Instance-Rolle = höchste Priorität + elif rule["roleMandateId"]: + priority = 2 # Mandate-Rolle + else: + priority = 1 # Global-Rolle = niedrigste Priorität + rulesWithPriority.append((priority, AccessRule(**rule))) + + return rulesWithPriority +``` + +--- + +## 4. System-Funktionen (Ohne Mandant) + +### 4.1 Immer verfügbare Endpoints + +Diese Funktionen benötigen **keinen Mandanten**: + +```python +# Keine RBAC-Prüfung auf Mandant/Feature-Ebene + +@router.get("/auth/me") # Eigener User +@router.put("/auth/profile") # Profil bearbeiten +@router.put("/auth/settings") # Einstellungen ändern +@router.post("/auth/logout") # Abmelden +@router.get("/mandates/my") # Meine Mandanten-Liste +@router.get("/features/my") # Meine Feature-Instanzen +``` + +### 4.2 Implementation + +```python +def getCurrentUser(token) -> User: + """Validiert Token, lädt User. Kein Mandant-Kontext.""" + userId = validateToken(token) + return db.getUser(userId) + +# System-Endpoints prüfen nur: User eingeloggt +@router.get("/auth/me") +async def getMe(currentUser: User = Depends(getCurrentUser)): + return currentUser +``` + +--- + +## 5. Admin-Funktionen (SysAdmin) + +### 5.1 Design-Entscheidung: Strikte Trennung System vs. Daten + +**Einfaches Prinzip:** `isSysAdmin=true` bedeutet **System-Zugriff, KEIN Daten-Zugriff**. + +```python +class User(BaseModel): + isSysAdmin: bool = False # Globales Admin-Flag + # isSysAdmin=true → Kann System verwalten, aber KEINE Mandant-Daten sehen +``` + +**Warum diese Trennung?** +- 🔒 **Einfach & sicher:** Kein komplexes 4-Augen-System nötig +- 📊 **Klare Verantwortung:** SysAdmin = Infrastruktur, User = Daten +- 🏦 **Bank-konform:** Datenzugriff nur über explizite Rollen, nie über Admin-Privileg +- 🔍 **Auditierbar:** Wenn SysAdmin Daten braucht → normaler User mit Audit-Trail + +### 5.2 Was SysAdmin KANN (System-Ebene) + +```python +def requireSysAdmin(currentUser: User = Depends(getCurrentUser)): + """SysAdmin-Check für System-Level Operationen.""" + if not currentUser.isSysAdmin: + raise HTTPException(403, "SysAdmin required") + + # Audit für alle SysAdmin-Aktionen + audit_logger.logSecurityEvent( + userId=str(currentUser.id), + mandateId="system", + action="sysadmin_action", + details="System-level operation" + ) + + return currentUser + +# ✅ SysAdmin KANN: +@router.get("/admin/mandates") +async def listAllMandates(admin: User = Depends(requireSysAdmin)): + """Liste aller Mandanten (nur Metadaten: id, name, enabled).""" + return db.getAllMandates(fields=["id", "name", "enabled", "createdAt"]) + +@router.post("/admin/mandates") +async def createMandate(data: MandateCreate, admin: User = Depends(requireSysAdmin)): + """Neuen Mandanten erstellen.""" + return db.createMandate(data) + +@router.delete("/admin/mandates/{mandateId}") +async def deleteMandate(mandateId: str, admin: User = Depends(requireSysAdmin)): + """Mandant löschen (Struktur, nicht Daten-Einblick).""" + # Prüft nur ob Mandant existiert, sieht keine Inhalte + return db.deleteMandate(mandateId) + +@router.get("/admin/users") +async def listAllUsers(admin: User = Depends(requireSysAdmin)): + """Liste aller User (nur Metadaten: id, username, email, enabled).""" + return db.getAllUsers(fields=["id", "username", "email", "enabled", "isSysAdmin"]) + +@router.post("/admin/users/{userId}/enable") +@router.post("/admin/users/{userId}/disable") +async def toggleUserEnabled(userId: str, admin: User = Depends(requireSysAdmin)): + """User aktivieren/deaktivieren.""" + +@router.get("/admin/rbac/global/export") +async def exportGlobalRbac(admin: User = Depends(requireSysAdmin)): + """Exportiert globale (Template) RBAC-Regeln.""" + return exportRbac(scope="global") + +@router.post("/admin/rbac/global/import") +async def importGlobalRbac(data: RbacImport, admin: User = Depends(requireSysAdmin)): + """Importiert globale RBAC-Regeln.""" +``` + +### 5.3 Was SysAdmin NICHT KANN (Daten-Ebene) + +```python +# ❌ SysAdmin kann NICHT: +# - Mandant-Daten lesen (TrusteeContracts, ChatWorkflows, etc.) +# - User-Rollen innerhalb eines Mandanten sehen +# - Feature-Instanz-Inhalte einsehen +# - RBAC-Regeln eines spezifischen Mandanten exportieren + +# Enforcement in RBAC-Check: +def checkPermission(ctx: RequestContext, context: str, item: str, action: str) -> AccessLevel: + """RBAC-Prüfung mit SysAdmin-Blockade für Mandant-Daten.""" + + # KRITISCH: SysAdmin hat KEINEN Zugriff auf Mandant-Daten! + if ctx.user.isSysAdmin and ctx.mandateId: + # SysAdmin versucht auf Mandant-Daten zuzugreifen + # Prüfe ob er auch reguläre Rollen im Mandanten hat + if not ctx.roleIds: + # Keine Rollen im Mandanten → kein Zugriff + logger.warning( + f"SysAdmin {ctx.user.id} blocked from mandate {ctx.mandateId} data" + ) + return AccessLevel.NONE + + # Normale RBAC-Auflösung für alle (inkl. SysAdmin mit Rollen) + rules = getRulesForUserBulk(ctx.user.id, ctx.mandateId, ctx.featureInstanceId) + + # ... Rest der Permission-Logik +``` + +### 5.4 SysAdmin braucht Datenzugriff? → Muss Mandate-Admin werden + +**Design-Entscheidung:** SysAdmin kann sich **NICHT selbst** zu einem Mandanten hinzufügen. + +Wenn ein SysAdmin auf Mandant-Daten zugreifen muss: +1. Ein **Mandate-Admin** muss den SysAdmin als regulären User zum Mandanten hinzufügen +2. Der SysAdmin unterliegt dann der normalen RBAC-Prüfung +3. Alle Aktionen werden vollständig auditiert + +**Begründung:** +- 🔒 **Keine Self-Service-Eskalation:** SysAdmin kann sich keine Rechte selbst geben +- 📊 **4-Augen-Prinzip:** Mandate-Admin muss Zugriff explizit gewähren +- 🔍 **Klarer Audit-Trail:** Wer hat wem wann Zugriff gegeben + +### 5.5 User zu Mandant hinzufügen (createUserMandate Endpoint) + +```python +class UserMandateCreate(BaseModel): + """Request-Model für User-Mandant-Verknüpfung""" + targetUserId: str + roleIds: List[str] # Rollen die dem User im Mandant zugewiesen werden + + +@router.post("/api/mandates/{mandateId}/users") +@limiter.limit("30/minute") +async def addUserToMandate( + mandateId: str, + data: UserMandateCreate, + currentUser: User = Depends(getCurrentUser), + ctx: RequestContext = Depends(getRequestContext) +): + """ + Fügt einen User zu einem Mandanten hinzu. + + Nur Mandate-Admin darf User hinzufügen. + SysAdmin kann sich NICHT selbst hinzufügen (Self-Eskalation Prevention). + """ + # 1. SysAdmin Self-Eskalation Prevention + if currentUser.isSysAdmin and data.targetUserId == currentUser.id: + raise HTTPException( + 403, + "SysAdmin cannot add themselves to a mandate. " + "A Mandate-Admin must grant access." + ) + + # 2. Mandate-Admin Berechtigung prüfen + if not hasMandateAdminRole(ctx, mandateId): + raise HTTPException(403, "Mandate-Admin role required to add users") + + # 3. Prüfen ob User existiert + targetUser = db.getUser(data.targetUserId) + if not targetUser: + raise HTTPException(404, f"User {data.targetUserId} not found") + + # 4. Prüfen ob User bereits Mitglied ist + existingMembership = db.getUserMandate(data.targetUserId, mandateId) + if existingMembership: + raise HTTPException(409, f"User {data.targetUserId} is already member of mandate") + + # 5. Rollen validieren (müssen im Mandant existieren) + for roleId in data.roleIds: + role = db.getRole(roleId) + if not role: + raise HTTPException(404, f"Role {roleId} not found") + if role.mandateId and role.mandateId != mandateId: + raise HTTPException(400, f"Role {roleId} belongs to different mandate") + + # 6. UserMandate erstellen + async with db.transaction() as tx: + userMandate = UserMandate( + userId=data.targetUserId, + mandateId=mandateId, + enabled=True + ) + await tx.create(userMandate) + + # 7. Rollen zuweisen via Junction Table + for roleId in data.roleIds: + userMandateRole = UserMandateRole( + userMandateId=userMandate.id, + roleId=roleId + ) + await tx.create(userMandateRole) + + await tx.commit() + + # 8. Audit + audit_logger.logSecurityEvent( + userId=str(currentUser.id), + mandateId=mandateId, + action="user_added_to_mandate", + details=f"targetUser={data.targetUserId}, roles={data.roleIds}" + ) + + return {"message": "User added to mandate", "userMandateId": userMandate.id} + + +@router.delete("/api/mandates/{mandateId}/users/{targetUserId}") +@limiter.limit("30/minute") +async def removeUserFromMandate( + mandateId: str, + targetUserId: str, + currentUser: User = Depends(getCurrentUser), + ctx: RequestContext = Depends(getRequestContext) +): + """Entfernt einen User aus einem Mandanten.""" + # Mandate-Admin Berechtigung prüfen + if not hasMandateAdminRole(ctx, mandateId): + raise HTTPException(403, "Mandate-Admin role required") + + # User darf sich nicht selbst entfernen wenn er letzter Admin ist + # (sonst ist Mandant ohne Admin) + membership = db.getUserMandate(targetUserId, mandateId) + if not membership: + raise HTTPException(404, "User is not member of mandate") + + # UserMandate löschen (CASCADE löscht UserMandateRoles) + db.delete(UserMandate, membership.id) + + audit_logger.logSecurityEvent( + userId=str(currentUser.id), + mandateId=mandateId, + action="user_removed_from_mandate", + details=f"targetUser={targetUserId}" + ) + + return {"message": "User removed from mandate"} +``` + +### 5.6 Übersicht: SysAdmin vs. Mandate-Admin + +| Aktion | SysAdmin (`isSysAdmin=true`) | Mandate-Admin (Rolle) | +|--------|------------------------------|----------------------| +| Mandanten erstellen | ✅ | ❌ | +| Mandanten löschen | ✅ | ❌ | +| User global verwalten | ✅ (enable/disable) | ❌ | +| Globale RBAC-Templates | ✅ | ❌ | +| **Mandant-Daten lesen** | ❌ | ✅ | +| **User zu Mandant hinzufügen** | ❌ | ✅ | +| **RBAC im Mandant ändern** | ❌ | ✅ | +| **Feature-Instanzen verwalten** | ❌ | ✅ | + +--- + +## 6. Rate Limiting (Bestehende Implementierung erweitern) + +### 6.1 Bestehende Rate Limits (routeSecurityLocal.py) + +```python +# Bereits implementiert im Gateway: +@router.post("/login") +@limiter.limit("30/minute") # ✅ + +@router.post("/register") +@limiter.limit("10/minute") # ✅ + +@router.post("/password-reset-request") +@limiter.limit("5/minute") # ✅ + +@router.post("/password-reset") +@limiter.limit("10/minute") # ✅ + +@router.get("/me") +@limiter.limit("30/minute") # ✅ + +@router.post("/refresh") +@limiter.limit("60/minute") # ✅ + +@router.post("/logout") +@limiter.limit("30/minute") # ✅ +``` + +### 6.2 Zusätzliche Rate Limits für Multi-Tenant + +```python +# Neue Limits für Admin-Endpoints +@router.get("/admin/mandates") +@limiter.limit("60/minute") + +@router.post("/admin/access-request") +@limiter.limit("5/minute") # Streng für kritische Aktionen + +@router.post("/admin/access-request/{requestId}/approve") +@limiter.limit("10/minute") + +# RBAC-Endpoints +@router.get("/rbac/rules") +@limiter.limit("120/minute") # Häufiger für UI + +@router.post("/rbac/rules") +@limiter.limit("30/minute") +``` + +--- + +## 7. Audit Logging (Bestehende Implementierung erweitern) + +### 7.1 Bestehende AuditLogger-Nutzung + +```python +# Bereits implementiert in routeSecurityLocal.py: + +# Login erfolgreich +audit_logger.logUserAccess( + userId=str(user.id), + mandateId=str(user.mandateId), + action="login", + successInfo="local_auth_success" +) + +# Login fehlgeschlagen +audit_logger.logUserAccess( + userId="unknown", + mandateId="unknown", + action="login", + successInfo=f"failed: {error_msg}" +) + +# Logout +audit_logger.logUserAccess( + userId=str(currentUser.id), + mandateId=str(currentUser.mandateId), + action="logout", + successInfo=f"revoked_tokens: {revoked}" +) + +# Password Reset +audit_logger.logSecurityEvent( + userId="unknown", + mandateId="unknown", + action="password_reset_via_token", + details="Password reset completed via magic link" +) +``` + +### 7.2 Erweiterte Audit-Events für Multi-Tenant + +```python +# Neue Audit-Events hinzufügen: + +# Mandant-Wechsel +audit_logger.logUserAccess( + userId=userId, + mandateId=newMandateId, + action="mandate_switch", + successInfo=f"from:{oldMandateId}" +) + +# Feature-Zugriff +audit_logger.logUserAccess( + userId=userId, + mandateId=mandateId, + action="feature_access", + successInfo=f"instance:{featureInstanceId}" +) + +# RBAC-Änderungen +audit_logger.logSecurityEvent( + userId=userId, + mandateId=mandateId, + action="rbac_rule_created", + details=f"role:{roleId}, item:{item}" +) + +# SysAdmin-Aktionen +audit_logger.logSecurityEvent( + userId=sysAdminId, + mandateId=targetMandateId, + action="sysadmin_action", + details=f"Action: {action_type}" +) +``` + +### 7.3 Audit-Log Retention Policy + +**Aufbewahrungsfristen für Audit-Logs:** + +| Log-Typ | Aufbewahrung | Begründung | +|---------|--------------|------------| +| **Security Events** | 24 Monate | Login/Logout, RBAC-Änderungen, Admin-Aktionen | +| **Data Access** | 12 Monate | DSGVO-Auskunft, Datenexporte | +| **User Activity** | 6 Monate | Feature-Zugriffe, Mandant-Wechsel | +| **System Events** | 3 Monate | Technische Logs, Performance | + +**Implementation:** +```python +# Konfiguration in APP_CONFIG +AUDIT_RETENTION_SECURITY_MONTHS = 24 +AUDIT_RETENTION_DATA_ACCESS_MONTHS = 12 +AUDIT_RETENTION_USER_ACTIVITY_MONTHS = 6 +AUDIT_RETENTION_SYSTEM_MONTHS = 3 + +# Cleanup-Job wird über den bestehenden EventManager (APScheduler) registriert +# Siehe: modules/shared/eventManagement.py + +async def cleanupExpiredAuditLogs(batchSize: int = 1000): + """ + Löscht Audit-Logs nach Ablauf der Aufbewahrungsfrist. + Löscht in Batches um Lock-Contention zu vermeiden. + """ + now = getUtcTimestamp() + + retentionPolicies = { + "security": AUDIT_RETENTION_SECURITY_MONTHS * 30 * 24 * 3600, + "data_access": AUDIT_RETENTION_DATA_ACCESS_MONTHS * 30 * 24 * 3600, + "user_activity": AUDIT_RETENTION_USER_ACTIVITY_MONTHS * 30 * 24 * 3600, + "system": AUDIT_RETENTION_SYSTEM_MONTHS * 30 * 24 * 3600, + } + + for logType, retentionSeconds in retentionPolicies.items(): + cutoffTime = now - retentionSeconds + totalDeleted = 0 + + while True: + async with db.transaction() as tx: + # Batch-Delete um Lock-Contention zu vermeiden + deleted = await tx.execute( + '''DELETE FROM "AuditLog" WHERE id IN ( + SELECT id FROM "AuditLog" + WHERE "logType" = :logType AND "timestamp" < :cutoff + LIMIT :batch + )''', + {"logType": logType, "cutoff": cutoffTime, "batch": batchSize} + ) + await tx.commit() + + if deleted == 0: + break + totalDeleted += deleted + + if totalDeleted > 0: + logger.info(f"Deleted {totalDeleted} expired {logType} audit logs") + + +# Registrierung im featuresLifecycle.py (analog zu bestehenden scheduled Jobs): +# eventManager.registerCron( +# jobId="audit_cleanup", +# func=cleanupExpiredAuditLogs, +# cronKwargs={"hour": "3", "minute": "0"} # Täglich um 03:00 +# ) +``` + +--- + +## 8. RBAC Export/Import (Mandantenspezifisch) + +### 8.1 Übersicht: Wer kann was exportieren? + +| Scope | Wer kann exportieren? | Wer kann importieren? | +|-------|----------------------|----------------------| +| **Global** (Templates) | SysAdmin | SysAdmin | +| **Mandant** | Mandate-Admin | Mandate-Admin | +| **Feature-Instanz** | Feature-Admin | Feature-Admin | + +### 8.2 Export-Format (JSON) + +```json +{ + "version": "2.0", + "exportedAt": "2026-01-16T10:00:00Z", + "exportedBy": "admin@example.com", + "scope": { + "type": "mandate", + "mandateId": "uuid-456", + "mandateName": "Althaus Consulting", + "featureInstanceId": null + }, + "roles": [ + { + "roleLabel": "trustee-admin", + "description": {"en": "Trustee Administrator", "de": "Treuhand-Administrator"}, + "featureCode": "trustee" + } + ], + "accessRules": [ + { + "roleLabel": "trustee-admin", + "context": "DATA", + "item": "TrusteeContract", + "view": true, + "read": "g", + "create": "g", + "update": "g", + "delete": "m" + }, + { + "roleLabel": "trustee-admin", + "context": "UI", + "item": "nav.trustee.contracts", + "view": true + } + ] +} +``` + +### 8.3 Export-Endpoints + +```python +# ============================================ +# GLOBAL EXPORT (SysAdmin only) +# ============================================ + +@router.get("/admin/rbac/global/export") +@limiter.limit("10/minute") +async def exportGlobalRbac( + featureCode: Optional[str] = Query(None, description="Filter by feature"), + admin: User = Depends(requireSysAdmin) +) -> RbacExport: + """ + Exportiert globale (Template) RBAC-Regeln. + Nur SysAdmin. + """ + # Globale Rollen (mandateId = None) + roles = db.getRoles(mandateId=None, featureCode=featureCode) + roleIds = [r.id for r in roles] + + # AccessRules für diese Rollen + rules = db.getAccessRulesByRoleIds(roleIds) + + return RbacExport( + scope={"type": "global", "mandateId": None, "featureCode": featureCode}, + roles=[r.toExportFormat() for r in roles], + accessRules=[r.toExportFormat() for r in rules] + ) + + +# ============================================ +# MANDATE EXPORT (Mandate-Admin) +# ============================================ + +@router.get("/api/mandates/{mandateId}/rbac/export") +@limiter.limit("10/minute") +async def exportMandateRbac( + mandateId: str, + featureCode: Optional[str] = Query(None), + currentUser: User = Depends(getCurrentUser), + ctx: RequestContext = Depends(getRequestContext) +) -> RbacExport: + """ + Exportiert RBAC-Regeln eines Mandanten. + Nur Mandate-Admin. + """ + if not hasMandateAdminRole(ctx, mandateId): + raise HTTPException(403, "Mandate-Admin role required") + + mandate = db.getMandate(mandateId) + if not mandate: + raise HTTPException(404, "Mandate not found") + + # Mandant-spezifische Rollen + roles = db.getRoles(mandateId=mandateId, featureCode=featureCode) + roleIds = [r.id for r in roles] + + rules = db.getAccessRulesByRoleIds(roleIds) + + audit_logger.logDataAccess( + userId=str(currentUser.id), + mandateId=mandateId, + action="rbac_export", + details=f"featureCode={featureCode or 'all'}" + ) + + return RbacExport( + scope={ + "type": "mandate", + "mandateId": mandateId, + "mandateName": mandate.name, + "featureCode": featureCode + }, + roles=[r.toExportFormat() for r in roles], + accessRules=[r.toExportFormat() for r in rules] + ) + + +# ============================================ +# FEATURE INSTANCE EXPORT (Feature-Admin) +# ============================================ + +@router.get("/api/features/instances/{instanceId}/rbac/export") +@limiter.limit("10/minute") +async def exportInstanceRbac( + instanceId: str, + currentUser: User = Depends(getCurrentUser), + ctx: RequestContext = Depends(getRequestContext) +) -> RbacExport: + """ + Exportiert RBAC-Regeln einer Feature-Instanz. + Nur Feature-Admin. + """ + if not hasAdminRoleForInstance(ctx, instanceId): + raise HTTPException(403, "Feature-Admin role required") + + instance = db.getFeatureInstance(instanceId) + if not instance: + raise HTTPException(404, "Feature instance not found") + + # Instanz-spezifische Rollen + roles = db.getRoles(featureInstanceId=instanceId) + roleIds = [r.id for r in roles] + + rules = db.getAccessRulesByRoleIds(roleIds) + + return RbacExport( + scope={ + "type": "instance", + "featureInstanceId": instanceId, + "mandateId": instance.mandateId, + "featureCode": instance.featureCode, + "instanceLabel": instance.label + }, + roles=[r.toExportFormat() for r in roles], + accessRules=[r.toExportFormat() for r in rules] + ) +``` + +### 8.4 Import-Endpoints + +```python +class RbacImportMode(str, Enum): + MERGE = "merge" # Upsert: Bestehende aktualisieren, fehlende hinzufügen + REPLACE = "replace" # Alle bestehenden im Scope löschen, dann importieren + ADD_ONLY = "add" # Nur fehlende hinzufügen, bestehende nicht ändern + + +@router.post("/api/mandates/{mandateId}/rbac/import") +@limiter.limit("5/minute") +async def importMandateRbac( + mandateId: str, + data: RbacExport, + mode: RbacImportMode = Query(RbacImportMode.MERGE), + currentUser: User = Depends(getCurrentUser), + ctx: RequestContext = Depends(getRequestContext) +) -> Dict: + """ + Importiert RBAC-Regeln in einen Mandanten. + Nur Mandate-Admin. + + Modi: + - merge: Bestehende aktualisieren, fehlende hinzufügen + - replace: Alle bestehenden Regeln löschen, dann importieren + - add: Nur fehlende hinzufügen + """ + if not hasMandateAdminRole(ctx, mandateId): + raise HTTPException(403, "Mandate-Admin role required") + + result = {"rolesCreated": 0, "rolesUpdated": 0, "rulesCreated": 0, "rulesUpdated": 0} + + async with db.transaction() as tx: + # Bei REPLACE: Bestehende löschen + if mode == RbacImportMode.REPLACE: + existingRoles = await tx.getRoles(mandateId=mandateId) + for role in existingRoles: + if not role.isSystemRole: # System-Rollen nicht löschen + await tx.delete(Role, role.id) + + # Rollen importieren + roleIdMapping = {} # exportedLabel -> newRoleId + + for roleData in data.roles: + # Prüfen ob Rolle existiert + existing = await tx.getRole( + mandateId=mandateId, + roleLabel=roleData["roleLabel"], + featureCode=roleData.get("featureCode") + ) + + if existing: + if mode == RbacImportMode.ADD_ONLY: + roleIdMapping[roleData["roleLabel"]] = existing.id + continue + + # Update description (context-Felder sind IMMUTABLE!) + await tx.update(Role, existing.id, { + "description": roleData.get("description", existing.description) + }) + roleIdMapping[roleData["roleLabel"]] = existing.id + result["rolesUpdated"] += 1 + else: + # Neue Rolle erstellen + newRole = Role( + roleLabel=roleData["roleLabel"], + description=roleData.get("description", {}), + featureCode=roleData.get("featureCode"), + mandateId=mandateId, # IMMUTABLE - wird beim Create gesetzt + featureInstanceId=None, + isSystemRole=False + ) + await tx.create(newRole) + roleIdMapping[roleData["roleLabel"]] = newRole.id + result["rolesCreated"] += 1 + + # AccessRules importieren + for ruleData in data.accessRules: + roleId = roleIdMapping.get(ruleData["roleLabel"]) + if not roleId: + continue # Rolle nicht gefunden/importiert + + # Prüfen ob Regel existiert (roleId + context + item) + existing = await tx.getAccessRule( + roleId=roleId, + context=ruleData["context"], + item=ruleData.get("item") + ) + + if existing: + if mode == RbacImportMode.ADD_ONLY: + continue + + # Update permissions (context und roleId sind IMMUTABLE!) + await tx.update(AccessRule, existing.id, { + "view": ruleData.get("view", False), + "read": ruleData.get("read"), + "create": ruleData.get("create"), + "update": ruleData.get("update"), + "delete": ruleData.get("delete") + }) + result["rulesUpdated"] += 1 + else: + # Neue Regel erstellen + newRule = AccessRule( + roleId=roleId, # IMMUTABLE + context=ruleData["context"], # IMMUTABLE + item=ruleData.get("item"), + view=ruleData.get("view", False), + read=ruleData.get("read"), + create=ruleData.get("create"), + update=ruleData.get("update"), + delete=ruleData.get("delete") + ) + await tx.create(newRule) + result["rulesCreated"] += 1 + + await tx.commit() + + # Audit + audit_logger.logSecurityEvent( + userId=str(currentUser.id), + mandateId=mandateId, + action="rbac_import", + details=f"mode={mode.value}, result={result}" + ) + + return {"status": "success", "mode": mode.value, **result} +``` + +### 8.5 Export/Import für Mandantenwechsel + +**Use Case:** Mandant "Althaus" möchte gleiche RBAC-Struktur wie Mandant "Soha Treuhand". + +```python +@router.post("/admin/rbac/copy") +@limiter.limit("5/minute") +async def copyRbacBetweenMandates( + sourceId: str = Query(..., description="Source mandate ID"), + targetId: str = Query(..., description="Target mandate ID"), + featureCode: Optional[str] = Query(None), + admin: User = Depends(requireSysAdmin) +) -> Dict: + """ + Kopiert RBAC-Struktur von einem Mandanten zu einem anderen. + Nur SysAdmin (da mandantenübergreifend). + """ + # 1. Export von Source + exportData = await exportMandateRbacInternal(sourceId, featureCode) + + # 2. Import zu Target (mit MERGE-Modus) + result = await importMandateRbacInternal(targetId, exportData, RbacImportMode.MERGE) + + audit_logger.logSecurityEvent( + userId=str(admin.id), + mandateId=targetId, + action="rbac_copy", + details=f"from={sourceId}, featureCode={featureCode or 'all'}" + ) + + return {"status": "success", "source": sourceId, "target": targetId, **result} +``` + +--- + +## 9. Invitation-Flow (Self-Service Onboarding) + +### 9.1 Invitation Model + +```python +class Invitation(BaseModel): + """Einladungs-Token für neue User""" + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + token: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) + + # Ziel der Einladung + mandateId: str # FK → Mandate + featureInstanceId: Optional[str] # Optional: Direkt zu Feature-Instanz + roleIds: List[str] # Rollen die zugewiesen werden + + # Einladungs-Details + email: Optional[str] # Für wen (optional, für Tracking) + createdBy: str # User-ID des Einladenden + createdAt: float # Timestamp + expiresAt: float # Ablaufzeit + + # Status + usedBy: Optional[str] # User-ID der Person, die eingeladen wurde + usedAt: Optional[float] # Wann eingelöst + revokedAt: Optional[float] # Wann widerrufen (falls) + + # Einschränkungen + maxUses: int = 1 # Wie oft kann Token verwendet werden + currentUses: int = 0 + + +class InvitationCreate(BaseModel): + """Request-Model für Einladungserstellung""" + email: Optional[str] = None + featureInstanceId: Optional[str] = None + roleIds: List[str] + expiresInHours: int = Field(default=24, ge=1, le=168) # 1h - 7 Tage + maxUses: int = Field(default=1, ge=1, le=100) +``` + +### 9.2 Einladung erstellen (Mandate-Admin) + +```python +@router.post("/api/mandates/{mandateId}/invitations") +@limiter.limit("30/minute") +async def createInvitation( + mandateId: str, + data: InvitationCreate, + currentUser: User = Depends(getCurrentUser), + ctx: RequestContext = Depends(getRequestContext) +) -> InvitationResponse: + """ + Erstellt einen Einladungslink für einen Mandanten. + Nur Mandate-Admin. + """ + if not hasMandateAdminRole(ctx, mandateId): + raise HTTPException(403, "Mandate-Admin role required") + + # Rollen validieren + for roleId in data.roleIds: + role = db.getRole(roleId) + if not role: + raise HTTPException(404, f"Role {roleId} not found") + if role.mandateId and role.mandateId != mandateId: + raise HTTPException(400, f"Role {roleId} belongs to different mandate") + + # Feature-Instanz validieren (falls angegeben) + if data.featureInstanceId: + instance = db.getFeatureInstance(data.featureInstanceId) + if not instance or instance.mandateId != mandateId: + raise HTTPException(400, "Feature instance not in this mandate") + + # Einladung erstellen + invitation = Invitation( + mandateId=mandateId, + featureInstanceId=data.featureInstanceId, + roleIds=data.roleIds, + email=data.email, + createdBy=currentUser.id, + createdAt=getUtcTimestamp(), + expiresAt=getUtcTimestamp() + (data.expiresInHours * 3600), + maxUses=data.maxUses + ) + + db.create(invitation) + + # Einladungs-URL generieren + mandate = db.getMandate(mandateId) + inviteUrl = f"{APP_CONFIG.BASE_URL}/invite/{invitation.token}" + + audit_logger.logSecurityEvent( + userId=str(currentUser.id), + mandateId=mandateId, + action="invitation_created", + details=f"email={data.email or 'any'}, expires={invitation.expiresAt}" + ) + + return InvitationResponse( + id=invitation.id, + token=invitation.token, + inviteUrl=inviteUrl, + mandateName=mandate.name, + expiresAt=invitation.expiresAt, + maxUses=invitation.maxUses + ) +``` + +### 9.3 Einladung einlösen + +```python +@router.get("/api/invitations/{token}") +async def getInvitationDetails(token: str) -> InvitationPublicInfo: + """ + Öffentlicher Endpoint: Zeigt Einladungs-Details (ohne sensible Daten). + Für Landing-Page vor Registration/Login. + """ + invitation = db.getInvitationByToken(token) + + if not invitation: + raise HTTPException(404, "Invitation not found or expired") + + if invitation.expiresAt < getUtcTimestamp(): + raise HTTPException(410, "Invitation has expired") + + if invitation.revokedAt: + raise HTTPException(410, "Invitation has been revoked") + + if invitation.currentUses >= invitation.maxUses: + raise HTTPException(410, "Invitation has been fully used") + + mandate = db.getMandate(invitation.mandateId) + roles = db.getRolesByIds(invitation.roleIds) + + return InvitationPublicInfo( + mandateName=mandate.name if mandate else "Unknown", + roles=[r.roleLabel for r in roles], + expiresAt=invitation.expiresAt, + requiresNewAccount=True # Frontend zeigt Register oder Login + ) + + +@router.post("/api/invitations/{token}/accept") +@limiter.limit("10/minute") +async def acceptInvitation( + token: str, + currentUser: User = Depends(getCurrentUser) +) -> Dict: + """ + Einladung annehmen (eingeloggter User). + Fügt User zum Mandanten/Feature hinzu. + """ + invitation = db.getInvitationByToken(token) + + # Validierungen + if not invitation: + raise HTTPException(404, "Invitation not found") + + if invitation.expiresAt < getUtcTimestamp(): + raise HTTPException(410, "Invitation has expired") + + if invitation.revokedAt: + raise HTTPException(410, "Invitation has been revoked") + + if invitation.currentUses >= invitation.maxUses: + raise HTTPException(410, "Invitation has been fully used") + + # Prüfen ob User bereits Mitglied ist + existingMembership = db.getUserMandate(currentUser.id, invitation.mandateId) + + async with db.transaction() as tx: + if not existingMembership: + # UserMandate erstellen + userMandate = UserMandate( + userId=currentUser.id, + mandateId=invitation.mandateId, + enabled=True + ) + await tx.create(userMandate) + + # Rollen zuweisen + for roleId in invitation.roleIds: + umRole = UserMandateRole( + userMandateId=userMandate.id, + roleId=roleId + ) + await tx.create(umRole) + else: + # Nur neue Rollen hinzufügen + existingRoleIds = set(db.getRoleIdsForUserMandate(existingMembership.id)) + for roleId in invitation.roleIds: + if roleId not in existingRoleIds: + umRole = UserMandateRole( + userMandateId=existingMembership.id, + roleId=roleId + ) + await tx.create(umRole) + + # Feature-Zugang (falls spezifiziert) + if invitation.featureInstanceId: + existingAccess = db.getFeatureAccess(currentUser.id, invitation.featureInstanceId) + if not existingAccess: + featureAccess = FeatureAccess( + userId=currentUser.id, + featureInstanceId=invitation.featureInstanceId, + enabled=True + ) + await tx.create(featureAccess) + + # Feature-Rollen zuweisen + for roleId in invitation.roleIds: + role = db.getRole(roleId) + if role and role.featureInstanceId == invitation.featureInstanceId: + faRole = FeatureAccessRole( + featureAccessId=featureAccess.id, + roleId=roleId + ) + await tx.create(faRole) + + # Invitation als verwendet markieren + await tx.update(Invitation, invitation.id, { + "currentUses": invitation.currentUses + 1, + "usedBy": currentUser.id, + "usedAt": getUtcTimestamp() + }) + + await tx.commit() + + mandate = db.getMandate(invitation.mandateId) + + audit_logger.logSecurityEvent( + userId=str(currentUser.id), + mandateId=invitation.mandateId, + action="invitation_accepted", + details=f"invitationId={invitation.id}" + ) + + return { + "status": "success", + "message": f"Successfully joined {mandate.name}", + "mandateId": invitation.mandateId + } +``` + +### 9.4 Einladungen verwalten (Mandate-Admin) + +```python +@router.get("/api/mandates/{mandateId}/invitations") +async def listInvitations( + mandateId: str, + status: Optional[str] = Query(None, regex="^(active|expired|used|revoked)$"), + currentUser: User = Depends(getCurrentUser), + ctx: RequestContext = Depends(getRequestContext) +) -> List[InvitationListItem]: + """Listet alle Einladungen eines Mandanten.""" + if not hasMandateAdminRole(ctx, mandateId): + raise HTTPException(403, "Mandate-Admin role required") + + invitations = db.getInvitations(mandateId=mandateId) + + # Filter by status + now = getUtcTimestamp() + if status: + filtered = [] + for inv in invitations: + if status == "active" and inv.expiresAt > now and not inv.revokedAt and inv.currentUses < inv.maxUses: + filtered.append(inv) + elif status == "expired" and inv.expiresAt <= now: + filtered.append(inv) + elif status == "used" and inv.currentUses >= inv.maxUses: + filtered.append(inv) + elif status == "revoked" and inv.revokedAt: + filtered.append(inv) + invitations = filtered + + return [inv.toListItem() for inv in invitations] + + +@router.delete("/api/mandates/{mandateId}/invitations/{invitationId}") +async def revokeInvitation( + mandateId: str, + invitationId: str, + currentUser: User = Depends(getCurrentUser), + ctx: RequestContext = Depends(getRequestContext) +) -> Dict: + """Widerruft eine Einladung.""" + if not hasMandateAdminRole(ctx, mandateId): + raise HTTPException(403, "Mandate-Admin role required") + + invitation = db.getInvitation(invitationId) + if not invitation or invitation.mandateId != mandateId: + raise HTTPException(404, "Invitation not found") + + db.update(Invitation, invitationId, { + "revokedAt": getUtcTimestamp() + }) + + audit_logger.logSecurityEvent( + userId=str(currentUser.id), + mandateId=mandateId, + action="invitation_revoked", + details=f"invitationId={invitationId}" + ) + + return {"status": "success", "message": "Invitation revoked"} +``` + +### 9.5 Registration mit Einladung (Neuer User) + +```python +@router.post("/api/invitations/{token}/register") +@limiter.limit("5/minute") +async def registerWithInvitation( + token: str, + data: UserRegistration +) -> AuthResponse: + """ + Registriert neuen User UND nimmt Einladung an. + Kombinierter Flow für neue User. + """ + invitation = db.getInvitationByToken(token) + + # Validierungen (wie bei acceptInvitation) + if not invitation or invitation.expiresAt < getUtcTimestamp(): + raise HTTPException(410, "Invitation expired or invalid") + + if invitation.revokedAt or invitation.currentUses >= invitation.maxUses: + raise HTTPException(410, "Invitation no longer valid") + + # Email-Check (falls Einladung für bestimmte Email) + if invitation.email and data.email != invitation.email: + raise HTTPException(400, f"This invitation is for {invitation.email}") + + async with db.transaction() as tx: + # 1. User erstellen + newUser = User( + username=data.username, + email=data.email, + fullName=data.fullName, + language=data.language or "en", + enabled=True, + isSysAdmin=False + ) + newUser.passwordHash = hashPassword(data.password) + await tx.create(newUser) + + # 2. Zum Mandanten hinzufügen + userMandate = UserMandate( + userId=newUser.id, + mandateId=invitation.mandateId, + enabled=True + ) + await tx.create(userMandate) + + # Rollen zuweisen + for roleId in invitation.roleIds: + umRole = UserMandateRole( + userMandateId=userMandate.id, + roleId=roleId + ) + await tx.create(umRole) + + # 3. Feature-Zugang (falls spezifiziert) + if invitation.featureInstanceId: + featureAccess = FeatureAccess( + userId=newUser.id, + featureInstanceId=invitation.featureInstanceId, + enabled=True + ) + await tx.create(featureAccess) + + for roleId in invitation.roleIds: + role = await tx.getRole(roleId) + if role and role.featureInstanceId == invitation.featureInstanceId: + faRole = FeatureAccessRole( + featureAccessId=featureAccess.id, + roleId=roleId + ) + await tx.create(faRole) + + # 4. Invitation aktualisieren + await tx.update(Invitation, invitation.id, { + "currentUses": invitation.currentUses + 1, + "usedBy": newUser.id, + "usedAt": getUtcTimestamp() + }) + + await tx.commit() + + # 5. Token generieren und einloggen + tokens = createTokenPair(newUser) + + audit_logger.logUserAccess( + userId=str(newUser.id), + mandateId=invitation.mandateId, + action="register_via_invitation", + successInfo=f"invitationId={invitation.id}" + ) + + return AuthResponse( + user=newUser.toPublic(), + accessToken=tokens.accessToken, + refreshToken=tokens.refreshToken + ) +``` + +--- + +## 10. Cascade Delete (Orphan Prevention) + +### 8.1 PostgreSQL Foreign Keys + +```sql +-- ============================================ +-- MANDATE CASCADE +-- ============================================ + +-- UserMandate: Löschen wenn Mandate oder User gelöscht +ALTER TABLE "UserMandate" + ADD CONSTRAINT fk_usermandate_mandate + FOREIGN KEY ("mandateId") REFERENCES "Mandate"("id") ON DELETE CASCADE; + +ALTER TABLE "UserMandate" + ADD CONSTRAINT fk_usermandate_user + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE; + +-- FeatureInstance: Löschen wenn Mandate gelöscht +ALTER TABLE "FeatureInstance" + ADD CONSTRAINT fk_featureinstance_mandate + FOREIGN KEY ("mandateId") REFERENCES "Mandate"("id") ON DELETE CASCADE; + +-- Role: Löschen wenn Mandate gelöscht +ALTER TABLE "Role" + ADD CONSTRAINT fk_role_mandate + FOREIGN KEY ("mandateId") REFERENCES "Mandate"("id") ON DELETE CASCADE; + +-- ============================================ +-- FEATURE INSTANCE CASCADE +-- ============================================ + +-- FeatureAccess: Löschen wenn FeatureInstance gelöscht +ALTER TABLE "FeatureAccess" + ADD CONSTRAINT fk_featureaccess_instance + FOREIGN KEY ("featureInstanceId") REFERENCES "FeatureInstance"("id") ON DELETE CASCADE; + +-- Role: Löschen wenn FeatureInstance gelöscht +ALTER TABLE "Role" + ADD CONSTRAINT fk_role_instance + FOREIGN KEY ("featureInstanceId") REFERENCES "FeatureInstance"("id") ON DELETE CASCADE; + +-- ============================================ +-- USER CASCADE +-- ============================================ + +-- FeatureAccess: Löschen wenn User gelöscht +ALTER TABLE "FeatureAccess" + ADD CONSTRAINT fk_featureaccess_user + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE; + +-- ============================================ +-- ROLE CASCADE (WICHTIG!) +-- ============================================ + +-- AccessRule: Löschen wenn Role gelöscht +ALTER TABLE "AccessRule" + ADD CONSTRAINT fk_accessrule_role + FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE; +``` + +### 8.2 Junction Tables (SQL-Definition) + +**Models:** Siehe Kapitel 2.3 für Pydantic-Models. + +```sql +-- ============================================ +-- JUNCTION TABLES +-- ============================================ + +-- UserMandate zu Role Verknüpfung +CREATE TABLE "UserMandateRole" ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "userMandateId" UUID NOT NULL REFERENCES "UserMandate"("id") ON DELETE CASCADE, + "roleId" UUID NOT NULL REFERENCES "Role"("id") ON DELETE CASCADE, + UNIQUE("userMandateId", "roleId") +); + +-- FeatureAccess zu Role Verknüpfung +CREATE TABLE "FeatureAccessRole" ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "featureAccessId" UUID NOT NULL REFERENCES "FeatureAccess"("id") ON DELETE CASCADE, + "roleId" UUID NOT NULL REFERENCES "Role"("id") ON DELETE CASCADE, + UNIQUE("featureAccessId", "roleId") +); +``` + +### 8.3 Cleanup bei leerer Mitgliedschaft (Application-Level) + +**Design-Entscheidung:** Cleanup wird auf **Application-Level** durchgeführt, nicht via DB-Trigger. + +**Begründung:** +- Bessere Kontrolle über Transaktionsgrenzen +- Vermeidung von Race Conditions bei parallelen Löschungen +- Explizites Verhalten statt impliziter Trigger-Magie +- Einfacheres Debugging und Testing + +```python +async def removeRoleFromUserMandate(userMandateId: str, roleId: str): + """ + Entfernt eine Rolle von einem UserMandate. + Löscht das UserMandate, wenn keine Rollen mehr vorhanden sind. + + Verwendet SELECT FOR UPDATE um Race Conditions zu vermeiden. + """ + async with db.transaction() as tx: + # 0. Lock auf UserMandate um Race Conditions zu vermeiden + await tx.execute( + 'SELECT * FROM "UserMandate" WHERE id = :id FOR UPDATE', + {"id": userMandateId} + ) + + # 1. Rolle entfernen + await tx.delete(UserMandateRole, {"userMandateId": userMandateId, "roleId": roleId}) + + # 2. Prüfen ob noch Rollen vorhanden + remainingRoles = await tx.getRecordset( + UserMandateRole, + recordFilter={"userMandateId": userMandateId} + ) + + # 3. Wenn keine Rollen mehr: UserMandate löschen + if not remainingRoles: + await tx.delete(UserMandate, userMandateId) + logger.info(f"Deleted empty UserMandate {userMandateId}") + + await tx.commit() + + +async def removeRoleFromFeatureAccess(featureAccessId: str, roleId: str): + """ + Entfernt eine Rolle von einem FeatureAccess. + Löscht das FeatureAccess, wenn keine Rollen mehr vorhanden sind. + + Verwendet SELECT FOR UPDATE um Race Conditions zu vermeiden. + """ + async with db.transaction() as tx: + # 0. Lock auf FeatureAccess um Race Conditions zu vermeiden + await tx.execute( + 'SELECT * FROM "FeatureAccess" WHERE id = :id FOR UPDATE', + {"id": featureAccessId} + ) + + # 1. Rolle entfernen + await tx.delete(FeatureAccessRole, {"featureAccessId": featureAccessId, "roleId": roleId}) + + # 2. Prüfen ob noch Rollen vorhanden + remainingRoles = await tx.getRecordset( + FeatureAccessRole, + recordFilter={"featureAccessId": featureAccessId} + ) + + # 3. Wenn keine Rollen mehr: FeatureAccess löschen + if not remainingRoles: + await tx.delete(FeatureAccess, featureAccessId) + logger.info(f"Deleted empty FeatureAccess {featureAccessId}") + + await tx.commit() +``` + +**Wichtig:** Alle Rollen-Löschungen müssen über diese Funktionen erfolgen, nicht direkt auf der Junction Table. + +### 8.4 Lösch-Kaskade Übersicht + +``` +Mandate löschen: + ├── UserMandate → gelöscht (FK CASCADE) + │ └── UserMandateRole → gelöscht (FK CASCADE) + ├── FeatureInstance → gelöscht (FK CASCADE) + │ ├── FeatureAccess → gelöscht (FK CASCADE) + │ │ └── FeatureAccessRole → gelöscht (FK CASCADE) + │ └── Role (instanz-spezifisch) → gelöscht (FK CASCADE) + │ └── AccessRule → gelöscht (FK CASCADE) + ├── Role (mandant-spezifisch) → gelöscht (FK CASCADE) + │ └── AccessRule → gelöscht (FK CASCADE) + └── Feature-Daten (ChatWorkflow, etc.) → gelöscht (FK CASCADE) + +Role löschen: + ├── AccessRule (alle für diese Rolle) → gelöscht (FK CASCADE) + ├── UserMandateRole (Verknüpfungen) → gelöscht (FK CASCADE) + │ └── Application: Prüft ob UserMandate leer → löscht wenn ja (siehe 8.3) + └── FeatureAccessRole (Verknüpfungen) → gelöscht (FK CASCADE) + └── Application: Prüft ob FeatureAccess leer → löscht wenn ja (siehe 8.3) + +User löschen: + ├── UserMandate → gelöscht (FK CASCADE) + │ └── UserMandateRole → gelöscht (FK CASCADE) + └── FeatureAccess → gelöscht (FK CASCADE) + └── FeatureAccessRole → gelöscht (FK CASCADE) +``` + +--- + +## 11. Transaktionen (Konsistenz-Garantie) + +### 9.1 Multi-Step Operations in Transaktionen + +```python +async def createFeatureInstanceWithRoles( + featureCode: str, + mandateId: str, + label: str +) -> FeatureInstance: + """ + Erstellt FeatureInstance mit kopierten Rollen und Rules. + Alles in einer Transaktion für Konsistenz. + """ + async with db.transaction() as tx: + try: + # 1. FeatureInstance erstellen + instance = FeatureInstance( + featureCode=featureCode, + mandateId=mandateId, + label=label + ) + await tx.create(instance) + + # 2. Globale Template-Rollen finden + globalRoles = await tx.getRoles( + featureCode=featureCode, + mandateId=None + ) + + for templateRole in globalRoles: + # 3. Rolle für diese Instanz kopieren + newRole = Role( + roleLabel=templateRole.roleLabel, + description=templateRole.description, + featureCode=templateRole.featureCode, + mandateId=mandateId, + featureInstanceId=instance.id + ) + await tx.create(newRole) + + # 4. AccessRules der Template-Rolle kopieren + templateRules = await tx.getAccessRules(roleId=templateRole.id) + for rule in templateRules: + newRule = AccessRule( + roleId=newRole.id, + context=rule.context, + item=rule.item, + view=rule.view, + read=rule.read, + create=rule.create, + update=rule.update, + delete=rule.delete + ) + await tx.create(newRule) + + # Commit nur wenn alles erfolgreich + await tx.commit() + + # Cache-Version inkrementieren + await onRbacChange(mandateId, instance.id) + + return instance + + except Exception as e: + await tx.rollback() + logger.error(f"Failed to create feature instance: {e}") + raise +``` + +--- + +## 12. API-Kontext + +### 10.1 Multi-Mandant Token-Design + +**Design-Entscheidung:** Ein JWT-Token ist **nicht** an einen spezifischen Mandanten gebunden. + +**Warum:** +- User arbeitet im UI **parallel in mehreren Mandanten** (z.B. mehrere Browser-Tabs) +- Mandant-Wechsel erfordert keinen neuen Login +- Token enthält nur User-Identität, nicht Mandant-Kontext + +**Sicherheit:** +- Der **Request-Header** (`X-Mandate-Id`) bestimmt den aktiven Kontext +- Bei **jedem Request** wird geprüft, ob der User Mitglied des Mandanten ist +- User kann nur auf Mandanten zugreifen, in denen er **explizit** Mitglied ist +- **Kein Sicherheitsrisiko:** Token-Kompromittierung erlaubt nur Zugriff auf Mandanten, in denen der User bereits berechtigt ist + +``` +JWT Token enthält: +├── userId: "abc-123" +├── sessionId: "sess-456" +├── authority: "local" +└── exp: 1737123456 + +Request Headers bestimmen Kontext: +├── X-Mandate-Id: "mandate-789" +└── X-Instance-Id: "instance-012" (optional) +``` + +### 10.2 Request-Kontext ermitteln + +```python +def getRequestContext( + mandateId: Optional[str] = Header(None, alias="X-Mandate-Id"), + featureInstanceId: Optional[str] = Header(None, alias="X-Instance-Id"), + currentUser: User = Depends(getCurrentUser) +) -> RequestContext: + """ + Ermittelt Kontext aus Headers. + Prüft Berechtigung und lädt Role-IDs. + + WICHTIG: Auch SysAdmin braucht explizite Membership für Mandant-Kontext! + SysAdmin-Flag gibt keinen impliziten Zugriff auf Mandant-Daten. + """ + ctx = RequestContext(user=currentUser) + + if mandateId: + # Prüfe Mandant-Mitgliedschaft - AUCH für SysAdmin! + # SysAdmin muss explizit zum Mandanten hinzugefügt werden + membership = db.getUserMandate(currentUser.id, mandateId) + if not membership: + # Kein impliziter Zugriff für SysAdmin - Fail-Fast! + raise HTTPException(403, "Not member of mandate") + + if not membership.enabled: + raise HTTPException(403, "Mandate membership is disabled") + + ctx.mandateId = mandateId + + # Rollen via Junction Table laden + ctx.roleIds = db.getRoleIdsForUserMandate(membership.id) + + if featureInstanceId: + # Prüfe Feature-Zugang - AUCH für SysAdmin! + access = db.getFeatureAccess(currentUser.id, featureInstanceId) + if not access: + raise HTTPException(403, "No access to feature instance") + + if not access.enabled: + raise HTTPException(403, "Feature access is disabled") + + ctx.featureInstanceId = featureInstanceId + + # Instanz-Rollen hinzufügen + ctx.roleIds.extend(db.getRoleIdsForFeatureAccess(access.id)) + + return ctx +``` + +### 10.3 RBAC-Prüfung (Stateless + SysAdmin-Blockade) + +```python +def checkPermission( + ctx: RequestContext, + context: str, # "DATA", "UI", "RESOURCE" + item: str, # z.B. "TrusteeContract" + action: str # "view", "read", "create", "update", "delete" +) -> AccessLevel: + """Prüft Berechtigung basierend auf Kontext und Role-IDs.""" + + # System-Level (kein Mandant): SysAdmin hat vollen Zugriff + if ctx.user.isSysAdmin and not ctx.mandateId: + return AccessLevel.ALL + + # KRITISCH: SysAdmin-Flag gibt KEINEN Zugriff auf Mandant-Daten! + # SysAdmin muss reguläre Rollen im Mandanten haben + if ctx.mandateId and not ctx.roleIds: + # User (auch SysAdmin) hat keine Rollen im Mandanten → kein Zugriff + return AccessLevel.NONE + + # Regeln für alle Role-IDs des Users laden (mit Caching) + rules = getRulesForUserBulk( + ctx.user.id, + ctx.mandateId, + ctx.featureInstanceId + ) + + # Beste Permission über alle Regeln + bestLevel = AccessLevel.NONE + for priority, rule in rules: + if not matchesItem(rule.item, item): + continue + if rule.context != context: + continue + + level = getattr(rule, action, None) + if level and isMorePermissive(level, bestLevel): + bestLevel = level + + return bestLevel +``` + +### 10.4 IMMUTABLE-Enforcement bei Updates + +```python +def validateUpdateRequest(model: str, recordId: str, updateData: dict): + """ + Blockiert Updates auf immutable Felder. + Wirft PermissionError wenn versucht wird, context-Felder zu ändern. + """ + IMMUTABLE = { + "Role": ["mandateId", "featureInstanceId", "featureCode"], + "AccessRule": ["context", "roleId"] + } + + forbidden = IMMUTABLE.get(model, []) + violations = [f for f in forbidden if f in updateData] + + if violations: + raise PermissionError( + f"Cannot update immutable fields on {model}: {violations}. " + f"Delete and recreate instead." + ) +``` + +### 10.5 AccessRule Scope-Validierung bei Erstellung + +**Sicherheitsregel:** Ein User darf nur AccessRules für Rollen erstellen, die in seinem Scope liegen. + +```python +def validateAccessRuleCreation(ctx: RequestContext, rule: AccessRule): + """ + Validiert, dass der User berechtigt ist, eine AccessRule für diese Rolle zu erstellen. + + Regeln: + - SysAdmin kann Regeln für globale Rollen (mandateId=None) erstellen + - Mandate-Admin kann Regeln für Rollen seines Mandanten erstellen + - Niemand kann Regeln für Rollen anderer Mandanten erstellen + """ + role = db.getRole(rule.roleId) + if not role: + raise ValueError(f"Role {rule.roleId} not found") + + # Globale Rollen: nur SysAdmin + if role.mandateId is None: + if not ctx.user.isSysAdmin: + raise PermissionError( + "Only SysAdmin can create AccessRules for global (template) roles" + ) + return # OK + + # Mandant-Rollen: nur wenn User im gleichen Mandant + if role.mandateId != ctx.mandateId: + raise PermissionError( + f"Cannot create AccessRules for roles of other mandates. " + f"Role belongs to mandate {role.mandateId}, your context is {ctx.mandateId}" + ) + + # Instanz-Rollen: zusätzlich prüfen ob User Admin der Instanz ist + if role.featureInstanceId: + if not hasAdminRoleForInstance(ctx, role.featureInstanceId): + raise PermissionError( + f"Admin role required to create AccessRules for instance-specific roles" + ) + + # Mandate-Rollen ohne Instanz: Mandate-Admin Rolle erforderlich + else: + if not hasMandateAdminRole(ctx): + raise PermissionError( + "Mandate-Admin role required to create AccessRules for mandate-level roles" + ) + + +# Anwendung im Endpoint: +@router.post("/api/rbac/rules") +async def createAccessRule( + rule: AccessRule, + currentUser: User = Depends(getCurrentUser), + ctx: RequestContext = Depends(getRequestContext) +): + # 1. FK-Validierung: Role muss existieren + role = db.getRole(rule.roleId) + if not role: + raise HTTPException(404, f"Role {rule.roleId} not found") + + # 2. Scope-Validierung (Role-Kontext prüfen) + validateAccessRuleCreation(ctx, rule) + + # IMMUTABLE-Validierung ist hier nicht nötig (nur bei UPDATE) + + # 3. Erstellen + createdRule = db.create(AccessRule, rule) + + audit_logger.logSecurityEvent( + userId=str(currentUser.id), + mandateId=ctx.mandateId or "global", + action="accessrule_created", + details=f"roleId={rule.roleId}, context={rule.context}, item={rule.item}" + ) + + return createdRule +``` + +--- + +## 13. DSGVO-Compliance Endpoints + +> **Hinweis:** Dieses Konzept behandelt nur die **technische Code-Implementierung** der DSGVO-Endpoints. Organisatorische Aspekte (z.B. Daten-Anonymisierung bei Export von Daten die andere User betreffen) sind **Verantwortung des Users/Mandanten** und werden hier nicht behandelt. + +### 11.1 Recht auf Auskunft (Art. 15 DSGVO) + +**Anforderung:** Betroffene Person kann Auskunft über alle gespeicherten Daten verlangen. + +```python +@router.get("/api/user/me/data-export") +@limiter.limit("5/hour") # Streng limitiert (aufwändige Operation) +async def exportUserData( + format: str = Query("json", regex="^(json|csv)$"), + currentUser: User = Depends(getCurrentUser) +) -> UserDataExport: + """ + Exportiert ALLE Daten des aktuellen Users (DSGVO Art. 15). + + Enthält: + - Persönliche Daten (User-Objekt) + - Alle Mandant-Mitgliedschaften + - Alle Feature-Zugänge + - Alle vom User erstellten Daten (pro Mandant) + - Audit-Log Einträge (eigene Aktionen) + + Returns: + UserDataExport mit allen Daten in strukturiertem Format + """ + export = UserDataExport( + exportedAt=getUtcTimestamp(), + userId=currentUser.id, + format=format + ) + + # 1. Persönliche Daten + export.personalData = { + "id": currentUser.id, + "username": currentUser.username, + "email": currentUser.email, + "fullName": currentUser.fullName, + "language": currentUser.language, + "createdAt": currentUser.createdAt, + "lastLogin": currentUser.lastLogin + } + + # 2. Mandant-Mitgliedschaften + memberships = db.getUserMandates(currentUser.id) + export.mandateMemberships = [] + for m in memberships: + mandate = db.getMandate(m.mandateId) + roles = db.getRolesForUserMandate(m.id) + export.mandateMemberships.append({ + "mandateId": m.mandateId, + "mandateName": mandate.name if mandate else "Unknown", + "roles": [r.roleLabel for r in roles], + "joinedAt": m.createdAt, + "enabled": m.enabled + }) + + # 3. Feature-Zugänge + accesses = db.getFeatureAccessesForUser(currentUser.id) + export.featureAccesses = [] + for a in accesses: + instance = db.getFeatureInstance(a.featureInstanceId) + roles = db.getRolesForFeatureAccess(a.id) + export.featureAccesses.append({ + "featureInstanceId": a.featureInstanceId, + "featureCode": instance.featureCode if instance else "Unknown", + "instanceLabel": instance.label if instance else "Unknown", + "roles": [r.roleLabel for r in roles], + "grantedAt": a.createdAt + }) + + # 4. Vom User erstellte Daten (pro Mandant, _createdBy = userId) + export.createdData = {} + for m in memberships: + mandateData = {} + + # Alle Tabellen mit _createdBy durchsuchen + for table in DATA_TABLES_WITH_CREATOR: + records = db.getRecordset( + table, + recordFilter={"_createdBy": currentUser.id, "mandateId": m.mandateId} + ) + if records: + # Nur Metadaten, keine sensiblen Inhalte anderer User + mandateData[table.__name__] = [ + {"id": r["id"], "createdAt": r.get("_createdAt"), "type": table.__name__} + for r in records + ] + + if mandateData: + export.createdData[m.mandateId] = mandateData + + # 5. Audit-Log (eigene Aktionen der letzten 12 Monate) + export.auditLog = db.getAuditEntriesForUser( + currentUser.id, + since=getUtcTimestamp() - (365 * 24 * 3600) + ) + + # Audit: Export wurde angefordert + audit_logger.logDataAccess( + userId=str(currentUser.id), + mandateId="all", + action="gdpr_data_export", + details=f"Format: {format}" + ) + + return export + + +class UserDataExport(BaseModel): + """DSGVO Art. 15 - Vollständiger Datenexport""" + exportedAt: float + userId: str + format: str + personalData: Dict[str, Any] + mandateMemberships: List[Dict[str, Any]] + featureAccesses: List[Dict[str, Any]] + createdData: Dict[str, Dict[str, List[Dict]]] # mandateId → table → records + auditLog: List[Dict[str, Any]] +``` + +### 11.2 Recht auf Datenübertragbarkeit (Art. 20 DSGVO) + +**Anforderung:** Daten in strukturiertem, maschinenlesbarem Format bereitstellen. + +```python +@router.get("/api/user/me/data-portability") +@limiter.limit("3/hour") +async def exportPortableData( + mandateId: Optional[str] = Query(None, description="Optional: nur für einen Mandanten"), + currentUser: User = Depends(getCurrentUser) +) -> Response: + """ + Exportiert User-Daten in portablem Format (DSGVO Art. 20). + + Format: JSON mit standardisierten Feldnamen + Kann direkt in andere Systeme importiert werden. + + Returns: + JSON-Datei zum Download + """ + portableData = { + "schema_version": "1.0", + "exported_at": datetime.utcnow().isoformat(), + "source_system": "PowerOn Platform", + "user": { + "identifier": currentUser.username, + "email": currentUser.email, + "display_name": currentUser.fullName, + "preferred_language": currentUser.language + }, + "data": {} + } + + # Mandanten filtern + memberships = db.getUserMandates(currentUser.id) + if mandateId: + memberships = [m for m in memberships if m.mandateId == mandateId] + + for m in memberships: + mandate = db.getMandate(m.mandateId) + mandateExport = { + "organization_name": mandate.name if mandate else "Unknown", + "membership_since": m.createdAt, + "content": {} + } + + # Feature-spezifische Daten exportieren + accesses = db.getFeatureAccessesForUser(currentUser.id) + for access in accesses: + instance = db.getFeatureInstance(access.featureInstanceId) + if not instance or instance.mandateId != m.mandateId: + continue + + featureData = _exportFeatureData( + featureCode=instance.featureCode, + instanceId=instance.id, + userId=currentUser.id + ) + + if featureData: + mandateExport["content"][instance.featureCode] = featureData + + portableData["data"][m.mandateId] = mandateExport + + # Audit + audit_logger.logDataAccess( + userId=str(currentUser.id), + mandateId=mandateId or "all", + action="gdpr_data_portability", + details=f"Exported portable data" + ) + + # Als Download-Datei zurückgeben + filename = f"poweron_export_{currentUser.username}_{datetime.utcnow().strftime('%Y%m%d')}.json" + + return Response( + content=json.dumps(portableData, indent=2, ensure_ascii=False), + media_type="application/json", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"' + } + ) + + +def _exportFeatureData(featureCode: str, instanceId: str, userId: str) -> Dict: + """ + Exportiert Feature-spezifische Daten im portablen Format. + Jedes Feature definiert sein Export-Schema. + """ + exporters = { + "trustee": _exportTrusteeData, + "chatbot": _exportChatbotData, + "workflow": _exportWorkflowData, + # Weitere Features... + } + + exporter = exporters.get(featureCode) + if exporter: + return exporter(instanceId, userId) + + return {} + + +def _exportTrusteeData(instanceId: str, userId: str) -> Dict: + """Exportiert Trustee-Daten im portablen Format.""" + contracts = db.getRecordset( + TrusteeContract, + recordFilter={"featureInstanceId": instanceId, "_createdBy": userId} + ) + + return { + "contracts": [ + { + "reference": c.get("reference"), + "client_name": c.get("clientName"), + "created_at": c.get("_createdAt"), + "status": c.get("status") + # Keine internen IDs, nur portable Daten + } + for c in contracts + ] + } +``` + +### 11.3 Recht auf Löschung (Art. 17 DSGVO) + +**Bereits implementiert via CASCADE DELETE**, aber expliziter Endpoint für User-initiated Löschung: + +```python +@router.delete("/api/user/me") +@limiter.limit("1/day") +async def deleteOwnAccount( + confirmation: str = Body(..., description="Must be 'DELETE MY ACCOUNT'"), + currentUser: User = Depends(getCurrentUser) +) -> Dict: + """ + User löscht eigenen Account (DSGVO Art. 17). + + ACHTUNG: Unwiderruflich! Löscht: + - User-Datensatz + - Alle Mandant-Mitgliedschaften (CASCADE) + - Alle Feature-Zugänge (CASCADE) + - Alle vom User erstellten Daten werden anonymisiert (_createdBy → 'deleted') + """ + if confirmation != "DELETE MY ACCOUNT": + raise HTTPException(400, "Confirmation text must be exactly 'DELETE MY ACCOUNT'") + + # SysAdmin kann sich nicht selbst löschen + if currentUser.isSysAdmin: + raise HTTPException(400, + "SysAdmin accounts cannot be self-deleted. Contact another SysAdmin.") + + # Vor dem Löschen: Datenexport anbieten + # (Frontend sollte dies vorher anzeigen) + + # 1. Anonymisiere erstellte Daten (statt löschen - für Audit-Trail) + for table in DATA_TABLES_WITH_CREATOR: + db.execute( + f'UPDATE "{table.__name__}" SET "_createdBy" = \'deleted-user\' ' + f'WHERE "_createdBy" = :userId', + {"userId": currentUser.id} + ) + + # 2. Audit BEVOR User gelöscht wird + audit_logger.logSecurityEvent( + userId=str(currentUser.id), + mandateId="all", + action="gdpr_account_deletion", + details=f"User {currentUser.username} deleted own account" + ) + + # 3. User löschen (CASCADE löscht Memberships, Accesses, Tokens, etc.) + db.delete(User, currentUser.id) + + return {"message": "Account successfully deleted", "deletedAt": getUtcTimestamp()} +``` + +### 11.4 Übersicht DSGVO-Endpoints + +| Endpoint | DSGVO | Beschreibung | Rate Limit | +|----------|-------|--------------|------------| +| `GET /api/user/me/data-export` | Art. 15 | Vollständiger Datenexport (JSON/CSV) | 5/hour | +| `GET /api/user/me/data-portability` | Art. 20 | Portables Format für Übertragung | 3/hour | +| `DELETE /api/user/me` | Art. 17 | Account-Löschung | 1/day | +| `PUT /api/user/me` | Art. 16 | Datenberichtigung (bereits vorhanden) | 30/min | + +--- + +## 14. Zusammenfassung + +### 14.1 Key Design Decisions + +| Entscheidung | Lösung | +|--------------|--------| +| User-Mandant-Beziehung | m:n via UserMandate + Junction Table UserMandateRole | +| Feature-Zugriff | m:n via FeatureAccess + Junction Table FeatureAccessRole | +| Rollen-Verknüpfung | Via Junction Tables (keine Array-Felder) für CASCADE DELETE | +| Role-Kontext | **IMMUTABLE** (`mandateId`, `featureInstanceId`, `featureCode`) | +| AccessRule-Kontext | **IMMUTABLE** (`context`, `roleId`) + **Scope-Validierung** | +| Admin-Funktionen | `isSysAdmin` Flag = System-Zugriff, **KEIN** Daten-Zugriff, **KEINE** Self-Service-Eskalation | +| System-Funktionen | Endpoints ohne Mandant-Kontext | +| RBAC-Scope | Via Role: Global → Mandant → Instanz | +| Item-Notation | Dot-separated hierarchisch | +| Orphan Prevention | PostgreSQL CASCADE DELETE + **Application-Level Cleanup** (kein Trigger) | +| RBAC-Design | **Stateless** - kein Cache, direkt aus DB (Cloud-Ready) | +| Token-Design | User-gebunden, **nicht** Mandant-gebunden (parallele Mandant-Arbeit) | +| Template-Sync | Explizite Synchronisation von Rollen via Admin-Funktion (nur für neue Instanzen) | +| Initial-Rollen | System-Rollen werden im **Bootstrap-Modul** (`interfaceBootstrap.py`) erstellt | +| Deployment | **Greenfield** - keine Migration, keine Backwards Compatibility | +| DB-Skalierung | Infrastruktur-Aufgabe (Read Replicas, Pooling), nicht Code-Aufgabe | +| Read Replica | Kritische RBAC-Reads immer vom **Primary** (Replication Lag) | +| **RBAC Export/Import** | **Global** (SysAdmin), **Mandant** (Mandate-Admin), **Instanz** (Feature-Admin) | +| **Invitation-Flow** | Token-basiert, via Mandate-Admin, Self-Service Registration | + +### 14.2 Bestehende Security-Features (Gateway) + +| Feature | Datei | Status | +|---------|-------|--------| +| Password Hashing (Argon2) | `interfaceDbAppObjects.py` | ✅ Vorhanden | +| JWT Token mit Refresh | `jwtService.py` | ✅ Vorhanden | +| Token Revocation | `interfaceDbAppObjects.py` | ✅ Vorhanden | +| httpOnly Cookies | `jwtService.py` | ✅ Vorhanden | +| Rate Limiting | `routeSecurityLocal.py` | ✅ Vorhanden | +| CSRF Protection | `app.py` | ✅ Vorhanden | +| Audit Logging | `auditLogger.py` | ✅ Vorhanden | + +### 14.3 Vollständige Dateiliste (Code-Scan) + +**Legende:** 🔴 Breaking Change | 🟡 Anpassung | 🟢 NEU + +#### 14.3.1 Datamodels (10 Dateien) + +| Datei | Typ | Änderung | +|-------|-----|----------| +| `datamodelUam.py` | 🔴 | **Entferne** `User.mandateId`, `User.roleLabels`; **Add** `User.isSysAdmin` | +| `datamodelRbac.py` | 🔴 | `Role` erweitern: `mandateId`, `featureInstanceId`, `featureCode` | +| `datamodelChat.py` | 🟡 | `mandateId` bleibt, aber Zugriff via Context | +| `datamodelTrustee.py` | 🟡 | `mandateId` bleibt, aber Zugriff via Context | +| `datamodelRealEstate.py` | 🟡 | `mandateId` bleibt, aber Zugriff via Context | +| `datamodelVoice.py` | 🟡 | `mandateId` bleibt, aber Zugriff via Context | +| `datamodelFiles.py` | 🟡 | `mandateId` bleibt, aber Zugriff via Context | +| `datamodelMessaging.py` | 🟡 | `mandateId` bleibt, aber Zugriff via Context | +| `datamodelNeutralizer.py` | 🟡 | `mandateId` bleibt, aber Zugriff via Context | +| `datamodelSecurity.py` | 🟡 | Token ohne `mandateId` | +| `datamodelFeatures.py` | 🟢 | NEU: `Feature`, `FeatureInstance`, `FeatureAccess` | +| `datamodelMembership.py` | 🟢 | NEU: `UserMandate`, `UserMandateRole`, `FeatureAccessRole` | +| `datamodelInvitation.py` | 🟢 | NEU: `Invitation` | + +#### 14.3.2 Interfaces (8 Dateien) + +| Datei | Typ | Änderung | +|-------|-----|----------| +| `interfaceDbAppObjects.py` | 🔴 | `setUserContext()` ohne `mandateId`, neue Membership-Methoden | +| `interfaceDbTrusteeObjects.py` | 🔴 | `currentUser.mandateId` → Request-Context | +| `interfaceDbChatObjects.py` | 🔴 | `currentUser.mandateId` → Request-Context | +| `interfaceDbRealEstateObjects.py` | 🔴 | `currentUser.mandateId` → Request-Context | +| `interfaceDbComponentObjects.py` | 🔴 | `currentUser.mandateId` → Request-Context | +| `interfaceVoiceObjects.py` | 🔴 | `currentUser.mandateId` → Request-Context | +| `interfaceRbac.py` | 🔴 | `buildRbacWhereClause()` mit explizitem `mandateId` Parameter | +| `interfaceBootstrap.py` | 🔴 | Initiale Rollen ohne `roleLabels` am User | +| `interfaceFeatures.py` | 🟢 | NEU: Template-Sync, Application-Level Cleanup | + +#### 14.3.3 Routes (18 Dateien) + +| Datei | Typ | Änderung | +|-------|-----|----------| +| `routeSecurityLocal.py` | 🔴 | Login ohne `mandateId` im Token, `roleLabels` aus Memberships | +| `routeSecurityMsft.py` | 🔴 | OAuth ohne `mandateId` im Token | +| `routeSecurityGoogle.py` | 🔴 | OAuth ohne `mandateId` im Token | +| `routeSecurityAdmin.py` | 🔴 | SysAdmin-Check via `isSysAdmin`, `roleLabels` → `roleIds` | +| `routeDataUsers.py` | 🔴 | Filter via Membership statt `mandateId` | +| `routeDataMandates.py` | 🟡 | Mandate-Admin Berechtigungen via Membership | +| `routeDataTrustee.py` | 🔴 | Context aus Header, nicht aus `currentUser` | +| `routeRbac.py` | 🔴 | Scope-Validierung, `roleLabels` → `roleIds` | +| `routeAdminRbacRoles.py` | 🔴 | Rollen mit Kontext-Validierung | +| `routeAdminAutomationEvents.py` | 🟡 | Context-Handling anpassen | +| `routeChatbot.py` | 🔴 | `currentUser.mandateId` → Request-Context | +| `routeRealEstate.py` | 🔴 | `currentUser.mandateId` → Request-Context | +| `routeDataNeutralization.py` | 🔴 | `currentUser.mandateId` → Request-Context | +| `routeFeatures.py` | 🟢 | NEU: Feature-Instanz-Management | +| `routeGdpr.py` | 🟢 | NEU: DSGVO-Endpoints | +| `routeRbacExport.py` | 🟢 | NEU: RBAC Export/Import | +| `routeInvitations.py` | 🟢 | NEU: Invitation-Flow | +| `routeAdmin.py` | 🟢 | NEU: SysAdmin-Endpoints | + +#### 14.3.4 Security & Auth (3 Dateien) + +| Datei | Typ | Änderung | +|-------|-----|----------| +| `security/rbac.py` | 🔴 | Bulk Query, IMMUTABLE-Enforcement, SysAdmin-Blockade | +| `auth/authentication.py` | 🔴 | Token ohne `mandateId`, Context aus Headers | +| `auth/tokenRefreshService.py` | 🟡 | Refresh ohne `mandateId` | + +#### 14.3.5 Features (4 Dateien) + +| Datei | Typ | Änderung | +|-------|-----|----------| +| `features/chatbot/mainChatbot.py` | 🔴 | `currentUser.mandateId` → Context | +| `features/realEstate/mainRealEstate.py` | 🔴 | `currentUser.mandateId` → Context | +| `features/dynamicOptions/mainDynamicOptions.py` | 🔴 | `currentUser.mandateId` → Context | +| `features/neutralizePlayground/mainNeutralizePlayground.py` | 🔴 | `currentUser.mandateId` → Context | + +#### 14.3.6 Workflows & Services (3 Dateien) + +| Datei | Typ | Änderung | +|-------|-----|----------| +| `workflows/workflowManager.py` | 🟡 | `mandateId` aus Context statt User | +| `workflows/methods/methodBase.py` | 🔴 | `roleLabels` → `roleIds` | +| `services/serviceNeutralization/mainServiceNeutralization.py` | 🟡 | Context-Handling | + +#### 14.3.7 Shared & Connectors (3 Dateien) + +| Datei | Typ | Änderung | +|-------|-----|----------| +| `shared/auditLogger.py` | 🟡 | `mandateId` aus Context | +| `shared/configuration.py` | 🟡 | Evtl. neue Config-Keys | +| `connectors/connectorDbPostgre.py` | 🔴 | Junction Tables, IMMUTABLE Triggers | + +**Zusammenfassung:** +- 🔴 **Breaking Changes:** 28 Dateien +- 🟡 **Anpassungen:** 12 Dateien +- 🟢 **Neue Dateien:** 9 Dateien +- **Total:** ~49 Dateien betroffen + +--- + +### 14.4 Phasenweise Implementierung (AI-optimiert) + +**Prinzip:** Jede Phase ist **in sich abgeschlossen testbar** und kann separat implementiert werden. + +--- + +#### PHASE 1: Foundation (Datenmodelle & DB) +**Geschätzte Komplexität:** Mittel | **Dateien:** ~8 + +**Ziel:** Neue Datenstrukturen ohne Breaking Changes am bestehenden Code. + +| Schritt | Datei | Aktion | +|---------|-------|--------| +| 1.1 | `datamodelFeatures.py` | 🟢 NEU: `Feature`, `FeatureInstance` | +| 1.2 | `datamodelMembership.py` | 🟢 NEU: `UserMandate`, `UserMandateRole`, `FeatureAccessRole` | +| 1.3 | `datamodelInvitation.py` | 🟢 NEU: `Invitation` | +| 1.4 | `datamodelRbac.py` | 🟡 ADD: `Role.mandateId`, `Role.featureInstanceId`, `Role.featureCode` | +| 1.5 | `datamodelUam.py` | 🟡 ADD: `User.isSysAdmin` (alte Felder NOCH NICHT entfernen!) | +| 1.6 | `connectorDbPostgre.py` | 🟡 ADD: Neue Tabellen, Foreign Keys, IMMUTABLE Triggers | +| 1.7 | `interfaceBootstrap.py` | 🟡 ADD: Initiale Template-Rollen erstellen | + +**Test:** Neue Tabellen existieren, bestehender Code funktioniert noch. + +--- + +#### PHASE 2: RBAC Core (Kern-Logik) +**Geschätzte Komplexität:** Hoch | **Dateien:** ~5 + +**Ziel:** Neues RBAC-System parallel zum alten lauffähig. + +| Schritt | Datei | Aktion | +|---------|-------|--------| +| 2.1 | `security/rbac.py` | 🔴 Neue `getRulesForUserBulk()` mit Junction Tables | +| 2.2 | `interfaceRbac.py` | 🔴 `buildRbacWhereClause()` mit explizitem `mandateId` | +| 2.3 | `interfaceDbAppObjects.py` | 🟡 ADD: Neue Membership-Methoden (parallel zu alten) | +| 2.4 | `interfaceFeatures.py` | 🟢 NEU: Feature-Instanz-Management | + +**Test:** Neue RBAC-Methoden funktionieren, alte noch vorhanden. + +--- + +#### PHASE 3: Auth & Context (Authentifizierung) +**Geschätzte Komplexität:** Hoch | **Dateien:** ~6 + +**Ziel:** Request-Context-System einführen. + +| Schritt | Datei | Aktion | +|---------|-------|--------| +| 3.1 | `auth/authentication.py` | 🔴 `getRequestContext()` mit Headers | +| 3.2 | `routeSecurityLocal.py` | 🔴 Login mit neuem Token (ohne `mandateId`) | +| 3.3 | `routeSecurityMsft.py` | 🔴 OAuth anpassen | +| 3.4 | `routeSecurityGoogle.py` | 🔴 OAuth anpassen | +| 3.5 | `datamodelSecurity.py` | 🟡 Token ohne `mandateId` | +| 3.6 | `auth/tokenRefreshService.py` | 🟡 Refresh anpassen | + +**Test:** Login funktioniert mit neuem Token-Format. + +--- + +#### PHASE 4: Routes Migration (Schrittweise) +**Geschätzte Komplexität:** Mittel-Hoch | **Dateien:** ~18 + +**Ziel:** Alle Routes auf neues Context-System migrieren. + +**4.A - Admin Routes:** +| Schritt | Datei | +|---------|-------| +| 4.A.1 | `routeSecurityAdmin.py` | +| 4.A.2 | `routeAdminRbacRoles.py` | +| 4.A.3 | `routeDataUsers.py` | +| 4.A.4 | `routeDataMandates.py` | +| 4.A.5 | `routeRbac.py` | + +**4.B - Feature Routes:** +| Schritt | Datei | +|---------|-------| +| 4.B.1 | `routeDataTrustee.py` | +| 4.B.2 | `routeChatbot.py` | +| 4.B.3 | `routeRealEstate.py` | +| 4.B.4 | `routeDataNeutralization.py` | +| 4.B.5 | `routeAdminAutomationEvents.py` | + +**Test:** Jede Route einzeln testbar. + +--- + +#### PHASE 5: Interfaces Migration +**Geschätzte Komplexität:** Mittel | **Dateien:** ~6 + +**Ziel:** Alle Interfaces auf Context-System migrieren. + +| Schritt | Datei | +|---------|-------| +| 5.1 | `interfaceDbTrusteeObjects.py` | +| 5.2 | `interfaceDbChatObjects.py` | +| 5.3 | `interfaceDbRealEstateObjects.py` | +| 5.4 | `interfaceDbComponentObjects.py` | +| 5.5 | `interfaceVoiceObjects.py` | + +**Test:** Interfaces funktionieren mit neuem Context. + +--- + +#### PHASE 6: Features Migration +**Geschätzte Komplexität:** Mittel | **Dateien:** ~7 + +**Ziel:** Feature-Module auf Context-System migrieren. + +| Schritt | Datei | +|---------|-------| +| 6.1 | `features/chatbot/mainChatbot.py` | +| 6.2 | `features/realEstate/mainRealEstate.py` | +| 6.3 | `features/dynamicOptions/mainDynamicOptions.py` | +| 6.4 | `features/neutralizePlayground/mainNeutralizePlayground.py` | +| 6.5 | `workflows/workflowManager.py` | +| 6.6 | `workflows/methods/methodBase.py` | +| 6.7 | `services/serviceNeutralization/mainServiceNeutralization.py` | + +**Test:** Features funktionieren mit neuem Context. + +--- + +#### PHASE 7: Cleanup & Breaking Changes +**Geschätzte Komplexität:** Niedrig | **Dateien:** ~3 + +**Ziel:** Alte Felder entfernen (erst wenn alles migriert ist!) + +| Schritt | Datei | Aktion | +|---------|-------|--------| +| 7.1 | `datamodelUam.py` | 🔴 **ENTFERNE** `User.mandateId`, `User.roleLabels` | +| 7.2 | `interfaceDbAppObjects.py` | 🔴 Alte Methoden entfernen | +| 7.3 | `interfaceBootstrap.py` | 🔴 Alte roleLabels-Logik entfernen | + +**Test:** Vollständiger Integrationstest. + +--- + +#### PHASE 8: Neue Features (Optional, parallel möglich) +**Geschätzte Komplexität:** Mittel | **Dateien:** ~6 + +**Ziel:** Neue Funktionalität hinzufügen. + +| Schritt | Datei | +|---------|-------| +| 8.1 | `routeFeatures.py` (NEU) | +| 8.2 | `routeInvitations.py` (NEU) | +| 8.3 | `routeRbacExport.py` (NEU) | +| 8.4 | `routeGdpr.py` (NEU) | +| 8.5 | `routeAdmin.py` (NEU) | + +**Test:** Neue Endpoints funktionieren. + +--- + +### 14.5 AI-Implementierungs-Empfehlung + +| Phase | Dateien | Empfehlung | +|-------|---------|------------| +| 1 | 8 | ✅ **Einzeln umsetzbar** - Keine Breaking Changes | +| 2 | 5 | ✅ **Einzeln umsetzbar** - Parallele Implementierung | +| 3 | 6 | ⚠️ **Zusammen umsetzen** - Auth ist kritisch | +| 4 | 18 | ✅ **In Gruppen** - A und B separat | +| 5 | 6 | ✅ **Einzeln umsetzbar** | +| 6 | 7 | ✅ **Einzeln umsetzbar** | +| 7 | 3 | ⚠️ **Erst nach Phase 6** - Breaking! | +| 8 | 6 | ✅ **Parallel zu Phase 4-6** | + +**Gesamtstrategie:** +- Phase 1-2 zuerst (Foundation) +- Phase 3 als Block (Auth-Critical) +- Phase 4-6 parallel/sequentiell +- Phase 7 erst ganz am Ende +- Phase 8 jederzeit parallel + +### 14.6 DSGVO-Compliance Checkliste + +| Anforderung | Implementierung | Status | +|-------------|-----------------|--------| +| Audit-Trail | `auditLogger.py` für alle Security-Events | ✅ | +| Audit-Retention | Aufbewahrungsfristen je Log-Typ (siehe 7.3) | ✅ Definiert | +| Löschrecht (Art. 17) | `DELETE /api/user/me` + CASCADE DELETE | ✅ Definiert | +| Zugriffsrecht (Art. 15) | `GET /api/user/me/data-export` | ✅ Definiert | +| Datenübertragbarkeit (Art. 20) | `GET /api/user/me/data-portability` | ✅ Definiert | +| Berichtigungsrecht (Art. 16) | `PUT /api/user/me` (bereits vorhanden) | ✅ | +| Verschlüsselung at rest | DB-Level (managed by cloud provider) | ✅ | +| Verschlüsselung in transit | HTTPS enforced | ✅ | + +> **Out of Scope:** Daten-Anonymisierung bei Export (organisatorische Verantwortung des Mandanten) diff --git a/implementation/Saas Multi Tenant Mandate/mandate_implementation_ui_myla.md b/implementation/Saas Multi Tenant Mandate/mandate_implementation_ui_myla.md new file mode 100644 index 0000000..85206bc --- /dev/null +++ b/implementation/Saas Multi Tenant Mandate/mandate_implementation_ui_myla.md @@ -0,0 +1,1925 @@ +# Multi-Tenant UI Konzept - Frontend Nyla + +## Architektur für mandantenunabhängige Benutzer + +**Version:** 1.0 +**Datum:** 14. Januar 2026 +**Status:** Entwurf +**Frontend:** Nyla (React) + +--- + +## 1. Grundprinzip + +### 1.1 User ohne Mandanten-Zugehörigkeit + +Ein User gehört **keinem Mandanten** an. Er sieht: + +``` +┌─────────────────────────────────────────────────────────┐ +│ User: patrick@example.com │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ SYSTEM (immer verfügbar) │ │ +│ │ • Profil bearbeiten │ │ +│ │ • Einstellungen │ │ +│ │ • Abmelden │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ FEATURE: Trustee │ │ +│ │ ├─ Instanz: "Soha Treuhand / PamoCreate AG" │ │ +│ │ ├─ Instanz: "Soha Treuhand / ValueOn AG" │ │ +│ │ └─ Instanz: "SwissTreu / Firma X" │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ FEATURE: Chatbot │ │ +│ │ └─ Instanz: "Althaus / Management-Tool" │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 1.2 Keine mandateId im Frontend + +**Alt:** Frontend speicherte `mandateId` im State und sendete bei jedem Request. + +**Neu:** Frontend kennt nur **Feature-Instanzen**. Der Mandant ergibt sich aus der Instanz. + +```typescript +// ALT +const currentMandateId = useStore(state => state.mandateId); +await api.get('/trustee/contracts', { params: { mandateId: currentMandateId }}); + +// NEU +const currentInstance = useStore(state => state.currentFeatureInstance); +await api.get('/trustee/contracts', { params: { instanceId: currentInstance.id }}); +// Backend ermittelt mandateId aus der Instanz +``` + +--- + +## 2. Mandat- und Feature-Struktur + +### 2.1 Mandat als oberste Ebene (Level 1) + +Die Navigation beginnt auf **Mandanten-Ebene**. Darunter liegen Features und deren Instanzen. + +```typescript +interface Mandate { + id: string; // mandateId + name: string; // Anzeige-Name + features: MandateFeature[]; +} + +interface MandateFeature { + code: string; // "trustee", "chatbot", "workflow-dynamic", ... + label: I18nLabel; // { en: "Trustee", de: "Treuhand" } + icon: string; // Material Icon Name + instances: FeatureInstance[]; +} + +interface FeatureInstance { + id: string; // UUID der Instanz + featureCode: string; // "trustee" + mandateId: string; // Zugehöriger Mandant + mandateName: string; // Für Anzeige + instanceLabel: string; // z.B. "PamoCreate AG" + userRole: string; // Rolle des Users in dieser Instanz + permissions: InstancePermissions; // Summarische Berechtigungen +} +``` + +### 2.2 Generisches Laden (Gateway → UI) + +Die UI rendert Mandanten/Features/Instanzen **generisch** aus dem Gateway-Connector. Ein Endpoint liefert alle sichtbaren Mandate mit deren Feature-Instanzen **inkl.** summarischen Berechtigungen: + +```json +GET /features/my +{ + "mandates": [ + { + "id": "mand-1", + "name": "Soha Treuhand", + "features": [ + { + "code": "trustee", + "label": { "de": "Treuhand" }, + "instances": [ + { + "id": "inst-123", + "instanceLabel": "PamoCreate AG", + "permissions": { ... } + } + ] + }, + { + "code": "workflow-dynamic", + "label": { "de": "Workflow (Dynamic)" }, + "instances": [ ... ] + } + ] + }, + { + "id": "mand-2", + "name": "SwissTreu", + "features": [ ... ] + } + ] +} +``` + +Die UI muss nur diese Struktur rendern; keine Feature-spezifische Navigation wird hartkodiert. + +### 2.3 Generisches Instanz-Handling + +Das Handling für Feature-Instanzen bleibt **generisch**. Die aktuelle Instanz ergibt sich aus der **URL** (Route-Parameter): + +```typescript +// stores/featureStore.ts + +interface FeatureState { + features: Feature[]; + + // Actions + loadFeatures: () => Promise; + getInstanceById: (instanceId: string) => FeatureInstance | undefined; + getFeatureByCode: (featureCode: string) => Feature | undefined; +} + +const useFeatureStore = create((set, get) => ({ + mandates: [], + + loadFeatures: async () => { + // Ein API-Call lädt alle Mandate + Features + Instanzen + Permissions + const response = await api.get('/features/my'); + set({ mandates: response.data.mandates }); + }, + + getInstanceById: (instanceId) => { + return get().mandates + .flatMap(m => m.features) + .flatMap(f => f.instances) + .find(i => i.id === instanceId); + }, + + getFeatureByCode: (featureCode) => { + return get().mandates + .flatMap(m => m.features) + .find(f => f.code === featureCode); + } +})); +``` + +### 2.3 Instanz aus URL-Parameter + +Die aktuelle Instanz wird **nicht** im Store gespeichert, sondern aus der URL gelesen: + +```typescript +// hooks/useCurrentInstance.ts + +export function useCurrentInstance(): FeatureInstance | undefined { + const { instanceId } = useParams<{ instanceId: string }>(); + const getInstanceById = useFeatureStore(s => s.getInstanceById); + + return instanceId ? getInstanceById(instanceId) : undefined; +} + +// Verwendung in Komponenten: +function ContractList() { + const instance = useCurrentInstance(); + + if (!instance) { + return ; // Keine Instanz in URL + } + + // Arbeite mit instance.permissions, instance.mandateId, etc. +} +``` + +--- + +## 3. Berechtigungs-Abfrage (Summarisch) + +### 3.1 Problem: Rate Limits + +**Vermeiden:** +```typescript +// SCHLECHT: Ein Request pro Objekt/Feld +for (const contract of contracts) { + const canEdit = await api.get(`/rbac/check?table=Contract&id=${contract.id}&action=update`); + const canDelete = await api.get(`/rbac/check?table=Contract&id=${contract.id}&action=delete`); +} +``` + +### 3.2 Lösung: Summarische Berechtigungen + +Berechtigungen werden **einmalig pro Feature-Instanz** geladen: + +```typescript +interface InstancePermissions { + // Tabellen-Level (CRUD pro Tabelle) + tables: { + [tableName: string]: { + view: boolean; + read: AccessLevel; // "n" | "m" | "g" | "a" + create: AccessLevel; + update: AccessLevel; + delete: AccessLevel; + } + }; + + // Feld-Level (nur wo eingeschränkt) + fields?: { + [tableName: string]: { + [fieldName: string]: { + read: boolean; + write: boolean; + } + } + }; + + // View-Level (Navigation) + views: { + [viewCode: string]: boolean; + }; +} +``` + +### 3.3 API-Endpunkt für Permissions + +```typescript +// GET /features/my +// Lädt alles in einem Request + +{ + "features": [ + { + "code": "trustee", + "label": { "en": "Trustee", "de": "Treuhand" }, + "instances": [ + { + "id": "inst-123", + "mandateId": "mand-456", + "mandateName": "Soha Treuhand", + "instanceLabel": "PamoCreate AG", + "userRole": "customer", + "permissions": { + "tables": { + "TrusteeOrganisation": { "view": true, "read": "m", "create": "n", "update": "m", "delete": "n" }, + "TrusteeContract": { "view": true, "read": "m", "create": "n", "update": "n", "delete": "n" }, + "TrusteeDocument": { "view": true, "read": "m", "create": "m", "update": "m", "delete": "n" } + }, + "views": { + "trustee-dashboard": true, + "trustee-contracts": true, + "trustee-admin": false + } + } + } + ] + } + ] +} +``` + +### 3.4 Permission-Hooks im Frontend + +```typescript +// hooks/usePermissions.ts + +export function useTablePermission(tableName: string) { + const instance = useCurrentInstance(); // Aus URL-Parameter + + if (!instance) { + return { view: false, read: 'n', create: 'n', update: 'n', delete: 'n' }; + } + + return instance.permissions.tables[tableName] ?? { + view: false, read: 'n', create: 'n', update: 'n', delete: 'n' + }; +} + +export function useCanView(viewCode: string): boolean { + const instance = useCurrentInstance(); // Aus URL-Parameter + return instance?.permissions.views[viewCode] ?? false; +} + +export function useCanEditRecord(tableName: string, record: any): boolean { + const permission = useTablePermission(tableName); + const userId = useAuthStore(s => s.user?.id); + + switch (permission.update) { + case 'n': return false; + case 'm': return record._createdBy === userId; + case 'g': return true; // Instanz-Scope + case 'a': return true; // Alle + default: return false; + } +} +``` + +--- + +## 4. Navigation & UI-Struktur + +### 4.1 Hauptnavigation (Level 1 = Mandant) + +``` +┌────────────────────────────────────────────────────────────┐ +│ [Logo] PowerOn [User] [Logout] │ +├────────────────────────────────────────────────────────────┤ +│ │ +│ SYSTEM │ +│ ○ Dashboard │ +│ ○ Profil │ +│ ○ Einstellungen │ +│ │ +│ ▼ Mandant: Soha Treuhand │ +│ │ │ +│ ├─▼ Feature: Trustee │ +│ │ ├─▼ Instanz: PamoCreate AG │ +│ │ │ ○ Übersicht │ +│ │ │ ○ Verträge │ +│ │ │ ○ Dokumente │ +│ │ │ ○ Positionen │ +│ │ │ │ +│ │ └─▼ Instanz: ValueOn AG │ +│ │ ○ Übersicht │ +│ │ ○ Verträge │ +│ │ ○ Dokumente │ +│ │ │ +│ └─▼ Feature: Workflow (Dynamic) │ +│ └─▼ Instanz: Beratung-Playground │ +│ ○ Übersicht │ +│ ○ Runs │ +│ │ +│ ▼ Mandant: SwissTreu │ +│ └─▼ Feature: Trustee │ +│ └─▶ Instanz: Firma X (collapsed) │ +│ │ +│ ───────────────────────────────────── │ +│ ADMIN (nur wenn sysadmin) │ +│ ○ Mandanten │ +│ ○ Users │ +│ ○ RBAC │ +│ │ +└────────────────────────────────────────────────────────────┘ +``` + +**Hierarchie:** +``` +Mandant (Level 1) + └─ Feature (Gruppe) + └─ Instanz (Subgruppe) + └─ Objekte/Views (Navigation Items) +``` + +### 4.2 Generische Navigation (Mandat → Feature → Instanz) + +Die Navigation wird ausschließlich aus `mandates[].features[].instances[]` gerendert (siehe Store). Keine hartcodierten Items. + +```tsx +// components/MandateNav.tsx + +function MandateNav() { + const mandates = useFeatureStore(s => s.mandates); + return ( + <> + {mandates.map(mandate => ( + + ))} + + ); +} +``` + +### 4.3 Mandat- und Feature-Gruppen + +Jedes Mandat enthält Features, jedes Feature enthält Instanzen. + +```tsx +// components/MandateNavGroup.tsx + +function MandateNavGroup({ mandate }: { mandate: Mandate }) { + if (!mandate.features.length) return null; + return ( + + + + {mandate.name} + + {mandate.features.map(feature => ( + + ))} + + ); +} +``` + +```tsx +// components/FeatureNavGroup.tsx + +function FeatureNavGroup({ + feature, + mandateId, +}: { + feature: MandateFeature; + mandateId: string; +}) { + if (!feature.instances.length) return null; + return ( + + + + {feature.label.de} + + + {feature.instances.map(instance => ( + + ))} + + ); +} +``` + +### 4.4 Instanz-Subgruppe Komponente + +```tsx +// components/InstanceNavSubgroup.tsx + +function InstanceNavSubgroup({ instance, featureCode, mandateId }: { + instance: FeatureInstance; + featureCode: string; + mandateId: string; +}) { + const [isExpanded, setIsExpanded] = useState(false); + + return ( + + setIsExpanded(!isExpanded)} + collapsible + > + + {instance.mandateName} / {instance.instanceLabel} + + + + {isExpanded && ( + + {instance.permissions.views[`${featureCode}-dashboard`] && ( + + Übersicht + + )} + {instance.permissions.views[`${featureCode}-contracts`] && ( + + Verträge + + )} + {instance.permissions.views[`${featureCode}-documents`] && ( + + Dokumente + + )} + {instance.permissions.views[`${featureCode}-positions`] && ( + + Positionen + + )} + + )} + + ); +} +``` + +### 4.5 URL-Struktur mit Mandant und Instanz + +Die URL enthält **Mandant** und **Instanz**, damit Kontext eindeutig ist: + +``` +/mandates/{mandateId}/{featureCode}/{instanceId}/dashboard +/mandates/{mandateId}/{featureCode}/{instanceId}/contracts +/mandates/{mandateId}/{featureCode}/{instanceId}/contracts/{contractId} +/mandates/{mandateId}/{featureCode}/{instanceId}/documents + +/mandates/{mandateId}/chatbot/{instanceId}/conversations +/mandates/{mandateId}/chatbot/{instanceId}/settings +``` + +**Router-Setup:** +```tsx +// routes.tsx + +}> + } /> + } /> + } /> + } /> + {/* ... */} + +``` + +--- + +## 5. System-Bereich (Ohne Mandant) + +### 5.1 Immer verfügbare Funktionen + +User ohne Mandant-Zugehörigkeit können: + +```typescript +const SYSTEM_FEATURES = { + // Immer verfügbar + profile: true, + settings: true, + logout: true, + + // Nur für sysadmin + adminMandates: (user) => user.isSysAdmin, + adminUsers: (user) => user.isSysAdmin, + adminRbac: (user) => user.isSysAdmin, +}; +``` + +### 5.2 Dashboard für User ohne Instanzen + +```tsx +// pages/Dashboard.tsx + +function Dashboard() { + const { features } = useFeatureStore(); + const hasInstances = features.some(f => f.instances.length > 0); + + if (!hasInstances) { + return ( + +

Willkommen bei PowerOn

+

Du hast aktuell Zugriff auf keine Feature-Instanzen.

+

Kontaktiere einen Administrator, um Zugriff zu erhalten.

+ + +

Was du jetzt tun kannst:

+
    +
  • Profil bearbeiten
  • +
  • Einstellungen anpassen
  • +
+
+
+ ); + } + + return ; +} +``` + +--- + +## 6. Daten-Requests mit Instanz-Kontext + +### 6.1 API-Client mit Instanz-Parameter + +Da die Instanz-ID in der URL steht, wird sie **explizit** an API-Calls übergeben: + +```typescript +// services/api.ts + +const api = axios.create({ baseURL: '/api' }); + +// Kein Interceptor nötig - Instanz-ID kommt aus URL und wird explizit übergeben +``` + +### 6.2 Feature-spezifische Queries + +```typescript +// hooks/useTrusteeContracts.ts + +export function useTrusteeContracts() { + const instance = useCurrentInstance(); // Aus URL: /trustee/:instanceId/contracts + + return useQuery({ + queryKey: ['trustee', 'contracts', instance?.id], + queryFn: async () => { + if (!instance) throw new Error('No instance in URL'); + + // Instanz-ID explizit im Request + const response = await api.get(`/trustee/${instance.id}/contracts`); + return response.data; + }, + enabled: !!instance && instance.featureCode === 'trustee' + }); +} +``` + +--- + +## 7. Berechtigungs-gesteuerte UI-Elemente + +### 7.1 Conditional Rendering + +```tsx +// components/ContractList.tsx + +function ContractList() { + const { data: contracts } = useTrusteeContracts(); + const tablePermission = useTablePermission('TrusteeContract'); + const userId = useAuthStore(s => s.user?.id); + + const canCreate = tablePermission.create !== 'n'; + + return ( +
+
+

Verträge

+ {canCreate && ( + + )} +
+ + + {contracts?.map(contract => { + const canEdit = canEditRecord(tablePermission.update, contract, userId); + const canDelete = canEditRecord(tablePermission.delete, contract, userId); + + return ( + + {contract.name} + + {canEdit && editContract(contract)} />} + {canDelete && deleteContract(contract)} />} + + + ); + })} +
+
+ ); +} + +function canEditRecord(accessLevel: AccessLevel, record: any, userId: string): boolean { + switch (accessLevel) { + case 'n': return false; + case 'm': return record._createdBy === userId; + case 'g': + case 'a': return true; + default: return false; + } +} +``` + +### 7.2 Permission-Wrapper Komponente + +```tsx +// components/PermissionGate.tsx + +interface PermissionGateProps { + table: string; + action: 'view' | 'read' | 'create' | 'update' | 'delete'; + record?: any; // Für "m" (my) Prüfung + children: React.ReactNode; + fallback?: React.ReactNode; +} + +function PermissionGate({ table, action, record, children, fallback = null }: PermissionGateProps) { + const permission = useTablePermission(table); + const userId = useAuthStore(s => s.user?.id); + + let hasPermission = false; + + if (action === 'view') { + hasPermission = permission.view; + } else { + const level = permission[action]; + if (level === 'n') { + hasPermission = false; + } else if (level === 'm' && record) { + hasPermission = record._createdBy === userId; + } else if (level === 'g' || level === 'a') { + hasPermission = true; + } + } + + return hasPermission ? <>{children} : <>{fallback}; +} + +// Verwendung: + + + + + + + +``` + +--- + +## 8. Login & Session + +### 8.1 Login-Response (ohne mandateId) + +```typescript +// Nach erfolgreichem Login: +{ + "token": "jwt...", + "user": { + "id": "user-123", + "username": "patrick", + "email": "patrick@example.com", + "isSysAdmin": false + }, + "features": [ + // Alle Features + Instanzen + Permissions (siehe Abschnitt 3.3) + ] +} +``` + +### 8.2 Auth-Store + +```typescript +// stores/authStore.ts + +interface AuthState { + user: User | null; + token: string | null; + isAuthenticated: boolean; + + login: (credentials: LoginCredentials) => Promise; + logout: () => void; +} + +const useAuthStore = create((set) => ({ + user: null, + token: null, + isAuthenticated: false, + + login: async (credentials) => { + const response = await api.post('/auth/login', credentials); + + // Token speichern + set({ + user: response.data.user, + token: response.data.token, + isAuthenticated: true + }); + + // Features laden (separater Store) + useFeatureStore.getState().setFeatures(response.data.features); + }, + + logout: () => { + set({ user: null, token: null, isAuthenticated: false }); + useFeatureStore.getState().reset(); + } +})); +``` + +--- + +## 9. Einladungs-Flow + +### 9.1 Einladungs-Link + +``` +https://app.poweron.ch/invite?token=abc123xyz +``` + +### 9.2 Einladungs-Seite + +```tsx +// pages/Invite.tsx + +function InvitePage() { + const token = useSearchParam('token'); + const [inviteData, setInviteData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + // Token validieren (ohne Login) + api.get(`/invite/validate?token=${token}`) + .then(res => setInviteData(res.data)) + .catch(err => setError(err.response?.data?.detail || 'Ungültiger Link')); + }, [token]); + + if (error) { + return ; + } + + if (!inviteData) { + return ; + } + + return ( + +

Einladung zu {inviteData.mandateName}

+

Du wurdest eingeladen, als {inviteData.roleLabel} mitzuarbeiten.

+ + {inviteData.existingUser ? ( + // User existiert bereits - nur Login erforderlich + acceptInvite(token)} + message="Melde dich an, um die Einladung anzunehmen." + /> + ) : ( + // Neuer User - Registrierung + acceptInvite(token, userId)} + /> + )} +
+ ); +} +``` + +### 9.3 Einladungs-Verwaltung (Admin) + +```tsx +// pages/admin/Invitations.tsx + +function InvitationsAdmin() { + const { data: invitations } = useQuery({ + queryKey: ['admin', 'invitations'], + queryFn: () => api.get('/admin/invitations').then(r => r.data) + }); + + const revokeInvite = useMutation({ + mutationFn: (token: string) => api.delete(`/admin/invitations/${token}`), + onSuccess: () => queryClient.invalidateQueries(['admin', 'invitations']) + }); + + return ( +
+

Ausstehende Einladungen

+ + + Email + Mandant + Rolle + Erstellt + Gültig bis + Aktionen + + + {invitations?.map(inv => ( + + {inv.email} + {inv.mandateName} + {inv.roleLabel} + {formatDate(inv.createdAt)} + {formatDate(inv.expiresAt)} + + revokeInvite.mutate(inv.token)} + title="Einladung widerrufen" + /> + + + ))} + +
+
+ ); +} +``` + +--- + +## 10. Administration (Gruppe "Administration") + +Die Administration gliedert sich in **zwei Bereiche**: + +1. **SysAdmin** (isSysAdmin=true): System-weite Verwaltung +2. **Mandate-Admin**: Mandanten-spezifische Verwaltung + +``` +┌────────────────────────────────────────────────────────────┐ +│ Navigation │ +├────────────────────────────────────────────────────────────┤ +│ │ +│ SYSTEM │ +│ ○ Dashboard │ +│ ○ Profil │ +│ │ +│ ▼ Mandant: Soha Treuhand │ +│ ├─▼ Feature: Trustee │ +│ │ └─▼ Instanz: PamoCreate AG │ +│ │ ○ ... │ +│ │ │ +│ └─▼ ADMINISTRATION (nur Mandate-Admin) │ +│ ○ Benutzer │ +│ ○ Rollen │ +│ ○ Berechtigungen │ +│ ○ Feature-Instanzen │ +│ ○ Einladungen │ +│ ○ RBAC Export/Import │ +│ │ +│ ───────────────────────────────────── │ +│ ADMINISTRATION (nur sysAdmin=true) │ +│ ○ Mandanten │ +│ ○ Alle Benutzer │ +│ ○ Globale Rollen (Templates) │ +│ ○ RBAC Templates │ +│ ○ System-Einstellungen │ +│ │ +└────────────────────────────────────────────────────────────┘ +``` + +### 10.1 SysAdmin Pages + +#### 10.1.1 Mandanten-Verwaltung + +```tsx +// pages/admin/Mandates.tsx + +function MandatesAdmin() { + const user = useAuthStore(s => s.user); + if (!user?.isSysAdmin) return ; + + const { data: mandates } = useQuery({ + queryKey: ['admin', 'mandates'], + queryFn: () => api.get('/admin/mandates').then(r => r.data) + }); + + return ( + + + + + + + + Name + Code + Features + User + Erstellt + Aktionen + + + {mandates?.map(mandate => ( + + {mandate.name} + {mandate.code} + + {mandate.featureCount} Features + + + {mandate.userCount} User + + {formatDate(mandate.createdAt)} + + editMandate(mandate)} /> + viewMandateUsers(mandate)} /> + deleteMandate(mandate)} /> + + + ))} + +
+
+ ); +} +``` + +#### 10.1.2 Globale Rollen (Templates) + +```tsx +// pages/admin/GlobalRoles.tsx + +interface GlobalRole { + id: string; + roleLabel: string; + description: I18nLabel; + featureCode: string | null; // null = mandanten-weit, sonst feature-spezifisch + isSystemRole: boolean; + accessRulesCount: number; +} + +function GlobalRolesAdmin() { + const { data: roles } = useQuery({ + queryKey: ['admin', 'roles', 'global'], + queryFn: () => api.get('/admin/rbac/roles?scope=global').then(r => r.data) + }); + + const [selectedRole, setSelectedRole] = useState(null); + + return ( + + + Globale Rollen dienen als Templates. Sie werden beim Erstellen von + Mandanten/Feature-Instanzen kopiert. + + + + + + + + {/* Linke Spalte: Rollen-Liste */} + + + + {roles?.filter(r => !r.featureCode).map(role => ( + setSelectedRole(role)} + /> + ))} + + + {/* Gruppiert nach featureCode */} + {groupBy(roles?.filter(r => r.featureCode), 'featureCode') + .map(([featureCode, featureRoles]) => ( + + {featureRoles.map(role => ( + setSelectedRole(role)} + /> + ))} + + ))} + + + + + {/* Rechte Spalte: AccessRules Editor */} + {selectedRole && ( + + )} + + + ); +} +``` + +#### 10.1.3 AccessRules Editor (Wiederverwendbar) + +```tsx +// components/admin/AccessRulesEditor.tsx + +interface AccessRulesEditorProps { + roleId: string; + isTemplate?: boolean; // Template = global, sonst mandant/instanz-spezifisch + readOnly?: boolean; + onSave?: () => void; +} + +function AccessRulesEditor({ roleId, isTemplate, readOnly, onSave }: AccessRulesEditorProps) { + const { data: rules } = useQuery({ + queryKey: ['rbac', 'rules', roleId], + queryFn: () => api.get(`/rbac/roles/${roleId}/rules`).then(r => r.data) + }); + + const [editedRules, setEditedRules] = useState([]); + + useEffect(() => { + if (rules) setEditedRules(rules); + }, [rules]); + + const saveRules = useMutation({ + mutationFn: (rules: AccessRule[]) => + api.put(`/rbac/roles/${roleId}/rules`, { rules }), + onSuccess: onSave + }); + + return ( + + + {/* Tab: Daten (Tabellen) */} + + r.item.startsWith('table.'))} + onChange={updateDataRules} + readOnly={readOnly} + /> + + + {/* Tab: UI (Views, Komponenten) */} + + r.item.startsWith('ui.'))} + onChange={updateUiRules} + readOnly={readOnly} + /> + + + {/* Tab: Resources */} + + r.item.startsWith('resource.'))} + onChange={updateResourceRules} + readOnly={readOnly} + /> + + + {/* Tab: Raw JSON (für Experten) */} + + + + + + {!readOnly && ( + + + + + )} + + ); +} +``` + +#### 10.1.4 Daten-Regeln Sektion + +```tsx +// components/admin/DataRulesSection.tsx + +interface DataRulesSectionProps { + rules: AccessRule[]; + onChange: (rules: AccessRule[]) => void; + readOnly?: boolean; +} + +function DataRulesSection({ rules, onChange, readOnly }: DataRulesSectionProps) { + // Gruppiere nach Tabelle + const tableRules = groupRulesByTable(rules); + + return ( +
+ + + + + {Object.entries(tableRules).map(([tableName, tableRule]) => ( + + + + {tableName} + {!readOnly && ( + removeTableRule(tableName)} /> + )} + + + + updateTableRule(tableName, 'view', v)} + options={['true', 'false']} + disabled={readOnly} + /> + updateTableRule(tableName, 'read', v)} + options={ACCESS_LEVELS} + disabled={readOnly} + /> + updateTableRule(tableName, 'create', v)} + options={ACCESS_LEVELS} + disabled={readOnly} + /> + updateTableRule(tableName, 'update', v)} + options={ACCESS_LEVELS} + disabled={readOnly} + /> + updateTableRule(tableName, 'delete', v)} + options={ACCESS_LEVELS} + disabled={readOnly} + /> + + + {/* Feld-Level Regeln (optional) */} + + updateTableFields(tableName, fields)} + readOnly={readOnly} + /> + + + + ))} +
+ ); +} + +const ACCESS_LEVELS = [ + { value: 'n', label: 'Keine', color: 'red' }, + { value: 'm', label: 'Eigene', color: 'yellow' }, + { value: 'g', label: 'Gruppe', color: 'blue' }, + { value: 'a', label: 'Alle', color: 'green' }, +]; +``` + +### 10.2 Mandate-Admin Pages + +#### 10.2.1 Mandanten-Benutzer + +```tsx +// pages/mandate/Users.tsx + +function MandateUsersPage() { + const { mandateId } = useParams<{ mandateId: string }>(); + const mandate = useMandateById(mandateId); + + // Prüfe Mandate-Admin Berechtigung + const isMandateAdmin = useIsMandateAdmin(mandateId); + if (!isMandateAdmin) return ; + + const { data: users } = useQuery({ + queryKey: ['mandate', mandateId, 'users'], + queryFn: () => api.get(`/mandates/${mandateId}/users`).then(r => r.data) + }); + + return ( + + + + + + + + + Name + Email + Mandanten-Rollen + Feature-Zugriffe + Aktionen + + + {users?.map(user => ( + + + + {user.displayName} + + {user.email} + + {user.mandateRoles.map(role => ( + + ))} + + + {user.featureAccessCount} Feature-Instanzen + + + openUserRolesDialog(user, mandateId)} + title="Rollen bearbeiten" + /> + removeUserFromMandate(user.id, mandateId)} + title="Aus Mandant entfernen" + /> + + + ))} + +
+
+ ); +} +``` + +#### 10.2.2 Mandanten-Rollen + +```tsx +// pages/mandate/Roles.tsx + +function MandateRolesPage() { + const { mandateId } = useParams<{ mandateId: string }>(); + const mandate = useMandateById(mandateId); + + const { data: roles } = useQuery({ + queryKey: ['mandate', mandateId, 'roles'], + queryFn: () => api.get(`/mandates/${mandateId}/rbac/roles`).then(r => r.data) + }); + + const [selectedRole, setSelectedRole] = useState(null); + + return ( + + + Diese Rollen gelten nur für den Mandanten "{mandate?.name}". + Für globale Template-Änderungen kontaktiere einen System-Administrator. + + + + {/* Linke Spalte: Rollen-Liste */} + + + + + + + + + {roles?.filter(r => !r.featureInstanceId).map(role => ( + setSelectedRole(role)} + showOrigin // Zeigt "Kopiert von Template" Badge + /> + ))} + + + {groupBy(roles?.filter(r => r.featureInstanceId), 'featureInstanceId') + .map(([instanceId, instanceRoles]) => ( + + {instanceRoles.map(role => ( + setSelectedRole(role)} + /> + ))} + + ))} + + + + + {/* Rechte Spalte: AccessRules Editor */} + {selectedRole && ( + + )} + + + ); +} +``` + +#### 10.2.3 RBAC Export/Import + +```tsx +// pages/mandate/RbacExportImport.tsx + +function RbacExportImportPage() { + const { mandateId } = useParams<{ mandateId: string }>(); + const mandate = useMandateById(mandateId); + + const [importMode, setImportMode] = useState<'merge' | 'replace' | 'add_only'>('merge'); + const [importFile, setImportFile] = useState(null); + + const exportRbac = async () => { + const response = await api.get(`/mandates/${mandateId}/rbac/export`, { + responseType: 'blob' + }); + downloadBlob(response.data, `rbac-${mandate?.code}-${formatDate(new Date())}.json`); + }; + + const importRbac = useMutation({ + mutationFn: async () => { + const formData = new FormData(); + formData.append('file', importFile!); + formData.append('mode', importMode); + return api.post(`/mandates/${mandateId}/rbac/import`, formData); + }, + onSuccess: () => { + toast.success('RBAC erfolgreich importiert'); + setImportFile(null); + } + }); + + return ( + + + {/* Export Panel */} + + + + Export + + +

+ Exportiert alle Rollen und Berechtigungen dieses Mandanten als JSON-Datei. + Feature-Instanz-spezifische Regeln werden ebenfalls exportiert. +

+ +
+
+ + {/* Import Panel */} + + + + Import + + + setImportFile(files[0])} + > + {importFile ? ( +
+ + {importFile.name} + setImportFile(null)} /> +
+ ) : ( +

JSON-Datei hier ablegen oder klicken

+ )} +
+ + + + Zusammenführen + Bestehende Regeln aktualisieren, neue hinzufügen + + + Nur hinzufügen + Nur neue Regeln hinzufügen, bestehende nicht ändern + + + Ersetzen + Alle bestehenden Regeln löschen und ersetzen + + + + +
+
+
+
+ ); +} +``` + +#### 10.2.4 Feature-Instanzen Verwaltung + +```tsx +// pages/mandate/FeatureInstances.tsx + +function FeatureInstancesPage() { + const { mandateId } = useParams<{ mandateId: string }>(); + const mandate = useMandateById(mandateId); + + const { data: instances } = useQuery({ + queryKey: ['mandate', mandateId, 'instances'], + queryFn: () => api.get(`/mandates/${mandateId}/feature-instances`).then(r => r.data) + }); + + const { data: availableFeatures } = useQuery({ + queryKey: ['features', 'available'], + queryFn: () => api.get('/features/available').then(r => r.data) + }); + + return ( + + + + + + {/* Gruppiert nach Feature */} + {groupBy(instances, 'featureCode').map(([featureCode, featureInstances]) => { + const feature = availableFeatures?.find(f => f.code === featureCode); + + return ( + + + + {feature?.label.de || featureCode} + {featureInstances.length} Instanzen + + + + + Label + Benutzer + Rollen + Erstellt + Aktionen + + + {featureInstances.map(instance => ( + + {instance.instanceLabel} + + {instance.userCount} User + + + {instance.roleCount} Rollen + + {formatDate(instance.createdAt)} + + openInstanceUsersDialog(instance)} + title="Benutzer verwalten" + /> + navigate(`/mandates/${mandateId}/admin/instances/${instance.id}/roles`)} + title="Rollen & Berechtigungen" + /> + syncInstanceFromTemplate(instance.id)} + title="Von Template synchronisieren" + /> + deleteInstance(instance)} + title="Instanz löschen" + /> + + + ))} + +
+
+
+ ); + })} +
+ ); +} +``` + +### 10.3 User-Rollen Dialog + +```tsx +// components/dialogs/UserRolesDialog.tsx + +interface UserRolesDialogProps { + user: User; + mandateId: string; + onClose: () => void; +} + +function UserRolesDialog({ user, mandateId, onClose }: UserRolesDialogProps) { + const { data: userMembership } = useQuery({ + queryKey: ['user', user.id, 'mandate', mandateId, 'membership'], + queryFn: () => api.get(`/mandates/${mandateId}/users/${user.id}/membership`).then(r => r.data) + }); + + const { data: availableRoles } = useQuery({ + queryKey: ['mandate', mandateId, 'roles', 'available'], + queryFn: () => api.get(`/mandates/${mandateId}/rbac/roles`).then(r => r.data) + }); + + const [selectedMandateRoles, setSelectedMandateRoles] = useState([]); + const [featureAccess, setFeatureAccess] = useState([]); + + useEffect(() => { + if (userMembership) { + setSelectedMandateRoles(userMembership.mandateRoleIds); + setFeatureAccess(userMembership.featureAccess); + } + }, [userMembership]); + + const saveMembership = useMutation({ + mutationFn: () => api.put(`/mandates/${mandateId}/users/${user.id}/membership`, { + mandateRoleIds: selectedMandateRoles, + featureAccess + }), + onSuccess: () => { + toast.success('Rollen gespeichert'); + onClose(); + } + }); + + return ( + + + +
+

{user.displayName}

+ {user.email} +
+
+ + + {/* Mandanten-Rollen */} +
+ + {availableRoles + ?.filter(r => !r.featureInstanceId) + .map(role => ( + + {role.roleLabel} + {role.description?.de} + + ))} + +
+ + {/* Feature-Instanz Zugriffe */} +
+ +
+
+ + + + + +
+ ); +} + +// Feature-Zugriff Editor +function FeatureAccessEditor({ mandateId, value, onChange }) { + const { data: instances } = useQuery({ + queryKey: ['mandate', mandateId, 'instances'], + queryFn: () => api.get(`/mandates/${mandateId}/feature-instances`).then(r => r.data) + }); + + return ( +
+ {groupBy(instances, 'featureCode').map(([featureCode, featureInstances]) => ( + + {featureInstances.map(instance => { + const access = value.find(a => a.instanceId === instance.id); + + return ( + + + toggleInstanceAccess(instance.id, checked)} + /> + {instance.instanceLabel} + + + {access && ( + + updateInstanceRoles(instance.id, roleIds)} + > + {instance.availableRoles.map(role => ( + + {role.roleLabel} + + ))} + + + )} + + ); + })} + + ))} +
+ ); +} +``` + +### 10.4 Navigation für Administration + +```tsx +// components/AdminNavSection.tsx + +function AdminNavSection() { + const user = useAuthStore(s => s.user); + const mandates = useFeatureStore(s => s.mandates); + + return ( + <> + {/* Mandate-Admin Sektionen (pro Mandant) */} + {mandates + .filter(m => userIsMandateAdmin(user, m.id)) + .map(mandate => ( + + + + Administration: {mandate.name} + + + Benutzer + + + Rollen + + + Feature-Instanzen + + + Einladungen + + + RBAC Export/Import + + + ))} + + {/* SysAdmin Sektion */} + {user?.isSysAdmin && ( + + + + System-Administration + + + Mandanten + + + Alle Benutzer + + + Globale Rollen + + + RBAC Templates + + + System-Einstellungen + + + )} + + ); +} +``` + +### 10.5 API-Endpunkte (Admin) + +| Endpunkt | Berechtigung | Beschreibung | +|----------|--------------|--------------| +| **SysAdmin** | | | +| `GET /admin/mandates` | isSysAdmin | Alle Mandanten | +| `POST /admin/mandates` | isSysAdmin | Mandant erstellen | +| `PUT /admin/mandates/{id}` | isSysAdmin | Mandant bearbeiten | +| `DELETE /admin/mandates/{id}` | isSysAdmin | Mandant löschen (CASCADE!) | +| `GET /admin/users` | isSysAdmin | Alle User im System | +| `GET /admin/rbac/roles?scope=global` | isSysAdmin | Globale Template-Rollen | +| `POST /admin/rbac/roles` | isSysAdmin | Template-Rolle erstellen | +| `PUT /admin/rbac/roles/{id}` | isSysAdmin | Template-Rolle bearbeiten | +| `GET /admin/rbac/roles/{id}/rules` | isSysAdmin | AccessRules einer Rolle | +| `PUT /admin/rbac/roles/{id}/rules` | isSysAdmin | AccessRules speichern | +| **Mandate-Admin** | | | +| `GET /mandates/{mid}/users` | Mandate-Admin | User im Mandant | +| `POST /mandates/{mid}/users` | Mandate-Admin | User hinzufügen | +| `DELETE /mandates/{mid}/users/{uid}` | Mandate-Admin | User entfernen | +| `GET /mandates/{mid}/users/{uid}/membership` | Mandate-Admin | User-Mitgliedschaft | +| `PUT /mandates/{mid}/users/{uid}/membership` | Mandate-Admin | Mitgliedschaft ändern | +| `GET /mandates/{mid}/rbac/roles` | Mandate-Admin | Mandanten-Rollen | +| `POST /mandates/{mid}/rbac/roles` | Mandate-Admin | Rolle erstellen | +| `GET /mandates/{mid}/rbac/export` | Mandate-Admin | RBAC exportieren | +| `POST /mandates/{mid}/rbac/import` | Mandate-Admin | RBAC importieren | +| `GET /mandates/{mid}/feature-instances` | Mandate-Admin | Feature-Instanzen | +| `POST /mandates/{mid}/feature-instances` | Mandate-Admin | Instanz erstellen | +| `DELETE /mandates/{mid}/feature-instances/{iid}` | Mandate-Admin | Instanz löschen | +| `POST /mandates/{mid}/feature-instances/{iid}/sync-roles` | Mandate-Admin | Rollen synchronisieren | + +--- + +## 11. Zusammenfassung + +### 11.1 Wichtige Prinzipien + +| Prinzip | Umsetzung | +|---------|-----------| +| **Kein mandateId** | User gehört keinem Mandanten, arbeitet in Feature-Instanzen | +| **Summarische Permissions** | Ein Request lädt alle Berechtigungen pro Instanz | +| **Generisches Feature-Handling** | Alle Features haben gleiche Struktur | +| **Instanz aus URL** | `/feature/{instanceId}/...` - Instanz-ID immer in URL | +| **Navigation = Hierarchie** | Mandant → Feature → Instanz → Views | +| **System-Features immer verfügbar** | Profil, Settings, Logout ohne Instanz | + +### 11.2 State-Architektur + +``` +┌─────────────────────────────────────────────────────────┐ +│ Frontend State │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ AuthStore │ +│ ├─ user: User │ +│ ├─ token: string │ +│ └─ isAuthenticated: boolean │ +│ │ +│ FeatureStore │ +│ ├─ mandates: Mandate[] │ +│ │ └─ features: MandateFeature[] │ +│ │ └─ instances: FeatureInstance[] │ +│ │ └─ permissions: InstancePermissions │ +│ └─ getInstanceById(id): FeatureInstance │ +│ │ +│ Router (URL) │ +│ └─ /mandates/:mandateId/:featureCode/:instanceId/* │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 11.3 API-Endpunkte (Frontend-relevant) + +| Endpunkt | Beschreibung | +|----------|--------------| +| `GET /features/my` | Alle Mandate + Features + Instanzen + Permissions (für User sichtbare) | +| `GET /invite/validate?token=X` | Einladung validieren | +| `POST /invite/accept` | Einladung annehmen | +| `GET /admin/invitations` | Alle Einladungen (Admin) | +| `DELETE /admin/invitations/{token}` | Einladung widerrufen | + +--- + +## 12. Migration (Frontend) + +### 12.1 Zu entfernen + +- `currentMandateId` aus globalem State +- `mandateId` aus API-Requests (Query-Params) +- Mandanten-Switcher + +### 12.2 Zu ändern + +- **Navigation:** Feature → Instanz-Subgruppen → Objekte (hierarchisch) +- **URLs:** Müssen `/:featureCode/:instanceId/...` enthalten +- **Permission-Checks:** Auf `useTablePermission` + `useCurrentInstance` umstellen +- **API-Calls:** Instanz-ID aus URL-Parameter verwenden + +### 12.3 Neu + +- `FeatureStore` mit allen Instanzen und Permissions +- `useCurrentInstance()` Hook (liest aus URL) +- `FeatureNavGroup` + `InstanceNavSubgroup` Komponenten +- `PermissionGate` Wrapper +- Einladungs-Flow UI diff --git a/implementation/Saas Multi Tenant Mandate/ui_concept_nyla.md b/implementation/Saas Multi Tenant Mandate/ui_concept_nyla.md deleted file mode 100644 index f414850..0000000 --- a/implementation/Saas Multi Tenant Mandate/ui_concept_nyla.md +++ /dev/null @@ -1,880 +0,0 @@ -# Multi-Tenant UI Konzept - Frontend Nyla - -## Architektur für mandantenunabhängige Benutzer - -**Version:** 1.0 -**Datum:** 14. Januar 2026 -**Status:** Entwurf -**Frontend:** Nyla (React) - ---- - -## 1. Grundprinzip - -### 1.1 User ohne Mandanten-Zugehörigkeit - -Ein User gehört **keinem Mandanten** an. Er sieht: - -``` -┌─────────────────────────────────────────────────────────┐ -│ User: patrick@example.com │ -│ │ -│ ┌─────────────────────────────────────────────────┐ │ -│ │ SYSTEM (immer verfügbar) │ │ -│ │ • Profil bearbeiten │ │ -│ │ • Einstellungen │ │ -│ │ • Abmelden │ │ -│ └─────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────┐ │ -│ │ FEATURE: Trustee │ │ -│ │ ├─ Instanz: "Soha Treuhand / PamoCreate AG" │ │ -│ │ ├─ Instanz: "Soha Treuhand / ValueOn AG" │ │ -│ │ └─ Instanz: "SwissTreu / Firma X" │ │ -│ └─────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────┐ │ -│ │ FEATURE: Chatbot │ │ -│ │ └─ Instanz: "Althaus / Management-Tool" │ │ -│ └─────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────┘ -``` - -### 1.2 Keine mandateId im Frontend - -**Alt:** Frontend speicherte `mandateId` im State und sendete bei jedem Request. - -**Neu:** Frontend kennt nur **Feature-Instanzen**. Der Mandant ergibt sich aus der Instanz. - -```typescript -// ALT -const currentMandateId = useStore(state => state.mandateId); -await api.get('/trustee/contracts', { params: { mandateId: currentMandateId }}); - -// NEU -const currentInstance = useStore(state => state.currentFeatureInstance); -await api.get('/trustee/contracts', { params: { instanceId: currentInstance.id }}); -// Backend ermittelt mandateId aus der Instanz -``` - ---- - -## 2. Feature-Objekt-Struktur - -### 2.1 Feature als UI-Gruppe - -Jedes Feature ist ein **Objekt**, das im UI als Gruppe organisiert ist: - -```typescript -interface Feature { - code: string; // "trustee", "chatbot", "crm" - label: I18nLabel; // { en: "Trustee", de: "Treuhand" } - icon: string; // Material Icon Name - instances: FeatureInstance[]; -} - -interface FeatureInstance { - id: string; // UUID der Instanz - featureCode: string; // "trustee" - mandateId: string; // Referenz (für Backend) - mandateName: string; // "Soha Treuhand" (für Anzeige) - instanceLabel: string; // "PamoCreate AG" (spezifischer Name) - userRole: string; // Rolle des Users in dieser Instanz - permissions: InstancePermissions; // Summarische Berechtigungen -} -``` - -### 2.2 Generisches Instanz-Handling - -Das Handling für Feature-Instanzen ist **generisch**. Die aktuelle Instanz ergibt sich aus der **URL** (Route-Parameter): - -```typescript -// stores/featureStore.ts - -interface FeatureState { - features: Feature[]; - - // Actions - loadFeatures: () => Promise; - getInstanceById: (instanceId: string) => FeatureInstance | undefined; - getFeatureByCode: (featureCode: string) => Feature | undefined; -} - -const useFeatureStore = create((set, get) => ({ - features: [], - - loadFeatures: async () => { - // Ein API-Call lädt alle Features + Instanzen + Permissions - const response = await api.get('/features/my'); - set({ features: response.data }); - }, - - getInstanceById: (instanceId) => { - return get().features - .flatMap(f => f.instances) - .find(i => i.id === instanceId); - }, - - getFeatureByCode: (featureCode) => { - return get().features.find(f => f.code === featureCode); - } -})); -``` - -### 2.3 Instanz aus URL-Parameter - -Die aktuelle Instanz wird **nicht** im Store gespeichert, sondern aus der URL gelesen: - -```typescript -// hooks/useCurrentInstance.ts - -export function useCurrentInstance(): FeatureInstance | undefined { - const { instanceId } = useParams<{ instanceId: string }>(); - const getInstanceById = useFeatureStore(s => s.getInstanceById); - - return instanceId ? getInstanceById(instanceId) : undefined; -} - -// Verwendung in Komponenten: -function ContractList() { - const instance = useCurrentInstance(); - - if (!instance) { - return ; // Keine Instanz in URL - } - - // Arbeite mit instance.permissions, instance.mandateId, etc. -} -``` - ---- - -## 3. Berechtigungs-Abfrage (Summarisch) - -### 3.1 Problem: Rate Limits - -**Vermeiden:** -```typescript -// SCHLECHT: Ein Request pro Objekt/Feld -for (const contract of contracts) { - const canEdit = await api.get(`/rbac/check?table=Contract&id=${contract.id}&action=update`); - const canDelete = await api.get(`/rbac/check?table=Contract&id=${contract.id}&action=delete`); -} -``` - -### 3.2 Lösung: Summarische Berechtigungen - -Berechtigungen werden **einmalig pro Feature-Instanz** geladen: - -```typescript -interface InstancePermissions { - // Tabellen-Level (CRUD pro Tabelle) - tables: { - [tableName: string]: { - view: boolean; - read: AccessLevel; // "n" | "m" | "g" | "a" - create: AccessLevel; - update: AccessLevel; - delete: AccessLevel; - } - }; - - // Feld-Level (nur wo eingeschränkt) - fields?: { - [tableName: string]: { - [fieldName: string]: { - read: boolean; - write: boolean; - } - } - }; - - // View-Level (Navigation) - views: { - [viewCode: string]: boolean; - }; -} -``` - -### 3.3 API-Endpunkt für Permissions - -```typescript -// GET /features/my -// Lädt alles in einem Request - -{ - "features": [ - { - "code": "trustee", - "label": { "en": "Trustee", "de": "Treuhand" }, - "instances": [ - { - "id": "inst-123", - "mandateId": "mand-456", - "mandateName": "Soha Treuhand", - "instanceLabel": "PamoCreate AG", - "userRole": "customer", - "permissions": { - "tables": { - "TrusteeOrganisation": { "view": true, "read": "m", "create": "n", "update": "m", "delete": "n" }, - "TrusteeContract": { "view": true, "read": "m", "create": "n", "update": "n", "delete": "n" }, - "TrusteeDocument": { "view": true, "read": "m", "create": "m", "update": "m", "delete": "n" } - }, - "views": { - "trustee-dashboard": true, - "trustee-contracts": true, - "trustee-admin": false - } - } - } - ] - } - ] -} -``` - -### 3.4 Permission-Hooks im Frontend - -```typescript -// hooks/usePermissions.ts - -export function useTablePermission(tableName: string) { - const instance = useCurrentInstance(); // Aus URL-Parameter - - if (!instance) { - return { view: false, read: 'n', create: 'n', update: 'n', delete: 'n' }; - } - - return instance.permissions.tables[tableName] ?? { - view: false, read: 'n', create: 'n', update: 'n', delete: 'n' - }; -} - -export function useCanView(viewCode: string): boolean { - const instance = useCurrentInstance(); // Aus URL-Parameter - return instance?.permissions.views[viewCode] ?? false; -} - -export function useCanEditRecord(tableName: string, record: any): boolean { - const permission = useTablePermission(tableName); - const userId = useAuthStore(s => s.user?.id); - - switch (permission.update) { - case 'n': return false; - case 'm': return record._createdBy === userId; - case 'g': return true; // Instanz-Scope - case 'a': return true; // Alle - default: return false; - } -} -``` - ---- - -## 4. Navigation & UI-Struktur - -### 4.1 Hauptnavigation - -``` -┌────────────────────────────────────────────────────────────┐ -│ [Logo] PowerOn [User] [Logout] │ -├────────────────────────────────────────────────────────────┤ -│ │ -│ SYSTEM │ -│ ○ Dashboard │ -│ ○ Profil │ -│ ○ Einstellungen │ -│ │ -│ ▼ TRUSTEE [Feature] │ -│ │ │ -│ ├─▼ Soha Treuhand / PamoCreate AG [Instanz] │ -│ │ ○ Übersicht │ -│ │ ○ Verträge │ -│ │ ○ Dokumente │ -│ │ ○ Positionen │ -│ │ │ -│ ├─▼ Soha Treuhand / ValueOn AG [Instanz] │ -│ │ ○ Übersicht │ -│ │ ○ Verträge │ -│ │ ○ Dokumente │ -│ │ │ -│ └─▶ SwissTreu / Firma X [Instanz] │ -│ (collapsed) │ -│ │ -│ ▼ CHATBOT [Feature] │ -│ │ │ -│ └─▼ Althaus / Management-Tool [Instanz] │ -│ ○ Conversations │ -│ ○ Settings │ -│ │ -│ ───────────────────────────────────── │ -│ ADMIN (nur wenn sysadmin) │ -│ ○ Mandanten │ -│ ○ Users │ -│ ○ RBAC │ -│ │ -└────────────────────────────────────────────────────────────┘ -``` - -**Hierarchie:** -``` -Feature (Gruppe) - └─ Instanz (Subgruppe) - └─ Objekte/Views (Navigation Items) -``` -``` - -### 4.2 Feature-Gruppe mit Instanz-Subgruppen - -Jedes Feature zeigt **alle Instanzen als Subgruppen** an: - -```tsx -// components/FeatureNavGroup.tsx - -function FeatureNavGroup({ featureCode }: { featureCode: string }) { - const { features } = useFeatureStore(); - const feature = features.find(f => f.code === featureCode); - - if (!feature || feature.instances.length === 0) { - return null; // Feature nicht anzeigen wenn keine Instanzen - } - - return ( - - {/* Feature als Hauptgruppe */} - - - {feature.label.de} - - - {/* Jede Instanz als Subgruppe */} - {feature.instances.map(instance => ( - - ))} - - ); -} -``` - -### 4.3 Instanz-Subgruppe Komponente - -```tsx -// components/InstanceNavSubgroup.tsx - -function InstanceNavSubgroup({ instance, featureCode }: { - instance: FeatureInstance; - featureCode: string; -}) { - const [isExpanded, setIsExpanded] = useState(false); - - return ( - - {/* Instanz als Subgruppen-Header */} - setIsExpanded(!isExpanded)} - collapsible - > - - {instance.mandateName} / {instance.instanceLabel} - - - - {/* Objekte/Views der Instanz */} - {isExpanded && ( - - {instance.permissions.views[`${featureCode}-dashboard`] && ( - - Übersicht - - )} - {instance.permissions.views[`${featureCode}-contracts`] && ( - - Verträge - - )} - {instance.permissions.views[`${featureCode}-documents`] && ( - - Dokumente - - )} - {instance.permissions.views[`${featureCode}-positions`] && ( - - Positionen - - )} - - )} - - ); -} -``` - -### 4.4 URL-Struktur mit Instanz-ID - -Die URL enthält immer die **Instanz-ID**, damit klar ist, in welcher Instanz gearbeitet wird: - -``` -/trustee/{instanceId}/dashboard -/trustee/{instanceId}/contracts -/trustee/{instanceId}/contracts/{contractId} -/trustee/{instanceId}/documents - -/chatbot/{instanceId}/conversations -/chatbot/{instanceId}/settings -``` - -**Router-Setup:** -```tsx -// routes.tsx - -}> - } /> - } /> - } /> - } /> - {/* ... */} - -``` - ---- - -## 5. System-Bereich (Ohne Mandant) - -### 5.1 Immer verfügbare Funktionen - -User ohne Mandant-Zugehörigkeit können: - -```typescript -const SYSTEM_FEATURES = { - // Immer verfügbar - profile: true, - settings: true, - logout: true, - - // Nur für sysadmin - adminMandates: (user) => user.isSysAdmin, - adminUsers: (user) => user.isSysAdmin, - adminRbac: (user) => user.isSysAdmin, -}; -``` - -### 5.2 Dashboard für User ohne Instanzen - -```tsx -// pages/Dashboard.tsx - -function Dashboard() { - const { features } = useFeatureStore(); - const hasInstances = features.some(f => f.instances.length > 0); - - if (!hasInstances) { - return ( - -

Willkommen bei PowerOn

-

Du hast aktuell Zugriff auf keine Feature-Instanzen.

-

Kontaktiere einen Administrator, um Zugriff zu erhalten.

- - -

Was du jetzt tun kannst:

-
    -
  • Profil bearbeiten
  • -
  • Einstellungen anpassen
  • -
-
-
- ); - } - - return ; -} -``` - ---- - -## 6. Daten-Requests mit Instanz-Kontext - -### 6.1 API-Client mit Instanz-Parameter - -Da die Instanz-ID in der URL steht, wird sie **explizit** an API-Calls übergeben: - -```typescript -// services/api.ts - -const api = axios.create({ baseURL: '/api' }); - -// Kein Interceptor nötig - Instanz-ID kommt aus URL und wird explizit übergeben -``` - -### 6.2 Feature-spezifische Queries - -```typescript -// hooks/useTrusteeContracts.ts - -export function useTrusteeContracts() { - const instance = useCurrentInstance(); // Aus URL: /trustee/:instanceId/contracts - - return useQuery({ - queryKey: ['trustee', 'contracts', instance?.id], - queryFn: async () => { - if (!instance) throw new Error('No instance in URL'); - - // Instanz-ID explizit im Request - const response = await api.get(`/trustee/${instance.id}/contracts`); - return response.data; - }, - enabled: !!instance && instance.featureCode === 'trustee' - }); -} -``` - ---- - -## 7. Berechtigungs-gesteuerte UI-Elemente - -### 7.1 Conditional Rendering - -```tsx -// components/ContractList.tsx - -function ContractList() { - const { data: contracts } = useTrusteeContracts(); - const tablePermission = useTablePermission('TrusteeContract'); - const userId = useAuthStore(s => s.user?.id); - - const canCreate = tablePermission.create !== 'n'; - - return ( -
-
-

Verträge

- {canCreate && ( - - )} -
- - - {contracts?.map(contract => { - const canEdit = canEditRecord(tablePermission.update, contract, userId); - const canDelete = canEditRecord(tablePermission.delete, contract, userId); - - return ( - - {contract.name} - - {canEdit && editContract(contract)} />} - {canDelete && deleteContract(contract)} />} - - - ); - })} -
-
- ); -} - -function canEditRecord(accessLevel: AccessLevel, record: any, userId: string): boolean { - switch (accessLevel) { - case 'n': return false; - case 'm': return record._createdBy === userId; - case 'g': - case 'a': return true; - default: return false; - } -} -``` - -### 7.2 Permission-Wrapper Komponente - -```tsx -// components/PermissionGate.tsx - -interface PermissionGateProps { - table: string; - action: 'view' | 'read' | 'create' | 'update' | 'delete'; - record?: any; // Für "m" (my) Prüfung - children: React.ReactNode; - fallback?: React.ReactNode; -} - -function PermissionGate({ table, action, record, children, fallback = null }: PermissionGateProps) { - const permission = useTablePermission(table); - const userId = useAuthStore(s => s.user?.id); - - let hasPermission = false; - - if (action === 'view') { - hasPermission = permission.view; - } else { - const level = permission[action]; - if (level === 'n') { - hasPermission = false; - } else if (level === 'm' && record) { - hasPermission = record._createdBy === userId; - } else if (level === 'g' || level === 'a') { - hasPermission = true; - } - } - - return hasPermission ? <>{children} : <>{fallback}; -} - -// Verwendung: - - - - - - - -``` - ---- - -## 8. Login & Session - -### 8.1 Login-Response (ohne mandateId) - -```typescript -// Nach erfolgreichem Login: -{ - "token": "jwt...", - "user": { - "id": "user-123", - "username": "patrick", - "email": "patrick@example.com", - "isSysAdmin": false - }, - "features": [ - // Alle Features + Instanzen + Permissions (siehe Abschnitt 3.3) - ] -} -``` - -### 8.2 Auth-Store - -```typescript -// stores/authStore.ts - -interface AuthState { - user: User | null; - token: string | null; - isAuthenticated: boolean; - - login: (credentials: LoginCredentials) => Promise; - logout: () => void; -} - -const useAuthStore = create((set) => ({ - user: null, - token: null, - isAuthenticated: false, - - login: async (credentials) => { - const response = await api.post('/auth/login', credentials); - - // Token speichern - set({ - user: response.data.user, - token: response.data.token, - isAuthenticated: true - }); - - // Features laden (separater Store) - useFeatureStore.getState().setFeatures(response.data.features); - }, - - logout: () => { - set({ user: null, token: null, isAuthenticated: false }); - useFeatureStore.getState().reset(); - } -})); -``` - ---- - -## 9. Einladungs-Flow - -### 9.1 Einladungs-Link - -``` -https://app.poweron.ch/invite?token=abc123xyz -``` - -### 9.2 Einladungs-Seite - -```tsx -// pages/Invite.tsx - -function InvitePage() { - const token = useSearchParam('token'); - const [inviteData, setInviteData] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - // Token validieren (ohne Login) - api.get(`/invite/validate?token=${token}`) - .then(res => setInviteData(res.data)) - .catch(err => setError(err.response?.data?.detail || 'Ungültiger Link')); - }, [token]); - - if (error) { - return ; - } - - if (!inviteData) { - return ; - } - - return ( - -

Einladung zu {inviteData.mandateName}

-

Du wurdest eingeladen, als {inviteData.roleLabel} mitzuarbeiten.

- - {inviteData.existingUser ? ( - // User existiert bereits - nur Login erforderlich - acceptInvite(token)} - message="Melde dich an, um die Einladung anzunehmen." - /> - ) : ( - // Neuer User - Registrierung - acceptInvite(token, userId)} - /> - )} -
- ); -} -``` - -### 9.3 Einladungs-Verwaltung (Admin) - -```tsx -// pages/admin/Invitations.tsx - -function InvitationsAdmin() { - const { data: invitations } = useQuery({ - queryKey: ['admin', 'invitations'], - queryFn: () => api.get('/admin/invitations').then(r => r.data) - }); - - const revokeInvite = useMutation({ - mutationFn: (token: string) => api.delete(`/admin/invitations/${token}`), - onSuccess: () => queryClient.invalidateQueries(['admin', 'invitations']) - }); - - return ( -
-

Ausstehende Einladungen

- - - Email - Mandant - Rolle - Erstellt - Gültig bis - Aktionen - - - {invitations?.map(inv => ( - - {inv.email} - {inv.mandateName} - {inv.roleLabel} - {formatDate(inv.createdAt)} - {formatDate(inv.expiresAt)} - - revokeInvite.mutate(inv.token)} - title="Einladung widerrufen" - /> - - - ))} - -
-
- ); -} -``` - ---- - -## 10. Zusammenfassung - -### 10.1 Wichtige Prinzipien - -| Prinzip | Umsetzung | -|---------|-----------| -| **Kein mandateId** | User gehört keinem Mandanten, arbeitet in Feature-Instanzen | -| **Summarische Permissions** | Ein Request lädt alle Berechtigungen pro Instanz | -| **Generisches Feature-Handling** | Alle Features haben gleiche Struktur | -| **Instanz aus URL** | `/feature/{instanceId}/...` - Instanz-ID immer in URL | -| **Navigation = Hierarchie** | Feature → Instanz (Subgruppe) → Objekte | -| **System-Features immer verfügbar** | Profil, Settings, Logout ohne Instanz | - -### 10.2 State-Architektur - -``` -┌─────────────────────────────────────────────────────────┐ -│ Frontend State │ -├─────────────────────────────────────────────────────────┤ -│ │ -│ AuthStore │ -│ ├─ user: User │ -│ ├─ token: string │ -│ └─ isAuthenticated: boolean │ -│ │ -│ FeatureStore │ -│ ├─ features: Feature[] │ -│ │ └─ instances: FeatureInstance[] │ -│ │ └─ permissions: InstancePermissions │ -│ └─ getInstanceById(id): FeatureInstance │ -│ │ -│ Router (URL) │ -│ └─ /:featureCode/:instanceId/* → aktuelle Instanz │ -│ │ -└─────────────────────────────────────────────────────────┘ -``` - -### 10.3 API-Endpunkte (Frontend-relevant) - -| Endpunkt | Beschreibung | -|----------|--------------| -| `GET /features/my` | Alle Features + Instanzen + Permissions | -| `GET /invite/validate?token=X` | Einladung validieren | -| `POST /invite/accept` | Einladung annehmen | -| `GET /admin/invitations` | Alle Einladungen (Admin) | -| `DELETE /admin/invitations/{token}` | Einladung widerrufen | - ---- - -## 11. Migration (Frontend) - -### 11.1 Zu entfernen - -- `currentMandateId` aus globalem State -- `mandateId` aus API-Requests (Query-Params) -- Mandanten-Switcher - -### 11.2 Zu ändern - -- **Navigation:** Feature → Instanz-Subgruppen → Objekte (hierarchisch) -- **URLs:** Müssen `/:featureCode/:instanceId/...` enthalten -- **Permission-Checks:** Auf `useTablePermission` + `useCurrentInstance` umstellen -- **API-Calls:** Instanz-ID aus URL-Parameter verwenden - -### 11.3 Neu - -- `FeatureStore` mit allen Instanzen und Permissions -- `useCurrentInstance()` Hook (liest aus URL) -- `FeatureNavGroup` + `InstanceNavSubgroup` Komponenten -- `PermissionGate` Wrapper -- Einladungs-Flow UI