26 KiB
Multi-Tenant SaaS Plattform - Implementierungskonzept
Technische Umsetzung für PowerOn Gateway
Version: 1.0
Datum: 14. Januar 2026
Status: Entwurf
Basis: 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:
# 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:
# 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:
# 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
# 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
# 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
# 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:
{
"sub": "admin",
"userId": "uuid-123",
"mandateId": "uuid-456",
"exp": 1234567890
}
Neu:
{
"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)
# 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
# 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
# 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
# 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
# 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
# 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)
{
"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
# 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
@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
# 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:
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)
UserMandateModel erstellenAccessRule.mandateIdhinzufügenUserModel anpassen (transiente Felder)- Migrations-Script erstellen
Phase 2: Backend-Core (Woche 2-3)
interfaceDbAppObjectsMembership-MethodeninterfaceRbacmandateId-ParameterRbacClassmandatsspezifische 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
-
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
-
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.
-
Einladungs-Flow: Email-Link mit überwachtem Token. Admin kann Token jederzeit widerrufen/löschen. Token hat Ablaufzeit.
-
System-Rollen (sysadmin): Global, nicht mandantsspezifisch. sysadmin wird direkt am User-Objekt markiert, nicht über Membership.