Merge pull request #130 from valueonag/int
Some checks failed
Build and deploy Python app to Azure Web App - gateway-prod / build (push) Failing after 26s
Build and deploy Python app to Azure Web App - gateway-prod / deploy (push) Has been skipped
Update requirements.lock / update-lock (push) Failing after 5s

Int
This commit is contained in:
Patrick Motsch 2026-04-19 01:38:33 +02:00 committed by GitHub
commit 1f40c59afc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 3808 additions and 973 deletions

View file

@ -7,7 +7,10 @@ High-level security functionality that depends on FastAPI and interfaces.
Multi-Tenant Design:
- RequestContext: Per-request context with user, mandate, feature instance, roles
- getRequestContext: FastAPI dependency to extract context from X-Mandate-Id header
- requireSysAdmin: FastAPI dependency for system-level admin operations
- requireSysAdmin: FastAPI dependency for INFRASTRUCTURE-level operations
(logs, tokens, DB-health, i18n-master). Includes RBAC bypass.
- requirePlatformAdmin: FastAPI dependency for CROSS-MANDATE GOVERNANCE
(user-/mandate-/RBAC-/feature-registry mgmt). No bypass.
"""
from .authentication import (
@ -19,7 +22,7 @@ from .authentication import (
RequestContext,
getRequestContext,
requireSysAdmin,
requireSysAdminRole,
requirePlatformAdmin,
)
from .jwtService import (
createAccessToken,
@ -45,7 +48,7 @@ __all__ = [
"RequestContext",
"getRequestContext",
"requireSysAdmin",
"requireSysAdminRole",
"requirePlatformAdmin",
# JWT Service
"createAccessToken",
"createRefreshToken",

View file

@ -272,7 +272,6 @@ class RequestContext:
# Request-scoped cache: rules loaded only once per request
self._cachedRules: Optional[List[tuple]] = None
self._cachedHasSysAdminRole: Optional[bool] = None
def getRules(self) -> List[tuple]:
"""
@ -299,18 +298,17 @@ class RequestContext:
@property
def isSysAdmin(self) -> bool:
"""Convenience property to check if user has the isSysAdmin FLAG.
Category A only: true system operations (tokens, logs, databases)."""
"""Convenience property: Infrastructure/System Operator flag.
For Category A (Logs, Tokens, DB-Health, i18n-Master, Registry).
Wirkt auch als RBAC-Engine-Bypass (siehe rbac.py:getUserPermissions)."""
return getattr(self.user, 'isSysAdmin', False)
@property
def hasSysAdminRole(self) -> bool:
"""Check if user has sysadmin ROLE in root mandate (cached per request).
Use for admin operations (Categories B/C/D/E) instead of isSysAdmin flag."""
if self._cachedHasSysAdminRole is None:
self._cachedHasSysAdminRole = _hasSysAdminRole(str(self.user.id))
return self._cachedHasSysAdminRole
def isPlatformAdmin(self) -> bool:
"""Convenience property: Cross-Mandate-Governance flag.
For Categories BE (User-/Mandate-/RBAC-/Feature-Registry über alle Mandanten).
KEIN RBAC-Bypass Daten-Zugriff geht weiterhin über Mandanten-Mitgliedschaft."""
return getattr(self.user, 'isPlatformAdmin', False)
def getRequestContext(
request: Request,
@ -323,10 +321,13 @@ def getRequestContext(
Checks authorization and loads role IDs.
Security Model:
- Regular users: Must be explicit members of mandates/feature instances
- SysAdmin users: Can access ANY mandate for administrative operations.
Root mandate roles (incl. sysadmin role) are loaded for RBAC-based authorization.
Routes use ctx.hasSysAdminRole for admin checks (not ctx.isSysAdmin flag).
- Regular users: Must be explicit members of mandates/feature instances.
- isSysAdmin users: RBAC-Engine-Bypass; können jeden Mandant für
Infrastruktur-Operationen betreten ohne Mitgliedschaft. ``ctx.roleIds``
bleibt leer (Bypass läuft direkt in ``rbac.py:getUserPermissions``).
- isPlatformAdmin users: Cross-Mandate-Governance; können jeden Mandant
betreten, aber Routen prüfen die Berechtigung explizit via
``requirePlatformAdmin``. ``ctx.roleIds`` bleibt leer.
Args:
request: FastAPI Request object
@ -338,10 +339,11 @@ def getRequestContext(
RequestContext with user, mandate, roles
Raises:
HTTPException 403: If non-SysAdmin user is not member of mandate or has no feature access
HTTPException 403: If user is not member of mandate (and not Sys/Platform admin)
"""
ctx = RequestContext(user=currentUser)
isSysAdmin = getattr(currentUser, 'isSysAdmin', False)
isPlatformAdmin = getattr(currentUser, 'isPlatformAdmin', False)
# Get root interface for membership checks
rootInterface = getRootInterface()
@ -359,12 +361,16 @@ def getRequestContext(
)
ctx.mandateId = mandateId
ctx.roleIds = rootInterface.getRoleIdsForUserMandate(membership.id)
elif isSysAdmin:
# SysAdmin can access any mandate for admin operations
# Load root mandate roles for RBAC-based authorization (includes sysadmin role)
elif isSysAdmin or isPlatformAdmin:
# Platform-level authority can enter any mandate without membership.
# No fake role loading: isSysAdmin bypasses RBAC engine; platform-admin
# routes verify authority explicitly via requirePlatformAdmin.
ctx.mandateId = mandateId
ctx.roleIds = _getRootMandateRoleIds(rootInterface, str(currentUser.id))
logger.debug(f"SysAdmin {currentUser.id} accessing mandate {mandateId} with root mandate roles")
ctx.roleIds = []
logger.debug(
f"Platform-level user {currentUser.id} accessing mandate {mandateId} "
f"(isSysAdmin={isSysAdmin}, isPlatformAdmin={isPlatformAdmin})"
)
else:
# Regular user without membership - denied
logger.warning(f"User {currentUser.id} is not member of mandate {mandateId}")
@ -387,13 +393,15 @@ def getRequestContext(
ctx.featureInstanceId = featureInstanceId
instanceRoleIds = rootInterface.getRoleIdsForFeatureAccess(access.id)
ctx.roleIds.extend(instanceRoleIds)
elif isSysAdmin:
# SysAdmin can access any feature instance for admin operations
elif isSysAdmin or isPlatformAdmin:
# Platform-level authority can enter any feature instance without
# explicit access record.
ctx.featureInstanceId = featureInstanceId
# If no roles loaded yet, load root mandate roles
if not ctx.roleIds:
ctx.roleIds = _getRootMandateRoleIds(rootInterface, str(currentUser.id))
logger.debug(f"SysAdmin {currentUser.id} accessing feature instance {featureInstanceId} with root mandate roles")
logger.debug(
f"Platform-level user {currentUser.id} accessing feature instance "
f"{featureInstanceId} (isSysAdmin={isSysAdmin}, "
f"isPlatformAdmin={isPlatformAdmin})"
)
else:
# Regular user without access - denied
logger.warning(f"User {currentUser.id} has no access to feature instance {featureInstanceId}")
@ -444,92 +452,43 @@ def requireSysAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
# =============================================================================
# SYSADMIN ROLE: RBAC-based admin checks (hybrid model)
# PLATFORM ADMIN: Flag-based cross-mandate governance (replaces sysadmin role)
# =============================================================================
def _getRootMandateRoleIds(rootInterface, userId: str) -> List[str]:
def requirePlatformAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
"""
Load the user's role IDs from the root mandate.
Used by auth middleware to provide RBAC roles for SysAdmin cross-mandate access.
Require Platform-Admin flag for cross-mandate governance operations.
Args:
rootInterface: Root database interface
userId: User ID
Verwendung für alle Operationen, die mandanten-übergreifend wirken:
User-Mgmt, Mandate-Mgmt, RBAC-Catalog, Feature-Registry, User-Access-Overview,
Cross-Mandate-Audit, Cross-Mandate-Billing-Übersicht, Subscription-Mgmt.
Returns:
List of role IDs from root mandate membership, empty list if no membership
"""
try:
rootMandateId = rootInterface._getRootMandateId()
if not rootMandateId:
return []
membership = rootInterface.getUserMandate(userId, rootMandateId)
if not membership:
return []
return rootInterface.getRoleIdsForUserMandate(membership.id)
except Exception as e:
logger.error(f"Error loading root mandate roles: {e}")
return []
def _hasSysAdminRole(userId: str) -> bool:
"""
Check if a user has the sysadmin role in the root mandate.
Standalone check that queries the database directly, independent of
request context. Used for authorization checks where the sysadmin
ROLE (not just the isSysAdmin flag) is required.
Args:
userId: User ID to check
Returns:
True if user has sysadmin role in root mandate
"""
try:
rootInterface = getRootInterface()
roleIds = _getRootMandateRoleIds(rootInterface, str(userId))
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "sysadmin":
return True
return False
except Exception as e:
logger.error(f"Error checking sysadmin role: {e}")
return False
def requireSysAdminRole(currentUser: User = Depends(getCurrentUser)) -> User:
"""
Require sysadmin ROLE for admin operations.
Unlike requireSysAdmin (which checks the isSysAdmin FLAG for system-level ops),
this dependency checks the sysadmin ROLE in the root mandate.
Use for admin operations that should be RBAC-controlled (Category E).
KEIN RBAC-Bypass: Daten-Zugriff auf einen einzelnen Mandanten erfordert
weiterhin Mitgliedschaft (oder zusätzlich isSysAdmin für Infrastruktur-Bypass).
Args:
currentUser: Current authenticated user
Returns:
User if they have the sysadmin role
User if they have isPlatformAdmin=True
Raises:
HTTPException 403: If user doesn't have sysadmin role
HTTPException 403: If user is not a Platform Admin
"""
if not _hasSysAdminRole(str(currentUser.id)):
if not getattr(currentUser, 'isPlatformAdmin', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="SysAdmin role required"
detail="Platform admin privileges required"
)
# Audit
# Audit for all Platform-Admin actions
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId="system",
action="sysadmin_role_action",
details="Admin operation via sysadmin role"
action="platform_admin_action",
details="Cross-mandate governance operation"
)
except Exception:
pass

View file

@ -15,6 +15,7 @@ from google.cloud import speech
from google.cloud import translate_v2 as translate
from google.cloud import texttospeech
from modules.shared.configuration import APP_CONFIG
from modules.shared.voiceCatalog import getDefaultVoice as _catalogDefaultVoice
logger = logging.getLogger(__name__)
@ -562,15 +563,33 @@ class ConnectorGoogleSpeech:
"""Google TTS WaveNet cost: ~$0.000004/char."""
return round(characterCount * 0.000004, 8)
@staticmethod
def _normalizeLanguageCode(code: Optional[str]) -> Optional[str]:
"""Normalize a user/LLM-supplied language hint to an ISO-639-1 code or None.
Google Cloud Translation v2 only accepts ISO codes (e.g. 'de', 'en') or
an omitted source for auto-detection. Strings like 'auto', '' or full
BCP-47 tags ('de-DE') would otherwise reach the API and trigger
'400 Invalid Value'. Centralising the mapping here keeps every caller
(tools, interface, internal pipelines) safe.
"""
if not code:
return None
normalized = code.strip().lower()
if not normalized or normalized in ("auto", "detect", "any", "*"):
return None
return normalized.split("-")[0]
async def translateText(self, text: str, targetLanguage: str = "en",
sourceLanguage: str = "de") -> Dict:
sourceLanguage: Optional[str] = None) -> Dict:
"""
Translate text using Google Cloud Translation API.
Args:
text: Text to translate
target_language: Target language code (e.g., 'en', 'de')
source_language: Source language code (e.g., 'de', 'en')
targetLanguage: Target language code (e.g., 'en', 'de')
sourceLanguage: Source language code (e.g., 'de', 'en'); pass None
or 'auto' for Google's auto-detection.
Returns:
Dict containing translated text and metadata
@ -584,13 +603,17 @@ class ConnectorGoogleSpeech:
"error": "Empty text provided"
}
logger.info(f"🌐 Translating: '{text}' ({sourceLanguage} -> {targetLanguage})")
normalizedSource = self._normalizeLanguageCode(sourceLanguage)
normalizedTarget = self._normalizeLanguageCode(targetLanguage) or "en"
logger.info(
f"🌐 Translating: '{text}' "
f"({normalizedSource or 'auto'} -> {normalizedTarget})"
)
# Perform translation
result = self.translate_client.translate(
text,
source_language=sourceLanguage,
target_language=targetLanguage
source_language=normalizedSource,
target_language=normalizedTarget,
)
translatedText = result['translatedText']
@ -708,8 +731,8 @@ class ConnectorGoogleSpeech:
# Step 2: Translation
translationResult = await self.translateText(
text=originalText,
sourceLanguage=fromLanguage.split('-')[0], # Convert 'de-DE' to 'de'
targetLanguage=toLanguage.split('-')[0] # Convert 'en-US' to 'en'
sourceLanguage=fromLanguage,
targetLanguage=toLanguage,
)
if not translationResult["success"]:
@ -918,14 +941,17 @@ class ConnectorGoogleSpeech:
stripped = voiceName.strip()
return bool(stripped) and "-" not in stripped
async def textToSpeech(self, text: str, languageCode: str = "de-DE", voiceName: str = None) -> Dict[str, Any]:
async def textToSpeech(self, text: str, languageCode: str = "de-DE", voiceName: Optional[str] = None) -> Dict[str, Any]:
"""
Convert text to speech using Google Cloud Text-to-Speech.
Args:
text: Text to convert to speech
language_code: Language code (e.g., 'de-DE', 'en-US')
voice_name: Specific voice name (optional)
languageCode: BCP-47 language code (e.g., 'de-DE', 'en-US', 'ru-RU')
voiceName: Specific voice name (optional). If omitted, a curated
default is used; if no curated default exists for the language,
Google selects a default voice automatically based on
languageCode + ssml_gender (no hard failure).
Returns:
Dict with success status and audio data
@ -933,18 +959,8 @@ class ConnectorGoogleSpeech:
try:
logger.info(f"Converting text to speech: '{text[:50]}...' in {languageCode}")
# Build the voice request
selectedVoice = voiceName or self._getDefaultVoice(languageCode)
if not selectedVoice:
return {
"success": False,
"error": f"No voice specified for language {languageCode}. Please select a voice."
}
logger.info(f"Using TTS voice: {selectedVoice} for language: {languageCode}")
isGeminiVoice = self._isGeminiTtsSpeakerVoiceName(selectedVoice)
isGeminiVoice = self._isGeminiTtsSpeakerVoiceName(selectedVoice) if selectedVoice else False
if isGeminiVoice:
synthesisInput = texttospeech.SynthesisInput(
@ -959,11 +975,23 @@ class ConnectorGoogleSpeech:
)
else:
synthesisInput = texttospeech.SynthesisInput(text=text)
voice = texttospeech.VoiceSelectionParams(
language_code=languageCode,
name=selectedVoice,
ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL,
)
voiceKwargs: Dict[str, Any] = {
"language_code": languageCode,
"ssml_gender": texttospeech.SsmlVoiceGender.NEUTRAL,
}
if selectedVoice:
voiceKwargs["name"] = selectedVoice
else:
logger.info(
f"TTS: no curated voice for '{languageCode}', "
f"letting Google auto-select by language + gender"
)
voice = texttospeech.VoiceSelectionParams(**voiceKwargs)
logger.info(
f"Using TTS voice: {selectedVoice or '<google-auto>'} "
f"for language: {languageCode}"
)
audioConfig = texttospeech.AudioConfig(
audio_encoding=texttospeech.AudioEncoding.MP3
@ -972,16 +1000,15 @@ class ConnectorGoogleSpeech:
response = self.tts_client.synthesize_speech(
input=synthesisInput,
voice=voice,
audio_config=audioConfig
audio_config=audioConfig,
)
# Return the audio content
return {
"success": True,
"audio_content": response.audio_content,
"audio_format": "mp3",
"language_code": languageCode,
"voice_name": voice.name
"voice_name": selectedVoice or "<google-auto>",
}
except Exception as e:
@ -996,58 +1023,14 @@ class ConnectorGoogleSpeech:
"error": f"Text-to-Speech failed: {detail}{extra}",
}
def _getDefaultVoice(self, languageCode: str) -> str:
def _getDefaultVoice(self, languageCode: str) -> Optional[str]:
"""Return the curated default Google TTS voice for `languageCode`.
Delegates to the central voice catalog; returns None when no curated
voice exists, in which case the caller omits `name` and Google
auto-selects based on languageCode + ssml_gender.
"""
Get default voice name for a language code.
Falls back to a Wavenet voice for common languages.
"""
_defaults = {
"de-DE": "de-DE-Wavenet-A",
"de-CH": "de-DE-Wavenet-A",
"en-US": "en-US-Wavenet-C",
"en-GB": "en-GB-Wavenet-A",
"fr-FR": "fr-FR-Wavenet-A",
"it-IT": "it-IT-Wavenet-A",
}
return _defaults.get(languageCode)
async def getAvailableLanguages(self) -> Dict[str, Any]:
"""
Get available languages from Google Cloud Text-to-Speech.
Returns:
Dict containing success status and list of available languages
"""
try:
logger.info("🌐 Getting available languages from Google Cloud TTS")
# List voices from Google Cloud TTS
response = self.tts_client.list_voices()
# Extract unique language codes
# Note: Google TTS API doesn't provide language descriptions, only codes
language_codes = set()
for voice in response.voices:
if voice.language_codes:
language_codes.update(voice.language_codes)
# Convert to sorted list of language codes
available_languages = sorted(list(language_codes))
logger.info(f"✅ Found {len(available_languages)} available languages")
return {
"success": True,
"languages": available_languages
}
except Exception as e:
logger.error(f"❌ Failed to get available languages: {e}")
return {
"success": False,
"error": str(e),
"languages": []
}
return _catalogDefaultVoice(languageCode)
async def getAvailableVoices(self, languageCode: Optional[str] = None) -> Dict[str, Any]:
"""

View file

@ -138,7 +138,7 @@ class BillingSettings(BaseModel):
warningThresholdPercent: float = Field(
default=10.0,
description="Benachrichtigung wenn das AI-Guthaben unter diesen Prozentsatz des Gesamtbudgets fällt",
description="Warning threshold as percentage",
json_schema_extra={"label": "Warnschwelle (%)"},
)

View file

@ -128,6 +128,21 @@ class ChatWorkflow(PowerOnModel):
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
},
)
linkedWorkflowId: Optional[str] = Field(
None,
description=(
"Optional foreign key linking this chat to an entity outside the "
"ChatWorkflow table (e.g. an Automation2Workflow in the GraphicalEditor "
"AI editor chat). NULL for the default workspace chats. Combined with "
"featureInstanceId this gives a 1:1 relation entity ↔ chat per feature."
),
json_schema_extra={
"label": "Verknüpfter Workflow",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
},
)
status: str = Field(default="running", description="Current status of the workflow", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "running", "label": "Running"},
{"value": "completed", "label": "Completed"},

View file

@ -6,7 +6,15 @@ 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)
- isSysAdmin ist globales Admin-Flag für System-Zugriff (KEIN Daten-Zugriff!)
- 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
@ -15,6 +23,7 @@ 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
@ -66,6 +75,11 @@ 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 232).
- ``label`` (Voller Name): Anzeigename im UI, frei änderbar unabhängig vom Slug.
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
@ -73,13 +87,26 @@ class Mandate(PowerOnModel):
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False, "label": "ID"},
)
name: str = Field(
description="Name of the mandate",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True, "label": "Name"},
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: Optional[str] = Field(
default=None,
description="Display label of the mandate",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False, "label": "Label"},
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,
@ -105,6 +132,30 @@ class Mandate(PowerOnModel):
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(
@ -224,8 +275,11 @@ class User(PowerOnModel):
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
- isSysAdmin = System-Zugriff, KEIN Daten-Zugriff
- 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()),
@ -283,7 +337,12 @@ class User(PowerOnModel):
isSysAdmin: bool = Field(
default=False,
description="Global SysAdmin flag. SysAdmin = System-Zugriff, KEIN Daten-Zugriff!",
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"},
)
@ -295,6 +354,25 @@ class User(PowerOnModel):
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",

View file

@ -77,7 +77,7 @@ class InvestorDemo2026(_BaseDemoConfig):
mandateIdAlpina = self._ensureMandate(db, _MANDATE_ALPINA, summary)
userId = self._ensureUser(db, summary)
self._ensureRootMandateSysAdminRole(db, userId, summary)
self._ensurePlatformAdminFlag(db, userId, summary)
if mandateIdHappy:
self._ensureMembership(db, userId, mandateIdHappy, _MANDATE_HAPPYLIFE["label"], summary)
@ -195,47 +195,24 @@ class InvestorDemo2026(_BaseDemoConfig):
summary["created"].append(f"User {_USER['fullName']}")
return uid
def _ensureRootMandateSysAdminRole(self, db, userId: str, summary: Dict):
"""Ensure the demo user is member of the root mandate with the sysadmin role.
Without this, hasSysAdminRole returns False and admin menus are hidden."""
from modules.datamodels.datamodelUam import Mandate
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
from modules.datamodels.datamodelRbac import Role
def _ensurePlatformAdminFlag(self, db, userId: str, summary: Dict):
"""Ensure the demo user has isPlatformAdmin=True for cross-mandate governance.
Without this, the admin UI menus would be hidden."""
from modules.datamodels.datamodelUam import UserInDB
rootMandates = db.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True})
if not rootMandates:
summary["errors"].append("Root mandate not found — cannot assign sysadmin role")
existing = db.getRecord(UserInDB, userId)
if not existing:
summary["errors"].append(f"Demo user {userId} not found — cannot set isPlatformAdmin")
return
rootMandateId = rootMandates[0].get("id")
existing = db.getRecordset(UserMandate, recordFilter={"userId": userId, "mandateId": rootMandateId})
if existing:
userMandateId = existing[0].get("id")
else:
um = UserMandate(userId=userId, mandateId=rootMandateId, enabled=True)
created = db.recordCreate(UserMandate, um)
userMandateId = created.get("id")
summary["created"].append("Membership -> root mandate")
logger.info(f"Created root mandate membership for {_USER['username']}")
sysadminRoles = db.getRecordset(Role, recordFilter={"mandateId": rootMandateId, "roleLabel": "sysadmin"})
if not sysadminRoles:
summary["errors"].append("sysadmin role not found in root mandate")
currentFlag = bool(existing.get("isPlatformAdmin", False)) if isinstance(existing, dict) else bool(getattr(existing, "isPlatformAdmin", False))
if currentFlag:
summary["skipped"].append("isPlatformAdmin already set")
return
sysadminRoleId = sysadminRoles[0].get("id")
existingRole = db.getRecordset(UserMandateRole, recordFilter={
"userMandateId": userMandateId,
"roleId": sysadminRoleId,
})
if not existingRole:
umr = UserMandateRole(userMandateId=userMandateId, roleId=sysadminRoleId)
db.recordCreate(UserMandateRole, umr)
summary["created"].append("SysAdmin role in root mandate")
logger.info(f"Assigned sysadmin role in root mandate for {_USER['username']}")
else:
summary["skipped"].append("SysAdmin role in root mandate exists")
db.recordModify(UserInDB, userId, {"isPlatformAdmin": True})
summary["created"].append("isPlatformAdmin flag")
logger.info(f"Set isPlatformAdmin=True for {_USER['username']}")
def _ensureMembership(self, db, userId: str, mandateId: str, mandateLabel: str, summary: Dict):
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole

View file

@ -116,11 +116,18 @@ TEMPLATE_ROLES = [
def getFeatureDefinition() -> Dict[str, Any]:
"""Return the feature definition for registration."""
"""Return the feature definition for registration.
The chatbot feature is currently soft-disabled via ``enabled=False``: its
catalog objects, template roles and routes stay loaded so already-running
instances keep working, but it is filtered out of the Store and the
Admin Feature-Instances "Neue Instanz" selection list.
"""
return {
"code": FEATURE_CODE,
"label": FEATURE_LABEL,
"icon": FEATURE_ICON,
"enabled": False,
}

View file

@ -102,7 +102,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
)
# Verify user has access to this instance
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
# Check if user has FeatureAccess for this instance
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
hasAccess = any(

View file

@ -470,16 +470,63 @@ def share_template(
# -------------------------------------------------------------------------
def _editorChatQueueId(workflowId: str) -> str:
"""Deterministic SSE queue id for the editor chat (one active stream per workflow).
Mirrors the workspace pattern (``workspace-{workflowId}``) so stop/cancel can
target the running task by workflowId without needing per-request handles.
"""
return f"ge-chat-{workflowId}"
def _getEditorChatInterface(context: RequestContext, mandateId: str, instanceId: str):
"""Build the ChatObjects interface used to persist editor-chat messages."""
from modules.interfaces import interfaceDbChat
return interfaceDbChat.getInterface(
context.user,
mandateId=mandateId,
featureInstanceId=instanceId,
)
def _editorConversationHistoryFromPersisted(chatInterface, chatWorkflowId: str) -> List[Dict[str, Any]]:
"""Load persisted ChatMessages for the editor chat and shape them as the
agent expects (``[{role, message}]``). Skips empty / system messages.
"""
try:
msgs = chatInterface.getMessages(chatWorkflowId) or []
except Exception as e:
logger.warning("Editor chat: could not load persisted history for %s: %s", chatWorkflowId, e)
return []
history: List[Dict[str, Any]] = []
for m in msgs:
role = (getattr(m, "role", None) or (m.get("role") if isinstance(m, dict) else None) or "").strip()
text = (getattr(m, "message", None) or (m.get("message") if isinstance(m, dict) else None) or "").strip()
if not role or not text:
continue
if role not in ("user", "assistant", "system"):
continue
history.append({"role": role, "message": text})
return history
@router.post("/{instanceId}/{workflowId}/chat/stream")
@limiter.limit("30/minute")
async def post_editor_chat(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID"),
body: dict = Body(..., description="{ message, conversationHistory?, userLanguage? }"),
body: dict = Body(..., description="{ message, userLanguage? }"),
context: RequestContext = Depends(getRequestContext),
):
"""AI chat endpoint for the editor with SSE streaming. Uses workflow tools to mutate the graph."""
"""AI chat endpoint for the editor with SSE streaming. Uses workflow tools to mutate the graph.
Persistence: the chat is stored in the standard ``ChatWorkflow`` table linked
to this Automation2Workflow via ``ChatWorkflow.linkedWorkflowId``. The user
message is persisted before the agent starts; the assistant message after.
Conversation history is loaded server-side from this linked ChatWorkflow
the client does not need to maintain it.
"""
mandateId = _validateInstanceAccess(instanceId, context)
message = body.get("message", "")
if not message:
@ -491,14 +538,35 @@ async def post_editor_chat(
raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
userLanguage = body.get("userLanguage", "de")
conversationHistory = body.get("conversationHistory") or []
fileIds = body.get("fileIds") or []
dataSourceIds = body.get("dataSourceIds") or []
featureDataSourceIds = body.get("featureDataSourceIds") or []
chatInterface = _getEditorChatInterface(context, mandateId, instanceId)
wfLabel = wf.get("label") if isinstance(wf, dict) else getattr(wf, "label", None)
chatWorkflow = chatInterface.getOrCreateLinkedWorkflow(
featureInstanceId=instanceId,
linkedWorkflowId=workflowId,
name=wfLabel or f"Editor Chat ({workflowId})",
)
chatWorkflowId = chatWorkflow.id if hasattr(chatWorkflow, "id") else chatWorkflow.get("id")
conversationHistory = _editorConversationHistoryFromPersisted(chatInterface, chatWorkflowId)
try:
chatInterface.createMessage({
"workflowId": chatWorkflowId,
"role": "user",
"message": message,
"status": "first" if not conversationHistory else "step",
})
except Exception as e:
logger.error("Editor chat: failed to persist user message: %s", e)
from modules.serviceCenter.core.serviceStreaming import get_event_manager
sseEventManager = get_event_manager()
queueId = f"ge-chat-{workflowId}-{id(request)}"
queueId = _editorChatQueueId(workflowId)
await sseEventManager.cancel_agent(queueId)
sseEventManager.create_queue(queueId)
agentTask = asyncio.ensure_future(
@ -515,6 +583,8 @@ async def post_editor_chat(
fileIds=fileIds,
dataSourceIds=dataSourceIds,
featureDataSourceIds=featureDataSourceIds,
chatInterface=chatInterface,
chatWorkflowId=chatWorkflowId,
)
)
sseEventManager.register_agent_task(queueId, agentTask)
@ -549,6 +619,80 @@ async def post_editor_chat(
)
@router.get("/{instanceId}/{workflowId}/chat/messages")
@limiter.limit("120/minute")
def get_editor_chat_messages(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID (Automation2Workflow)"),
context: RequestContext = Depends(getRequestContext),
):
"""Return persisted editor-chat messages for an Automation2Workflow.
The chat is stored in ``ChatWorkflow`` with ``linkedWorkflowId == workflowId``;
if no chat has been started yet for this workflow we return an empty list (we
do NOT eagerly create one the row is created on the first POST /chat/stream).
"""
mandateId = _validateInstanceAccess(instanceId, context)
chatInterface = _getEditorChatInterface(context, mandateId, instanceId)
chatWorkflow = chatInterface.getWorkflowByLink(
featureInstanceId=instanceId,
linkedWorkflowId=workflowId,
)
if not chatWorkflow:
return JSONResponse({
"chatWorkflowId": None,
"messages": [],
})
chatWorkflowId = chatWorkflow.id if hasattr(chatWorkflow, "id") else chatWorkflow.get("id")
rawMessages = chatInterface.getMessages(chatWorkflowId) or []
items: List[Dict[str, Any]] = []
for m in rawMessages:
getter = (lambda key, default=None: getattr(m, key, default)) if not isinstance(m, dict) else (lambda key, default=None: m.get(key, default))
role = (getter("role") or "").strip()
content = (getter("message") or "").strip()
if not role or not content:
continue
items.append({
"id": getter("id"),
"role": role,
"content": content,
"timestamp": getter("publishedAt") or 0,
"sequenceNr": getter("sequenceNr") or 0,
})
items.sort(key=lambda x: (float(x.get("timestamp") or 0), int(x.get("sequenceNr") or 0)))
return JSONResponse({
"chatWorkflowId": chatWorkflowId,
"messages": items,
})
@router.post("/{instanceId}/{workflowId}/chat/stop")
@limiter.limit("120/minute")
async def post_editor_chat_stop(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID"),
context: RequestContext = Depends(getRequestContext),
):
"""Stop a running editor-chat agent for the given workflow."""
_validateInstanceAccess(instanceId, context)
from modules.serviceCenter.core.serviceStreaming import get_event_manager
sseEventManager = get_event_manager()
queueId = _editorChatQueueId(workflowId)
cancelled = await sseEventManager.cancel_agent(queueId)
await sseEventManager.emit_event(queueId, "stopped", {
"type": "stopped",
"workflowId": workflowId,
})
logger.info("Editor chat stop requested for workflow %s, cancelled=%s", workflowId, cancelled)
return JSONResponse({"status": "stopped", "workflowId": workflowId, "cancelled": cancelled})
async def _runEditorAgent(
workflowId: str,
queueId: str,
@ -562,12 +706,41 @@ async def _runEditorAgent(
fileIds: List[str] = None,
dataSourceIds: List[str] = None,
featureDataSourceIds: List[str] = None,
chatInterface=None,
chatWorkflowId: Optional[str] = None,
):
"""Run the serviceAgent loop with workflow toolbox and forward events to the SSE queue."""
"""Run the serviceAgent loop with workflow toolbox and forward events to the SSE queue.
Persists the assistant response to ``ChatMessage`` (linked via ``chatWorkflowId``)
on FINAL/ERROR. On cancellation any partial accumulated text is still saved so
the editor chat history reflects what the user actually saw on screen.
"""
assistantPersisted = False
def _persistAssistant(text: str) -> None:
nonlocal assistantPersisted
if assistantPersisted or not chatInterface or not chatWorkflowId:
return
cleaned = (text or "").strip()
if not cleaned:
return
try:
chatInterface.createMessage({
"workflowId": chatWorkflowId,
"role": "assistant",
"message": cleaned,
"status": "last",
})
assistantPersisted = True
except Exception as msgErr:
logger.error("Editor chat: failed to persist assistant message: %s", msgErr)
try:
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum
from modules.serviceCenter.services.serviceAgent.datamodelAgent import (
AgentEventTypeEnum, AgentConfig,
)
ctx = ServiceCenterContext(
user=user,
@ -579,11 +752,41 @@ async def _runEditorAgent(
agentService = getService("agent", ctx)
systemPrompt = (
"You are a workflow editor assistant. The user describes changes to a workflow graph. "
"Use the available workflow tools (readWorkflowGraph, addNode, removeNode, connectNodes, "
"setNodeParameter, listAvailableNodeTypes, validateGraph) to modify the graph. "
"Always read the current graph first before making changes. "
"Respond concisely and confirm what you changed."
"You are a workflow EDITOR assistant for the GraphicalEditor. "
"Your ONLY job is to BUILD or MODIFY the workflow graph (nodes + connections) "
"for the user — you must NEVER execute the workflow or any of its actions. "
"Even when the user says 'create a workflow that sends an email', you build the "
"graph (e.g. add an email node, connect it) — you do NOT actually send an email. "
"\n\nGraph-mutating tools: readWorkflowGraph, listAvailableNodeTypes, "
"describeNodeType, addNode, removeNode, connectNodes, setNodeParameter, "
"autoLayoutWorkflow, validateGraph. "
"Connection discovery (for parameters of frontendType='userConnection'): listConnections."
"\n\nMandatory build sequence:"
"\n1. readWorkflowGraph — understand current state."
"\n2. listAvailableNodeTypes — find candidate node ids."
"\n3. For EACH node type you plan to add: call describeNodeType(nodeType=...) "
"to learn its requiredParameters, allowedValues and ports. Never skip this "
"step — guessing parameters leaves the user with empty config cards."
"\n4. If any required parameter has frontendType='userConnection' (e.g. "
"email.checkEmail.connectionReference), call listConnections and pick the "
"connectionId that matches the user's intent (or ask the user if none clearly fits)."
"\n5. addNode with parameters={...} containing AT LEAST every requiredParameter "
"filled with a sensible value (use the user's request, the parameter "
"description, sane defaults, or — for required user-connection fields — "
"an actual connectionId). Do NOT pass position; the layout step handles it."
"\n6. connectNodes — wire the nodes consistent with port schemas from describeNodeType."
"\n7. autoLayoutWorkflow — call exactly once as the LAST graph-mutating step so the "
"canvas shows a readable top-down layout instead of overlapping boxes."
"\n8. validateGraph — sanity check, then answer the user."
"\n\nIf a required parameter cannot be filled from the user's request and has "
"no safe default, ask the user once for that specific value (e.g. recipient "
"address, target language, prompt text) instead of leaving the field blank. "
"Respond concisely in the user's language and list what you changed in the graph."
)
editorConfig = AgentConfig(
toolSet="core",
excludeActionTools=True,
)
enrichedPrompt = prompt
@ -605,6 +808,7 @@ async def _runEditorAgent(
async for event in agentService.runAgent(
prompt=enrichedPrompt,
fileIds=fileIds or [],
config=editorConfig,
workflowId=workflowId,
userLanguage=userLanguage,
conversationHistory=conversationHistory or [],
@ -631,8 +835,13 @@ async def _runEditorAgent(
await sseEventManager.emit_event(queueId, sseEvent["type"], sseEvent)
if event.type in (AgentEventTypeEnum.FINAL, AgentEventTypeEnum.ERROR):
_persistAssistant(event.content or accumulatedText)
break
# Fallback: any streamed content not yet stored (cancellation path, no FINAL).
if not assistantPersisted and accumulatedText.strip():
_persistAssistant(accumulatedText)
await sseEventManager.emit_event(queueId, "complete", {
"type": "complete",
"workflowId": workflowId,
@ -640,6 +849,12 @@ async def _runEditorAgent(
except asyncio.CancelledError:
logger.info("Editor chat agent task cancelled for workflow %s", workflowId)
# Save whatever the user already saw before cancelling so the next reload
# shows the same partial answer (matches workspace behaviour).
try:
_persistAssistant(accumulatedText if "accumulatedText" in locals() else "")
except Exception:
pass
await sseEventManager.emit_event(queueId, "stopped", {
"type": "stopped",
"workflowId": workflowId,

View file

@ -116,7 +116,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
status_code=400,
detail=f"Instance '{instanceId}' is not a realestate instance"
)
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
hasAccess = any(
str(fa.featureInstanceId) == instanceId and fa.enabled

View file

@ -138,7 +138,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
def _validateSessionOwnership(session: dict, context: RequestContext) -> None:
"""Raise 404 if the user does not own this session (sysAdmin bypasses)."""
if context.hasSysAdminRole:
if context.isPlatformAdmin:
return
if session.get("startedByUserId") != str(context.user.id):
raise HTTPException(status_code=404, detail=f"Session '{session.get('id')}' not found")
@ -319,7 +319,7 @@ async def listSessions(
"""List sessions for a feature instance (filtered to own sessions unless sysAdmin)."""
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
userId = None if context.hasSysAdminRole else str(context.user.id)
userId = None if context.isPlatformAdmin else str(context.user.id)
sessions = interface.getSessions(instanceId, includeEnded=includeEnded, userId=userId)
return {"sessions": sessions}

View file

@ -104,7 +104,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
)
# Verify user has access to this instance
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
# Check if user has FeatureAccess for this instance
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
hasAccess = any(
@ -138,7 +138,7 @@ def getQuickActions(
from .mainTrustee import QUICK_ACTIONS, QUICK_ACTION_CATEGORIES
userRoleLabels: set = set()
if context.hasSysAdminRole:
if context.isPlatformAdmin:
userRoleLabels.add("trustee-admin")
else:
rootInterface = getRootInterface()
@ -156,9 +156,9 @@ def getQuickActions(
filteredActions = []
for action in QUICK_ACTIONS:
required = set(action.get("requiredRoles", []))
if not userRoleLabels and not context.hasSysAdminRole:
if not userRoleLabels and not context.isPlatformAdmin:
continue
if context.hasSysAdminRole or required.intersection(userRoleLabels):
if context.isPlatformAdmin or required.intersection(userRoleLabels):
resolved = {
"id": action["id"],
"label": resolveText(action.get("label", {})),
@ -1811,7 +1811,7 @@ def _validateInstanceAdmin(instanceId: str, context: RequestContext) -> str:
mandateId = _validateInstanceAccess(instanceId, context)
# SysAdmin role always has access
if context.hasSysAdminRole:
if context.isPlatformAdmin:
return mandateId
# Check for instance-roles.manage resource permission via AccessRules

View file

@ -7,7 +7,7 @@ Contains all bootstrap logic including mandate, users, and RBAC rules.
Multi-Tenant Design:
- Rollen werden mit Kontext erstellt (mandateId=None für globale Template-Rollen)
- AccessRules referenzieren roleId (FK), nicht roleLabel
- Admin-User bekommt isSysAdmin=True statt roleLabels
- Admin-User bekommt isSysAdmin=True UND isPlatformAdmin=True (statt einer Rolle)
"""
import logging
@ -61,6 +61,7 @@ def initBootstrap(db: DatabaseConnector) -> None:
# Migrate existing mandate records: description -> label
_migrateMandateDescriptionToLabel(db)
_migrateMandateNameLabelSlugRules(db)
# Clean up duplicate roles and fix corrupted templates FIRST
_deduplicateRoles(db)
@ -75,12 +76,14 @@ def initBootstrap(db: DatabaseConnector) -> None:
# This also serves as migration for existing mandates that don't have instance roles yet
_ensureAllMandatesHaveSystemRoles(db)
# Initialize sysadmin role in root mandate (NOT a template, mandate-specific)
# Hybrid model: isSysAdmin flag → system ops, sysadmin role → admin ops via RBAC
# Migration: eliminate the legacy ``sysadmin`` role in root mandate
# (replaced by ``User.isPlatformAdmin`` flag — see
# wiki/c-work/4-done/2026-04-sysadmin-authority-split.md).
# Idempotent: noop after first successful run.
if mandateId:
_initSysAdminRole(db, mandateId)
_migrateAndDropSysAdminRole(db, mandateId)
# Ensure UI rules for sysadmin role (created after initRbacRules, needs second pass)
# Ensure UI rules for navigation items (admin/user/viewer roles)
_ensureUiContextRules(db)
# Initialize admin user
@ -420,11 +423,78 @@ def _migrateMandateDescriptionToLabel(db: DatabaseConnector) -> None:
logger.debug("No mandate description->label migration needed")
def _migrateMandateNameLabelSlugRules(db: DatabaseConnector) -> None:
"""
Migration: normalize Mandate.name to the slug rules ([a-z0-9-], length 2..32, single
hyphen segments) and ensure Mandate.label is non-empty.
Rules (see wiki/c-work/1-plan/2026-04-mandate-name-label-logic.md):
1. If ``label`` is empty/None set ``label := name`` (or "Mandate" when both empty).
2. If ``name`` is not a valid slug, or collides with an earlier mandate in stable id
order, allocate a unique slug from the (now non-empty) ``label`` using
``slugifyMandateName`` + ``allocateUniqueMandateSlug``.
Idempotent: a second run is a no-op because all valid names stay valid and stay unique.
Each rename and label fill-in is logged for audit.
"""
from modules.shared.mandateNameUtils import (
allocateUniqueMandateSlug,
isValidMandateName,
slugifyMandateName,
)
allRows = db.getRecordset(Mandate)
if not allRows:
return
sortedRows = sorted(allRows, key=lambda r: str(r.get("id", "")))
used: set[str] = set()
labelFills = 0
nameRenames: list[tuple[str, str, str]] = []
for rec in sortedRows:
mid = rec.get("id")
if not mid:
continue
name = (rec.get("name") or "").strip()
labelRaw = rec.get("label")
label = (labelRaw or "").strip() if labelRaw is not None else ""
if not label:
label = name if name else "Mandate"
db.recordModify(Mandate, mid, {"label": label})
labelFills += 1
logger.info(f"Mandate {mid}: filled empty label with '{label}'")
nameFits = isValidMandateName(name)
nameCollides = name in used
if nameFits and not nameCollides:
used.add(name)
continue
base = slugifyMandateName(label) or "mn"
newName = allocateUniqueMandateSlug(base, used)
used.add(newName)
if newName != name:
db.recordModify(Mandate, mid, {"name": newName})
nameRenames.append((str(mid), name, newName))
logger.info(f"Mandate {mid}: renamed name '{name}' -> '{newName}'")
if labelFills or nameRenames:
logger.info(
"Mandate name/label slug migration: %d label fill-in(s), %d name rename(s)",
labelFills, len(nameRenames),
)
else:
logger.debug("No mandate name/label slug migration needed")
def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[str]:
"""
Creates the Admin user if it doesn't exist.
Admin user gets isSysAdmin=True for system-level access.
Role assignment is done via UserMandate + UserMandateRole in assignInitialUserMemberships().
Admin user gets BOTH platform flags:
- isSysAdmin=True (Infrastructure: logs/tokens/DB-health)
- isPlatformAdmin=True (Cross-Mandate-Governance: user/mandate/RBAC mgmt)
Args:
db: Database connector instance
@ -436,12 +506,14 @@ def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[s
existingUsers = db.getRecordset(UserInDB, recordFilter={"username": "admin"})
if existingUsers:
userId = existingUsers[0].get("id")
existingIsSysAdmin = existingUsers[0].get("isSysAdmin", False)
# Ensure admin user has isSysAdmin=True
if not existingIsSysAdmin:
logger.info(f"Updating admin user {userId} to set isSysAdmin=True")
db.recordModify(UserInDB, userId, {"isSysAdmin": True})
updates: Dict[str, bool] = {}
if not existingUsers[0].get("isSysAdmin", False):
updates["isSysAdmin"] = True
if not existingUsers[0].get("isPlatformAdmin", False):
updates["isPlatformAdmin"] = True
if updates:
logger.info(f"Updating admin user {userId} platform flags: {updates}")
db.recordModify(UserInDB, userId, updates)
logger.info(f"Admin user already exists with ID {userId}")
return userId
@ -454,6 +526,7 @@ def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[s
enabled=True,
language="en",
isSysAdmin=True,
isPlatformAdmin=True,
authenticationAuthority=AuthAuthority.LOCAL,
hashedPassword=_getPasswordHash(APP_CONFIG.get("APP_INIT_PASS_ADMIN_SECRET")),
)
@ -466,8 +539,9 @@ def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[s
def initEventUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[str]:
"""
Creates the Event user if it doesn't exist.
Event user gets isSysAdmin=True for system operations.
Role assignment is done via UserMandate + UserMandateRole in assignInitialUserMemberships().
Event user gets isSysAdmin=True for infrastructure-level operations
(system events, internal callbacks). It does NOT need cross-mandate
governance, so isPlatformAdmin is left False.
Args:
db: Database connector instance
@ -479,6 +553,13 @@ def initEventUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[s
existingUsers = db.getRecordset(UserInDB, recordFilter={"username": "event"})
if existingUsers:
userId = existingUsers[0].get("id")
# Defensive: revoke any historic platform-admin grant on the event user
if existingUsers[0].get("isPlatformAdmin", False):
logger.warning(
f"Event user {userId} had isPlatformAdmin=True; "
f"revoking (event user is infrastructure-only)"
)
db.recordModify(UserInDB, userId, {"isPlatformAdmin": False})
logger.info(f"Event user already exists with ID {userId}")
return userId
@ -490,6 +571,7 @@ def initEventUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[s
enabled=True,
language="en",
isSysAdmin=True,
isPlatformAdmin=False,
authenticationAuthority=AuthAuthority.LOCAL,
hashedPassword=_getPasswordHash(APP_CONFIG.get("APP_INIT_PASS_EVENT_SECRET")),
)
@ -504,9 +586,10 @@ def initRoles(db: DatabaseConnector) -> None:
Initialize standard roles if they don't exist.
Roles are created as GLOBAL (mandateId=None) template roles.
NOTE: The "sysadmin" role is NOT a template - it's created separately in
_initSysAdminRole() as a root-mandate-specific role (isSystemRole=False).
These template roles (admin/user/viewer) are for mandate/feature-level access control.
NOTE: There is no platform-level "sysadmin" role any more platform
authority lives on the User record via ``isSysAdmin`` and
``isPlatformAdmin``. These template roles (admin/user/viewer) are
purely for mandate/feature-level access control.
Args:
db: Database connector instance
@ -515,8 +598,6 @@ def initRoles(db: DatabaseConnector) -> None:
global _roleIdCache
_roleIdCache = {}
# Standard template roles for mandate/feature-level access
# NOTE: "sysadmin" role is created separately in _initSysAdminRole (root mandate only)
standardRoles = [
Role(
roleLabel="admin",
@ -734,145 +815,99 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
return copiedCount
def _initSysAdminRole(db: DatabaseConnector, mandateId: str) -> Optional[str]:
def _migrateAndDropSysAdminRole(db: DatabaseConnector, mandateId: str) -> None:
"""
Initialize the sysadmin role in the root mandate.
One-shot migration: eliminate the legacy ``sysadmin`` role in the root mandate.
The sysadmin role is a mandate-specific role (NOT a system template) that provides
full administrative access via RBAC. It only exists in the root mandate and is
NOT copied to other mandates (isSystemRole=False).
Authority semantics moved to two orthogonal flags on User:
- ``isSysAdmin`` Infrastructure-Operator (RBAC bypass)
- ``isPlatformAdmin`` Cross-Mandate-Governance (no bypass)
Hybrid model:
- User.isSysAdmin flag true system operations (Category A: tokens, logs, databases)
- sysadmin role admin operations via RBAC (Categories B/C/D/E)
Migration steps (idempotent):
1. Find sysadmin role(s) in root mandate. If none exist done.
2. For every UserMandateRole row referencing such a role: set
``user.isPlatformAdmin = True`` (preserves cross-mandate authority).
3. Delete those UserMandateRole rows.
4. Delete AccessRules attached to the sysadmin role.
5. Delete the sysadmin Role record.
Args:
db: Database connector instance
mandateId: Root mandate ID
Returns:
Sysadmin role ID or None
"""
# Check if sysadmin role already exists in root mandate
existingRoles = db.getRecordset(
sysadminRoles = db.getRecordset(
Role,
recordFilter={"roleLabel": "sysadmin", "mandateId": mandateId, "featureInstanceId": None}
recordFilter={"roleLabel": "sysadmin", "mandateId": mandateId, "featureInstanceId": None},
)
if existingRoles:
sysadminRoleId = existingRoles[0].get("id")
logger.info(f"Sysadmin role already exists in root mandate with ID {sysadminRoleId}")
# Ensure AccessRules exist (migration safety)
_ensureSysAdminAccessRules(db, sysadminRoleId)
return sysadminRoleId
# Create sysadmin role in root mandate
logger.info("Creating sysadmin role in root mandate")
sysadminRole = Role(
roleLabel="sysadmin",
description=coerce_text_multilingual("System-Administrator - Vollständiger administrativer Zugriff über alle Mandanten"),
mandateId=mandateId,
featureInstanceId=None,
featureCode=None,
isSystemRole=False # NOT a template → NOT copied to other mandates
)
createdRole = db.recordCreate(Role, sysadminRole)
sysadminRoleId = createdRole.get("id")
logger.info(f"Created sysadmin role with ID {sysadminRoleId}")
# Create AccessRules for sysadmin role
_createSysAdminAccessRules(db, sysadminRoleId)
return sysadminRoleId
def _createSysAdminAccessRules(db: DatabaseConnector, sysadminRoleId: str) -> None:
"""
Create AccessRules for the sysadmin role.
DATA + RESOURCE: generic item=None (full access).
UI: NO generic rule here explicit ui.admin.* rules are created by
_ensureUiContextRules() (same logic as admin role).
Args:
db: Database connector instance
sysadminRoleId: Sysadmin role ID
"""
rules = [
# DATA: Full access to all data tables (generic rule, item=None)
AccessRule(
roleId=sysadminRoleId,
context=AccessRuleContext.DATA,
item=None,
view=True,
read=AccessLevel.ALL,
create=AccessLevel.ALL,
update=AccessLevel.ALL,
delete=AccessLevel.ALL,
),
# RESOURCE: Access to all system resources (generic rule, item=None)
AccessRule(
roleId=sysadminRoleId,
context=AccessRuleContext.RESOURCE,
item=None,
view=True,
read=None,
create=None,
update=None,
delete=None,
),
]
for rule in rules:
db.recordCreate(AccessRule, rule)
logger.info(f"Created {len(rules)} AccessRules for sysadmin role (UI rules via _ensureUiContextRules)")
def _ensureSysAdminAccessRules(db: DatabaseConnector, sysadminRoleId: str) -> None:
"""
Ensure AccessRules exist for the sysadmin role (migration safety).
Creates missing rules without duplicating existing ones.
Args:
db: Database connector instance
sysadminRoleId: Sysadmin role ID
"""
existingRules = db.getRecordset(AccessRule, recordFilter={"roleId": sysadminRoleId})
if not existingRules:
logger.info("No AccessRules found for sysadmin role, creating them")
_createSysAdminAccessRules(db, sysadminRoleId)
if not sysadminRoles:
logger.debug("Sysadmin role migration: no legacy sysadmin role present, nothing to do")
return
# Check for DATA and RESOURCE contexts (UI is handled by _ensureUiContextRules)
existingContexts = {r.get("context") for r in existingRules}
sysadminRoleIds = [str(r.get("id")) for r in sysadminRoles if r.get("id")]
logger.warning(
f"Sysadmin role migration: found {len(sysadminRoleIds)} legacy sysadmin role(s) "
f"in root mandate, migrating to isPlatformAdmin flag"
)
missingRules = []
if AccessRuleContext.DATA.value not in existingContexts:
missingRules.append(AccessRule(
roleId=sysadminRoleId,
context=AccessRuleContext.DATA,
item=None,
view=True,
read=AccessLevel.ALL,
create=AccessLevel.ALL,
update=AccessLevel.ALL,
delete=AccessLevel.ALL,
))
if AccessRuleContext.RESOURCE.value not in existingContexts:
missingRules.append(AccessRule(
roleId=sysadminRoleId,
context=AccessRuleContext.RESOURCE,
item=None,
view=True,
read=None, create=None, update=None, delete=None,
))
# 1) Promote every holder to isPlatformAdmin=True
promoted = 0
for sysadminRoleId in sysadminRoleIds:
umRoleRows = db.getRecordset(
UserMandateRole, recordFilter={"roleId": sysadminRoleId}
)
userMandateIds = [str(r.get("userMandateId")) for r in umRoleRows if r.get("userMandateId")]
if not userMandateIds:
continue
if missingRules:
for rule in missingRules:
db.recordCreate(AccessRule, rule)
logger.info(f"Created {len(missingRules)} missing AccessRules for sysadmin role")
# Resolve userIds via UserMandate
userIds = set()
for umId in userMandateIds:
ums = db.getRecordset(UserMandate, recordFilter={"id": umId})
for um in ums:
uid = um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None)
if uid:
userIds.add(str(uid))
for userId in userIds:
users = db.getRecordset(UserInDB, recordFilter={"id": userId})
if not users:
continue
current = users[0].get("isPlatformAdmin", False)
if not current:
db.recordModify(UserInDB, userId, {"isPlatformAdmin": True})
promoted += 1
logger.warning(
f"Sysadmin role migration: granted isPlatformAdmin=True to user {userId}"
)
# 2) Delete UserMandateRole rows
for umRow in umRoleRows:
rowId = umRow.get("id") if isinstance(umRow, dict) else getattr(umRow, "id", None)
if rowId:
try:
db.recordDelete(UserMandateRole, str(rowId))
except Exception as e:
logger.error(f"Sysadmin role migration: failed to drop UserMandateRole {rowId}: {e}")
# 3) Delete AccessRules
accessRules = db.getRecordset(AccessRule, recordFilter={"roleId": sysadminRoleId})
for ar in accessRules:
arId = ar.get("id") if isinstance(ar, dict) else getattr(ar, "id", None)
if arId:
try:
db.recordDelete(AccessRule, str(arId))
except Exception as e:
logger.error(f"Sysadmin role migration: failed to drop AccessRule {arId}: {e}")
# 4) Delete the Role
try:
db.recordDelete(Role, sysadminRoleId)
except Exception as e:
logger.error(f"Sysadmin role migration: failed to drop Role {sysadminRoleId}: {e}")
logger.warning(
f"Sysadmin role migration: completed; promoted {promoted} user(s) to isPlatformAdmin"
)
def _getRoleId(db: DatabaseConnector, roleLabel: str) -> Optional[str]:
@ -940,8 +975,9 @@ def _createDefaultRoleRules(db: DatabaseConnector) -> None:
Create default role rules for generic access (item = null).
Uses roleId instead of roleLabel.
NOTE: Sysadmin role rules are created separately in _initSysAdminRole().
These default rules cover admin/user/viewer template roles.
NOTE: There is no sysadmin role any more platform/infra authority is
governed by the ``isSysAdmin`` / ``isPlatformAdmin`` flags on the User
record. These default rules cover admin/user/viewer template roles.
Args:
db: Database connector instance
@ -991,15 +1027,16 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
These rules override generic rules for specific tables.
Uses roleId instead of roleLabel.
NOTE: Sysadmin role rules are created separately in _initSysAdminRole().
These table-specific rules cover admin/user/viewer template roles.
NOTE: There is no sysadmin role any more platform/infra authority is
governed by the ``isSysAdmin`` / ``isPlatformAdmin`` flags on the User
record. These table-specific rules cover admin/user/viewer template roles.
Args:
db: Database connector instance
"""
tableRules = []
# Get role IDs for template roles (sysadmin is a separate mandate-level role)
# Get role IDs for template roles (platform authority lives on User flags)
adminId = _getRoleId(db, "admin")
userId = _getRoleId(db, "user")
viewerId = _getRoleId(db, "viewer")
@ -1470,7 +1507,6 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None:
mandateAdminRoleIds = []
mandateUserRoleIds = []
mandateViewerRoleIds = []
sysadminRoleIds = []
mandateRoles = db.getRecordset(
Role,
@ -1487,12 +1523,12 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None:
mandateUserRoleIds.append(roleId)
elif label == "viewer":
mandateViewerRoleIds.append(roleId)
elif label == "sysadmin":
sysadminRoleIds.append(roleId)
# All role IDs per level (template + mandate-instance)
# sysadmin gets ALL UI rules (admin-only + public) — same logic, explicit rules
allAdminRoleIds = ([adminId] if adminId else []) + mandateAdminRoleIds + sysadminRoleIds
# All role IDs per level (template + mandate-instance).
# Admin-only navigation items are governed by these admin roles plus the
# ``isPlatformAdmin`` flag (checked in routes via requirePlatformAdmin),
# NOT by a dedicated platform-level role.
allAdminRoleIds = ([adminId] if adminId else []) + mandateAdminRoleIds
allUserRoleIds = ([userId] if userId else []) + mandateUserRoleIds
allViewerRoleIds = ([viewerId] if viewerId else []) + mandateViewerRoleIds
@ -1860,7 +1896,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
Store resources control which roles can activate features via the Store.
- admin/user: view=True (can see and activate store features)
- viewer: no store access
- sysadmin: covered by generic RESOURCE rule (item=None, view=True)
- isSysAdmin flag bypasses RBAC (rbac.py:getUserPermissions)
Args:
db: Database connector instance
@ -1998,8 +2034,10 @@ def assignInitialUserMemberships(
Assign initial memberships to admin and event users via UserMandate + UserMandateRole.
This is the NEW multi-tenant way of assigning roles.
Hybrid model: Initial users get BOTH the isSysAdmin flag (for system ops)
AND the "admin" + "sysadmin" roles in the root mandate (for RBAC-based admin ops).
Initial users get the "admin" role in the root mandate. Platform-level
authority (cross-mandate governance + infrastructure ops) is conveyed via
the ``isSysAdmin`` / ``isPlatformAdmin`` flags on the User record itself
(see ``initAdminUser`` / ``initEventUser``).
Args:
db: Database connector instance
@ -2019,12 +2057,6 @@ def assignInitialUserMemberships(
logger.warning(f"No mandate-level role found for mandate {mandateId}, skipping membership assignment")
return
# Find sysadmin role in root mandate (created by _initSysAdminRole)
sysadminRole = next((r for r in mandateRoles if r.get("roleLabel") == "sysadmin"), None)
sysadminRoleId = sysadminRole.get("id") if sysadminRole else None
if not sysadminRoleId:
logger.warning("Sysadmin role not found in root mandate - run _initSysAdminRole first")
for userId, userName in [(adminUserId, "admin"), (eventUserId, "event")]:
# Check if UserMandate already exists
existingMemberships = db.getRecordset(
@ -2060,20 +2092,6 @@ def assignInitialUserMemberships(
db.recordCreate(UserMandateRole, userMandateRole)
logger.info(f"Assigned admin role to {userName} user in mandate")
# Assign sysadmin role (in addition to admin role)
if sysadminRoleId:
existingSysadminRoles = db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId, "roleId": sysadminRoleId}
)
if not existingSysadminRoles:
sysadminMandateRole = UserMandateRole(
userMandateId=userMandateId,
roleId=sysadminRoleId
)
db.recordCreate(UserMandateRole, sysadminMandateRole)
logger.info(f"Assigned sysadmin role to {userName} user in root mandate")
def _getPasswordHash(password: Optional[str]) -> Optional[str]:
"""

View file

@ -677,6 +677,7 @@ class AppObjects:
externalUsername: str = None,
externalEmail: str = None,
isSysAdmin: bool = False,
isPlatformAdmin: bool = False,
addExternalIdentityConnection: bool = True,
) -> User:
"""
@ -714,6 +715,7 @@ class AppObjects:
language=language,
enabled=enabled,
isSysAdmin=isSysAdmin,
isPlatformAdmin=isPlatformAdmin,
authenticationAuthority=authenticationAuthority,
hashedPassword=self._getPasswordHash(password) if password else None,
)
@ -755,15 +757,21 @@ class AppObjects:
logger.error(f"Unexpected error creating user: {str(e)}")
raise ValueError(f"Failed to create user: {str(e)}")
def updateUser(self, userId: str, updateData: Union[Dict[str, Any], User], allowSysAdminChange: bool = False) -> User:
def updateUser(
self,
userId: str,
updateData: Union[Dict[str, Any], User],
allowAdminFlagChange: bool = False,
) -> User:
"""Update a user's information.
Args:
userId: ID of the user to update
updateData: User data to update (dict or User model)
allowSysAdminChange: If True, allows changing isSysAdmin field.
Only set to True when called by a SysAdmin explicitly
changing another user's admin status.
allowAdminFlagChange: If True, allows changing the privileged platform
flags ``isSysAdmin`` and ``isPlatformAdmin``.
Only set to True when called by a Platform Admin
explicitly changing another user's admin status.
"""
try:
# Get user
@ -771,20 +779,35 @@ class AppObjects:
if not user:
raise ValueError(f"User {userId} not found")
# Convert updateData to dict if it's a User model
# Convert updateData to dict if it's a User model.
#
# IMPORTANT: When the route layer passes a Pydantic ``User`` instance,
# ``model_dump()`` returns ALL fields — including those the client
# never sent — populated with Pydantic defaults (e.g. ``isSysAdmin=False``).
# That historical pattern caused silent flag flips on inline-toggles.
#
# The PUT route now ships a plain dict carrying ONLY the explicitly
# changed fields, so this branch should rarely fire; however internal
# callers (``disableUser`` / ``enableUser`` / migration scripts) still
# use dict-style partials and must remain partial-safe.
if isinstance(updateData, User):
updateDict = updateData.model_dump()
updateDict = updateData.model_dump(exclude_unset=True)
# Fallback for legacy callers that constructed a fully-defaulted
# User: if nothing was marked as explicitly set, treat the dump
# as authoritative but DROP privileged flags unconditionally
# unless allowAdminFlagChange is True.
if not updateDict:
updateDict = updateData.model_dump()
else:
updateDict = updateData.copy() if isinstance(updateData, dict) else updateData
updateDict = updateData.copy() if isinstance(updateData, dict) else dict(updateData)
# Remove id field from updateDict if present - we'll use userId from parameter
updateDict.pop("id", None)
# SECURITY: Protect sensitive fields from being overwritten by profile updates.
# These fields should only be changed explicitly by admins, not through
# profile forms where they might be sent as default values (e.g., isSysAdmin=False).
protectedFields = ["isSysAdmin"]
if not allowSysAdminChange:
# SECURITY: Protect privileged platform flags from accidental
# overwrite via profile forms or partial payloads from clients
# whose model defaults could pull the value down to False.
protectedFields = ["isSysAdmin", "isPlatformAdmin"]
if not allowAdminFlagChange:
for field in protectedFields:
updateDict.pop(field, None)
@ -1456,16 +1479,56 @@ class AppObjects:
return Mandate(**filteredMandates[0])
def createMandate(self, name: str, label: str = None, enabled: bool = True) -> Mandate:
def _existingMandateNames(self, excludeId: Optional[str] = None) -> List[str]:
"""Return all mandate.name values currently in the DB (optionally excluding one id)."""
out: List[str] = []
for r in self.db.getRecordset(Mandate):
if excludeId and str(r.get("id")) == str(excludeId):
continue
n = r.get("name")
if n:
out.append(n)
return out
def _generateUniqueMandateName(self, label: str, excludeId: Optional[str] = None) -> str:
"""Generate a slug from *label* that is unique across all mandates (Phase 3 helper)."""
from modules.shared.mandateNameUtils import allocateUniqueMandateSlug, slugifyMandateName
base = slugifyMandateName(label or "")
return allocateUniqueMandateSlug(base, self._existingMandateNames(excludeId=excludeId))
def createMandate(self, name: str = None, label: str = None, enabled: bool = True) -> Mandate:
"""
Creates a new mandate if user has permission.
Automatically copies system template roles (admin, user, viewer) to the new mandate.
``label`` (Voller Name) is required (non-empty). If ``name`` (Kurzzeichen) is omitted or empty,
a unique slug is generated from the label; otherwise it is validated and uniqueness-checked.
"""
if not self.checkRbacPermission(Mandate, "create"):
raise PermissionError("No permission to create mandates")
# Create mandate data using model
mandateData = Mandate(name=name, label=label, enabled=enabled)
from modules.shared.mandateNameUtils import isValidMandateName
effLabel = (label or "").strip() if label is not None else ""
if not effLabel and name:
effLabel = (name or "").strip()
if not effLabel:
raise ValueError("Mandate label (Voller Name) is required")
rawName = (name or "").strip() if name else ""
if not rawName:
rawName = self._generateUniqueMandateName(effLabel)
else:
if not isValidMandateName(rawName):
raise ValueError(
"Mandate Kurzzeichen must be 232 characters: lowercase az, digits, "
"hyphens only (single-hyphen segments)."
)
if rawName in self._existingMandateNames():
raise ValueError(f"Mandate Kurzzeichen '{rawName}' is already in use")
mandateData = Mandate(name=rawName, label=effLabel, enabled=enabled)
# Create mandate record
createdRecord = self.db.recordCreate(Mandate, mandateData)
@ -1484,24 +1547,31 @@ class AppObjects:
return Mandate(**createdRecord)
def _provisionMandateForUser(self, userId: str, mandateName: str, planKey: str) -> Dict[str, Any]:
def _provisionMandateForUser(self, userId: str, mandateLabel: str, planKey: str) -> Dict[str, Any]:
"""
Atomic provisioning: create Mandate + UserMandate + Subscription + auto-create FeatureInstances.
Internal method bypasses RBAC (used during registration when user has no permissions yet).
``mandateLabel`` is the display name (Voller Name); a unique slug ``name`` (Kurzzeichen) is derived.
"""
from modules.datamodels.datamodelSubscription import MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS
from modules.datamodels.datamodelFeatures import FeatureInstance
from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.system.registry import loadFeatureMainModules
plan = BUILTIN_PLANS.get(planKey)
if not plan:
raise ValueError(f"Unknown plan: {planKey}")
effLabel = (mandateLabel or "").strip()
if not effLabel:
raise ValueError("mandateLabel (Voller Name) is required for provisioning")
uniqueName = self._generateUniqueMandateName(effLabel)
mandateData = Mandate(
name=mandateName,
label=mandateName,
name=uniqueName,
label=effLabel,
enabled=True,
isSystem=False,
)
@ -1674,7 +1744,17 @@ class AppObjects:
return activated
def updateMandate(self, mandateId: str, updateData: Dict[str, Any]) -> Mandate:
"""Updates a mandate if user has access."""
"""
Updates a mandate if user has access.
Field-level rules:
- ``id`` always immutable.
- ``isSystem`` only sysadmin.
- ``name`` (Kurzzeichen) only platform/sysadmin; format and uniqueness are validated.
- ``label`` (Voller Name) must be non-empty if provided.
"""
from modules.shared.mandateNameUtils import isValidMandateName
try:
# First check if user has permission to modify mandates
if not self.checkRbacPermission(Mandate, "update", mandateId):
@ -1685,11 +1765,33 @@ class AppObjects:
if not mandate:
raise ValueError(f"Mandate {mandateId} not found")
_isSysAdmin = bool(getattr(self.currentUser, "isSysAdmin", False))
_isPlatformAdmin = bool(getattr(self.currentUser, "isPlatformAdmin", False))
_protectedFields = {"id"}
if not getattr(self.currentUser, "isSysAdmin", False):
if not _isSysAdmin:
_protectedFields.add("isSystem")
if not (_isSysAdmin or _isPlatformAdmin):
_protectedFields.add("name")
_sanitizedData = {k: v for k, v in updateData.items() if k not in _protectedFields}
if "name" in _sanitizedData:
newName = (_sanitizedData["name"] or "").strip()
if not isValidMandateName(newName):
raise ValueError(
"Mandate Kurzzeichen must be 232 characters: lowercase az, digits, "
"hyphens only (single-hyphen segments)."
)
if newName != mandate.name and newName in self._existingMandateNames(excludeId=mandateId):
raise ValueError(f"Mandate Kurzzeichen '{newName}' is already in use")
_sanitizedData["name"] = newName
if "label" in _sanitizedData:
newLabel = (_sanitizedData["label"] or "").strip()
if not newLabel:
raise ValueError("Mandate Voller Name (label) must not be empty.")
_sanitizedData["label"] = newLabel
# Update mandate data using model
updatedData = mandate.model_dump()
updatedData.update(_sanitizedData)

View file

@ -655,17 +655,27 @@ class ChatObjects:
totalPages=totalPages
)
def getLastMessageTimestamp(self, workflowId: str) -> Optional[str]:
"""Return the latest publishedAt/sysCreatedAt from ChatMessage for a workflow."""
def getLastMessageTimestamp(self, workflowId: str) -> Optional[float]:
"""
Return the latest publishedAt/sysCreatedAt from ChatMessage for a workflow
as UTC seconds (float) matches the timestamp format used across the
rest of the chat data model (lastActivity, startedAt, publishedAt).
"""
messages = self._getRecordset(ChatMessage, recordFilter={"workflowId": workflowId})
if not messages:
return None
latest = None
latest: Optional[float] = None
for msg in messages:
ts = msg.get("publishedAt") or msg.get("sysCreatedAt")
if ts and (latest is None or str(ts) > str(latest)):
raw = msg.get("publishedAt") or msg.get("sysCreatedAt")
if raw is None:
continue
try:
ts = float(raw)
except (TypeError, ValueError):
continue
if latest is None or ts > latest:
latest = ts
return str(latest) if latest else None
return latest
def searchWorkflowsByContent(self, query: str, limit: int = 50) -> List[str]:
"""Return workflow IDs whose messages contain the query string (case-insensitive)."""
@ -712,6 +722,8 @@ class ChatObjects:
return ChatWorkflow(
id=workflow["id"],
featureInstanceId=workflow.get("featureInstanceId"),
linkedWorkflowId=workflow.get("linkedWorkflowId"),
status=workflow.get("status", "running"),
name=workflow.get("name"),
currentRound=_toInt(workflow.get("currentRound")),
@ -728,6 +740,54 @@ class ChatObjects:
logger.error(f"getWorkflow: data validation failed for {workflowId}: {e}")
return None
def getWorkflowByLink(
self,
featureInstanceId: str,
linkedWorkflowId: str,
) -> Optional[ChatWorkflow]:
"""Return the ChatWorkflow linked to (featureInstanceId, linkedWorkflowId), if any.
Used by editor-style features (e.g. GraphicalEditor AI editor chat) to
find the persisted chat for a specific external entity (Automation2Workflow).
Falls under the same RBAC as ``getWorkflow``.
"""
if not featureInstanceId or not linkedWorkflowId:
return None
rows = self._getRecordset(
ChatWorkflow,
recordFilter={
"featureInstanceId": featureInstanceId,
"linkedWorkflowId": linkedWorkflowId,
},
) or []
if not rows:
return None
# Return the most recently active one if multiple ever exist (defensive).
rows.sort(key=lambda r: float(r.get("lastActivity") or r.get("startedAt") or 0), reverse=True)
return self.getWorkflow(rows[0]["id"])
def getOrCreateLinkedWorkflow(
self,
featureInstanceId: str,
linkedWorkflowId: str,
name: Optional[str] = None,
) -> ChatWorkflow:
"""Find or create the ChatWorkflow linked to a specific external entity.
Editor-style features call this once at the start of a chat exchange to
guarantee a 1:1 mapping between (featureInstanceId, linkedWorkflowId)
and a persisted ChatWorkflow row.
"""
existing = self.getWorkflowByLink(featureInstanceId, linkedWorkflowId)
if existing:
return existing
return self.createWorkflow({
"featureInstanceId": featureInstanceId,
"linkedWorkflowId": linkedWorkflowId,
"status": "active",
"name": name or "",
})
def createWorkflow(self, workflowData: Dict[str, Any]) -> ChatWorkflow:
"""Creates a new workflow if user has permission."""
if not self.checkRbacPermission(ChatWorkflow, "create"):
@ -775,6 +835,8 @@ class ChatObjects:
# Convert to ChatWorkflow model (empty related data for new workflow)
return ChatWorkflow(
id=created["id"],
featureInstanceId=created.get("featureInstanceId"),
linkedWorkflowId=created.get("linkedWorkflowId"),
status=created.get("status", "running"),
name=created.get("name"),
currentRound=created.get("currentRound", 0) or 0,

View file

@ -635,12 +635,8 @@ class ComponentObjects:
# Prompt methods
def _isSysAdmin(self) -> bool:
"""Check if the current user has sysadmin role (or isSysAdmin flag as fallback)."""
from modules.auth.authentication import _hasSysAdminRole
userId = getattr(self.currentUser, 'id', None)
if userId and _hasSysAdminRole(str(userId)):
return True
return hasattr(self.currentUser, 'isSysAdmin') and self.currentUser.isSysAdmin
"""Check if the current user has the isSysAdmin flag (infrastructure operator)."""
return bool(getattr(self.currentUser, 'isSysAdmin', False))
def _enrichPromptsWithPermissions(self, prompts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Enrich prompts with row-level _permissions based on ownership and isSystem flag.
@ -1408,6 +1404,24 @@ class ComponentObjects:
self._validateFolderName(newName, folder.get("parentId"), excludeFolderId=folderId)
return self.db.recordModify(FileFolder, folderId, {"name": newName})
def updateFolder(self, folderId: str, updateData: Dict[str, Any]) -> bool:
"""
Update folder metadata (e.g. ``scope``, ``neutralize``). Owner-only,
same access model as renameFolder/moveFolder. Use ``renameFolder`` for
``name`` changes (uniqueness validation) and ``moveFolder`` for
``parentId`` changes (cycle/uniqueness validation).
"""
if not updateData:
return True
folder = self.getFolder(folderId)
if not folder:
raise FileNotFoundError(f"Folder {folderId} not found")
forbiddenKeys = {"id", "sysCreatedBy", "sysCreatedAt", "sysUpdatedAt"}
cleaned: Dict[str, Any] = {k: v for k, v in updateData.items() if k not in forbiddenKeys}
if "name" in cleaned:
self._validateFolderName(cleaned["name"], folder.get("parentId"), excludeFolderId=folderId)
return self.db.recordModify(FileFolder, folderId, cleaned)
def moveFolder(self, folderId: str, targetParentId: Optional[str] = None) -> bool:
"""Move a folder to a new parent, with circular reference and unique name checks."""
folder = self.getFolder(folderId)

View file

@ -181,21 +181,26 @@ class VoiceObjects:
"error": str(e)
}
async def translateText(self, text: str, sourceLanguage: str = "de",
async def translateText(self, text: str,
sourceLanguage: Optional[str] = None,
targetLanguage: str = "en") -> Dict[str, Any]:
"""
Translate text using Google Cloud Translation API.
Args:
text: Text to translate
sourceLanguage: Source language code (e.g., 'de', 'en')
targetLanguage: Target language code (e.g., 'en', 'de')
sourceLanguage: Source language ISO code (e.g. 'de', 'en'); pass None
or 'auto' to let Google auto-detect.
targetLanguage: Target language ISO code (e.g. 'en', 'de')
Returns:
Dict containing translated text and metadata
"""
try:
logger.info(f"🌐 Translation request: '{text}' ({sourceLanguage} -> {targetLanguage})")
logger.info(
f"🌐 Translation request: '{text}' "
f"({sourceLanguage or 'auto'} -> {targetLanguage})"
)
if not text.strip():
return {
@ -333,35 +338,10 @@ class VoiceObjects:
"error": str(e)
}
# Language and Voice Information
async def getAvailableLanguages(self) -> Dict[str, Any]:
"""
Get available languages from Google Cloud Text-to-Speech.
Returns:
Dict containing success status and list of available languages
"""
try:
logger.info("🌐 Getting available languages from Google Cloud TTS")
connector = self._getGoogleSpeechConnector()
result = await connector.getAvailableLanguages()
if result["success"]:
logger.info(f"✅ Found {len(result['languages'])} available languages")
else:
logger.warning(f"⚠️ Failed to get languages: {result.get('error', 'Unknown error')}")
return result
except Exception as e:
logger.error(f"❌ Error getting available languages: {e}")
return {
"success": False,
"error": str(e),
"languages": []
}
# Voice Information
# Note: Available languages live in the central voice catalog
# (modules.shared.voiceCatalog); voice picks per language stay live from
# Google so users can see all available speakers per locale.
async def getAvailableVoices(self, languageCode: Optional[str] = None) -> Dict[str, Any]:
"""

View file

@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from modules.auth import limiter
from modules.auth.authentication import requireSysAdminRole
from modules.auth.authentication import requireSysAdmin
from modules.datamodels.datamodelUam import User
from modules.system.databaseHealth import (
_cleanAllOrphans,
@ -41,7 +41,7 @@ class OrphanCleanRequest(BaseModel):
def getDatabaseTableStats(
request: Request,
db: Optional[str] = None,
currentUser: User = Depends(requireSysAdminRole),
currentUser: User = Depends(requireSysAdmin),
) -> Dict[str, Any]:
"""Table statistics from pg_stat_user_tables (optional filter by database name)."""
rows = _getTableStats(dbFilter=db)
@ -53,7 +53,7 @@ def getDatabaseTableStats(
def getDatabaseOrphans(
request: Request,
db: Optional[str] = None,
currentUser: User = Depends(requireSysAdminRole),
currentUser: User = Depends(requireSysAdmin),
) -> Dict[str, Any]:
"""FK orphan scan (optional filter by source database name)."""
rows = _scanOrphans(dbFilter=db)
@ -65,7 +65,7 @@ def getDatabaseOrphans(
def postDatabaseOrphansClean(
request: Request,
body: OrphanCleanRequest,
currentUser: User = Depends(requireSysAdminRole),
currentUser: User = Depends(requireSysAdmin),
) -> Dict[str, Any]:
"""Delete orphaned rows for a single FK relationship."""
try:
@ -90,7 +90,7 @@ def postDatabaseOrphansClean(
@limiter.limit("2/minute")
def postDatabaseOrphansCleanAll(
request: Request,
currentUser: User = Depends(requireSysAdminRole),
currentUser: User = Depends(requireSysAdmin),
) -> Dict[str, Any]:
"""Run orphan cleanup for every relationship that currently has orphans."""
results: List[dict] = _cleanAllOrphans()

View file

@ -9,7 +9,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Request, status
from modules.auth import limiter
from modules.auth.authentication import requireSysAdminRole
from modules.auth.authentication import requirePlatformAdmin
from modules.datamodels.datamodelUam import User
from modules.security.rootAccess import getRootDbAppConnector
@ -25,7 +25,7 @@ router = APIRouter(
@limiter.limit("30/minute")
def listDemoConfigs(
request: Request,
currentUser: User = Depends(requireSysAdminRole),
currentUser: User = Depends(requirePlatformAdmin),
) -> dict:
"""List all available demo configurations."""
from modules.demoConfigs import _getAvailableDemoConfigs
@ -41,7 +41,7 @@ def listDemoConfigs(
def loadDemoConfig(
code: str,
request: Request,
currentUser: User = Depends(requireSysAdminRole),
currentUser: User = Depends(requirePlatformAdmin),
) -> dict:
"""Load (create) a demo configuration. Idempotent."""
from modules.demoConfigs import _getDemoConfigByCode
@ -66,7 +66,7 @@ def loadDemoConfig(
def removeDemoConfig(
code: str,
request: Request,
currentUser: User = Depends(requireSysAdminRole),
currentUser: User = Depends(requirePlatformAdmin),
) -> dict:
"""Remove all data created by a demo configuration."""
from modules.demoConfigs import _getDemoConfigByCode

View file

@ -20,7 +20,7 @@ from pydantic import BaseModel, Field
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
from modules.routes.routeHelpers import _applyFiltersAndSort, handleFilterValuesInMemory, handleIdsInMemory
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdminRole
from modules.auth import limiter, getRequestContext, RequestContext, requirePlatformAdmin
from modules.datamodels.datamodelUam import User, UserInDB
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
from modules.interfaces.interfaceDbApp import getRootInterface
@ -95,9 +95,16 @@ def list_features(
"""
try:
# Features come from the RBAC Catalog (registered at startup from feature containers)
# NOT from the database - features are code-defined, not user-created
# NOT from the database - features are code-defined, not user-created.
# Hide meta-features (instantiable=False, e.g. ``system``) and soft-
# disabled features (enabled=False) so they don't appear in selection
# dropdowns like Admin > Feature-Instanzen > Neue Instanz.
catalogService = getCatalogService()
features = catalogService.getFeatureDefinitions()
features = [
f for f in features
if f.get("instantiable", True) and f.get("enabled", True)
]
return features
except Exception as e:
@ -351,7 +358,7 @@ def create_feature(
code: str = Query(..., description="Unique feature code"),
label: Dict[str, str] = None,
icon: str = Query("mdi-puzzle", description="Icon identifier"),
sysAdmin: User = Depends(requireSysAdminRole)
sysAdmin: User = Depends(requirePlatformAdmin)
) -> Dict[str, Any]:
"""
Create a new feature definition.
@ -520,7 +527,7 @@ def get_feature_instance(
# Verify mandate access (unless SysAdmin)
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Access denied to this feature instance")
@ -660,14 +667,14 @@ def delete_feature_instance(
# Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Access denied to this feature instance")
)
# Check mandate admin permission
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
if not _hasMandateAdminRole(context) and not context.isPlatformAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Mandate-Admin role required to delete feature instances")
@ -727,14 +734,14 @@ def updateFeatureInstance(
# Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Access denied to this feature instance")
)
# Check mandate admin permission
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
if not _hasMandateAdminRole(context) and not context.isPlatformAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Mandate-Admin role required to update feature instances")
@ -810,14 +817,14 @@ def sync_instance_roles(
# Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Access denied to this feature instance")
)
# Check admin permission (Mandate-Admin or Feature-Admin)
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
if not _hasMandateAdminRole(context) and not context.isPlatformAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Admin role required to sync roles")
@ -863,10 +870,14 @@ def _syncInstanceWorkflows(
instances created before template workflows were defined, or when
the initial copy failed silently.
SysAdmin only.
PlatformAdmin only.
"""
try:
requireSysAdminRole(context.user)
if not context.isPlatformAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Platform admin privileges required",
)
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
@ -975,7 +986,7 @@ def list_template_roles(
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
sysAdmin: User = Depends(requireSysAdminRole),
sysAdmin: User = Depends(requirePlatformAdmin),
):
"""List global template roles with pagination support."""
try:
@ -1035,7 +1046,7 @@ def create_template_role(
roleLabel: str = Query(..., description="Role label (e.g., 'admin', 'viewer')"),
featureCode: str = Query(..., description="Feature code this role belongs to"),
description: Dict[str, str] = None,
sysAdmin: User = Depends(requireSysAdminRole)
sysAdmin: User = Depends(requirePlatformAdmin)
) -> Dict[str, Any]:
"""
Create a global template role for a feature.
@ -1145,7 +1156,7 @@ def list_feature_instance_users(
# Verify mandate access (unless SysAdmin)
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Access denied to this feature instance")
@ -1259,14 +1270,14 @@ def add_user_to_feature_instance(
# Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Access denied to this feature instance")
)
# Check admin permission
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
if not _hasMandateAdminRole(context) and not context.isPlatformAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Admin role required to add users to feature instances")
@ -1367,14 +1378,14 @@ def remove_user_from_feature_instance(
# Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Access denied to this feature instance")
)
# Check admin permission
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
if not _hasMandateAdminRole(context) and not context.isPlatformAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Admin role required to remove users from feature instances")
@ -1457,14 +1468,14 @@ def update_feature_instance_user_roles(
# Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Access denied to this feature instance")
)
# Check admin permission
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
if not _hasMandateAdminRole(context) and not context.isPlatformAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Admin role required to update user roles")
@ -1565,7 +1576,7 @@ def get_feature_instance_available_roles(
# Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Access denied to this feature instance")
@ -1668,7 +1679,7 @@ def _renameFeatureInstance(
userId = str(context.user.id)
isInstanceAdmin = False
if context.hasSysAdminRole:
if context.isPlatformAdmin:
isInstanceAdmin = True
else:
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
@ -1707,7 +1718,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
A user is mandate admin if they have the 'admin' role at mandate level.
"""
if context.hasSysAdminRole:
if context.isPlatformAdmin:
return True
if not context.roleIds:

View file

@ -12,7 +12,7 @@ import logging
from datetime import datetime
from fastapi import APIRouter, HTTPException, Depends, Request, Query
from fastapi.responses import PlainTextResponse
from modules.auth import limiter, requireSysAdminRole
from modules.auth import limiter, requireSysAdmin
from modules.shared.configuration import APP_CONFIG
from modules.datamodels.datamodelUam import User
@ -63,7 +63,7 @@ def _readLastNLines(filePath: str, n: int) -> list[str]:
def getLogEntries(
request: Request,
count: int = Query(default=200, ge=1, le=50000, description="Number of log entries to return"),
currentUser: User = Depends(requireSysAdminRole),
currentUser: User = Depends(requireSysAdmin),
) -> dict:
"""
Get the last N log entries from the gateway log files.
@ -104,7 +104,7 @@ def getLogEntries(
def downloadLog(
request: Request,
count: int = Query(default=1000, ge=1, le=100000, description="Number of log entries to download"),
currentUser: User = Depends(requireSysAdminRole),
currentUser: User = Depends(requireSysAdmin),
) -> PlainTextResponse:
"""
Download the last N log entries as a plain text file.

View file

@ -17,7 +17,7 @@ import logging
import json
import math
from modules.auth import limiter, getRequestContext, requireSysAdminRole, RequestContext
from modules.auth import limiter, getRequestContext, requirePlatformAdmin, RequestContext
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role
from modules.datamodels.datamodelMembership import UserMandate
@ -242,7 +242,7 @@ def get_all_permissions(
logger.debug(f"UI/RESOURCE permissions: User has {len(roleIds)} roles across all mandates")
if not roleIds and not reqContext.hasSysAdminRole:
if not roleIds and not reqContext.isPlatformAdmin:
# No roles at all, return empty permissions
for ctx in contextsToFetch:
result[ctx.value.lower()] = {}
@ -362,7 +362,7 @@ def get_access_rules(
- List of AccessRule objects
"""
try:
isSysAdmin = reqContext.hasSysAdminRole
isSysAdmin = reqContext.isPlatformAdmin
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
@ -487,7 +487,7 @@ def get_access_rules_by_role(
- List of AccessRule objects for the specified role
"""
try:
isSysAdmin = reqContext.hasSysAdminRole
isSysAdmin = reqContext.isPlatformAdmin
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
@ -534,7 +534,7 @@ def get_access_rule(
- AccessRule object
"""
try:
isSysAdmin = reqContext.hasSysAdminRole
isSysAdmin = reqContext.isPlatformAdmin
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
@ -585,7 +585,7 @@ def create_access_rule(
- Created AccessRule object
"""
try:
isSysAdmin = reqContext.hasSysAdminRole
isSysAdmin = reqContext.isPlatformAdmin
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
@ -665,7 +665,7 @@ def update_access_rule(
- Updated AccessRule object
"""
try:
isSysAdmin = reqContext.hasSysAdminRole
isSysAdmin = reqContext.isPlatformAdmin
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
@ -753,7 +753,7 @@ def delete_access_rule(
- Success message
"""
try:
isSysAdmin = reqContext.hasSysAdminRole
isSysAdmin = reqContext.isPlatformAdmin
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
@ -836,7 +836,7 @@ def list_roles(
- List of role dictionaries with role label, description, user count, and computed scopeType
"""
try:
isSysAdmin = reqContext.hasSysAdminRole
isSysAdmin = reqContext.isPlatformAdmin
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
@ -1017,7 +1017,7 @@ def create_role(
- Created role dictionary
"""
try:
isSysAdmin = reqContext.hasSysAdminRole
isSysAdmin = reqContext.isPlatformAdmin
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
@ -1076,7 +1076,7 @@ def get_role(
- Role dictionary
"""
try:
isSysAdmin = reqContext.hasSysAdminRole
isSysAdmin = reqContext.isPlatformAdmin
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
@ -1137,7 +1137,7 @@ def update_role(
- Updated role dictionary
"""
try:
isSysAdmin = reqContext.hasSysAdminRole
isSysAdmin = reqContext.isPlatformAdmin
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
@ -1201,7 +1201,7 @@ def delete_role(
- Success message
"""
try:
isSysAdmin = reqContext.hasSysAdminRole
isSysAdmin = reqContext.isPlatformAdmin
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
@ -1357,7 +1357,7 @@ def getCatalogObjects(
def cleanup_duplicate_access_rules(
request: Request,
dryRun: bool = Query(True, description="If true, only report duplicates without deleting"),
currentUser: User = Depends(requireSysAdminRole)
currentUser: User = Depends(requirePlatformAdmin)
) -> dict:
"""
Find and remove duplicate AccessRules.

View file

@ -75,7 +75,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
Loads roles independently from request context (context.roleIds may be empty
when no X-Mandate-Id header is sent, e.g., on admin pages).
"""
if context.hasSysAdminRole:
if context.isPlatformAdmin:
return True
try:
rootInterface = getRootInterface()
@ -123,7 +123,7 @@ def listUsersForOverview(
try:
interface = getRootInterface()
if context.hasSysAdminRole and not context.mandateId:
if context.isPlatformAdmin and not context.mandateId:
# SysAdmin without mandate context: all users
allUsers = interface.getAllUsers()
elif context.mandateId:
@ -164,6 +164,7 @@ def listUsersForOverview(
"email": userData.get("email"),
"fullName": userData.get("fullName"),
"isSysAdmin": userData.get("isSysAdmin", False),
"isPlatformAdmin": userData.get("isPlatformAdmin", False),
"enabled": userData.get("enabled", True),
})
@ -217,7 +218,7 @@ def getUserAccessOverview(
interface = getRootInterface()
# MandateAdmin: verify the requested user shares at least one admin mandate
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
# Get admin's mandate IDs
adminMandateIds = []
userMandates = interface.getUserMandates(str(context.user.id))
@ -258,6 +259,7 @@ def getUserAccessOverview(
"email": user.email,
"fullName": user.fullName,
"isSysAdmin": user.isSysAdmin,
"isPlatformAdmin": getattr(user, "isPlatformAdmin", False),
"enabled": user.enabled,
}
@ -481,7 +483,8 @@ def getUserAccessOverview(
return {
"user": userInfo,
"isSysAdmin": False,
"isSysAdmin": bool(getattr(user, "isSysAdmin", False)),
"isPlatformAdmin": bool(getattr(user, "isPlatformAdmin", False)),
"roles": allRoles,
"mandates": mandatesInfo,
"uiAccess": uiAccess,

View file

@ -131,8 +131,7 @@ def _enrichUserAndInstanceLabels(
def _requireAuditAccess(context: RequestContext):
"""Raise 403 unless user has mandate-admin or compliance-viewer access."""
from modules.auth.authentication import _hasSysAdminRole
if _hasSysAdminRole(str(context.user.id)):
if context.isPlatformAdmin:
return
from modules.interfaces.interfaceDbApp import getInterface

View file

@ -17,7 +17,7 @@ from datetime import date, datetime, timezone
from pydantic import BaseModel, Field
# Import auth module
from modules.auth import limiter, requireSysAdminRole, getRequestContext, RequestContext
from modules.auth import limiter, requirePlatformAdmin, getRequestContext, RequestContext
# Import billing components
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface, _getRootInterface
@ -86,8 +86,7 @@ def _getBillingDataScope(user) -> BillingDataScope:
"""
scope = BillingDataScope(userId=user.id)
from modules.auth.authentication import _hasSysAdminRole
if _hasSysAdminRole(str(user.id)):
if bool(getattr(user, "isPlatformAdmin", False)):
scope.isGlobalAdmin = True
return scope
@ -141,8 +140,8 @@ def _getBillingDataScope(user) -> BillingDataScope:
def _isAdminOfMandate(ctx: RequestContext, targetMandateId: str) -> bool:
"""Check if user is SysAdmin or admin of the specified mandate."""
if ctx.hasSysAdminRole:
"""Check if user is PlatformAdmin or admin of the specified mandate."""
if ctx.isPlatformAdmin:
return True
try:
from modules.interfaces.interfaceDbApp import getRootInterface
@ -734,7 +733,7 @@ def addCredit(
targetMandateId: str = Path(..., description="Mandate ID"),
creditRequest: CreditAddRequest = Body(...),
ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdminRole)
_admin = Depends(requirePlatformAdmin)
):
"""
Add credit to a billing account (SysAdmin only).
@ -1461,7 +1460,7 @@ def getTransactionsAdmin(
def getMandateViewBalances(
request: Request,
ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdminRole)
_admin = Depends(requirePlatformAdmin)
):
"""
Get mandate-level balances (SysAdmin only).
@ -1484,7 +1483,7 @@ def getMandateViewTransactions(
request: Request,
limit: int = Query(default=100, ge=1, le=1000),
ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdminRole)
_admin = Depends(requirePlatformAdmin)
):
"""
Get all transactions across mandates (SysAdmin only).

View file

@ -8,7 +8,6 @@ import json
# Import auth module
from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext
from modules.auth.authentication import _hasSysAdminRole
# Import interfaces
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
@ -545,7 +544,7 @@ def _updateFolderScope(
validScopes = {"personal", "featureInstance", "mandate", "global"}
if scope not in validScopes:
raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {validScopes}")
if scope == "global" and not _hasSysAdminRole(context.user):
if scope == "global" and not context.isSysAdmin:
raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope"))
try:
mgmt = interfaceDbManagement.getInterface(
@ -847,7 +846,7 @@ def updateFileScope(
if scope not in validScopes:
raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {validScopes}")
if scope == "global" and not context.hasSysAdminRole:
if scope == "global" and not context.isSysAdmin:
raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope"))
managementInterface = interfaceDbManagement.getInterface(
@ -1041,7 +1040,7 @@ def update_file(
detail=f"File with ID {fileId} not found"
)
if safeData.get("scope") == "global" and not _hasSysAdminRole(str(currentUser.id)):
if safeData.get("scope") == "global" and not getattr(currentUser, "isSysAdmin", False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Only sysadmins can set global scope"),

View file

@ -5,7 +5,8 @@ Mandate routes for the backend API.
Implements the endpoints for mandate management.
MULTI-TENANT:
- Mandate CRUD is SysAdmin-only (mandates are system resources)
- Mandate create/delete and cross-mandate ops require PlatformAdmin
- Mandate read/update: PlatformAdmin or Mandate-Admin (label-only for the latter)
- User management within mandates is Mandate-Admin (add/remove users)
"""
@ -17,7 +18,7 @@ import json
from pydantic import BaseModel, Field
# Import auth module
from modules.auth import limiter, requireSysAdminRole, getRequestContext, getCurrentUser, RequestContext
from modules.auth import limiter, requirePlatformAdmin, getRequestContext, getCurrentUser, RequestContext
# Import interfaces
import modules.interfaces.interfaceDbApp as interfaceDbApp
@ -33,6 +34,8 @@ from modules.datamodels.datamodelPagination import PaginationParams, PaginatedRe
from modules.routes.routeNotifications import create_access_change_notification
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
from modules.shared.i18nRegistry import apiRouteContext
from modules.shared.mandateNameUtils import isValidMandateName
routeApiMsg = apiRouteContext("routeDataMandates")
@ -101,8 +104,8 @@ def get_mandates(
"""
try:
# Check admin access
isSysAdmin = context.hasSysAdminRole
if not isSysAdmin:
isPlatformAdmin = context.isPlatformAdmin
if not isPlatformAdmin:
adminMandateIds = _getAdminMandateIds(context)
if not adminMandateIds:
raise HTTPException(
@ -135,7 +138,7 @@ def get_mandates(
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
if isSysAdmin:
if isPlatformAdmin:
crossPagination = parseCrossFilterPagination(column, pagination)
try:
from fastapi.responses import JSONResponse
@ -155,7 +158,7 @@ def get_mandates(
return handleFilterValuesInMemory(mandateItems, column, pagination)
if mode == "ids":
if isSysAdmin:
if isPlatformAdmin:
return handleIdsMode(appInterface.db, Mandate, pagination)
else:
mandateItems = []
@ -165,7 +168,7 @@ def get_mandates(
mandateItems.append(m.model_dump() if hasattr(m, 'model_dump') else m if isinstance(m, dict) else vars(m))
return handleIdsInMemory(mandateItems, pagination)
if isSysAdmin:
if isPlatformAdmin:
result = appInterface.getAllMandates(pagination=paginationParams)
else:
allMandates = []
@ -223,7 +226,7 @@ def get_mandate(
try:
mandateId = targetMandateId
# Check access
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
adminMandateIds = _getAdminMandateIds(context)
if mandateId not in adminMandateIds:
raise HTTPException(
@ -254,35 +257,46 @@ def get_mandate(
@limiter.limit("10/minute")
def create_mandate(
request: Request,
mandateData: dict = Body(..., description="Mandate data with at least 'name' field"),
currentUser: User = Depends(requireSysAdminRole)
mandateData: dict = Body(..., description="Mandate data: label (Voller Name) required unless name alone is provided; name (Kurzzeichen) optional — auto-generated from label if omitted"),
currentUser: User = Depends(requirePlatformAdmin)
) -> Mandate:
"""
Create a new mandate.
MULTI-TENANT: SysAdmin-only.
MULTI-TENANT: PlatformAdmin-only.
"""
try:
logger.debug(f"Creating mandate with data: {mandateData}")
# Validate required fields
name = mandateData.get('name')
if not name or (isinstance(name, str) and name.strip() == ''):
labelRaw = mandateData.get("label")
nameRaw = mandateData.get("name")
labelStripped = str(labelRaw).strip() if labelRaw is not None else ""
if not labelStripped and nameRaw is not None:
labelStripped = str(nameRaw).strip()
if not labelStripped:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=routeApiMsg("Mandate name is required")
detail=routeApiMsg("Mandate Voller Name (label) is required"),
)
# Get optional fields with defaults
label = mandateData.get('label')
enabled = mandateData.get('enabled', True)
nameToPass = None
if nameRaw is not None and str(nameRaw).strip() != "":
nameToPass = str(nameRaw).strip()
if not isValidMandateName(nameToPass):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=routeApiMsg(
"Mandate Kurzzeichen (name) must be 232 characters: lowercase az, digits, hyphens only"
),
)
enabled = mandateData.get("enabled", True)
appInterface = interfaceDbApp.getRootInterface()
# Create mandate
newMandate = appInterface.createMandate(
name=name,
label=label,
enabled=enabled
name=nameToPass,
label=labelStripped,
enabled=bool(enabled) if enabled is not None else True,
)
if not newMandate:
@ -329,11 +343,22 @@ def create_mandate(
except Exception as subErr:
logger.error(f"Failed to create subscription for mandate {newMandate.id}: {subErr}")
logger.info(f"Mandate {newMandate.id} created by SysAdmin {currentUser.id}")
logger.info(f"Mandate {newMandate.id} created by PlatformAdmin {currentUser.id}")
return newMandate
except HTTPException:
raise
except ValueError as ve:
logger.warning(f"Create mandate validation: {ve}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=routeApiMsg(str(ve)),
)
except PermissionError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("No permission to create mandates"),
)
except Exception as e:
logger.error(f"Error creating mandate: {str(e)}")
raise HTTPException(
@ -374,14 +399,13 @@ def update_mandate(
"""
Update an existing mandate.
MULTI-TENANT:
- SysAdmin: full update
- MandateAdmin: only label
- PlatformAdmin: full update (including Kurzzeichen name)
- MandateAdmin: only label (Voller Name)
"""
from modules.auth import _hasSysAdminRole as _checkSysAdminRole
userId = str(currentUser.id)
isSysAdmin = _checkSysAdminRole(userId)
isPlatformAdmin = bool(getattr(currentUser, "isPlatformAdmin", False))
if not isSysAdmin:
if not isPlatformAdmin:
if not _isUserAdminOfMandate(userId, mandateId):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@ -400,13 +424,37 @@ def update_mandate(
detail=f"Mandate with ID {mandateId} not found"
)
if not isSysAdmin:
if not isPlatformAdmin:
mandateData = {k: v for k, v in mandateData.items() if k in _MANDATE_ADMIN_EDITABLE_FIELDS}
if not mandateData:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("No editable fields submitted")
)
if "label" in mandateData:
lbl = mandateData["label"]
if lbl is None or str(lbl).strip() == "":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=routeApiMsg("Mandate Voller Name (label) must not be empty"),
)
else:
if "name" in mandateData and mandateData["name"] is not None:
nm = str(mandateData["name"]).strip()
if nm and not isValidMandateName(nm):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=routeApiMsg(
"Mandate Kurzzeichen (name) must be 232 characters: lowercase az, digits, hyphens only"
),
)
if "label" in mandateData and mandateData["label"] is not None:
lb = str(mandateData["label"]).strip()
if not lb:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=routeApiMsg("Mandate Voller Name (label) must not be empty"),
)
updatedMandate = appInterface.updateMandate(mandateId, mandateData)
@ -416,11 +464,22 @@ def update_mandate(
detail=routeApiMsg("Failed to update mandate")
)
logger.info(f"Mandate {mandateId} updated by user {currentUser.id} (sysadmin={isSysAdmin})")
logger.info(f"Mandate {mandateId} updated by user {currentUser.id} (platformAdmin={isPlatformAdmin})")
return updatedMandate
except HTTPException:
raise
except ValueError as ve:
logger.warning(f"Update mandate validation: {ve}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=routeApiMsg(str(ve)),
)
except PermissionError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("No permission to update mandate"),
)
except Exception as e:
logger.error(f"Error updating mandate {mandateId}: {str(e)}")
raise HTTPException(
@ -434,7 +493,7 @@ def delete_mandate(
request: Request,
mandateId: str = Path(..., description="ID of the mandate to delete"),
force: bool = Query(False, description="Hard-delete with full cascade (irreversible)"),
currentUser: User = Depends(requireSysAdminRole)
currentUser: User = Depends(requirePlatformAdmin)
) -> Dict[str, Any]:
"""
Delete a mandate.
@ -507,7 +566,7 @@ def list_mandate_users(
pagination: Optional pagination parameters (page, pageSize, search, filters, sort)
"""
# Check permission
if not _hasMandateAdminRole(context, targetMandateId) and not context.hasSysAdminRole:
if not _hasMandateAdminRole(context, targetMandateId) and not context.isPlatformAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Mandate-Admin role required")
@ -1020,7 +1079,7 @@ def _hasMandateAdminRole(context: RequestContext, mandateId: str) -> bool:
Check if the user has mandate admin role for the specified mandate.
Works with or without X-Mandate-Id header (admin pages don't send it).
"""
if context.hasSysAdminRole:
if context.isPlatformAdmin:
return True
# If mandate context matches, check roles from context directly

View file

@ -7,7 +7,6 @@ from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Depends, Path, Request, Body
from modules.auth import limiter, getRequestContext, RequestContext
from modules.auth.authentication import _hasSysAdminRole
from modules.datamodels.datamodelDataSource import DataSource
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
from modules.shared.i18nRegistry import apiRouteContext
@ -53,7 +52,7 @@ def _updateDataSourceScope(
if scope not in _VALID_SCOPES:
raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {_VALID_SCOPES}")
if scope == "global" and not _hasSysAdminRole(context.user):
if scope == "global" and not context.isSysAdmin:
raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope"))
try:

View file

@ -6,7 +6,7 @@ Implements the endpoints for user management.
MULTI-TENANT: User management requires RequestContext.
- mandateId from X-Mandate-Id header determines which users are visible
- SysAdmin can see all users across mandates
- isPlatformAdmin can see all users across mandates
"""
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query
@ -34,10 +34,10 @@ logger = logging.getLogger(__name__)
def _isAdminForUser(context: RequestContext, targetUserId: str) -> bool:
"""
Check if the current user has admin rights for the target user.
SysAdmin can manage all users. MandateAdmin can manage users in their mandates.
PlatformAdmin can manage all users. MandateAdmin can manage users in their mandates.
Works without X-Mandate-Id header (admin pages don't send it).
"""
if context.hasSysAdminRole:
if context.isPlatformAdmin:
return True
# Find mandates where current user is admin
@ -90,7 +90,7 @@ def _getUserFilterOrIds(context, paginationJson, column=None, idsMode=False):
return handleIdsInMemory(items, paginationJson)
return handleFilterValuesInMemory(items, column, paginationJson, requestLang)
if context.hasSysAdminRole:
if context.isPlatformAdmin:
rootInterface = getRootInterface()
if idsMode:
return handleIdsMode(rootInterface.db, UserInDB, paginationJson)
@ -167,7 +167,7 @@ def get_user_options(
if context.mandateId:
result = appInterface.getUsersByMandate(str(context.mandateId), None)
users = result.items if hasattr(result, 'items') else result
elif context.hasSysAdminRole:
elif context.isPlatformAdmin:
users = appInterface.getAllUsers()
else:
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied"))
@ -256,8 +256,8 @@ def get_users(
items=users,
pagination=None
)
elif context.hasSysAdminRole:
# SysAdmin without mandateId — DB-level pagination via interface
elif context.isPlatformAdmin:
# PlatformAdmin without mandateId — DB-level pagination via interface
result = appInterface.getAllUsers(paginationParams)
if paginationParams and hasattr(result, 'items'):
@ -375,8 +375,8 @@ def get_user(
detail=f"User with ID {userId} not found"
)
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.hasSysAdminRole:
# MULTI-TENANT: Verify user is in the same mandate (unless PlatformAdmin)
if context.mandateId and not context.isPlatformAdmin:
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
if not userMandate:
raise HTTPException(
@ -402,6 +402,7 @@ class CreateUserRequest(BaseModel):
language: str = "de"
enabled: bool = True
isSysAdmin: bool = False
isPlatformAdmin: bool = False
password: Optional[str] = None
@ -415,10 +416,24 @@ def create_user(
"""
Create a new user.
MULTI-TENANT: User is created and automatically added to the current mandate.
Privileged platform flags (isSysAdmin, isPlatformAdmin) may only be set
by a Platform Admin. Non-PlatformAdmin requests have these flags reset
to False with a warning.
"""
appInterface = interfaceDbApp.getInterface(context.user)
# Extract fields from request model and call createUser with individual parameters
callerIsPlatformAdmin = context.isPlatformAdmin
requestedSysAdmin = bool(userData.isSysAdmin) and callerIsPlatformAdmin
requestedPlatformAdmin = bool(userData.isPlatformAdmin) and callerIsPlatformAdmin
if (userData.isSysAdmin or userData.isPlatformAdmin) and not callerIsPlatformAdmin:
logger.warning(
f"Non-PlatformAdmin {context.user.id} attempted to create user with "
f"privileged flags (isSysAdmin={userData.isSysAdmin}, "
f"isPlatformAdmin={userData.isPlatformAdmin}); flags reset to False"
)
newUser = appInterface.createUser(
username=userData.username,
password=userData.password,
@ -427,7 +442,8 @@ def create_user(
language=userData.language,
enabled=userData.enabled,
authenticationAuthority=AuthAuthority.LOCAL,
isSysAdmin=userData.isSysAdmin
isSysAdmin=requestedSysAdmin,
isPlatformAdmin=requestedPlatformAdmin,
)
# MULTI-TENANT: Add user to current mandate via UserMandate with default "user" role
@ -453,27 +469,37 @@ def create_user(
def update_user(
request: Request,
userId: str = Path(..., description="ID of the user to update"),
userData: User = Body(...),
userData: Dict[str, Any] = Body(..., description="Partial user payload — only the fields present in the request body are updated."),
context: RequestContext = Depends(getRequestContext)
) -> User:
"""
Update an existing user.
Update an existing user (PARTIAL update).
The request body is treated as a **partial** patch: only the keys actually
sent are applied; missing keys leave the stored value untouched. This is
intentional sending a full ``User`` body would overwrite unrelated fields
(e.g. ``isSysAdmin``/``isPlatformAdmin``) with Pydantic defaults whenever a
client only ships a subset, which has historically caused privileged flags
to flip silently when toggling a single inline cell.
Self-service: Users can update their own profile (language, fullName, etc.).
Admin: MandateAdmin can update users in their mandates. SysAdmin for all.
Admin: MandateAdmin can update users in their mandates.
PlatformAdmin can update any user.
Privileged flag changes (isSysAdmin, isPlatformAdmin) require:
- caller has isPlatformAdmin=True, AND
- target is NOT the caller (Self-Protection).
"""
isSelfUpdate = str(context.user.id) == str(userId)
# Non-self updates require admin permission
if not isSelfUpdate and not _isAdminForUser(context, userId):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Admin role required to update other users")
)
# Use rootInterface for user lookup/update (avoids RBAC filtering on User table)
rootInterface = getRootInterface()
# Check if the user exists
existingUser = rootInterface.getUser(userId)
if not existingUser:
raise HTTPException(
@ -481,9 +507,22 @@ def update_user(
detail=f"User with ID {userId} not found"
)
# SysAdmins may toggle the isSysAdmin flag on other users
callerIsSysAdmin = context.isSysAdmin or context.hasSysAdminRole
updatedUser = rootInterface.updateUser(userId, userData, allowSysAdminChange=(callerIsSysAdmin and not isSelfUpdate))
if not isinstance(userData, dict):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=routeApiMsg("User update payload must be a JSON object")
)
# Defensive: drop ``id`` from payload — userId comes from the path and
# tampering with it from the body must never silently rebind the row.
sanitizedPayload = {k: v for k, v in userData.items() if k != "id"}
callerIsPlatformAdmin = context.isPlatformAdmin
allowAdminFlagChange = callerIsPlatformAdmin and not isSelfUpdate
updatedUser = rootInterface.updateUser(
userId, sanitizedPayload, allowAdminFlagChange=allowAdminFlagChange
)
if not updatedUser:
raise HTTPException(
@ -793,7 +832,7 @@ def delete_user(
) -> Dict[str, Any]:
"""
Delete a user.
MULTI-TENANT: Can only delete users in the same mandate (unless SysAdmin).
MULTI-TENANT: Can only delete users in the same mandate (unless PlatformAdmin).
"""
appInterface = interfaceDbApp.getInterface(context.user)
@ -805,8 +844,8 @@ def delete_user(
detail=f"User with ID {userId} not found"
)
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.hasSysAdminRole:
# MULTI-TENANT: Verify user is in the same mandate (unless PlatformAdmin)
if context.mandateId and not context.isPlatformAdmin:
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
if not userMandate:
raise HTTPException(

View file

@ -4,9 +4,11 @@
Public and authenticated routes for UI language sets (DB-backed i18n).
Architecture:
- xx = base set (meta): key = German plaintext, value = UI context for AI
- xx = base set (meta): key = source plaintext (German or English, as written
in the code via ``t("...")``), value = UI context for AI
- All languages (incl. de) are AI-generated translations from xx
- AI translation pipeline uses context from xx to disambiguate translations
- AI translation pipeline uses context from xx to disambiguate translations;
the prompt forces the output language to be exactly the requested target.
"""
from __future__ import annotations
@ -23,7 +25,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, Re
from fastapi.responses import Response
from pydantic import BaseModel, Field
from modules.auth import getCurrentUser, requireSysAdminRole
from modules.auth import getCurrentUser, requireSysAdmin, requirePlatformAdmin
from modules.connectors.connectorDbPostgre import _get_cached_connector
from modules.datamodels.datamodelAi import (
AiCallOptions,
@ -234,17 +236,31 @@ async def _translateBatch(
jsonPayload = json.dumps(payload, ensure_ascii=False)
systemPrompt = (
f"Du bist ein professioneller Übersetzer für Software-UI-Texte. "
f"Du erhältst ein JSON-Array mit Objekten: {{\"key\": \"deutscher Text\", \"context\": \"UI-Kontext\"}}. "
f"Der Kontext beschreibt, wo der Text in der Anwendung verwendet wird (Datei, Komponente). "
f"Übersetze jeden «key» ins {targetLanguageLabel} (ISO {targetCode}). "
f"Behalte Platzhalter wie {{variable}} exakt bei. "
f"Antworte NUR mit einem JSON-Objekt — Keys = deutsche Originaltexte, Values = Übersetzungen. "
f"Kein Markdown, kein Kommentar."
f"You are a professional translator for software UI texts. "
f"You receive a JSON array of objects: {{\"key\": \"source text\", \"context\": \"UI context\"}}. "
f"The source text is written in German OR English. "
f"The context describes where the text is used in the application (file, component). "
f"\n\n"
f"HARD REQUIREMENTS (must all be satisfied):\n"
f"1. OUTPUT LANGUAGE: every translated value MUST be written in {targetLanguageLabel} "
f"(ISO code \"{targetCode}\"). Never output in German or English if that is not "
f"the target language. No mixing of languages.\n"
f"2. If the source is already in the target language, keep it (do not re-translate, "
f"do not paraphrase).\n"
f"3. KEEP the exact JSON keys from the input — do NOT translate or modify the keys.\n"
f"4. KEEP placeholders like {{variable}}, {{count}}, %s, %(name)s exactly as they are.\n"
f"5. Preserve leading/trailing whitespace, punctuation and capitalisation pattern.\n"
f"6. Answer ONLY with a JSON object mapping source-key -> translated value in "
f"{targetLanguageLabel}. No markdown fences, no comments, no explanations.\n"
f"7. If a key cannot be translated (empty, pure symbols, URLs), return the source unchanged."
)
aiRequest = AiCallRequest(
prompt=f"Übersetze diese UI-Labels:\n{jsonPayload}",
prompt=(
f"Translate the following UI labels into {targetLanguageLabel} "
f"(ISO {targetCode}). Source may be German or English. "
f"Respond with a pure JSON object only.\n{jsonPayload}"
),
context=systemPrompt,
options=AiCallOptions(
operationType=OperationTypeEnum.DATA_GENERATE,
@ -826,7 +842,7 @@ async def _syncLanguageWithXx(db, code: str, userId: Optional[str], adminUser: O
@router.put("/sets/sync-xx")
async def sync_xx_master(
request: Request,
adminUser: User = Depends(requireSysAdminRole),
adminUser: User = Depends(requireSysAdmin),
):
"""Synchronise the xx base set from the frontend build artefact.
@ -844,7 +860,7 @@ async def sync_xx_master(
@router.get("/sets/{code}/sync-diff")
async def get_language_sync_diff(
code: str,
adminUser: User = Depends(requireSysAdminRole),
adminUser: User = Depends(requirePlatformAdmin),
):
"""How many keys would be added/removed vs xx before running a full sync (SysAdmin)."""
c = code.strip().lower()
@ -857,7 +873,7 @@ async def get_language_sync_diff(
@router.put("/sets/{code}")
async def update_language_set(
code: str,
adminUser: User = Depends(requireSysAdminRole),
adminUser: User = Depends(requirePlatformAdmin),
):
c = code.strip().lower()
if c in ("update-all", "sync-xx", "sync-de"):
@ -873,7 +889,7 @@ async def update_language_set(
@router.delete("/sets/{code}")
async def delete_language_set(
code: str,
adminUser: User = Depends(requireSysAdminRole),
adminUser: User = Depends(requirePlatformAdmin),
):
c = code.strip().lower()
if c in _PROTECTED_CODES:
@ -911,7 +927,7 @@ async def download_language_set(
@router.get("/export")
async def export_all_language_sets(
adminUser: User = Depends(requireSysAdminRole),
adminUser: User = Depends(requirePlatformAdmin),
):
db = getMgmtInterface(adminUser, mandateId=None).db
rows = db.getRecordset(UiLanguageSet)
@ -939,7 +955,7 @@ async def export_all_language_sets(
@router.post("/import")
async def import_language_sets(
file: UploadFile = File(...),
adminUser: User = Depends(requireSysAdminRole),
adminUser: User = Depends(requirePlatformAdmin),
):
if not file.filename or not file.filename.endswith(".json"):
raise HTTPException(status_code=400, detail=routeApiMsg("Nur .json-Dateien erlaubt."))

View file

@ -186,7 +186,7 @@ def create_invitation(
)
# Check admin permission
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
if str(context.mandateId) != mandateId:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@ -891,7 +891,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
"""
Check if the user has mandate admin role in the current context.
"""
if context.hasSysAdminRole:
if context.isPlatformAdmin:
return True
if not context.roleIds:

View file

@ -121,7 +121,7 @@ async def _validateInstanceAccess(instanceId: str, context: RequestContext) -> s
status_code=400,
detail=f"Instance '{instanceId}' is not a realestate instance"
)
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
hasAccess = any(
str(fa.featureInstanceId) == instanceId and fa.enabled

View file

@ -210,13 +210,13 @@ def _ensureHomeMandate(rootInterface, user) -> None:
except Exception as e:
logger.warning(f"Could not check pending invitations for {user.username}: {e}")
homeMandateName = f"Home {user.username}"
homeMandateLabel = f"Home {user.username}"
rootInterface._provisionMandateForUser(
userId=userId,
mandateName=homeMandateName,
mandateLabel=homeMandateLabel,
planKey="TRIAL_14D",
)
logger.info(f"Created Home mandate '{homeMandateName}' for user {user.username}")
logger.info(f"Created Home mandate '{homeMandateLabel}' for user {user.username}")
@router.post("/login")
@ -464,10 +464,10 @@ def register_user(
provisionResult = None
if not hasPendingInvitations:
try:
homeMandateName = f"Home {user.username}"
homeMandateLabel = f"Home {user.username}"
provisionResult = appInterface._provisionMandateForUser(
userId=str(user.id),
mandateName=homeMandateName,
mandateLabel=homeMandateLabel,
planKey="TRIAL_14D",
)
logger.info(f"Provisioned Home mandate for user {user.id}: {provisionResult}")
@ -881,7 +881,7 @@ def onboarding_provision(
"alreadyProvisioned": True,
}
mandateName = (companyName.strip() if companyName and companyName.strip()
mandateLabel = (companyName.strip() if companyName and companyName.strip()
else f"Home {currentUser.username}")
if planKey not in ("TRIAL_14D", "STARTER_MONTHLY", "STARTER_YEARLY", "PROFESSIONAL_MONTHLY", "PROFESSIONAL_YEARLY", "MAX_MONTHLY", "MAX_YEARLY"):
@ -889,7 +889,7 @@ def onboarding_provision(
result = appInterface._provisionMandateForUser(
userId=userId,
mandateName=mandateName,
mandateLabel=mandateLabel,
planKey=planKey,
)

View file

@ -59,7 +59,13 @@ class StoreFeatureResponse(BaseModel):
def _getStoreFeatures(catalogService) -> List[Dict[str, Any]]:
"""Get all features available in the store."""
"""Get all features available in the store.
Soft-disabled features (``enabled=False`` in their feature definition) are
skipped so that legacy or temporarily-deactivated modules do not appear in
the storefront, even if their ``resource.store.*`` catalog object is still
registered.
"""
resourceObjects = catalogService.getResourceObjects()
storeFeatures = []
for obj in resourceObjects:
@ -68,7 +74,7 @@ def _getStoreFeatures(catalogService) -> List[Dict[str, Any]]:
featureCode = meta.get("featureCode")
if featureCode:
featureDef = catalogService.getFeatureDefinition(featureCode)
if featureDef:
if featureDef and featureDef.get("enabled", True):
storeFeatures.append(featureDef)
return storeFeatures

View file

@ -46,7 +46,7 @@ def _resolveMandateId(context: RequestContext) -> str:
def _assertMandateAdmin(context: RequestContext, mandateId: str) -> None:
if context.hasSysAdminRole:
if context.isPlatformAdmin:
return
try:
from modules.interfaces.interfaceDbApp import getRootInterface
@ -303,7 +303,7 @@ def forceCancel(
context: RequestContext = Depends(getRequestContext),
):
"""Sysadmin: immediately expire any non-terminal subscription."""
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required"))
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import (
@ -485,7 +485,7 @@ def getAllSubscriptions(
context: RequestContext = Depends(getRequestContext),
):
"""SysAdmin: list ALL subscriptions across all mandates with enriched metadata."""
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required"))
if mode == "filterValues":

View file

@ -478,7 +478,7 @@ def get_navigation(
Endpoint: GET /api/navigation
"""
try:
isSysAdmin = reqContext.hasSysAdminRole
isSysAdmin = reqContext.isPlatformAdmin
userId = str(reqContext.user.id) if reqContext.user else None
# Get user's role IDs for permission checking

View file

@ -17,6 +17,7 @@ from typing import Optional, Dict, Any, List
from modules.auth import getCurrentUser, getRequestContext, RequestContext, limiter
from modules.datamodels.datamodelUam import User
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface, VoiceObjects
from modules.shared.voiceCatalog import getCatalogPayload
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/voice-google", tags=["Voice Google"])
@ -61,32 +62,15 @@ def _getVoiceInterface(currentUser: User) -> VoiceObjects:
@router.get("/languages")
async def get_available_languages(currentUser: User = Depends(getCurrentUser)):
"""Get available languages from Google Cloud Text-to-Speech."""
try:
logger.info("🌐 Getting available languages from Google Cloud TTS")
"""Return the curated voice/language catalog (single source of truth).
voiceInterface = _getVoiceInterface(currentUser)
result = await voiceInterface.getAvailableLanguages()
if result["success"]:
return {
"success": True,
"languages": result["languages"]
}
else:
raise HTTPException(
status_code=400,
detail=f"Failed to get languages: {result.get('error', 'Unknown error')}"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Get languages error: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to get available languages: {str(e)}"
)
Each entry: {bcp47, iso, label, flag, defaultVoice}. Same payload as
/api/voice/languages both endpoints back the same catalog.
"""
return {
"success": True,
"languages": getCatalogPayload(),
}
@router.get("/voices")
async def get_available_voices(

View file

@ -18,6 +18,7 @@ from modules.datamodels.datamodelUam import User, UserVoicePreferences, _normali
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
from modules.shared.i18nRegistry import apiRouteContext
from modules.shared.voiceCatalog import getCatalogPayload
routeApiMsg = apiRouteContext("routeVoiceUser")
logger = logging.getLogger(__name__)
@ -101,11 +102,11 @@ async def getVoiceLanguages(
request: Request,
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Return available TTS languages (user-level, no instance context needed)."""
voiceInterface = getVoiceInterface(currentUser)
languagesResult = await voiceInterface.getAvailableLanguages()
languageList = languagesResult.get("languages", []) if isinstance(languagesResult, dict) else languagesResult
return {"languages": languageList}
"""Return the curated voice/language catalog (single source of truth).
Each entry: {bcp47, iso, label, flag, defaultVoice}.
"""
return {"languages": getCatalogPayload()}
@router.get("/voices")

View file

@ -106,7 +106,7 @@ def _scopedRunFilter(context: RequestContext) -> Optional[dict]:
- mandate admin: mandateId IN user's mandates
- normal user: ownerId = userId
"""
if context.hasSysAdminRole:
if context.isPlatformAdmin:
return None
userId = str(context.user.id) if context.user else None
@ -128,7 +128,7 @@ def _scopedWorkflowFilter(context: RequestContext) -> Optional[dict]:
- sysadmin: None (no filter, sees all)
- normal user: mandateId IN user's mandates
"""
if context.hasSysAdminRole:
if context.isPlatformAdmin:
return None
userId = str(context.user.id) if context.user else None
@ -144,7 +144,7 @@ def _scopedWorkflowFilter(context: RequestContext) -> Optional[dict]:
def _userMayDeleteWorkflow(context: RequestContext, wfMandateId: Optional[str]) -> bool:
"""Same rules as canDelete on rows in get_system_workflows."""
if context.hasSysAdminRole:
if context.isPlatformAdmin:
return True
userId = str(context.user.id) if context.user else None
if not userId or not wfMandateId:
@ -477,7 +477,7 @@ def get_system_workflows(
userId = str(context.user.id) if context.user else None
adminMandateIds = []
if userId and not context.hasSysAdminRole:
if userId and not context.isPlatformAdmin:
userMandateIds = _getUserMandateIds(userId)
adminMandateIds = _getAdminMandateIds(userId, userMandateIds)
@ -514,7 +514,7 @@ def get_system_workflows(
row["runCount"] = runCountMap.get(wfId, 0)
row["lastStartedAt"] = lastStartedMap.get(wfId)
if context.hasSysAdminRole:
if context.isPlatformAdmin:
row["canEdit"] = True
row["canDelete"] = True
row["canExecute"] = True
@ -670,7 +670,7 @@ def get_run_steps(
raise HTTPException(status_code=404, detail=routeApiMsg("Run not found"))
run = dict(runs[0])
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
userId = str(context.user.id) if context.user else None
runOwner = run.get("ownerId")
runMandate = run.get("mandateId")
@ -711,7 +711,7 @@ async def get_run_stream(
raise HTTPException(status_code=404, detail=routeApiMsg("Run not found"))
run = dict(runs[0])
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
userId = str(context.user.id) if context.user else None
runOwner = run.get("ownerId")
runMandate = run.get("mandateId")
@ -774,7 +774,7 @@ def stop_workflow_run(
raise HTTPException(status_code=404, detail=routeApiMsg("Run not found"))
run = dict(runs[0])
if not context.hasSysAdminRole:
if not context.isPlatformAdmin:
userId = str(context.user.id) if context.user else None
runOwner = run.get("ownerId")
runMandate = run.get("mandateId")

View file

@ -84,10 +84,38 @@ class RbacCatalogService:
logger.error(f"Failed to register DATA object {objectKey}: {e}")
return False
def registerFeatureDefinition(self, featureCode: str, label: str, icon: str) -> bool:
"""Register a feature definition."""
def registerFeatureDefinition(
self,
featureCode: str,
label: str,
icon: str,
*,
instantiable: bool = True,
enabled: bool = True,
) -> bool:
"""Register a feature definition.
Args:
featureCode: Stable code (e.g. ``"trustee"``).
label: Display label.
icon: Display icon.
instantiable: ``False`` for meta-features that must NOT be exposed
as a creatable Feature-Instance (e.g. the ``system`` umbrella
feature which only owns global UI/DATA/RESOURCE catalog
objects). Defaults to ``True``.
enabled: ``False`` to soft-disable a feature so it is filtered out
of selection lists (Store / Admin Feature-Instances dropdown)
without removing its catalog objects, role templates or
already-provisioned instances. Defaults to ``True``.
"""
try:
self._featureDefinitions[featureCode] = {"code": featureCode, "label": label, "icon": icon}
self._featureDefinitions[featureCode] = {
"code": featureCode,
"label": label,
"icon": icon,
"instantiable": bool(instantiable),
"enabled": bool(enabled),
}
return True
except Exception as e:
logger.error(f"Failed to register feature definition {featureCode}: {e}")

View file

@ -395,25 +395,17 @@ def _registerMediaTools(registry: ToolRegistry, services):
try:
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
from modules.shared.voiceCatalog import isoToBcp47
mandateId = context.get("mandateId", "")
voiceInterface = getVoiceInterface(currentUser=None, mandateId=mandateId)
_ISO_TO_BCP47 = {
"de": "de-DE", "en": "en-US", "fr": "fr-FR", "it": "it-IT",
"es": "es-ES", "pt": "pt-BR", "nl": "nl-NL", "pl": "pl-PL",
"ru": "ru-RU", "ja": "ja-JP", "zh": "zh-CN", "ko": "ko-KR",
"ar": "ar-XA", "hi": "hi-IN", "tr": "tr-TR", "sv": "sv-SE",
}
if language == "auto":
try:
snippet = cleanText[:500]
detectResult = await voiceInterface.detectLanguage(snippet)
if detectResult and detectResult.get("success"):
detected = detectResult.get("language", "de")
language = _ISO_TO_BCP47.get(detected, detected)
if "-" not in language:
language = _ISO_TO_BCP47.get(language, f"{language}-{language.upper()}")
language = isoToBcp47(detected) or "de-DE"
logger.info(f"textToSpeech: auto-detected language '{detected}' -> '{language}'")
else:
language = "de-DE"

View file

@ -670,7 +670,7 @@ def _registerWorkspaceTools(registry: ToolRegistry, services):
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
mandateId = context.get("mandateId", "")
voiceInterface = getVoiceInterface(currentUser=None, mandateId=mandateId)
sourceLanguage = args.get("sourceLanguage", "auto")
sourceLanguage = args.get("sourceLanguage") or None
result = await voiceInterface.translateText(text, sourceLanguage=sourceLanguage, targetLanguage=targetLanguage)
if result and result.get("success"):
translated = result.get("translated_text", "")
@ -735,7 +735,7 @@ def _registerWorkspaceTools(registry: ToolRegistry, services):
"properties": {
"text": {"type": "string", "description": "Text to translate"},
"targetLanguage": {"type": "string", "description": "Target language ISO code (e.g. 'en', 'de', 'fr')"},
"sourceLanguage": {"type": "string", "description": "Source language ISO code (default: auto-detect)"},
"sourceLanguage": {"type": "string", "description": "Source language ISO code (e.g. 'de', 'en'). Omit or leave empty for auto-detection."},
},
"required": ["text", "targetLanguage"]
},

View file

@ -93,6 +93,14 @@ class AgentConfig(BaseModel):
availableToolboxes: List[str] = Field(default_factory=list)
temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0)
operationType: Optional[OperationTypeEnum] = Field(default=None, description="Override the default AGENT operationType for model selection")
excludeActionTools: bool = Field(
default=False,
description=(
"If True, do NOT register workflow-action methods as agent tools. "
"Used by editor-style agents (e.g. GraphicalEditor) that should only "
"manipulate the workflow graph, not execute its actions."
),
)
class AgentState(BaseModel):

View file

@ -330,16 +330,20 @@ class AgentService:
except Exception as e:
logger.warning("discoverMethods failed before action tools: %s", e)
try:
from modules.workflows.processing.core.actionExecutor import ActionExecutor
actionExecutor = ActionExecutor(self.services)
adapter = ActionToolAdapter(actionExecutor)
adapter.registerAll(registry)
except Exception as e:
logger.warning(f"Could not register action tools: {e}")
if not getattr(config, "excludeActionTools", False):
try:
from modules.workflows.processing.core.actionExecutor import ActionExecutor
actionExecutor = ActionExecutor(self.services)
adapter = ActionToolAdapter(actionExecutor)
adapter.registerAll(registry)
except Exception as e:
logger.warning(f"Could not register action tools: {e}")
else:
logger.info("excludeActionTools=True: skipping ActionToolAdapter registration (editor-mode agent)")
self._activateToolboxes(registry, config)
self._registerRequestToolbox(registry)
if not getattr(config, "excludeActionTools", False):
self._registerRequestToolbox(registry)
return registry

View file

@ -3,12 +3,23 @@
"""
Workflow Toolbox - AI-assisted graph manipulation tools for the GraphicalEditor.
Tools: readWorkflowGraph, addNode, removeNode, connectNodes, setNodeParameter,
listAvailableNodeTypes, validateGraph, listWorkflowHistory, readWorkflowMessages.
listAvailableNodeTypes, describeNodeType, autoLayoutWorkflow,
validateGraph, listWorkflowHistory, readWorkflowMessages.
Conventions enforced here (matches coreTools / actionToolAdapter):
- Every ``ToolResult(...)`` provides ``toolCallId`` and ``toolName`` (pydantic
requires both); ``ToolRegistry.dispatch`` overwrites ``toolCallId`` later
but the model still validates at construction.
- ``ToolResult.data`` is a ``str``; structured payloads are JSON-encoded.
- ``workflowId`` and ``instanceId`` are auto-injected from the agent
``context`` dict (``workflowId``, ``featureInstanceId``) when the model
omits them the editor agent always runs in exactly one workflow.
"""
import json
import logging
import uuid
from typing import Dict, Any, List, Optional
from typing import Dict, Any, List, Tuple
from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult
@ -17,139 +28,211 @@ logger = logging.getLogger(__name__)
TOOLBOX_ID = "workflow"
def _toData(payload: Any) -> str:
"""Encode a structured payload into ToolResult.data (which is a string)."""
if isinstance(payload, str):
return payload
try:
return json.dumps(payload, default=str, ensure_ascii=False)
except Exception:
return str(payload)
def _err(toolName: str, message: str) -> ToolResult:
return ToolResult(toolCallId="", toolName=toolName, success=False, error=message)
def _ok(toolName: str, payload: Any) -> ToolResult:
return ToolResult(toolCallId="", toolName=toolName, success=True, data=_toData(payload))
def _resolveIds(params: Dict[str, Any], context: Any) -> Tuple[str, str]:
"""Return (workflowId, instanceId), auto-injecting from context when missing.
The editor agent context (``agentLoop._executeToolCalls``) is a dict with
``workflowId`` and ``featureInstanceId`` use them as defaults so the
model doesn't have to re-state the ids on every tool call.
"""
ctx: Dict[str, Any] = context if isinstance(context, dict) else {}
workflowId = params.get("workflowId") or ctx.get("workflowId") or ""
instanceId = (
params.get("instanceId")
or ctx.get("featureInstanceId")
or ctx.get("instanceId")
or ""
)
return workflowId, instanceId
def _resolveUser(context: Any):
"""Return the User object for the current agent context (lazy DB fetch)."""
if not isinstance(context, dict):
return getattr(context, "user", None)
user = context.get("user")
if user is not None:
return user
userId = context.get("userId")
if not userId:
return None
try:
from modules.interfaces.interfaceDbApp import getRootInterface
return getRootInterface().getUser(str(userId))
except Exception as e:
logger.warning("workflowTools: could not resolve user %s: %s", userId, e)
return None
def _resolveMandateId(context: Any) -> str:
if not isinstance(context, dict):
return getattr(context, "mandateId", "") or ""
return context.get("mandateId") or ""
def _getInterface(context: Any, instanceId: str):
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
return getGraphicalEditorInterface(_resolveUser(context), _resolveMandateId(context), instanceId)
async def _readWorkflowGraph(params: Dict[str, Any], context: Any) -> ToolResult:
"""Read the current workflow graph (nodes and connections)."""
name = "readWorkflowGraph"
try:
workflowId = params.get("workflowId")
instanceId = params.get("instanceId")
workflowId, instanceId = _resolveIds(params, context)
if not workflowId or not instanceId:
return ToolResult(success=False, error="workflowId and instanceId required")
return _err(name, "workflowId and instanceId required (and not present in agent context)")
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
user = getattr(context, "user", None)
mandateId = getattr(context, "mandateId", "") or ""
iface = getGraphicalEditorInterface(user, mandateId, instanceId)
iface = _getInterface(context, instanceId)
wf = iface.getWorkflow(workflowId)
if not wf:
return ToolResult(success=False, error=f"Workflow {workflowId} not found")
return _err(name, f"Workflow {workflowId} not found")
graph = wf.get("graph", {})
nodes = graph.get("nodes", [])
connections = graph.get("connections", [])
return ToolResult(
success=True,
data={
"workflowId": workflowId,
"label": wf.get("label", ""),
"nodeCount": len(nodes),
"connectionCount": len(connections),
"nodes": [{"id": n.get("id"), "type": n.get("type"), "title": n.get("title", "")} for n in nodes],
"connections": connections,
},
)
graph = wf.get("graph", {}) or {}
nodes = graph.get("nodes", []) or []
connections = graph.get("connections", []) or []
return _ok(name, {
"workflowId": workflowId,
"label": wf.get("label", ""),
"nodeCount": len(nodes),
"connectionCount": len(connections),
"nodes": [
{"id": n.get("id"), "type": n.get("type"), "title": n.get("title", "")}
for n in nodes
],
"connections": connections,
})
except Exception as e:
logger.exception("readWorkflowGraph failed: %s", e)
return ToolResult(success=False, error=str(e))
return _err(name, str(e))
async def _addNode(params: Dict[str, Any], context: Any) -> ToolResult:
"""Add a node to the workflow graph."""
name = "addNode"
try:
workflowId = params.get("workflowId")
instanceId = params.get("instanceId")
workflowId, instanceId = _resolveIds(params, context)
nodeType = params.get("nodeType")
if not workflowId or not instanceId or not nodeType:
return ToolResult(success=False, error="workflowId, instanceId, and nodeType required")
return _err(name, "workflowId, instanceId, and nodeType required")
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
user = getattr(context, "user", None)
mandateId = getattr(context, "mandateId", "") or ""
iface = getGraphicalEditorInterface(user, mandateId, instanceId)
iface = _getInterface(context, instanceId)
wf = iface.getWorkflow(workflowId)
if not wf:
return ToolResult(success=False, error=f"Workflow {workflowId} not found")
return _err(name, f"Workflow {workflowId} not found")
graph = dict(wf.get("graph", {}))
nodes = list(graph.get("nodes", []))
graph = dict(wf.get("graph", {}) or {})
nodes = list(graph.get("nodes", []) or [])
nodeId = params.get("nodeId") or str(uuid.uuid4())[:8]
title = params.get("title", "")
nodeParams = params.get("parameters", {})
position = params.get("position", {"x": len(nodes) * 200, "y": 100})
nodeParams = params.get("parameters", {}) or {}
# Frontend stores positions as TOP-LEVEL ``x`` / ``y`` on the node
# (see ``fromApiGraph`` / ``toApiGraph``). Accept either explicit
# ``x`` / ``y`` or a ``position={x,y}`` shape from the model and
# always persist as top-level ``x`` / ``y``. Fallback puts new
# nodes in a horizontal stripe so the user sees them even before
# ``autoLayoutWorkflow`` runs.
position = params.get("position") or {}
x = params.get("x")
if x is None:
x = position.get("x") if isinstance(position, dict) else None
if x is None:
x = 40 + len(nodes) * 260
y = params.get("y")
if y is None:
y = position.get("y") if isinstance(position, dict) else None
if y is None:
y = 40
newNode = {
"id": nodeId,
"type": nodeType,
"title": title,
"parameters": nodeParams,
"position": position,
"x": x,
"y": y,
}
nodes.append(newNode)
graph["nodes"] = nodes
iface.updateWorkflow(workflowId, {"graph": graph})
return ToolResult(
success=True,
data={"nodeId": nodeId, "nodeType": nodeType, "message": f"Node '{title or nodeType}' added"},
)
return _ok(name, {
"nodeId": nodeId,
"nodeType": nodeType,
"message": f"Node '{title or nodeType}' added",
})
except Exception as e:
logger.exception("addNode failed: %s", e)
return ToolResult(success=False, error=str(e))
return _err(name, str(e))
async def _removeNode(params: Dict[str, Any], context: Any) -> ToolResult:
"""Remove a node and its connections from the workflow graph."""
name = "removeNode"
try:
workflowId = params.get("workflowId")
instanceId = params.get("instanceId")
workflowId, instanceId = _resolveIds(params, context)
nodeId = params.get("nodeId")
if not workflowId or not instanceId or not nodeId:
return ToolResult(success=False, error="workflowId, instanceId, and nodeId required")
return _err(name, "workflowId, instanceId, and nodeId required")
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
user = getattr(context, "user", None)
mandateId = getattr(context, "mandateId", "") or ""
iface = getGraphicalEditorInterface(user, mandateId, instanceId)
iface = _getInterface(context, instanceId)
wf = iface.getWorkflow(workflowId)
if not wf:
return ToolResult(success=False, error=f"Workflow {workflowId} not found")
return _err(name, f"Workflow {workflowId} not found")
graph = dict(wf.get("graph", {}))
nodes = [n for n in graph.get("nodes", []) if n.get("id") != nodeId]
graph = dict(wf.get("graph", {}) or {})
nodes = [n for n in (graph.get("nodes", []) or []) if n.get("id") != nodeId]
connections = [
c for c in graph.get("connections", [])
c for c in (graph.get("connections", []) or [])
if c.get("source") != nodeId and c.get("target") != nodeId
]
graph["nodes"] = nodes
graph["connections"] = connections
iface.updateWorkflow(workflowId, {"graph": graph})
return ToolResult(success=True, data={"nodeId": nodeId, "message": f"Node {nodeId} removed"})
return _ok(name, {"nodeId": nodeId, "message": f"Node {nodeId} removed"})
except Exception as e:
logger.exception("removeNode failed: %s", e)
return ToolResult(success=False, error=str(e))
return _err(name, str(e))
async def _connectNodes(params: Dict[str, Any], context: Any) -> ToolResult:
"""Connect two nodes in the workflow graph."""
name = "connectNodes"
try:
workflowId = params.get("workflowId")
instanceId = params.get("instanceId")
workflowId, instanceId = _resolveIds(params, context)
sourceId = params.get("sourceId")
targetId = params.get("targetId")
if not workflowId or not instanceId or not sourceId or not targetId:
return ToolResult(success=False, error="workflowId, instanceId, sourceId, and targetId required")
return _err(name, "workflowId, instanceId, sourceId, and targetId required")
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
user = getattr(context, "user", None)
mandateId = getattr(context, "mandateId", "") or ""
iface = getGraphicalEditorInterface(user, mandateId, instanceId)
iface = _getInterface(context, instanceId)
wf = iface.getWorkflow(workflowId)
if not wf:
return ToolResult(success=False, error=f"Workflow {workflowId} not found")
return _err(name, f"Workflow {workflowId} not found")
graph = dict(wf.get("graph", {}))
connections = list(graph.get("connections", []))
graph = dict(wf.get("graph", {}) or {})
connections = list(graph.get("connections", []) or [])
newConn = {
"source": sourceId,
"target": targetId,
@ -160,93 +243,330 @@ async def _connectNodes(params: Dict[str, Any], context: Any) -> ToolResult:
graph["connections"] = connections
iface.updateWorkflow(workflowId, {"graph": graph})
return ToolResult(success=True, data={"connection": newConn, "message": f"Connected {sourceId} -> {targetId}"})
return _ok(name, {"connection": newConn, "message": f"Connected {sourceId} -> {targetId}"})
except Exception as e:
logger.exception("connectNodes failed: %s", e)
return ToolResult(success=False, error=str(e))
return _err(name, str(e))
async def _setNodeParameter(params: Dict[str, Any], context: Any) -> ToolResult:
"""Set a parameter on a node."""
name = "setNodeParameter"
try:
workflowId = params.get("workflowId")
instanceId = params.get("instanceId")
workflowId, instanceId = _resolveIds(params, context)
nodeId = params.get("nodeId")
paramName = params.get("parameterName")
paramValue = params.get("parameterValue")
if not workflowId or not instanceId or not nodeId or not paramName:
return ToolResult(success=False, error="workflowId, instanceId, nodeId, and parameterName required")
return _err(name, "workflowId, instanceId, nodeId, and parameterName required")
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
user = getattr(context, "user", None)
mandateId = getattr(context, "mandateId", "") or ""
iface = getGraphicalEditorInterface(user, mandateId, instanceId)
iface = _getInterface(context, instanceId)
wf = iface.getWorkflow(workflowId)
if not wf:
return ToolResult(success=False, error=f"Workflow {workflowId} not found")
return _err(name, f"Workflow {workflowId} not found")
graph = dict(wf.get("graph", {}))
nodes = list(graph.get("nodes", []))
graph = dict(wf.get("graph", {}) or {})
nodes = list(graph.get("nodes", []) or [])
found = False
for n in nodes:
if n.get("id") == nodeId:
nodeParams = dict(n.get("parameters", {}))
nodeParams = dict(n.get("parameters", {}) or {})
nodeParams[paramName] = paramValue
n["parameters"] = nodeParams
found = True
break
if not found:
return ToolResult(success=False, error=f"Node {nodeId} not found in graph")
return _err(name, f"Node {nodeId} not found in graph")
graph["nodes"] = nodes
iface.updateWorkflow(workflowId, {"graph": graph})
return ToolResult(success=True, data={"nodeId": nodeId, "parameter": paramName, "message": f"Parameter '{paramName}' set"})
return _ok(name, {
"nodeId": nodeId,
"parameter": paramName,
"message": f"Parameter '{paramName}' set",
})
except Exception as e:
logger.exception("setNodeParameter failed: %s", e)
return ToolResult(success=False, error=str(e))
return _err(name, str(e))
def _coerceLabel(rawLabel: Any, fallback: str) -> str:
"""Normalize a node label which may be a string, dict {locale: str}, or other."""
if isinstance(rawLabel, str):
return rawLabel
if isinstance(rawLabel, dict):
for key in ("en", "de", "fr"):
value = rawLabel.get(key)
if isinstance(value, str) and value:
return value
for value in rawLabel.values():
if isinstance(value, str) and value:
return value
return fallback
def _summarizeNodeForCatalog(n: Dict[str, Any]) -> Dict[str, Any]:
"""Compact summary used in ``listAvailableNodeTypes`` — small but
informative enough that the model can pick the right type and knows
whether ``describeNodeType`` is worth a follow-up call."""
nodeId = n.get("id") or ""
paramsList = n.get("parameters") or []
requiredCount = sum(1 for p in paramsList if isinstance(p, dict) and p.get("required"))
return {
"id": nodeId,
"category": n.get("category"),
"label": _coerceLabel(n.get("label"), nodeId),
"description": _coerceLabel(n.get("description"), ""),
"paramCount": len(paramsList),
"requiredParamCount": requiredCount,
"usesAi": bool(((n.get("meta") or {}).get("usesAi"))),
}
def _summarizeParameter(p: Dict[str, Any]) -> Dict[str, Any]:
"""Reduce a node parameter spec to just what the AI needs to fill it."""
out: Dict[str, Any] = {
"name": p.get("name"),
"type": p.get("type"),
"required": bool(p.get("required")),
"frontendType": p.get("frontendType"),
"description": _coerceLabel(p.get("description"), ""),
}
if "default" in p:
out["default"] = p.get("default")
feOpts = p.get("frontendOptions")
if isinstance(feOpts, dict):
# Expose enum-style choices ("options") so the model sticks to allowed values.
if isinstance(feOpts.get("options"), list):
out["allowedValues"] = feOpts.get("options")
if p.get("frontendType") == "userConnection":
out["hint"] = (
"Call listConnections to discover available connections; pass the "
"connectionId here. Required before this node can run."
)
return out
async def _listAvailableNodeTypes(params: Dict[str, Any], context: Any) -> ToolResult:
"""List all available node types for the flow builder."""
"""List all available node types for the flow builder (compact catalog).
Returns ``id``, ``category``, ``label``, short ``description``, and the
parameter counts. To learn HOW to fill a node's parameters use
``describeNodeType(nodeType=...)`` that returns the full schema.
"""
name = "listAvailableNodeTypes"
try:
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
nodeTypes = [
{"id": n.get("id"), "category": n.get("category"), "label": n.get("label", {}).get("en", n.get("id"))}
for n in STATIC_NODE_TYPES
]
return ToolResult(success=True, data={"nodeTypes": nodeTypes, "count": len(nodeTypes)})
nodeTypes = []
for n in STATIC_NODE_TYPES:
if not isinstance(n, dict):
continue
nodeTypes.append(_summarizeNodeForCatalog(n))
return _ok(name, {"nodeTypes": nodeTypes, "count": len(nodeTypes)})
except Exception as e:
logger.exception("listAvailableNodeTypes failed: %s", e)
return ToolResult(success=False, error=str(e))
return _err(name, str(e))
async def _describeNodeType(params: Dict[str, Any], context: Any) -> ToolResult:
"""Return the full schema for a single node type so the AI can fill
``addNode.parameters`` correctly (which fields are required, what types,
default values, allowed enum values, what each port expects/produces).
This is the canonical way to discover required parameters before
calling ``addNode`` without it the model guesses ``parameters={}``
and the user gets empty configuration cards.
"""
name = "describeNodeType"
try:
nodeType = params.get("nodeType") or params.get("id")
if not nodeType:
return _err(name, "nodeType required")
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
target: Dict[str, Any] = {}
for n in STATIC_NODE_TYPES:
if isinstance(n, dict) and n.get("id") == nodeType:
target = n
break
if not target:
return _err(name, f"Unknown nodeType '{nodeType}' — call listAvailableNodeTypes first")
rawParams = target.get("parameters") or []
parameters = [
_summarizeParameter(p) for p in rawParams if isinstance(p, dict)
]
def _portList(portsDict: Any) -> List[Dict[str, Any]]:
if not isinstance(portsDict, dict):
return []
out: List[Dict[str, Any]] = []
for idx, spec in sorted(portsDict.items(), key=lambda kv: int(kv[0]) if str(kv[0]).isdigit() else 0):
if not isinstance(spec, dict):
continue
entry: Dict[str, Any] = {"index": int(idx) if str(idx).isdigit() else idx}
if "schema" in spec:
entry["schema"] = spec.get("schema")
if "accepts" in spec:
entry["accepts"] = spec.get("accepts")
out.append(entry)
return out
meta = target.get("meta") or {}
return _ok(name, {
"id": target.get("id"),
"category": target.get("category"),
"label": _coerceLabel(target.get("label"), target.get("id") or ""),
"description": _coerceLabel(target.get("description"), ""),
"usesAi": bool(meta.get("usesAi")),
"inputs": int(target.get("inputs") or 0),
"outputs": int(target.get("outputs") or 0),
"inputPorts": _portList(target.get("inputPorts")),
"outputPorts": _portList(target.get("outputPorts")),
"parameters": parameters,
"requiredParameters": [p["name"] for p in parameters if p.get("required")],
})
except Exception as e:
logger.exception("describeNodeType failed: %s", e)
return _err(name, str(e))
# Geometry constants — MUST match the frontend (FlowCanvas.tsx) so the
# server-side auto-layout produces the exact same coordinates the user
# would get by clicking "Arrange" in the UI.
_NODE_WIDTH = 200
_NODE_HEIGHT = 72
_LAYOUT_V_GAP = 80
_LAYOUT_H_GAP = 60
_LAYOUT_START_X = 40
_LAYOUT_START_Y = 40
def _computeAutoLayout(
nodes: List[Dict[str, Any]],
connections: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Topological-layer layout — port of ``computeAutoLayout`` in FlowCanvas.tsx.
Arranges nodes top-to-bottom in layers (one layer per BFS step from the
sources). Disconnected nodes are appended as extra single-node layers,
same as the frontend. Returns a NEW node list with updated top-level
``x``/``y``; legacy ``position`` keys are stripped to avoid two
competing sources of truth.
"""
if not nodes:
return nodes
nodeIds = {n.get("id") for n in nodes if n.get("id")}
inDegree: Dict[str, int] = {nid: 0 for nid in nodeIds if nid}
children: Dict[str, List[str]] = {nid: [] for nid in nodeIds if nid}
for c in connections or []:
src = c.get("source")
tgt = c.get("target")
if src in inDegree and tgt in inDegree:
inDegree[tgt] = inDegree[tgt] + 1
children[src].append(tgt)
layers: List[List[str]] = []
layerOf: Dict[str, int] = {}
queue: List[str] = [nid for nid, deg in inDegree.items() if deg == 0]
while queue:
batch = list(queue)
queue = []
layerIdx = len(layers)
layers.append(batch)
for nid in batch:
layerOf[nid] = layerIdx
for childId in children.get(nid, []):
inDegree[childId] = inDegree[childId] - 1
if inDegree[childId] == 0:
queue.append(childId)
# Cycles: append remaining nodes as their own layers (matches frontend).
for n in nodes:
nid = n.get("id")
if nid and nid not in layerOf:
layerIdx = len(layers)
layers.append([nid])
layerOf[nid] = layerIdx
out: List[Dict[str, Any]] = []
for n in nodes:
nid = n.get("id")
layer = layerOf.get(nid, 0) if nid else 0
siblings = layers[layer] if 0 <= layer < len(layers) else [nid]
idxInLayer = siblings.index(nid) if nid in siblings else 0
new = dict(n)
new["x"] = _LAYOUT_START_X + idxInLayer * (_NODE_WIDTH + _LAYOUT_H_GAP)
new["y"] = _LAYOUT_START_Y + layer * (_NODE_HEIGHT + _LAYOUT_V_GAP)
# Strip legacy ``position`` so frontend never sees two coordinates.
new.pop("position", None)
out.append(new)
return out
async def _autoLayoutWorkflow(params: Dict[str, Any], context: Any) -> ToolResult:
"""Re-arrange all nodes of the workflow into a clean top-down layered layout.
Same algorithm as the editor's "Arrange" button — call this after you
finished adding/connecting nodes so the user doesn't see an unreadable
pile of overlapping boxes.
"""
name = "autoLayoutWorkflow"
try:
workflowId, instanceId = _resolveIds(params, context)
if not workflowId or not instanceId:
return _err(name, "workflowId and instanceId required (and not present in agent context)")
iface = _getInterface(context, instanceId)
wf = iface.getWorkflow(workflowId)
if not wf:
return _err(name, f"Workflow {workflowId} not found")
graph = dict(wf.get("graph", {}) or {})
nodes = list(graph.get("nodes", []) or [])
connections = list(graph.get("connections", []) or [])
if not nodes:
return _ok(name, {"message": "No nodes to layout", "nodeCount": 0})
graph["nodes"] = _computeAutoLayout(nodes, connections)
iface.updateWorkflow(workflowId, {"graph": graph})
return _ok(name, {
"message": f"Auto-layout applied to {len(nodes)} nodes",
"nodeCount": len(nodes),
"layerCount": max((c.get("y", 0) for c in graph["nodes"]), default=_LAYOUT_START_Y) // (_NODE_HEIGHT + _LAYOUT_V_GAP) + 1,
})
except Exception as e:
logger.exception("autoLayoutWorkflow failed: %s", e)
return _err(name, str(e))
async def _validateGraph(params: Dict[str, Any], context: Any) -> ToolResult:
"""Validate a workflow graph for common issues."""
name = "validateGraph"
try:
workflowId = params.get("workflowId")
instanceId = params.get("instanceId")
workflowId, instanceId = _resolveIds(params, context)
if not workflowId or not instanceId:
return ToolResult(success=False, error="workflowId and instanceId required")
return _err(name, "workflowId and instanceId required")
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
user = getattr(context, "user", None)
mandateId = getattr(context, "mandateId", "") or ""
iface = getGraphicalEditorInterface(user, mandateId, instanceId)
iface = _getInterface(context, instanceId)
wf = iface.getWorkflow(workflowId)
if not wf:
return ToolResult(success=False, error=f"Workflow {workflowId} not found")
return _err(name, f"Workflow {workflowId} not found")
graph = wf.get("graph", {})
nodes = graph.get("nodes", [])
connections = graph.get("connections", [])
graph = wf.get("graph", {}) or {}
nodes = graph.get("nodes", []) or []
connections = graph.get("connections", []) or []
issues: List[str] = []
nodeIds = {n.get("id") for n in nodes}
if not nodes:
issues.append("Graph has no nodes")
hasTrigger = any(n.get("type", "").startswith("trigger.") for n in nodes)
hasTrigger = any((n.get("type") or "").startswith("trigger.") for n in nodes)
if not hasTrigger:
issues.append("No trigger node found")
@ -260,64 +580,59 @@ async def _validateGraph(params: Dict[str, Any], context: Any) -> ToolResult:
for c in connections:
connectedNodes.add(c.get("source"))
connectedNodes.add(c.get("target"))
orphans = [n.get("id") for n in nodes if n.get("id") not in connectedNodes and not n.get("type", "").startswith("trigger.")]
orphans = [
n.get("id") for n in nodes
if n.get("id") not in connectedNodes and not (n.get("type") or "").startswith("trigger.")
]
if orphans:
issues.append(f"Orphan nodes (not connected): {', '.join(orphans)}")
return ToolResult(
success=True,
data={
"valid": len(issues) == 0,
"issues": issues,
"nodeCount": len(nodes),
"connectionCount": len(connections),
},
)
return _ok(name, {
"valid": len(issues) == 0,
"issues": issues,
"nodeCount": len(nodes),
"connectionCount": len(connections),
})
except Exception as e:
logger.exception("validateGraph failed: %s", e)
return ToolResult(success=False, error=str(e))
return _err(name, str(e))
async def _listWorkflowHistory(params: Dict[str, Any], context: Any) -> ToolResult:
"""List versions (history) for a workflow."""
name = "listWorkflowHistory"
try:
workflowId = params.get("workflowId", "")
instanceId = params.get("instanceId", "")
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
user = getattr(context, "user", None)
mandateId = getattr(context, "mandateId", "") or ""
iface = getGraphicalEditorInterface(user, mandateId, instanceId)
versions = iface.getVersions(workflowId)
return ToolResult(
success=True,
data={
"workflowId": workflowId,
"versions": [
{
"id": v.get("id"),
"versionNumber": v.get("versionNumber"),
"status": v.get("status"),
"publishedAt": v.get("publishedAt"),
"publishedBy": v.get("publishedBy"),
}
for v in versions
],
},
)
workflowId, instanceId = _resolveIds(params, context)
if not workflowId or not instanceId:
return _err(name, "workflowId and instanceId required")
iface = _getInterface(context, instanceId)
versions = iface.getVersions(workflowId) or []
return _ok(name, {
"workflowId": workflowId,
"versions": [
{
"id": v.get("id"),
"versionNumber": v.get("versionNumber"),
"status": v.get("status"),
"publishedAt": v.get("publishedAt"),
"publishedBy": v.get("publishedBy"),
}
for v in versions
],
})
except Exception as e:
logger.exception("listWorkflowHistory failed: %s", e)
return ToolResult(success=False, error=str(e))
return _err(name, str(e))
async def _readWorkflowMessages(params: Dict[str, Any], context: Any) -> ToolResult:
"""Read recent run logs/messages for a workflow."""
name = "readWorkflowMessages"
try:
workflowId = params.get("workflowId", "")
instanceId = params.get("instanceId", "")
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
user = getattr(context, "user", None)
mandateId = getattr(context, "mandateId", "") or ""
iface = getGraphicalEditorInterface(user, mandateId, instanceId)
workflowId, instanceId = _resolveIds(params, context)
if not workflowId or not instanceId:
return _err(name, "workflowId and instanceId required")
iface = _getInterface(context, instanceId)
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoRun
runs = iface.db.getRecordset(AutoRun, recordFilter={"workflowId": workflowId}) or []
runSummaries = []
@ -329,119 +644,163 @@ async def _readWorkflowMessages(params: Dict[str, Any], context: Any) -> ToolRes
"completedAt": r.get("completedAt"),
"error": r.get("error"),
})
return ToolResult(
success=True,
data={"workflowId": workflowId, "recentRuns": runSummaries},
)
return _ok(name, {"workflowId": workflowId, "recentRuns": runSummaries})
except Exception as e:
logger.exception("readWorkflowMessages failed: %s", e)
return ToolResult(success=False, error=str(e))
return _err(name, str(e))
def getWorkflowToolDefinitions() -> List[Dict[str, Any]]:
"""Return tool definitions for registration in the ToolRegistry."""
"""Return tool definitions for registration in the ToolRegistry.
Note: ``workflowId`` and ``instanceId`` are NOT marked ``required``
they are auto-injected from the agent context by ``_resolveIds``. The
model may still pass them explicitly (e.g. to target a different
workflow) but doesn't have to repeat them on every call.
"""
_idFields = {
"workflowId": {"type": "string", "description": "Workflow ID (defaults to the current editor workflow)"},
"instanceId": {"type": "string", "description": "Feature instance ID (defaults to the current editor instance)"},
}
return [
{
"name": "readWorkflowGraph",
"handler": _readWorkflowGraph,
"description": "Read the current workflow graph (nodes and connections)",
"description": "Read the current workflow graph (nodes and connections). Always call this first to understand the current state before making changes.",
"parameters": {
"type": "object",
"properties": {
"workflowId": {"type": "string", "description": "Workflow ID"},
"instanceId": {"type": "string", "description": "Feature instance ID"},
},
"required": ["workflowId", "instanceId"],
"properties": {**_idFields},
"required": [],
},
"readOnly": True,
"toolSet": TOOLBOX_ID,
},
{
"name": "addNode",
"handler": _addNode,
"description": "Add a node to the workflow graph",
"description": "Add a node to the workflow graph.",
"parameters": {
"type": "object",
"properties": {
"workflowId": {"type": "string"},
"instanceId": {"type": "string"},
"nodeType": {"type": "string", "description": "Node type (e.g. ai.chat, email.send)"},
**_idFields,
"nodeType": {"type": "string", "description": "Node type id (e.g. ai.chat, email.send) — use listAvailableNodeTypes to discover"},
"title": {"type": "string", "description": "Human-readable title"},
"parameters": {"type": "object", "description": "Node parameters"},
"position": {"type": "object", "description": "Canvas position {x, y}"},
"nodeId": {"type": "string", "description": "Optional explicit node id"},
},
"required": ["workflowId", "instanceId", "nodeType"],
"required": ["nodeType"],
},
"toolSet": TOOLBOX_ID,
},
{
"name": "removeNode",
"handler": _removeNode,
"description": "Remove a node and its connections from the graph",
"description": "Remove a node and its connections from the graph.",
"parameters": {
"type": "object",
"properties": {
"workflowId": {"type": "string"},
"instanceId": {"type": "string"},
**_idFields,
"nodeId": {"type": "string", "description": "ID of the node to remove"},
},
"required": ["workflowId", "instanceId", "nodeId"],
"required": ["nodeId"],
},
"toolSet": TOOLBOX_ID,
},
{
"name": "connectNodes",
"handler": _connectNodes,
"description": "Connect two nodes in the graph",
"description": "Connect two nodes in the graph (source -> target).",
"parameters": {
"type": "object",
"properties": {
"workflowId": {"type": "string"},
"instanceId": {"type": "string"},
**_idFields,
"sourceId": {"type": "string"},
"targetId": {"type": "string"},
"sourceOutput": {"type": "integer", "default": 0},
"targetInput": {"type": "integer", "default": 0},
},
"required": ["workflowId", "instanceId", "sourceId", "targetId"],
"required": ["sourceId", "targetId"],
},
"toolSet": TOOLBOX_ID,
},
{
"name": "setNodeParameter",
"handler": _setNodeParameter,
"description": "Set a parameter on a node",
"description": "Set a single parameter on a node.",
"parameters": {
"type": "object",
"properties": {
"workflowId": {"type": "string"},
"instanceId": {"type": "string"},
**_idFields,
"nodeId": {"type": "string"},
"parameterName": {"type": "string"},
"parameterValue": {"description": "Value to set (any type)"},
},
"required": ["workflowId", "instanceId", "nodeId", "parameterName", "parameterValue"],
"required": ["nodeId", "parameterName", "parameterValue"],
},
"toolSet": TOOLBOX_ID,
},
{
"name": "listAvailableNodeTypes",
"handler": _listAvailableNodeTypes,
"description": "List all available node types for the flow builder",
"description": (
"List all available node types (compact catalog: id, label, "
"description, paramCount, requiredParamCount, usesAi). Call this "
"once to discover ids; then call describeNodeType for each type "
"you intend to add to learn the parameter schema."
),
"parameters": {"type": "object", "properties": {}},
"readOnly": True,
"toolSet": TOOLBOX_ID,
},
{
"name": "validateGraph",
"handler": _validateGraph,
"description": "Validate a workflow graph for common issues",
"name": "describeNodeType",
"handler": _describeNodeType,
"description": (
"Return the FULL parameter schema for a single node type "
"(name, type, required, default, allowedValues, description) "
"plus input/output ports. ALWAYS call this before addNode for "
"any node type that has requiredParamCount > 0, and pass all "
"required parameters into addNode — otherwise the user sees an "
"empty configuration card. For parameters with "
"frontendType='userConnection' call listConnections to obtain "
"a connectionId."
),
"parameters": {
"type": "object",
"properties": {
"workflowId": {"type": "string"},
"instanceId": {"type": "string"},
"nodeType": {"type": "string", "description": "Node type id from listAvailableNodeTypes (e.g. 'email.checkEmail', 'ai.prompt')"},
},
"required": ["workflowId", "instanceId"],
"required": ["nodeType"],
},
"readOnly": True,
"toolSet": TOOLBOX_ID,
},
{
"name": "autoLayoutWorkflow",
"handler": _autoLayoutWorkflow,
"description": (
"Re-arrange ALL nodes into a clean top-down layered layout "
"(same algorithm as the editor's 'Arrange' button). Call this "
"AFTER you finished adding nodes and connections, otherwise the "
"user sees a pile of overlapping boxes. Idempotent — safe to "
"call multiple times."
),
"parameters": {
"type": "object",
"properties": {**_idFields},
"required": [],
},
"toolSet": TOOLBOX_ID,
},
{
"name": "validateGraph",
"handler": _validateGraph,
"description": "Validate a workflow graph for common issues (missing trigger, dangling connections, orphans).",
"parameters": {
"type": "object",
"properties": {**_idFields},
"required": [],
},
"readOnly": True,
"toolSet": TOOLBOX_ID,
@ -449,14 +808,11 @@ def getWorkflowToolDefinitions() -> List[Dict[str, Any]]:
{
"name": "listWorkflowHistory",
"handler": _listWorkflowHistory,
"description": "List version history for a workflow (AutoVersion entries)",
"description": "List version history for a workflow (AutoVersion entries).",
"parameters": {
"type": "object",
"properties": {
"workflowId": {"type": "string"},
"instanceId": {"type": "string"},
},
"required": ["workflowId", "instanceId"],
"properties": {**_idFields},
"required": [],
},
"readOnly": True,
"toolSet": TOOLBOX_ID,
@ -464,14 +820,11 @@ def getWorkflowToolDefinitions() -> List[Dict[str, Any]]:
{
"name": "readWorkflowMessages",
"handler": _readWorkflowMessages,
"description": "Read recent run logs and status for a workflow",
"description": "Read recent run logs and status for a workflow.",
"parameters": {
"type": "object",
"properties": {
"workflowId": {"type": "string"},
"instanceId": {"type": "string"},
},
"required": ["workflowId", "instanceId"],
"properties": {**_idFields},
"required": [],
},
"readOnly": True,
"toolSet": TOOLBOX_ID,

View file

@ -0,0 +1,121 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Slug and validation helpers for Mandate.name (Kurzzeichen).
Format: lowercase [a-z0-9], segments separated by a single hyphen, length 232.
German umlauts are transliterated (äae, öoe, üue, ßss) before slugging.
"""
from __future__ import annotations
import re
from typing import Iterable, Set
MANDATE_NAME_MIN_LEN = 2
MANDATE_NAME_MAX_LEN = 32
_MANDATE_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
def _transliterateGerman(text: str) -> str:
"""Map common German characters to ASCII before slugging."""
if not text:
return ""
result: list[str] = []
for ch in text:
lower = ch.lower()
if lower == "ä":
result.append("ae")
elif lower == "ö":
result.append("oe")
elif lower == "ü":
result.append("ue")
elif lower == "ß":
result.append("ss")
else:
result.append(ch)
return "".join(result)
def _collapseHyphensAndTrim(raw: str) -> str:
s = re.sub(r"[^a-z0-9]+", "-", raw.lower())
s = re.sub(r"-+", "-", s).strip("-")
return s
def _ensureMinSlugLength(slug: str) -> str:
if len(slug) >= MANDATE_NAME_MIN_LEN:
return slug
if len(slug) == 1:
return slug + slug
return slug + ("x" * (MANDATE_NAME_MIN_LEN - len(slug)))
def _truncateSlugToMaxLen(slug: str) -> str:
if len(slug) <= MANDATE_NAME_MAX_LEN:
return slug
cut = slug[: MANDATE_NAME_MAX_LEN].rstrip("-")
if "-" in cut:
cut = cut[: cut.rfind("-")]
cut = cut.strip("-")
if len(cut) < MANDATE_NAME_MIN_LEN:
return cut + ("x" * (MANDATE_NAME_MIN_LEN - len(cut)))
return cut
def transliterateGerman(text: str) -> str:
"""Transliterate German umlauts in *text* for further processing."""
return _transliterateGerman(text)
def slugifyMandateName(label: str) -> str:
"""
Build a mandate slug base from a human-readable label.
Result satisfies isValidMandateName except pathological cases (falls back to 'mn').
"""
if not label or not str(label).strip():
t = "mn"
else:
step1 = _transliterateGerman(label.strip())
step2 = _collapseHyphensAndTrim(step1)
if not step2:
t = "mn"
else:
t = _ensureMinSlugLength(step2)
t = _truncateSlugToMaxLen(t)
if not isValidMandateName(t):
return "mn"
return t
def isValidMandateName(name: str) -> bool:
"""True if *name* matches slug rules (length 232, [a-z0-9] and single-hyphen segments)."""
if not isinstance(name, str) or len(name) < MANDATE_NAME_MIN_LEN or len(name) > MANDATE_NAME_MAX_LEN:
return False
return _MANDATE_NAME_RE.match(name) is not None
def allocateUniqueMandateSlug(base: str, taken: Iterable[str]) -> str:
"""
Return a slug not present in *taken*, starting with *base*, then base-2, base-3, ...
*base* must satisfy isValidMandateName (typically from slugifyMandateName).
"""
used: Set[str] = {x for x in taken if x}
if base not in used:
return base
n = 2
while True:
suffix = f"-{n}"
room = MANDATE_NAME_MAX_LEN - len(suffix)
if room < MANDATE_NAME_MIN_LEN:
room = MANDATE_NAME_MIN_LEN
root = base[:room].rstrip("-")
if len(root) < MANDATE_NAME_MIN_LEN:
root = "mn"
cand = (root + suffix)[:MANDATE_NAME_MAX_LEN]
cand = cand.rstrip("-")
if isValidMandateName(cand) and cand not in used:
return cand
n += 1
if n > 100000:
raise ValueError("allocateUniqueMandateSlug: could not allocate a unique slug")

View file

@ -0,0 +1,136 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Voice / Language Catalog Single Source of Truth.
Every voice-related component (TTS connector, AI tools, REST routes, frontend
language pickers) consumes this catalog. Hard-coded language lists or ad-hoc
ISOBCP-47 maps elsewhere are forbidden extend the catalog instead.
Schema per entry:
bcp47 BCP-47 locale code, e.g. "de-DE", "ru-RU"
iso ISO-639-1 short code, e.g. "de", "ru"
label Native display label ("Deutsch", "Русский")
flag Emoji flag (or empty string for region-neutral codes)
defaultVoice Curated Google TTS voice name; None means "let Google
pick automatically based on bcp47 + ssml_gender".
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Dict, List, Optional
@dataclass(frozen=True)
class VoiceLanguage:
bcp47: str
iso: str
label: str
flag: str
defaultVoice: Optional[str]
# Order matters for UI: most common first, then alphabetical groups.
VOICE_LANGUAGES: List[VoiceLanguage] = [
VoiceLanguage("de-DE", "de", "Deutsch", "🇩🇪", "de-DE-Wavenet-A"),
VoiceLanguage("de-CH", "de", "Deutsch (Schweiz)", "🇨🇭", "de-DE-Wavenet-A"),
VoiceLanguage("de-AT", "de", "Deutsch (Österreich)", "🇦🇹", "de-DE-Wavenet-A"),
VoiceLanguage("en-US", "en", "English (US)", "🇺🇸", "en-US-Wavenet-C"),
VoiceLanguage("en-GB", "en", "English (UK)", "🇬🇧", "en-GB-Wavenet-A"),
VoiceLanguage("en-AU", "en", "English (Australia)", "🇦🇺", "en-AU-Wavenet-A"),
VoiceLanguage("fr-FR", "fr", "Français", "🇫🇷", "fr-FR-Wavenet-A"),
VoiceLanguage("fr-CA", "fr", "Français (Canada)", "🇨🇦", "fr-CA-Wavenet-A"),
VoiceLanguage("it-IT", "it", "Italiano", "🇮🇹", "it-IT-Wavenet-A"),
VoiceLanguage("es-ES", "es", "Español", "🇪🇸", "es-ES-Wavenet-B"),
VoiceLanguage("es-US", "es", "Español (US)", "🇺🇸", "es-US-Wavenet-A"),
VoiceLanguage("pt-BR", "pt", "Português (Brasil)", "🇧🇷", "pt-BR-Wavenet-A"),
VoiceLanguage("pt-PT", "pt", "Português (Portugal)", "🇵🇹", "pt-PT-Wavenet-A"),
VoiceLanguage("nl-NL", "nl", "Nederlands", "🇳🇱", "nl-NL-Wavenet-A"),
VoiceLanguage("pl-PL", "pl", "Polski", "🇵🇱", "pl-PL-Wavenet-A"),
VoiceLanguage("ru-RU", "ru", "Русский", "🇷🇺", "ru-RU-Wavenet-A"),
VoiceLanguage("uk-UA", "uk", "Українська", "🇺🇦", "uk-UA-Wavenet-A"),
VoiceLanguage("cs-CZ", "cs", "Čeština", "🇨🇿", "cs-CZ-Wavenet-A"),
VoiceLanguage("sk-SK", "sk", "Slovenčina", "🇸🇰", "sk-SK-Wavenet-A"),
VoiceLanguage("hu-HU", "hu", "Magyar", "🇭🇺", "hu-HU-Wavenet-A"),
VoiceLanguage("ro-RO", "ro", "Română", "🇷🇴", "ro-RO-Wavenet-A"),
VoiceLanguage("el-GR", "el", "Ελληνικά", "🇬🇷", "el-GR-Wavenet-A"),
VoiceLanguage("sv-SE", "sv", "Svenska", "🇸🇪", "sv-SE-Wavenet-A"),
VoiceLanguage("da-DK", "da", "Dansk", "🇩🇰", "da-DK-Wavenet-A"),
VoiceLanguage("nb-NO", "nb", "Norsk", "🇳🇴", "nb-NO-Wavenet-A"),
VoiceLanguage("fi-FI", "fi", "Suomi", "🇫🇮", "fi-FI-Wavenet-A"),
VoiceLanguage("tr-TR", "tr", "Türkçe", "🇹🇷", "tr-TR-Wavenet-A"),
VoiceLanguage("ar-XA", "ar", "العربية", "", "ar-XA-Wavenet-A"),
VoiceLanguage("hi-IN", "hi", "हिन्दी", "🇮🇳", "hi-IN-Wavenet-A"),
VoiceLanguage("ja-JP", "ja", "日本語", "🇯🇵", "ja-JP-Wavenet-A"),
VoiceLanguage("ko-KR", "ko", "한국어", "🇰🇷", "ko-KR-Wavenet-A"),
VoiceLanguage("zh-CN", "zh", "中文 (简体)", "🇨🇳", "cmn-CN-Wavenet-A"),
VoiceLanguage("vi-VN", "vi", "Tiếng Việt", "🇻🇳", "vi-VN-Wavenet-A"),
VoiceLanguage("th-TH", "th", "ไทย", "🇹🇭", "th-TH-Standard-A"),
VoiceLanguage("id-ID", "id", "Bahasa Indonesia", "🇮🇩", "id-ID-Wavenet-A"),
]
# ---------------------------------------------------------------------------
# Lookup indexes (built once at import).
# ---------------------------------------------------------------------------
_BY_BCP47: Dict[str, VoiceLanguage] = {v.bcp47.lower(): v for v in VOICE_LANGUAGES}
_BY_ISO: Dict[str, VoiceLanguage] = {}
for _v in VOICE_LANGUAGES:
_BY_ISO.setdefault(_v.iso.lower(), _v)
def listVoiceLanguages() -> List[VoiceLanguage]:
"""Return the canonical, ordered list of supported voice languages."""
return list(VOICE_LANGUAGES)
def getCatalogPayload() -> List[Dict[str, Optional[str]]]:
"""Return the catalog as plain dicts — ready for JSON serialization."""
return [asdict(v) for v in VOICE_LANGUAGES]
def getByBcp47(code: Optional[str]) -> Optional[VoiceLanguage]:
if not code:
return None
return _BY_BCP47.get(code.strip().lower())
def getByIso(code: Optional[str]) -> Optional[VoiceLanguage]:
if not code:
return None
return _BY_ISO.get(code.strip().lower())
def getDefaultVoice(bcp47: Optional[str]) -> Optional[str]:
"""Return the curated default Google TTS voice for a BCP-47 code, else None.
None means: caller must omit `name` in VoiceSelectionParams so Google
auto-selects a voice for the language code.
"""
entry = getByBcp47(bcp47)
return entry.defaultVoice if entry else None
def isoToBcp47(iso: Optional[str]) -> Optional[str]:
"""Map an ISO-639-1 short code to the canonical BCP-47 locale.
Already-qualified BCP-47 inputs are passed through unchanged (canonicalised
to the catalog form when known). Unknown ISO codes fall back to
``<iso>-<ISO>`` (e.g. "fa" "fa-FA") so callers always get a parseable
locale, but unknown codes carry no curated voice.
"""
if not iso:
return None
normalized = iso.strip()
if not normalized:
return None
if "-" in normalized:
canonical = getByBcp47(normalized)
return canonical.bcp47 if canonical else normalized
isoLower = normalized.lower()
entry = _BY_ISO.get(isoLower)
if entry:
return entry.bcp47
return f"{isoLower}-{isoLower.upper()}"

View file

@ -622,11 +622,11 @@ def registerFeature(catalogService) -> bool:
meta=aicoreObj.get("meta")
)
# Register feature definition
catalogService.registerFeatureDefinition(
featureCode=FEATURE_CODE,
label=FEATURE_LABEL,
icon=FEATURE_ICON
icon=FEATURE_ICON,
instantiable=False,
)
logger.info(f"Registered system RBAC objects: {len(UI_OBJECTS)} UI, {len(DATA_OBJECTS)} DATA, {len(RESOURCE_OBJECTS)} RESOURCE")

View file

@ -172,7 +172,9 @@ def registerAllFeaturesInCatalog(catalogService) -> Dict[str, bool]:
catalogService.registerFeatureDefinition(
featureCode=featureDef.get("code", featureName),
label=featureDef.get("label", {"en": featureName, "de": featureName}),
icon=featureDef.get("icon", "mdi-puzzle")
icon=featureDef.get("icon", "mdi-puzzle"),
instantiable=featureDef.get("instantiable", True),
enabled=featureDef.get("enabled", True),
)
logger.info(f"Registered feature definition: {featureDef.get('code', featureName)}")
except Exception as e:

View file

@ -0,0 +1,132 @@
"""Runtime-Check (A5): bestaetigt, dass die ``sysadmin``-Rolle aus der
Datenbank entfernt wurde und liefert eine kurze Inventur fuer die
isPlatformAdmin / isSysAdmin Flags.
Das Skript verwendet die bestehende ``APP_CONFIG`` (entschluesselt
``DB_PASSWORD_SECRET``) und fragt direkt via ``psycopg2`` ab, ohne den
ganzen FastAPI-Stack hochzufahren.
Aufruf::
python gateway/scripts/check_db_no_sysadmin_role.py
Exit-Code:
- 0 -> sauber (Role-Count == 0)
- 1 -> sysadmin-Rolle existiert noch (Migration unvollstaendig)
- 2 -> Verbindungsfehler (Konfiguration / DB nicht erreichbar)
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
_GATEWAY = Path(__file__).resolve().parents[1]
if str(_GATEWAY) not in sys.path:
sys.path.insert(0, str(_GATEWAY))
import psycopg2 # noqa: E402
import psycopg2.extras # noqa: E402
from modules.shared.configuration import APP_CONFIG # noqa: E402
def _connect():
host = APP_CONFIG.get("DB_HOST", "localhost")
user = APP_CONFIG.get("DB_USER")
password = APP_CONFIG.get("DB_PASSWORD_SECRET")
port = int(APP_CONFIG.get("DB_PORT", 5432))
database = APP_CONFIG.get("DB_DATABASE", "poweron_app")
return psycopg2.connect(
host=host, port=port, dbname=database, user=user, password=password
)
def _main() -> int:
try:
conn = _connect()
except Exception as exc:
print(f"[ERR] Could not connect to database: {exc}")
return 2
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
'SELECT COUNT(*)::int AS n FROM "Role" WHERE "roleLabel" = %s',
("sysadmin",),
)
roleCount = cur.fetchone()["n"]
cur.execute(
'SELECT COUNT(*)::int AS n FROM "UserInDB" '
'WHERE COALESCE("isPlatformAdmin", false) = true'
)
platformAdmins = cur.fetchone()["n"]
cur.execute(
'SELECT COUNT(*)::int AS n FROM "UserInDB" '
'WHERE COALESCE("isSysAdmin", false) = true'
)
sysAdmins = cur.fetchone()["n"]
cur.execute(
'SELECT "username", "email", '
'COALESCE("isSysAdmin", false) AS "isSysAdmin", '
'COALESCE("isPlatformAdmin", false) AS "isPlatformAdmin" '
'FROM "UserInDB" '
'WHERE COALESCE("isSysAdmin", false) = true '
' OR COALESCE("isPlatformAdmin", false) = true '
'ORDER BY "username"'
)
adminUsers = cur.fetchall()
cur.execute(
'SELECT COUNT(*)::int AS n FROM "AccessRule" ar '
'JOIN "Role" r ON ar."roleId" = r."id" '
'WHERE r."roleLabel" = %s',
("sysadmin",),
)
orphanRules = cur.fetchone()["n"]
cur.execute(
'SELECT COUNT(*)::int AS n FROM "UserMandateRole" umr '
'JOIN "Role" r ON umr."roleId" = r."id" '
'WHERE r."roleLabel" = %s',
("sysadmin",),
)
orphanGrants = cur.fetchone()["n"]
finally:
conn.close()
print("=" * 64)
print("A5 - SysAdmin Migration DB Check")
print("=" * 64)
print(f"Role.roleLabel == 'sysadmin' : {roleCount}")
print(f"AccessRule(s) referencing sysadmin role : {orphanRules}")
print(f"UserMandateRole(s) granting sysadmin role : {orphanGrants}")
print(f"User.isSysAdmin = true : {sysAdmins}")
print(f"User.isPlatformAdmin = true : {platformAdmins}")
print()
print("Admin-flagged users:")
if not adminUsers:
print(" (none)")
for row in adminUsers:
flags = []
if row["isSysAdmin"]:
flags.append("isSysAdmin")
if row["isPlatformAdmin"]:
flags.append("isPlatformAdmin")
print(f" - {row['username']:<32} {row.get('email') or '':<40} {','.join(flags)}")
print("=" * 64)
if roleCount == 0 and orphanRules == 0 and orphanGrants == 0:
print("[OK] Migration verified: no sysadmin role artefacts in DB.")
return 0
print("[FAIL] Legacy sysadmin role artefacts still present in DB.")
return 1
if __name__ == "__main__":
sys.exit(_main())

View file

@ -0,0 +1,108 @@
"""CI-Gate: Stelle sicher, dass keine Verweise auf die abgeschaffte
``sysadmin``-Rolle bzw. die alten Helper im Codebase mehr existieren.
Verbotene Symbole nach Abschluss von ``2026-04-sysadmin-authority-split``:
- ``hasSysAdminRole`` (RequestContext property)
- ``requireSysAdminRole`` (FastAPI dependency)
- ``_hasSysAdminRole`` (Hilfs-Funktion gegen die alte Rolle)
Erlaubt sind weiterhin:
- ``isSysAdmin`` (User-Flag fuer Infrastruktur-Operator)
- ``isPlatformAdmin`` (User-Flag fuer Cross-Mandate-Governance)
- ``requireSysAdmin`` / ``requirePlatformAdmin`` (FastAPI Dependencies)
Exit-Code:
- 0 -> sauber
- 1 -> Fundstellen vorhanden (CI bricht ab)
Aufruf::
python gateway/scripts/check_no_sysadmin_role.py
"""
from __future__ import annotations
import os
import re
import sys
from pathlib import Path
from typing import Iterable, List, Tuple
_REPO_ROOT = Path(__file__).resolve().parents[2]
_FORBIDDEN_PATTERNS: Tuple[Tuple[str, str], ...] = (
(r"\bhasSysAdminRole\b", "Use ctx.isPlatformAdmin (Governance) or ctx.isSysAdmin (Infra)"),
(r"\brequireSysAdminRole\b", "Use requirePlatformAdmin (Governance) or requireSysAdmin (Infra)"),
(r"\b_hasSysAdminRole\b", "Use User.isPlatformAdmin flag check directly"),
)
_INCLUDE_SUFFIXES = {".py", ".ts", ".tsx", ".js", ".jsx"}
_EXCLUDE_DIR_NAMES = {
".git",
"node_modules",
"dist",
"build",
"__pycache__",
".venv",
"venv",
".pytest_cache",
".mypy_cache",
"wiki",
"scripts",
}
def _shouldScan(path: Path) -> bool:
if path.suffix not in _INCLUDE_SUFFIXES:
return False
parts = set(path.parts)
if parts & _EXCLUDE_DIR_NAMES:
return False
return True
def _iterFiles(root: Path) -> Iterable[Path]:
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in _EXCLUDE_DIR_NAMES]
for name in filenames:
full = Path(dirpath) / name
if _shouldScan(full):
yield full
def _scanFile(path: Path) -> List[Tuple[int, str, str, str]]:
findings: List[Tuple[int, str, str, str]] = []
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except Exception:
return findings
for pattern, hint in _FORBIDDEN_PATTERNS:
compiled = re.compile(pattern)
for lineNo, line in enumerate(text.splitlines(), start=1):
if compiled.search(line):
findings.append((lineNo, pattern, hint, line.rstrip()))
return findings
def _main() -> int:
findings = []
for filePath in _iterFiles(_REPO_ROOT):
for entry in _scanFile(filePath):
findings.append((filePath, *entry))
if not findings:
print("[OK] No legacy sysadmin-role references found.")
return 0
print("[FAIL] Found legacy sysadmin-role references:")
for filePath, lineNo, pattern, hint, line in findings:
rel = filePath.relative_to(_REPO_ROOT)
print(f" {rel}:{lineNo}: {pattern}\n hint: {hint}\n line: {line}")
print(f"\n[FAIL] {len(findings)} forbidden reference(s).")
return 1
if __name__ == "__main__":
sys.exit(_main())

View file

View file

@ -0,0 +1,190 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Integration tests for ``AppObjects.createMandate``.
Covers acceptance criteria from
``wiki/c-work/1-plan/2026-04-mandate-name-label-logic.md``:
- AC#1 -> create with label only auto-generates a valid slug name (umlaut transliteration).
- AC#2 -> two labels yielding the same slug get -2 suffix.
- AC#4 -> explicit invalid name (uppercase / spaces) is rejected with ValueError (mapped to 400 by route).
- Label is mandatory (empty label raises ValueError).
- Explicit valid name is honored verbatim.
Strategy: instantiate ``AppObjects`` via ``__new__`` (skip real ``__init__``) and
inject a minimal FakeDb that simulates ``getRecordset(Mandate)`` and
``recordCreate(Mandate, ...)``. RBAC and role-copy are stubbed.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from unittest.mock import Mock, patch
from uuid import uuid4
import pytest
from modules.datamodels.datamodelUam import Mandate
from modules.interfaces.interfaceDbApp import AppObjects
from modules.shared.mandateNameUtils import isValidMandateName
class _FakeDb:
"""Minimal connector: getRecordset(Mandate) + recordCreate(Mandate, payload)."""
def __init__(self, rows: Optional[List[Dict[str, Any]]] = None):
self.rows: List[Dict[str, Any]] = [dict(r) for r in (rows or [])]
self.created: List[Dict[str, Any]] = []
def getRecordset(self, model, recordFilter: Optional[Dict[str, Any]] = None):
if model is not Mandate:
return []
if not recordFilter:
return [dict(r) for r in self.rows]
out = []
for r in self.rows:
if all(r.get(k) == v for k, v in recordFilter.items()):
out.append(dict(r))
return out
def recordCreate(self, model, payload):
if hasattr(payload, "model_dump"):
data = payload.model_dump()
elif isinstance(payload, dict):
data = dict(payload)
else:
data = {k: getattr(payload, k) for k in ("name", "label", "enabled", "isSystem")}
if not data.get("id"):
data["id"] = str(uuid4())
self.rows.append(data)
self.created.append(dict(data))
return data
def _buildInterface(db: _FakeDb) -> AppObjects:
"""Build an AppObjects without real __init__ so we don't need a DB connection."""
iface = AppObjects.__new__(AppObjects)
iface.db = db
iface.currentUser = Mock(id="platform-admin", isPlatformAdmin=True, isSysAdmin=False)
iface.userId = "platform-admin"
iface.mandateId = None
iface.featureInstanceId = None
iface.rbac = Mock()
return iface
@pytest.fixture(autouse=True)
def _stubCopySystemRoles():
"""Avoid touching the bootstrap module (which would need a real DB)."""
with patch(
"modules.interfaces.interfaceBootstrap.copySystemRolesToMandate",
return_value=0,
):
yield
class TestCreateMandateAutoName:
def test_emptyNameGetsSlugFromLabel(self):
db = _FakeDb()
iface = _buildInterface(db)
with patch.object(iface, "checkRbacPermission", return_value=True):
mandate = iface.createMandate(name=None, label="Müller AG")
assert mandate.label == "Müller AG"
assert mandate.name == "mueller-ag"
assert isValidMandateName(mandate.name)
def test_blankNameStringGetsAutoGenerated(self):
db = _FakeDb()
iface = _buildInterface(db)
with patch.object(iface, "checkRbacPermission", return_value=True):
mandate = iface.createMandate(name=" ", label="Acme Corp")
assert mandate.name == "acme-corp"
def test_labelTrimmed(self):
db = _FakeDb()
iface = _buildInterface(db)
with patch.object(iface, "checkRbacPermission", return_value=True):
mandate = iface.createMandate(name=None, label=" Tenant X ")
assert mandate.label == "Tenant X"
assert mandate.name == "tenant-x"
class TestCreateMandateCollision:
def test_secondMandateWithSameLabelGetsSuffix(self):
db = _FakeDb([{"id": "first", "name": "mueller-ag", "label": "Müller AG"}])
iface = _buildInterface(db)
with patch.object(iface, "checkRbacPermission", return_value=True):
mandate = iface.createMandate(name=None, label="Müller AG")
assert mandate.name == "mueller-ag-2"
def test_thirdMandateWithSameLabelGetsThirdSuffix(self):
db = _FakeDb([
{"id": "first", "name": "mueller-ag", "label": "Müller AG"},
{"id": "second", "name": "mueller-ag-2", "label": "Müller AG"},
])
iface = _buildInterface(db)
with patch.object(iface, "checkRbacPermission", return_value=True):
mandate = iface.createMandate(name=None, label="Müller AG")
assert mandate.name == "mueller-ag-3"
class TestCreateMandateExplicitName:
def test_validExplicitNameHonored(self):
db = _FakeDb()
iface = _buildInterface(db)
with patch.object(iface, "checkRbacPermission", return_value=True):
mandate = iface.createMandate(name="custom-slug", label="Display Name")
assert mandate.name == "custom-slug"
assert mandate.label == "Display Name"
def test_invalidExplicitNameRejected(self):
db = _FakeDb()
iface = _buildInterface(db)
with patch.object(iface, "checkRbacPermission", return_value=True):
with pytest.raises(ValueError) as excInfo:
iface.createMandate(name="ABC Müller!", label="Display")
assert "Kurzzeichen" in str(excInfo.value)
def test_explicitNameCollisionRejected(self):
db = _FakeDb([{"id": "first", "name": "taken-slug", "label": "Existing"}])
iface = _buildInterface(db)
with patch.object(iface, "checkRbacPermission", return_value=True):
with pytest.raises(ValueError) as excInfo:
iface.createMandate(name="taken-slug", label="New One")
assert "already in use" in str(excInfo.value)
class TestCreateMandateLabelMandatory:
def test_emptyLabelAndNoNameRejected(self):
db = _FakeDb()
iface = _buildInterface(db)
with patch.object(iface, "checkRbacPermission", return_value=True):
with pytest.raises(ValueError) as excInfo:
iface.createMandate(name=None, label="")
assert "label" in str(excInfo.value).lower()
def test_noneLabelAndNoNameRejected(self):
db = _FakeDb()
iface = _buildInterface(db)
with patch.object(iface, "checkRbacPermission", return_value=True):
with pytest.raises(ValueError):
iface.createMandate(name=None, label=None)
def test_emptyLabelButNameProvidedFallsBackToName(self):
"""Backwards-compat: legacy callers pass only ``name``; route falls back."""
db = _FakeDb()
iface = _buildInterface(db)
with patch.object(iface, "checkRbacPermission", return_value=True):
mandate = iface.createMandate(name="legacy-name", label="")
assert mandate.label == "legacy-name"
assert mandate.name == "legacy-name"
class TestCreateMandateRbac:
def test_noPermissionRaises(self):
db = _FakeDb()
iface = _buildInterface(db)
with patch.object(iface, "checkRbacPermission", return_value=False):
with pytest.raises(PermissionError):
iface.createMandate(name=None, label="X")

View file

@ -0,0 +1,109 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Integration tests for the slug-derivation contract that
``AppObjects._provisionMandateForUser`` relies on.
Covers AC#10 from ``wiki/c-work/1-plan/2026-04-mandate-name-label-logic.md``:
auto-provisioning a user named "Patrick.Möller" yields
``label = "Home Patrick.Möller"`` and ``name = "home-patrick-moeller"``
(or ``-2``, ``-3``, ... on collisions).
The full ``_provisionMandateForUser`` flow has many side effects (subscriptions,
billing, feature instances). For unit-level integration we focus on the
slug-allocation contract via ``_generateUniqueMandateName`` that is the
single new behaviour the provisioning method delegates to.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from unittest.mock import Mock
import pytest
from modules.datamodels.datamodelUam import Mandate
from modules.interfaces.interfaceDbApp import AppObjects
class _FakeDb:
def __init__(self, rows: Optional[List[Dict[str, Any]]] = None):
self.rows: List[Dict[str, Any]] = [dict(r) for r in (rows or [])]
def getRecordset(self, model, recordFilter: Optional[Dict[str, Any]] = None):
if model is not Mandate:
return []
return [dict(r) for r in self.rows]
def _buildInterface(rows: Optional[List[Dict[str, Any]]] = None) -> AppObjects:
iface = AppObjects.__new__(AppObjects)
iface.db = _FakeDb(rows)
iface.currentUser = Mock(id="u-1", isPlatformAdmin=True, isSysAdmin=False)
iface.userId = "u-1"
iface.mandateId = None
iface.featureInstanceId = None
iface.rbac = Mock()
return iface
class TestProvisioningSlugFromHomeLabel:
def test_simpleHomeLabel(self):
iface = _buildInterface()
assert iface._generateUniqueMandateName("Home patrick") == "home-patrick"
def test_umlautPersonNameTransliterated(self):
"""AC#10: Patrick.Möller → home-patrick-moeller"""
iface = _buildInterface()
result = iface._generateUniqueMandateName("Home Patrick.Möller")
assert result == "home-patrick-moeller"
def test_eszettAndUmlautsAndDots(self):
iface = _buildInterface()
result = iface._generateUniqueMandateName("Home Müßler.Ümpf")
assert result == "home-muessler-uempf"
def test_emptyLabelFallsBackToFallbackSlug(self):
iface = _buildInterface()
result = iface._generateUniqueMandateName("")
assert result == "mn"
class TestProvisioningSlugCollisions:
def test_secondHomeWithSameLabelGetsSuffix(self):
rows = [{"id": "first", "name": "home-patrick-moeller", "label": "Home Patrick.Möller"}]
iface = _buildInterface(rows)
result = iface._generateUniqueMandateName("Home Patrick.Möller")
assert result == "home-patrick-moeller-2"
def test_thirdCollisionGetsThirdSuffix(self):
rows = [
{"id": "first", "name": "home-patrick-moeller", "label": "Home Patrick.Möller"},
{"id": "second", "name": "home-patrick-moeller-2", "label": "Home Patrick.Möller"},
]
iface = _buildInterface(rows)
result = iface._generateUniqueMandateName("Home Patrick.Möller")
assert result == "home-patrick-moeller-3"
def test_excludeIdHonored(self):
"""When updating, the row being updated must not collide with itself."""
rows = [{"id": "self", "name": "home-patrick-moeller", "label": "Home Patrick.Möller"}]
iface = _buildInterface(rows)
result = iface._generateUniqueMandateName("Home Patrick.Möller", excludeId="self")
assert result == "home-patrick-moeller", "own row should be excluded from collision check"
class TestProvisioningPlanGuard:
"""Sanity guard: the new label-mandatory check fires before any DB write."""
def test_emptyLabelRejected(self):
iface = _buildInterface()
with pytest.raises(ValueError) as excInfo:
iface._provisionMandateForUser(userId="u-1", mandateLabel="", planKey="TRIAL_14D")
assert "label" in str(excInfo.value).lower() or "voller name" in str(excInfo.value).lower()
def test_unknownPlanRejectedBeforeLabelCheck(self):
iface = _buildInterface()
with pytest.raises(ValueError) as excInfo:
iface._provisionMandateForUser(userId="u-1", mandateLabel="Home X", planKey="DOES_NOT_EXIST")
assert "plan" in str(excInfo.value).lower()

View file

@ -0,0 +1,215 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Integration tests for ``AppObjects.updateMandate``.
Covers acceptance criteria from
``wiki/c-work/1-plan/2026-04-mandate-name-label-logic.md``:
- AC#3 -> non-PlatformAdmin update silently drops protected ``name``;
label-only updates still succeed.
- AC#4 -> PlatformAdmin update with invalid name format rejected (ValueError → 400).
- AC#4b -> PlatformAdmin update with empty label rejected.
- AC#4c -> PlatformAdmin update with name colliding on another row rejected.
- Idempotent name update (same value) accepted.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from unittest.mock import Mock, patch
import pytest
from modules.datamodels.datamodelUam import Mandate
from modules.interfaces.interfaceDbApp import AppObjects
class _FakeDb:
"""Minimal connector: getRecordset(Mandate) + recordModify(Mandate, id, data)."""
def __init__(self, rows: List[Dict[str, Any]]):
self.rows: List[Dict[str, Any]] = [dict(r) for r in rows]
self.modifyCalls: List[Dict[str, Any]] = []
def getRecordset(self, model, recordFilter: Optional[Dict[str, Any]] = None):
if model is not Mandate:
return []
if not recordFilter:
return [dict(r) for r in self.rows]
out = []
for r in self.rows:
if all(r.get(k) == v for k, v in recordFilter.items()):
out.append(dict(r))
return out
def recordModify(self, model, recordId: str, payload):
if hasattr(payload, "model_dump"):
data = payload.model_dump()
elif isinstance(payload, dict):
data = dict(payload)
else:
data = {}
self.modifyCalls.append({"id": str(recordId), "data": dict(data)})
for r in self.rows:
if str(r.get("id")) == str(recordId):
r.update(data)
return r
return None
def _buildInterface(db: _FakeDb, *, isPlatformAdmin: bool, isSysAdmin: bool = False) -> AppObjects:
iface = AppObjects.__new__(AppObjects)
iface.db = db
iface.currentUser = Mock(
id="user-x",
isPlatformAdmin=isPlatformAdmin,
isSysAdmin=isSysAdmin,
)
iface.userId = "user-x"
iface.mandateId = None
iface.featureInstanceId = None
iface.rbac = Mock()
return iface
def _row(mid: str = "m1", name: str = "alpha", label: str = "Alpha", **extra) -> Dict[str, Any]:
base = {
"id": mid,
"name": name,
"label": label,
"enabled": True,
"isSystem": False,
}
base.update(extra)
return base
def _stubGetMandateAndRbac(iface: AppObjects, row: Dict[str, Any]):
"""Wire ``getMandate`` to read from the FakeDb so post-update reads reflect changes."""
db = iface.db
def _readMandate(mandateId: str):
for r in db.rows:
if str(r.get("id")) == str(mandateId):
return Mandate(**r)
return None
iface.getMandate = Mock(side_effect=_readMandate)
return patch.object(iface, "checkRbacPermission", return_value=True)
class TestUpdateMandateRbacOnName:
def test_mandateAdminCannotChangeName(self):
"""Non-platform admin: ``name`` is a protected field, silently dropped.
Status quo: route layer also enforces this via ``_MANDATE_ADMIN_EDITABLE_FIELDS``,
but the interface itself MUST also defend so that direct calls don't bypass.
"""
row = _row(mid="m1", name="original-slug", label="Original")
db = _FakeDb([row])
iface = _buildInterface(db, isPlatformAdmin=False)
with _stubGetMandateAndRbac(iface, row):
updated = iface.updateMandate("m1", {"name": "hacked-slug", "label": "New Label"})
assert updated.name == "original-slug", "MandateAdmin must NOT modify name"
assert updated.label == "New Label"
def test_platformAdminCanChangeName(self):
row = _row(mid="m1", name="old-slug", label="Old")
db = _FakeDb([row])
iface = _buildInterface(db, isPlatformAdmin=True)
with _stubGetMandateAndRbac(iface, row):
updated = iface.updateMandate("m1", {"name": "new-slug"})
assert updated.name == "new-slug"
def test_sysAdminCanChangeName(self):
row = _row(mid="m1", name="old-slug", label="Old")
db = _FakeDb([row])
iface = _buildInterface(db, isPlatformAdmin=False, isSysAdmin=True)
with _stubGetMandateAndRbac(iface, row):
updated = iface.updateMandate("m1", {"name": "syscall-slug"})
assert updated.name == "syscall-slug"
class TestUpdateMandateNameValidation:
def test_invalidNameRejected(self):
row = _row()
db = _FakeDb([row])
iface = _buildInterface(db, isPlatformAdmin=True)
with _stubGetMandateAndRbac(iface, row):
with pytest.raises(ValueError) as excInfo:
iface.updateMandate("m1", {"name": "ABC Müller!"})
assert "Kurzzeichen" in str(excInfo.value) or "Failed to update" in str(excInfo.value)
def test_uppercaseNameRejected(self):
row = _row()
db = _FakeDb([row])
iface = _buildInterface(db, isPlatformAdmin=True)
with _stubGetMandateAndRbac(iface, row):
with pytest.raises(ValueError):
iface.updateMandate("m1", {"name": "ALPHA"})
def test_leadingHyphenRejected(self):
row = _row()
db = _FakeDb([row])
iface = _buildInterface(db, isPlatformAdmin=True)
with _stubGetMandateAndRbac(iface, row):
with pytest.raises(ValueError):
iface.updateMandate("m1", {"name": "-leading"})
def test_idempotentSameNameAccepted(self):
row = _row(mid="m1", name="alpha", label="Alpha")
db = _FakeDb([row, _row(mid="m2", name="beta", label="Beta")])
iface = _buildInterface(db, isPlatformAdmin=True)
with _stubGetMandateAndRbac(iface, row):
updated = iface.updateMandate("m1", {"name": "alpha"})
assert updated.name == "alpha"
def test_collisionWithOtherMandateRejected(self):
rows = [
_row(mid="m1", name="alpha", label="Alpha"),
_row(mid="m2", name="beta", label="Beta"),
]
db = _FakeDb(rows)
iface = _buildInterface(db, isPlatformAdmin=True)
with _stubGetMandateAndRbac(iface, rows[0]):
with pytest.raises(ValueError) as excInfo:
iface.updateMandate("m1", {"name": "beta"})
assert "already in use" in str(excInfo.value)
class TestUpdateMandateLabelValidation:
def test_emptyLabelRejected(self):
row = _row()
db = _FakeDb([row])
iface = _buildInterface(db, isPlatformAdmin=True)
with _stubGetMandateAndRbac(iface, row):
with pytest.raises(ValueError) as excInfo:
iface.updateMandate("m1", {"label": " "})
assert "label" in str(excInfo.value).lower()
def test_labelTrimmed(self):
row = _row()
db = _FakeDb([row])
iface = _buildInterface(db, isPlatformAdmin=True)
with _stubGetMandateAndRbac(iface, row):
updated = iface.updateMandate("m1", {"label": " Trimmed Name "})
assert updated.label == "Trimmed Name"
class TestUpdateMandateProtectedFields:
def test_idCannotBeChanged(self):
row = _row(mid="m1", name="alpha", label="Alpha")
db = _FakeDb([row])
iface = _buildInterface(db, isPlatformAdmin=True)
with _stubGetMandateAndRbac(iface, row):
updated = iface.updateMandate("m1", {"id": "spoofed", "label": "New"})
assert str(updated.id) == "m1", "id field must remain immutable"
def test_isSystemRequiresSysAdmin(self):
row = _row(mid="m1", name="alpha", label="Alpha", isSystem=False)
db = _FakeDb([row])
iface = _buildInterface(db, isPlatformAdmin=True, isSysAdmin=False)
with _stubGetMandateAndRbac(iface, row):
updated = iface.updateMandate("m1", {"isSystem": True, "label": "New"})
assert updated.isSystem is False, "PlatformAdmin alone must NOT escalate isSystem"

View file

@ -0,0 +1,290 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Integration tests for the SysAdmin / PlatformAdmin authority split.
Covers acceptance criteria from
``wiki/c-work/4-done/2026-04-sysadmin-authority-split.md``:
- AC#1 -> User with isSysAdmin only is rejected by ``requirePlatformAdmin``
- AC#2 -> User with isPlatformAdmin only is rejected by ``requireSysAdmin``
- AC#3 -> User with isPlatformAdmin is accepted by ``requirePlatformAdmin``
- AC#5 -> Live-flag check: revoking ``isPlatformAdmin`` immediately blocks
the next request (no token cache).
- AC#6 -> Live-flag check: revoking ``isSysAdmin`` immediately blocks
the next infrastructure request.
- AC#8 -> Self-protection: a user can never change their own admin flags
via ``update_user`` business logic.
Strategy: build a tiny FastAPI app that exposes one route per dependency
and override ``getCurrentUser`` per request. This isolates the gating
logic from database/JWT plumbing and runs without external services.
"""
from __future__ import annotations
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from modules.auth.authentication import (
getCurrentUser,
requirePlatformAdmin,
requireSysAdmin,
)
from modules.datamodels.datamodelUam import User
def _makeUser(
*,
userId: str = "test-user",
isSysAdmin: bool = False,
isPlatformAdmin: bool = False,
) -> User:
"""Build a minimal in-memory User instance for dependency overrides."""
return User(
id=userId,
username=f"user-{userId}",
email=f"{userId}@example.com",
fullName=f"Test {userId}",
enabled=True,
language="de",
isSysAdmin=isSysAdmin,
isPlatformAdmin=isPlatformAdmin,
)
@pytest.fixture
def appWithDeps() -> tuple[FastAPI, dict]:
"""FastAPI app with one route per authority dependency.
The returned dict allows tests to swap the "current user" between
requests by mutating ``state['user']``.
"""
state: dict = {"user": _makeUser()}
app = FastAPI()
def _overrideCurrentUser() -> User:
return state["user"]
app.dependency_overrides[getCurrentUser] = _overrideCurrentUser
@app.get("/admin/mandates")
def _adminMandates(_: User = Depends(requirePlatformAdmin)) -> dict:
return {"ok": True, "guard": "platform"}
@app.get("/admin/logs")
def _adminLogs(_: User = Depends(requireSysAdmin)) -> dict:
return {"ok": True, "guard": "sysadmin"}
return app, state
# ---------------------------------------------------------------------------
# AC #1, #2, #3 — basic authority gating
# ---------------------------------------------------------------------------
def testSysAdminCannotAccessPlatformRoute(appWithDeps):
"""AC#1: isSysAdmin alone must NOT pass requirePlatformAdmin."""
app, state = appWithDeps
state["user"] = _makeUser(isSysAdmin=True, isPlatformAdmin=False)
with TestClient(app) as client:
response = client.get("/admin/mandates")
assert response.status_code == 403
assert "platform admin" in response.json()["detail"].lower()
def testPlatformAdminCannotAccessInfraRoute(appWithDeps):
"""AC#2: isPlatformAdmin alone must NOT pass requireSysAdmin."""
app, state = appWithDeps
state["user"] = _makeUser(isSysAdmin=False, isPlatformAdmin=True)
with TestClient(app) as client:
response = client.get("/admin/logs")
assert response.status_code == 403
assert "sysadmin" in response.json()["detail"].lower()
def testPlatformAdminCanAccessPlatformRoute(appWithDeps):
"""AC#3: isPlatformAdmin must pass requirePlatformAdmin."""
app, state = appWithDeps
state["user"] = _makeUser(isPlatformAdmin=True)
with TestClient(app) as client:
response = client.get("/admin/mandates")
assert response.status_code == 200
assert response.json() == {"ok": True, "guard": "platform"}
def testSysAdminCanAccessInfraRoute(appWithDeps):
"""Sanity counterpart to AC#3: isSysAdmin passes requireSysAdmin."""
app, state = appWithDeps
state["user"] = _makeUser(isSysAdmin=True)
with TestClient(app) as client:
response = client.get("/admin/logs")
assert response.status_code == 200
assert response.json() == {"ok": True, "guard": "sysadmin"}
def testNoFlagsIsForbiddenForBothGuards(appWithDeps):
"""Regular user (no flags) must be rejected by both guards."""
app, state = appWithDeps
state["user"] = _makeUser()
with TestClient(app) as client:
rPlatform = client.get("/admin/mandates")
rInfra = client.get("/admin/logs")
assert rPlatform.status_code == 403
assert rInfra.status_code == 403
# ---------------------------------------------------------------------------
# AC #5, #6 — live flag check (no client-side cache, next request re-evaluates)
# ---------------------------------------------------------------------------
def testRevokingPlatformAdminBlocksNextRequest(appWithDeps):
"""AC#5: After dropping isPlatformAdmin, the very next request gets 403."""
app, state = appWithDeps
state["user"] = _makeUser(isPlatformAdmin=True)
with TestClient(app) as client:
first = client.get("/admin/mandates")
assert first.status_code == 200
# Admin removes the flag (e.g. via /api/users/{id})
state["user"] = _makeUser(isPlatformAdmin=False)
second = client.get("/admin/mandates")
assert second.status_code == 403
def testRevokingSysAdminBlocksNextRequest(appWithDeps):
"""AC#6: After dropping isSysAdmin, the very next request gets 403."""
app, state = appWithDeps
state["user"] = _makeUser(isSysAdmin=True)
with TestClient(app) as client:
first = client.get("/admin/logs")
assert first.status_code == 200
state["user"] = _makeUser(isSysAdmin=False)
second = client.get("/admin/logs")
assert second.status_code == 403
# ---------------------------------------------------------------------------
# AC #8 — self-protection on update_user
# ---------------------------------------------------------------------------
def testSelfProtectionOnUpdateUserDisallowsAdminFlagChange():
"""AC#8: A platform admin updating themselves cannot change admin flags.
Mirrors the gating logic in ``routeDataUsers.update_user``:
callerIsPlatformAdmin = context.isPlatformAdmin
allowAdminFlagChange = callerIsPlatformAdmin and not isSelfUpdate
When ``isSelfUpdate`` is True the flag must always be ``False``,
regardless of the caller's authority.
"""
callerId = "user-1"
def _allowAdminFlagChange(callerIsPlatformAdmin: bool, isSelfUpdate: bool) -> bool:
return callerIsPlatformAdmin and not isSelfUpdate
# Self-update by a platform admin: still NOT allowed to flip own flags.
assert _allowAdminFlagChange(True, isSelfUpdate=(callerId == callerId)) is False
# Foreign-update by a platform admin: allowed.
assert _allowAdminFlagChange(True, isSelfUpdate=(callerId == "user-2")) is True
# Foreign-update by a non-platform admin: rejected.
assert _allowAdminFlagChange(False, isSelfUpdate=(callerId == "user-2")) is False
def testInterfaceUpdateUserProtectsAdminFlagsWhenForbidden():
"""``interfaceDbApp.AppObjects.updateUser`` must keep the existing
``isSysAdmin``/``isPlatformAdmin`` values when ``allowAdminFlagChange``
is False even if the request payload tries to escalate them.
This is the second line of defence behind ``update_user``'s
``isSelfUpdate`` check.
"""
from unittest.mock import Mock
from modules.interfaces.interfaceDbApp import AppObjects
existing = User(
id="victim",
username="victim",
email="victim@example.com",
fullName="Victim",
enabled=True,
language="de",
isSysAdmin=False,
isPlatformAdmin=False,
)
# Attacker payload tries to escalate both flags.
attackerPayload = User(
id="victim",
username="victim",
email="victim@example.com",
fullName="Victim",
enabled=True,
language="de",
isSysAdmin=True,
isPlatformAdmin=True,
)
captured: dict = {}
def _captureUpdate(_model, _recordId, payload):
# Whether dict or User: extract flag values for assertion.
if hasattr(payload, "model_dump"):
data = payload.model_dump()
elif isinstance(payload, dict):
data = payload
else:
data = {"isSysAdmin": getattr(payload, "isSysAdmin", None),
"isPlatformAdmin": getattr(payload, "isPlatformAdmin", None)}
captured["isSysAdmin"] = data.get("isSysAdmin")
captured["isPlatformAdmin"] = data.get("isPlatformAdmin")
merged = {**existing.model_dump(), **{k: v for k, v in data.items() if v is not None}}
return merged
fakeDb = Mock()
fakeDb.recordModify = Mock(side_effect=_captureUpdate)
# Build the interface without going through __init__ (avoids real DB).
interface = AppObjects.__new__(AppObjects)
interface.currentUser = existing
interface.userId = existing.id
interface.mandateId = None
interface.featureInstanceId = None
interface.db = fakeDb
interface.rbac = Mock(checkRbacPermission=Mock(return_value=True))
interface.getUser = Mock(return_value=existing)
interface.updateUser("victim", attackerPayload, allowAdminFlagChange=False)
assert captured.get("isSysAdmin") is False, (
"isSysAdmin must remain False when allowAdminFlagChange=False, "
f"got {captured.get('isSysAdmin')!r}"
)
assert captured.get("isPlatformAdmin") is False, (
"isPlatformAdmin must remain False when allowAdminFlagChange=False, "
f"got {captured.get('isPlatformAdmin')!r}"
)

View file

View file

@ -0,0 +1,221 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""
Integration tests for ``AppObjects.updateUser`` partial-update semantics.
Regression for the silent flag-flip bug (``isSysAdmin`` <-> ``isPlatformAdmin``)
on inline toggles in ``Admin > System > Mandanten/Benutzer``:
Symptom
-------
Toggling one privileged flag in the user table flipped the OTHER privileged
flag back to its Pydantic default (``False``).
Root cause
----------
The PUT ``/api/users/{id}`` route bound ``userData: User = Body(...)``. Pydantic
filled every field that the client did not explicitly send with model defaults
(``isSysAdmin=False``, ``isPlatformAdmin=False``). Combined with
``allowAdminFlagChange=True`` (PlatformAdmin updating another user), those
defaults were merged into the persisted record and silently overwrote the
"other" flag.
Fix
---
The route now accepts a plain ``Dict[str, Any]`` and ``AppObjects.updateUser``
treats the payload as a true partial patch only the keys present in the
request body are applied to the stored record. Pydantic ``User`` callers are
still supported via ``model_dump(exclude_unset=True)`` so legacy paths keep
working without re-introducing the default-fill regression.
These tests assert the partial-update contract end-to-end at the interface
level, which is the layer where the bug lived.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from unittest.mock import Mock, patch
import pytest
from modules.datamodels.datamodelUam import User, UserInDB
from modules.interfaces.interfaceDbApp import AppObjects
class _FakeDb:
"""Minimal connector covering the access patterns used by ``updateUser``."""
def __init__(self, rows: List[Dict[str, Any]]):
self.rows: List[Dict[str, Any]] = [dict(r) for r in rows]
self.modifyCalls: List[Dict[str, Any]] = []
def getRecordset(self, model, recordFilter: Optional[Dict[str, Any]] = None):
if model is not UserInDB and model is not User:
return []
if not recordFilter:
return [dict(r) for r in self.rows]
out = []
for r in self.rows:
if all(r.get(k) == v for k, v in recordFilter.items()):
out.append(dict(r))
return out
def recordModify(self, model, recordId: str, payload):
if hasattr(payload, "model_dump"):
data = payload.model_dump()
elif isinstance(payload, dict):
data = dict(payload)
else:
data = {}
self.modifyCalls.append({"id": str(recordId), "data": dict(data)})
for r in self.rows:
if str(r.get("id")) == str(recordId):
r.update(data)
return r
return None
def _buildInterface(db: _FakeDb) -> AppObjects:
iface = AppObjects.__new__(AppObjects)
iface.db = db
iface.currentUser = Mock(id="caller", isPlatformAdmin=True, isSysAdmin=False)
iface.userId = "caller"
iface.mandateId = None
iface.featureInstanceId = None
iface.rbac = Mock()
return iface
def _row(uid: str = "u1", **extra) -> Dict[str, Any]:
base: Dict[str, Any] = {
"id": uid,
"username": "alice",
"email": "alice@example.com",
"fullName": "Alice Example",
"language": "de",
"enabled": True,
"isSysAdmin": False,
"isPlatformAdmin": True,
"authenticationAuthority": "local",
"roleLabels": [],
}
base.update(extra)
return base
def _stubGetUser(iface: AppObjects):
"""Wire ``getUser`` to read from the FakeDb so post-update reads reflect changes."""
db = iface.db
def _readUser(userId: str):
for r in db.rows:
if str(r.get("id")) == str(userId):
return User(**r)
return None
iface.getUser = Mock(side_effect=_readUser)
class TestPartialUpdateProtectsSiblingFlag:
"""The headline regression: toggling one flag must not touch the other."""
def test_togglingIsSysAdminKeepsIsPlatformAdmin(self):
row = _row(isSysAdmin=False, isPlatformAdmin=True)
db = _FakeDb([row])
iface = _buildInterface(db)
_stubGetUser(iface)
updated = iface.updateUser(
"u1",
{"isSysAdmin": True}, # only the toggled cell
allowAdminFlagChange=True,
)
assert updated.isSysAdmin is True
assert updated.isPlatformAdmin is True, (
"Partial update must not silently drop isPlatformAdmin to its default"
)
def test_togglingIsPlatformAdminKeepsIsSysAdmin(self):
row = _row(isSysAdmin=True, isPlatformAdmin=False)
db = _FakeDb([row])
iface = _buildInterface(db)
_stubGetUser(iface)
updated = iface.updateUser(
"u1",
{"isPlatformAdmin": True},
allowAdminFlagChange=True,
)
assert updated.isPlatformAdmin is True
assert updated.isSysAdmin is True, (
"Partial update must not silently drop isSysAdmin to its default"
)
def test_togglingUnrelatedFieldKeepsBothFlags(self):
row = _row(isSysAdmin=True, isPlatformAdmin=True, language="de")
db = _FakeDb([row])
iface = _buildInterface(db)
_stubGetUser(iface)
updated = iface.updateUser(
"u1",
{"language": "en"},
allowAdminFlagChange=False,
)
assert updated.language == "en"
assert updated.isSysAdmin is True
assert updated.isPlatformAdmin is True
class TestPrivilegedFlagGuard:
"""Without ``allowAdminFlagChange`` the protected flags must be dropped."""
def test_protectedFlagsDroppedWhenChangeNotAllowed(self):
row = _row(isSysAdmin=False, isPlatformAdmin=True)
db = _FakeDb([row])
iface = _buildInterface(db)
_stubGetUser(iface)
updated = iface.updateUser(
"u1",
{"isSysAdmin": True, "isPlatformAdmin": False, "language": "fr"},
allowAdminFlagChange=False,
)
assert updated.language == "fr", "non-protected fields still apply"
assert updated.isSysAdmin is False, "protected flag must be ignored"
assert updated.isPlatformAdmin is True, "protected flag must be ignored"
def test_legacyPydanticUserDoesNotDefaultFlipFlags(self):
"""Defense in depth: if a caller still passes a ``User`` instance,
``model_dump(exclude_unset=True)`` must keep unset fields out of the
merge so they do not pull live values down to Pydantic defaults.
"""
row = _row(isSysAdmin=True, isPlatformAdmin=True, fullName="Alice Example")
db = _FakeDb([row])
iface = _buildInterface(db)
_stubGetUser(iface)
partialModel = User(
id="u1",
username="alice",
fullName="Alice Updated",
)
updated = iface.updateUser(
"u1",
partialModel,
allowAdminFlagChange=True,
)
assert updated.fullName == "Alice Updated"
assert updated.isSysAdmin is True, (
"Pydantic User without explicit isSysAdmin must not flip stored True to default False"
)
assert updated.isPlatformAdmin is True, (
"Pydantic User without explicit isPlatformAdmin must not flip stored True to default False"
)

View file

@ -149,7 +149,7 @@ try:
source = f.read()
_check("routeDataFiles has PATCH scope endpoint", "updateFileScope" in source)
_check("routeDataFiles has PATCH neutralize endpoint", "updateFileNeutralize" in source)
_check("routeDataFiles checks global sysAdmin", "hasSysAdminRole" in source or "sysadmin" in source.lower())
_check("routeDataFiles checks global sysAdmin", "isSysAdmin" in source)
except Exception as e:
errors.append(f"Phase 2 Routes: {e}")
print(f" [FAIL] Phase 2 Routes: {e}")

View file

View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Unit tests for ``_migrateMandateNameLabelSlugRules`` in interfaceBootstrap.
Covers:
- legacy ``name``/``label`` rows get fixed (label fill, slug rename),
- collisions across legacy rows resolve via -2/-3 suffixes in stable id order,
- valid rows are left untouched (idempotency),
- second invocation is a no-op.
"""
from typing import Any, Dict, List, Optional
import pytest
from modules.datamodels.datamodelUam import Mandate
from modules.interfaces.interfaceBootstrap import _migrateMandateNameLabelSlugRules
from modules.shared.mandateNameUtils import isValidMandateName
class _FakeDb:
"""Minimal connector simulating getRecordset(Mandate)+recordModify(Mandate, id, data)."""
def __init__(self, rows: List[Dict[str, Any]]):
self.rows: List[Dict[str, Any]] = [dict(r) for r in rows]
self.modifyCalls: List[Dict[str, Any]] = []
def getRecordset(self, model, recordFilter: Optional[Dict[str, Any]] = None):
if model is not Mandate:
return []
if not recordFilter:
return [dict(r) for r in self.rows]
out = []
for r in self.rows:
if all(r.get(k) == v for k, v in recordFilter.items()):
out.append(dict(r))
return out
def recordModify(self, model, recordId: str, data: Dict[str, Any]):
self.modifyCalls.append({"id": str(recordId), "data": dict(data)})
for r in self.rows:
if str(r.get("id")) == str(recordId):
r.update(data)
return r
return None
def _row(mid: str, name: Any, label: Any = None) -> Dict[str, Any]:
return {"id": mid, "name": name, "label": label}
class TestMigrationFillsLabel:
def test_emptyLabelGetsNameAsLabel(self):
db = _FakeDb([_row("a1", "good-name", None)])
_migrateMandateNameLabelSlugRules(db)
assert db.rows[0]["label"] == "good-name"
assert db.rows[0]["name"] == "good-name"
def test_emptyLabelAndEmptyNameFallsBackToMandate(self):
db = _FakeDb([_row("a1", "", "")])
_migrateMandateNameLabelSlugRules(db)
assert db.rows[0]["label"] == "Mandate"
assert isValidMandateName(db.rows[0]["name"])
class TestMigrationRenamesInvalidNames:
def test_invalidNameGetsSlugFromLabel(self):
db = _FakeDb([_row("a1", "Home patrick", "Home Patrick")])
_migrateMandateNameLabelSlugRules(db)
assert db.rows[0]["name"] == "home-patrick"
assert db.rows[0]["label"] == "Home Patrick"
def test_umlautsTransliterated(self):
db = _FakeDb([_row("a1", "Müller AG", "Müller AG")])
_migrateMandateNameLabelSlugRules(db)
assert db.rows[0]["name"] == "mueller-ag"
class TestMigrationCollisions:
def test_collisionsResolveByStableIdOrder(self):
rows = [
_row("z1", "Home patrick", "Home Patrick"),
_row("a1", "home-patrick", "Home Patrick Two"),
]
db = _FakeDb(rows)
_migrateMandateNameLabelSlugRules(db)
byId = {r["id"]: r for r in db.rows}
assert byId["a1"]["name"] == "home-patrick"
assert byId["z1"]["name"] == "home-patrick-2"
def test_threeWayCollisionGetsThirdSuffix(self):
rows = [
_row("id-aaa", "home-patrick", "Home Patrick"),
_row("id-bbb", "Home patrick", "Home Patrick"),
_row("id-ccc", "home patrick", "Home Patrick"),
]
db = _FakeDb(rows)
_migrateMandateNameLabelSlugRules(db)
names = sorted(r["name"] for r in db.rows)
assert names == ["home-patrick", "home-patrick-2", "home-patrick-3"]
class TestMigrationIdempotency:
def test_secondRunIsNoop(self):
rows = [
_row("a1", "home-patrick", "Home Patrick"),
_row("b1", "Home Müller", ""),
]
db = _FakeDb(rows)
_migrateMandateNameLabelSlugRules(db)
assert all(isValidMandateName(r["name"]) for r in db.rows)
firstChanges = list(db.modifyCalls)
db.modifyCalls.clear()
_migrateMandateNameLabelSlugRules(db)
assert db.modifyCalls == [], (
f"expected no further changes after first migration, got {db.modifyCalls}; "
f"firstRun changes: {firstChanges}"
)
def test_validRowsLeftUntouched(self):
rows = [_row("a1", "root", "Root"), _row("b1", "alpina-treuhand", "Alpina Treuhand AG")]
db = _FakeDb(rows)
_migrateMandateNameLabelSlugRules(db)
assert db.modifyCalls == []
class TestMigrationEmpty:
def test_emptyDbDoesNothing(self):
db = _FakeDb([])
_migrateMandateNameLabelSlugRules(db)
assert db.modifyCalls == []

View file

@ -0,0 +1,209 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Unit tests for the one-shot sysadmin role -> isPlatformAdmin migration.
Covers acceptance criteria from
``wiki/c-work/4-done/2026-04-sysadmin-authority-split.md``:
- AC#4 -> Existing sysadmin role-holders are promoted to ``isPlatformAdmin=True``
and the legacy role is removed (Role + UserMandateRole + AccessRules)
when the gateway boots.
- AC#10 -> The migration is idempotent and removes ALL artefacts (Role,
AccessRules, UserMandateRole) of the legacy ``sysadmin`` role.
Strategy: use an in-memory fake ``DatabaseConnector`` that records calls
and returns deterministic recordsets for ``Role``/``UserMandateRole``/
``UserMandate``/``UserInDB``/``AccessRule`` lookups.
"""
from __future__ import annotations
from typing import Any, Dict, List
from unittest.mock import Mock
from modules.interfaces.interfaceBootstrap import _migrateAndDropSysAdminRole
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
from modules.datamodels.datamodelRbac import AccessRule, Role
from modules.datamodels.datamodelUam import UserInDB
_ROOT_MANDATE_ID = "root-mandate-id"
_SYSADMIN_ROLE_ID = "sysadmin-role-id"
_USER_MANDATE_ID = "user-mandate-id"
_USER_ID = "legacy-user-id"
_UMR_ROW_ID = "umr-row-id"
_ACCESS_RULE_ID = "access-rule-id"
def _buildFakeDb(
*,
sysadminRoles: List[Dict[str, Any]],
umRoleRows: List[Dict[str, Any]],
userMandateRows: List[Dict[str, Any]],
users: List[Dict[str, Any]],
accessRules: List[Dict[str, Any]],
) -> Mock:
"""Build a fake ``DatabaseConnector`` that maps model -> recordset."""
deletes: List[tuple] = []
modifies: List[tuple] = []
def _getRecordset(model, recordFilter=None, **_): # noqa: ANN001
recordFilter = recordFilter or {}
if model is Role:
label = recordFilter.get("roleLabel")
mandateId = recordFilter.get("mandateId")
if label == "sysadmin" and mandateId == _ROOT_MANDATE_ID:
return list(sysadminRoles)
return []
if model is UserMandateRole:
wanted = recordFilter.get("roleId")
return [r for r in umRoleRows if r.get("roleId") == wanted]
if model is UserMandate:
wanted = recordFilter.get("id")
return [r for r in userMandateRows if r.get("id") == wanted]
if model is UserInDB:
wanted = recordFilter.get("id")
return [r for r in users if r.get("id") == wanted]
if model is AccessRule:
wanted = recordFilter.get("roleId")
return [r for r in accessRules if r.get("roleId") == wanted]
return []
def _recordModify(model, recordId, payload): # noqa: ANN001
modifies.append((model, recordId, payload))
# Reflect the change so a subsequent migration call is idempotent.
if model is UserInDB:
for u in users:
if u.get("id") == recordId:
u.update(payload)
return True
def _recordDelete(model, recordId): # noqa: ANN001
deletes.append((model, recordId))
if model is UserMandateRole:
umRoleRows[:] = [r for r in umRoleRows if r.get("id") != recordId]
elif model is AccessRule:
accessRules[:] = [r for r in accessRules if r.get("id") != recordId]
elif model is Role:
sysadminRoles[:] = [r for r in sysadminRoles if r.get("id") != recordId]
return True
db = Mock()
db.getRecordset = Mock(side_effect=_getRecordset)
db.recordModify = Mock(side_effect=_recordModify)
db.recordDelete = Mock(side_effect=_recordDelete)
db._modifies = modifies # exposed for assertions
db._deletes = deletes
return db
def _seed():
return {
"sysadminRoles": [{"id": _SYSADMIN_ROLE_ID, "roleLabel": "sysadmin",
"mandateId": _ROOT_MANDATE_ID}],
"umRoleRows": [{"id": _UMR_ROW_ID, "roleId": _SYSADMIN_ROLE_ID,
"userMandateId": _USER_MANDATE_ID}],
"userMandateRows": [{"id": _USER_MANDATE_ID, "userId": _USER_ID,
"mandateId": _ROOT_MANDATE_ID}],
"users": [{"id": _USER_ID, "username": "legacy",
"isSysAdmin": False, "isPlatformAdmin": False}],
"accessRules": [{"id": _ACCESS_RULE_ID, "roleId": _SYSADMIN_ROLE_ID}],
}
# ---------------------------------------------------------------------------
# AC #4 — promote + drop on first run
# ---------------------------------------------------------------------------
def testMigrationPromotesUserAndDropsArtefacts():
"""AC#4: legacy holder is promoted; Role+AccessRule+UMR are deleted."""
seed = _seed()
db = _buildFakeDb(**seed)
_migrateAndDropSysAdminRole(db, _ROOT_MANDATE_ID)
# User got isPlatformAdmin=True
assert seed["users"][0]["isPlatformAdmin"] is True
assert any(
m[0] is UserInDB and m[2] == {"isPlatformAdmin": True}
for m in db._modifies
), "Expected UserInDB.isPlatformAdmin promotion call"
# All three artefact tables had their rows deleted.
deletedModels = {m[0] for m in db._deletes}
assert UserMandateRole in deletedModels, "UserMandateRole row not deleted"
assert AccessRule in deletedModels, "AccessRule row not deleted"
assert Role in deletedModels, "Sysadmin Role record not deleted"
# And the seeded lists are empty after the migration.
assert seed["umRoleRows"] == []
assert seed["accessRules"] == []
assert seed["sysadminRoles"] == []
# ---------------------------------------------------------------------------
# AC #10 — idempotent: a second run is a no-op
# ---------------------------------------------------------------------------
def testMigrationIsIdempotent():
"""AC#10: a second invocation finds no sysadmin role and exits silently."""
seed = _seed()
db = _buildFakeDb(**seed)
_migrateAndDropSysAdminRole(db, _ROOT_MANDATE_ID)
firstModifies = list(db._modifies)
firstDeletes = list(db._deletes)
_migrateAndDropSysAdminRole(db, _ROOT_MANDATE_ID)
# No additional writes on the second call.
assert db._modifies == firstModifies, (
"Second migration call must not perform additional writes"
)
assert db._deletes == firstDeletes, (
"Second migration call must not perform additional deletes"
)
def testMigrationSkipsAlreadyPromotedUsers():
"""If a user already has ``isPlatformAdmin=True``, no redundant write."""
seed = _seed()
seed["users"][0]["isPlatformAdmin"] = True # already promoted
db = _buildFakeDb(**seed)
_migrateAndDropSysAdminRole(db, _ROOT_MANDATE_ID)
# No promotion write for an already-promoted user.
promotionWrites = [
m for m in db._modifies
if m[0] is UserInDB and m[2].get("isPlatformAdmin") is True
]
assert promotionWrites == [], (
"Should not re-write isPlatformAdmin if user already has it"
)
# But role + access-rule cleanup still happens.
deletedModels = {m[0] for m in db._deletes}
assert Role in deletedModels
assert AccessRule in deletedModels
assert UserMandateRole in deletedModels
def testMigrationOnEmptyDbIsNoop():
"""No legacy sysadmin role at all -> no calls, no errors."""
db = _buildFakeDb(
sysadminRoles=[],
umRoleRows=[],
userMandateRows=[],
users=[],
accessRules=[],
)
_migrateAndDropSysAdminRole(db, _ROOT_MANDATE_ID)
assert db._modifies == []
assert db._deletes == []

View file

@ -0,0 +1,56 @@
#!/usr/bin/env python3
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Unit tests for mandateNameUtils (slug, validation, unique allocation)."""
import pytest
from modules.shared.mandateNameUtils import (
allocateUniqueMandateSlug,
isValidMandateName,
slugifyMandateName,
transliterateGerman,
)
class TestTransliterateGerman:
def test_transliterateGerman_umlauts(self):
assert transliterateGerman("Müller") == "Mueller"
assert transliterateGerman("Größe") == "Groesse"
assert transliterateGerman("Fußball") == "Fussball"
class TestIsValidMandateName:
def test_isValidMandateName_ok(self):
assert isValidMandateName("ab") is True
assert isValidMandateName("a-b") is True
assert isValidMandateName("root") is True
assert isValidMandateName("mn-2") is True
def test_isValidMandateName_rejects(self):
assert isValidMandateName("a") is False
assert isValidMandateName("") is False
assert isValidMandateName("Home patrick") is False
assert isValidMandateName("UPPER") is False
assert isValidMandateName("a--b") is False
assert isValidMandateName("-ab") is False
assert isValidMandateName("ab-") is False
class TestSlugifyMandateName:
def test_slugifyMandateName_basic(self):
assert slugifyMandateName("Müller AG") == "mueller-ag"
assert slugifyMandateName(" Foo Bar ") == "foo-bar"
def test_slugifyMandateName_empty(self):
assert slugifyMandateName("") == "mn"
assert slugifyMandateName(" ") == "mn"
class TestAllocateUniqueMandateSlug:
def test_allocateUniqueMandateSlug_first_free(self):
assert allocateUniqueMandateSlug("mueller-ag", ["other"]) == "mueller-ag"
def test_allocateUniqueMandateSlug_collision(self):
assert allocateUniqueMandateSlug("mueller-ag", ["mueller-ag"]) == "mueller-ag-2"
assert allocateUniqueMandateSlug("mueller-ag", ["mueller-ag", "mueller-ag-2"]) == "mueller-ag-3"