wiki/implementation/Saas Multi Tenant Mandate/implementation_concept.md
2026-01-14 22:38:46 +01:00

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.pychatProcess() Einfacher Chat mit direkten AI-Calls
workflow-dynamic features/workflow/mainWorkflow.pychatStart() Interaktiver KI-Workflow mit Task-Planning
workflow-automation features/workflow/mainWorkflow.pyexecuteAutomation() 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)

  • 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

  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.