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]: