827 lines
26 KiB
Markdown
827 lines
26 KiB
Markdown
# 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.
|