500 lines
19 KiB
Python
500 lines
19 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
||
# All rights reserved.
|
||
"""
|
||
UAM models: User, Mandate, UserConnection.
|
||
|
||
Multi-Tenant Design:
|
||
- User gehört NICHT direkt zu einem Mandanten
|
||
- Zugehörigkeit wird über UserMandate gesteuert (siehe datamodelMembership.py)
|
||
- Zwei orthogonale Plattform-Autoritäts-Flags:
|
||
* isSysAdmin → Infrastruktur-Operator (Logs, Tokens, DB-Health,
|
||
i18n-Master, Registry). RBAC-Engine-Bypass.
|
||
KEIN Cross-Mandate-Governance.
|
||
* isPlatformAdmin → Cross-Mandate-Governance (User-/Mandate-/RBAC-/
|
||
Feature-Verwaltung über alle Mandanten).
|
||
KEIN RBAC-Bypass.
|
||
Beide einzeln vergebbar, einzeln auditierbar.
|
||
Siehe wiki/c-work/4-done/2026-04-sysadmin-authority-split.md
|
||
"""
|
||
|
||
import uuid
|
||
from typing import Optional, List, Dict, Any
|
||
from enum import Enum
|
||
from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
|
||
from modules.datamodels.datamodelBase import PowerOnModel
|
||
from modules.shared.i18nRegistry import i18nModel, normalizePrimaryLanguageTag
|
||
from modules.shared.mandateNameUtils import MANDATE_NAME_MAX_LEN, MANDATE_NAME_MIN_LEN
|
||
from modules.shared.timeUtils import getUtcTimestamp
|
||
|
||
|
||
class AuthAuthority(str, Enum):
|
||
LOCAL = "local"
|
||
GOOGLE = "google"
|
||
MSFT = "msft"
|
||
CLICKUP = "clickup"
|
||
|
||
class ConnectionStatus(str, Enum):
|
||
ACTIVE = "active"
|
||
EXPIRED = "expired"
|
||
REVOKED = "revoked"
|
||
PENDING = "pending"
|
||
|
||
class AccessLevel(str, Enum):
|
||
"""Access level enumeration for RBAC"""
|
||
ALL = "a" # All records
|
||
MY = "m" # My records (created by me)
|
||
GROUP = "g" # Group records (group context is the mandate)
|
||
NONE = "n" # No access
|
||
|
||
class UserPermissions(BaseModel):
|
||
"""User permissions model for RBAC"""
|
||
view: bool = Field(
|
||
default=False,
|
||
description="View permission: if true, item is visible/enabled"
|
||
)
|
||
read: AccessLevel = Field(
|
||
default=AccessLevel.NONE,
|
||
description="Read permission level"
|
||
)
|
||
create: AccessLevel = Field(
|
||
default=AccessLevel.NONE,
|
||
description="Create permission level"
|
||
)
|
||
update: AccessLevel = Field(
|
||
default=AccessLevel.NONE,
|
||
description="Update permission level"
|
||
)
|
||
delete: AccessLevel = Field(
|
||
default=AccessLevel.NONE,
|
||
description="Delete permission level"
|
||
)
|
||
|
||
|
||
@i18nModel("Mandant")
|
||
class Mandate(PowerOnModel):
|
||
"""
|
||
Mandate (Mandant/Tenant) model.
|
||
Ein Mandant ist ein isolierter Bereich für Daten und Berechtigungen.
|
||
|
||
Semantik:
|
||
- ``name`` (Kurzzeichen): plattformweit eindeutiger, stabiler technischer Code (Slug),
|
||
Audit-/Referenz-Identifier. Nur Kleinbuchstaben, Ziffern und ``-`` (Länge 2–32).
|
||
- ``label`` (Voller Name): Anzeigename im UI, frei änderbar unabhängig vom Slug.
|
||
"""
|
||
id: str = Field(
|
||
default_factory=lambda: str(uuid.uuid4()),
|
||
description="Unique ID of the mandate",
|
||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False, "label": "ID"},
|
||
)
|
||
name: str = Field(
|
||
description="Unique stable mandate code (slug); lowercase, digits, hyphen segments only.",
|
||
min_length=MANDATE_NAME_MIN_LEN,
|
||
max_length=MANDATE_NAME_MAX_LEN,
|
||
pattern=r"^[a-z0-9]+(-[a-z0-9]+)*$",
|
||
json_schema_extra={
|
||
"frontend_type": "slug",
|
||
"frontend_readonly": False,
|
||
"frontend_required": True,
|
||
"label": "Kurzzeichen",
|
||
},
|
||
)
|
||
label: str = Field(
|
||
description="Human-readable mandate name shown in the UI (Voller Name).",
|
||
min_length=1,
|
||
json_schema_extra={
|
||
"frontend_type": "text",
|
||
"frontend_readonly": False,
|
||
"frontend_required": True,
|
||
"label": "Voller Name",
|
||
},
|
||
)
|
||
enabled: bool = Field(
|
||
default=True,
|
||
description="Indicates whether the mandate is enabled",
|
||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "Aktiviert"},
|
||
)
|
||
isSystem: bool = Field(
|
||
default=False,
|
||
description="Whether this is a system mandate (e.g. root mandate). Cannot be deleted.",
|
||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False, "label": "System-Mandant"},
|
||
)
|
||
deletedAt: Optional[float] = Field(
|
||
default=None,
|
||
description="Timestamp when the mandate was soft-deleted. After 30 days, hard-delete is triggered.",
|
||
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Gelöscht am"},
|
||
)
|
||
|
||
@field_validator('isSystem', mode='before')
|
||
@classmethod
|
||
def _coerceIsSystem(cls, v):
|
||
"""Coerce None to False (for existing DB records without isSystem field)."""
|
||
if v is None:
|
||
return False
|
||
return v
|
||
|
||
@field_validator("name", mode="before")
|
||
@classmethod
|
||
def _stripName(cls, v):
|
||
if v is None:
|
||
return ""
|
||
if isinstance(v, str):
|
||
return v.strip()
|
||
return v
|
||
|
||
@field_validator("label", mode="before")
|
||
@classmethod
|
||
def _coerceLabel(cls, v):
|
||
if v is None:
|
||
return ""
|
||
return v
|
||
|
||
@field_validator("label")
|
||
@classmethod
|
||
def _validateMandateLabel(cls, v: str) -> str:
|
||
s = v.strip()
|
||
if len(s) < 1:
|
||
raise ValueError("Mandate Voller Name (label) must not be empty.")
|
||
return s
|
||
|
||
@i18nModel("Benutzerverbindung")
|
||
class UserConnection(PowerOnModel):
|
||
id: str = Field(
|
||
default_factory=lambda: str(uuid.uuid4()),
|
||
description="Unique ID of the connection",
|
||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
|
||
)
|
||
userId: str = Field(
|
||
description="ID of the user this connection belongs to",
|
||
json_schema_extra={
|
||
"frontend_type": "text",
|
||
"frontend_readonly": True,
|
||
"frontend_required": False,
|
||
"label": "Benutzer-ID",
|
||
"fk_target": {"db": "poweron_app", "table": "User"},
|
||
},
|
||
)
|
||
authority: AuthAuthority = Field(
|
||
description="Authentication authority",
|
||
json_schema_extra={
|
||
"frontend_type": "select",
|
||
"frontend_readonly": True,
|
||
"frontend_required": False,
|
||
"frontend_options": "/api/connections/authorities/options",
|
||
"label": "Autorität",
|
||
},
|
||
)
|
||
externalId: str = Field(
|
||
description="User ID in the external system",
|
||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Externe ID"},
|
||
)
|
||
externalUsername: str = Field(
|
||
description="Username in the external system",
|
||
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False, "label": "Externer Benutzername"},
|
||
)
|
||
externalEmail: Optional[EmailStr] = Field(
|
||
None,
|
||
description="Email in the external system",
|
||
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False, "label": "Externe E-Mail"},
|
||
)
|
||
status: ConnectionStatus = Field(
|
||
default=ConnectionStatus.ACTIVE,
|
||
description="Connection status",
|
||
json_schema_extra={
|
||
"frontend_type": "select",
|
||
"frontend_readonly": False,
|
||
"frontend_required": False,
|
||
"frontend_options": "/api/connections/statuses/options",
|
||
"label": "Status",
|
||
},
|
||
)
|
||
connectedAt: float = Field(
|
||
default_factory=getUtcTimestamp,
|
||
description="When the connection was established (UTC timestamp in seconds)",
|
||
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Verbunden am"},
|
||
)
|
||
lastChecked: float = Field(
|
||
default_factory=getUtcTimestamp,
|
||
description="When the connection was last verified (UTC timestamp in seconds)",
|
||
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Zuletzt geprüft"},
|
||
)
|
||
expiresAt: Optional[float] = Field(
|
||
None,
|
||
description="When the connection expires (UTC timestamp in seconds)",
|
||
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Läuft ab am"},
|
||
)
|
||
tokenStatus: Optional[str] = Field(
|
||
None,
|
||
description="Current token status: active, expired, none",
|
||
json_schema_extra={
|
||
"frontend_type": "select",
|
||
"frontend_readonly": True,
|
||
"frontend_required": False,
|
||
"frontend_options": [
|
||
{"value": "active", "label": "Active"},
|
||
{"value": "expired", "label": "Expired"},
|
||
{"value": "none", "label": "None"},
|
||
],
|
||
"label": "Verbindungsstatus",
|
||
},
|
||
)
|
||
tokenExpiresAt: Optional[float] = Field(
|
||
None,
|
||
description="When the current token expires (UTC timestamp in seconds)",
|
||
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Token läuft ab am"},
|
||
)
|
||
grantedScopes: Optional[List[str]] = Field(
|
||
None,
|
||
description="OAuth scopes granted for this connection",
|
||
json_schema_extra={"frontend_type": "list", "frontend_readonly": True, "frontend_required": False, "label": "Gewährte Berechtigungen"},
|
||
)
|
||
|
||
@computed_field
|
||
@property
|
||
def connectionReference(self) -> str:
|
||
"""Generate connection reference string in format: connection:{authority}:{username}"""
|
||
return f"connection:{self.authority.value}:{self.externalUsername}"
|
||
|
||
@computed_field
|
||
@property
|
||
def displayLabel(self) -> str:
|
||
"""Human-readable label for display in dropdowns"""
|
||
authorityLabels = {
|
||
"msft": "Microsoft",
|
||
"google": "Google",
|
||
"local": "Local",
|
||
"clickup": "ClickUp",
|
||
}
|
||
return f"{authorityLabels.get(self.authority.value, self.authority.value)}: {self.externalUsername}"
|
||
|
||
|
||
@i18nModel("Benutzer")
|
||
class User(PowerOnModel):
|
||
"""
|
||
User model.
|
||
|
||
Multi-Tenant Design:
|
||
- User gehört NICHT direkt zu einem Mandanten
|
||
- Zugehörigkeit wird über UserMandate gesteuert (siehe datamodelMembership.py)
|
||
- Rollen werden über UserMandateRole gesteuert (mandanten-scoped)
|
||
- Plattform-Autorität via zwei orthogonalen Flags:
|
||
* isSysAdmin → Infrastruktur (Bypass der RBAC-Engine, KEIN
|
||
Cross-Mandate-Governance)
|
||
* isPlatformAdmin → Cross-Mandate-Governance (KEIN RBAC-Bypass)
|
||
"""
|
||
id: str = Field(
|
||
default_factory=lambda: str(uuid.uuid4()),
|
||
description="Unique ID of the user",
|
||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False, "label": "ID"},
|
||
)
|
||
username: str = Field(
|
||
description="Username for login (immutable after creation)",
|
||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Benutzername"},
|
||
)
|
||
email: Optional[EmailStr] = Field(
|
||
default=None,
|
||
description="Email address of the user",
|
||
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": True, "label": "E-Mail"},
|
||
)
|
||
fullName: Optional[str] = Field(
|
||
default=None,
|
||
description="Full name of the user",
|
||
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False, "label": "Vollständiger Name"},
|
||
)
|
||
language: str = Field(
|
||
default="de",
|
||
description="Preferred UI language code (must exist as UiLanguageSet).",
|
||
json_schema_extra={
|
||
"frontend_type": "select",
|
||
"frontend_readonly": False,
|
||
"frontend_required": True,
|
||
"frontend_options": "/api/i18n/codes",
|
||
"label": "Sprache",
|
||
},
|
||
)
|
||
|
||
@field_validator('language', mode='before')
|
||
@classmethod
|
||
def _normalizeLanguage(cls, v):
|
||
"""Normalize to primary language subtag (2–8 letters); default remains ``de``."""
|
||
if v is None:
|
||
return "de"
|
||
langMap = {
|
||
'english': 'en', 'englisch': 'en',
|
||
'german': 'de', 'deutsch': 'de',
|
||
'french': 'fr', 'französisch': 'fr', 'francais': 'fr',
|
||
'italian': 'it', 'italienisch': 'it', 'italiano': 'it',
|
||
}
|
||
normalized = str(v).lower().strip()
|
||
if normalized in langMap:
|
||
return langMap[normalized]
|
||
return normalizePrimaryLanguageTag(normalized, "de")
|
||
|
||
enabled: bool = Field(
|
||
default=True,
|
||
description="Indicates whether the user is enabled",
|
||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "Aktiviert"},
|
||
)
|
||
|
||
isSysAdmin: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Infrastructure/System Operator flag. Erlaubt RBAC-Engine-Bypass "
|
||
"und Zugriff auf Infrastruktur-Operationen (Logs, Tokens, DB-Health, "
|
||
"i18n-Master, Registry). Gibt KEIN Cross-Mandate-Governance-Recht "
|
||
"(dafür ist isPlatformAdmin zuständig)."
|
||
),
|
||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "System-Admin"},
|
||
)
|
||
|
||
@field_validator('isSysAdmin', mode='before')
|
||
@classmethod
|
||
def _coerceIsSysAdmin(cls, v):
|
||
"""Konvertiert None zu False (für bestehende DB-Einträge ohne isSysAdmin Feld)."""
|
||
if v is None:
|
||
return False
|
||
return v
|
||
|
||
isPlatformAdmin: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Platform/Cross-Mandate Governance flag. Erlaubt mandanten-übergreifende "
|
||
"Verwaltungsoperationen (User-/Mandate-/RBAC-/Feature-Registry). "
|
||
"KEIN RBAC-Engine-Bypass und KEIN impliziter Zugriff auf Mandanten-Daten."
|
||
),
|
||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "Plattform-Admin"},
|
||
)
|
||
|
||
@field_validator('isPlatformAdmin', mode='before')
|
||
@classmethod
|
||
def _coerceIsPlatformAdmin(cls, v):
|
||
"""Konvertiert None zu False (für bestehende DB-Einträge ohne isPlatformAdmin Feld)."""
|
||
if v is None:
|
||
return False
|
||
return v
|
||
|
||
|
||
authenticationAuthority: AuthAuthority = Field(
|
||
default=AuthAuthority.LOCAL,
|
||
description="Primary authentication authority",
|
||
json_schema_extra={
|
||
"frontend_type": "select",
|
||
"frontend_readonly": True,
|
||
"frontend_required": False,
|
||
"frontend_options": "/api/connections/authorities/options",
|
||
"label": "Authentifizierung",
|
||
},
|
||
)
|
||
roleLabels: List[str] = Field(
|
||
default_factory=list,
|
||
description="Role labels (from DB or enriched when loading users)",
|
||
json_schema_extra={
|
||
"frontend_type": "multiselect",
|
||
"frontend_readonly": True,
|
||
"frontend_visible": False,
|
||
"frontend_required": False,
|
||
"label": "Rollen-Labels",
|
||
},
|
||
)
|
||
|
||
|
||
@i18nModel("Benutzerzugang")
|
||
class UserInDB(User):
|
||
"""User model with password hash for database storage."""
|
||
hashedPassword: Optional[str] = Field(
|
||
None,
|
||
description="Hash of the user password",
|
||
json_schema_extra={"label": "Passwort-Hash"},
|
||
)
|
||
resetToken: Optional[str] = Field(
|
||
None,
|
||
description="Password reset token (UUID)",
|
||
json_schema_extra={"label": "Reset-Token"},
|
||
)
|
||
resetTokenExpires: Optional[float] = Field(
|
||
None,
|
||
description="Reset token expiration (UTC timestamp in seconds)",
|
||
json_schema_extra={"label": "Token läuft ab"},
|
||
)
|
||
|
||
|
||
def _normalizeTtsVoiceMap(value: Any) -> Optional[Dict[str, str]]:
|
||
"""
|
||
Coerce ttsVoiceMap payloads to Dict[str, str].
|
||
|
||
UI/clients may send per-locale objects like {"voiceName": "de-DE-Chirp3-HD-Achird"};
|
||
storage and model field type are locale -> voice id string.
|
||
"""
|
||
if value is None:
|
||
return None
|
||
if not isinstance(value, dict):
|
||
return None
|
||
out: Dict[str, str] = {}
|
||
for rawKey, rawVal in value.items():
|
||
key = str(rawKey)
|
||
if rawVal is None:
|
||
continue
|
||
if isinstance(rawVal, str):
|
||
out[key] = rawVal
|
||
elif isinstance(rawVal, dict):
|
||
vn = rawVal.get("voiceName")
|
||
if vn is not None and str(vn).strip() != "":
|
||
out[key] = str(vn).strip()
|
||
else:
|
||
out[key] = str(rawVal)
|
||
return out if out else None
|
||
|
||
|
||
@i18nModel("Spracheinstellungen")
|
||
class UserVoicePreferences(PowerOnModel):
|
||
"""User-level voice/language preferences, shared across all features."""
|
||
id: str = Field(
|
||
default_factory=lambda: str(uuid.uuid4()),
|
||
description="Primary key",
|
||
json_schema_extra={"label": "ID"},
|
||
)
|
||
userId: str = Field(
|
||
description="User ID",
|
||
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "User"}},
|
||
)
|
||
mandateId: Optional[str] = Field(
|
||
default=None,
|
||
description="Mandate scope (None = global for user)",
|
||
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate"}},
|
||
)
|
||
sttLanguage: str = Field(
|
||
default="de-DE",
|
||
description="Speech-to-text language code",
|
||
json_schema_extra={"label": "STT-Sprache"},
|
||
)
|
||
ttsLanguage: str = Field(
|
||
default="de-DE",
|
||
description="Text-to-speech language code",
|
||
json_schema_extra={"label": "TTS-Sprache"},
|
||
)
|
||
ttsVoice: Optional[str] = Field(
|
||
default=None,
|
||
description="Preferred TTS voice identifier",
|
||
json_schema_extra={"label": "TTS-Stimme"},
|
||
)
|
||
ttsVoiceMap: Optional[Dict[str, str]] = Field(
|
||
default=None,
|
||
description="Language-to-voice mapping",
|
||
json_schema_extra={"label": "Stimmen-Zuordnung"},
|
||
)
|
||
translationSourceLanguage: Optional[str] = Field(
|
||
default=None,
|
||
description="Source language for translations",
|
||
json_schema_extra={"label": "Übersetzung Quelle"},
|
||
)
|
||
translationTargetLanguage: Optional[str] = Field(
|
||
default=None,
|
||
description="Target language for translations",
|
||
json_schema_extra={"label": "Übersetzung Ziel"},
|
||
)
|
||
|
||
@field_validator("ttsVoiceMap", mode="before")
|
||
@classmethod
|
||
def _validateTtsVoiceMap(cls, value: Any) -> Optional[Dict[str, str]]:
|
||
return _normalizeTtsVoiceMap(value)
|
||
|
||
|