From 829711f7551b10ab7c95bc0b9cbf994f52655908 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Mon, 26 Jan 2026 12:39:00 +0100
Subject: [PATCH] fixed system and dynamic data rbac
---
modules/datamodels/datamodelChat.py | 41 +-
.../interfaceFeatureNeutralizer.py | 84 +++-
.../mainServiceNeutralization.py | 7 +-
modules/interfaces/interfaceBootstrap.py | 387 ++++++++++++++----
modules/interfaces/interfaceDbApp.py | 8 +-
modules/interfaces/interfaceDbChat.py | 80 ++--
modules/interfaces/interfaceRbac.py | 101 ++++-
modules/routes/routeAdminRbacRules.py | 29 +-
modules/routes/routeDataConnections.py | 9 +-
modules/routes/routeNotifications.py | 12 +
modules/security/rbac.py | 64 +--
modules/system/mainSystem.py | 72 ++--
.../actions/getExpensesFromPdf.py | 46 ++-
tests/unit/rbac/test_rbac_bootstrap.py | 10 +-
tests/unit/rbac/test_rbac_permissions.py | 26 +-
15 files changed, 695 insertions(+), 281 deletions(-)
diff --git a/modules/datamodels/datamodelChat.py b/modules/datamodels/datamodelChat.py
index 328bee22..3d71bf63 100644
--- a/modules/datamodels/datamodelChat.py
+++ b/modules/datamodels/datamodelChat.py
@@ -11,15 +11,10 @@ import uuid
class ChatStat(BaseModel):
+ """Statistics for chat operations. User-owned, no mandate context."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
)
- mandateId: Optional[str] = Field(
- default="", description="ID of the mandate this stat belongs to"
- )
- featureInstanceId: Optional[str] = Field(
- default="", description="ID of the feature instance this stat belongs to"
- )
workflowId: Optional[str] = Field(
None, description="Foreign key to workflow (for workflow stats)"
)
@@ -39,8 +34,6 @@ registerModelLabels(
{"en": "Chat Statistics", "fr": "Statistiques de chat"},
{
"id": {"en": "ID", "fr": "ID"},
- "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
- "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"workflowId": {"en": "Workflow ID", "fr": "ID du workflow"},
"processingTime": {"en": "Processing Time", "fr": "Temps de traitement"},
"bytesSent": {"en": "Bytes Sent", "fr": "Octets envoyés"},
@@ -54,15 +47,10 @@ registerModelLabels(
class ChatLog(BaseModel):
+ """Log entries for chat workflows. User-owned, no mandate context."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
)
- mandateId: Optional[str] = Field(
- default="", description="ID of the mandate this log belongs to"
- )
- featureInstanceId: Optional[str] = Field(
- default="", description="ID of the feature instance this log belongs to"
- )
workflowId: str = Field(description="Foreign key to workflow")
message: str = Field(description="Log message")
type: str = Field(description="Log type (info, warning, error, etc.)")
@@ -93,8 +81,6 @@ registerModelLabels(
{"en": "Chat Log", "fr": "Journal de chat"},
{
"id": {"en": "ID", "fr": "ID"},
- "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
- "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"workflowId": {"en": "Workflow ID", "fr": "ID du flux de travail"},
"message": {"en": "Message", "fr": "Message"},
"type": {"en": "Type", "fr": "Type"},
@@ -107,15 +93,10 @@ registerModelLabels(
class ChatDocument(BaseModel):
+ """Documents attached to chat messages. User-owned, no mandate context."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
)
- mandateId: Optional[str] = Field(
- default="", description="ID of the mandate this document belongs to"
- )
- featureInstanceId: Optional[str] = Field(
- default="", description="ID of the feature instance this document belongs to"
- )
messageId: str = Field(description="Foreign key to message")
fileId: str = Field(description="Foreign key to file")
fileName: str = Field(description="Name of the file")
@@ -134,8 +115,6 @@ registerModelLabels(
{"en": "Chat Document", "fr": "Document de chat"},
{
"id": {"en": "ID", "fr": "ID"},
- "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
- "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"messageId": {"en": "Message ID", "fr": "ID du message"},
"fileId": {"en": "File ID", "fr": "ID du fichier"},
"fileName": {"en": "File Name", "fr": "Nom du fichier"},
@@ -221,15 +200,10 @@ registerModelLabels(
class ChatMessage(BaseModel):
+ """Messages in chat workflows. User-owned, no mandate context."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
)
- mandateId: Optional[str] = Field(
- default="", description="ID of the mandate this message belongs to"
- )
- featureInstanceId: Optional[str] = Field(
- default="", description="ID of the feature instance this message belongs to"
- )
workflowId: str = Field(description="Foreign key to workflow")
parentMessageId: Optional[str] = Field(
None, description="Parent message ID for threading"
@@ -281,8 +255,6 @@ registerModelLabels(
{"en": "Chat Message", "fr": "Message de chat"},
{
"id": {"en": "ID", "fr": "ID"},
- "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
- "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"workflowId": {"en": "Workflow ID", "fr": "ID du flux de travail"},
"parentMessageId": {"en": "Parent Message ID", "fr": "ID du message parent"},
"documents": {"en": "Documents", "fr": "Documents"},
@@ -326,9 +298,8 @@ registerModelLabels(
class ChatWorkflow(BaseModel):
+ """Chat workflow container. User-owned, no mandate context."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
- mandateId: Optional[str] = Field(default="", description="ID of the mandate this workflow belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
- featureInstanceId: Optional[str] = Field(default="", description="ID of the feature instance this workflow belongs to", json_schema_extra={"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": {"en": "Running", "fr": "En cours"}},
{"value": "completed", "label": {"en": "Completed", "fr": "Terminé"}},
@@ -402,8 +373,6 @@ registerModelLabels(
{"en": "Chat Workflow", "fr": "Flux de travail de chat"},
{
"id": {"en": "ID", "fr": "ID"},
- "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
- "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"status": {"en": "Status", "fr": "Statut"},
"name": {"en": "Name", "fr": "Nom"},
"currentRound": {"en": "Current Round", "fr": "Tour actuel"},
diff --git a/modules/features/neutralization/interfaceFeatureNeutralizer.py b/modules/features/neutralization/interfaceFeatureNeutralizer.py
index 970f51ff..54533166 100644
--- a/modules/features/neutralization/interfaceFeatureNeutralizer.py
+++ b/modules/features/neutralization/interfaceFeatureNeutralizer.py
@@ -12,29 +12,76 @@ from modules.features.neutralization.datamodelFeatureNeutralizer import (
DataNeutraliserConfig,
DataNeutralizerAttributes,
)
+from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
+from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp
+from modules.datamodels.datamodelUam import User
logger = logging.getLogger(__name__)
+# Singleton cache for interface instances
+_neutralizerInterfaces = {}
+
class InterfaceFeatureNeutralizer:
"""Database interface for Neutralizer feature operations"""
- def __init__(self, db, currentUser, mandateId: str, userId: str):
+ # Feature code for RBAC objectKey construction
+ FEATURE_CODE = "neutralization"
+
+ def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""
Initialize the interface with database connection and user context.
Args:
- db: Database connection instance
currentUser: Current user object for RBAC
mandateId: Current mandate ID
- userId: Current user ID
+ featureInstanceId: Current feature instance ID
"""
- self.db = db
self.currentUser = currentUser
self.mandateId = mandateId
- self.userId = userId
+ self.featureInstanceId = featureInstanceId
+ self.userId = currentUser.id if currentUser else None
+ self.db = None
+
+ # Initialize database
+ self._initializeDatabase()
+
+ def _initializeDatabase(self):
+ """Initialize the database connection."""
+ try:
+ # Use same database config pattern as other feature interfaces
+ dbHost = APP_CONFIG.get("DB_HOST", "localhost")
+ dbDatabase = APP_CONFIG.get("DB_DATABASE_NEUTRALIZATION", APP_CONFIG.get("DB_DATABASE", "poweron"))
+ dbUser = APP_CONFIG.get("DB_USER", "postgres")
+ dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
+ dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
+
+ self.db = DatabaseConnector(
+ dbHost=dbHost,
+ dbDatabase=dbDatabase,
+ dbUser=dbUser,
+ dbPassword=dbPassword,
+ dbPort=dbPort,
+ userId=self.userId,
+ )
+ self.db.initDbSystem()
+ logger.debug("Neutralizer database initialized successfully")
+ except Exception as e:
+ logger.error(f"Error initializing Neutralizer database: {str(e)}")
+ raise
+
+ def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
+ """Sets the user context for the interface."""
+ if not currentUser:
+ logger.info("Initializing interface without user context")
+ return
+
+ self.currentUser = currentUser
+ self.userId = currentUser.id
+ self.mandateId = mandateId
+ self.featureInstanceId = featureInstanceId
def getNeutralizationConfig(self) -> Optional[DataNeutraliserConfig]:
"""Get the data neutralization configuration for the current user's mandate"""
@@ -160,17 +207,34 @@ class InterfaceFeatureNeutralizer:
return None
-def getInterface(db, currentUser, mandateId: str, userId: str) -> InterfaceFeatureNeutralizer:
+def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> InterfaceFeatureNeutralizer:
"""
- Factory function to create a Neutralizer interface instance.
+ Factory function to get or create a Neutralizer interface instance.
+ Uses singleton pattern per user context.
Args:
- db: Database connection
currentUser: Current user for RBAC
mandateId: Current mandate ID
- userId: Current user ID
+ featureInstanceId: Current feature instance ID
Returns:
InterfaceFeatureNeutralizer instance
"""
- return InterfaceFeatureNeutralizer(db, currentUser, mandateId, userId)
+ global _neutralizerInterfaces
+
+ if not currentUser:
+ raise ValueError("Valid user context required")
+
+ effectiveMandateId = str(mandateId) if mandateId else None
+ effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
+
+ # Include featureInstanceId in cache key for proper isolation
+ cacheKey = f"{currentUser.id}_{effectiveMandateId}_{effectiveFeatureInstanceId}"
+
+ if cacheKey not in _neutralizerInterfaces:
+ _neutralizerInterfaces[cacheKey] = InterfaceFeatureNeutralizer(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
+ else:
+ # Update user context if needed
+ _neutralizerInterfaces[cacheKey].setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
+
+ return _neutralizerInterfaces[cacheKey]
diff --git a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py
index 4351f400..b4b34cf7 100644
--- a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py
+++ b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py
@@ -14,7 +14,7 @@ import json
from typing import Dict, List, Any, Optional
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes
-from modules.features.neutralization.interfaceFeatureNeutralizer import InterfaceFeatureNeutralizer
+from modules.features.neutralization.interfaceFeatureNeutralizer import InterfaceFeatureNeutralizer, getInterface as getNeutralizerInterface
# Import all necessary classes and functions for neutralization
from .subProcessCommon import CommonUtils, NeutralizationResult, NeutralizationAttribute
@@ -42,11 +42,10 @@ class NeutralizationService:
self.interfaceNeutralizer: InterfaceFeatureNeutralizer = None
if serviceCenter and serviceCenter.interfaceDbApp:
dbApp = serviceCenter.interfaceDbApp
- self.interfaceNeutralizer = InterfaceFeatureNeutralizer(
- db=dbApp.db,
+ self.interfaceNeutralizer = getNeutralizerInterface(
currentUser=dbApp.currentUser,
mandateId=dbApp.mandateId,
- userId=dbApp.userId
+ featureInstanceId=getattr(dbApp, 'featureInstanceId', None)
)
# Initialize anonymization processors
diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py
index 0e66ce7b..c73c4dd1 100644
--- a/modules/interfaces/interfaceBootstrap.py
+++ b/modules/interfaces/interfaceBootstrap.py
@@ -277,6 +277,9 @@ def initRbacRules(db: DatabaseConnector) -> None:
existingRules = db.getRecordset(AccessRule)
if existingRules:
logger.info(f"RBAC rules already exist ({len(existingRules)} rules)")
+ # Still ensure UI and DATA rules exist (may have been added later)
+ _ensureUiContextRules(db)
+ _ensureDataContextRules(db)
return
logger.info("Initializing RBAC rules")
@@ -377,20 +380,31 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
viewerId = _getRoleId(db, "viewer")
# ==========================================================================
- # SYSTEM TABLE RULES - Using standardized dot format: data.system.{TableName}
+ # DATA TABLE RULES - Using semantic namespace structure
# ==========================================================================
- # All DATA context items MUST use the full objectKey format for consistency.
- # This matches the DATA_OBJECTS registration in mainSystem.py.
- # Feature tables use: data.feature.{featureCode}.{TableName}
+ # Namespace structure:
+ # - data.uam.* → User Access Management (mandantenübergreifend)
+ # - data.chat.* → Chat/AI-Daten (benutzer-eigen, kein Mandantenkontext)
+ # - data.files.* → Dateien (benutzer-eigen)
+ # - data.automation.* → Automation (benutzer-eigen)
+ # - data.feature.* → Mandanten-/Feature-spezifische Daten (dynamisch)
+ #
+ # GROUP-Berechtigung:
+ # - data.uam.*: GROUP filtert nach Mandant (via UserMandate)
+ # - data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen)
# ==========================================================================
+ # -------------------------------------------------------------------------
+ # UAM Namespace - User Access Management
+ # -------------------------------------------------------------------------
+
# Mandate table - Only SysAdmin (flag) can access, not roles
# Regular roles have no access to Mandate table
if adminId:
tableRules.append(AccessRule(
roleId=adminId,
context=AccessRuleContext.DATA,
- item="data.system.Mandate",
+ item="data.uam.Mandate",
view=False,
read=AccessLevel.NONE,
create=AccessLevel.NONE,
@@ -401,7 +415,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule(
roleId=userId,
context=AccessRuleContext.DATA,
- item="data.system.Mandate",
+ item="data.uam.Mandate",
view=False,
read=AccessLevel.NONE,
create=AccessLevel.NONE,
@@ -412,7 +426,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule(
roleId=viewerId,
context=AccessRuleContext.DATA,
- item="data.system.Mandate",
+ item="data.uam.Mandate",
view=False,
read=AccessLevel.NONE,
create=AccessLevel.NONE,
@@ -425,7 +439,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule(
roleId=adminId,
context=AccessRuleContext.DATA,
- item="data.system.UserInDB",
+ item="data.uam.UserInDB",
view=True,
read=AccessLevel.GROUP,
create=AccessLevel.GROUP,
@@ -436,7 +450,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule(
roleId=userId,
context=AccessRuleContext.DATA,
- item="data.system.UserInDB",
+ item="data.uam.UserInDB",
view=True,
read=AccessLevel.MY,
create=AccessLevel.NONE,
@@ -447,7 +461,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule(
roleId=viewerId,
context=AccessRuleContext.DATA,
- item="data.system.UserInDB",
+ item="data.uam.UserInDB",
view=True,
read=AccessLevel.MY,
create=AccessLevel.NONE,
@@ -455,92 +469,37 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
delete=AccessLevel.NONE,
))
- # FileItem and UserConnection: All users (user, admin, viewer) only MY-level CRUD
- restrictedTables = [
- "data.system.UserConnection", # User connections/sessions - only own records
- "data.system.FileItem", # Uploaded files - only own files
- ]
-
- for objectKey in restrictedTables:
- # Admin: Only MY-level access (not group-level!)
- if adminId:
+ # UserConnection: All users only MY-level CRUD (UAM namespace)
+ for roleId in [adminId, userId]:
+ if roleId:
tableRules.append(AccessRule(
- roleId=adminId,
+ roleId=roleId,
context=AccessRuleContext.DATA,
- item=objectKey,
+ item="data.uam.UserConnection",
view=True,
read=AccessLevel.MY,
create=AccessLevel.MY,
update=AccessLevel.MY,
delete=AccessLevel.MY,
))
- # User: MY-level CRUD
- if userId:
- tableRules.append(AccessRule(
- roleId=userId,
- context=AccessRuleContext.DATA,
- item=objectKey,
- view=True,
- read=AccessLevel.MY,
- create=AccessLevel.MY,
- update=AccessLevel.MY,
- delete=AccessLevel.MY,
- ))
- # Viewer: MY-level read-only
- if viewerId:
- tableRules.append(AccessRule(
- roleId=viewerId,
- context=AccessRuleContext.DATA,
- item=objectKey,
- view=True,
- read=AccessLevel.MY,
- create=AccessLevel.NONE,
- update=AccessLevel.NONE,
- delete=AccessLevel.NONE,
- ))
-
- # Prompt: Special rule - CRUD for MY + Read for GROUP
- # Each user can manage own prompts (m) but can read group prompts (g)
- if adminId:
- tableRules.append(AccessRule(
- roleId=adminId,
- context=AccessRuleContext.DATA,
- item="data.system.Prompt",
- view=True,
- read=AccessLevel.GROUP,
- create=AccessLevel.MY,
- update=AccessLevel.MY,
- delete=AccessLevel.MY,
- ))
- if userId:
- tableRules.append(AccessRule(
- roleId=userId,
- context=AccessRuleContext.DATA,
- item="data.system.Prompt",
- view=True,
- read=AccessLevel.GROUP,
- create=AccessLevel.MY,
- update=AccessLevel.MY,
- delete=AccessLevel.MY,
- ))
if viewerId:
tableRules.append(AccessRule(
roleId=viewerId,
context=AccessRuleContext.DATA,
- item="data.system.Prompt",
+ item="data.uam.UserConnection",
view=True,
- read=AccessLevel.GROUP,
+ read=AccessLevel.MY,
create=AccessLevel.NONE,
update=AccessLevel.NONE,
delete=AccessLevel.NONE,
))
- # Invitation: Standard group-level access
+ # Invitation: Standard group-level access (UAM namespace)
if adminId:
tableRules.append(AccessRule(
roleId=adminId,
context=AccessRuleContext.DATA,
- item="data.system.Invitation",
+ item="data.uam.Invitation",
view=True,
read=AccessLevel.GROUP,
create=AccessLevel.GROUP,
@@ -551,7 +510,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule(
roleId=userId,
context=AccessRuleContext.DATA,
- item="data.system.Invitation",
+ item="data.uam.Invitation",
view=True,
read=AccessLevel.MY,
create=AccessLevel.MY,
@@ -562,7 +521,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule(
roleId=viewerId,
context=AccessRuleContext.DATA,
- item="data.system.Invitation",
+ item="data.uam.Invitation",
view=True,
read=AccessLevel.MY,
create=AccessLevel.NONE,
@@ -570,13 +529,12 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
delete=AccessLevel.NONE,
))
- # AuthEvent table - Audit logs (no delete allowed for audit integrity!)
- # SysAdmin can delete via isSysAdmin bypass, but regular admins cannot
+ # AuthEvent table - Audit logs (UAM namespace, no delete for audit integrity!)
if adminId:
tableRules.append(AccessRule(
roleId=adminId,
context=AccessRuleContext.DATA,
- item="data.system.AuthEvent",
+ item="data.uam.AuthEvent",
view=True,
read=AccessLevel.ALL,
create=AccessLevel.NONE,
@@ -587,7 +545,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule(
roleId=userId,
context=AccessRuleContext.DATA,
- item="data.system.AuthEvent",
+ item="data.uam.AuthEvent",
view=True,
read=AccessLevel.MY,
create=AccessLevel.NONE,
@@ -598,7 +556,120 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule(
roleId=viewerId,
context=AccessRuleContext.DATA,
- item="data.system.AuthEvent",
+ item="data.uam.AuthEvent",
+ view=True,
+ read=AccessLevel.MY,
+ create=AccessLevel.NONE,
+ update=AccessLevel.NONE,
+ delete=AccessLevel.NONE,
+ ))
+
+ # -------------------------------------------------------------------------
+ # Chat Namespace - User-owned, no mandate context
+ # -------------------------------------------------------------------------
+
+ # Prompt: Only MY-level access (user-owned, no mandate context)
+ # Each user manages only their own prompts
+ for roleId in [adminId, userId]:
+ if roleId:
+ tableRules.append(AccessRule(
+ roleId=roleId,
+ context=AccessRuleContext.DATA,
+ item="data.chat.Prompt",
+ view=True,
+ read=AccessLevel.MY,
+ create=AccessLevel.MY,
+ update=AccessLevel.MY,
+ delete=AccessLevel.MY,
+ ))
+ if viewerId:
+ tableRules.append(AccessRule(
+ roleId=viewerId,
+ context=AccessRuleContext.DATA,
+ item="data.chat.Prompt",
+ view=True,
+ read=AccessLevel.MY,
+ create=AccessLevel.NONE,
+ update=AccessLevel.NONE,
+ delete=AccessLevel.NONE,
+ ))
+
+ # ChatWorkflow: Only MY-level access (user-owned, no mandate context)
+ for roleId in [adminId, userId]:
+ if roleId:
+ tableRules.append(AccessRule(
+ roleId=roleId,
+ context=AccessRuleContext.DATA,
+ item="data.chat.ChatWorkflow",
+ view=True,
+ read=AccessLevel.MY,
+ create=AccessLevel.MY,
+ update=AccessLevel.MY,
+ delete=AccessLevel.MY,
+ ))
+ if viewerId:
+ tableRules.append(AccessRule(
+ roleId=viewerId,
+ context=AccessRuleContext.DATA,
+ item="data.chat.ChatWorkflow",
+ view=True,
+ read=AccessLevel.MY,
+ create=AccessLevel.NONE,
+ update=AccessLevel.NONE,
+ delete=AccessLevel.NONE,
+ ))
+
+ # -------------------------------------------------------------------------
+ # Files Namespace - User-owned, no mandate context
+ # -------------------------------------------------------------------------
+
+ # FileItem: Only MY-level access (user-owned)
+ for roleId in [adminId, userId]:
+ if roleId:
+ tableRules.append(AccessRule(
+ roleId=roleId,
+ context=AccessRuleContext.DATA,
+ item="data.files.FileItem",
+ view=True,
+ read=AccessLevel.MY,
+ create=AccessLevel.MY,
+ update=AccessLevel.MY,
+ delete=AccessLevel.MY,
+ ))
+ if viewerId:
+ tableRules.append(AccessRule(
+ roleId=viewerId,
+ context=AccessRuleContext.DATA,
+ item="data.files.FileItem",
+ view=True,
+ read=AccessLevel.MY,
+ create=AccessLevel.NONE,
+ update=AccessLevel.NONE,
+ delete=AccessLevel.NONE,
+ ))
+
+ # -------------------------------------------------------------------------
+ # Automation Namespace - User-owned, no mandate context
+ # -------------------------------------------------------------------------
+
+ # AutomationDefinition: Only MY-level access (user-owned)
+ for roleId in [adminId, userId]:
+ if roleId:
+ tableRules.append(AccessRule(
+ roleId=roleId,
+ context=AccessRuleContext.DATA,
+ item="data.automation.AutomationDefinition",
+ view=True,
+ read=AccessLevel.MY,
+ create=AccessLevel.MY,
+ update=AccessLevel.MY,
+ delete=AccessLevel.MY,
+ ))
+ if viewerId:
+ tableRules.append(AccessRule(
+ roleId=viewerId,
+ context=AccessRuleContext.DATA,
+ item="data.automation.AutomationDefinition",
view=True,
read=AccessLevel.MY,
create=AccessLevel.NONE,
@@ -670,6 +741,160 @@ def _createUiContextRules(db: DatabaseConnector) -> None:
logger.info(f"Created {len(uiRules)} UI context rules")
+def _ensureUiContextRules(db: DatabaseConnector) -> None:
+ """
+ Ensure UI context rules exist for all navigation items.
+ This is called during bootstrap to add missing UI rules for new navigation items.
+
+ Args:
+ db: Database connector instance
+ """
+ from modules.system.mainSystem import NAVIGATION_SECTIONS
+
+ adminId = _getRoleId(db, "admin")
+ userId = _getRoleId(db, "user")
+ viewerId = _getRoleId(db, "viewer")
+
+ # Get existing UI rules
+ existingUiRules = db.getRecordset(
+ AccessRule,
+ recordFilter={"context": AccessRuleContext.UI.value}
+ )
+
+ # Build set of existing (roleId, item) combinations
+ existingCombinations = set()
+ for rule in existingUiRules:
+ roleId = rule.get("roleId")
+ item = rule.get("item")
+ if roleId and item:
+ existingCombinations.add((roleId, item))
+
+ # Check each navigation item and add missing rules
+ missingRules = []
+ for section in NAVIGATION_SECTIONS:
+ isAdminSection = section.get("adminOnly", False)
+
+ for item in section.get("items", []):
+ objectKey = item.get("objectKey")
+ if not objectKey:
+ continue
+
+ isAdminOnly = item.get("adminOnly", False) or isAdminSection
+
+ if isAdminOnly:
+ # Admin-only: only admin role
+ if adminId and (adminId, objectKey) not in existingCombinations:
+ missingRules.append(AccessRule(
+ roleId=adminId,
+ context=AccessRuleContext.UI,
+ item=objectKey,
+ view=True,
+ read=None, create=None, update=None, delete=None,
+ ))
+ else:
+ # Public/normal: all roles
+ for roleId in [adminId, userId, viewerId]:
+ if roleId and (roleId, objectKey) not in existingCombinations:
+ missingRules.append(AccessRule(
+ roleId=roleId,
+ context=AccessRuleContext.UI,
+ item=objectKey,
+ view=True,
+ read=None, create=None, update=None, delete=None,
+ ))
+
+ # Create missing rules
+ if missingRules:
+ for rule in missingRules:
+ db.recordCreate(AccessRule, rule)
+ logger.info(f"Created {len(missingRules)} missing UI context rules")
+ else:
+ logger.debug("All UI context rules already exist")
+
+
+def _ensureDataContextRules(db: DatabaseConnector) -> None:
+ """
+ Ensure DATA context rules exist for key tables like ChatWorkflow and AutomationDefinition.
+ This is called during bootstrap to add missing DATA rules for new tables.
+
+ Args:
+ db: Database connector instance
+ """
+ adminId = _getRoleId(db, "admin")
+ userId = _getRoleId(db, "user")
+ viewerId = _getRoleId(db, "viewer")
+
+ # Get existing DATA rules
+ existingDataRules = db.getRecordset(
+ AccessRule,
+ recordFilter={"context": AccessRuleContext.DATA.value}
+ )
+
+ # Build set of existing (roleId, item) combinations
+ existingCombinations = set()
+ for rule in existingDataRules:
+ roleId = rule.get("roleId")
+ item = rule.get("item")
+ if roleId and item:
+ existingCombinations.add((roleId, item))
+
+ # Define tables that need rules (user-owned, no mandate context)
+ # Users can only manage their own records (MY-level access)
+ tablesNeedingRules = [
+ "data.chat.ChatWorkflow",
+ "data.automation.AutomationDefinition",
+ ]
+
+ missingRules = []
+ for objectKey in tablesNeedingRules:
+ # Admin: MY-level access (user-owned, no mandate context)
+ if adminId and (adminId, objectKey) not in existingCombinations:
+ missingRules.append(AccessRule(
+ roleId=adminId,
+ context=AccessRuleContext.DATA,
+ item=objectKey,
+ view=True,
+ read=AccessLevel.MY,
+ create=AccessLevel.MY,
+ update=AccessLevel.MY,
+ delete=AccessLevel.MY,
+ ))
+
+ # User: MY-level access (user-owned, no mandate context)
+ if userId and (userId, objectKey) not in existingCombinations:
+ missingRules.append(AccessRule(
+ roleId=userId,
+ context=AccessRuleContext.DATA,
+ item=objectKey,
+ view=True,
+ read=AccessLevel.MY,
+ create=AccessLevel.MY,
+ update=AccessLevel.MY,
+ delete=AccessLevel.MY,
+ ))
+
+ # Viewer: MY read-only (user-owned, no mandate context)
+ if viewerId and (viewerId, objectKey) not in existingCombinations:
+ missingRules.append(AccessRule(
+ roleId=viewerId,
+ context=AccessRuleContext.DATA,
+ item=objectKey,
+ view=True,
+ read=AccessLevel.MY,
+ create=AccessLevel.NONE,
+ update=AccessLevel.NONE,
+ delete=AccessLevel.NONE,
+ ))
+
+ # Create missing rules
+ if missingRules:
+ for rule in missingRules:
+ db.recordCreate(AccessRule, rule)
+ logger.info(f"Created {len(missingRules)} missing DATA context rules")
+ else:
+ logger.debug("All DATA context rules already exist")
+
+
def _createResourceContextRules(db: DatabaseConnector) -> None:
"""
Create RESOURCE context rules for controlling resource access.
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index a7dfc689..250b2a38 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -1217,10 +1217,10 @@ class AppObjects:
The created UserConnection object
"""
try:
- # Get the user
- user = self.getUser(userId)
- if not user:
- raise ValueError(f"User not found: {userId}")
+ # Note: User verification is skipped here because:
+ # 1. The caller (route) already has an authenticated currentUser
+ # 2. Users should always be able to create connections for themselves
+ # 3. getUser() uses RBAC filtering which may fail for users without UserInDB view permissions
# Create new connection with all required fields
connection = UserConnection(
diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py
index 3c4d35ad..6a43599b 100644
--- a/modules/interfaces/interfaceDbChat.py
+++ b/modules/interfaces/interfaceDbChat.py
@@ -364,10 +364,13 @@ class ChatObjects:
return False
tableName = modelClass.__name__
+ # Use buildDataObjectKey for semantic namespace lookup
+ from modules.interfaces.interfaceRbac import buildDataObjectKey
+ objectKey = buildDataObjectKey(tableName)
permissions = self.rbac.getUserPermissions(
self.currentUser,
AccessRuleContext.DATA,
- tableName,
+ objectKey,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
)
@@ -680,8 +683,7 @@ class ChatObjects:
startedAt=workflow.get("startedAt", getUtcTimestamp()),
logs=logs,
messages=messages,
- stats=stats,
- mandateId=workflow.get("mandateId", self.mandateId)
+ stats=stats
)
except Exception as e:
logger.error(f"Error validating workflow data: {str(e)}")
@@ -702,9 +704,22 @@ class ChatObjects:
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in workflowData or not workflowData["mandateId"]:
- workflowData["mandateId"] = self.mandateId
- if "featureInstanceId" not in workflowData or not workflowData["featureInstanceId"]:
- workflowData["featureInstanceId"] = self.featureInstanceId
+ # Use request context mandateId, or fall back to Root mandate
+ effectiveMandateId = self.mandateId
+ if not effectiveMandateId:
+ # Fall back to Root mandate (first mandate in system)
+ try:
+ from modules.datamodels.datamodelUam import Mandate
+ from modules.security.rootAccess import getRootDbAppConnector
+ dbAppConn = getRootDbAppConnector()
+ allMandates = dbAppConn.getRecordset(Mandate)
+ if allMandates:
+ effectiveMandateId = allMandates[0].get("id")
+ logger.debug(f"createWorkflow: Using Root mandate {effectiveMandateId}")
+ except Exception as e:
+ logger.warning(f"Could not get Root mandate: {e}")
+ # Note: Chat data is user-owned, no mandate/featureInstance context stored
+ # mandateId/featureInstanceId removed from ChatWorkflow model
# Use generic field separation based on ChatWorkflow model
simpleFields, objectFields = self._separateObjectFields(ChatWorkflow, workflowData)
@@ -714,6 +729,7 @@ class ChatObjects:
# Convert to ChatWorkflow model (empty related data for new workflow)
+ # Note: Chat data is user-owned, no mandate/featureInstance fields
return ChatWorkflow(
id=created["id"],
status=created.get("status", "running"),
@@ -728,7 +744,6 @@ class ChatObjects:
logs=[],
messages=[],
stats=[],
- mandateId=created.get("mandateId", self.mandateId),
workflowMode=created["workflowMode"],
maxSteps=created.get("maxSteps", 1)
)
@@ -774,8 +789,7 @@ class ChatObjects:
startedAt=updated.get("startedAt", workflow.startedAt),
logs=logs,
messages=messages,
- stats=stats,
- mandateId=updated.get("mandateId", workflow.mandateId)
+ stats=stats
)
def deleteWorkflow(self, workflowId: str) -> bool:
@@ -886,7 +900,7 @@ class ChatObjects:
# Apply default sorting by publishedAt if no sort specified
if pagination is None or not pagination.sort:
- messageDicts.sort(key=lambda x: x.get("publishedAt", getUtcTimestamp()))
+ messageDicts.sort(key=lambda x: x.get("publishedAt") or getUtcTimestamp())
# Apply filtering (if filters provided)
if pagination and pagination.filters:
@@ -1026,11 +1040,8 @@ class ChatObjects:
if "actionNumber" not in messageData:
messageData["actionNumber"] = workflow.currentAction
- # Set mandateId and featureInstanceId from context for proper data isolation
- if "mandateId" not in messageData or not messageData["mandateId"]:
- messageData["mandateId"] = self.mandateId
- if "featureInstanceId" not in messageData or not messageData["featureInstanceId"]:
- messageData["featureInstanceId"] = self.featureInstanceId
+ # Note: Chat data is user-owned, no mandate/featureInstance context stored
+ # mandateId/featureInstanceId removed from ChatMessage model
# Use generic field separation based on ChatMessage model
simpleFields, objectFields = self._separateObjectFields(ChatMessage, messageData)
@@ -1306,11 +1317,8 @@ class ChatObjects:
def createDocument(self, documentData: Dict[str, Any]) -> ChatDocument:
"""Creates a document for a message in normalized table."""
try:
- # Set mandateId and featureInstanceId from context for proper data isolation
- if "mandateId" not in documentData or not documentData["mandateId"]:
- documentData["mandateId"] = self.mandateId
- if "featureInstanceId" not in documentData or not documentData["featureInstanceId"]:
- documentData["featureInstanceId"] = self.featureInstanceId
+ # Note: Chat data is user-owned, no mandate/featureInstance context stored
+ # mandateId/featureInstanceId removed from ChatDocument model
# Validate and normalize document data to dict
document = ChatDocument(**documentData)
@@ -1431,11 +1439,8 @@ class ChatObjects:
if "timestamp" not in logData:
logData["timestamp"] = getUtcTimestamp()
- # Set mandateId and featureInstanceId from context for proper data isolation
- if "mandateId" not in logData or not logData["mandateId"]:
- logData["mandateId"] = self.mandateId
- if "featureInstanceId" not in logData or not logData["featureInstanceId"]:
- logData["featureInstanceId"] = self.featureInstanceId
+ # Note: Chat data is user-owned, no mandate/featureInstance context stored
+ # mandateId/featureInstanceId removed from ChatLog model
# Add status information if not present
if "status" not in logData and "type" in logData:
@@ -1500,11 +1505,8 @@ class ChatObjects:
if "workflowId" not in statData:
raise ValueError("workflowId is required in statData")
- # Set mandateId and featureInstanceId from context for proper data isolation
- if "mandateId" not in statData or not statData["mandateId"]:
- statData["mandateId"] = self.mandateId
- if "featureInstanceId" not in statData or not statData["featureInstanceId"]:
- statData["featureInstanceId"] = self.featureInstanceId
+ # Note: Chat data is user-owned, no mandate/featureInstance context stored
+ # mandateId/featureInstanceId removed from ChatStat model
# Validate the stat data against ChatStat model
stat = ChatStat(**statData)
@@ -1783,8 +1785,22 @@ class ChatObjects:
automationData["id"] = str(uuid.uuid4())
# Ensure mandateId and featureInstanceId are set for proper data isolation
- if "mandateId" not in automationData:
- automationData["mandateId"] = self.mandateId
+ if "mandateId" not in automationData or not automationData.get("mandateId"):
+ # Use request context mandateId, or fall back to Root mandate
+ effectiveMandateId = self.mandateId
+ if not effectiveMandateId:
+ # Fall back to Root mandate (first mandate in system)
+ try:
+ from modules.datamodels.datamodelUam import Mandate
+ from modules.security.rootAccess import getRootDbAppConnector
+ dbAppConn = getRootDbAppConnector()
+ allMandates = dbAppConn.getRecordset(Mandate)
+ if allMandates:
+ effectiveMandateId = allMandates[0].get("id")
+ logger.debug(f"createAutomationDefinition: Using Root mandate {effectiveMandateId}")
+ except Exception as e:
+ logger.warning(f"Could not get Root mandate: {e}")
+ automationData["mandateId"] = effectiveMandateId
if "featureInstanceId" not in automationData:
automationData["featureInstanceId"] = self.featureInstanceId
diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py
index aec97b5a..3e062048 100644
--- a/modules/interfaces/interfaceRbac.py
+++ b/modules/interfaces/interfaceRbac.py
@@ -7,6 +7,18 @@ Provides RBAC filtering for database queries without connectors importing securi
Multi-Tenant Design:
- mandateId kommt aus Request-Context (X-Mandate-Id Header)
- GROUP-Filter verwendet expliziten mandateId Parameter
+
+Data Namespace Structure:
+- data.uam.{Table} → User Access Management (mandantenübergreifend)
+- data.chat.{Table} → Chat/AI-Daten (benutzer-eigen, kein Mandantenkontext)
+- data.files.{Table} → Dateien (benutzer-eigen)
+- data.automation.{Table} → Automation (benutzer-eigen)
+- data.feature.{code}.{Table} → Mandanten-/Feature-spezifische Daten (dynamisch)
+
+GROUP-Berechtigung:
+- data.uam.*: GROUP filtert nach Mandant (via UserMandate)
+- data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen, kein Mandantenkontext)
+- data.feature.*: GROUP filtert nach mandateId/featureInstanceId
"""
import logging
@@ -21,25 +33,70 @@ from modules.security.rootAccess import getRootDbAppConnector
logger = logging.getLogger(__name__)
+# =============================================================================
+# Namespace-Mapping für statische Tabellen
+# =============================================================================
+# Definiert, welcher Namespace für jede Tabelle verwendet wird.
+# Tabellen ohne Eintrag fallen auf "system" zurück (Fallback für Rückwärtskompatibilität).
+# =============================================================================
+
+TABLE_NAMESPACE = {
+ # UAM (User Access Management) - mandantenübergreifend
+ "UserInDB": "uam",
+ "UserConnection": "uam",
+ "AuthEvent": "uam",
+ "Mandate": "uam",
+ "UserMandate": "uam",
+ "UserMandateRole": "uam",
+ "Invitation": "uam",
+ "Role": "uam",
+ "AccessRule": "uam",
+ "FeatureInstance": "uam",
+ "FeatureAccess": "uam",
+ "FeatureAccessRole": "uam",
+ # Chat - benutzer-eigen, kein Mandantenkontext
+ "ChatWorkflow": "chat",
+ "ChatMessage": "chat",
+ "ChatLog": "chat",
+ "ChatStat": "chat",
+ "ChatDocument": "chat",
+ "Prompt": "chat",
+ # Files - benutzer-eigen
+ "FileItem": "files",
+ "FileData": "files",
+ # Automation - benutzer-eigen
+ "AutomationDefinition": "automation",
+}
+
+# Namespaces ohne Mandantenkontext - GROUP wird auf MY gemappt
+USER_OWNED_NAMESPACES = {"chat", "files", "automation"}
+
+
def buildDataObjectKey(tableName: str, featureCode: Optional[str] = None) -> str:
"""
Build the standardized objectKey for a DATA context item.
Format:
- - System tables: data.system.{TableName}
+ - UAM tables: data.uam.{TableName}
+ - Chat tables: data.chat.{TableName}
+ - File tables: data.files.{TableName}
+ - Automation tables: data.automation.{TableName}
- Feature tables: data.feature.{featureCode}.{TableName}
Args:
- tableName: The database table name (e.g., "UserInDB", "TrusteePosition")
+ tableName: The database table name (e.g., "UserInDB", "ChatWorkflow")
featureCode: Optional feature code (e.g., "trustee", "realestate")
- If None, assumes system table.
+ If provided, uses data.feature.{featureCode}.{tableName}
Returns:
- Full objectKey string (e.g., "data.system.UserInDB" or "data.feature.trustee.TrusteePosition")
+ Full objectKey string (e.g., "data.uam.UserInDB", "data.chat.ChatWorkflow",
+ or "data.feature.trustee.TrusteePosition")
"""
if featureCode:
return f"data.feature.{featureCode}.{tableName}"
- return f"data.system.{tableName}"
+
+ namespace = TABLE_NAMESPACE.get(tableName, "system") # Fallback für unbekannte Tabellen
+ return f"data.{namespace}.{tableName}"
def getRecordsetWithRBAC(
@@ -107,7 +164,7 @@ def getRecordsetWithRBAC(
permissions = rbacInstance.getUserPermissions(
currentUser,
AccessRuleContext.DATA,
- objectKey, # Use full objectKey (e.g., "data.system.UserInDB")
+ objectKey, # Use full objectKey (e.g., "data.uam.UserInDB", "data.chat.ChatWorkflow")
mandateId=effectiveMandateId,
featureInstanceId=featureInstanceId
)
@@ -271,10 +328,32 @@ def buildRbacWhereClause(
"values": [currentUser.id]
}
- # Group records - filter by mandateId
+ # Group records - filter by mandateId or ownership based on namespace
if readLevel == AccessLevel.GROUP:
+ # Determine namespace for this table
+ namespace = TABLE_NAMESPACE.get(table, "system")
+
+ # For user-owned namespaces (chat, files, automation):
+ # GROUP has no meaning - these tables have no mandate context
+ # Simply ignore GROUP (no filtering)
+ if namespace in USER_OWNED_NAMESPACES:
+ return None
+
+ # For UAM and other namespaces: GROUP filters by mandate
effectiveMandateId = mandateId
+ if not effectiveMandateId:
+ # Fall back to Root mandate (first mandate in system) for GROUP access
+ # This allows system-level tables to be accessed without explicit mandate context
+ try:
+ from modules.datamodels.datamodelUam import Mandate
+ dbApp = getRootDbAppConnector()
+ allMandates = dbApp.getRecordset(Mandate)
+ if allMandates:
+ effectiveMandateId = allMandates[0].get("id")
+ except Exception as e:
+ logger.error(f"Error getting Root mandate: {e}")
+
if not effectiveMandateId:
logger.warning(f"User {currentUser.id} has no mandateId for GROUP access")
return {"condition": "1 = 0", "values": []}
@@ -324,10 +403,16 @@ def buildRbacWhereClause(
logger.error(f"Error building GROUP filter for UserConnection: {e}")
return {"condition": "1 = 0", "values": []}
+ # For system tables without mandateId column (Mandate, Role, etc.):
+ # No row-level filtering - GROUP access = ALL access for these
+ elif table in ("Mandate", "Role"):
+ return None
+
# For other tables, filter by mandateId field
+ # Also include records with NULL mandateId for backwards compatibility
else:
return {
- "condition": '"mandateId" = %s',
+ "condition": '("mandateId" = %s OR "mandateId" IS NULL)',
"values": [effectiveMandateId]
}
diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py
index d125bc2c..916caf38 100644
--- a/modules/routes/routeAdminRbacRules.py
+++ b/modules/routes/routeAdminRbacRules.py
@@ -184,9 +184,12 @@ async def get_all_permissions(
recordFilter={"userId": str(reqContext.user.id), "enabled": True}
)
+ logger.debug(f"UI/RESOURCE permissions: Found {len(userMandates)} UserMandates for user {reqContext.user.id}")
+
# Collect all role IDs the user has across all mandates
for userMandate in userMandates:
mandateRoleIds = rootInterface.getRoleIdsForUserMandate(userMandate.get("id"))
+ logger.debug(f"UI/RESOURCE permissions: UserMandate {userMandate.get('id')} (mandate {userMandate.get('mandateId')}) has {len(mandateRoleIds)} roles: {mandateRoleIds}")
for rid in mandateRoleIds:
if rid not in roleIds:
roleIds.append(rid)
@@ -261,20 +264,24 @@ async def get_all_permissions(
items.add(rule.item)
# For each item, calculate user permissions
+ # For UI/RESOURCE context: Calculate permissions directly from the collected rules
+ # (Don't use getUserPermissions with mandateId - that would limit to one mandate's roles)
for item in sorted(items):
- permissions = interface.rbac.getUserPermissions(
- reqContext.user, ctx, item,
- mandateId=reqContext.mandateId,
- featureInstanceId=reqContext.featureInstanceId
- )
+ # Find matching rules for this item from the already-collected rules
+ itemView = False
+ for rule in allRules[ctx]:
+ if rule.item == item and rule.view:
+ itemView = True
+ break
+
# Only include if user has view permission
- if permissions.view:
+ if itemView:
result[ctx.value.lower()][item] = {
- "view": permissions.view,
- "read": permissions.read.value if permissions.read else None,
- "create": permissions.create.value if permissions.create else None,
- "update": permissions.update.value if permissions.update else None,
- "delete": permissions.delete.value if permissions.delete else None
+ "view": True,
+ "read": None, # UI context doesn't use CRUD permissions
+ "create": None,
+ "update": None,
+ "delete": None
}
return result
diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py
index 37200186..5d84efd9 100644
--- a/modules/routes/routeDataConnections.py
+++ b/modules/routes/routeDataConnections.py
@@ -331,13 +331,8 @@ async def create_connection(
detail=f"Unsupported connection type: {connection_data.get('type')}"
)
- # Get fresh copy of user from database
- user = interface.getUser(currentUser.id)
- if not user:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="User not found"
- )
+ # Note: currentUser is already authenticated via JWT - no need to re-verify from database
+ # The getCurrentUser dependency already validated the user exists
# Always create a new connection with PENDING status
connection = interface.addUserConnection(
diff --git a/modules/routes/routeNotifications.py b/modules/routes/routeNotifications.py
index 2016a745..7c8cf9ad 100644
--- a/modules/routes/routeNotifications.py
+++ b/modules/routes/routeNotifications.py
@@ -19,6 +19,7 @@ from modules.datamodels.datamodelNotification import (
NotificationStatus,
NotificationAction
)
+from modules.datamodels.datamodelRbac import Role
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.timeUtils import getUtcTimestamp
@@ -452,6 +453,17 @@ async def _handleInvitationAction(
mandateId = invitation.get("mandateId")
roleIds = invitation.get("roleIds", [])
+ # Ensure user gets the system "user" role for access to public UI elements (e.g. playground)
+ userRoles = rootInterface.db.getRecordset(
+ model_class=Role,
+ recordFilter={"roleLabel": "user"}
+ )
+ if userRoles:
+ userRoleId = userRoles[0].get("id")
+ if userRoleId and userRoleId not in roleIds:
+ roleIds = roleIds + [userRoleId]
+ logger.debug(f"Added system 'user' role {userRoleId} to invitation roles")
+
# Get mandate name for result message
mandates = rootInterface.db.getRecordset(
model_class=Mandate,
diff --git a/modules/security/rbac.py b/modules/security/rbac.py
index 34e80105..5d20d1fb 100644
--- a/modules/security/rbac.py
+++ b/modules/security/rbac.py
@@ -13,7 +13,7 @@ Multi-Tenant Design:
import logging
from typing import List, Optional, TYPE_CHECKING
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext, Role
-from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
+from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel, Mandate
from modules.datamodels.datamodelMembership import (
UserMandate,
UserMandateRole,
@@ -148,6 +148,9 @@ class RbacClass:
Get all role IDs for a user in the given context.
Uses UserMandate + UserMandateRole for the new multi-tenant model.
+ Also includes roles from the Root mandate (first mandate) if different
+ from the requested mandate, so system-level permissions are always available.
+
Args:
user: User object
mandateId: Mandate context
@@ -156,30 +159,40 @@ class RbacClass:
Returns:
List of role IDs
"""
- roleIds = []
-
- if not mandateId:
- return roleIds
+ roleIds = set() # Use set to avoid duplicates
try:
- # Lade UserMandate
- userMandates = self.dbApp.getRecordset(
- UserMandate,
- recordFilter={"userId": user.id, "mandateId": mandateId, "enabled": True}
- )
+ # Get Root mandate ID (first mandate in system)
+ allMandates = self.dbApp.getRecordset(Mandate)
+ rootMandateId = allMandates[0].get("id") if allMandates else None
- if not userMandates:
- return roleIds
+ # Collect mandates to check:
+ # - If mandateId provided: current mandate + Root mandate (if different)
+ # - If no mandateId: just Root mandate (for system-level access)
+ mandatesToCheck = []
+ if mandateId:
+ mandatesToCheck.append(mandateId)
+ if rootMandateId and rootMandateId not in mandatesToCheck:
+ mandatesToCheck.append(rootMandateId)
- userMandateId = userMandates[0].get("id")
-
- # Lade UserMandateRoles (Mandate-level roles)
- userMandateRoles = self.dbApp.getRecordset(
- UserMandateRole,
- recordFilter={"userMandateId": userMandateId}
- )
-
- roleIds.extend([r.get("roleId") for r in userMandateRoles if r.get("roleId")])
+ # Load roles from each mandate
+ for checkMandateId in mandatesToCheck:
+ userMandates = self.dbApp.getRecordset(
+ UserMandate,
+ recordFilter={"userId": user.id, "mandateId": checkMandateId, "enabled": True}
+ )
+
+ if userMandates:
+ userMandateId = userMandates[0].get("id")
+
+ # Lade UserMandateRoles (Mandate-level roles)
+ userMandateRoles = self.dbApp.getRecordset(
+ UserMandateRole,
+ recordFilter={"userMandateId": userMandateId}
+ )
+
+ foundRoles = [r.get("roleId") for r in userMandateRoles if r.get("roleId")]
+ roleIds.update(foundRoles)
# Load FeatureAccess + FeatureAccessRole (Instance-level roles)
if featureInstanceId:
@@ -200,12 +213,12 @@ class RbacClass:
recordFilter={"featureAccessId": featureAccessId}
)
- roleIds.extend([r.get("roleId") for r in featureAccessRoles if r.get("roleId")])
+ roleIds.update([r.get("roleId") for r in featureAccessRoles if r.get("roleId")])
except Exception as e:
logger.error(f"Error loading role IDs for user {user.id}: {e}")
- return roleIds
+ return list(roleIds)
def getRulesForUserBulk(
self,
@@ -388,7 +401,10 @@ class RbacClass:
Example: rule "data.feature.trustee" matches item "data.feature.trustee.TrusteePosition"
All items MUST use the full objectKey format:
- - System: data.system.{TableName} (e.g., "data.system.UserInDB")
+ - UAM: data.uam.{TableName} (e.g., "data.uam.UserInDB")
+ - Chat: data.chat.{TableName} (e.g., "data.chat.ChatWorkflow")
+ - Files: data.files.{TableName} (e.g., "data.files.FileItem")
+ - Automation: data.automation.{TableName} (e.g., "data.automation.AutomationDefinition")
- Feature: data.feature.{featureCode}.{TableName} (e.g., "data.feature.trustee.TrusteePosition")
- UI: ui.{area}.{page} (e.g., "ui.admin.users")
diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py
index 24d1d410..644b121f 100644
--- a/modules/system/mainSystem.py
+++ b/modules/system/mainSystem.py
@@ -72,6 +72,7 @@ NAVIGATION_SECTIONS = [
"icon": "FaPlay",
"path": "/workflows/playground",
"order": 10,
+ "public": True,
},
{
"id": "chats",
@@ -80,6 +81,7 @@ NAVIGATION_SECTIONS = [
"icon": "FaListAlt",
"path": "/workflows/list",
"order": 20,
+ "public": True,
},
{
"id": "automations",
@@ -88,6 +90,7 @@ NAVIGATION_SECTIONS = [
"icon": "FaCogs",
"path": "/workflows/automations",
"order": 30,
+ "public": True,
},
],
},
@@ -297,72 +300,83 @@ UI_OBJECTS = _buildUiObjectsFromNavigation()
# =============================================================================
# System DATA Objects
# =============================================================================
+# Namespace structure:
+# - data.uam.* → User Access Management (mandantenübergreifend)
+# - data.chat.* → Chat/AI-Daten (benutzer-eigen, kein Mandantenkontext)
+# - data.files.* → Dateien (benutzer-eigen)
+# - data.automation.* → Automation (benutzer-eigen)
+# - data.feature.* → Mandanten-/Feature-spezifische Daten (dynamisch)
+# =============================================================================
DATA_OBJECTS = [
- # User/Auth tables
+ # UAM (User Access Management) - mandantenübergreifend
{
- "objectKey": "data.system.UserInDB",
+ "objectKey": "data.uam.UserInDB",
"label": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
- "meta": {"table": "UserInDB"}
+ "meta": {"table": "UserInDB", "namespace": "uam"}
},
{
- "objectKey": "data.system.AuthEvent",
+ "objectKey": "data.uam.AuthEvent",
"label": {"en": "Auth Event", "de": "Auth-Ereignis", "fr": "Événement d'auth"},
- "meta": {"table": "AuthEvent"}
+ "meta": {"table": "AuthEvent", "namespace": "uam"}
},
{
- "objectKey": "data.system.UserConnection",
+ "objectKey": "data.uam.UserConnection",
"label": {"en": "Connection", "de": "Verbindung", "fr": "Connexion"},
- "meta": {"table": "UserConnection"}
+ "meta": {"table": "UserConnection", "namespace": "uam"}
},
- # Mandate/Membership tables
{
- "objectKey": "data.system.Mandate",
+ "objectKey": "data.uam.Mandate",
"label": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
- "meta": {"table": "Mandate"}
+ "meta": {"table": "Mandate", "namespace": "uam"}
},
{
- "objectKey": "data.system.UserMandate",
+ "objectKey": "data.uam.UserMandate",
"label": {"en": "User Mandate", "de": "Benutzer-Mandant", "fr": "Mandat utilisateur"},
- "meta": {"table": "UserMandate"}
+ "meta": {"table": "UserMandate", "namespace": "uam"}
},
{
- "objectKey": "data.system.Invitation",
+ "objectKey": "data.uam.Invitation",
"label": {"en": "Invitation", "de": "Einladung", "fr": "Invitation"},
- "meta": {"table": "Invitation"}
+ "meta": {"table": "Invitation", "namespace": "uam"}
},
- # RBAC tables
{
- "objectKey": "data.system.Role",
+ "objectKey": "data.uam.Role",
"label": {"en": "Role", "de": "Rolle", "fr": "Rôle"},
- "meta": {"table": "Role"}
+ "meta": {"table": "Role", "namespace": "uam"}
},
{
- "objectKey": "data.system.AccessRule",
+ "objectKey": "data.uam.AccessRule",
"label": {"en": "Access Rule", "de": "Zugriffsregel", "fr": "Règle d'accès"},
- "meta": {"table": "AccessRule"}
+ "meta": {"table": "AccessRule", "namespace": "uam"}
},
- # Feature tables
{
- "objectKey": "data.system.FeatureInstance",
+ "objectKey": "data.uam.FeatureInstance",
"label": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance de feature"},
- "meta": {"table": "FeatureInstance"}
+ "meta": {"table": "FeatureInstance", "namespace": "uam"}
},
- # Content tables
+ # Chat - benutzer-eigen, kein Mandantenkontext
{
- "objectKey": "data.system.Prompt",
+ "objectKey": "data.chat.Prompt",
"label": {"en": "Prompt", "de": "Prompt", "fr": "Prompt"},
- "meta": {"table": "Prompt"}
+ "meta": {"table": "Prompt", "namespace": "chat", "groupDisabled": True}
},
{
- "objectKey": "data.system.ChatWorkflow",
+ "objectKey": "data.chat.ChatWorkflow",
"label": {"en": "Chat Workflow", "de": "Chat-Workflow", "fr": "Workflow de chat"},
- "meta": {"table": "ChatWorkflow"}
+ "meta": {"table": "ChatWorkflow", "namespace": "chat", "groupDisabled": True}
},
+ # Files - benutzer-eigen
{
- "objectKey": "data.system.FileItem",
+ "objectKey": "data.files.FileItem",
"label": {"en": "File", "de": "Datei", "fr": "Fichier"},
- "meta": {"table": "FileItem"}
+ "meta": {"table": "FileItem", "namespace": "files", "groupDisabled": True}
+ },
+ # Automation - benutzer-eigen
+ {
+ "objectKey": "data.automation.AutomationDefinition",
+ "label": {"en": "Automation", "de": "Automatisierung", "fr": "Automatisation"},
+ "meta": {"table": "AutomationDefinition", "namespace": "automation", "groupDisabled": True}
},
]
diff --git a/modules/workflows/methods/methodSharepoint/actions/getExpensesFromPdf.py b/modules/workflows/methods/methodSharepoint/actions/getExpensesFromPdf.py
index c2ecb7c9..e583d8bf 100644
--- a/modules/workflows/methods/methodSharepoint/actions/getExpensesFromPdf.py
+++ b/modules/workflows/methods/methodSharepoint/actions/getExpensesFromPdf.py
@@ -360,7 +360,7 @@ async def _extractExpensesWithAi(services, fileContent: bytes, fileName: str, pr
from modules.datamodels.datamodelChat import ChatDocument
from modules.datamodels.datamodelDocref import DocumentReferenceList
- dbInterface = getDbInterface()
+ dbInterface = getDbInterface(services.user, mandateId=services.mandateId, featureInstanceId=featureInstanceId)
# Create file record
fileItem = dbInterface.createFile(
@@ -375,40 +375,52 @@ async def _extractExpensesWithAi(services, fileContent: bytes, fileName: str, pr
logger.info(f"Stored PDF {fileName} ({len(fileContent)} bytes) with fileId: {fileItem.id}")
# Step 2: Create ChatDocument referencing the file
- # Use workflow context if available
- workflowId = services.workflow.id if services.workflow else str(uuid.uuid4())
- messageId = f"expense_import_{workflowId}_{str(uuid.uuid4())[:8]}"
-
+ documentId = str(uuid.uuid4())
chatDocument = ChatDocument(
- id=str(uuid.uuid4()),
+ id=documentId,
mandateId=services.mandateId or "",
featureInstanceId=featureInstanceId or "",
- messageId=messageId,
+ messageId="", # Will be set when attached to message
fileId=fileItem.id,
fileName=fileName,
fileSize=len(fileContent),
mimeType="application/pdf"
)
- # Step 3: Create DocumentReferenceList for AI service
+ # Step 3: Create a proper message with the document attached to the workflow
+ # This ensures getChatDocumentsFromDocumentList can find the document via workflow.messages
+ messageData = {
+ "id": f"msg_expense_import_{str(uuid.uuid4())[:8]}",
+ "documentsLabel": f"expense_pdf_{fileName}",
+ "role": "user",
+ "status": "step",
+ "message": f"PDF document for expense extraction: {fileName}"
+ }
+
+ # Use storeMessageWithDocuments to properly create message + document and sync with workflow
+ createdMessage = services.chat.storeMessageWithDocuments(
+ services.workflow,
+ messageData,
+ [chatDocument.model_dump()]
+ )
+
+ # Update documentId to match the created document's actual ID
+ if createdMessage and createdMessage.documents:
+ documentId = createdMessage.documents[0].id
+
+ logger.info(f"Created message {createdMessage.id} with ChatDocument {documentId} for AI processing")
+
+ # Step 4: Create DocumentReferenceList for AI service
from modules.datamodels.datamodelDocref import DocumentItemReference
documentList = DocumentReferenceList(
references=[
DocumentItemReference(
- documentId=chatDocument.id,
+ documentId=documentId,
fileName=fileName
)
]
)
- # Step 4: Store the ChatDocument so AI service can retrieve it
- # The AI service uses getChatDocumentsFromDocumentList which queries the database
- from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
- chatInterface = getChatInterface(services.user, mandateId=services.mandateId, featureInstanceId=featureInstanceId)
- chatInterface.createDocument(chatDocument.model_dump())
-
- logger.info(f"Created ChatDocument {chatDocument.id} for AI processing")
-
# Step 5: Call AI with documentList - let AI service handle everything
# (extraction, intent analysis, chunking, image processing)
options = AiCallOptions(
diff --git a/tests/unit/rbac/test_rbac_bootstrap.py b/tests/unit/rbac/test_rbac_bootstrap.py
index 05951264..e8b04f07 100644
--- a/tests/unit/rbac/test_rbac_bootstrap.py
+++ b/tests/unit/rbac/test_rbac_bootstrap.py
@@ -119,9 +119,9 @@ class TestRbacBootstrap:
# Should create multiple rules for different tables
assert db.recordCreate.call_count > 0
- # Check that Mandate table rules are created with full objectKey
+ # Check that Mandate table rules are created with full objectKey (UAM namespace)
mandateCalls = [call for call in db.recordCreate.call_args_list
- if call[0][1].item == "data.system.Mandate"]
+ if call[0][1].item == "data.uam.Mandate"]
assert len(mandateCalls) > 0
# Check that all roles have view=False and no access for Mandate
@@ -134,11 +134,11 @@ class TestRbacBootstrap:
def testInitRbacRulesSkipsIfExists(self):
"""Test that initRbacRules skips default rule creation if rules already exist, but adds missing table-specific rules."""
db = Mock()
- # Mock existing rules - include rules for ChatWorkflow and Prompt to prevent adding missing rules
+ # Mock existing rules - include rules for ChatWorkflow and AutomationDefinition to prevent adding missing rules
# Need rules for all required roles to fully prevent creation
- # Using full objectKey format: data.system.{TableName}
+ # Using semantic namespace format: data.chat.{TableName}, data.automation.{TableName}
existingRules = []
- for table in ["data.system.ChatWorkflow", "data.system.Prompt"]:
+ for table in ["data.chat.ChatWorkflow", "data.automation.AutomationDefinition"]:
for role in ["admin", "user", "viewer"]:
existingRules.append({
"id": f"rule_{table}_{role}",
diff --git a/tests/unit/rbac/test_rbac_permissions.py b/tests/unit/rbac/test_rbac_permissions.py
index a3387f92..b40bebe3 100644
--- a/tests/unit/rbac/test_rbac_permissions.py
+++ b/tests/unit/rbac/test_rbac_permissions.py
@@ -94,7 +94,7 @@ class TestRbacPermissionResolution:
AccessRule(
roleLabel="user",
context=AccessRuleContext.DATA,
- item="data.system.UserInDB", # Specific rule with full objectKey
+ item="data.uam.UserInDB", # Specific rule with UAM namespace
view=True,
read=AccessLevel.MY,
create=AccessLevel.NONE,
@@ -107,11 +107,11 @@ class TestRbacPermissionResolution:
rbac._getRulesForRole = mockGetRulesForRole
# Get permissions for UserInDB table - should use specific rule
- # Using full objectKey format: data.system.UserInDB
+ # Using UAM namespace: data.uam.UserInDB
permissions = rbac.getUserPermissions(
user,
AccessRuleContext.DATA,
- "data.system.UserInDB"
+ "data.uam.UserInDB"
)
# Most specific rule should win
@@ -253,29 +253,29 @@ class TestRbacPermissionResolution:
AccessRule(
roleLabel="user",
context=AccessRuleContext.DATA,
- item="data.system.UserInDB", # Table-level with full objectKey
+ item="data.uam.UserInDB", # Table-level with UAM namespace
view=True,
read=AccessLevel.MY
),
AccessRule(
roleLabel="user",
context=AccessRuleContext.DATA,
- item="data.system.UserInDB.email", # Field-level - most specific
+ item="data.uam.UserInDB.email", # Field-level - most specific
view=True,
read=AccessLevel.NONE
)
]
# Test exact match
- rule = rbac.findMostSpecificRule(rules, "data.system.UserInDB.email")
+ rule = rbac.findMostSpecificRule(rules, "data.uam.UserInDB.email")
assert rule is not None
- assert rule.item == "data.system.UserInDB.email"
+ assert rule.item == "data.uam.UserInDB.email"
assert rule.read == AccessLevel.NONE
# Test table-level match
- rule = rbac.findMostSpecificRule(rules, "data.system.UserInDB")
+ rule = rbac.findMostSpecificRule(rules, "data.uam.UserInDB")
assert rule is not None
- assert rule.item == "data.system.UserInDB"
+ assert rule.item == "data.uam.UserInDB"
assert rule.read == AccessLevel.MY
# Test generic fallback
@@ -294,7 +294,7 @@ class TestRbacPermissionResolution:
rule1 = AccessRule(
roleLabel="user",
context=AccessRuleContext.DATA,
- item="data.system.UserInDB",
+ item="data.uam.UserInDB",
view=True,
read=AccessLevel.MY,
create=AccessLevel.MY,
@@ -307,7 +307,7 @@ class TestRbacPermissionResolution:
rule2 = AccessRule(
roleLabel="user",
context=AccessRuleContext.DATA,
- item="data.system.UserInDB",
+ item="data.uam.UserInDB",
view=True,
read=AccessLevel.MY,
create=AccessLevel.GROUP, # Not allowed
@@ -320,7 +320,7 @@ class TestRbacPermissionResolution:
rule3 = AccessRule(
roleLabel="admin",
context=AccessRuleContext.DATA,
- item="data.system.UserInDB",
+ item="data.uam.UserInDB",
view=True,
read=AccessLevel.GROUP,
create=AccessLevel.GROUP,
@@ -333,7 +333,7 @@ class TestRbacPermissionResolution:
rule4 = AccessRule(
roleLabel="user",
context=AccessRuleContext.DATA,
- item="data.system.UserInDB",
+ item="data.uam.UserInDB",
view=True,
read=AccessLevel.NONE,
create=AccessLevel.MY, # Not allowed without read