wiki/z-archive/implementation/Saas Multi Tenant Mandate/mandate_implementation_gateway.md

3107 lines
108 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)