diff --git a/implementation/Saas Multi Tenant Mandate/implementation_concept.md b/implementation/Saas Multi Tenant Mandate/implementation_concept.md new file mode 100644 index 0000000..2e40ab0 --- /dev/null +++ b/implementation/Saas Multi Tenant Mandate/implementation_concept.md @@ -0,0 +1,827 @@ +# 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 new file mode 100644 index 0000000..e187bf5 --- /dev/null +++ b/implementation/Saas Multi Tenant Mandate/mandate_concept.md @@ -0,0 +1,609 @@ +# Multi-Tenant SaaS Plattform + +## Architektur-Konzept für Mandantenfähigkeit + +**Version:** 1.0 + +**Datum:** 14. Januar 2025 + +**Status:** Konzept + +--- + +## 1. Executive Summary + +### Was ist das Ziel? + +Wir entwickeln eine **mandantenfähige SaaS-Plattform**, die es verschiedenen Organisationen (Mandanten) ermöglicht, mehrere Geschäftsanwendungen (Features) über ein einziges System zu nutzen, während: + +- ✅ **Datenisolation** zwischen Mandanten gewährleistet ist +- ✅ **Flexible Zugriffskontrolle** auf Daten- und Feldebene möglich ist +- ✅ **Ein Benutzer mit einem Account** in mehreren Mandanten arbeiten kann +- ✅ **Self-Service-Onboarding** für neue Benutzer unterstützt wird + +### Hauptmerkmale + +| Merkmal | Beschreibung | +| --- | --- | +| **Multi-Mandant** | Verschiedene Firmen nutzen die gleiche Plattform isoliert | +| **Multi-Feature** | Pro Mandant können verschiedene Geschäftsanwendungen aktiviert werden | +| **Ein Account, viele Rollen** | Ein Benutzer kann in verschiedenen Mandanten unterschiedliche Rollen haben | +| **Granulare Berechtigungen** | Zugriffskontrolle bis auf Feld-Ebene möglich | +| **Konfigurierbar** | Berechtigungen können als Konfiguration importiert/exportiert werden | + +--- + +## 2. Geschäftsziele & Nutzen + +### 2.1 Für die Plattform-Betreiber + +**Effizienz:** + +- Ein System für alle Kunden (Mandanten) +- Zentrale Wartung und Updates +- Reduzierte Infrastruktur-Kosten + +**Skalierbarkeit:** + +- Beliebig viele Mandanten onboardbar +- Neue Features können zentral ausgerollt werden +- Pay-per-Use Modelle möglich + +**Compliance:** + +- Strikte Datentrennung zwischen Mandanten +- Audit-fähige Zugriffskontrollen +- DSGVO-konform durch granulare Berechtigungen + +### 2.2 Für Mandanten (Kunden) + +**Flexibilität:** + +- Nur die Features nutzen, die benötigt werden +- Benutzer können in mehreren Mandanten aktiv sein +- Anpassbare Berechtigungsstrukturen + +**Effizienz:** + +- Schnelles Onboarding neuer Mitarbeiter +- Self-Service Einladungssystem +- Zentrale Benutzerverwaltung + +**Sicherheit:** + +- Eigene Daten sind isoliert von anderen Mandanten +- Granulare Kontrolle über Datenzugriffe +- Nachvollziehbare Berechtigungsstruktur + +### 2.3 Für End-Benutzer + +**Einfachheit:** + +- Ein Login für alle Mandanten +- Einfacher Wechsel zwischen Mandanten +- Klare Rollenzuordnungen + +**Transparenz:** + +- User sieht, in welchen Mandanten er Mitglied ist +- Klare Rollen und Berechtigungen +- Keine versteckten Zugriffe + +--- + +## 3. Kernkonzepte + +### 3.1 Was ist ein Mandant? + +Ein **Mandant** ist eine Organisation (z.B. eine Firma), die die Plattform nutzt. Jeder Mandant hat: + +- Eigene Benutzer +- Eigene Daten +- Eigene Feature-Instanzen + +**Beispiele:** + +- Treuhandbüro "Soha Treuhand" +- Beratungsfirma "Althaus Consulting" +- Immobilienfirma "Universe Corp" + +### 3.2 Was ist ein Feature? + +Ein **Feature** ist eine Geschäftsanwendung innerhalb der Plattform. Beispiele: + +| Feature | Beschreibung | Typische Nutzer | +| --- | --- | --- | +| **Chatbot** | KI-gestützte Chatbots für verschiedene Anwendungsfälle | Kundenservice, Support | +| **Treuhand** | Buchhaltung und Treuhandverwaltung | Treuhänder, Kunden | +| **Immobilien** | Verwaltung von Immobilienprojekten | Property Manager, Mieter | + +### 3.3 Was ist eine Feature-Instanz? + +Ein Mandant kann **mehrere Instanzen** desselben Features haben: + +**Beispiel: Mandant "Althaus"** + +- Chatbot-Instanz "Produkt-Support" (für Produktfragen) +- Chatbot-Instanz "HR-Assistent" (für HR-Anfragen) +- Chatbot-Instanz "Management-Tool" (für Strategiediskussionen) + +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 + +### 4.1 System-Administrator + +**Wer:** Plattform-Betreiber (z.B. IT-Team) + +**Verantwortlichkeiten:** + +- ✅ 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) + +**Kann NICHT:** + +- ❌ Automatisch in Feature-Instanzen arbeiten (muss sich explizit Zugriff geben) + +### 4.2 Mandanten-Administrator + +**Wer:** Führungsperson oder IT-Verantwortlicher der Organisation + +**Verantwortlichkeiten:** + +- ✅ Erstellt Feature-Instanzen im eigenen Mandant +- ✅ Erstellt Einladungslinks für neue Benutzer +- ✅ Vergibt Rollen innerhalb des Mandanten +- ✅ Sieht alle Benutzer des eigenen Mandanten +- ✅ Verwaltet Feature-Instanzen + +**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 + +**Isolation:** Ein Mandanten-Administrator sieht nur die Benutzer und Daten seines eigenen Mandanten. + +### 4.3 Standard-Benutzer + +**Wer:** Mitarbeiter, Kunden, Partner der Organisation + +**Verantwortlichkeiten:** + +- ✅ Nutzt Feature-Instanzen entsprechend seiner Rollen +- ✅ Arbeitet mit Daten im Rahmen seiner Berechtigungen + +**Kann NICHT:** + +- ❌ Feature-Instanzen erstellen +- ❌ Andere Benutzer verwalten +- ❌ Berechtigungen vergeben + +### 4.4 Rollen innerhalb von Features + +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 | + +--- + +## 5. Anwendungsfälle (Use Cases) + +### 5.1 Use Case 1: Patrick mit mehreren Firmen + +**Situation:** +Patrick hat 3 eigene Firmen und nutzt für jede ein anderes Treuhandbüro. Gleichzeitig arbeitet er als externer Berater für die Firma Althaus. + +**Lösung:** + +``` +Benutzer: Patrick (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 +│ +├─ Mandant: SwissTreu (Treuhandbüro 2) +│ └─ Treuhand-Instanz "Firma X" → Rolle: Kunde +│ +└─ Mandant: Althaus (Beratungsfirma) + └─ Chatbot-Instanz "Management-Tool" → Rolle: Administrator + +``` + +**Vorteile:** + +- Patrick hat **einen Login** für alle Mandanten +- Patrick sieht bei Soha Treuhand nur seine 3 Firmen (nicht die Daten anderer Kunden) +- Patrick ist bei Althaus Administrator und kann dort alles verwalten +- Jeder Mandant sieht nur seine eigenen Daten + +### 5.2 Use Case 2: Shelly als Multi-Mandant-Benutzer + +**Situation:** +Shelly arbeitet für ihre eigene Firma "Universe Corp", unterstützt aber auch andere Firmen als Freelancerin. + +**Lösung:** + +``` +Benutzerin: Shelly (shelly@universe.com) +│ +├─ Mandant: Universe Corp (eigene Firma) +│ ├─ Rolle: Mandanten-Administrator +│ └─ Kann alle Features verwalten und Benutzer einladen +│ +├─ Mandant: Althaus (Kunde als Freelancerin) +│ └─ Chatbot-Instanz "Produkt-Support" → Rolle: Administrator +│ └─ Chatbot-Instanz "HR-Assistent" → Rolle: Benutzer (nur für einen spezifischen Bot) +│ +└─ Mandant: Soha Treuhand (nutzt deren Treuhand-Service) + └─ Treuhand-Instanz "Universe Corp 2025" → Rolle: Kunde + +``` + +**Vorteile:** + +- Shelly verwaltet ihren eigenen Mandanten vollständig +- Shelly kann bei Althaus als Expertin unterstützen +- Shelly nutzt gleichzeitig Soha Treuhand als Kundin +- Klare Rollentrennung in jedem Mandanten + +### 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. + +**Lösung:** + +``` +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 ... + +``` + +**Datenzugriff:** + +- **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 + +**Technische Umsetzung:** Automatische Filterung basierend auf "Eigentümer"-Feld in der Datenbank. + +--- + +## 6. Datensicherheit & Isolation + +### 6.1 Mandanten-Isolation + +**Prinzip:** Jeder Mandant ist vollständig von anderen Mandanten getrennt. + +**Konkret:** + +- Mandant A sieht **keine Daten** von Mandant B +- Mandant A sieht **keine Benutzer** von Mandant B +- Mandant A kann **nicht auf Feature-Instanzen** von Mandant B zugreifen + +**Ausnahme:** System-Administrator (für Support und Wartung) + +### 6.2 Zugriffskontrolle: Das "n/m/g/a"-System + +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 | +| **g** | Group | Alle Daten der Instanz | Mitarbeiter sieht alle Kunden der Instanz | +| **a** | All | Alle Daten des Features | Super-Admin sieht alle Instanzen | + +**Anwendungsbeispiel:** + +``` +Feature: Chatbot +Instanz: "Produkt-Support" + +Rolle "Kunde": + - Konversationen: Scope "m" (my) → Nur eigene Chats + - Einstellungen: Scope "n" (none) → Kein Zugriff + +Rolle "Benutzer": + - Konversationen: Scope "g" (group) → Alle Chats der Instanz + - Einstellungen: Scope "n" (none) → Kein Zugriff + +Rolle "Administrator": + - Konversationen: Scope "g" (group) → Alle Chats der Instanz + - Einstellungen: Scope "g" (group) → Voller Zugriff auf Einstellungen + +``` + +### 6.3 Feld-Level-Sicherheit + +Zusätzlich zur Tabellen-Ebene können einzelne **Datenfelder** geschützt werden: + +**Beispiel: Benutzer-Tabelle** + +| Feld | System-Admin | Mandanten-Admin | Standard-Benutzer | +| --- | --- | --- | --- | +| Name | Lesen/Schreiben | Lesen | Lesen (nur eigener) | +| Email | Lesen/Schreiben | Lesen | Lesen/Schreiben (nur eigene) | +| Passwort-Hash | Lesen/Schreiben | Kein Zugriff | Kein Zugriff | +| isSystemAdmin | Lesen/Schreiben | Kein Zugriff | Kein Zugriff | + +**Vorteil:** Sehr granulare Kontrolle über sensible Daten. + +### 6.4 Berechtigungs-Policies (Regelsets) + +**Was sind Policies?** +Policies sind **konfigurierbare Regelsets**, die definieren, wer auf welche Daten zugreifen kann. + +**Beispiel-Policy: "Chatbot Kunde"** + +``` +Policy-Name: chatbot_customer_policy +Gilt für: Rolle "Kunde" im Feature "Chatbot" + +Regeln: +1. Tabelle "Konversationen" → Lesen erlaubt (Scope: my) +2. Tabelle "Konversationen" → Erstellen erlaubt (Scope: my) +3. Tabelle "Nachrichten" → Lesen erlaubt (Scope: my) +4. Tabelle "Admin-Einstellungen" → Kein Zugriff (Scope: none) + +``` + +**Vorteile:** + +- ✅ Policies können als Konfiguration exportiert/importiert werden +- ✅ Änderungen benötigen keine Code-Anpassungen +- ✅ Nachvollziehbar und audit-fähig +- ✅ Wiederverwendbar über verschiedene Mandanten + +--- + +## 7. Onboarding-Prozesse + +### 7.1 Neuer Mandant wird erstellt + +``` +1. System-Administrator erstellt Mandant + └─ Mandant-Name: "Althaus" + └─ Mandant-Typ: "Enterprise" + +2. System-Administrator schaltet Features frei + └─ Feature "Chatbot": Aktiviert + └─ Feature "Treuhand": Nicht aktiviert + +3. System-Administrator weist ersten Benutzer als Mandanten-Admin zu + └─ User: admin@althaus.com + └─ Rolle: Mandanten-Administrator + +4. Mandanten-Administrator übernimmt + └─ Erstellt Feature-Instanzen + └─ Lädt weitere Benutzer ein + +``` + +### 7.2 Neuer Benutzer wird eingeladen (Self-Service) + +``` +1. Mandanten-Administrator erstellt Einladungslink + └─ Feature-Instanz: "Chatbot Produkt-Support" + └─ Rolle: "Benutzer" + └─ Link: https://app.example.com/register?invite=abc123 + └─ Gültigkeit: 24 Stunden + +2. Administrator sendet Link an neue Mitarbeiterin + +3. Neue Mitarbeiterin öffnet Link und registriert sich + └─ Benutzername: "anna" + └─ Email: "anna@althaus.com" + └─ Passwort: *** + +4. System erstellt automatisch: + └─ Benutzer-Account + └─ Zuordnung zum Mandanten "Althaus" + └─ Rolle "Benutzer" in "Chatbot Produkt-Support" + +5. Anna kann sich sofort einloggen und arbeiten + +``` + +### 7.3 Bestehender Benutzer wird zu weiterem Mandanten hinzugefügt + +``` +1. System-Administrator weist Shelly zu neuem Mandanten zu + └─ User: 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" + +3. Shelly erhält Email-Benachrichtigung + +4. Shelly loggt sich ein und sieht neuen Mandanten im Menü + └─ Kann zwischen "Universe Corp" und "Soha Treuhand" wechseln + +``` + +--- + +## 8. Skalierbarkeit & Flexibilität + +### 8.1 Skalierung von Mandanten + +**Horizontal:** + +- Beliebig viele Mandanten können hinzugefügt werden +- Jeder Mandant ist isoliert → keine Auswirkungen auf andere +- Neue Mandanten können in Minuten onboardet werden + +**Vertikal:** + +- Pro Mandant können beliebig viele Feature-Instanzen erstellt werden +- Pro Feature-Instanz können beliebig viele Benutzer hinzugefügt werden + +### 8.2 Neue Features hinzufügen + +``` +1. Entwicklung eines neuen Features (z.B. "CRM") + +2. System-Administrator erstellt Feature-Definition + └─ Feature-Code: "crm" + └─ Verfügbare Rollen: ["admin", "user", "viewer"] + +3. System-Administrator importiert Berechtigungs-Policies + └─ Import von JSON-Konfiguration + +4. System-Administrator schaltet Feature für Mandanten frei + └─ Mandant "Althaus" → CRM aktiviert + └─ Mandant "Soha Treuhand" → CRM nicht aktiviert + +5. Mandanten-Administratoren können Feature nutzen + └─ Erstellen CRM-Instanzen + └─ Laden Benutzer ein + +``` + +**Vorteil:** Neue Features können zentral entwickelt und flexibel ausgerollt werden. + +### 8.3 Anpassbare Berechtigungen + +**Szenario:** Mandant "Althaus" benötigt spezielle Berechtigungsstruktur. + +**Lösung:** + +1. System-Administrator exportiert Standard-Policies +2. Mandanten-Administrator passt Policies an (mit Freigabe) +3. System-Administrator importiert angepasste Policies für diesen Mandanten +4. Nur "Althaus" nutzt angepasste Policies, andere Mandanten Standard + +**Vorteil:** Flexibilität für Sonderanforderungen ohne Code-Änderungen. + +--- + +## 9. Frequently Asked Questions (FAQ) + +### 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. + +### F: Was passiert, wenn zwei Mandanten den gleichen Benutzer einladen wollen? + +**A:** Da Benutzernamen eindeutig sind, Email-Adressen aber nicht, kann ein Benutzer: + +- Entweder seinen bestehenden Account nutzen (System-Admin weist zu) +- Oder ein zweiter Account wird mit anderem Benutzernamen erstellt + +**Empfehlung:** System-Admin weist bestehenden Account zu, sodass ein Benutzer wirklich nur einen Account benötigt. + +### F: Können Mandanten-Administratoren Benutzer von anderen Mandanten sehen? + +**A:** Nein, strikte Isolation. Ein Mandanten-Admin sieht nur: + +- Benutzer seines eigenen Mandanten +- Feature-Instanzen seines eigenen Mandanten +- Daten seines eigenen Mandanten + +### F: Wie wird sichergestellt, dass Daten zwischen Mandanten getrennt bleiben? + +**A:** Mehrschichtige Sicherheit: + +1. **Architektur-Ebene:** Jede Datenabfrage filtert automatisch nach Mandant +2. **Berechtigungs-Ebene:** Policies prüfen Zugriff auf Tabellen-/Feldebene +3. **Datenbank-Ebene:** Row-Level Security kann zusätzlich aktiviert werden + +### F: Was ist der Unterschied zwischen "Mandanten-Admin" und "Feature-Administrator"? + +**A:** + +- **Mandanten-Admin:** Verwaltet den gesamten Mandanten (erstellt Feature-Instanzen, lädt Benutzer ein) +- **Feature-Administrator:** Hat Admin-Rolle in einer spezifischen Feature-Instanz (z.B. Chatbot-Admin) + +Ein Benutzer kann beides gleichzeitig sein. + +### F: Können Berechtigungen für einzelne Benutzer individuell angepasst werden? + +**A:** Ja, über "Custom Permissions". Standard sind Rollen-basierte Policies, aber für Sonderfälle können individuelle Überschreibungen definiert werden. + +### F: Wie werden neue Features ausgerollt? + +**A:** Zentral und kontrolliert: + +1. Feature wird entwickelt +2. System-Admin schaltet Feature für ausgewählte Mandanten frei +3. Mandanten-Admins können Feature-Instanzen erstellen +4. Schrittweises Rollout möglich (Beta-Mandanten zuerst) + +### F: Was passiert, wenn ein Mandant gekündigt wird? + +**A:** + +1. System-Admin deaktiviert Mandanten +2. Alle Benutzer verlieren Zugriff auf diesen Mandanten +3. Daten können archiviert oder gelöscht werden (nach DSGVO) +4. Benutzer, die nur in diesem Mandanten waren, verlieren Zugriff +5. Benutzer, die in anderen Mandanten sind, behalten diese Zugriffe + +--- + +## 10. Glossar + +| Begriff | Erklärung | +| --- | --- | +| **Mandant** | Organisation (Firma), die die Plattform nutzt | +| **Feature** | Geschäftsanwendung (z.B. Chatbot, Treuhand, CRM) | +| **Feature-Instanz** | Konkrete Instanz eines Features in einem Mandanten | +| **System-Administrator** | Plattform-Betreiber mit globalen Rechten | +| **Mandanten-Administrator** | Verwalter eines spezifischen Mandanten | +| **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 diff --git a/implementation/Saas Multi Tenant Mandate/ui_concept_nyla.md b/implementation/Saas Multi Tenant Mandate/ui_concept_nyla.md new file mode 100644 index 0000000..f414850 --- /dev/null +++ b/implementation/Saas Multi Tenant Mandate/ui_concept_nyla.md @@ -0,0 +1,880 @@ +# 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