3107 lines
108 KiB
Markdown
3107 lines
108 KiB
Markdown
# 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)
|