# Multi-Tenant Gateway - Implementierungskonzept **Version:** 3.3 **Datum:** 16. Januar 2026 **Status:** Entwurf (Review-Feedback eingearbeitet) **Basis:** [mandate_concept.md](./mandate_concept.md) > ⚠️ **GREENFIELD-Implementierung:** Keine Datenmigration, keine Backwards Compatibility, keine Fallback-Logik. Bestehende User-Mandate-Struktur wird vollständig ersetzt. ### Scope dieses Konzepts Dieses Konzept definiert die **Code-Architektur und Datenmodelle** für Multi-Mandantenfähigkeit. **Enthalten:** - Datenmodelle und Beziehungen - RBAC-System und Berechtigungslogik - API-Endpoints und Kontext-Handling - Sicherheitsaspekte auf Code-Ebene **Nicht enthalten (out of scope):** - **Testing:** Testkonzepte und Teststrategie werden separat dokumentiert - **Datenbank-Performance:** Wird über DB-Skalierung (Read Replicas, Connection Pooling, etc.) auf Infrastruktur-Ebene gelöst, nicht im Code. Keine Limits für Rollen pro User, keine Archivierungsstrategie für Junction Tables - dies sind DB-Infrastruktur-Themen. - **Organisatorische DSGVO-Themen:** Daten-Anonymisierung bei Export ist Verantwortung des Users/Mandanten, kein Code-Problem. Der generische Gateway stellt nur die technischen Endpoints bereit - Feature-spezifische Export-Logik (z.B. Trustee-Contracts mit Kundendaten) wird in den jeweiligen Features implementiert. - **Consent-Tracking (DSGVO Art. 7):** Einwilligungs-Management ist nicht Scope des Gateway - wird separat in den Features behandelt, die personenbezogene Daten verarbeiten. **Breaking Changes (gewollt):** - `User.mandateId` und `User.roleLabels` werden **entfernt** - dies ist bewusste Design-Entscheidung für das neue Multi-Mandant-Modell - Alle bestehenden Code-Referenzen auf diese Felder müssen migriert werden --- ## 1. Architektur-Übersicht ### 1.1 Kernprinzip ``` User (global) │ ├── UserMandate ──► Mandate │ └── roleIds: List[str] → Role.id[] │ └── FeatureAccess ──► FeatureInstance ──► Mandate └── roleIds: List[str] → Role.id[] ``` **Kein User gehört zu einem Mandanten.** Zugehörigkeit wird über `UserMandate` und `FeatureAccess` gesteuert. ### 1.2 Drei Ebenen von Funktionen | Ebene | Beschreibung | Beispiele | Berechtigung | |-------|--------------|-----------|--------------| | **System** | Grundfunktionen ohne Mandant | Login, Logout, Profil, Settings | Jeder eingeloggte User | | **Business** | Features in Mandanten | Trustee, Chatbot, Workflow | Via FeatureAccess | | **Admin** | Plattform-Verwaltung | User-, Mandate-, RBAC-Management | `isSysAdmin=true` am User | ### 1.3 Bestehende Security-Infrastruktur (Gateway) | Feature | Status | Implementierung | |---------|--------|-----------------| | **Password Hashing** | ✅ Vorhanden | Argon2 via `passlib` (`CryptContext(schemes=["argon2"])`) | | **JWT Token** | ✅ Vorhanden | HS256/HS384, httpOnly Cookies, SameSite=strict | | **Token Revocation** | ✅ Vorhanden | DB-backed mit `sessionId`, Status active/revoked | | **Rate Limiting** | ✅ Vorhanden | `slowapi.Limiter` auf allen Auth-Endpoints | | **CSRF Protection** | ✅ Vorhanden | `CSRFMiddleware` in `app.py` | | **Audit Logging** | ✅ Vorhanden | `auditLogger.py` mit daily rotation | --- ## 2. Datenmodell ### 2.1 Core Models ```python class User(BaseModel): id: str username: str email: Optional[str] isSysAdmin: bool = False # Globales Admin-Flag enabled: bool = True # KEIN mandateId, KEINE roleLabels direkt am User! class Mandate(BaseModel): id: str name: str enabled: bool = True # Kein rbacCacheVersion - Stateless Design, kein Cache ``` ### 2.2 Role Model (Kontextgebunden) **Kernkonzept:** Eine Rolle existiert immer in einem spezifischen Kontext. Der Kontext ist **IMMUTABLE** nach Erstellung. ```python class Role(BaseModel): """Rolle mit unveränderlichem Kontext""" id: str roleLabel: str # z.B. "admin", "user", "viewer" description: Dict[str, str] # I18n # KONTEXT - IMMUTABLE nach Create (nur Create/Delete, kein Update!) mandateId: Optional[str] # FK → Mandate (CASCADE DELETE) featureInstanceId: Optional[str] # FK → FeatureInstance (CASCADE DELETE) featureCode: Optional[str] # z.B. "trustee" - für Template-Rollen # Kontext-Typ (abgeleitet, für Klarheit) # - mandateId=None, featureInstanceId=None → GLOBAL (Template) # - mandateId=X, featureInstanceId=None → MANDATE # - mandateId=X, featureInstanceId=Y → INSTANCE isSystemRole: bool = False # System-Rollen können nicht gelöscht werden ``` **RBAC-Enforcement für Role:** ```python # Role.context-Felder (mandateId, featureInstanceId) haben NUR: # - CREATE: Ja (beim Erstellen setzen) # - READ: Ja # - UPDATE: NEIN! → Wird vom RBAC-System blockiert # - DELETE: Ja (ganze Role löschen) ``` ### 2.3 Membership Models (Junction Tables für CASCADE DELETE) **Architektur:** Rollen werden über Junction Tables verknüpft, nicht als Array-Felder. Dies ermöglicht saubere CASCADE DELETE auf Datenbankebene. ```python class UserMandate(BaseModel): """User-Mitgliedschaft in Mandant""" id: str userId: str # FK → User (CASCADE DELETE) mandateId: str # FK → Mandate (CASCADE DELETE) enabled: bool = True # Rollen via Junction Table UserMandateRole class FeatureAccess(BaseModel): """User-Zugriff auf Feature-Instanz""" id: str userId: str # FK → User (CASCADE DELETE) featureInstanceId: str # FK → FeatureInstance (CASCADE DELETE) enabled: bool = True # Rollen via Junction Table FeatureAccessRole class UserMandateRole(BaseModel): """Junction: UserMandate zu Role""" id: str userMandateId: str # FK → UserMandate (CASCADE DELETE) roleId: str # FK → Role (CASCADE DELETE) class FeatureAccessRole(BaseModel): """Junction: FeatureAccess zu Role""" id: str featureAccessId: str # FK → FeatureAccess (CASCADE DELETE) roleId: str # FK → Role (CASCADE DELETE) ``` **Begründung für Junction Tables (statt Array-Felder):** - PostgreSQL CASCADE DELETE funktioniert auf Foreign Keys, nicht auf Array-Elemente - Saubere referentielle Integrität - Performante Queries mit JOINs - Standard-Pattern für m:n Beziehungen ### 2.4 Feature Models ```python class Feature(BaseModel): """Feature-Definition (global, z.B. 'trustee')""" code: str # PK, z.B. "trustee" label: Dict[str, str] # I18n icon: str class FeatureInstance(BaseModel): """Instanz eines Features in einem Mandanten""" id: str featureCode: str # FK → Feature mandateId: str # FK → Mandate (CASCADE DELETE) label: str # z.B. "Buchhaltung 2025" enabled: bool = True # Kein rbacCacheVersion - Stateless Design, kein Cache ``` ### 2.5 AccessRule Model ```python class AccessRule(BaseModel): """Berechtigungsregel für eine Rolle""" id: str roleId: str # FK → Role.id (CASCADE DELETE!) context: str # "DATA" | "UI" | "RESOURCE" - IMMUTABLE! item: Optional[str] # Dot-Notation (siehe 3.1) view: bool = False read: Optional[str] # "n" | "m" | "g" | "a" create: Optional[str] update: Optional[str] delete: Optional[str] ``` **RBAC-Enforcement für AccessRule:** ```python # AccessRule.context und roleId sind IMMUTABLE: # - CREATE: Ja # - READ: Ja # - UPDATE auf context/roleId: NEIN! → Blockiert # - UPDATE auf permissions (view, read, etc.): Ja # - DELETE: Ja ``` ### 2.6 Kontext-Vererbung ``` Role definiert den Scope: ├── Global Role (mandateId=None) → AccessRules gelten überall ├── Mandate Role (mandateId=X) → AccessRules gelten nur in Mandant X └── Instance Role (mandateId=X, instanceId=Y) → AccessRules gelten nur in Instanz Y ``` **AccessRule erbt Scope von Role** - kein separates `mandateId`/`featureInstanceId` auf AccessRule nötig! --- ## 3. RBAC-System ### 3.1 Item-Notation (Dot-Separated) Alle berechtigungspflichtigen Elemente werden mit **Dot-Notation** definiert: | Context | Format | Beispiele | |---------|--------|-----------| | `DATA` | `table` oder `table.field` | `ChatWorkflow`, `User.email`, `TrusteeContract.amount` | | `UI` | `component.group.name...` | `nav.trustee`, `dialog.settings.voice`, `button.export` | | `RESOURCE` | `resource.category.name...` | `ai.model.anthropic`, `action.email.send`, `connector.sharepoint` | **Hierarchie:** Längster Match gewinnt. `trustee.contract` überschreibt `trustee`. ### 3.2 IMMUTABLE Felder (Enforcement) **Kritisch:** Bestimmte Felder dürfen nach Erstellung NICHT mehr geändert werden: | Model | Feld | Create | Read | Update | Delete | |-------|------|--------|------|--------|--------| | `Role` | `mandateId` | ✅ | ✅ | ❌ | ✅ (ganzes Objekt) | | `Role` | `featureInstanceId` | ✅ | ✅ | ❌ | ✅ (ganzes Objekt) | | `AccessRule` | `context` | ✅ | ✅ | ❌ | ✅ (ganzes Objekt) | | `AccessRule` | `roleId` | ✅ | ✅ | ❌ | ✅ (ganzes Objekt) | **Implementation auf zwei Ebenen:** **1. Application Level (erste Verteidigung):** ```python # Im RBAC-System: Update auf immutable Felder blockieren IMMUTABLE_FIELDS = { "Role": ["mandateId", "featureInstanceId", "featureCode"], "AccessRule": ["context", "roleId"] } def validateUpdate(model: str, updateData: dict): forbidden = IMMUTABLE_FIELDS.get(model, []) for field in forbidden: if field in updateData: raise PermissionError(f"Field '{field}' is immutable on {model}") ``` **2. Database Level (zweite Verteidigung - verhindert Bypass):** ```sql -- Trigger: Verhindert Änderung von immutable Feldern auf Role CREATE OR REPLACE FUNCTION prevent_role_context_update() RETURNS TRIGGER AS $$ BEGIN IF OLD."mandateId" IS DISTINCT FROM NEW."mandateId" THEN RAISE EXCEPTION 'mandateId is immutable on Role'; END IF; IF OLD."featureInstanceId" IS DISTINCT FROM NEW."featureInstanceId" THEN RAISE EXCEPTION 'featureInstanceId is immutable on Role'; END IF; IF OLD."featureCode" IS DISTINCT FROM NEW."featureCode" THEN RAISE EXCEPTION 'featureCode is immutable on Role'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER tr_role_immutable BEFORE UPDATE ON "Role" FOR EACH ROW EXECUTE FUNCTION prevent_role_context_update(); -- Trigger: Verhindert Änderung von immutable Feldern auf AccessRule CREATE OR REPLACE FUNCTION prevent_accessrule_context_update() RETURNS TRIGGER AS $$ BEGIN IF OLD."context" IS DISTINCT FROM NEW."context" THEN RAISE EXCEPTION 'context is immutable on AccessRule'; END IF; IF OLD."roleId" IS DISTINCT FROM NEW."roleId" THEN RAISE EXCEPTION 'roleId is immutable on AccessRule'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER tr_accessrule_immutable BEFORE UPDATE ON "AccessRule" FOR EACH ROW EXECUTE FUNCTION prevent_accessrule_context_update(); ``` ### 3.3 Regel-Ebenen und Priorisierung ``` Priorisierung (höher überschreibt niedriger): 1. Instance Role (Role.featureInstanceId gesetzt) → höchste Priorität 2. Mandate Role (Role.mandateId gesetzt, featureInstanceId=None) 3. Global Role (Role.mandateId=None) → niedrigste Priorität ``` ### 3.4 Standard-Rollen pro Feature Beim Erstellen einer FeatureInstance werden **Rollen und deren AccessRules kopiert**: ```python def createFeatureInstance(featureCode: str, mandateId: str, label: str): """ Erstellt eine neue Feature-Instanz und kopiert Template-Rollen. WICHTIG: Templates werden NUR bei Erstellung kopiert. Spätere Template-Änderungen werden NICHT automatisch propagiert. Für manuelle Nachsynchronisation siehe syncRolesFromTemplate(). """ instance = FeatureInstance(featureCode=featureCode, mandateId=mandateId, label=label) db.create(instance) # Globale Template-Rollen für dieses Feature finden globalRoles = db.getRoles(featureCode=featureCode, mandateId=None) templateRoleIds = [r.id for r in globalRoles] if not templateRoleIds: return instance # BULK: Alle Template-AccessRules in einem Query laden allTemplateRules = db.execute( 'SELECT * FROM "AccessRule" WHERE "roleId" = ANY(:roleIds)', {"roleIds": templateRoleIds} ) # Index für schnellen Lookup: roleId -> rules rulesByRoleId = {} for rule in allTemplateRules: roleId = rule["roleId"] if roleId not in rulesByRoleId: rulesByRoleId[roleId] = [] rulesByRoleId[roleId].append(rule) # Neue Rollen und AccessRules sammeln für Bulk-Insert newRoles = [] newAccessRules = [] for templateRole in globalRoles: newRoleId = str(uuid.uuid4()) newRoles.append({ "id": newRoleId, "roleLabel": templateRole.roleLabel, "description": templateRole.description, "featureCode": templateRole.featureCode, "mandateId": mandateId, "featureInstanceId": instance.id, "isSystemRole": False }) # AccessRules für diese Rolle vorbereiten templateRulesForRole = rulesByRoleId.get(templateRole.id, []) for rule in templateRulesForRole: newAccessRules.append({ "id": str(uuid.uuid4()), "roleId": newRoleId, "context": rule["context"], "item": rule["item"], "view": rule["view"], "read": rule["read"], "create": rule["create"], "update": rule["update"], "delete": rule["delete"] }) # BULK INSERT: Alle neuen Rollen und AccessRules if newRoles: db.bulkInsert("Role", newRoles) if newAccessRules: db.bulkInsert("AccessRule", newAccessRules) return instance ``` ### 3.5 Template-Rollen Synchronisation **Problem:** Wenn globale Template-Rollen aktualisiert werden, sind bestehende Feature-Instanzen nicht automatisch aktuell. **Lösung:** Explizite Sync-Funktion für Mandant-Admins oder bei Template-Updates. ```python async def syncRolesFromTemplate(featureInstanceId: str, addOnly: bool = True): """ Synchronisiert Rollen einer Feature-Instanz mit den aktuellen Templates. WICHTIG: Templates werden NUR bei Erstellung einer neuen FeatureInstance kopiert. Diese Sync-Funktion ist für manuelle Nachsynchronisation gedacht, nicht für automatische Propagation von Template-Änderungen. Args: featureInstanceId: ID der zu synchronisierenden Instanz addOnly: Wenn True, werden nur fehlende Rollen hinzugefügt. Wenn False, werden auch überzählige Rollen entfernt. Returns: SyncResult mit added/removed/unchanged Counts """ instance = db.getFeatureInstance(featureInstanceId) if not instance: raise ValueError(f"FeatureInstance {featureInstanceId} not found") # Aktuelle Template-Rollen für dieses Feature templateRoles = db.getRoles(featureCode=instance.featureCode, mandateId=None) templateLabels = {r.roleLabel for r in templateRoles} templateRoleIds = [r.id for r in templateRoles] # Aktuelle Instanz-Rollen instanceRoles = db.getRoles(featureInstanceId=featureInstanceId) instanceLabels = {r.roleLabel for r in instanceRoles} result = {"added": 0, "removed": 0, "unchanged": 0} async with db.transaction() as tx: # BULK: Alle Template-AccessRules in einem Query laden allTemplateRules = [] if templateRoleIds: allTemplateRules = await tx.execute( 'SELECT * FROM "AccessRule" WHERE "roleId" = ANY(:roleIds)', {"roleIds": templateRoleIds} ) # Index für schnellen Lookup: roleId -> rules rulesByRoleId = {} for rule in allTemplateRules: roleId = rule["roleId"] if roleId not in rulesByRoleId: rulesByRoleId[roleId] = [] rulesByRoleId[roleId].append(rule) # Neue Rollen und AccessRules sammeln für Bulk-Insert newRoles = [] newAccessRules = [] roleIdMapping = {} # templateRoleId -> newRoleId for templateRole in templateRoles: if templateRole.roleLabel not in instanceLabels: # Neue Rolle vorbereiten newRoleId = str(uuid.uuid4()) roleIdMapping[templateRole.id] = newRoleId newRoles.append({ "id": newRoleId, "roleLabel": templateRole.roleLabel, "description": templateRole.description, "featureCode": templateRole.featureCode, "mandateId": instance.mandateId, "featureInstanceId": featureInstanceId, "isSystemRole": False }) # AccessRules für diese Rolle vorbereiten templateRulesForRole = rulesByRoleId.get(templateRole.id, []) for rule in templateRulesForRole: newAccessRules.append({ "id": str(uuid.uuid4()), "roleId": newRoleId, "context": rule["context"], "item": rule["item"], "view": rule["view"], "read": rule["read"], "create": rule["create"], "update": rule["update"], "delete": rule["delete"] }) result["added"] += 1 else: result["unchanged"] += 1 # BULK INSERT: Alle neuen Rollen if newRoles: await tx.bulkInsert("Role", newRoles) # BULK INSERT: Alle neuen AccessRules if newAccessRules: await tx.bulkInsert("AccessRule", newAccessRules) # Überzählige Rollen entfernen (optional) if not addOnly: for instanceRole in instanceRoles: if instanceRole.roleLabel not in templateLabels: # Prüfen ob Rolle noch verwendet wird usages = await tx.getRecordset( FeatureAccessRole, recordFilter={"roleId": instanceRole.id} ) if not usages: await tx.delete(Role, instanceRole.id) result["removed"] += 1 await tx.commit() logger.info(f"Synced roles for instance {featureInstanceId}: {result}") return result # Endpoint für Mandate-Admin @router.post("/api/features/instances/{instanceId}/sync-roles") @limiter.limit("10/minute") async def syncInstanceRoles( instanceId: str, addOnly: bool = Query(True, description="Only add missing roles, don't remove extras"), currentUser: User = Depends(getCurrentUser), ctx: RequestContext = Depends(getRequestContext) ): """Synchronisiert Rollen einer Feature-Instanz mit Templates.""" # Nur Mandate-Admin oder Feature-Admin darf synchronisieren if not hasAdminRole(ctx, instanceId): raise HTTPException(403, "Admin role required") result = await syncRolesFromTemplate(instanceId, addOnly) return result ``` ### 3.6 RBAC - Stateless Design (Kein Cache) **Prinzip:** Gateway ist vollständig **stateless** - wie JWT Session-Handling. **Warum kein Cache:** - Gateway läuft mit **Load Balancing** (mehrere Cloud-Instanzen) - Horizontale Skalierung erfolgt über Cloud-Infrastruktur, nicht im Code - Keine zusätzliche Komponente (Redis) nötig - PostgreSQL ist die einzige Source of Truth **Performance durch optimierte Queries:** - Bulk-Query mit JOIN (siehe 3.7) statt N+1 Queries - Richtige Indizes auf Foreign Keys - Query-Zeit typisch <10ms - **Datenbank-Skalierung ist Infrastruktur-Aufgabe**, nicht Code-Aufgabe (Read Replicas, Connection Pooling, etc.) ```python # KEIN globaler Cache! # Jeder Request lädt RBAC-Regeln frisch aus der Datenbank. # Das ist by design: Stateless = Cloud-Ready # Optional: Request-Scoped Cache (nur innerhalb EINES Requests) class RequestContext: """Kontext für einen einzelnen Request - wird nicht persistiert.""" def __init__(self): self.user: Optional[User] = None self.mandateId: Optional[str] = None self.featureInstanceId: Optional[str] = None self.roleIds: List[str] = [] # Request-Scoped: Regeln nur einmal pro Request laden self._cachedRules: Optional[List[AccessRule]] = None def getRules(self) -> List[AccessRule]: """Lädt Regeln einmal pro Request (nicht über Requests hinweg).""" if self._cachedRules is None: self._cachedRules = getRulesForUserBulk( self.user.id, self.mandateId, self.featureInstanceId ) return self._cachedRules ``` **Vorteile des Stateless-Designs:** - ✅ Keine Cache-Invalidierung nötig - ✅ Keine Inkonsistenzen zwischen Instanzen - ✅ Keine zusätzliche Infrastruktur (Redis) - ✅ Einfaches Deployment (nur Gateway + PostgreSQL) - ✅ RBAC-Änderungen sind sofort wirksam **Read Replica Handling:** Bei Verwendung von Read Replicas kann es zu Replication Lag kommen. Kritische RBAC-Reads (nach Berechtigungsänderungen) müssen vom **Primary** erfolgen: ```python # RBAC-Queries immer vom Primary lesen # Verhindert stale reads nach Berechtigungsänderungen def getRulesForUserBulk(userId: str, mandateId: str, featureInstanceId: Optional[str] = None, usePrimary: bool = True) -> List[Tuple[int, AccessRule]]: """ Lädt alle relevanten Regeln für einen User. Args: usePrimary: Wenn True, wird Primary-DB verwendet (verhindert Replication Lag). Default True für sicherheitskritische RBAC-Queries. """ db = getPrimaryConnection() if usePrimary else getReplicaConnection() # ... rest of implementation ``` **Wichtig: DB-Indizes für Performance** ```sql -- ============================================ -- CORE RBAC INDIZES -- ============================================ -- UserMandate: Lookup by User (alle Mandanten eines Users) CREATE INDEX idx_usermandate_user ON "UserMandate"("userId"); -- UserMandate: Lookup by User+Mandate (Mitgliedschaft prüfen) CREATE INDEX idx_usermandate_user_mandate ON "UserMandate"("userId", "mandateId"); -- UserMandate: Lookup by Mandate (alle User eines Mandanten) CREATE INDEX idx_usermandate_mandate ON "UserMandate"("mandateId"); -- UserMandate: Partial Index für aktive Memberships (häufigster Query-Fall) CREATE INDEX idx_usermandate_user_enabled ON "UserMandate"("userId") WHERE "enabled" = true; -- UserMandateRole: Lookup by UserMandate (Rollen laden) CREATE INDEX idx_usermandaterole_usermandate ON "UserMandateRole"("userMandateId"); -- UserMandateRole: Lookup by Role (wer hat diese Rolle?) CREATE INDEX idx_usermandaterole_role ON "UserMandateRole"("roleId"); -- FeatureAccess: Lookup by User+Instance (Zugang prüfen) CREATE INDEX idx_featureaccess_user_instance ON "FeatureAccess"("userId", "featureInstanceId"); -- FeatureAccess: Lookup by User (alle Instanzen eines Users) CREATE INDEX idx_featureaccess_user ON "FeatureAccess"("userId"); -- FeatureAccess: Lookup by Instance (alle User einer Instanz) CREATE INDEX idx_featureaccess_instance ON "FeatureAccess"("featureInstanceId"); -- FeatureAccessRole: Lookup by FeatureAccess (Rollen laden) CREATE INDEX idx_featureaccessrole_featureaccess ON "FeatureAccessRole"("featureAccessId"); -- FeatureAccessRole: Lookup by Role (wer hat diese Rolle?) CREATE INDEX idx_featureaccessrole_role ON "FeatureAccessRole"("roleId"); -- ============================================ -- ACCESSRULE INDIZES -- ============================================ -- AccessRule: Bulk-Load by Role (Hauptquery) CREATE INDEX idx_accessrule_roleid ON "AccessRule"("roleId"); -- AccessRule: Lookup by Context+Role (gefilterte Queries) CREATE INDEX idx_accessrule_context_roleid ON "AccessRule"("context", "roleId"); -- ============================================ -- ROLE INDIZES -- ============================================ -- Role: Lookup by Mandate+Instance (Instanz-Rollen) CREATE INDEX idx_role_mandate_instance ON "Role"("mandateId", "featureInstanceId"); -- Role: Lookup by FeatureCode (Template-Rollen finden) CREATE INDEX idx_role_featurecode ON "Role"("featureCode") WHERE "mandateId" IS NULL; -- Role: Lookup by Label (Rolle nach Name finden) CREATE INDEX idx_role_label ON "Role"("roleLabel"); -- ============================================ -- FEATUREINSTANCE INDIZES -- ============================================ -- FeatureInstance: Lookup by Mandate (alle Instanzen eines Mandanten) CREATE INDEX idx_featureinstance_mandate ON "FeatureInstance"("mandateId"); -- FeatureInstance: Lookup by Mandate+FeatureCode (gefiltert) CREATE INDEX idx_featureinstance_mandate_code ON "FeatureInstance"("mandateId", "featureCode"); ``` def onRbacChange(mandateId: str, featureInstanceId: Optional[str] = None): """ Wird aufgerufen wenn RBAC-Regeln geändert werden. Bei Stateless-Design: Keine Aktion nötig! Änderungen sind sofort wirksam, da jeder Request frisch aus DB lädt. Diese Funktion existiert als Hook für: - Audit-Logging von RBAC-Änderungen - Zukünftige Erweiterungen (z.B. Notifications) """ from modules.shared.auditLogger import audit_logger audit_logger.logSecurityEvent( userId="system", mandateId=mandateId, action="rbac_change", details=f"featureInstanceId={featureInstanceId}" if featureInstanceId else "mandate-level" ) ``` ### 3.7 Optimierte Regel-Auflösung (Bulk Query) **Problem:** N+1 Queries bei vielen Rollen. **Lösung:** Bulk-Query mit JOIN. ```python def getRulesForUserBulk(userId: str, mandateId: str, featureInstanceId: Optional[str] = None) -> List[Tuple[int, AccessRule]]: """ Lädt alle relevanten Regeln für einen User in EINEM Query. Stateless: Kein Cache, direkt aus DB. Returns: Liste von (priority, AccessRule) Tupeln """ # 1. Alle roleIds für User sammeln via Junction Tables roleIds = set() # Mandant-Rollen via UserMandate → UserMandateRole query = """ SELECT umr."roleId" FROM "UserMandate" um JOIN "UserMandateRole" umr ON umr."userMandateId" = um.id WHERE um."userId" = :userId AND um."mandateId" = :mandateId AND um."enabled" = true """ mandateRoles = db.execute(query, {"userId": userId, "mandateId": mandateId}) roleIds.update(r["roleId"] for r in mandateRoles) # Instanz-Rollen via FeatureAccess → FeatureAccessRole if featureInstanceId: query = """ SELECT far."roleId" FROM "FeatureAccess" fa JOIN "FeatureAccessRole" far ON far."featureAccessId" = fa.id WHERE fa."userId" = :userId AND fa."featureInstanceId" = :instanceId AND fa."enabled" = true """ instanceRoles = db.execute(query, {"userId": userId, "instanceId": featureInstanceId}) roleIds.update(r["roleId"] for r in instanceRoles) if not roleIds: return [] # 2. BULK Query: Alle Regeln für alle Rollen + zugehörige Role-Daten # SINGLE Query mit JOIN statt N+1 query = """ SELECT ar.*, r."mandateId" as "roleMandateId", r."featureInstanceId" as "roleInstanceId" FROM "AccessRule" ar JOIN "Role" r ON ar."roleId" = r.id WHERE ar."roleId" = ANY(:roleIds) """ allRulesWithContext = db.execute(query, {"roleIds": list(roleIds)}) # 3. Priorität zuweisen basierend auf Role-Scope rulesWithPriority = [] for rule in allRulesWithContext: if rule["roleInstanceId"]: priority = 3 # Instance-Rolle = höchste Priorität elif rule["roleMandateId"]: priority = 2 # Mandate-Rolle else: priority = 1 # Global-Rolle = niedrigste Priorität rulesWithPriority.append((priority, AccessRule(**rule))) return rulesWithPriority ``` --- ## 4. System-Funktionen (Ohne Mandant) ### 4.1 Immer verfügbare Endpoints Diese Funktionen benötigen **keinen Mandanten**: ```python # Keine RBAC-Prüfung auf Mandant/Feature-Ebene @router.get("/auth/me") # Eigener User @router.put("/auth/profile") # Profil bearbeiten @router.put("/auth/settings") # Einstellungen ändern @router.post("/auth/logout") # Abmelden @router.get("/mandates/my") # Meine Mandanten-Liste @router.get("/features/my") # Meine Feature-Instanzen ``` ### 4.2 Implementation ```python def getCurrentUser(token) -> User: """Validiert Token, lädt User. Kein Mandant-Kontext.""" userId = validateToken(token) return db.getUser(userId) # System-Endpoints prüfen nur: User eingeloggt @router.get("/auth/me") async def getMe(currentUser: User = Depends(getCurrentUser)): return currentUser ``` --- ## 5. Admin-Funktionen (SysAdmin) ### 5.1 Design-Entscheidung: Strikte Trennung System vs. Daten **Einfaches Prinzip:** `isSysAdmin=true` bedeutet **System-Zugriff, KEIN Daten-Zugriff**. ```python class User(BaseModel): isSysAdmin: bool = False # Globales Admin-Flag # isSysAdmin=true → Kann System verwalten, aber KEINE Mandant-Daten sehen ``` **Warum diese Trennung?** - 🔒 **Einfach & sicher:** Kein komplexes 4-Augen-System nötig - 📊 **Klare Verantwortung:** SysAdmin = Infrastruktur, User = Daten - 🏦 **Bank-konform:** Datenzugriff nur über explizite Rollen, nie über Admin-Privileg - 🔍 **Auditierbar:** Wenn SysAdmin Daten braucht → normaler User mit Audit-Trail ### 5.2 Was SysAdmin KANN (System-Ebene) ```python def requireSysAdmin(currentUser: User = Depends(getCurrentUser)): """SysAdmin-Check für System-Level Operationen.""" if not currentUser.isSysAdmin: raise HTTPException(403, "SysAdmin required") # Audit für alle SysAdmin-Aktionen audit_logger.logSecurityEvent( userId=str(currentUser.id), mandateId="system", action="sysadmin_action", details="System-level operation" ) return currentUser # ✅ SysAdmin KANN: @router.get("/admin/mandates") async def listAllMandates(admin: User = Depends(requireSysAdmin)): """Liste aller Mandanten (nur Metadaten: id, name, enabled).""" return db.getAllMandates(fields=["id", "name", "enabled", "createdAt"]) @router.post("/admin/mandates") async def createMandate(data: MandateCreate, admin: User = Depends(requireSysAdmin)): """Neuen Mandanten erstellen.""" return db.createMandate(data) @router.delete("/admin/mandates/{mandateId}") async def deleteMandate(mandateId: str, admin: User = Depends(requireSysAdmin)): """Mandant löschen (Struktur, nicht Daten-Einblick).""" # Prüft nur ob Mandant existiert, sieht keine Inhalte return db.deleteMandate(mandateId) @router.get("/admin/users") async def listAllUsers(admin: User = Depends(requireSysAdmin)): """Liste aller User (nur Metadaten: id, username, email, enabled).""" return db.getAllUsers(fields=["id", "username", "email", "enabled", "isSysAdmin"]) @router.post("/admin/users/{userId}/enable") @router.post("/admin/users/{userId}/disable") async def toggleUserEnabled(userId: str, admin: User = Depends(requireSysAdmin)): """User aktivieren/deaktivieren.""" @router.get("/admin/rbac/global/export") async def exportGlobalRbac(admin: User = Depends(requireSysAdmin)): """Exportiert globale (Template) RBAC-Regeln.""" return exportRbac(scope="global") @router.post("/admin/rbac/global/import") async def importGlobalRbac(data: RbacImport, admin: User = Depends(requireSysAdmin)): """Importiert globale RBAC-Regeln.""" ``` ### 5.3 Was SysAdmin NICHT KANN (Daten-Ebene) ```python # ❌ SysAdmin kann NICHT: # - Mandant-Daten lesen (TrusteeContracts, ChatWorkflows, etc.) # - User-Rollen innerhalb eines Mandanten sehen # - Feature-Instanz-Inhalte einsehen # - RBAC-Regeln eines spezifischen Mandanten exportieren # Enforcement in RBAC-Check: def checkPermission(ctx: RequestContext, context: str, item: str, action: str) -> AccessLevel: """RBAC-Prüfung mit SysAdmin-Blockade für Mandant-Daten.""" # KRITISCH: SysAdmin hat KEINEN Zugriff auf Mandant-Daten! if ctx.user.isSysAdmin and ctx.mandateId: # SysAdmin versucht auf Mandant-Daten zuzugreifen # Prüfe ob er auch reguläre Rollen im Mandanten hat if not ctx.roleIds: # Keine Rollen im Mandanten → kein Zugriff logger.warning( f"SysAdmin {ctx.user.id} blocked from mandate {ctx.mandateId} data" ) return AccessLevel.NONE # Normale RBAC-Auflösung für alle (inkl. SysAdmin mit Rollen) rules = getRulesForUserBulk(ctx.user.id, ctx.mandateId, ctx.featureInstanceId) # ... Rest der Permission-Logik ``` ### 5.4 SysAdmin braucht Datenzugriff? → Muss Mandate-Admin werden **Design-Entscheidung:** SysAdmin kann sich **NICHT selbst** zu einem Mandanten hinzufügen. Wenn ein SysAdmin auf Mandant-Daten zugreifen muss: 1. Ein **Mandate-Admin** muss den SysAdmin als regulären User zum Mandanten hinzufügen 2. Der SysAdmin unterliegt dann der normalen RBAC-Prüfung 3. Alle Aktionen werden vollständig auditiert **Begründung:** - 🔒 **Keine Self-Service-Eskalation:** SysAdmin kann sich keine Rechte selbst geben - 📊 **4-Augen-Prinzip:** Mandate-Admin muss Zugriff explizit gewähren - 🔍 **Klarer Audit-Trail:** Wer hat wem wann Zugriff gegeben ### 5.5 User zu Mandant hinzufügen (createUserMandate Endpoint) ```python class UserMandateCreate(BaseModel): """Request-Model für User-Mandant-Verknüpfung""" targetUserId: str roleIds: List[str] # Rollen die dem User im Mandant zugewiesen werden @router.post("/api/mandates/{mandateId}/users") @limiter.limit("30/minute") async def addUserToMandate( mandateId: str, data: UserMandateCreate, currentUser: User = Depends(getCurrentUser), ctx: RequestContext = Depends(getRequestContext) ): """ Fügt einen User zu einem Mandanten hinzu. Nur Mandate-Admin darf User hinzufügen. SysAdmin kann sich NICHT selbst hinzufügen (Self-Eskalation Prevention). """ # 1. SysAdmin Self-Eskalation Prevention if currentUser.isSysAdmin and data.targetUserId == currentUser.id: raise HTTPException( 403, "SysAdmin cannot add themselves to a mandate. " "A Mandate-Admin must grant access." ) # 2. Mandate-Admin Berechtigung prüfen if not hasMandateAdminRole(ctx, mandateId): raise HTTPException(403, "Mandate-Admin role required to add users") # 3. Prüfen ob User existiert targetUser = db.getUser(data.targetUserId) if not targetUser: raise HTTPException(404, f"User {data.targetUserId} not found") # 4. Prüfen ob User bereits Mitglied ist existingMembership = db.getUserMandate(data.targetUserId, mandateId) if existingMembership: raise HTTPException(409, f"User {data.targetUserId} is already member of mandate") # 5. Rollen validieren (müssen im Mandant existieren) for roleId in data.roleIds: role = db.getRole(roleId) if not role: raise HTTPException(404, f"Role {roleId} not found") if role.mandateId and role.mandateId != mandateId: raise HTTPException(400, f"Role {roleId} belongs to different mandate") # 6. UserMandate erstellen async with db.transaction() as tx: userMandate = UserMandate( userId=data.targetUserId, mandateId=mandateId, enabled=True ) await tx.create(userMandate) # 7. Rollen zuweisen via Junction Table for roleId in data.roleIds: userMandateRole = UserMandateRole( userMandateId=userMandate.id, roleId=roleId ) await tx.create(userMandateRole) await tx.commit() # 8. Audit audit_logger.logSecurityEvent( userId=str(currentUser.id), mandateId=mandateId, action="user_added_to_mandate", details=f"targetUser={data.targetUserId}, roles={data.roleIds}" ) return {"message": "User added to mandate", "userMandateId": userMandate.id} @router.delete("/api/mandates/{mandateId}/users/{targetUserId}") @limiter.limit("30/minute") async def removeUserFromMandate( mandateId: str, targetUserId: str, currentUser: User = Depends(getCurrentUser), ctx: RequestContext = Depends(getRequestContext) ): """Entfernt einen User aus einem Mandanten.""" # Mandate-Admin Berechtigung prüfen if not hasMandateAdminRole(ctx, mandateId): raise HTTPException(403, "Mandate-Admin role required") # User darf sich nicht selbst entfernen wenn er letzter Admin ist # (sonst ist Mandant ohne Admin) membership = db.getUserMandate(targetUserId, mandateId) if not membership: raise HTTPException(404, "User is not member of mandate") # UserMandate löschen (CASCADE löscht UserMandateRoles) db.delete(UserMandate, membership.id) audit_logger.logSecurityEvent( userId=str(currentUser.id), mandateId=mandateId, action="user_removed_from_mandate", details=f"targetUser={targetUserId}" ) return {"message": "User removed from mandate"} ``` ### 5.6 Übersicht: SysAdmin vs. Mandate-Admin | Aktion | SysAdmin (`isSysAdmin=true`) | Mandate-Admin (Rolle) | |--------|------------------------------|----------------------| | Mandanten erstellen | ✅ | ❌ | | Mandanten löschen | ✅ | ❌ | | User global verwalten | ✅ (enable/disable) | ❌ | | Globale RBAC-Templates | ✅ | ❌ | | **Mandant-Daten lesen** | ❌ | ✅ | | **User zu Mandant hinzufügen** | ❌ | ✅ | | **RBAC im Mandant ändern** | ❌ | ✅ | | **Feature-Instanzen verwalten** | ❌ | ✅ | --- ## 6. Rate Limiting (Bestehende Implementierung erweitern) ### 6.1 Bestehende Rate Limits (routeSecurityLocal.py) ```python # Bereits implementiert im Gateway: @router.post("/login") @limiter.limit("30/minute") # ✅ @router.post("/register") @limiter.limit("10/minute") # ✅ @router.post("/password-reset-request") @limiter.limit("5/minute") # ✅ @router.post("/password-reset") @limiter.limit("10/minute") # ✅ @router.get("/me") @limiter.limit("30/minute") # ✅ @router.post("/refresh") @limiter.limit("60/minute") # ✅ @router.post("/logout") @limiter.limit("30/minute") # ✅ ``` ### 6.2 Zusätzliche Rate Limits für Multi-Tenant ```python # Neue Limits für Admin-Endpoints @router.get("/admin/mandates") @limiter.limit("60/minute") @router.post("/admin/access-request") @limiter.limit("5/minute") # Streng für kritische Aktionen @router.post("/admin/access-request/{requestId}/approve") @limiter.limit("10/minute") # RBAC-Endpoints @router.get("/rbac/rules") @limiter.limit("120/minute") # Häufiger für UI @router.post("/rbac/rules") @limiter.limit("30/minute") ``` --- ## 7. Audit Logging (Bestehende Implementierung erweitern) ### 7.1 Bestehende AuditLogger-Nutzung ```python # Bereits implementiert in routeSecurityLocal.py: # Login erfolgreich audit_logger.logUserAccess( userId=str(user.id), mandateId=str(user.mandateId), action="login", successInfo="local_auth_success" ) # Login fehlgeschlagen audit_logger.logUserAccess( userId="unknown", mandateId="unknown", action="login", successInfo=f"failed: {error_msg}" ) # Logout audit_logger.logUserAccess( userId=str(currentUser.id), mandateId=str(currentUser.mandateId), action="logout", successInfo=f"revoked_tokens: {revoked}" ) # Password Reset audit_logger.logSecurityEvent( userId="unknown", mandateId="unknown", action="password_reset_via_token", details="Password reset completed via magic link" ) ``` ### 7.2 Erweiterte Audit-Events für Multi-Tenant ```python # Neue Audit-Events hinzufügen: # Mandant-Wechsel audit_logger.logUserAccess( userId=userId, mandateId=newMandateId, action="mandate_switch", successInfo=f"from:{oldMandateId}" ) # Feature-Zugriff audit_logger.logUserAccess( userId=userId, mandateId=mandateId, action="feature_access", successInfo=f"instance:{featureInstanceId}" ) # RBAC-Änderungen audit_logger.logSecurityEvent( userId=userId, mandateId=mandateId, action="rbac_rule_created", details=f"role:{roleId}, item:{item}" ) # SysAdmin-Aktionen audit_logger.logSecurityEvent( userId=sysAdminId, mandateId=targetMandateId, action="sysadmin_action", details=f"Action: {action_type}" ) ``` ### 7.3 Audit-Log Retention Policy **Aufbewahrungsfristen für Audit-Logs:** | Log-Typ | Aufbewahrung | Begründung | |---------|--------------|------------| | **Security Events** | 24 Monate | Login/Logout, RBAC-Änderungen, Admin-Aktionen | | **Data Access** | 12 Monate | DSGVO-Auskunft, Datenexporte | | **User Activity** | 6 Monate | Feature-Zugriffe, Mandant-Wechsel | | **System Events** | 3 Monate | Technische Logs, Performance | **Implementation:** ```python # Konfiguration in APP_CONFIG AUDIT_RETENTION_SECURITY_MONTHS = 24 AUDIT_RETENTION_DATA_ACCESS_MONTHS = 12 AUDIT_RETENTION_USER_ACTIVITY_MONTHS = 6 AUDIT_RETENTION_SYSTEM_MONTHS = 3 # Cleanup-Job wird über den bestehenden EventManager (APScheduler) registriert # Siehe: modules/shared/eventManagement.py async def cleanupExpiredAuditLogs(batchSize: int = 1000): """ Löscht Audit-Logs nach Ablauf der Aufbewahrungsfrist. Löscht in Batches um Lock-Contention zu vermeiden. """ now = getUtcTimestamp() retentionPolicies = { "security": AUDIT_RETENTION_SECURITY_MONTHS * 30 * 24 * 3600, "data_access": AUDIT_RETENTION_DATA_ACCESS_MONTHS * 30 * 24 * 3600, "user_activity": AUDIT_RETENTION_USER_ACTIVITY_MONTHS * 30 * 24 * 3600, "system": AUDIT_RETENTION_SYSTEM_MONTHS * 30 * 24 * 3600, } for logType, retentionSeconds in retentionPolicies.items(): cutoffTime = now - retentionSeconds totalDeleted = 0 while True: async with db.transaction() as tx: # Batch-Delete um Lock-Contention zu vermeiden deleted = await tx.execute( '''DELETE FROM "AuditLog" WHERE id IN ( SELECT id FROM "AuditLog" WHERE "logType" = :logType AND "timestamp" < :cutoff LIMIT :batch )''', {"logType": logType, "cutoff": cutoffTime, "batch": batchSize} ) await tx.commit() if deleted == 0: break totalDeleted += deleted if totalDeleted > 0: logger.info(f"Deleted {totalDeleted} expired {logType} audit logs") # Registrierung im featuresLifecycle.py (analog zu bestehenden scheduled Jobs): # eventManager.registerCron( # jobId="audit_cleanup", # func=cleanupExpiredAuditLogs, # cronKwargs={"hour": "3", "minute": "0"} # Täglich um 03:00 # ) ``` --- ## 8. RBAC Export/Import (Mandantenspezifisch) ### 8.1 Übersicht: Wer kann was exportieren? | Scope | Wer kann exportieren? | Wer kann importieren? | |-------|----------------------|----------------------| | **Global** (Templates) | SysAdmin | SysAdmin | | **Mandant** | Mandate-Admin | Mandate-Admin | | **Feature-Instanz** | Feature-Admin | Feature-Admin | ### 8.2 Export-Format (JSON) ```json { "version": "2.0", "exportedAt": "2026-01-16T10:00:00Z", "exportedBy": "admin@example.com", "scope": { "type": "mandate", "mandateId": "uuid-456", "mandateName": "Althaus Consulting", "featureInstanceId": null }, "roles": [ { "roleLabel": "trustee-admin", "description": {"en": "Trustee Administrator", "de": "Treuhand-Administrator"}, "featureCode": "trustee" } ], "accessRules": [ { "roleLabel": "trustee-admin", "context": "DATA", "item": "TrusteeContract", "view": true, "read": "g", "create": "g", "update": "g", "delete": "m" }, { "roleLabel": "trustee-admin", "context": "UI", "item": "nav.trustee.contracts", "view": true } ] } ``` ### 8.3 Export-Endpoints ```python # ============================================ # GLOBAL EXPORT (SysAdmin only) # ============================================ @router.get("/admin/rbac/global/export") @limiter.limit("10/minute") async def exportGlobalRbac( featureCode: Optional[str] = Query(None, description="Filter by feature"), admin: User = Depends(requireSysAdmin) ) -> RbacExport: """ Exportiert globale (Template) RBAC-Regeln. Nur SysAdmin. """ # Globale Rollen (mandateId = None) roles = db.getRoles(mandateId=None, featureCode=featureCode) roleIds = [r.id for r in roles] # AccessRules für diese Rollen rules = db.getAccessRulesByRoleIds(roleIds) return RbacExport( scope={"type": "global", "mandateId": None, "featureCode": featureCode}, roles=[r.toExportFormat() for r in roles], accessRules=[r.toExportFormat() for r in rules] ) # ============================================ # MANDATE EXPORT (Mandate-Admin) # ============================================ @router.get("/api/mandates/{mandateId}/rbac/export") @limiter.limit("10/minute") async def exportMandateRbac( mandateId: str, featureCode: Optional[str] = Query(None), currentUser: User = Depends(getCurrentUser), ctx: RequestContext = Depends(getRequestContext) ) -> RbacExport: """ Exportiert RBAC-Regeln eines Mandanten. Nur Mandate-Admin. """ if not hasMandateAdminRole(ctx, mandateId): raise HTTPException(403, "Mandate-Admin role required") mandate = db.getMandate(mandateId) if not mandate: raise HTTPException(404, "Mandate not found") # Mandant-spezifische Rollen roles = db.getRoles(mandateId=mandateId, featureCode=featureCode) roleIds = [r.id for r in roles] rules = db.getAccessRulesByRoleIds(roleIds) audit_logger.logDataAccess( userId=str(currentUser.id), mandateId=mandateId, action="rbac_export", details=f"featureCode={featureCode or 'all'}" ) return RbacExport( scope={ "type": "mandate", "mandateId": mandateId, "mandateName": mandate.name, "featureCode": featureCode }, roles=[r.toExportFormat() for r in roles], accessRules=[r.toExportFormat() for r in rules] ) # ============================================ # FEATURE INSTANCE EXPORT (Feature-Admin) # ============================================ @router.get("/api/features/instances/{instanceId}/rbac/export") @limiter.limit("10/minute") async def exportInstanceRbac( instanceId: str, currentUser: User = Depends(getCurrentUser), ctx: RequestContext = Depends(getRequestContext) ) -> RbacExport: """ Exportiert RBAC-Regeln einer Feature-Instanz. Nur Feature-Admin. """ if not hasAdminRoleForInstance(ctx, instanceId): raise HTTPException(403, "Feature-Admin role required") instance = db.getFeatureInstance(instanceId) if not instance: raise HTTPException(404, "Feature instance not found") # Instanz-spezifische Rollen roles = db.getRoles(featureInstanceId=instanceId) roleIds = [r.id for r in roles] rules = db.getAccessRulesByRoleIds(roleIds) return RbacExport( scope={ "type": "instance", "featureInstanceId": instanceId, "mandateId": instance.mandateId, "featureCode": instance.featureCode, "instanceLabel": instance.label }, roles=[r.toExportFormat() for r in roles], accessRules=[r.toExportFormat() for r in rules] ) ``` ### 8.4 Import-Endpoints ```python class RbacImportMode(str, Enum): MERGE = "merge" # Upsert: Bestehende aktualisieren, fehlende hinzufügen REPLACE = "replace" # Alle bestehenden im Scope löschen, dann importieren ADD_ONLY = "add" # Nur fehlende hinzufügen, bestehende nicht ändern @router.post("/api/mandates/{mandateId}/rbac/import") @limiter.limit("5/minute") async def importMandateRbac( mandateId: str, data: RbacExport, mode: RbacImportMode = Query(RbacImportMode.MERGE), currentUser: User = Depends(getCurrentUser), ctx: RequestContext = Depends(getRequestContext) ) -> Dict: """ Importiert RBAC-Regeln in einen Mandanten. Nur Mandate-Admin. Modi: - merge: Bestehende aktualisieren, fehlende hinzufügen - replace: Alle bestehenden Regeln löschen, dann importieren - add: Nur fehlende hinzufügen """ if not hasMandateAdminRole(ctx, mandateId): raise HTTPException(403, "Mandate-Admin role required") result = {"rolesCreated": 0, "rolesUpdated": 0, "rulesCreated": 0, "rulesUpdated": 0} async with db.transaction() as tx: # Bei REPLACE: Bestehende löschen if mode == RbacImportMode.REPLACE: existingRoles = await tx.getRoles(mandateId=mandateId) for role in existingRoles: if not role.isSystemRole: # System-Rollen nicht löschen await tx.delete(Role, role.id) # Rollen importieren roleIdMapping = {} # exportedLabel -> newRoleId for roleData in data.roles: # Prüfen ob Rolle existiert existing = await tx.getRole( mandateId=mandateId, roleLabel=roleData["roleLabel"], featureCode=roleData.get("featureCode") ) if existing: if mode == RbacImportMode.ADD_ONLY: roleIdMapping[roleData["roleLabel"]] = existing.id continue # Update description (context-Felder sind IMMUTABLE!) await tx.update(Role, existing.id, { "description": roleData.get("description", existing.description) }) roleIdMapping[roleData["roleLabel"]] = existing.id result["rolesUpdated"] += 1 else: # Neue Rolle erstellen newRole = Role( roleLabel=roleData["roleLabel"], description=roleData.get("description", {}), featureCode=roleData.get("featureCode"), mandateId=mandateId, # IMMUTABLE - wird beim Create gesetzt featureInstanceId=None, isSystemRole=False ) await tx.create(newRole) roleIdMapping[roleData["roleLabel"]] = newRole.id result["rolesCreated"] += 1 # AccessRules importieren for ruleData in data.accessRules: roleId = roleIdMapping.get(ruleData["roleLabel"]) if not roleId: continue # Rolle nicht gefunden/importiert # Prüfen ob Regel existiert (roleId + context + item) existing = await tx.getAccessRule( roleId=roleId, context=ruleData["context"], item=ruleData.get("item") ) if existing: if mode == RbacImportMode.ADD_ONLY: continue # Update permissions (context und roleId sind IMMUTABLE!) await tx.update(AccessRule, existing.id, { "view": ruleData.get("view", False), "read": ruleData.get("read"), "create": ruleData.get("create"), "update": ruleData.get("update"), "delete": ruleData.get("delete") }) result["rulesUpdated"] += 1 else: # Neue Regel erstellen newRule = AccessRule( roleId=roleId, # IMMUTABLE context=ruleData["context"], # IMMUTABLE item=ruleData.get("item"), view=ruleData.get("view", False), read=ruleData.get("read"), create=ruleData.get("create"), update=ruleData.get("update"), delete=ruleData.get("delete") ) await tx.create(newRule) result["rulesCreated"] += 1 await tx.commit() # Audit audit_logger.logSecurityEvent( userId=str(currentUser.id), mandateId=mandateId, action="rbac_import", details=f"mode={mode.value}, result={result}" ) return {"status": "success", "mode": mode.value, **result} ``` ### 8.5 Export/Import für Mandantenwechsel **Use Case:** Mandant "Althaus" möchte gleiche RBAC-Struktur wie Mandant "Soha Treuhand". ```python @router.post("/admin/rbac/copy") @limiter.limit("5/minute") async def copyRbacBetweenMandates( sourceId: str = Query(..., description="Source mandate ID"), targetId: str = Query(..., description="Target mandate ID"), featureCode: Optional[str] = Query(None), admin: User = Depends(requireSysAdmin) ) -> Dict: """ Kopiert RBAC-Struktur von einem Mandanten zu einem anderen. Nur SysAdmin (da mandantenübergreifend). """ # 1. Export von Source exportData = await exportMandateRbacInternal(sourceId, featureCode) # 2. Import zu Target (mit MERGE-Modus) result = await importMandateRbacInternal(targetId, exportData, RbacImportMode.MERGE) audit_logger.logSecurityEvent( userId=str(admin.id), mandateId=targetId, action="rbac_copy", details=f"from={sourceId}, featureCode={featureCode or 'all'}" ) return {"status": "success", "source": sourceId, "target": targetId, **result} ``` --- ## 9. Invitation-Flow (Self-Service Onboarding) ### 9.1 Invitation Model ```python class Invitation(BaseModel): """Einladungs-Token für neue User""" id: str = Field(default_factory=lambda: str(uuid.uuid4())) token: str = Field(default_factory=lambda: secrets.token_urlsafe(32)) # Ziel der Einladung mandateId: str # FK → Mandate featureInstanceId: Optional[str] # Optional: Direkt zu Feature-Instanz roleIds: List[str] # Rollen die zugewiesen werden # Einladungs-Details email: Optional[str] # Für wen (optional, für Tracking) createdBy: str # User-ID des Einladenden createdAt: float # Timestamp expiresAt: float # Ablaufzeit # Status usedBy: Optional[str] # User-ID der Person, die eingeladen wurde usedAt: Optional[float] # Wann eingelöst revokedAt: Optional[float] # Wann widerrufen (falls) # Einschränkungen maxUses: int = 1 # Wie oft kann Token verwendet werden currentUses: int = 0 class InvitationCreate(BaseModel): """Request-Model für Einladungserstellung""" email: Optional[str] = None featureInstanceId: Optional[str] = None roleIds: List[str] expiresInHours: int = Field(default=24, ge=1, le=168) # 1h - 7 Tage maxUses: int = Field(default=1, ge=1, le=100) ``` ### 9.2 Einladung erstellen (Mandate-Admin) ```python @router.post("/api/mandates/{mandateId}/invitations") @limiter.limit("30/minute") async def createInvitation( mandateId: str, data: InvitationCreate, currentUser: User = Depends(getCurrentUser), ctx: RequestContext = Depends(getRequestContext) ) -> InvitationResponse: """ Erstellt einen Einladungslink für einen Mandanten. Nur Mandate-Admin. """ if not hasMandateAdminRole(ctx, mandateId): raise HTTPException(403, "Mandate-Admin role required") # Rollen validieren for roleId in data.roleIds: role = db.getRole(roleId) if not role: raise HTTPException(404, f"Role {roleId} not found") if role.mandateId and role.mandateId != mandateId: raise HTTPException(400, f"Role {roleId} belongs to different mandate") # Feature-Instanz validieren (falls angegeben) if data.featureInstanceId: instance = db.getFeatureInstance(data.featureInstanceId) if not instance or instance.mandateId != mandateId: raise HTTPException(400, "Feature instance not in this mandate") # Einladung erstellen invitation = Invitation( mandateId=mandateId, featureInstanceId=data.featureInstanceId, roleIds=data.roleIds, email=data.email, createdBy=currentUser.id, createdAt=getUtcTimestamp(), expiresAt=getUtcTimestamp() + (data.expiresInHours * 3600), maxUses=data.maxUses ) db.create(invitation) # Einladungs-URL generieren mandate = db.getMandate(mandateId) inviteUrl = f"{APP_CONFIG.BASE_URL}/invite/{invitation.token}" audit_logger.logSecurityEvent( userId=str(currentUser.id), mandateId=mandateId, action="invitation_created", details=f"email={data.email or 'any'}, expires={invitation.expiresAt}" ) return InvitationResponse( id=invitation.id, token=invitation.token, inviteUrl=inviteUrl, mandateName=mandate.name, expiresAt=invitation.expiresAt, maxUses=invitation.maxUses ) ``` ### 9.3 Einladung einlösen ```python @router.get("/api/invitations/{token}") async def getInvitationDetails(token: str) -> InvitationPublicInfo: """ Öffentlicher Endpoint: Zeigt Einladungs-Details (ohne sensible Daten). Für Landing-Page vor Registration/Login. """ invitation = db.getInvitationByToken(token) if not invitation: raise HTTPException(404, "Invitation not found or expired") if invitation.expiresAt < getUtcTimestamp(): raise HTTPException(410, "Invitation has expired") if invitation.revokedAt: raise HTTPException(410, "Invitation has been revoked") if invitation.currentUses >= invitation.maxUses: raise HTTPException(410, "Invitation has been fully used") mandate = db.getMandate(invitation.mandateId) roles = db.getRolesByIds(invitation.roleIds) return InvitationPublicInfo( mandateName=mandate.name if mandate else "Unknown", roles=[r.roleLabel for r in roles], expiresAt=invitation.expiresAt, requiresNewAccount=True # Frontend zeigt Register oder Login ) @router.post("/api/invitations/{token}/accept") @limiter.limit("10/minute") async def acceptInvitation( token: str, currentUser: User = Depends(getCurrentUser) ) -> Dict: """ Einladung annehmen (eingeloggter User). Fügt User zum Mandanten/Feature hinzu. """ invitation = db.getInvitationByToken(token) # Validierungen if not invitation: raise HTTPException(404, "Invitation not found") if invitation.expiresAt < getUtcTimestamp(): raise HTTPException(410, "Invitation has expired") if invitation.revokedAt: raise HTTPException(410, "Invitation has been revoked") if invitation.currentUses >= invitation.maxUses: raise HTTPException(410, "Invitation has been fully used") # Prüfen ob User bereits Mitglied ist existingMembership = db.getUserMandate(currentUser.id, invitation.mandateId) async with db.transaction() as tx: if not existingMembership: # UserMandate erstellen userMandate = UserMandate( userId=currentUser.id, mandateId=invitation.mandateId, enabled=True ) await tx.create(userMandate) # Rollen zuweisen for roleId in invitation.roleIds: umRole = UserMandateRole( userMandateId=userMandate.id, roleId=roleId ) await tx.create(umRole) else: # Nur neue Rollen hinzufügen existingRoleIds = set(db.getRoleIdsForUserMandate(existingMembership.id)) for roleId in invitation.roleIds: if roleId not in existingRoleIds: umRole = UserMandateRole( userMandateId=existingMembership.id, roleId=roleId ) await tx.create(umRole) # Feature-Zugang (falls spezifiziert) if invitation.featureInstanceId: existingAccess = db.getFeatureAccess(currentUser.id, invitation.featureInstanceId) if not existingAccess: featureAccess = FeatureAccess( userId=currentUser.id, featureInstanceId=invitation.featureInstanceId, enabled=True ) await tx.create(featureAccess) # Feature-Rollen zuweisen for roleId in invitation.roleIds: role = db.getRole(roleId) if role and role.featureInstanceId == invitation.featureInstanceId: faRole = FeatureAccessRole( featureAccessId=featureAccess.id, roleId=roleId ) await tx.create(faRole) # Invitation als verwendet markieren await tx.update(Invitation, invitation.id, { "currentUses": invitation.currentUses + 1, "usedBy": currentUser.id, "usedAt": getUtcTimestamp() }) await tx.commit() mandate = db.getMandate(invitation.mandateId) audit_logger.logSecurityEvent( userId=str(currentUser.id), mandateId=invitation.mandateId, action="invitation_accepted", details=f"invitationId={invitation.id}" ) return { "status": "success", "message": f"Successfully joined {mandate.name}", "mandateId": invitation.mandateId } ``` ### 9.4 Einladungen verwalten (Mandate-Admin) ```python @router.get("/api/mandates/{mandateId}/invitations") async def listInvitations( mandateId: str, status: Optional[str] = Query(None, regex="^(active|expired|used|revoked)$"), currentUser: User = Depends(getCurrentUser), ctx: RequestContext = Depends(getRequestContext) ) -> List[InvitationListItem]: """Listet alle Einladungen eines Mandanten.""" if not hasMandateAdminRole(ctx, mandateId): raise HTTPException(403, "Mandate-Admin role required") invitations = db.getInvitations(mandateId=mandateId) # Filter by status now = getUtcTimestamp() if status: filtered = [] for inv in invitations: if status == "active" and inv.expiresAt > now and not inv.revokedAt and inv.currentUses < inv.maxUses: filtered.append(inv) elif status == "expired" and inv.expiresAt <= now: filtered.append(inv) elif status == "used" and inv.currentUses >= inv.maxUses: filtered.append(inv) elif status == "revoked" and inv.revokedAt: filtered.append(inv) invitations = filtered return [inv.toListItem() for inv in invitations] @router.delete("/api/mandates/{mandateId}/invitations/{invitationId}") async def revokeInvitation( mandateId: str, invitationId: str, currentUser: User = Depends(getCurrentUser), ctx: RequestContext = Depends(getRequestContext) ) -> Dict: """Widerruft eine Einladung.""" if not hasMandateAdminRole(ctx, mandateId): raise HTTPException(403, "Mandate-Admin role required") invitation = db.getInvitation(invitationId) if not invitation or invitation.mandateId != mandateId: raise HTTPException(404, "Invitation not found") db.update(Invitation, invitationId, { "revokedAt": getUtcTimestamp() }) audit_logger.logSecurityEvent( userId=str(currentUser.id), mandateId=mandateId, action="invitation_revoked", details=f"invitationId={invitationId}" ) return {"status": "success", "message": "Invitation revoked"} ``` ### 9.5 Registration mit Einladung (Neuer User) ```python @router.post("/api/invitations/{token}/register") @limiter.limit("5/minute") async def registerWithInvitation( token: str, data: UserRegistration ) -> AuthResponse: """ Registriert neuen User UND nimmt Einladung an. Kombinierter Flow für neue User. """ invitation = db.getInvitationByToken(token) # Validierungen (wie bei acceptInvitation) if not invitation or invitation.expiresAt < getUtcTimestamp(): raise HTTPException(410, "Invitation expired or invalid") if invitation.revokedAt or invitation.currentUses >= invitation.maxUses: raise HTTPException(410, "Invitation no longer valid") # Email-Check (falls Einladung für bestimmte Email) if invitation.email and data.email != invitation.email: raise HTTPException(400, f"This invitation is for {invitation.email}") async with db.transaction() as tx: # 1. User erstellen newUser = User( username=data.username, email=data.email, fullName=data.fullName, language=data.language or "en", enabled=True, isSysAdmin=False ) newUser.passwordHash = hashPassword(data.password) await tx.create(newUser) # 2. Zum Mandanten hinzufügen userMandate = UserMandate( userId=newUser.id, mandateId=invitation.mandateId, enabled=True ) await tx.create(userMandate) # Rollen zuweisen for roleId in invitation.roleIds: umRole = UserMandateRole( userMandateId=userMandate.id, roleId=roleId ) await tx.create(umRole) # 3. Feature-Zugang (falls spezifiziert) if invitation.featureInstanceId: featureAccess = FeatureAccess( userId=newUser.id, featureInstanceId=invitation.featureInstanceId, enabled=True ) await tx.create(featureAccess) for roleId in invitation.roleIds: role = await tx.getRole(roleId) if role and role.featureInstanceId == invitation.featureInstanceId: faRole = FeatureAccessRole( featureAccessId=featureAccess.id, roleId=roleId ) await tx.create(faRole) # 4. Invitation aktualisieren await tx.update(Invitation, invitation.id, { "currentUses": invitation.currentUses + 1, "usedBy": newUser.id, "usedAt": getUtcTimestamp() }) await tx.commit() # 5. Token generieren und einloggen tokens = createTokenPair(newUser) audit_logger.logUserAccess( userId=str(newUser.id), mandateId=invitation.mandateId, action="register_via_invitation", successInfo=f"invitationId={invitation.id}" ) return AuthResponse( user=newUser.toPublic(), accessToken=tokens.accessToken, refreshToken=tokens.refreshToken ) ``` --- ## 10. Cascade Delete (Orphan Prevention) ### 8.1 PostgreSQL Foreign Keys ```sql -- ============================================ -- MANDATE CASCADE -- ============================================ -- UserMandate: Löschen wenn Mandate oder User gelöscht ALTER TABLE "UserMandate" ADD CONSTRAINT fk_usermandate_mandate FOREIGN KEY ("mandateId") REFERENCES "Mandate"("id") ON DELETE CASCADE; ALTER TABLE "UserMandate" ADD CONSTRAINT fk_usermandate_user FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE; -- FeatureInstance: Löschen wenn Mandate gelöscht ALTER TABLE "FeatureInstance" ADD CONSTRAINT fk_featureinstance_mandate FOREIGN KEY ("mandateId") REFERENCES "Mandate"("id") ON DELETE CASCADE; -- Role: Löschen wenn Mandate gelöscht ALTER TABLE "Role" ADD CONSTRAINT fk_role_mandate FOREIGN KEY ("mandateId") REFERENCES "Mandate"("id") ON DELETE CASCADE; -- ============================================ -- FEATURE INSTANCE CASCADE -- ============================================ -- FeatureAccess: Löschen wenn FeatureInstance gelöscht ALTER TABLE "FeatureAccess" ADD CONSTRAINT fk_featureaccess_instance FOREIGN KEY ("featureInstanceId") REFERENCES "FeatureInstance"("id") ON DELETE CASCADE; -- Role: Löschen wenn FeatureInstance gelöscht ALTER TABLE "Role" ADD CONSTRAINT fk_role_instance FOREIGN KEY ("featureInstanceId") REFERENCES "FeatureInstance"("id") ON DELETE CASCADE; -- ============================================ -- USER CASCADE -- ============================================ -- FeatureAccess: Löschen wenn User gelöscht ALTER TABLE "FeatureAccess" ADD CONSTRAINT fk_featureaccess_user FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE; -- ============================================ -- ROLE CASCADE (WICHTIG!) -- ============================================ -- AccessRule: Löschen wenn Role gelöscht ALTER TABLE "AccessRule" ADD CONSTRAINT fk_accessrule_role FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE; ``` ### 8.2 Junction Tables (SQL-Definition) **Models:** Siehe Kapitel 2.3 für Pydantic-Models. ```sql -- ============================================ -- JUNCTION TABLES -- ============================================ -- UserMandate zu Role Verknüpfung CREATE TABLE "UserMandateRole" ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), "userMandateId" UUID NOT NULL REFERENCES "UserMandate"("id") ON DELETE CASCADE, "roleId" UUID NOT NULL REFERENCES "Role"("id") ON DELETE CASCADE, UNIQUE("userMandateId", "roleId") ); -- FeatureAccess zu Role Verknüpfung CREATE TABLE "FeatureAccessRole" ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), "featureAccessId" UUID NOT NULL REFERENCES "FeatureAccess"("id") ON DELETE CASCADE, "roleId" UUID NOT NULL REFERENCES "Role"("id") ON DELETE CASCADE, UNIQUE("featureAccessId", "roleId") ); ``` ### 8.3 Cleanup bei leerer Mitgliedschaft (Application-Level) **Design-Entscheidung:** Cleanup wird auf **Application-Level** durchgeführt, nicht via DB-Trigger. **Begründung:** - Bessere Kontrolle über Transaktionsgrenzen - Vermeidung von Race Conditions bei parallelen Löschungen - Explizites Verhalten statt impliziter Trigger-Magie - Einfacheres Debugging und Testing ```python async def removeRoleFromUserMandate(userMandateId: str, roleId: str): """ Entfernt eine Rolle von einem UserMandate. Löscht das UserMandate, wenn keine Rollen mehr vorhanden sind. Verwendet SELECT FOR UPDATE um Race Conditions zu vermeiden. """ async with db.transaction() as tx: # 0. Lock auf UserMandate um Race Conditions zu vermeiden await tx.execute( 'SELECT * FROM "UserMandate" WHERE id = :id FOR UPDATE', {"id": userMandateId} ) # 1. Rolle entfernen await tx.delete(UserMandateRole, {"userMandateId": userMandateId, "roleId": roleId}) # 2. Prüfen ob noch Rollen vorhanden remainingRoles = await tx.getRecordset( UserMandateRole, recordFilter={"userMandateId": userMandateId} ) # 3. Wenn keine Rollen mehr: UserMandate löschen if not remainingRoles: await tx.delete(UserMandate, userMandateId) logger.info(f"Deleted empty UserMandate {userMandateId}") await tx.commit() async def removeRoleFromFeatureAccess(featureAccessId: str, roleId: str): """ Entfernt eine Rolle von einem FeatureAccess. Löscht das FeatureAccess, wenn keine Rollen mehr vorhanden sind. Verwendet SELECT FOR UPDATE um Race Conditions zu vermeiden. """ async with db.transaction() as tx: # 0. Lock auf FeatureAccess um Race Conditions zu vermeiden await tx.execute( 'SELECT * FROM "FeatureAccess" WHERE id = :id FOR UPDATE', {"id": featureAccessId} ) # 1. Rolle entfernen await tx.delete(FeatureAccessRole, {"featureAccessId": featureAccessId, "roleId": roleId}) # 2. Prüfen ob noch Rollen vorhanden remainingRoles = await tx.getRecordset( FeatureAccessRole, recordFilter={"featureAccessId": featureAccessId} ) # 3. Wenn keine Rollen mehr: FeatureAccess löschen if not remainingRoles: await tx.delete(FeatureAccess, featureAccessId) logger.info(f"Deleted empty FeatureAccess {featureAccessId}") await tx.commit() ``` **Wichtig:** Alle Rollen-Löschungen müssen über diese Funktionen erfolgen, nicht direkt auf der Junction Table. ### 8.4 Lösch-Kaskade Übersicht ``` Mandate löschen: ├── UserMandate → gelöscht (FK CASCADE) │ └── UserMandateRole → gelöscht (FK CASCADE) ├── FeatureInstance → gelöscht (FK CASCADE) │ ├── FeatureAccess → gelöscht (FK CASCADE) │ │ └── FeatureAccessRole → gelöscht (FK CASCADE) │ └── Role (instanz-spezifisch) → gelöscht (FK CASCADE) │ └── AccessRule → gelöscht (FK CASCADE) ├── Role (mandant-spezifisch) → gelöscht (FK CASCADE) │ └── AccessRule → gelöscht (FK CASCADE) └── Feature-Daten (ChatWorkflow, etc.) → gelöscht (FK CASCADE) Role löschen: ├── AccessRule (alle für diese Rolle) → gelöscht (FK CASCADE) ├── UserMandateRole (Verknüpfungen) → gelöscht (FK CASCADE) │ └── Application: Prüft ob UserMandate leer → löscht wenn ja (siehe 8.3) └── FeatureAccessRole (Verknüpfungen) → gelöscht (FK CASCADE) └── Application: Prüft ob FeatureAccess leer → löscht wenn ja (siehe 8.3) User löschen: ├── UserMandate → gelöscht (FK CASCADE) │ └── UserMandateRole → gelöscht (FK CASCADE) └── FeatureAccess → gelöscht (FK CASCADE) └── FeatureAccessRole → gelöscht (FK CASCADE) ``` --- ## 11. Transaktionen (Konsistenz-Garantie) ### 9.1 Multi-Step Operations in Transaktionen ```python async def createFeatureInstanceWithRoles( featureCode: str, mandateId: str, label: str ) -> FeatureInstance: """ Erstellt FeatureInstance mit kopierten Rollen und Rules. Alles in einer Transaktion für Konsistenz. """ async with db.transaction() as tx: try: # 1. FeatureInstance erstellen instance = FeatureInstance( featureCode=featureCode, mandateId=mandateId, label=label ) await tx.create(instance) # 2. Globale Template-Rollen finden globalRoles = await tx.getRoles( featureCode=featureCode, mandateId=None ) for templateRole in globalRoles: # 3. Rolle für diese Instanz kopieren newRole = Role( roleLabel=templateRole.roleLabel, description=templateRole.description, featureCode=templateRole.featureCode, mandateId=mandateId, featureInstanceId=instance.id ) await tx.create(newRole) # 4. AccessRules der Template-Rolle kopieren templateRules = await tx.getAccessRules(roleId=templateRole.id) for rule in templateRules: newRule = AccessRule( roleId=newRole.id, context=rule.context, item=rule.item, view=rule.view, read=rule.read, create=rule.create, update=rule.update, delete=rule.delete ) await tx.create(newRule) # Commit nur wenn alles erfolgreich await tx.commit() # Cache-Version inkrementieren await onRbacChange(mandateId, instance.id) return instance except Exception as e: await tx.rollback() logger.error(f"Failed to create feature instance: {e}") raise ``` --- ## 12. API-Kontext ### 10.1 Multi-Mandant Token-Design **Design-Entscheidung:** Ein JWT-Token ist **nicht** an einen spezifischen Mandanten gebunden. **Warum:** - User arbeitet im UI **parallel in mehreren Mandanten** (z.B. mehrere Browser-Tabs) - Mandant-Wechsel erfordert keinen neuen Login - Token enthält nur User-Identität, nicht Mandant-Kontext **Sicherheit:** - Der **Request-Header** (`X-Mandate-Id`) bestimmt den aktiven Kontext - Bei **jedem Request** wird geprüft, ob der User Mitglied des Mandanten ist - User kann nur auf Mandanten zugreifen, in denen er **explizit** Mitglied ist - **Kein Sicherheitsrisiko:** Token-Kompromittierung erlaubt nur Zugriff auf Mandanten, in denen der User bereits berechtigt ist ``` JWT Token enthält: ├── userId: "abc-123" ├── sessionId: "sess-456" ├── authority: "local" └── exp: 1737123456 Request Headers bestimmen Kontext: ├── X-Mandate-Id: "mandate-789" └── X-Instance-Id: "instance-012" (optional) ``` ### 10.2 Request-Kontext ermitteln ```python def getRequestContext( mandateId: Optional[str] = Header(None, alias="X-Mandate-Id"), featureInstanceId: Optional[str] = Header(None, alias="X-Instance-Id"), currentUser: User = Depends(getCurrentUser) ) -> RequestContext: """ Ermittelt Kontext aus Headers. Prüft Berechtigung und lädt Role-IDs. WICHTIG: Auch SysAdmin braucht explizite Membership für Mandant-Kontext! SysAdmin-Flag gibt keinen impliziten Zugriff auf Mandant-Daten. """ ctx = RequestContext(user=currentUser) if mandateId: # Prüfe Mandant-Mitgliedschaft - AUCH für SysAdmin! # SysAdmin muss explizit zum Mandanten hinzugefügt werden membership = db.getUserMandate(currentUser.id, mandateId) if not membership: # Kein impliziter Zugriff für SysAdmin - Fail-Fast! raise HTTPException(403, "Not member of mandate") if not membership.enabled: raise HTTPException(403, "Mandate membership is disabled") ctx.mandateId = mandateId # Rollen via Junction Table laden ctx.roleIds = db.getRoleIdsForUserMandate(membership.id) if featureInstanceId: # Prüfe Feature-Zugang - AUCH für SysAdmin! access = db.getFeatureAccess(currentUser.id, featureInstanceId) if not access: raise HTTPException(403, "No access to feature instance") if not access.enabled: raise HTTPException(403, "Feature access is disabled") ctx.featureInstanceId = featureInstanceId # Instanz-Rollen hinzufügen ctx.roleIds.extend(db.getRoleIdsForFeatureAccess(access.id)) return ctx ``` ### 10.3 RBAC-Prüfung (Stateless + SysAdmin-Blockade) ```python def checkPermission( ctx: RequestContext, context: str, # "DATA", "UI", "RESOURCE" item: str, # z.B. "TrusteeContract" action: str # "view", "read", "create", "update", "delete" ) -> AccessLevel: """Prüft Berechtigung basierend auf Kontext und Role-IDs.""" # System-Level (kein Mandant): SysAdmin hat vollen Zugriff if ctx.user.isSysAdmin and not ctx.mandateId: return AccessLevel.ALL # KRITISCH: SysAdmin-Flag gibt KEINEN Zugriff auf Mandant-Daten! # SysAdmin muss reguläre Rollen im Mandanten haben if ctx.mandateId and not ctx.roleIds: # User (auch SysAdmin) hat keine Rollen im Mandanten → kein Zugriff return AccessLevel.NONE # Regeln für alle Role-IDs des Users laden (mit Caching) rules = getRulesForUserBulk( ctx.user.id, ctx.mandateId, ctx.featureInstanceId ) # Beste Permission über alle Regeln bestLevel = AccessLevel.NONE for priority, rule in rules: if not matchesItem(rule.item, item): continue if rule.context != context: continue level = getattr(rule, action, None) if level and isMorePermissive(level, bestLevel): bestLevel = level return bestLevel ``` ### 10.4 IMMUTABLE-Enforcement bei Updates ```python def validateUpdateRequest(model: str, recordId: str, updateData: dict): """ Blockiert Updates auf immutable Felder. Wirft PermissionError wenn versucht wird, context-Felder zu ändern. """ IMMUTABLE = { "Role": ["mandateId", "featureInstanceId", "featureCode"], "AccessRule": ["context", "roleId"] } forbidden = IMMUTABLE.get(model, []) violations = [f for f in forbidden if f in updateData] if violations: raise PermissionError( f"Cannot update immutable fields on {model}: {violations}. " f"Delete and recreate instead." ) ``` ### 10.5 AccessRule Scope-Validierung bei Erstellung **Sicherheitsregel:** Ein User darf nur AccessRules für Rollen erstellen, die in seinem Scope liegen. ```python def validateAccessRuleCreation(ctx: RequestContext, rule: AccessRule): """ Validiert, dass der User berechtigt ist, eine AccessRule für diese Rolle zu erstellen. Regeln: - SysAdmin kann Regeln für globale Rollen (mandateId=None) erstellen - Mandate-Admin kann Regeln für Rollen seines Mandanten erstellen - Niemand kann Regeln für Rollen anderer Mandanten erstellen """ role = db.getRole(rule.roleId) if not role: raise ValueError(f"Role {rule.roleId} not found") # Globale Rollen: nur SysAdmin if role.mandateId is None: if not ctx.user.isSysAdmin: raise PermissionError( "Only SysAdmin can create AccessRules for global (template) roles" ) return # OK # Mandant-Rollen: nur wenn User im gleichen Mandant if role.mandateId != ctx.mandateId: raise PermissionError( f"Cannot create AccessRules for roles of other mandates. " f"Role belongs to mandate {role.mandateId}, your context is {ctx.mandateId}" ) # Instanz-Rollen: zusätzlich prüfen ob User Admin der Instanz ist if role.featureInstanceId: if not hasAdminRoleForInstance(ctx, role.featureInstanceId): raise PermissionError( f"Admin role required to create AccessRules for instance-specific roles" ) # Mandate-Rollen ohne Instanz: Mandate-Admin Rolle erforderlich else: if not hasMandateAdminRole(ctx): raise PermissionError( "Mandate-Admin role required to create AccessRules for mandate-level roles" ) # Anwendung im Endpoint: @router.post("/api/rbac/rules") async def createAccessRule( rule: AccessRule, currentUser: User = Depends(getCurrentUser), ctx: RequestContext = Depends(getRequestContext) ): # 1. FK-Validierung: Role muss existieren role = db.getRole(rule.roleId) if not role: raise HTTPException(404, f"Role {rule.roleId} not found") # 2. Scope-Validierung (Role-Kontext prüfen) validateAccessRuleCreation(ctx, rule) # IMMUTABLE-Validierung ist hier nicht nötig (nur bei UPDATE) # 3. Erstellen createdRule = db.create(AccessRule, rule) audit_logger.logSecurityEvent( userId=str(currentUser.id), mandateId=ctx.mandateId or "global", action="accessrule_created", details=f"roleId={rule.roleId}, context={rule.context}, item={rule.item}" ) return createdRule ``` --- ## 13. DSGVO-Compliance Endpoints > **Hinweis:** Dieses Konzept behandelt nur die **technische Code-Implementierung** der DSGVO-Endpoints. Organisatorische Aspekte (z.B. Daten-Anonymisierung bei Export von Daten die andere User betreffen) sind **Verantwortung des Users/Mandanten** und werden hier nicht behandelt. ### 11.1 Recht auf Auskunft (Art. 15 DSGVO) **Anforderung:** Betroffene Person kann Auskunft über alle gespeicherten Daten verlangen. ```python @router.get("/api/user/me/data-export") @limiter.limit("5/hour") # Streng limitiert (aufwändige Operation) async def exportUserData( format: str = Query("json", regex="^(json|csv)$"), currentUser: User = Depends(getCurrentUser) ) -> UserDataExport: """ Exportiert ALLE Daten des aktuellen Users (DSGVO Art. 15). Enthält: - Persönliche Daten (User-Objekt) - Alle Mandant-Mitgliedschaften - Alle Feature-Zugänge - Alle vom User erstellten Daten (pro Mandant) - Audit-Log Einträge (eigene Aktionen) Returns: UserDataExport mit allen Daten in strukturiertem Format """ export = UserDataExport( exportedAt=getUtcTimestamp(), userId=currentUser.id, format=format ) # 1. Persönliche Daten export.personalData = { "id": currentUser.id, "username": currentUser.username, "email": currentUser.email, "fullName": currentUser.fullName, "language": currentUser.language, "createdAt": currentUser.createdAt, "lastLogin": currentUser.lastLogin } # 2. Mandant-Mitgliedschaften memberships = db.getUserMandates(currentUser.id) export.mandateMemberships = [] for m in memberships: mandate = db.getMandate(m.mandateId) roles = db.getRolesForUserMandate(m.id) export.mandateMemberships.append({ "mandateId": m.mandateId, "mandateName": mandate.name if mandate else "Unknown", "roles": [r.roleLabel for r in roles], "joinedAt": m.createdAt, "enabled": m.enabled }) # 3. Feature-Zugänge accesses = db.getFeatureAccessesForUser(currentUser.id) export.featureAccesses = [] for a in accesses: instance = db.getFeatureInstance(a.featureInstanceId) roles = db.getRolesForFeatureAccess(a.id) export.featureAccesses.append({ "featureInstanceId": a.featureInstanceId, "featureCode": instance.featureCode if instance else "Unknown", "instanceLabel": instance.label if instance else "Unknown", "roles": [r.roleLabel for r in roles], "grantedAt": a.createdAt }) # 4. Vom User erstellte Daten (pro Mandant, _createdBy = userId) export.createdData = {} for m in memberships: mandateData = {} # Alle Tabellen mit _createdBy durchsuchen for table in DATA_TABLES_WITH_CREATOR: records = db.getRecordset( table, recordFilter={"_createdBy": currentUser.id, "mandateId": m.mandateId} ) if records: # Nur Metadaten, keine sensiblen Inhalte anderer User mandateData[table.__name__] = [ {"id": r["id"], "createdAt": r.get("_createdAt"), "type": table.__name__} for r in records ] if mandateData: export.createdData[m.mandateId] = mandateData # 5. Audit-Log (eigene Aktionen der letzten 12 Monate) export.auditLog = db.getAuditEntriesForUser( currentUser.id, since=getUtcTimestamp() - (365 * 24 * 3600) ) # Audit: Export wurde angefordert audit_logger.logDataAccess( userId=str(currentUser.id), mandateId="all", action="gdpr_data_export", details=f"Format: {format}" ) return export class UserDataExport(BaseModel): """DSGVO Art. 15 - Vollständiger Datenexport""" exportedAt: float userId: str format: str personalData: Dict[str, Any] mandateMemberships: List[Dict[str, Any]] featureAccesses: List[Dict[str, Any]] createdData: Dict[str, Dict[str, List[Dict]]] # mandateId → table → records auditLog: List[Dict[str, Any]] ``` ### 11.2 Recht auf Datenübertragbarkeit (Art. 20 DSGVO) **Anforderung:** Daten in strukturiertem, maschinenlesbarem Format bereitstellen. ```python @router.get("/api/user/me/data-portability") @limiter.limit("3/hour") async def exportPortableData( mandateId: Optional[str] = Query(None, description="Optional: nur für einen Mandanten"), currentUser: User = Depends(getCurrentUser) ) -> Response: """ Exportiert User-Daten in portablem Format (DSGVO Art. 20). Format: JSON mit standardisierten Feldnamen Kann direkt in andere Systeme importiert werden. Returns: JSON-Datei zum Download """ portableData = { "schema_version": "1.0", "exported_at": datetime.utcnow().isoformat(), "source_system": "PowerOn Platform", "user": { "identifier": currentUser.username, "email": currentUser.email, "display_name": currentUser.fullName, "preferred_language": currentUser.language }, "data": {} } # Mandanten filtern memberships = db.getUserMandates(currentUser.id) if mandateId: memberships = [m for m in memberships if m.mandateId == mandateId] for m in memberships: mandate = db.getMandate(m.mandateId) mandateExport = { "organization_name": mandate.name if mandate else "Unknown", "membership_since": m.createdAt, "content": {} } # Feature-spezifische Daten exportieren accesses = db.getFeatureAccessesForUser(currentUser.id) for access in accesses: instance = db.getFeatureInstance(access.featureInstanceId) if not instance or instance.mandateId != m.mandateId: continue featureData = _exportFeatureData( featureCode=instance.featureCode, instanceId=instance.id, userId=currentUser.id ) if featureData: mandateExport["content"][instance.featureCode] = featureData portableData["data"][m.mandateId] = mandateExport # Audit audit_logger.logDataAccess( userId=str(currentUser.id), mandateId=mandateId or "all", action="gdpr_data_portability", details=f"Exported portable data" ) # Als Download-Datei zurückgeben filename = f"poweron_export_{currentUser.username}_{datetime.utcnow().strftime('%Y%m%d')}.json" return Response( content=json.dumps(portableData, indent=2, ensure_ascii=False), media_type="application/json", headers={ "Content-Disposition": f'attachment; filename="{filename}"' } ) def _exportFeatureData(featureCode: str, instanceId: str, userId: str) -> Dict: """ Exportiert Feature-spezifische Daten im portablen Format. Jedes Feature definiert sein Export-Schema. """ exporters = { "trustee": _exportTrusteeData, "chatbot": _exportChatbotData, "workflow": _exportWorkflowData, # Weitere Features... } exporter = exporters.get(featureCode) if exporter: return exporter(instanceId, userId) return {} def _exportTrusteeData(instanceId: str, userId: str) -> Dict: """Exportiert Trustee-Daten im portablen Format.""" contracts = db.getRecordset( TrusteeContract, recordFilter={"featureInstanceId": instanceId, "_createdBy": userId} ) return { "contracts": [ { "reference": c.get("reference"), "client_name": c.get("clientName"), "created_at": c.get("_createdAt"), "status": c.get("status") # Keine internen IDs, nur portable Daten } for c in contracts ] } ``` ### 11.3 Recht auf Löschung (Art. 17 DSGVO) **Bereits implementiert via CASCADE DELETE**, aber expliziter Endpoint für User-initiated Löschung: ```python @router.delete("/api/user/me") @limiter.limit("1/day") async def deleteOwnAccount( confirmation: str = Body(..., description="Must be 'DELETE MY ACCOUNT'"), currentUser: User = Depends(getCurrentUser) ) -> Dict: """ User löscht eigenen Account (DSGVO Art. 17). ACHTUNG: Unwiderruflich! Löscht: - User-Datensatz - Alle Mandant-Mitgliedschaften (CASCADE) - Alle Feature-Zugänge (CASCADE) - Alle vom User erstellten Daten werden anonymisiert (_createdBy → 'deleted') """ if confirmation != "DELETE MY ACCOUNT": raise HTTPException(400, "Confirmation text must be exactly 'DELETE MY ACCOUNT'") # SysAdmin kann sich nicht selbst löschen if currentUser.isSysAdmin: raise HTTPException(400, "SysAdmin accounts cannot be self-deleted. Contact another SysAdmin.") # Vor dem Löschen: Datenexport anbieten # (Frontend sollte dies vorher anzeigen) # 1. Anonymisiere erstellte Daten (statt löschen - für Audit-Trail) for table in DATA_TABLES_WITH_CREATOR: db.execute( f'UPDATE "{table.__name__}" SET "_createdBy" = \'deleted-user\' ' f'WHERE "_createdBy" = :userId', {"userId": currentUser.id} ) # 2. Audit BEVOR User gelöscht wird audit_logger.logSecurityEvent( userId=str(currentUser.id), mandateId="all", action="gdpr_account_deletion", details=f"User {currentUser.username} deleted own account" ) # 3. User löschen (CASCADE löscht Memberships, Accesses, Tokens, etc.) db.delete(User, currentUser.id) return {"message": "Account successfully deleted", "deletedAt": getUtcTimestamp()} ``` ### 11.4 Übersicht DSGVO-Endpoints | Endpoint | DSGVO | Beschreibung | Rate Limit | |----------|-------|--------------|------------| | `GET /api/user/me/data-export` | Art. 15 | Vollständiger Datenexport (JSON/CSV) | 5/hour | | `GET /api/user/me/data-portability` | Art. 20 | Portables Format für Übertragung | 3/hour | | `DELETE /api/user/me` | Art. 17 | Account-Löschung | 1/day | | `PUT /api/user/me` | Art. 16 | Datenberichtigung (bereits vorhanden) | 30/min | --- ## 14. Zusammenfassung ### 14.1 Key Design Decisions | Entscheidung | Lösung | |--------------|--------| | User-Mandant-Beziehung | m:n via UserMandate + Junction Table UserMandateRole | | Feature-Zugriff | m:n via FeatureAccess + Junction Table FeatureAccessRole | | Rollen-Verknüpfung | Via Junction Tables (keine Array-Felder) für CASCADE DELETE | | Role-Kontext | **IMMUTABLE** (`mandateId`, `featureInstanceId`, `featureCode`) | | AccessRule-Kontext | **IMMUTABLE** (`context`, `roleId`) + **Scope-Validierung** | | Admin-Funktionen | `isSysAdmin` Flag = System-Zugriff, **KEIN** Daten-Zugriff, **KEINE** Self-Service-Eskalation | | System-Funktionen | Endpoints ohne Mandant-Kontext | | RBAC-Scope | Via Role: Global → Mandant → Instanz | | Item-Notation | Dot-separated hierarchisch | | Orphan Prevention | PostgreSQL CASCADE DELETE + **Application-Level Cleanup** (kein Trigger) | | RBAC-Design | **Stateless** - kein Cache, direkt aus DB (Cloud-Ready) | | Token-Design | User-gebunden, **nicht** Mandant-gebunden (parallele Mandant-Arbeit) | | Template-Sync | Explizite Synchronisation von Rollen via Admin-Funktion (nur für neue Instanzen) | | Initial-Rollen | System-Rollen werden im **Bootstrap-Modul** (`interfaceBootstrap.py`) erstellt | | Deployment | **Greenfield** - keine Migration, keine Backwards Compatibility | | DB-Skalierung | Infrastruktur-Aufgabe (Read Replicas, Pooling), nicht Code-Aufgabe | | Read Replica | Kritische RBAC-Reads immer vom **Primary** (Replication Lag) | | **RBAC Export/Import** | **Global** (SysAdmin), **Mandant** (Mandate-Admin), **Instanz** (Feature-Admin) | | **Invitation-Flow** | Token-basiert, via Mandate-Admin, Self-Service Registration | ### 14.2 Bestehende Security-Features (Gateway) | Feature | Datei | Status | |---------|-------|--------| | Password Hashing (Argon2) | `interfaceDbAppObjects.py` | ✅ Vorhanden | | JWT Token mit Refresh | `jwtService.py` | ✅ Vorhanden | | Token Revocation | `interfaceDbAppObjects.py` | ✅ Vorhanden | | httpOnly Cookies | `jwtService.py` | ✅ Vorhanden | | Rate Limiting | `routeSecurityLocal.py` | ✅ Vorhanden | | CSRF Protection | `app.py` | ✅ Vorhanden | | Audit Logging | `auditLogger.py` | ✅ Vorhanden | ### 14.3 Vollständige Dateiliste (Code-Scan) **Legende:** 🔴 Breaking Change | 🟡 Anpassung | 🟢 NEU #### 14.3.1 Datamodels (10 Dateien) | Datei | Typ | Änderung | |-------|-----|----------| | `datamodelUam.py` | 🔴 | **Entferne** `User.mandateId`, `User.roleLabels`; **Add** `User.isSysAdmin` | | `datamodelRbac.py` | 🔴 | `Role` erweitern: `mandateId`, `featureInstanceId`, `featureCode` | | `datamodelChat.py` | 🟡 | `mandateId` bleibt, aber Zugriff via Context | | `datamodelTrustee.py` | 🟡 | `mandateId` bleibt, aber Zugriff via Context | | `datamodelRealEstate.py` | 🟡 | `mandateId` bleibt, aber Zugriff via Context | | `datamodelVoice.py` | 🟡 | `mandateId` bleibt, aber Zugriff via Context | | `datamodelFiles.py` | 🟡 | `mandateId` bleibt, aber Zugriff via Context | | `datamodelMessaging.py` | 🟡 | `mandateId` bleibt, aber Zugriff via Context | | `datamodelNeutralizer.py` | 🟡 | `mandateId` bleibt, aber Zugriff via Context | | `datamodelSecurity.py` | 🟡 | Token ohne `mandateId` | | `datamodelFeatures.py` | 🟢 | NEU: `Feature`, `FeatureInstance`, `FeatureAccess` | | `datamodelMembership.py` | 🟢 | NEU: `UserMandate`, `UserMandateRole`, `FeatureAccessRole` | | `datamodelInvitation.py` | 🟢 | NEU: `Invitation` | #### 14.3.2 Interfaces (8 Dateien) | Datei | Typ | Änderung | |-------|-----|----------| | `interfaceDbAppObjects.py` | 🔴 | `setUserContext()` ohne `mandateId`, neue Membership-Methoden | | `interfaceDbTrusteeObjects.py` | 🔴 | `currentUser.mandateId` → Request-Context | | `interfaceDbChatObjects.py` | 🔴 | `currentUser.mandateId` → Request-Context | | `interfaceDbRealEstateObjects.py` | 🔴 | `currentUser.mandateId` → Request-Context | | `interfaceDbComponentObjects.py` | 🔴 | `currentUser.mandateId` → Request-Context | | `interfaceVoiceObjects.py` | 🔴 | `currentUser.mandateId` → Request-Context | | `interfaceRbac.py` | 🔴 | `buildRbacWhereClause()` mit explizitem `mandateId` Parameter | | `interfaceBootstrap.py` | 🔴 | Initiale Rollen ohne `roleLabels` am User | | `interfaceFeatures.py` | 🟢 | NEU: Template-Sync, Application-Level Cleanup | #### 14.3.3 Routes (18 Dateien) | Datei | Typ | Änderung | |-------|-----|----------| | `routeSecurityLocal.py` | 🔴 | Login ohne `mandateId` im Token, `roleLabels` aus Memberships | | `routeSecurityMsft.py` | 🔴 | OAuth ohne `mandateId` im Token | | `routeSecurityGoogle.py` | 🔴 | OAuth ohne `mandateId` im Token | | `routeSecurityAdmin.py` | 🔴 | SysAdmin-Check via `isSysAdmin`, `roleLabels` → `roleIds` | | `routeDataUsers.py` | 🔴 | Filter via Membership statt `mandateId` | | `routeDataMandates.py` | 🟡 | Mandate-Admin Berechtigungen via Membership | | `routeFeatureTrustee.py` | 🔴 | Context aus Header (← routeDataTrustee.py) | | `routeRbac.py` | 🔴 | Scope-Validierung, `roleLabels` → `roleIds` | | `routeAdminRbacRoles.py` | 🔴 | Rollen mit Kontext-Validierung | | `routeFeatureAutomation.py` | 🟡 | Context-Handling (← routeAdminAutomationEvents.py) | | `routeFeatureChatbot.py` | 🔴 | Context aus Header (← routeChatbot.py) | | `routeFeatureChatDynamic.py` | 🔴 | Context aus Header (← routeChatPlayground.py) | | `routeFeatureRealEstate.py` | 🔴 | Context aus Header (← routeRealEstate.py) | | `routeFeatureNeutralization.py` | 🔴 | Context aus Header (← routeDataNeutralization.py) | | `routeFeatures.py` | 🟢 | NEU: Feature-Instanz-Management | | `routeGdpr.py` | 🟢 | NEU: DSGVO-Endpoints | | `routeRbacExport.py` | 🟢 | NEU: RBAC Export/Import | | `routeInvitations.py` | 🟢 | NEU: Invitation-Flow | | `routeAdmin.py` | 🟢 | NEU: SysAdmin-Endpoints | #### 14.3.4 Security & Auth (3 Dateien) | Datei | Typ | Änderung | |-------|-----|----------| | `security/rbac.py` | 🔴 | Bulk Query, IMMUTABLE-Enforcement, SysAdmin-Blockade | | `auth/authentication.py` | 🔴 | Token ohne `mandateId`, Context aus Headers | | `auth/tokenRefreshService.py` | 🟡 | Refresh ohne `mandateId` | #### 14.3.5 Features (4 Dateien) | Datei | Typ | Änderung | |-------|-----|----------| | `features/chatbot/mainChatbot.py` | 🔴 | `currentUser.mandateId` → Context | | `features/realEstate/mainRealEstate.py` | 🔴 | `currentUser.mandateId` → Context | | `features/dynamicOptions/mainDynamicOptions.py` | 🔴 | `currentUser.mandateId` → Context | | `features/neutralizePlayground/mainNeutralizePlayground.py` | 🔴 | `currentUser.mandateId` → Context | #### 14.3.6 Workflows & Services (3 Dateien) | Datei | Typ | Änderung | |-------|-----|----------| | `workflows/workflowManager.py` | 🟡 | `mandateId` aus Context statt User | | `workflows/methods/methodBase.py` | 🔴 | `roleLabels` → `roleIds` | | `services/serviceNeutralization/mainServiceNeutralization.py` | 🟡 | Context-Handling | #### 14.3.7 Shared & Connectors (3 Dateien) | Datei | Typ | Änderung | |-------|-----|----------| | `shared/auditLogger.py` | 🟡 | `mandateId` aus Context | | `shared/configuration.py` | 🟡 | Evtl. neue Config-Keys | | `connectors/connectorDbPostgre.py` | 🔴 | Junction Tables, IMMUTABLE Triggers | **Zusammenfassung:** - 🔴 **Breaking Changes:** 28 Dateien - 🟡 **Anpassungen:** 12 Dateien - 🟢 **Neue Dateien:** 9 Dateien - **Total:** ~49 Dateien betroffen --- ### 14.4 Phasenweise Implementierung (AI-optimiert) **Prinzip:** Jede Phase ist **in sich abgeschlossen testbar** und kann separat implementiert werden. --- #### PHASE 1: Foundation (Datenmodelle & DB) **Geschätzte Komplexität:** Mittel | **Dateien:** ~8 **Ziel:** Neue Datenstrukturen ohne Breaking Changes am bestehenden Code. | Schritt | Datei | Aktion | |---------|-------|--------| | 1.1 | `datamodelFeatures.py` | 🟢 NEU: `Feature`, `FeatureInstance` | | 1.2 | `datamodelMembership.py` | 🟢 NEU: `UserMandate`, `UserMandateRole`, `FeatureAccessRole` | | 1.3 | `datamodelInvitation.py` | 🟢 NEU: `Invitation` | | 1.4 | `datamodelRbac.py` | 🟡 ADD: `Role.mandateId`, `Role.featureInstanceId`, `Role.featureCode` | | 1.5 | `datamodelUam.py` | 🟡 ADD: `User.isSysAdmin` (alte Felder NOCH NICHT entfernen!) | | 1.6 | `connectorDbPostgre.py` | 🟡 ADD: Neue Tabellen, Foreign Keys, IMMUTABLE Triggers | | 1.7 | `interfaceBootstrap.py` | 🟡 ADD: Initiale Template-Rollen erstellen | **Test:** Neue Tabellen existieren, bestehender Code funktioniert noch. --- #### PHASE 2: RBAC Core (Kern-Logik) **Geschätzte Komplexität:** Hoch | **Dateien:** ~5 **Ziel:** Neues RBAC-System parallel zum alten lauffähig. | Schritt | Datei | Aktion | |---------|-------|--------| | 2.1 | `security/rbac.py` | 🔴 Neue `getRulesForUserBulk()` mit Junction Tables | | 2.2 | `interfaceRbac.py` | 🔴 `buildRbacWhereClause()` mit explizitem `mandateId` | | 2.3 | `interfaceDbAppObjects.py` | 🟡 ADD: Neue Membership-Methoden (parallel zu alten) | | 2.4 | `interfaceFeatures.py` | 🟢 NEU: Feature-Instanz-Management | **Test:** Neue RBAC-Methoden funktionieren, alte noch vorhanden. --- #### PHASE 3: Auth & Context (Authentifizierung) **Geschätzte Komplexität:** Hoch | **Dateien:** ~6 **Ziel:** Request-Context-System einführen. | Schritt | Datei | Aktion | |---------|-------|--------| | 3.1 | `auth/authentication.py` | 🔴 `getRequestContext()` mit Headers | | 3.2 | `routeSecurityLocal.py` | 🔴 Login mit neuem Token (ohne `mandateId`) | | 3.3 | `routeSecurityMsft.py` | 🔴 OAuth anpassen | | 3.4 | `routeSecurityGoogle.py` | 🔴 OAuth anpassen | | 3.5 | `datamodelSecurity.py` | 🟡 Token ohne `mandateId` | | 3.6 | `auth/tokenRefreshService.py` | 🟡 Refresh anpassen | **Test:** Login funktioniert mit neuem Token-Format. --- #### PHASE 4: Routes Migration (Schrittweise) **Geschätzte Komplexität:** Mittel-Hoch | **Dateien:** ~18 **Ziel:** Alle Routes auf neues Context-System migrieren. **4.A - Admin Routes:** | Schritt | Datei | |---------|-------| | 4.A.1 | `routeSecurityAdmin.py` | | 4.A.2 | `routeAdminRbacRoles.py` | | 4.A.3 | `routeDataUsers.py` | | 4.A.4 | `routeDataMandates.py` | | 4.A.5 | `routeRbac.py` | **4.B - Feature Routes:** | Schritt | Datei | |---------|-------| | 4.B.1 | `routeFeatureTrustee.py` (← routeDataTrustee.py) | | 4.B.2 | `routeFeatureChatbot.py` (← routeChatbot.py) | | 4.B.3 | `routeFeatureRealEstate.py` (← routeRealEstate.py) | | 4.B.4 | `routeFeatureNeutralization.py` (← routeDataNeutralization.py) | | 4.B.5 | `routeFeatureAutomation.py` (← routeAdminAutomationEvents.py) | | 4.B.6 | `routeFeatureChatDynamic.py` (← routeChatPlayground.py) | **Test:** Jede Route einzeln testbar. --- #### PHASE 5: Interfaces Migration **Geschätzte Komplexität:** Mittel | **Dateien:** ~6 **Ziel:** Alle Interfaces auf Context-System migrieren. | Schritt | Datei | |---------|-------| | 5.1 | `interfaceDbTrusteeObjects.py` | | 5.2 | `interfaceDbChatObjects.py` | | 5.3 | `interfaceDbRealEstateObjects.py` | | 5.4 | `interfaceDbComponentObjects.py` | | 5.5 | `interfaceVoiceObjects.py` | **Test:** Interfaces funktionieren mit neuem Context. --- #### PHASE 6: Features Migration **Geschätzte Komplexität:** Mittel | **Dateien:** ~7 **Ziel:** Feature-Module auf Context-System migrieren. | Schritt | Datei | |---------|-------| | 6.1 | `features/chatbot/mainChatbot.py` | | 6.2 | `features/realEstate/mainRealEstate.py` | | 6.3 | `features/dynamicOptions/mainDynamicOptions.py` | | 6.4 | `features/neutralizePlayground/mainNeutralizePlayground.py` | | 6.5 | `workflows/workflowManager.py` | | 6.6 | `workflows/methods/methodBase.py` | | 6.7 | `services/serviceNeutralization/mainServiceNeutralization.py` | **Test:** Features funktionieren mit neuem Context. --- #### PHASE 7: Cleanup & Breaking Changes **Geschätzte Komplexität:** Niedrig | **Dateien:** ~3 **Ziel:** Alte Felder entfernen (erst wenn alles migriert ist!) | Schritt | Datei | Aktion | |---------|-------|--------| | 7.1 | `datamodelUam.py` | 🔴 **ENTFERNE** `User.mandateId`, `User.roleLabels` | | 7.2 | `interfaceDbAppObjects.py` | 🔴 Alte Methoden entfernen | | 7.3 | `interfaceBootstrap.py` | 🔴 Alte roleLabels-Logik entfernen | **Test:** Vollständiger Integrationstest. --- #### PHASE 8: Neue Features (Optional, parallel möglich) **Geschätzte Komplexität:** Mittel | **Dateien:** ~6 **Ziel:** Neue Funktionalität hinzufügen. | Schritt | Datei | |---------|-------| | 8.1 | `routeFeatures.py` (NEU) | | 8.2 | `routeInvitations.py` (NEU) | | 8.3 | `routeRbacExport.py` (NEU) | | 8.4 | `routeGdpr.py` (NEU) | | 8.5 | `routeAdmin.py` (NEU) | **Test:** Neue Endpoints funktionieren. --- ### 14.5 AI-Implementierungs-Empfehlung | Phase | Dateien | Empfehlung | |-------|---------|------------| | 1 | 8 | ✅ **Einzeln umsetzbar** - Keine Breaking Changes | | 2 | 5 | ✅ **Einzeln umsetzbar** - Parallele Implementierung | | 3 | 6 | ⚠️ **Zusammen umsetzen** - Auth ist kritisch | | 4 | 18 | ✅ **In Gruppen** - A und B separat | | 5 | 6 | ✅ **Einzeln umsetzbar** | | 6 | 7 | ✅ **Einzeln umsetzbar** | | 7 | 3 | ⚠️ **Erst nach Phase 6** - Breaking! | | 8 | 6 | ✅ **Parallel zu Phase 4-6** | **Gesamtstrategie:** - Phase 1-2 zuerst (Foundation) - Phase 3 als Block (Auth-Critical) - Phase 4-6 parallel/sequentiell - Phase 7 erst ganz am Ende - Phase 8 jederzeit parallel ### 14.6 DSGVO-Compliance Checkliste | Anforderung | Implementierung | Status | |-------------|-----------------|--------| | Audit-Trail | `auditLogger.py` für alle Security-Events | ✅ | | Audit-Retention | Aufbewahrungsfristen je Log-Typ (siehe 7.3) | ✅ Definiert | | Löschrecht (Art. 17) | `DELETE /api/user/me` + CASCADE DELETE | ✅ Definiert | | Zugriffsrecht (Art. 15) | `GET /api/user/me/data-export` | ✅ Definiert | | Datenübertragbarkeit (Art. 20) | `GET /api/user/me/data-portability` | ✅ Definiert | | Berichtigungsrecht (Art. 16) | `PUT /api/user/me` (bereits vorhanden) | ✅ | | Verschlüsselung at rest | DB-Level (managed by cloud provider) | ✅ | | Verschlüsselung in transit | HTTPS enforced | ✅ | > **Out of Scope:** Daten-Anonymisierung bei Export (organisatorische Verantwortung des Mandanten)