tenant concepts

This commit is contained in:
ValueOn AG 2026-01-14 22:38:46 +01:00
parent cc4953da50
commit d8d692200e
3 changed files with 2316 additions and 0 deletions

View file

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

View file

@ -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 |

View file

@ -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<void>;
getInstanceById: (instanceId: string) => FeatureInstance | undefined;
getFeatureByCode: (featureCode: string) => Feature | undefined;
}
const useFeatureStore = create<FeatureState>((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 <Navigate to="/" />; // 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 (
<NavGroup>
{/* Feature als Hauptgruppe */}
<NavGroupHeader collapsible>
<Icon name={feature.icon} />
<span>{feature.label.de}</span>
</NavGroupHeader>
{/* Jede Instanz als Subgruppe */}
{feature.instances.map(instance => (
<InstanceNavSubgroup
key={instance.id}
instance={instance}
featureCode={featureCode}
/>
))}
</NavGroup>
);
}
```
### 4.3 Instanz-Subgruppe Komponente
```tsx
// components/InstanceNavSubgroup.tsx
function InstanceNavSubgroup({ instance, featureCode }: {
instance: FeatureInstance;
featureCode: string;
}) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<NavSubgroup>
{/* Instanz als Subgruppen-Header */}
<NavSubgroupHeader
onClick={() => setIsExpanded(!isExpanded)}
collapsible
>
<Icon name={isExpanded ? 'expand_more' : 'chevron_right'} />
<span>{instance.mandateName} / {instance.instanceLabel}</span>
<RoleBadge role={instance.userRole} />
</NavSubgroupHeader>
{/* Objekte/Views der Instanz */}
{isExpanded && (
<NavSubgroupItems>
{instance.permissions.views[`${featureCode}-dashboard`] && (
<NavItem to={`/${featureCode}/${instance.id}/dashboard`}>
Übersicht
</NavItem>
)}
{instance.permissions.views[`${featureCode}-contracts`] && (
<NavItem to={`/${featureCode}/${instance.id}/contracts`}>
Verträge
</NavItem>
)}
{instance.permissions.views[`${featureCode}-documents`] && (
<NavItem to={`/${featureCode}/${instance.id}/documents`}>
Dokumente
</NavItem>
)}
{instance.permissions.views[`${featureCode}-positions`] && (
<NavItem to={`/${featureCode}/${instance.id}/positions`}>
Positionen
</NavItem>
)}
</NavSubgroupItems>
)}
</NavSubgroup>
);
}
```
### 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
<Route path="/:featureCode/:instanceId/*" element={<FeatureLayout />}>
<Route path="dashboard" element={<Dashboard />} />
<Route path="contracts" element={<ContractList />} />
<Route path="contracts/:contractId" element={<ContractDetail />} />
<Route path="documents" element={<DocumentList />} />
{/* ... */}
</Route>
```
---
## 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 (
<WelcomeScreen>
<h1>Willkommen bei PowerOn</h1>
<p>Du hast aktuell Zugriff auf keine Feature-Instanzen.</p>
<p>Kontaktiere einen Administrator, um Zugriff zu erhalten.</p>
<Card>
<h3>Was du jetzt tun kannst:</h3>
<ul>
<li><Link to="/profile">Profil bearbeiten</Link></li>
<li><Link to="/settings">Einstellungen anpassen</Link></li>
</ul>
</Card>
</WelcomeScreen>
);
}
return <FeatureOverviewDashboard features={features} />;
}
```
---
## 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 (
<div>
<Header>
<h2>Verträge</h2>
{canCreate && (
<Button onClick={openCreateDialog}>Neuer Vertrag</Button>
)}
</Header>
<Table>
{contracts?.map(contract => {
const canEdit = canEditRecord(tablePermission.update, contract, userId);
const canDelete = canEditRecord(tablePermission.delete, contract, userId);
return (
<TableRow key={contract.id}>
<TableCell>{contract.name}</TableCell>
<TableCell>
{canEdit && <IconButton icon="edit" onClick={() => editContract(contract)} />}
{canDelete && <IconButton icon="delete" onClick={() => deleteContract(contract)} />}
</TableCell>
</TableRow>
);
})}
</Table>
</div>
);
}
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:
<PermissionGate table="TrusteeContract" action="create">
<Button>Neuer Vertrag</Button>
</PermissionGate>
<PermissionGate table="TrusteeContract" action="update" record={contract}>
<IconButton icon="edit" />
</PermissionGate>
```
---
## 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<void>;
logout: () => void;
}
const useAuthStore = create<AuthState>((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<InviteData | null>(null);
const [error, setError] = useState<string | null>(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 <ErrorScreen message={error} />;
}
if (!inviteData) {
return <Loading />;
}
return (
<InviteForm>
<h1>Einladung zu {inviteData.mandateName}</h1>
<p>Du wurdest eingeladen, als <strong>{inviteData.roleLabel}</strong> mitzuarbeiten.</p>
{inviteData.existingUser ? (
// User existiert bereits - nur Login erforderlich
<LoginForm
onSuccess={() => acceptInvite(token)}
message="Melde dich an, um die Einladung anzunehmen."
/>
) : (
// Neuer User - Registrierung
<RegisterForm
prefillEmail={inviteData.email}
onSuccess={(userId) => acceptInvite(token, userId)}
/>
)}
</InviteForm>
);
}
```
### 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 (
<div>
<h2>Ausstehende Einladungen</h2>
<Table>
<TableHeader>
<TableColumn>Email</TableColumn>
<TableColumn>Mandant</TableColumn>
<TableColumn>Rolle</TableColumn>
<TableColumn>Erstellt</TableColumn>
<TableColumn>Gültig bis</TableColumn>
<TableColumn>Aktionen</TableColumn>
</TableHeader>
<TableBody>
{invitations?.map(inv => (
<TableRow key={inv.token}>
<TableCell>{inv.email}</TableCell>
<TableCell>{inv.mandateName}</TableCell>
<TableCell>{inv.roleLabel}</TableCell>
<TableCell>{formatDate(inv.createdAt)}</TableCell>
<TableCell>{formatDate(inv.expiresAt)}</TableCell>
<TableCell>
<IconButton
icon="delete"
onClick={() => revokeInvite.mutate(inv.token)}
title="Einladung widerrufen"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
```
---
## 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