mandate refactory
This commit is contained in:
parent
d8d692200e
commit
a70581d9f8
5 changed files with 5160 additions and 1777 deletions
|
|
@ -1,827 +0,0 @@
|
|||
# Multi-Tenant SaaS Plattform - Implementierungskonzept
|
||||
|
||||
## Technische Umsetzung für PowerOn Gateway
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 14. Januar 2026
|
||||
**Status:** Entwurf
|
||||
**Basis:** [mandate_concept.md](./mandate_concept.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Übersicht
|
||||
|
||||
### 1.1 Grundprinzip
|
||||
|
||||
**Kernänderung:** User gehören grundsätzlich **keinem** Mandanten an. Die Zugehörigkeit wird über eine separate Membership-Tabelle (`UserMandate`) geregelt.
|
||||
|
||||
```
|
||||
AKTUELL (IST):
|
||||
User ──── mandateId ──→ Mandate (1:1)
|
||||
|
||||
NEU (SOLL):
|
||||
User ◄──── UserMandate ────► Mandate (m:n)
|
||||
└─ roleLabels (mandantsspezifisch)
|
||||
```
|
||||
|
||||
### 1.2 Konsequenzen
|
||||
|
||||
| Aspekt | Aktuell | Neu |
|
||||
|--------|---------|-----|
|
||||
| User.mandateId | Pflichtfeld | Optional/Entfällt |
|
||||
| Rollen | Global pro User | Pro Membership |
|
||||
| Auth-Token | Enthält mandateId | Enthält nur userId |
|
||||
| API-Calls | Implizit aus Token | Explizit pro Request |
|
||||
| RBAC GROUP | User.mandateId | Request-Parameter |
|
||||
|
||||
---
|
||||
|
||||
## 2. Feature-Analyse (Code-Review)
|
||||
|
||||
### 2.1 Identifizierte Business-Features
|
||||
|
||||
Basierend auf Code-Analyse (`gateway/modules/`) wurden folgende Features identifiziert, die in das neue Multi-Tenant-Modell überführt werden müssen:
|
||||
|
||||
| Feature-Code | Name | Datenmodelle | Routes | Feature-Code |
|
||||
|--------------|------|--------------|--------|--------------|
|
||||
| `trustee` | **Treuhand** | `TrusteeOrganisation`, `TrusteeRole`, `TrusteeAccess`, `TrusteeContract`, `TrusteeDocument`, `TrusteePosition` | `routeDataTrustee.py` | `interfaceDbTrusteeObjects.py` |
|
||||
| `chatbot` | **Chatbot** | `ChatWorkflow` (mode=Chatbot), `ChatMessage`, `ChatDocument` | `routeChatbot.py` | `features/chatbot/` |
|
||||
| `workflow-dynamic` | **Dynamic Workflow** | `ChatWorkflow` (mode=Dynamic), `ChatMessage`, `ChatDocument`, `ChatLog`, `ChatStat` | `routeChatPlayground.py`, `routeWorkflows.py` | `features/workflow/` + `WorkflowManager` |
|
||||
| `workflow-automation` | **Automation** | `AutomationDefinition`, `ChatWorkflow` (mode=Automation) | `routeDataAutomation.py`, `routeAdminAutomationEvents.py` | `features/workflow/` + `WorkflowManager` |
|
||||
| `voice-center` | **Voice Center** | `VoiceSettings` | `routeVoiceGoogle.py` | `interfaceVoiceObjects.py` |
|
||||
| `realestate` | **Immobilien** | `Projekt`, `Parzelle`, `Land`, `Kanton`, `Gemeinde`, `Dokument` | `routeRealEstate.py` | `interfaceDbRealEstateObjects.py` |
|
||||
|
||||
### 2.2 Workflow-Modes und Feature-Zuordnung
|
||||
|
||||
Der `WorkflowModeEnum` definiert verschiedene Modi:
|
||||
|
||||
```python
|
||||
# datamodelChat.py - Zeile 277-281
|
||||
class WorkflowModeEnum(str, Enum):
|
||||
WORKFLOW_DYNAMIC = "Dynamic" # → Feature: workflow-dynamic (WorkflowManager)
|
||||
WORKFLOW_AUTOMATION = "Automation" # → Feature: workflow-automation (WorkflowManager + Templates)
|
||||
WORKFLOW_CHATBOT = "Chatbot" # → Feature: chatbot (eigener Code in features/chatbot/)
|
||||
WORKFLOW_REACT = "React" # Legacy - kann entfallen
|
||||
```
|
||||
|
||||
**Code-Struktur:**
|
||||
|
||||
| Feature | Code-Basis | Beschreibung |
|
||||
|---------|-----------|--------------|
|
||||
| `chatbot` | `features/chatbot/mainChatbot.py` → `chatProcess()` | Einfacher Chat mit direkten AI-Calls |
|
||||
| `workflow-dynamic` | `features/workflow/mainWorkflow.py` → `chatStart()` | Interaktiver KI-Workflow mit Task-Planning |
|
||||
| `workflow-automation` | `features/workflow/mainWorkflow.py` → `executeAutomation()` | Zeitgesteuerte Workflows mit Templates |
|
||||
|
||||
**Hinweis:** `routeChatPlayground.py` ist **keine eigenes Feature**, sondern eine Test-Route, die sowohl `workflow-dynamic` als auch `workflow-automation` aufrufen kann (via Query-Parameter `workflowMode`).
|
||||
|
||||
**Begründung für Feature-Trennung:**
|
||||
- Unterschiedliche Berechtigungen pro Mode möglich
|
||||
- Unterschiedliche Instanzen pro Mandant (z.B. Mandant A hat Automation, Mandant B nicht)
|
||||
- Unterschiedliche Pricing-Modelle
|
||||
|
||||
### 2.3 System-Features (ohne Mandant)
|
||||
|
||||
Diese Features sind **global** und erfordern **keinen Mandanten**:
|
||||
|
||||
| Feature | Beschreibung | Routes |
|
||||
|---------|--------------|--------|
|
||||
| `user-profile` | Eigenes Profil bearbeiten | `routeDataUsers.py` |
|
||||
| `user-settings` | Persönliche Einstellungen | `routeOptions.py` |
|
||||
| `file-management` | Dateiverwaltung (mandantenübergreifend) | `routeDataFiles.py` |
|
||||
| `connections` | Verbindungen (OAuth, APIs) | `routeDataConnections.py` |
|
||||
|
||||
### 2.4 Admin-Features (nur sysadmin)
|
||||
|
||||
Diese Features sind nur für System-Administratoren:
|
||||
|
||||
| Feature | Beschreibung | Routes |
|
||||
|---------|--------------|--------|
|
||||
| `admin-mandates` | Mandanten verwalten | `routeDataMandates.py` |
|
||||
| `admin-users` | Alle User verwalten | `routeDataUsers.py` |
|
||||
| `admin-rbac` | RBAC-Regeln verwalten | `routeRbac.py`, `routeAdminRbacRoles.py` |
|
||||
| `admin-security` | Security-Einstellungen | `routeSecurityAdmin.py` |
|
||||
|
||||
### 2.5 Mandant-spezifische Felder in Datamodels
|
||||
|
||||
Alle Business-Feature-Models haben ein `mandateId`-Feld:
|
||||
|
||||
```python
|
||||
# Beispiele aus dem Code:
|
||||
|
||||
# datamodelChat.py - ChatWorkflow
|
||||
mandateId: str = Field(description="ID of the mandate this workflow belongs to")
|
||||
|
||||
# datamodelVoice.py - VoiceSettings
|
||||
mandateId: str = Field(description="ID of the mandate these settings belong to")
|
||||
|
||||
# datamodelTrustee.py - TrusteeOrganisation
|
||||
mandateId: Optional[str] = Field(default=None, description="Mandate ID")
|
||||
|
||||
# datamodelRealEstate.py - Projekt, Parzelle, etc.
|
||||
mandateId: str = Field(description="ID of the mandate")
|
||||
```
|
||||
|
||||
### 2.6 Zu ergänzen: Feature-Definition Model
|
||||
|
||||
Neues Model für Feature-Registrierung:
|
||||
|
||||
```python
|
||||
# modules/datamodels/datamodelFeatures.py (NEU)
|
||||
|
||||
class FeatureDefinition(BaseModel):
|
||||
"""Definition eines Features im System."""
|
||||
code: str = Field(description="Eindeutiger Feature-Code (z.B. 'trustee')")
|
||||
label: Dict[str, str] = Field(description="I18n-Labels {'en': '...', 'de': '...'}")
|
||||
icon: str = Field(description="Material Icon Name")
|
||||
category: str = Field(description="Kategorie: 'business', 'system', 'admin'")
|
||||
tables: List[str] = Field(description="Zugehörige DB-Tabellen")
|
||||
routes: List[str] = Field(description="API-Route-Prefixe")
|
||||
defaultRoles: List[str] = Field(description="Standard-Rollen für dieses Feature")
|
||||
enabled: bool = Field(default=True)
|
||||
|
||||
|
||||
class FeatureInstance(BaseModel):
|
||||
"""Instanz eines Features für einen Mandanten."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
featureCode: str = Field(description="Referenz auf FeatureDefinition.code")
|
||||
mandateId: str = Field(description="Referenz auf Mandate.id")
|
||||
instanceLabel: str = Field(description="Instanz-Name (z.B. 'Buchhaltung 2025')")
|
||||
enabled: bool = Field(default=True)
|
||||
settings: Dict[str, Any] = Field(default_factory=dict, description="Feature-spezifische Einstellungen")
|
||||
|
||||
|
||||
class FeatureAccess(BaseModel):
|
||||
"""Zugriff eines Users auf eine Feature-Instanz."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
userId: str = Field(description="Referenz auf User.id")
|
||||
featureInstanceId: str = Field(description="Referenz auf FeatureInstance.id")
|
||||
roleLabel: str = Field(description="Rolle in dieser Feature-Instanz")
|
||||
enabled: bool = Field(default=True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Datenmodell-Änderungen
|
||||
|
||||
### 3.1 Neues Model: `UserMandate`
|
||||
|
||||
```python
|
||||
# modules/datamodels/datamodelUam.py
|
||||
|
||||
class UserMandate(BaseModel):
|
||||
"""Membership: Verknüpft User mit Mandanten und deren Rollen."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
userId: str = Field(description="Referenz auf User.id")
|
||||
mandateId: str = Field(description="Referenz auf Mandate.id")
|
||||
roleLabels: List[str] = Field(
|
||||
default_factory=lambda: ["user"],
|
||||
description="Rollen des Users in diesem Mandanten"
|
||||
)
|
||||
enabled: bool = Field(default=True)
|
||||
invitedBy: Optional[str] = Field(None, description="User-ID des Einladenden")
|
||||
invitedAt: Optional[float] = Field(None, description="Zeitpunkt der Einladung")
|
||||
acceptedAt: Optional[float] = Field(None, description="Zeitpunkt der Annahme")
|
||||
# System-Felder: _createdAt, _modifiedAt, _createdBy, _modifiedBy
|
||||
```
|
||||
|
||||
### 3.2 Änderung: `User` Model
|
||||
|
||||
```python
|
||||
# modules/datamodels/datamodelUam.py
|
||||
|
||||
class User(BaseModel):
|
||||
id: str = Field(...)
|
||||
username: str = Field(...)
|
||||
email: Optional[EmailStr] = Field(None)
|
||||
fullName: Optional[str] = Field(None)
|
||||
language: str = Field(default="en")
|
||||
enabled: bool = Field(default=True)
|
||||
# ENTFERNT: mandateId - User gehören keinem Mandanten
|
||||
# ENTFERNT: roleLabels - Rollen sind mandantsspezifisch
|
||||
authenticationAuthority: AuthAuthority = Field(default=AuthAuthority.LOCAL)
|
||||
|
||||
# NEU: Transiente Felder (nicht in DB, werden zur Laufzeit befüllt)
|
||||
currentMandateId: Optional[str] = Field(None, exclude=True)
|
||||
currentRoleLabels: List[str] = Field(default_factory=list, exclude=True)
|
||||
```
|
||||
|
||||
### 3.3 Änderung: `AccessRule` Model
|
||||
|
||||
```python
|
||||
# modules/datamodels/datamodelRbac.py
|
||||
|
||||
class AccessRule(BaseModel):
|
||||
id: str = Field(...)
|
||||
roleLabel: str = Field(...)
|
||||
context: AccessRuleContext = Field(...)
|
||||
item: Optional[str] = Field(None)
|
||||
view: bool = Field(False)
|
||||
read: Optional[AccessLevel] = Field(None)
|
||||
create: Optional[AccessLevel] = Field(None)
|
||||
update: Optional[AccessLevel] = Field(None)
|
||||
delete: Optional[AccessLevel] = Field(None)
|
||||
# NEU: Mandant-spezifische Regeln
|
||||
mandateId: Optional[str] = Field(
|
||||
None,
|
||||
description="Wenn gesetzt, gilt Regel nur für diesen Mandanten. None = Global."
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Authentifizierung & Kontext
|
||||
|
||||
### 4.1 JWT Token (vereinfacht)
|
||||
|
||||
**Aktuell:**
|
||||
```json
|
||||
{
|
||||
"sub": "admin",
|
||||
"userId": "uuid-123",
|
||||
"mandateId": "uuid-456",
|
||||
"exp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
**Neu:**
|
||||
```json
|
||||
{
|
||||
"sub": "admin",
|
||||
"userId": "uuid-123",
|
||||
"exp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
Der Token enthält **kein mandateId** mehr, da der User keinem Mandanten angehört.
|
||||
|
||||
### 4.2 Mandanten-Kontext per Request
|
||||
|
||||
Der Mandanten-Kontext wird **pro API-Call** bestimmt:
|
||||
|
||||
**Option A: Query-Parameter**
|
||||
```
|
||||
GET /api/trustee/organisations?mandateId=uuid-456
|
||||
```
|
||||
|
||||
**Option B: Header**
|
||||
```
|
||||
X-Mandate-Id: uuid-456
|
||||
```
|
||||
|
||||
**Option C: Implizit aus Daten**
|
||||
```
|
||||
POST /api/trustee/contracts
|
||||
Body: { "organisationId": "org-123" }
|
||||
→ Backend ermittelt mandateId aus Organisation
|
||||
```
|
||||
|
||||
### 4.3 Authentifizierung Flow
|
||||
|
||||
```
|
||||
1. Login
|
||||
└─ User authentifiziert sich
|
||||
└─ Token enthält nur userId
|
||||
└─ Response enthält Liste der Memberships
|
||||
|
||||
2. API-Call
|
||||
└─ Token wird validiert (userId)
|
||||
└─ mandateId wird aus Request extrahiert
|
||||
└─ Membership wird geprüft (userId + mandateId)
|
||||
└─ Rollen werden aus UserMandate geladen
|
||||
└─ RBAC wird mit diesen Rollen ausgeführt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. RBAC-Anpassungen
|
||||
|
||||
### 5.1 Regel-Auflösung (Mandant-aware)
|
||||
|
||||
```python
|
||||
# modules/security/rbac.py
|
||||
|
||||
def _getRulesForRole(self, roleLabel: str, context: AccessRuleContext, mandateId: Optional[str] = None) -> List[AccessRule]:
|
||||
"""
|
||||
Lädt Regeln mit Mandant-Priorisierung:
|
||||
1. Mandant-spezifische Regeln (mandateId = X)
|
||||
2. Globale Regeln (mandateId = None)
|
||||
|
||||
Spezifische überschreiben globale.
|
||||
"""
|
||||
allRules = self.dbApp.getRecordset(
|
||||
AccessRule,
|
||||
recordFilter={"roleLabel": roleLabel, "context": context.value}
|
||||
)
|
||||
|
||||
# Sortiere: mandateId-spezifisch vor global
|
||||
mandateRules = [r for r in allRules if r.get("mandateId") == mandateId]
|
||||
globalRules = [r for r in allRules if r.get("mandateId") is None]
|
||||
|
||||
# Spezifische haben Vorrang
|
||||
rulesByItem = {}
|
||||
for rule in globalRules:
|
||||
rulesByItem[rule.get("item")] = rule
|
||||
for rule in mandateRules:
|
||||
rulesByItem[rule.get("item")] = rule # Überschreibt global
|
||||
|
||||
return list(rulesByItem.values())
|
||||
```
|
||||
|
||||
### 5.2 GROUP-Filter anpassen
|
||||
|
||||
```python
|
||||
# modules/interfaces/interfaceRbac.py
|
||||
|
||||
def buildRbacWhereClause(
|
||||
permissions: UserPermissions,
|
||||
currentUser: User,
|
||||
table: str,
|
||||
connector,
|
||||
mandateId: str # NEU: Expliziter Parameter
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
GROUP filtert jetzt nach übergebenem mandateId,
|
||||
nicht mehr nach currentUser.mandateId.
|
||||
"""
|
||||
if permissions.read == AccessLevel.GROUP:
|
||||
if not mandateId:
|
||||
return {"condition": "1 = 0", "values": []}
|
||||
|
||||
return {
|
||||
"condition": '"mandateId" = %s',
|
||||
"values": [mandateId]
|
||||
}
|
||||
# ... rest bleibt gleich
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Interface-Anpassungen
|
||||
|
||||
### 6.1 Kontext ohne Pflicht-Mandat
|
||||
|
||||
```python
|
||||
# modules/interfaces/interfaceDbAppObjects.py
|
||||
|
||||
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None):
|
||||
"""
|
||||
Setzt User-Kontext. mandateId ist optional.
|
||||
Wenn mandateId gesetzt, wird Membership geprüft.
|
||||
"""
|
||||
self.currentUser = currentUser
|
||||
self.userId = currentUser.id
|
||||
self.currentMandateId = mandateId
|
||||
|
||||
if mandateId:
|
||||
# Membership prüfen und Rollen laden
|
||||
membership = self.getUserMandate(currentUser.id, mandateId)
|
||||
if not membership:
|
||||
raise PermissionError(f"User {currentUser.id} ist nicht Mitglied in Mandant {mandateId}")
|
||||
|
||||
# Rollen aus Membership für RBAC verwenden
|
||||
currentUser.currentRoleLabels = membership.roleLabels
|
||||
else:
|
||||
# Ohne Mandat: System-Rollen (sysadmin) oder leer
|
||||
currentUser.currentRoleLabels = ["sysadmin"] if self._isSysAdmin(currentUser) else []
|
||||
```
|
||||
|
||||
### 6.2 Neue Membership-Methoden
|
||||
|
||||
```python
|
||||
# modules/interfaces/interfaceDbAppObjects.py
|
||||
|
||||
def getUserMandates(self, userId: str) -> List[UserMandate]:
|
||||
"""Alle Mandanten-Mitgliedschaften eines Users."""
|
||||
records = self.db.getRecordset(UserMandate, recordFilter={"userId": userId})
|
||||
return [UserMandate(**r) for r in records]
|
||||
|
||||
def getUserMandate(self, userId: str, mandateId: str) -> Optional[UserMandate]:
|
||||
"""Spezifische Mitgliedschaft prüfen."""
|
||||
records = self.db.getRecordset(
|
||||
UserMandate,
|
||||
recordFilter={"userId": userId, "mandateId": mandateId}
|
||||
)
|
||||
return UserMandate(**records[0]) if records else None
|
||||
|
||||
def getMandateMembers(self, mandateId: str) -> List[UserMandate]:
|
||||
"""Alle Mitglieder eines Mandanten."""
|
||||
records = self.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
|
||||
return [UserMandate(**r) for r in records]
|
||||
|
||||
def addUserToMandate(self, userId: str, mandateId: str, roleLabels: List[str], invitedBy: str) -> UserMandate:
|
||||
"""Fügt User zu Mandant hinzu."""
|
||||
membership = UserMandate(
|
||||
userId=userId,
|
||||
mandateId=mandateId,
|
||||
roleLabels=roleLabels,
|
||||
invitedBy=invitedBy,
|
||||
invitedAt=getUtcTimestamp()
|
||||
)
|
||||
result = self.db.recordCreate(UserMandate, membership)
|
||||
return UserMandate(**result)
|
||||
|
||||
def removeUserFromMandate(self, userId: str, mandateId: str) -> bool:
|
||||
"""Entfernt User aus Mandant."""
|
||||
memberships = self.db.getRecordset(
|
||||
UserMandate,
|
||||
recordFilter={"userId": userId, "mandateId": mandateId}
|
||||
)
|
||||
if memberships:
|
||||
return self.db.recordDelete(UserMandate, memberships[0]["id"])
|
||||
return False
|
||||
|
||||
def updateMembershipRoles(self, membershipId: str, roleLabels: List[str]) -> UserMandate:
|
||||
"""Aktualisiert Rollen einer Mitgliedschaft."""
|
||||
result = self.db.recordModify(UserMandate, membershipId, {"roleLabels": roleLabels})
|
||||
return UserMandate(**result)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. API-Routen
|
||||
|
||||
### 6.1 Neue Membership-Routen
|
||||
|
||||
```python
|
||||
# modules/routes/routeMandateMemberships.py
|
||||
|
||||
router = APIRouter(prefix="/api/mandates", tags=["Mandate Memberships"])
|
||||
|
||||
@router.get("/my", response_model=List[MandateMembershipResponse])
|
||||
async def getMyMandates(currentUser: User = Depends(getCurrentUser)):
|
||||
"""Alle Mandanten des eingeloggten Users."""
|
||||
interface = getInterface(currentUser)
|
||||
memberships = interface.getUserMandates(currentUser.id)
|
||||
|
||||
result = []
|
||||
for m in memberships:
|
||||
mandate = interface.getMandate(m.mandateId)
|
||||
result.append({
|
||||
"membershipId": m.id,
|
||||
"mandateId": m.mandateId,
|
||||
"mandateName": mandate.name if mandate else "Unknown",
|
||||
"roleLabels": m.roleLabels,
|
||||
"enabled": m.enabled
|
||||
})
|
||||
return result
|
||||
|
||||
@router.get("/{mandateId}/members", response_model=List[MemberResponse])
|
||||
async def getMandateMembers(
|
||||
mandateId: str,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
):
|
||||
"""Alle Mitglieder eines Mandanten (nur für Admins)."""
|
||||
interface = getInterface(currentUser, mandateId)
|
||||
# Prüft automatisch Membership und admin-Rolle
|
||||
return interface.getMandateMembers(mandateId)
|
||||
|
||||
@router.post("/{mandateId}/members")
|
||||
async def addMember(
|
||||
mandateId: str,
|
||||
data: AddMemberRequest,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
):
|
||||
"""Fügt User zu Mandant hinzu (nur sysadmin oder mandant-admin)."""
|
||||
interface = getInterface(currentUser, mandateId)
|
||||
return interface.addUserToMandate(
|
||||
userId=data.userId,
|
||||
mandateId=mandateId,
|
||||
roleLabels=data.roleLabels,
|
||||
invitedBy=currentUser.id
|
||||
)
|
||||
|
||||
@router.delete("/{mandateId}/members/{userId}")
|
||||
async def removeMember(
|
||||
mandateId: str,
|
||||
userId: str,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
):
|
||||
"""Entfernt User aus Mandant."""
|
||||
interface = getInterface(currentUser, mandateId)
|
||||
return interface.removeUserFromMandate(userId, mandateId)
|
||||
|
||||
@router.put("/{mandateId}/members/{userId}/roles")
|
||||
async def updateMemberRoles(
|
||||
mandateId: str,
|
||||
userId: str,
|
||||
data: UpdateRolesRequest,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
):
|
||||
"""Aktualisiert Rollen eines Mitglieds."""
|
||||
interface = getInterface(currentUser, mandateId)
|
||||
membership = interface.getUserMandate(userId, mandateId)
|
||||
return interface.updateMembershipRoles(membership.id, data.roleLabels)
|
||||
```
|
||||
|
||||
### 7.2 Anpassung bestehender Routen
|
||||
|
||||
```python
|
||||
# modules/routes/routeDataUsers.py
|
||||
|
||||
@router.get("/")
|
||||
async def getUsers(
|
||||
mandateId: Optional[str] = Query(None), # Explizit, nicht aus Token
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
):
|
||||
"""
|
||||
Users abrufen.
|
||||
- Ohne mandateId: Nur sysadmin sieht alle
|
||||
- Mit mandateId: Mitglieder des Mandanten
|
||||
"""
|
||||
if mandateId:
|
||||
interface = getInterface(currentUser, mandateId)
|
||||
memberships = interface.getMandateMembers(mandateId)
|
||||
userIds = [m.userId for m in memberships]
|
||||
return interface.getUsersByIds(userIds)
|
||||
else:
|
||||
# Nur sysadmin
|
||||
if not _isSysAdmin(currentUser):
|
||||
raise HTTPException(403, "mandateId required for non-sysadmin")
|
||||
interface = getInterface(currentUser)
|
||||
return interface.getAllUsers()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. RBAC Import/Export
|
||||
|
||||
### 8.1 Export-Format (JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"exportedAt": "2026-01-14T10:00:00Z",
|
||||
"exportedBy": "admin",
|
||||
"scope": {
|
||||
"mandateId": "uuid-456",
|
||||
"description": "Althaus Consulting - Custom Policies"
|
||||
},
|
||||
"roles": [
|
||||
{
|
||||
"roleLabel": "trustee-admin",
|
||||
"description": {"en": "Trustee Administrator", "de": "Treuhand-Administrator"},
|
||||
"isSystemRole": false
|
||||
}
|
||||
],
|
||||
"rules": [
|
||||
{
|
||||
"roleLabel": "trustee-admin",
|
||||
"context": "DATA",
|
||||
"item": "TrusteeContract",
|
||||
"view": true,
|
||||
"read": "g",
|
||||
"create": "g",
|
||||
"update": "g",
|
||||
"delete": "g",
|
||||
"mandateId": "uuid-456"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 Export-Endpoint
|
||||
|
||||
```python
|
||||
# modules/routes/routeRbac.py
|
||||
|
||||
@router.get("/export")
|
||||
async def exportRbac(
|
||||
mandateId: Optional[str] = Query(None),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
):
|
||||
"""
|
||||
Exportiert RBAC-Konfiguration.
|
||||
- mandateId=None: Globale Regeln
|
||||
- mandateId=X: Regeln für Mandant X
|
||||
"""
|
||||
interface = getInterface(currentUser, mandateId)
|
||||
|
||||
roles = interface.getAllRoles()
|
||||
rules = interface.getAccessRules(mandateId=mandateId)
|
||||
|
||||
return {
|
||||
"version": "1.0",
|
||||
"exportedAt": getUtcTimestamp(),
|
||||
"exportedBy": currentUser.username,
|
||||
"scope": {
|
||||
"mandateId": mandateId,
|
||||
"description": "Global" if not mandateId else f"Mandate: {mandateId}"
|
||||
},
|
||||
"roles": [r.model_dump() for r in roles],
|
||||
"rules": [r.model_dump() for r in rules]
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 Import-Endpoint
|
||||
|
||||
```python
|
||||
@router.post("/import")
|
||||
async def importRbac(
|
||||
data: RbacImportRequest,
|
||||
mode: str = Query("merge", description="merge oder replace"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
):
|
||||
"""
|
||||
Importiert RBAC-Konfiguration.
|
||||
- mode=merge: Upsert (bestehende werden aktualisiert)
|
||||
- mode=replace: Alle bestehenden Regeln des Scope löschen, dann importieren
|
||||
"""
|
||||
interface = getInterface(currentUser)
|
||||
mandateId = data.scope.get("mandateId")
|
||||
|
||||
# Nur sysadmin darf importieren
|
||||
if not _isSysAdmin(currentUser):
|
||||
raise HTTPException(403, "Only sysadmin can import RBAC")
|
||||
|
||||
if mode == "replace" and mandateId:
|
||||
# Lösche alle bestehenden Regeln für diesen Mandanten
|
||||
interface.deleteAccessRulesByMandate(mandateId)
|
||||
|
||||
imported = {"roles": 0, "rules": 0}
|
||||
|
||||
# Rollen importieren
|
||||
for roleData in data.roles:
|
||||
existing = interface.getRoleByLabel(roleData["roleLabel"])
|
||||
if existing:
|
||||
interface.updateRole(existing.id, Role(**roleData))
|
||||
else:
|
||||
interface.createRole(Role(**roleData))
|
||||
imported["roles"] += 1
|
||||
|
||||
# Regeln importieren
|
||||
for ruleData in data.rules:
|
||||
ruleData["mandateId"] = mandateId # Scope setzen
|
||||
|
||||
# Eindeutigkeit: roleLabel + context + item + mandateId
|
||||
existing = interface.findAccessRule(
|
||||
roleLabel=ruleData["roleLabel"],
|
||||
context=ruleData["context"],
|
||||
item=ruleData.get("item"),
|
||||
mandateId=mandateId
|
||||
)
|
||||
|
||||
if existing:
|
||||
interface.updateAccessRule(existing.id, AccessRule(**ruleData))
|
||||
else:
|
||||
interface.createAccessRule(AccessRule(**ruleData))
|
||||
imported["rules"] += 1
|
||||
|
||||
return {"status": "success", "imported": imported}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Migration
|
||||
|
||||
### 9.1 Datenbank-Migration
|
||||
|
||||
```python
|
||||
# migration_001_user_mandate.py
|
||||
|
||||
def migrate():
|
||||
"""
|
||||
Migriert bestehende User.mandateId zu UserMandate-Einträgen.
|
||||
"""
|
||||
db = getRootDbConnector()
|
||||
|
||||
# 1. UserMandate-Tabelle erstellen
|
||||
db._ensureTableExists(UserMandate)
|
||||
|
||||
# 2. Bestehende User migrieren
|
||||
users = db.getRecordset(UserInDB)
|
||||
|
||||
for user in users:
|
||||
mandateId = user.get("mandateId")
|
||||
if mandateId:
|
||||
# Prüfen ob Membership existiert
|
||||
existing = db.getRecordset(
|
||||
UserMandate,
|
||||
recordFilter={"userId": user["id"], "mandateId": mandateId}
|
||||
)
|
||||
|
||||
if not existing:
|
||||
# Membership erstellen
|
||||
membership = UserMandate(
|
||||
userId=user["id"],
|
||||
mandateId=mandateId,
|
||||
roleLabels=user.get("roleLabels", ["user"]),
|
||||
enabled=True,
|
||||
acceptedAt=getUtcTimestamp()
|
||||
)
|
||||
db.recordCreate(UserMandate, membership)
|
||||
print(f"Created membership for user {user['id']} in mandate {mandateId}")
|
||||
|
||||
# 3. Optional: mandateId und roleLabels aus User entfernen
|
||||
# (erst nach vollständigem Test der neuen Logik)
|
||||
|
||||
print("Migration completed")
|
||||
```
|
||||
|
||||
### 9.2 Abwärtskompatibilität
|
||||
|
||||
Während der Übergangsphase:
|
||||
|
||||
```python
|
||||
def getCurrentUser(token: str = Depends(cookieAuth)) -> User:
|
||||
"""
|
||||
Lädt User und setzt Legacy-Kompatibilität.
|
||||
"""
|
||||
user = _loadUserFromToken(token)
|
||||
|
||||
# Legacy: Falls Code noch user.mandateId erwartet
|
||||
# Erstes Mandat aus Memberships als Default
|
||||
memberships = getUserMandates(user.id)
|
||||
if memberships:
|
||||
user.mandateId = memberships[0].mandateId # Deprecated
|
||||
user.roleLabels = memberships[0].roleLabels # Deprecated
|
||||
|
||||
return user
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Schrittweise Umsetzung
|
||||
|
||||
### Phase 1: Datenmodell (Woche 1-2)
|
||||
- [ ] `UserMandate` Model erstellen
|
||||
- [ ] `AccessRule.mandateId` hinzufügen
|
||||
- [ ] `User` Model anpassen (transiente Felder)
|
||||
- [ ] Migrations-Script erstellen
|
||||
|
||||
### Phase 2: Backend-Core (Woche 2-3)
|
||||
- [ ] `interfaceDbAppObjects` Membership-Methoden
|
||||
- [ ] `interfaceRbac` mandateId-Parameter
|
||||
- [ ] `RbacClass` mandatsspezifische Regeln
|
||||
- [ ] Auth-Token ohne mandateId
|
||||
|
||||
### Phase 3: API-Routen (Woche 3-4)
|
||||
- [ ] Membership-Routen erstellen
|
||||
- [ ] Bestehende Routen anpassen (mandateId explizit)
|
||||
- [ ] RBAC Import/Export Endpoints
|
||||
|
||||
### Phase 4: Tests & Migration (Woche 4-5)
|
||||
- [ ] Unit-Tests für Memberships
|
||||
- [ ] Integration-Tests für RBAC
|
||||
- [ ] Migration bestehender Daten
|
||||
- [ ] Abwärtskompatibilität testen
|
||||
|
||||
### Phase 5: Frontend (Woche 5-6)
|
||||
- [ ] Mandanten-Auswahl UI
|
||||
- [ ] Membership-Verwaltung
|
||||
- [ ] RBAC Import/Export UI
|
||||
|
||||
---
|
||||
|
||||
## 11. Betroffene Dateien
|
||||
|
||||
### 11.1 Zu ändern
|
||||
|
||||
| Datei | Änderungen |
|
||||
|-------|------------|
|
||||
| `datamodels/datamodelUam.py` | `UserMandate` Model, `User` anpassen |
|
||||
| `datamodels/datamodelRbac.py` | `AccessRule.mandateId` |
|
||||
| `auth/authentication.py` | Token ohne mandateId |
|
||||
| `security/rbac.py` | Mandant-aware Regelauflösung |
|
||||
| `interfaces/interfaceRbac.py` | mandateId-Parameter |
|
||||
| `interfaces/interfaceDbAppObjects.py` | Membership-Methoden, Kontext-Logik |
|
||||
| `interfaces/interfaceDbTrusteeObjects.py` | Mandant aus Request statt User |
|
||||
| `interfaces/interfaceBootstrap.py` | Initiale Memberships |
|
||||
| `routes/routeDataUsers.py` | mandateId explizit |
|
||||
| `routes/routeDataMandates.py` | Membership-Filter |
|
||||
| `routes/routeRbac.py` | Import/Export Endpoints |
|
||||
|
||||
### 11.2 Neu zu erstellen
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `routes/routeMandateMemberships.py` | Membership-API |
|
||||
| `migrations/001_user_mandate.py` | Datenmigration |
|
||||
|
||||
### 11.3 Tests anzupassen
|
||||
|
||||
- `tests/unit/rbac/*`
|
||||
- `tests/integration/rbac/*`
|
||||
- `tests/functional/test*`
|
||||
|
||||
---
|
||||
|
||||
## 12. Risiken & Mitigationen
|
||||
|
||||
| Risiko | Mitigation |
|
||||
|--------|------------|
|
||||
| Breaking Changes | Übergangsphase mit Legacy-Kompatibilität |
|
||||
| Datenverlust | Migration erstellt Memberships, löscht nichts |
|
||||
| Performance | Membership-Cache pro Request |
|
||||
| Komplexität | Schrittweise Umsetzung, Feature-Flags |
|
||||
|
||||
---
|
||||
|
||||
## 13. Geklärte Design-Entscheidungen
|
||||
|
||||
1. **Frontend-Handling:** Features als Objekte/Gruppen organisiert, generisches Handling für Instanzen. Berechtigungen werden **summarisch** pro Feature-Instanz geladen (ein Request), nicht pro Objekt/Feld. → Siehe [ui_concept_nyla.md](./ui_concept_nyla.md)
|
||||
|
||||
2. **Default-Mandant:** User hat **keinen** Default-Mandanten. User ohne Mandant-Zugehörigkeit kann System-Basics nutzen (Login, Profil, Settings). Ein sysadmin hat typischerweise keinen Mandanten.
|
||||
|
||||
3. **Einladungs-Flow:** Email-Link mit überwachtem Token. Admin kann Token jederzeit widerrufen/löschen. Token hat Ablaufzeit.
|
||||
|
||||
4. **System-Rollen (sysadmin):** Global, nicht mandantsspezifisch. sysadmin wird direkt am User-Objekt markiert, nicht über Membership.
|
||||
|
|
@ -129,31 +129,6 @@ Ein Mandant kann **mehrere Instanzen** desselben Features haben:
|
|||
|
||||
Jede Instanz hat eigene Daten und Benutzer-Berechtigungen.
|
||||
|
||||
### 3.4 Benutzer-Zuordnung: Das "Ein Account, viele Rollen"-Prinzip
|
||||
|
||||
```
|
||||
Benutzerin: Shelly (shelly@universe.com)
|
||||
│
|
||||
├─ Mandant: Althaus
|
||||
│ ├─ Rolle im Mandant: Standard-Benutzer
|
||||
│ └─ Zugriff auf:
|
||||
│ ├─ Chatbot "Produkt-Support" → Rolle: Administrator
|
||||
│ └─ Chatbot "HR-Assistent" → Rolle: Benutzer
|
||||
│
|
||||
├─ Mandant: Soha Treuhand
|
||||
│ ├─ Rolle im Mandant: Standard-Benutzer
|
||||
│ └─ Zugriff auf:
|
||||
│ └─ Treuhand "Buchhaltung 2025" → Rolle: Kunde (nur eigene Daten)
|
||||
│
|
||||
└─ Mandant: Universe Corp
|
||||
├─ Rolle im Mandant: Mandanten-Administrator
|
||||
└─ Zugriff auf:
|
||||
└─ Alle Features im Mandant
|
||||
|
||||
```
|
||||
|
||||
**Ergebnis:** Shelly hat **einen Account**, aber **verschiedene Rollen** in **drei Mandanten**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Benutzerrollen & Verantwortlichkeiten
|
||||
|
|
@ -166,13 +141,25 @@ Benutzerin: Shelly (shelly@universe.com)
|
|||
|
||||
- ✅ Erstellt und verwaltet Mandanten
|
||||
- ✅ Schaltet Features für Mandanten frei
|
||||
- ✅ Weist Benutzer zu Mandanten zu
|
||||
- ✅ Definiert und verwaltet Berechtigungs-Policies
|
||||
- ✅ Hat Zugriff auf alle Daten (für Support/Wartung)
|
||||
- ✅ Definiert und verwaltet globale Berechtigungs-Templates
|
||||
- ✅ Verwaltet User-Accounts global (aktivieren/deaktivieren)
|
||||
- ✅ Überwacht System-Health und Audit-Logs
|
||||
|
||||
**Kann NICHT:**
|
||||
|
||||
- ❌ Automatisch in Feature-Instanzen arbeiten (muss sich explizit Zugriff geben)
|
||||
- ❌ Mandant-Daten einsehen (keine Daten von Kunden, Verträgen, etc.)
|
||||
- ❌ Sich selbst zu einem Mandanten hinzufügen (4-Augen-Prinzip)
|
||||
- ❌ RBAC-Regeln eines spezifischen Mandanten ändern
|
||||
|
||||
**Wichtig: Strikte Trennung System vs. Daten**
|
||||
|
||||
Der SysAdmin hat **keinen Zugriff auf Mandant-Daten**. Dies ist eine bewusste Sicherheitsentscheidung:
|
||||
|
||||
- 🔒 **Bank-konform:** Datenzugriff nur über explizite Rollen im Mandanten
|
||||
- 📊 **4-Augen-Prinzip:** Wenn SysAdmin Datenzugriff braucht, muss ein Mandate-Admin ihn einladen
|
||||
- 🔍 **Audit-Trail:** Alle Zugriffserteilungen sind nachvollziehbar
|
||||
|
||||
**Beispiel:** SysAdmin "Patrick" muss Support für Mandant "Althaus" leisten. Der Mandate-Admin von "Althaus" fügt Patrick als regulären User hinzu. Patrick unterliegt dann der normalen RBAC-Prüfung und alle Aktionen werden auditiert.
|
||||
|
||||
### 4.2 Mandanten-Administrator
|
||||
|
||||
|
|
@ -182,15 +169,17 @@ Benutzerin: Shelly (shelly@universe.com)
|
|||
|
||||
- ✅ Erstellt Feature-Instanzen im eigenen Mandant
|
||||
- ✅ Erstellt Einladungslinks für neue Benutzer
|
||||
- ✅ Fügt bestehende User zum Mandanten hinzu
|
||||
- ✅ Vergibt Rollen innerhalb des Mandanten
|
||||
- ✅ Sieht alle Benutzer des eigenen Mandanten
|
||||
- ✅ Verwaltet Feature-Instanzen
|
||||
- ✅ Exportiert/Importiert RBAC-Regeln für den eigenen Mandanten
|
||||
|
||||
**Kann NICHT:**
|
||||
|
||||
- ❌ Benutzer zum Mandanten hinzufügen (nur System-Admin)
|
||||
- ❌ Features für den Mandanten freischalten (nur System-Admin)
|
||||
- ❌ Andere Mandanten sehen oder darauf zugreifen
|
||||
- ❌ Globale RBAC-Templates ändern (nur System-Admin)
|
||||
|
||||
**Isolation:** Ein Mandanten-Administrator sieht nur die Benutzer und Daten seines eigenen Mandanten.
|
||||
|
||||
|
|
@ -215,10 +204,11 @@ Jede Feature-Instanz hat eigene Rollen:
|
|||
|
||||
| Rolle | Typische Berechtigungen | Beispiel |
|
||||
| --- | --- | --- |
|
||||
| **Administrator** | Voller Zugriff auf alle Daten der Instanz | Chatbot-Admin sieht alle Konversationen |
|
||||
| **Benutzer** | Zugriff auf Instanz-Daten, kann eigene Daten erstellen | Marketing-Mitarbeiter nutzt Chatbot |
|
||||
| **Kunde** | Nur Zugriff auf eigene Daten | Treuhand-Kunde sieht nur eigene Buchhaltung |
|
||||
| **Betrachter** | Nur Lesezugriff | Externer Berater sieht Berichte, kann nichts ändern |
|
||||
| **Administrator** | Voller Zugriff auf alle Daten der Instanz (Scope "g") | Chatbot-Admin sieht alle Konversationen |
|
||||
| **Benutzer** | Zugriff gemäss konfiguriertem Scope ("m" oder "g") | Scope "m": Nur eigene Daten; Scope "g": Alle Instanz-Daten |
|
||||
| **Betrachter** | Nur Lesezugriff (Scope "m" oder "g") | Externer Berater sieht Berichte, kann nichts ändern |
|
||||
|
||||
**Hinweis:** Der Datenzugriff wird über den **Scope** gesteuert (siehe Kap. 6.2), nicht über separate Rollen. Ein "Benutzer" mit Scope "m" sieht nur eigene Daten, mit Scope "g" alle Daten der Instanz.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -232,15 +222,15 @@ Patrick hat 3 eigene Firmen und nutzt für jede ein anderes Treuhandbüro. Gleic
|
|||
**Lösung:**
|
||||
|
||||
```
|
||||
Benutzer: Patrick (patrick@example.com)
|
||||
Benutzer: Patrick ([patrick@example.com](mailto:patrick@example.com))
|
||||
│
|
||||
├─ Mandant: Soha Treuhand (Treuhandbüro 1)
|
||||
│ └─ Treuhand-Instanz "PamoCreate AG" → Rolle: Kunde
|
||||
│ └─ Treuhand-Instanz "ValueOn AG" → Rolle: Kunde
|
||||
│ └─ Treuhand-Instanz "PowerOn GmbH" → Rolle: Kunde
|
||||
│ └─ Treuhand-Instanz "PamoCreate AG" → Rolle: Benutzer
|
||||
│ └─ Treuhand-Instanz "ValueOn AG" → Rolle: Benutzer
|
||||
│ └─ Treuhand-Instanz "PowerOn GmbH" → Rolle: Benutzer
|
||||
│
|
||||
├─ Mandant: SwissTreu (Treuhandbüro 2)
|
||||
│ └─ Treuhand-Instanz "Firma X" → Rolle: Kunde
|
||||
│ └─ Treuhand-Instanz "Firma X" → Rolle: Benutzer
|
||||
│
|
||||
└─ Mandant: Althaus (Beratungsfirma)
|
||||
└─ Chatbot-Instanz "Management-Tool" → Rolle: Administrator
|
||||
|
|
@ -262,7 +252,7 @@ Shelly arbeitet für ihre eigene Firma "Universe Corp", unterstützt aber auch a
|
|||
**Lösung:**
|
||||
|
||||
```
|
||||
Benutzerin: Shelly (shelly@universe.com)
|
||||
Benutzerin: Shelly ([shelly@universe.com](mailto:shelly@universe.com))
|
||||
│
|
||||
├─ Mandant: Universe Corp (eigene Firma)
|
||||
│ ├─ Rolle: Mandanten-Administrator
|
||||
|
|
@ -270,10 +260,10 @@ Benutzerin: Shelly (shelly@universe.com)
|
|||
│
|
||||
├─ Mandant: Althaus (Kunde als Freelancerin)
|
||||
│ └─ Chatbot-Instanz "Produkt-Support" → Rolle: Administrator
|
||||
│ └─ Chatbot-Instanz "HR-Assistent" → Rolle: Benutzer (nur für einen spezifischen Bot)
|
||||
│ └─ Chatbot-Instanz "HR-Assistent" → Rolle: Benutzer
|
||||
│
|
||||
└─ Mandant: Soha Treuhand (nutzt deren Treuhand-Service)
|
||||
└─ Treuhand-Instanz "Universe Corp 2025" → Rolle: Kunde
|
||||
└─ Treuhand-Instanz "Universe Corp 2025" → Rolle: Benutzer
|
||||
|
||||
```
|
||||
|
||||
|
|
@ -287,34 +277,41 @@ Benutzerin: Shelly (shelly@universe.com)
|
|||
### 5.3 Use Case 3: Treuhandbüro mit vielen Kunden
|
||||
|
||||
**Situation:**
|
||||
Das Treuhandbüro "Soha Treuhand" betreut 50 kleine Firmen. Jede Firma soll nur ihre eigenen Buchhaltungsdaten sehen.
|
||||
Das Treuhandbüro "Soha Treuhand" betreut mehrere Firmen. Jede Firma soll nur ihre eigenen Buchhaltungsdaten sehen.
|
||||
|
||||
**Lösung:**
|
||||
**Flexibilität bei der Instanz-Strukturierung:**
|
||||
Das Treuhandbüro kann frei wählen, wie es die Feature-Instanzen ("Buchhaltungsinstanzen") strukturiert:
|
||||
|
||||
- **Option A:** Eine Buchhaltungsinstanz pro Firma (z.B. "PamoCreate AG 2025")
|
||||
- **Option B:** Eine Buchhaltungsinstanz pro Jahr mit mehreren Firmen (z.B. "Jahresbuchhaltung 2025")
|
||||
|
||||
**Lösung (Option A – empfohlen für Datenisolation):**
|
||||
|
||||
```
|
||||
Mandant: Soha Treuhand
|
||||
│
|
||||
├─ Feature: Treuhand (aktiviert)
|
||||
│
|
||||
├─ Feature-Instanz: "Buchhaltung 2025"
|
||||
│ │
|
||||
│ ├─ Mitarbeiter "Anna" → Rolle: Administrator (sieht alle Kunden)
|
||||
│ ├─ Mitarbeiter "Tom" → Rolle: Benutzer (sieht alle Kunden)
|
||||
│ │
|
||||
│ ├─ Kunde "Patrick (PamoCreate AG)" → Rolle: Kunde (sieht nur PamoCreate)
|
||||
│ ├─ Kunde "Shelly (Universe Corp)" → Rolle: Kunde (sieht nur Universe)
|
||||
│ └─ ... 48 weitere Kunden ...
|
||||
├─ Buchhaltungsinstanz: "PamoCreate AG 2025"
|
||||
│ ├─ Mitarbeiter "Anna" → Rolle: Administrator
|
||||
│ ├─ Patrick (Inhaber) → Rolle: Benutzer
|
||||
│ └─ Lisa (Mitarbeiterin) → Rolle: Benutzer
|
||||
│
|
||||
├─ Buchhaltungsinstanz: "Universe Corp 2025"
|
||||
│ ├─ Mitarbeiter "Tom" → Rolle: Administrator
|
||||
│ └─ Shelly (Inhaberin) → Rolle: Benutzer
|
||||
│
|
||||
└─ ... weitere Buchhaltungsinstanzen ...
|
||||
|
||||
```
|
||||
|
||||
**Datenzugriff:**
|
||||
**Datenzugriff (Option A):**
|
||||
|
||||
- **Anna** (Administrator): Sieht alle 50 Firmen und deren Daten
|
||||
- **Tom** (Benutzer): Sieht alle 50 Firmen und deren Daten
|
||||
- **Patrick** (Kunde): Sieht **nur** Daten von PamoCreate AG
|
||||
- **Shelly** (Kunde): Sieht **nur** Daten von Universe Corp
|
||||
- **Anna** (Administrator): Sieht alle Daten der Instanz "PamoCreate AG 2025"
|
||||
- **Patrick & Lisa** (Benutzer): Sehen alle Daten ihrer gemeinsamen Buchhaltungsinstanz (Scope "g")
|
||||
- **Shelly** (Benutzer): Sieht nur Daten von "Universe Corp 2025" – keine Einsicht in andere Instanzen
|
||||
|
||||
**Technische Umsetzung:** Automatische Filterung basierend auf "Eigentümer"-Feld in der Datenbank.
|
||||
**Vorteil:** Mehrere Mitarbeiter einer Firma können in der **gleichen Buchhaltungsinstanz** arbeiten und sehen automatisch die gemeinsamen Firmendaten.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -338,8 +335,8 @@ Für jede Rolle in einer Feature-Instanz wird der **Datenzugriff** definiert:
|
|||
|
||||
| Scope | Name | Beschreibung | Beispiel |
|
||||
| --- | --- | --- | --- |
|
||||
| **n** | None | Kein Zugriff | Kunde sieht keine Admin-Einstellungen |
|
||||
| **m** | My | Nur eigene Daten | Kunde sieht nur eigene Rechnungen |
|
||||
| **n** | None | Kein Zugriff | Benutzer sieht keine Admin-Einstellungen |
|
||||
| **m** | My | Nur eigene Daten | Benutzer (Scope m) sieht nur eigene Rechnungen |
|
||||
| **g** | Group | Alle Daten der Instanz | Mitarbeiter sieht alle Kunden der Instanz |
|
||||
| **a** | All | Alle Daten des Features | Super-Admin sieht alle Instanzen |
|
||||
|
||||
|
|
@ -349,16 +346,20 @@ Für jede Rolle in einer Feature-Instanz wird der **Datenzugriff** definiert:
|
|||
Feature: Chatbot
|
||||
Instanz: "Produkt-Support"
|
||||
|
||||
Rolle "Kunde":
|
||||
Rolle "Benutzer (eingeschränkt)":
|
||||
- Konversationen: Scope "m" (my) → Nur eigene Chats
|
||||
- Connector-Daten: Scope "n" (none) → Kein Zugriff auf gemeinsame Daten
|
||||
- Einstellungen: Scope "n" (none) → Kein Zugriff
|
||||
|
||||
Rolle "Benutzer":
|
||||
- Konversationen: Scope "g" (group) → Alle Chats der Instanz
|
||||
- Konversationen: Scope "m" (my) → Nur eigene Chats (Privacy by Default)
|
||||
- Connector-Daten: Scope "g" (group) → Zugriff auf gemeinsame Datenquellen
|
||||
- Einstellungen: Scope "n" (none) → Kein Zugriff
|
||||
- Hinweis: Chat-Freigabe für andere nur durch expliziten User-Consent (RBAC-Regel)
|
||||
|
||||
Rolle "Administrator":
|
||||
- Konversationen: Scope "g" (group) → Alle Chats der Instanz
|
||||
- Konversationen: Scope "g" (group) → Alle Chats der Instanz (für Support)
|
||||
- Connector-Daten: Scope "g" (group) → Voller Zugriff
|
||||
- Einstellungen: Scope "g" (group) → Voller Zugriff auf Einstellungen
|
||||
|
||||
```
|
||||
|
|
@ -383,11 +384,11 @@ Zusätzlich zur Tabellen-Ebene können einzelne **Datenfelder** geschützt werde
|
|||
**Was sind Policies?**
|
||||
Policies sind **konfigurierbare Regelsets**, die definieren, wer auf welche Daten zugreifen kann.
|
||||
|
||||
**Beispiel-Policy: "Chatbot Kunde"**
|
||||
**Beispiel-Policy: "Chatbot Benutzer (Scope m)"**
|
||||
|
||||
```
|
||||
Policy-Name: chatbot_customer_policy
|
||||
Gilt für: Rolle "Kunde" im Feature "Chatbot"
|
||||
Policy-Name: chatbot_user_scope_m_policy
|
||||
Gilt für: Rolle "Benutzer" mit Scope "m" im Feature "Chatbot"
|
||||
|
||||
Regeln:
|
||||
1. Tabelle "Konversationen" → Lesen erlaubt (Scope: my)
|
||||
|
|
@ -429,6 +430,13 @@ Regeln:
|
|||
|
||||
```
|
||||
|
||||
<aside>
|
||||
🚀
|
||||
|
||||
**Roadmap (Phase 2):** Langfristig können Benutzer selbstständig neue Mandanten anlegen – mit direkter Anbindung an Zahlungssystem und verschiedene Lizenzmodelle. Dies ermöglicht volle Skalierbarkeit als "Selbstläufer".
|
||||
|
||||
</aside>
|
||||
|
||||
### 7.2 Neuer Benutzer wird eingeladen (Self-Service)
|
||||
|
||||
```
|
||||
|
|
@ -458,13 +466,20 @@ Regeln:
|
|||
|
||||
```
|
||||
1. System-Administrator weist Shelly zu neuem Mandanten zu
|
||||
└─ User: shelly@universe.com (besteht bereits)
|
||||
└─ User:
|
||||
```
|
||||
|
||||
**Code-Block 7.3 (NEU):**
|
||||
|
||||
```
|
||||
1. System-Administrator weist Shelly zu neuem Mandanten zu
|
||||
└─ User: [shelly@universe.com](mailto:shelly@universe.com) (besteht bereits)
|
||||
└─ Neuer Mandant: "Soha Treuhand"
|
||||
└─ Rolle: "Standard-Benutzer"
|
||||
|
||||
2. Mandanten-Admin von "Soha Treuhand" vergibt Feature-Rolle
|
||||
└─ Feature-Instanz: "Buchhaltung 2025"
|
||||
└─ Rolle: "Kunde"
|
||||
└─ Rolle: "Benutzer"
|
||||
|
||||
3. Shelly erhält Email-Benachrichtigung
|
||||
|
||||
|
|
@ -533,7 +548,7 @@ Regeln:
|
|||
|
||||
### F: Kann ein Benutzer wirklich mit einem Account in mehreren Mandanten arbeiten?
|
||||
|
||||
**A:** Ja, das ist ein Kernfeature. Ein Benutzer hat einen globalen Account und kann in beliebig vielen Mandanten Mitglied sein. In jedem Mandanten kann er unterschiedliche Rollen haben.
|
||||
**A:** Ja, das ist ein Kernfeature. Ein Benutzer hat einen globalen Account und kann in beliebig vielen Mandanten Mitglied sein. In jedem Mandanten kann er unterschiedliche Rollen haben. Ein User kann auch mit 0 Mandanten starten, dann ist er einfach registriert im System.
|
||||
|
||||
### F: Was passiert, wenn zwei Mandanten den gleichen Benutzer einladen wollen?
|
||||
|
||||
|
|
@ -592,6 +607,51 @@ Ein Benutzer kann beides gleichzeitig sein.
|
|||
4. Benutzer, die nur in diesem Mandanten waren, verlieren Zugriff
|
||||
5. Benutzer, die in anderen Mandanten sind, behalten diese Zugriffe
|
||||
|
||||
### F: Gibt es "Einzellizenzen" – kann ich mich ohne Mandant registrieren?
|
||||
|
||||
**A:** Ja, das ist ein Kernfeature. Ein Benutzer hat grundsätzlich **keinen** Mandanten zugeordnet – er kann in beliebig viele Mandanten eingeladen werden (auch 0). Das Prinzip:
|
||||
|
||||
- **Globaler Account:** Benutzer registriert sich auf der Plattform (ohne Mandantenzuordnung)
|
||||
- **Einladung:** Benutzer wird von beliebigen Mandanten eingeladen und erhält dort Rollen
|
||||
- **Flexibilität:** Ein Benutzer kann mit 0, 1 oder vielen Mandanten arbeiten
|
||||
|
||||
Dies entspricht dem Discord-Prinzip: Ein Discord-Account existiert unabhängig von Servern – man kann Mitglied in 0 bis n Servern sein.
|
||||
|
||||
### F: Ermöglicht das Mandantenmanagement später eine Chat-Funktion zwischen Benutzern?
|
||||
|
||||
**A:** Ja, das ist eine mögliche Erweiterung. Das strukturierte Mandantenmanagement schafft die Grundlage für:
|
||||
|
||||
- **Intra-Mandant-Kommunikation:** Chat zwischen Benutzern innerhalb eines Mandanten
|
||||
- **Feature-Instanz-Kommunikation:** Austausch innerhalb einer Feature-Instanz (z.B. Team-Chat im Chatbot-Support)
|
||||
- **Cross-Mandant-Messaging:** Optional für Benutzer, die in mehreren Mandanten aktiv sind
|
||||
|
||||
Dies ist als Roadmap-Feature für spätere Phasen vorgesehen.
|
||||
|
||||
### F: Wo finde ich eine detaillierte Liste aller Rollen mit Hierarchien und Berechtigungen?
|
||||
|
||||
**A:** Eine vollständige Berechtigungsmatrix wird vor der Implementierung erstellt. Diese enthält:
|
||||
|
||||
- Alle Rollen (System-Admin, Mandanten-Admin, Feature-Admin, Benutzer, Betrachter)
|
||||
- Hierarchie-Ebenen und Vererbung
|
||||
- Konkrete Berechtigungen pro Datentabelle und Feld
|
||||
- Scope-Definitionen (n/m/g/a) pro Rolle und Feature
|
||||
|
||||
**Hinweis:** Kapitel 4 (Benutzerrollen) und 6.2 (n/m/g/a-System) bieten bereits einen Überblick. Die detaillierte Matrix wird als separates Dokument vor dem Implementierungsstart finalisiert.
|
||||
|
||||
### F: Gibt es ein gutes Praxisbeispiel für dieses Konzept?
|
||||
|
||||
**A:** Ja, **Discord** ist ein hervorragendes Beispiel für exakt dieses Mandantenmanagement:
|
||||
|
||||
| Unser Konzept | Discord-Äquivalent |
|
||||
| --- | --- |
|
||||
| Benutzer-Account (global) | Discord-Account |
|
||||
| Mandant | Discord-Server |
|
||||
| Feature-Instanz | Channel im Server |
|
||||
| Mandanten-Admin | Server-Owner/Admin |
|
||||
| Einladungslink | Server-Einladungslink |
|
||||
|
||||
Bei Discord kann ein Benutzer mit einem Account in beliebig vielen Servern Mitglied sein – mit unterschiedlichen Rollen pro Server. Genau dieses Prinzip setzen wir um.
|
||||
|
||||
---
|
||||
|
||||
## 10. Glossar
|
||||
|
|
@ -606,4 +666,4 @@ Ein Benutzer kann beides gleichzeitig sein.
|
|||
| **Policy** | Konfigurierbare Berechtigungsregel |
|
||||
| **Scope (n/m/g/a)** | Datenzugriffs-Bereich (None/My/Group/All) |
|
||||
| **Isolation** | Strikte Trennung zwischen Mandanten |
|
||||
| **Onboarding** | Prozess der Neukundenaufnahm |
|
||||
| **Onboarding** | Prozess der Neukundenaufnahme |
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,880 +0,0 @@
|
|||
# Multi-Tenant UI Konzept - Frontend Nyla
|
||||
|
||||
## Architektur für mandantenunabhängige Benutzer
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 14. Januar 2026
|
||||
**Status:** Entwurf
|
||||
**Frontend:** Nyla (React)
|
||||
|
||||
---
|
||||
|
||||
## 1. Grundprinzip
|
||||
|
||||
### 1.1 User ohne Mandanten-Zugehörigkeit
|
||||
|
||||
Ein User gehört **keinem Mandanten** an. Er sieht:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ User: patrick@example.com │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ SYSTEM (immer verfügbar) │ │
|
||||
│ │ • Profil bearbeiten │ │
|
||||
│ │ • Einstellungen │ │
|
||||
│ │ • Abmelden │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ FEATURE: Trustee │ │
|
||||
│ │ ├─ Instanz: "Soha Treuhand / PamoCreate AG" │ │
|
||||
│ │ ├─ Instanz: "Soha Treuhand / ValueOn AG" │ │
|
||||
│ │ └─ Instanz: "SwissTreu / Firma X" │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ FEATURE: Chatbot │ │
|
||||
│ │ └─ Instanz: "Althaus / Management-Tool" │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 Keine mandateId im Frontend
|
||||
|
||||
**Alt:** Frontend speicherte `mandateId` im State und sendete bei jedem Request.
|
||||
|
||||
**Neu:** Frontend kennt nur **Feature-Instanzen**. Der Mandant ergibt sich aus der Instanz.
|
||||
|
||||
```typescript
|
||||
// ALT
|
||||
const currentMandateId = useStore(state => state.mandateId);
|
||||
await api.get('/trustee/contracts', { params: { mandateId: currentMandateId }});
|
||||
|
||||
// NEU
|
||||
const currentInstance = useStore(state => state.currentFeatureInstance);
|
||||
await api.get('/trustee/contracts', { params: { instanceId: currentInstance.id }});
|
||||
// Backend ermittelt mandateId aus der Instanz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Feature-Objekt-Struktur
|
||||
|
||||
### 2.1 Feature als UI-Gruppe
|
||||
|
||||
Jedes Feature ist ein **Objekt**, das im UI als Gruppe organisiert ist:
|
||||
|
||||
```typescript
|
||||
interface Feature {
|
||||
code: string; // "trustee", "chatbot", "crm"
|
||||
label: I18nLabel; // { en: "Trustee", de: "Treuhand" }
|
||||
icon: string; // Material Icon Name
|
||||
instances: FeatureInstance[];
|
||||
}
|
||||
|
||||
interface FeatureInstance {
|
||||
id: string; // UUID der Instanz
|
||||
featureCode: string; // "trustee"
|
||||
mandateId: string; // Referenz (für Backend)
|
||||
mandateName: string; // "Soha Treuhand" (für Anzeige)
|
||||
instanceLabel: string; // "PamoCreate AG" (spezifischer Name)
|
||||
userRole: string; // Rolle des Users in dieser Instanz
|
||||
permissions: InstancePermissions; // Summarische Berechtigungen
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Generisches Instanz-Handling
|
||||
|
||||
Das Handling für Feature-Instanzen ist **generisch**. Die aktuelle Instanz ergibt sich aus der **URL** (Route-Parameter):
|
||||
|
||||
```typescript
|
||||
// stores/featureStore.ts
|
||||
|
||||
interface FeatureState {
|
||||
features: Feature[];
|
||||
|
||||
// Actions
|
||||
loadFeatures: () => Promise<void>;
|
||||
getInstanceById: (instanceId: string) => FeatureInstance | undefined;
|
||||
getFeatureByCode: (featureCode: string) => Feature | undefined;
|
||||
}
|
||||
|
||||
const useFeatureStore = create<FeatureState>((set, get) => ({
|
||||
features: [],
|
||||
|
||||
loadFeatures: async () => {
|
||||
// Ein API-Call lädt alle Features + Instanzen + Permissions
|
||||
const response = await api.get('/features/my');
|
||||
set({ features: response.data });
|
||||
},
|
||||
|
||||
getInstanceById: (instanceId) => {
|
||||
return get().features
|
||||
.flatMap(f => f.instances)
|
||||
.find(i => i.id === instanceId);
|
||||
},
|
||||
|
||||
getFeatureByCode: (featureCode) => {
|
||||
return get().features.find(f => f.code === featureCode);
|
||||
}
|
||||
}));
|
||||
```
|
||||
|
||||
### 2.3 Instanz aus URL-Parameter
|
||||
|
||||
Die aktuelle Instanz wird **nicht** im Store gespeichert, sondern aus der URL gelesen:
|
||||
|
||||
```typescript
|
||||
// hooks/useCurrentInstance.ts
|
||||
|
||||
export function useCurrentInstance(): FeatureInstance | undefined {
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
const getInstanceById = useFeatureStore(s => s.getInstanceById);
|
||||
|
||||
return instanceId ? getInstanceById(instanceId) : undefined;
|
||||
}
|
||||
|
||||
// Verwendung in Komponenten:
|
||||
function ContractList() {
|
||||
const instance = useCurrentInstance();
|
||||
|
||||
if (!instance) {
|
||||
return <Navigate to="/" />; // Keine Instanz in URL
|
||||
}
|
||||
|
||||
// Arbeite mit instance.permissions, instance.mandateId, etc.
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Berechtigungs-Abfrage (Summarisch)
|
||||
|
||||
### 3.1 Problem: Rate Limits
|
||||
|
||||
**Vermeiden:**
|
||||
```typescript
|
||||
// SCHLECHT: Ein Request pro Objekt/Feld
|
||||
for (const contract of contracts) {
|
||||
const canEdit = await api.get(`/rbac/check?table=Contract&id=${contract.id}&action=update`);
|
||||
const canDelete = await api.get(`/rbac/check?table=Contract&id=${contract.id}&action=delete`);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Lösung: Summarische Berechtigungen
|
||||
|
||||
Berechtigungen werden **einmalig pro Feature-Instanz** geladen:
|
||||
|
||||
```typescript
|
||||
interface InstancePermissions {
|
||||
// Tabellen-Level (CRUD pro Tabelle)
|
||||
tables: {
|
||||
[tableName: string]: {
|
||||
view: boolean;
|
||||
read: AccessLevel; // "n" | "m" | "g" | "a"
|
||||
create: AccessLevel;
|
||||
update: AccessLevel;
|
||||
delete: AccessLevel;
|
||||
}
|
||||
};
|
||||
|
||||
// Feld-Level (nur wo eingeschränkt)
|
||||
fields?: {
|
||||
[tableName: string]: {
|
||||
[fieldName: string]: {
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// View-Level (Navigation)
|
||||
views: {
|
||||
[viewCode: string]: boolean;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 API-Endpunkt für Permissions
|
||||
|
||||
```typescript
|
||||
// GET /features/my
|
||||
// Lädt alles in einem Request
|
||||
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"code": "trustee",
|
||||
"label": { "en": "Trustee", "de": "Treuhand" },
|
||||
"instances": [
|
||||
{
|
||||
"id": "inst-123",
|
||||
"mandateId": "mand-456",
|
||||
"mandateName": "Soha Treuhand",
|
||||
"instanceLabel": "PamoCreate AG",
|
||||
"userRole": "customer",
|
||||
"permissions": {
|
||||
"tables": {
|
||||
"TrusteeOrganisation": { "view": true, "read": "m", "create": "n", "update": "m", "delete": "n" },
|
||||
"TrusteeContract": { "view": true, "read": "m", "create": "n", "update": "n", "delete": "n" },
|
||||
"TrusteeDocument": { "view": true, "read": "m", "create": "m", "update": "m", "delete": "n" }
|
||||
},
|
||||
"views": {
|
||||
"trustee-dashboard": true,
|
||||
"trustee-contracts": true,
|
||||
"trustee-admin": false
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Permission-Hooks im Frontend
|
||||
|
||||
```typescript
|
||||
// hooks/usePermissions.ts
|
||||
|
||||
export function useTablePermission(tableName: string) {
|
||||
const instance = useCurrentInstance(); // Aus URL-Parameter
|
||||
|
||||
if (!instance) {
|
||||
return { view: false, read: 'n', create: 'n', update: 'n', delete: 'n' };
|
||||
}
|
||||
|
||||
return instance.permissions.tables[tableName] ?? {
|
||||
view: false, read: 'n', create: 'n', update: 'n', delete: 'n'
|
||||
};
|
||||
}
|
||||
|
||||
export function useCanView(viewCode: string): boolean {
|
||||
const instance = useCurrentInstance(); // Aus URL-Parameter
|
||||
return instance?.permissions.views[viewCode] ?? false;
|
||||
}
|
||||
|
||||
export function useCanEditRecord(tableName: string, record: any): boolean {
|
||||
const permission = useTablePermission(tableName);
|
||||
const userId = useAuthStore(s => s.user?.id);
|
||||
|
||||
switch (permission.update) {
|
||||
case 'n': return false;
|
||||
case 'm': return record._createdBy === userId;
|
||||
case 'g': return true; // Instanz-Scope
|
||||
case 'a': return true; // Alle
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Navigation & UI-Struktur
|
||||
|
||||
### 4.1 Hauptnavigation
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ [Logo] PowerOn [User] [Logout] │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ SYSTEM │
|
||||
│ ○ Dashboard │
|
||||
│ ○ Profil │
|
||||
│ ○ Einstellungen │
|
||||
│ │
|
||||
│ ▼ TRUSTEE [Feature] │
|
||||
│ │ │
|
||||
│ ├─▼ Soha Treuhand / PamoCreate AG [Instanz] │
|
||||
│ │ ○ Übersicht │
|
||||
│ │ ○ Verträge │
|
||||
│ │ ○ Dokumente │
|
||||
│ │ ○ Positionen │
|
||||
│ │ │
|
||||
│ ├─▼ Soha Treuhand / ValueOn AG [Instanz] │
|
||||
│ │ ○ Übersicht │
|
||||
│ │ ○ Verträge │
|
||||
│ │ ○ Dokumente │
|
||||
│ │ │
|
||||
│ └─▶ SwissTreu / Firma X [Instanz] │
|
||||
│ (collapsed) │
|
||||
│ │
|
||||
│ ▼ CHATBOT [Feature] │
|
||||
│ │ │
|
||||
│ └─▼ Althaus / Management-Tool [Instanz] │
|
||||
│ ○ Conversations │
|
||||
│ ○ Settings │
|
||||
│ │
|
||||
│ ───────────────────────────────────── │
|
||||
│ ADMIN (nur wenn sysadmin) │
|
||||
│ ○ Mandanten │
|
||||
│ ○ Users │
|
||||
│ ○ RBAC │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Hierarchie:**
|
||||
```
|
||||
Feature (Gruppe)
|
||||
└─ Instanz (Subgruppe)
|
||||
└─ Objekte/Views (Navigation Items)
|
||||
```
|
||||
```
|
||||
|
||||
### 4.2 Feature-Gruppe mit Instanz-Subgruppen
|
||||
|
||||
Jedes Feature zeigt **alle Instanzen als Subgruppen** an:
|
||||
|
||||
```tsx
|
||||
// components/FeatureNavGroup.tsx
|
||||
|
||||
function FeatureNavGroup({ featureCode }: { featureCode: string }) {
|
||||
const { features } = useFeatureStore();
|
||||
const feature = features.find(f => f.code === featureCode);
|
||||
|
||||
if (!feature || feature.instances.length === 0) {
|
||||
return null; // Feature nicht anzeigen wenn keine Instanzen
|
||||
}
|
||||
|
||||
return (
|
||||
<NavGroup>
|
||||
{/* Feature als Hauptgruppe */}
|
||||
<NavGroupHeader collapsible>
|
||||
<Icon name={feature.icon} />
|
||||
<span>{feature.label.de}</span>
|
||||
</NavGroupHeader>
|
||||
|
||||
{/* Jede Instanz als Subgruppe */}
|
||||
{feature.instances.map(instance => (
|
||||
<InstanceNavSubgroup
|
||||
key={instance.id}
|
||||
instance={instance}
|
||||
featureCode={featureCode}
|
||||
/>
|
||||
))}
|
||||
</NavGroup>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Instanz-Subgruppe Komponente
|
||||
|
||||
```tsx
|
||||
// components/InstanceNavSubgroup.tsx
|
||||
|
||||
function InstanceNavSubgroup({ instance, featureCode }: {
|
||||
instance: FeatureInstance;
|
||||
featureCode: string;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<NavSubgroup>
|
||||
{/* Instanz als Subgruppen-Header */}
|
||||
<NavSubgroupHeader
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
collapsible
|
||||
>
|
||||
<Icon name={isExpanded ? 'expand_more' : 'chevron_right'} />
|
||||
<span>{instance.mandateName} / {instance.instanceLabel}</span>
|
||||
<RoleBadge role={instance.userRole} />
|
||||
</NavSubgroupHeader>
|
||||
|
||||
{/* Objekte/Views der Instanz */}
|
||||
{isExpanded && (
|
||||
<NavSubgroupItems>
|
||||
{instance.permissions.views[`${featureCode}-dashboard`] && (
|
||||
<NavItem to={`/${featureCode}/${instance.id}/dashboard`}>
|
||||
Übersicht
|
||||
</NavItem>
|
||||
)}
|
||||
{instance.permissions.views[`${featureCode}-contracts`] && (
|
||||
<NavItem to={`/${featureCode}/${instance.id}/contracts`}>
|
||||
Verträge
|
||||
</NavItem>
|
||||
)}
|
||||
{instance.permissions.views[`${featureCode}-documents`] && (
|
||||
<NavItem to={`/${featureCode}/${instance.id}/documents`}>
|
||||
Dokumente
|
||||
</NavItem>
|
||||
)}
|
||||
{instance.permissions.views[`${featureCode}-positions`] && (
|
||||
<NavItem to={`/${featureCode}/${instance.id}/positions`}>
|
||||
Positionen
|
||||
</NavItem>
|
||||
)}
|
||||
</NavSubgroupItems>
|
||||
)}
|
||||
</NavSubgroup>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 URL-Struktur mit Instanz-ID
|
||||
|
||||
Die URL enthält immer die **Instanz-ID**, damit klar ist, in welcher Instanz gearbeitet wird:
|
||||
|
||||
```
|
||||
/trustee/{instanceId}/dashboard
|
||||
/trustee/{instanceId}/contracts
|
||||
/trustee/{instanceId}/contracts/{contractId}
|
||||
/trustee/{instanceId}/documents
|
||||
|
||||
/chatbot/{instanceId}/conversations
|
||||
/chatbot/{instanceId}/settings
|
||||
```
|
||||
|
||||
**Router-Setup:**
|
||||
```tsx
|
||||
// routes.tsx
|
||||
|
||||
<Route path="/:featureCode/:instanceId/*" element={<FeatureLayout />}>
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="contracts" element={<ContractList />} />
|
||||
<Route path="contracts/:contractId" element={<ContractDetail />} />
|
||||
<Route path="documents" element={<DocumentList />} />
|
||||
{/* ... */}
|
||||
</Route>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. System-Bereich (Ohne Mandant)
|
||||
|
||||
### 5.1 Immer verfügbare Funktionen
|
||||
|
||||
User ohne Mandant-Zugehörigkeit können:
|
||||
|
||||
```typescript
|
||||
const SYSTEM_FEATURES = {
|
||||
// Immer verfügbar
|
||||
profile: true,
|
||||
settings: true,
|
||||
logout: true,
|
||||
|
||||
// Nur für sysadmin
|
||||
adminMandates: (user) => user.isSysAdmin,
|
||||
adminUsers: (user) => user.isSysAdmin,
|
||||
adminRbac: (user) => user.isSysAdmin,
|
||||
};
|
||||
```
|
||||
|
||||
### 5.2 Dashboard für User ohne Instanzen
|
||||
|
||||
```tsx
|
||||
// pages/Dashboard.tsx
|
||||
|
||||
function Dashboard() {
|
||||
const { features } = useFeatureStore();
|
||||
const hasInstances = features.some(f => f.instances.length > 0);
|
||||
|
||||
if (!hasInstances) {
|
||||
return (
|
||||
<WelcomeScreen>
|
||||
<h1>Willkommen bei PowerOn</h1>
|
||||
<p>Du hast aktuell Zugriff auf keine Feature-Instanzen.</p>
|
||||
<p>Kontaktiere einen Administrator, um Zugriff zu erhalten.</p>
|
||||
|
||||
<Card>
|
||||
<h3>Was du jetzt tun kannst:</h3>
|
||||
<ul>
|
||||
<li><Link to="/profile">Profil bearbeiten</Link></li>
|
||||
<li><Link to="/settings">Einstellungen anpassen</Link></li>
|
||||
</ul>
|
||||
</Card>
|
||||
</WelcomeScreen>
|
||||
);
|
||||
}
|
||||
|
||||
return <FeatureOverviewDashboard features={features} />;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Daten-Requests mit Instanz-Kontext
|
||||
|
||||
### 6.1 API-Client mit Instanz-Parameter
|
||||
|
||||
Da die Instanz-ID in der URL steht, wird sie **explizit** an API-Calls übergeben:
|
||||
|
||||
```typescript
|
||||
// services/api.ts
|
||||
|
||||
const api = axios.create({ baseURL: '/api' });
|
||||
|
||||
// Kein Interceptor nötig - Instanz-ID kommt aus URL und wird explizit übergeben
|
||||
```
|
||||
|
||||
### 6.2 Feature-spezifische Queries
|
||||
|
||||
```typescript
|
||||
// hooks/useTrusteeContracts.ts
|
||||
|
||||
export function useTrusteeContracts() {
|
||||
const instance = useCurrentInstance(); // Aus URL: /trustee/:instanceId/contracts
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['trustee', 'contracts', instance?.id],
|
||||
queryFn: async () => {
|
||||
if (!instance) throw new Error('No instance in URL');
|
||||
|
||||
// Instanz-ID explizit im Request
|
||||
const response = await api.get(`/trustee/${instance.id}/contracts`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!instance && instance.featureCode === 'trustee'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Berechtigungs-gesteuerte UI-Elemente
|
||||
|
||||
### 7.1 Conditional Rendering
|
||||
|
||||
```tsx
|
||||
// components/ContractList.tsx
|
||||
|
||||
function ContractList() {
|
||||
const { data: contracts } = useTrusteeContracts();
|
||||
const tablePermission = useTablePermission('TrusteeContract');
|
||||
const userId = useAuthStore(s => s.user?.id);
|
||||
|
||||
const canCreate = tablePermission.create !== 'n';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header>
|
||||
<h2>Verträge</h2>
|
||||
{canCreate && (
|
||||
<Button onClick={openCreateDialog}>Neuer Vertrag</Button>
|
||||
)}
|
||||
</Header>
|
||||
|
||||
<Table>
|
||||
{contracts?.map(contract => {
|
||||
const canEdit = canEditRecord(tablePermission.update, contract, userId);
|
||||
const canDelete = canEditRecord(tablePermission.delete, contract, userId);
|
||||
|
||||
return (
|
||||
<TableRow key={contract.id}>
|
||||
<TableCell>{contract.name}</TableCell>
|
||||
<TableCell>
|
||||
{canEdit && <IconButton icon="edit" onClick={() => editContract(contract)} />}
|
||||
{canDelete && <IconButton icon="delete" onClick={() => deleteContract(contract)} />}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function canEditRecord(accessLevel: AccessLevel, record: any, userId: string): boolean {
|
||||
switch (accessLevel) {
|
||||
case 'n': return false;
|
||||
case 'm': return record._createdBy === userId;
|
||||
case 'g':
|
||||
case 'a': return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Permission-Wrapper Komponente
|
||||
|
||||
```tsx
|
||||
// components/PermissionGate.tsx
|
||||
|
||||
interface PermissionGateProps {
|
||||
table: string;
|
||||
action: 'view' | 'read' | 'create' | 'update' | 'delete';
|
||||
record?: any; // Für "m" (my) Prüfung
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
function PermissionGate({ table, action, record, children, fallback = null }: PermissionGateProps) {
|
||||
const permission = useTablePermission(table);
|
||||
const userId = useAuthStore(s => s.user?.id);
|
||||
|
||||
let hasPermission = false;
|
||||
|
||||
if (action === 'view') {
|
||||
hasPermission = permission.view;
|
||||
} else {
|
||||
const level = permission[action];
|
||||
if (level === 'n') {
|
||||
hasPermission = false;
|
||||
} else if (level === 'm' && record) {
|
||||
hasPermission = record._createdBy === userId;
|
||||
} else if (level === 'g' || level === 'a') {
|
||||
hasPermission = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasPermission ? <>{children}</> : <>{fallback}</>;
|
||||
}
|
||||
|
||||
// Verwendung:
|
||||
<PermissionGate table="TrusteeContract" action="create">
|
||||
<Button>Neuer Vertrag</Button>
|
||||
</PermissionGate>
|
||||
|
||||
<PermissionGate table="TrusteeContract" action="update" record={contract}>
|
||||
<IconButton icon="edit" />
|
||||
</PermissionGate>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Login & Session
|
||||
|
||||
### 8.1 Login-Response (ohne mandateId)
|
||||
|
||||
```typescript
|
||||
// Nach erfolgreichem Login:
|
||||
{
|
||||
"token": "jwt...",
|
||||
"user": {
|
||||
"id": "user-123",
|
||||
"username": "patrick",
|
||||
"email": "patrick@example.com",
|
||||
"isSysAdmin": false
|
||||
},
|
||||
"features": [
|
||||
// Alle Features + Instanzen + Permissions (siehe Abschnitt 3.3)
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 Auth-Store
|
||||
|
||||
```typescript
|
||||
// stores/authStore.ts
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
|
||||
login: (credentials: LoginCredentials) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
login: async (credentials) => {
|
||||
const response = await api.post('/auth/login', credentials);
|
||||
|
||||
// Token speichern
|
||||
set({
|
||||
user: response.data.user,
|
||||
token: response.data.token,
|
||||
isAuthenticated: true
|
||||
});
|
||||
|
||||
// Features laden (separater Store)
|
||||
useFeatureStore.getState().setFeatures(response.data.features);
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
set({ user: null, token: null, isAuthenticated: false });
|
||||
useFeatureStore.getState().reset();
|
||||
}
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Einladungs-Flow
|
||||
|
||||
### 9.1 Einladungs-Link
|
||||
|
||||
```
|
||||
https://app.poweron.ch/invite?token=abc123xyz
|
||||
```
|
||||
|
||||
### 9.2 Einladungs-Seite
|
||||
|
||||
```tsx
|
||||
// pages/Invite.tsx
|
||||
|
||||
function InvitePage() {
|
||||
const token = useSearchParam('token');
|
||||
const [inviteData, setInviteData] = useState<InviteData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Token validieren (ohne Login)
|
||||
api.get(`/invite/validate?token=${token}`)
|
||||
.then(res => setInviteData(res.data))
|
||||
.catch(err => setError(err.response?.data?.detail || 'Ungültiger Link'));
|
||||
}, [token]);
|
||||
|
||||
if (error) {
|
||||
return <ErrorScreen message={error} />;
|
||||
}
|
||||
|
||||
if (!inviteData) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<InviteForm>
|
||||
<h1>Einladung zu {inviteData.mandateName}</h1>
|
||||
<p>Du wurdest eingeladen, als <strong>{inviteData.roleLabel}</strong> mitzuarbeiten.</p>
|
||||
|
||||
{inviteData.existingUser ? (
|
||||
// User existiert bereits - nur Login erforderlich
|
||||
<LoginForm
|
||||
onSuccess={() => acceptInvite(token)}
|
||||
message="Melde dich an, um die Einladung anzunehmen."
|
||||
/>
|
||||
) : (
|
||||
// Neuer User - Registrierung
|
||||
<RegisterForm
|
||||
prefillEmail={inviteData.email}
|
||||
onSuccess={(userId) => acceptInvite(token, userId)}
|
||||
/>
|
||||
)}
|
||||
</InviteForm>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 Einladungs-Verwaltung (Admin)
|
||||
|
||||
```tsx
|
||||
// pages/admin/Invitations.tsx
|
||||
|
||||
function InvitationsAdmin() {
|
||||
const { data: invitations } = useQuery({
|
||||
queryKey: ['admin', 'invitations'],
|
||||
queryFn: () => api.get('/admin/invitations').then(r => r.data)
|
||||
});
|
||||
|
||||
const revokeInvite = useMutation({
|
||||
mutationFn: (token: string) => api.delete(`/admin/invitations/${token}`),
|
||||
onSuccess: () => queryClient.invalidateQueries(['admin', 'invitations'])
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Ausstehende Einladungen</h2>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableColumn>Email</TableColumn>
|
||||
<TableColumn>Mandant</TableColumn>
|
||||
<TableColumn>Rolle</TableColumn>
|
||||
<TableColumn>Erstellt</TableColumn>
|
||||
<TableColumn>Gültig bis</TableColumn>
|
||||
<TableColumn>Aktionen</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invitations?.map(inv => (
|
||||
<TableRow key={inv.token}>
|
||||
<TableCell>{inv.email}</TableCell>
|
||||
<TableCell>{inv.mandateName}</TableCell>
|
||||
<TableCell>{inv.roleLabel}</TableCell>
|
||||
<TableCell>{formatDate(inv.createdAt)}</TableCell>
|
||||
<TableCell>{formatDate(inv.expiresAt)}</TableCell>
|
||||
<TableCell>
|
||||
<IconButton
|
||||
icon="delete"
|
||||
onClick={() => revokeInvite.mutate(inv.token)}
|
||||
title="Einladung widerrufen"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Zusammenfassung
|
||||
|
||||
### 10.1 Wichtige Prinzipien
|
||||
|
||||
| Prinzip | Umsetzung |
|
||||
|---------|-----------|
|
||||
| **Kein mandateId** | User gehört keinem Mandanten, arbeitet in Feature-Instanzen |
|
||||
| **Summarische Permissions** | Ein Request lädt alle Berechtigungen pro Instanz |
|
||||
| **Generisches Feature-Handling** | Alle Features haben gleiche Struktur |
|
||||
| **Instanz aus URL** | `/feature/{instanceId}/...` - Instanz-ID immer in URL |
|
||||
| **Navigation = Hierarchie** | Feature → Instanz (Subgruppe) → Objekte |
|
||||
| **System-Features immer verfügbar** | Profil, Settings, Logout ohne Instanz |
|
||||
|
||||
### 10.2 State-Architektur
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Frontend State │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ AuthStore │
|
||||
│ ├─ user: User │
|
||||
│ ├─ token: string │
|
||||
│ └─ isAuthenticated: boolean │
|
||||
│ │
|
||||
│ FeatureStore │
|
||||
│ ├─ features: Feature[] │
|
||||
│ │ └─ instances: FeatureInstance[] │
|
||||
│ │ └─ permissions: InstancePermissions │
|
||||
│ └─ getInstanceById(id): FeatureInstance │
|
||||
│ │
|
||||
│ Router (URL) │
|
||||
│ └─ /:featureCode/:instanceId/* → aktuelle Instanz │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 10.3 API-Endpunkte (Frontend-relevant)
|
||||
|
||||
| Endpunkt | Beschreibung |
|
||||
|----------|--------------|
|
||||
| `GET /features/my` | Alle Features + Instanzen + Permissions |
|
||||
| `GET /invite/validate?token=X` | Einladung validieren |
|
||||
| `POST /invite/accept` | Einladung annehmen |
|
||||
| `GET /admin/invitations` | Alle Einladungen (Admin) |
|
||||
| `DELETE /admin/invitations/{token}` | Einladung widerrufen |
|
||||
|
||||
---
|
||||
|
||||
## 11. Migration (Frontend)
|
||||
|
||||
### 11.1 Zu entfernen
|
||||
|
||||
- `currentMandateId` aus globalem State
|
||||
- `mandateId` aus API-Requests (Query-Params)
|
||||
- Mandanten-Switcher
|
||||
|
||||
### 11.2 Zu ändern
|
||||
|
||||
- **Navigation:** Feature → Instanz-Subgruppen → Objekte (hierarchisch)
|
||||
- **URLs:** Müssen `/:featureCode/:instanceId/...` enthalten
|
||||
- **Permission-Checks:** Auf `useTablePermission` + `useCurrentInstance` umstellen
|
||||
- **API-Calls:** Instanz-ID aus URL-Parameter verwenden
|
||||
|
||||
### 11.3 Neu
|
||||
|
||||
- `FeatureStore` mit allen Instanzen und Permissions
|
||||
- `useCurrentInstance()` Hook (liest aus URL)
|
||||
- `FeatureNavGroup` + `InstanceNavSubgroup` Komponenten
|
||||
- `PermissionGate` Wrapper
|
||||
- Einladungs-Flow UI
|
||||
Loading…
Reference in a new issue