From 091a3672dea3bd7d8a8ded298417edc147343874 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 12 Apr 2026 10:12:03 +0200 Subject: [PATCH] fixed udb issues --- modules/interfaces/interfaceDbManagement.py | 261 +++++++++++------- modules/interfaces/interfaceRbac.py | 105 ++++++- modules/routes/routeDataFiles.py | 12 +- modules/security/rbac.py | 10 +- .../serviceAgent/coreTools/_helpers.py | 17 ++ .../serviceAgent/coreTools/_workspaceTools.py | 5 + modules/shared/attributeUtils.py | 13 +- 7 files changed, 309 insertions(+), 114 deletions(-) diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index c9bafe19..085be891 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -14,7 +14,7 @@ import mimetypes from typing import Dict, Any, List, Optional, Union from modules.connectors.connectorDbPostgre import DatabaseConnector, _get_cached_connector -from modules.interfaces.interfaceRbac import getRecordsetWithRBAC +from modules.interfaces.interfaceRbac import getRecordsetWithRBAC, getRecordsetPaginatedWithRBAC from modules.security.rbac import RbacClass from modules.datamodels.datamodelRbac import AccessRuleContext from modules.datamodels.datamodelUam import AccessLevel @@ -958,11 +958,13 @@ class ComponentObjects: def getAllFiles(self, pagination: Optional[PaginationParams] = None) -> Union[List[FileItem], PaginatedResult]: """ - Returns files owned by the current user (user-scoped, not RBAC-based). + Returns files visible to the current user based on RBAC scope rules. - Mandate scoping: - - With mandateId: files of that mandate + files without mandate reference - - Without mandateId: only files without mandate reference + Visibility (via RBAC GROUP / scope-based filtering): + - Own files (sysCreatedBy = currentUser) always visible + - Files with scope='global' visible to everyone + - Files with scope='mandate' visible to members of that mandate + - Files with scope='featureInstance' visible to users with access to that instance Args: pagination: Optional pagination parameters. If None, returns all items. @@ -971,8 +973,6 @@ class ComponentObjects: If pagination is None: List[FileItem] If pagination is provided: PaginatedResult with items and metadata """ - recordFilter = {"sysCreatedBy": self.userId} - def _convertFileItems(files): fileItems = [] for file in files: @@ -999,48 +999,53 @@ class ComponentObjects: continue return fileItems - def _mandateFilter(files): - """Apply mandate scoping: - - With mandate context: files of that mandate + mandateless files - - Without mandate context: ALL files (user already scoped by sysCreatedBy)""" - if self.mandateId: - return [f for f in files - if self._isMandatelessFile(f) - or f.get("mandateId") == self.mandateId] - return files - + folderFilter = None + hasFolderFilter = False + if pagination and pagination.filters and "folderId" in pagination.filters: + folderFilter = pagination.filters.pop("folderId") + hasFolderFilter = True + + def _applyFolderFilter(files): + if not hasFolderFilter: + return files + if folderFilter is None: + return [f for f in files if not (f.get("folderId") if isinstance(f, dict) else getattr(f, "folderId", None))] + return [f for f in files if (f.get("folderId") if isinstance(f, dict) else getattr(f, "folderId", None)) == folderFilter] + if pagination is None: - allFiles = self._getFilesByCurrentUser() - return _convertFileItems(_mandateFilter(allFiles)) - - # Mandate scoping cannot be expressed as a single recordFilter (OR logic), - # so we get the IDs of allowed files first, then use DB pagination on those. - allFiles = self._getFilesByCurrentUser() - allowedIds = [f.get("id") for f in _mandateFilter(allFiles) if f.get("id")] + allFiles = getRecordsetWithRBAC( + self.db, FileItem, self.currentUser, + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId, + ) + return _convertFileItems(_applyFolderFilter(allFiles)) - if not allowedIds: - return PaginatedResult(items=[], totalItems=0, totalPages=0) - - # DB-level pagination with ID whitelist + original user filter - recordFilter["id"] = allowedIds - result = self.db.getRecordsetPaginated( - FileItem, + result = getRecordsetPaginatedWithRBAC( + self.db, FileItem, self.currentUser, pagination=pagination, - recordFilter=recordFilter, + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId, ) - items = _convertFileItems(result["items"]) + if isinstance(result, PaginatedResult): + filtered = _applyFolderFilter(result.items) + return PaginatedResult( + items=_convertFileItems(filtered), + totalItems=len(filtered), + totalPages=max(1, -(-len(filtered) // (pagination.pageSize or 20))), + ) - return PaginatedResult( - items=items, - totalItems=result["totalItems"], - totalPages=result["totalPages"] - ) + raw = result if isinstance(result, list) else [] + return _convertFileItems(_applyFolderFilter(raw)) def getFile(self, fileId: str) -> Optional[FileItem]: - """Returns a file by ID if it belongs to the current user (user-scoped).""" - # Files are always user-scoped: filter by sysCreatedBy (bypasses RBAC SysAdmin override) - filteredFiles = self._getFilesByCurrentUser(recordFilter={"id": fileId}) + """Returns a file by ID if the current user has RBAC access (scope-based).""" + filteredFiles = getRecordsetWithRBAC( + self.db, FileItem, self.currentUser, + recordFilter={"id": fileId}, + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId, + ) if not filteredFiles: return None @@ -1049,18 +1054,14 @@ class ComponentObjects: try: sysCreatedAt = file.get("sysCreatedAt") if sysCreatedAt is None or not isinstance(sysCreatedAt, (int, float)) or sysCreatedAt <= 0: - sysCreatedAt = getUtcTimestamp() + file["sysCreatedAt"] = getUtcTimestamp() - return FileItem( - id=file.get("id"), - mandateId=file.get("mandateId"), - featureInstanceId=file.get("featureInstanceId", ""), - fileName=file.get("fileName"), - mimeType=file.get("mimeType"), - fileHash=file.get("fileHash"), - fileSize=file.get("fileSize"), - sysCreatedAt=sysCreatedAt, - ) + if file.get("scope") is None: + file["scope"] = "personal" + if file.get("neutralize") is None: + file["neutralize"] = False + + return FileItem(**file) except Exception as e: logger.error(f"Error converting file record: {str(e)}") return None @@ -1139,7 +1140,8 @@ class ComponentObjects: fileName=uniqueName, mimeType=mimeType, fileSize=fileSize, - fileHash=fileHash + fileHash=fileHash, + folderId="", ) # Store in database @@ -1147,15 +1149,36 @@ class ComponentObjects: return fileItem + def _isFileOwner(self, file) -> bool: + """Check if the current user owns the file.""" + createdBy = getattr(file, "sysCreatedBy", None) or (file.get("sysCreatedBy") if isinstance(file, dict) else None) + return createdBy == self.userId + + def _requireFileWriteAccess(self, file, fileId: str, operation: str = "update"): + """Raise PermissionError if the user cannot mutate this file. + Owners always can. Non-owners need RBAC ALL level.""" + if self._isFileOwner(file): + return + from modules.interfaces.interfaceRbac import buildDataObjectKey + from modules.datamodels.datamodelRbac import AccessRuleContext + objectKey = buildDataObjectKey("FileItem") + permissions = self.rbac.getUserPermissions( + self.currentUser, AccessRuleContext.DATA, objectKey, + mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, + ) + level = getattr(permissions, operation, None) + if level != AccessLevel.ALL: + raise PermissionError( + f"No permission to {operation} file {fileId} (not owner, access level: {level})" + ) + def updateFile(self, fileId: str, updateData: Dict[str, Any]) -> Dict[str, Any]: - """Updates file metadata if user has access.""" - # Check if the file exists and user has access + """Updates file metadata if user has access and is owner (or has ALL).""" file = self.getFile(fileId) if not file: raise FileNotFoundError(f"File with ID {fileId} not found") - if not self.checkRbacPermission(FileItem, "update", fileId): - raise PermissionError(f"No permission to update file {fileId}") + self._requireFileWriteAccess(file, fileId, "update") # If fileName is being updated, ensure it's unique if "fileName" in updateData: @@ -1168,16 +1191,14 @@ class ComponentObjects: return success def deleteFile(self, fileId: str) -> bool: - """Deletes a file if user has access.""" + """Deletes a file if user is owner (or has ALL access).""" try: - # Check if the file exists and user has access file = self.getFile(fileId) if not file: raise FileNotFoundError(f"File with ID {fileId} not found") - if not self.checkRbacPermission(FileItem, "update", fileId): - raise PermissionError(f"No permission to delete file {fileId}") + self._requireFileWriteAccess(file, fileId, "delete") # Check for other references to this file (by hash) - user-scoped check fileHash = file.fileHash @@ -1211,7 +1232,8 @@ class ComponentObjects: raise FileDeletionError(f"Error deleting file: {str(e)}") def deleteFilesBatch(self, fileIds: List[str]) -> Dict[str, Any]: - """Delete multiple files in a single SQL batch call.""" + """Delete multiple files in a single SQL batch call. + Owner can always delete; non-owners need RBAC ALL level.""" uniqueIds = [str(fid) for fid in dict.fromkeys(fileIds or []) if fid] if not uniqueIds: return {"deletedFiles": 0} @@ -1220,20 +1242,21 @@ class ComponentObjects: self.db._ensure_connection() with self.db.connection.cursor() as cursor: cursor.execute( - 'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s', - (uniqueIds, self.userId or ""), + 'SELECT "id", "sysCreatedBy" FROM "FileItem" WHERE "id" = ANY(%s)', + (uniqueIds,), ) - accessibleIds = [row["id"] for row in cursor.fetchall()] + rows = cursor.fetchall() + foundIds = {row["id"] for row in rows} + missing = sorted(set(uniqueIds) - foundIds) + if missing: + raise FileNotFoundError(f"Files not found: {missing}") - if len(accessibleIds) != len(uniqueIds): - missingIds = sorted(set(uniqueIds) - set(accessibleIds)) - raise FileNotFoundError(f"Files not found or not accessible: {missingIds}") + for row in rows: + self._requireFileWriteAccess(row, row["id"], "delete") + accessibleIds = [row["id"] for row in rows] cursor.execute('DELETE FROM "FileData" WHERE "id" = ANY(%s)', (accessibleIds,)) - cursor.execute( - 'DELETE FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s', - (accessibleIds, self.userId or ""), - ) + cursor.execute('DELETE FROM "FileItem" WHERE "id" = ANY(%s)', (accessibleIds,)) deletedFiles = cursor.rowcount self.db.connection.commit() @@ -1274,6 +1297,40 @@ class ComponentObjects: currentId = folders[0].get("parentId") return False + def _ensureFeatureInstanceFolder(self, featureInstanceId: str, mandateId: str = "") -> Optional[str]: + """Return the folder ID for a feature instance, creating it on first use. + The folder is named after the feature instance label.""" + existing = self.db.getRecordset( + FileFolder, + recordFilter={ + "featureInstanceId": featureInstanceId, + "sysCreatedBy": self.userId or "", + }, + ) + if existing: + return existing[0].get("id") + + # Resolve the instance label for the folder name + folderName = featureInstanceId[:8] + try: + from modules.datamodels.datamodelFeatures import FeatureInstance + from modules.security.rootAccess import getRootDbAppConnector + dbApp = getRootDbAppConnector() + instances = dbApp.getRecordset(FeatureInstance, recordFilter={"id": featureInstanceId}) + if instances: + folderName = instances[0].get("label") or folderName + except Exception as e: + logger.warning(f"Could not resolve feature instance label: {e}") + + folder = FileFolder( + name=folderName, + parentId=None, + mandateId=mandateId, + featureInstanceId=featureInstanceId, + ) + created = self.db.recordCreate(FileFolder, folder) + return created.get("id") if isinstance(created, dict) else getattr(created, "id", None) + def getFolder(self, folderId: str) -> Optional[Dict[str, Any]]: """Returns a folder by ID if it belongs to the current user.""" folders = self.db.getRecordset(FileFolder, recordFilter={"id": folderId, "sysCreatedBy": self.userId or ""}) @@ -1281,7 +1338,8 @@ class ComponentObjects: def listFolders(self, parentId: Optional[str] = None) -> List[Dict[str, Any]]: """List folders for current user, optionally filtered by parentId. - Each folder is enriched with ``fileCount`` (number of direct files).""" + Each folder is enriched with ``fileCount`` (number of direct files + visible to this user via RBAC scope rules).""" recordFilter = {"sysCreatedBy": self.userId or ""} if parentId is not None: recordFilter["parentId"] = parentId @@ -1293,23 +1351,32 @@ class ComponentObjects: folderIds = [f["id"] for f in folders if f.get("id")] fileCounts: Dict[str, int] = {} try: - mandateClause = "" - mandateValues: list = [] - if self.mandateId: - mandateClause = ( - ' AND ("mandateId" = %s OR "mandateId" IS NULL OR "mandateId" = \'\')' - ) - mandateValues = [self.mandateId] + # Count files per folder that the user can see (RBAC scope-aware). + # Own files are always counted; shared files (global/mandate/featureInstance) + # that happen to be in one of the user's folders are also counted. + from modules.interfaces.interfaceRbac import _buildFilesScopeWhereClause + from modules.datamodels.datamodelUam import User as UserModel + scopeClause = _buildFilesScopeWhereClause( + self.currentUser, "FileItem", self.db, + self.mandateId, self.featureInstanceId, + [], [], + ) + self.db._ensure_connection() with self.db.connection.cursor() as cursor: - cursor.execute( + baseQuery = ( 'SELECT "folderId", COUNT(*) AS cnt ' 'FROM "FileItem" ' - 'WHERE "sysCreatedBy" = %s AND "folderId" = ANY(%s)' - + mandateClause + - ' GROUP BY "folderId"', - [self.userId or "", folderIds] + mandateValues, + 'WHERE "folderId" = ANY(%s)' ) + queryValues: list = [folderIds] + + if scopeClause: + baseQuery += ' AND (' + scopeClause["condition"] + ')' + queryValues.extend(scopeClause["values"]) + + baseQuery += ' GROUP BY "folderId"' + cursor.execute(baseQuery, queryValues) for row in cursor.fetchall(): fileCounts[row["folderId"]] = row["cnt"] except Exception as e: @@ -1350,7 +1417,8 @@ class ComponentObjects: return self.db.recordModify(FileFolder, folderId, {"parentId": targetParentId}) def moveFilesBatch(self, fileIds: List[str], targetFolderId: Optional[str] = None) -> Dict[str, Any]: - """Move multiple files with one SQL update.""" + """Move multiple files with one SQL update. + Owner can always move; non-owners need RBAC ALL level.""" uniqueIds = [str(fid) for fid in dict.fromkeys(fileIds or []) if fid] if not uniqueIds: return {"movedFiles": 0} @@ -1364,18 +1432,23 @@ class ComponentObjects: self.db._ensure_connection() with self.db.connection.cursor() as cursor: cursor.execute( - 'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s', - (uniqueIds, self.userId or ""), + 'SELECT "id", "sysCreatedBy" FROM "FileItem" WHERE "id" = ANY(%s)', + (uniqueIds,), ) - accessibleIds = [row["id"] for row in cursor.fetchall()] - if len(accessibleIds) != len(uniqueIds): - missingIds = sorted(set(uniqueIds) - set(accessibleIds)) - raise FileNotFoundError(f"Files not found or not accessible: {missingIds}") + rows = cursor.fetchall() + foundIds = {row["id"] for row in rows} + missing = sorted(set(uniqueIds) - foundIds) + if missing: + raise FileNotFoundError(f"Files not found: {missing}") + for row in rows: + self._requireFileWriteAccess(row, row["id"], "update") + + accessibleIds = [row["id"] for row in rows] cursor.execute( 'UPDATE "FileItem" SET "folderId" = %s, "sysModifiedAt" = %s, "sysModifiedBy" = %s ' - 'WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s', - (targetFolderId, getUtcTimestamp(), self.userId or "", accessibleIds, self.userId or ""), + 'WHERE "id" = ANY(%s)', + (targetFolderId, getUtcTimestamp(), self.userId or "", accessibleIds), ) movedFiles = cursor.rowcount diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py index e93a12a5..885db8f5 100644 --- a/modules/interfaces/interfaceRbac.py +++ b/modules/interfaces/interfaceRbac.py @@ -17,7 +17,8 @@ Data Namespace Structure: GROUP-Berechtigung: - data.uam.*: GROUP filtert nach Mandant (via UserMandate) -- data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen); bei gesetztem featureInstanceId zusätzlich sysCreatedBy +- data.chat.*, data.automation.*: GROUP = MY (benutzer-eigen); bei gesetztem featureInstanceId zusätzlich sysCreatedBy +- data.files.*: GROUP = eigene Files + scope-basierte Sichtbarkeit (global, mandate, featureInstance) - data.feature.*: GROUP filtert nach mandateId/featureInstanceId """ @@ -93,7 +94,8 @@ TABLE_NAMESPACE = { } # Namespaces ohne Mandantenkontext - GROUP wird auf MY gemappt -USER_OWNED_NAMESPACES = {"chat", "chatbot", "files", "automation", "knowledge", "datasource"} +# NOTE: "files" is NOT in this set – files use scope-based visibility for GROUP +USER_OWNED_NAMESPACES = {"chat", "chatbot", "automation", "knowledge", "datasource"} def buildDataObjectKey(tableName: str, featureCode: Optional[str] = None) -> str: @@ -612,6 +614,82 @@ def getDistinctColumnValuesWithRBAC( return [] +def _buildFilesScopeWhereClause( + currentUser: User, + table: str, + connector, + mandateId: Optional[str], + featureInstanceId: Optional[str], + baseConditions: List[str], + baseValues: List, +) -> Optional[Dict[str, Any]]: + """Build WHERE clause for files namespace with scope-based visibility. + + Two modes depending on request context: + + WITHOUT instance/mandate context (Dateien-Seite): + Only own files: sysCreatedBy = currentUser + + WITH instance context (Instanz-Seiten): + - sysCreatedBy = me AND featureInstanceId = X (own personal files of this instance) + - scope = 'featureInstance' AND featureInstanceId = X + - scope = 'mandate' AND mandateId = M (M = mandate of the instance) + - scope = 'global' + """ + conditions = list(baseConditions) + values = list(baseValues) + + # ── No context: Dateien-Seite → only own files ──────────────────────── + if not featureInstanceId and not mandateId: + conditions.append('"sysCreatedBy" = %s') + values.append(currentUser.id) + if conditions: + return {"condition": " AND ".join(conditions), "values": values} + return None + + # ── With context: Instanz-/Mandanten-Seite → scope-based visibility ── + effectiveMandateId = mandateId + if featureInstanceId and not effectiveMandateId: + try: + from modules.datamodels.datamodelFeatures import FeatureInstance + dbApp = getRootDbAppConnector() + instances = dbApp.getRecordset( + FeatureInstance, recordFilter={"id": featureInstanceId}, + ) + if instances: + effectiveMandateId = instances[0].get("mandateId") or "" + except Exception as e: + logger.warning(f"_buildFilesScopeWhereClause: could not resolve mandate for instance {featureInstanceId}: {e}") + + scopeParts: List[str] = [] + scopeValues: List = [] + + if featureInstanceId: + # 1) Own personal files of this specific instance + scopeParts.append('("sysCreatedBy" = %s AND "featureInstanceId" = %s)') + scopeValues.extend([currentUser.id, featureInstanceId]) + + # 2) scope=featureInstance files shared with this instance + scopeParts.append('("scope" = \'featureInstance\' AND "featureInstanceId" = %s)') + scopeValues.append(featureInstanceId) + + # 3) scope=mandate files of the effective mandate + if effectiveMandateId: + scopeParts.append('("scope" = \'mandate\' AND "mandateId" = %s)') + scopeValues.append(effectiveMandateId) + + # 4) scope=global files + scopeParts.append('"scope" = \'global\'') + + if scopeParts: + conditions.append("(" + " OR ".join(scopeParts) + ")") + values.extend(scopeValues) + + if conditions: + return {"condition": " AND ".join(conditions), "values": values} + return None + + def buildRbacWhereClause( permissions: UserPermissions, currentUser: User, @@ -648,12 +726,15 @@ def buildRbacWhereClause( return {"condition": "1 = 0", "values": []} # CRITICAL: featureInstanceId filter is ALWAYS required when provided - # This ensures data isolation between feature instances regardless of access level + # This ensures data isolation between feature instances regardless of access level. + # EXCEPTION: files namespace handles featureInstanceId inside its own scope logic + # because files with scope=global or scope=mandate must remain visible even when + # they belong to a different (or no) featureInstanceId. baseConditions = [] baseValues = [] - if featureInstanceId: - # Strict filter: only records for this exact feature instance + namespace = TABLE_NAMESPACE.get(table, "system") + if featureInstanceId and namespace != "files": baseConditions.append('"featureInstanceId" = %s') baseValues.append(featureInstanceId) @@ -703,7 +784,19 @@ def buildRbacWhereClause( # Determine namespace for this table namespace = TABLE_NAMESPACE.get(table, "system") - # For user-owned namespaces (chat, files, automation): + # ── Files namespace: scope-based visibility ────────────────────── + # GROUP for files = own files + shared files based on scope field: + # - scope='global' → visible to everyone + # - scope='mandate' → visible to users in that mandate + # - scope='featureInstance' → visible to users with access to that instance + # - scope='personal' → only visible to owner (sysCreatedBy) + if namespace == "files": + return _buildFilesScopeWhereClause( + currentUser, table, connector, mandateId, featureInstanceId, + baseConditions, baseValues, + ) + + # For user-owned namespaces (chat, automation): # GROUP has no meaning - these tables have no mandate context # But still apply featureInstanceId filter if provided if namespace in USER_OWNED_NAMESPACES: diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py index 9f3cc9fe..fc9b347e 100644 --- a/modules/routes/routeDataFiles.py +++ b/modules/routes/routeDataFiles.py @@ -866,9 +866,13 @@ def update_file( ) -> FileItem: """Update file info""" try: + _EDITABLE_FIELDS = {"fileName", "scope", "tags", "description", "folderId", "neutralize"} + safeData = {k: v for k, v in file_info.items() if k in _EDITABLE_FIELDS} + if not safeData: + raise HTTPException(status_code=400, detail=routeApiMsg("No editable fields provided")) + managementInterface = interfaceDbManagement.getInterface(currentUser) - # Get the file from the database file = managementInterface.getFile(fileId) if not file: raise HTTPException( @@ -876,21 +880,19 @@ def update_file( detail=f"File with ID {fileId} not found" ) - if file_info.get("scope") == "global" and not _hasSysAdminRole(str(currentUser.id)): + if safeData.get("scope") == "global" and not _hasSysAdminRole(str(currentUser.id)): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Only sysadmins can set global scope"), ) - # Check if user has access to the file using RBAC if not managementInterface.checkRbacPermission(FileItem, "update", fileId): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Not authorized to update this file") ) - # Update the file - result = managementInterface.updateFile(fileId, file_info) + result = managementInterface.updateFile(fileId, safeData) if not result: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/modules/security/rbac.py b/modules/security/rbac.py index 9199e73b..bec0b70e 100644 --- a/modules/security/rbac.py +++ b/modules/security/rbac.py @@ -86,12 +86,10 @@ class RbacClass: # NOTE: sysadmin ROLE users get full access via AccessRules (DATA: ALL) # This flag bypass is kept as fallback for true system-level operations if hasattr(user, 'isSysAdmin') and user.isSysAdmin: - # User-owned namespaces: SysAdmin gets MY access only (own data). - # Every user -- including SysAdmin -- only has CRUD for their own - # chat workflows and files. Automation is excluded because it's - # managed by admins and the system event user needs ALL access. - _USER_OWNED_PREFIXES = ("data.chat.", "data.files.") - if item and any(item.startswith(p) for p in _USER_OWNED_PREFIXES): + # Chat namespace: SysAdmin gets MY access only (own data). + # Files namespace: SysAdmin gets ALL (can manage all files in system). + _CHAT_PREFIXES = ("data.chat.",) + if item and any(item.startswith(p) for p in _CHAT_PREFIXES): return UserPermissions( view=True, read=AccessLevel.MY, diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py b/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py index b0c556f5..6d03f23b 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py @@ -5,6 +5,8 @@ import logging from typing import Optional +logger = logging.getLogger(__name__) + _MAX_TOOL_RESULT_CHARS = 50_000 _BINARY_SIGNATURES = (b"%PDF", b"\x89PNG", b"\xff\xd8\xff", b"GIF8", b"PK\x03\x04", b"Rar!", b"\x1f\x8b") @@ -43,6 +45,21 @@ def _looksLikeBinary(data: bytes, sampleSize: int = 1024) -> bool: return nonPrintable / len(sample) > 0.10 +def _getOrCreateInstanceFolder(chatService, featureInstanceId: str, mandateId: str = "") -> Optional[str]: + """Return the folder ID for a feature instance, creating it on first use. + + Delegates to interfaceDbManagement._ensureFeatureInstanceFolder. + AI tools call this when saving a file without an explicit folderId + so that instance-produced files land in a named folder automatically. + """ + try: + dbMgmt = chatService.interfaceDbComponent + return dbMgmt._ensureFeatureInstanceFolder(featureInstanceId, mandateId) + except Exception as e: + logger.warning(f"Could not get/create instance folder for {featureInstanceId}: {e}") + return None + + def _getOrCreateTempFolder(chatService) -> Optional[str]: """Return the ID of the root-level 'Temp' folder, creating it if it doesn't exist.""" try: diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py index b5a8f49f..a3f17db5 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py @@ -9,6 +9,7 @@ from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResul from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( + _getOrCreateInstanceFolder, _getOrCreateTempFolder, _looksLikeBinary, _resolveFileScope, @@ -421,6 +422,10 @@ def _registerWorkspaceTools(registry: ToolRegistry, services): dbMgmt.updateFile(fileItem.id, {"featureInstanceId": fiId}) if args.get("folderId"): dbMgmt.updateFile(fileItem.id, {"folderId": args["folderId"]}) + elif fiId: + instanceFolderId = _getOrCreateInstanceFolder(chatService, fiId, context.get("mandateId", "")) + if instanceFolderId: + dbMgmt.updateFile(fileItem.id, {"folderId": instanceFolderId}) if args.get("tags"): dbMgmt.updateFile(fileItem.id, {"tags": args["tags"]}) return ToolResult( diff --git a/modules/shared/attributeUtils.py b/modules/shared/attributeUtils.py index 5b7486e9..46333cc0 100644 --- a/modules/shared/attributeUtils.py +++ b/modules/shared/attributeUtils.py @@ -63,15 +63,22 @@ def getModelLabels(modelName: str) -> Dict[str, str]: def _resolveOptionLabels(options): - """Resolve frontend_options label values via resolveText().""" + """Resolve frontend_options label values via resolveText(). + + CRITICAL: deep-copy so the shared json_schema_extra dicts are never mutated. + Without the copy, each request would re-translate the already-translated + label, wrapping it in another layer of ``[…]`` brackets. + """ if not isinstance(options, list): return options + import copy from modules.shared.i18nRegistry import resolveText - for opt in options: + resolved = copy.deepcopy(options) + for opt in resolved: if not isinstance(opt, dict) or "label" not in opt: continue opt["label"] = resolveText(opt["label"]) - return options + return resolved def _mergedAttributeLabels(modelClass: Type[BaseModel]) -> Dict[str, str]: