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