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