fixed udb issues

This commit is contained in:
ValueOn AG 2026-04-12 10:12:03 +02:00
parent c30c18fc71
commit 091a3672de
7 changed files with 309 additions and 114 deletions

View file

@ -14,7 +14,7 @@ import mimetypes
from typing import Dict, Any, List, Optional, Union from typing import Dict, Any, List, Optional, Union
from modules.connectors.connectorDbPostgre import DatabaseConnector, _get_cached_connector 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.security.rbac import RbacClass
from modules.datamodels.datamodelRbac import AccessRuleContext from modules.datamodels.datamodelRbac import AccessRuleContext
from modules.datamodels.datamodelUam import AccessLevel from modules.datamodels.datamodelUam import AccessLevel
@ -958,11 +958,13 @@ class ComponentObjects:
def getAllFiles(self, pagination: Optional[PaginationParams] = None) -> Union[List[FileItem], PaginatedResult]: 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: Visibility (via RBAC GROUP / scope-based filtering):
- With mandateId: files of that mandate + files without mandate reference - Own files (sysCreatedBy = currentUser) always visible
- Without mandateId: only files without mandate reference - 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: Args:
pagination: Optional pagination parameters. If None, returns all items. pagination: Optional pagination parameters. If None, returns all items.
@ -971,8 +973,6 @@ class ComponentObjects:
If pagination is None: List[FileItem] If pagination is None: List[FileItem]
If pagination is provided: PaginatedResult with items and metadata If pagination is provided: PaginatedResult with items and metadata
""" """
recordFilter = {"sysCreatedBy": self.userId}
def _convertFileItems(files): def _convertFileItems(files):
fileItems = [] fileItems = []
for file in files: for file in files:
@ -999,48 +999,53 @@ class ComponentObjects:
continue continue
return fileItems return fileItems
def _mandateFilter(files): folderFilter = None
"""Apply mandate scoping: hasFolderFilter = False
- With mandate context: files of that mandate + mandateless files if pagination and pagination.filters and "folderId" in pagination.filters:
- Without mandate context: ALL files (user already scoped by sysCreatedBy)""" folderFilter = pagination.filters.pop("folderId")
if self.mandateId: hasFolderFilter = True
return [f for f in files
if self._isMandatelessFile(f) def _applyFolderFilter(files):
or f.get("mandateId") == self.mandateId] if not hasFolderFilter:
return files 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: if pagination is None:
allFiles = self._getFilesByCurrentUser() allFiles = getRecordsetWithRBAC(
return _convertFileItems(_mandateFilter(allFiles)) self.db, FileItem, self.currentUser,
mandateId=self.mandateId,
# Mandate scoping cannot be expressed as a single recordFilter (OR logic), featureInstanceId=self.featureInstanceId,
# so we get the IDs of allowed files first, then use DB pagination on those. )
allFiles = self._getFilesByCurrentUser() return _convertFileItems(_applyFolderFilter(allFiles))
allowedIds = [f.get("id") for f in _mandateFilter(allFiles) if f.get("id")]
if not allowedIds: result = getRecordsetPaginatedWithRBAC(
return PaginatedResult(items=[], totalItems=0, totalPages=0) self.db, FileItem, self.currentUser,
# DB-level pagination with ID whitelist + original user filter
recordFilter["id"] = allowedIds
result = self.db.getRecordsetPaginated(
FileItem,
pagination=pagination, 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( raw = result if isinstance(result, list) else []
items=items, return _convertFileItems(_applyFolderFilter(raw))
totalItems=result["totalItems"],
totalPages=result["totalPages"]
)
def getFile(self, fileId: str) -> Optional[FileItem]: def getFile(self, fileId: str) -> Optional[FileItem]:
"""Returns a file by ID if it belongs to the current user (user-scoped).""" """Returns a file by ID if the current user has RBAC access (scope-based)."""
# Files are always user-scoped: filter by sysCreatedBy (bypasses RBAC SysAdmin override) filteredFiles = getRecordsetWithRBAC(
filteredFiles = self._getFilesByCurrentUser(recordFilter={"id": fileId}) self.db, FileItem, self.currentUser,
recordFilter={"id": fileId},
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId,
)
if not filteredFiles: if not filteredFiles:
return None return None
@ -1049,18 +1054,14 @@ class ComponentObjects:
try: try:
sysCreatedAt = file.get("sysCreatedAt") sysCreatedAt = file.get("sysCreatedAt")
if sysCreatedAt is None or not isinstance(sysCreatedAt, (int, float)) or sysCreatedAt <= 0: if sysCreatedAt is None or not isinstance(sysCreatedAt, (int, float)) or sysCreatedAt <= 0:
sysCreatedAt = getUtcTimestamp() file["sysCreatedAt"] = getUtcTimestamp()
return FileItem( if file.get("scope") is None:
id=file.get("id"), file["scope"] = "personal"
mandateId=file.get("mandateId"), if file.get("neutralize") is None:
featureInstanceId=file.get("featureInstanceId", ""), file["neutralize"] = False
fileName=file.get("fileName"),
mimeType=file.get("mimeType"), return FileItem(**file)
fileHash=file.get("fileHash"),
fileSize=file.get("fileSize"),
sysCreatedAt=sysCreatedAt,
)
except Exception as e: except Exception as e:
logger.error(f"Error converting file record: {str(e)}") logger.error(f"Error converting file record: {str(e)}")
return None return None
@ -1139,7 +1140,8 @@ class ComponentObjects:
fileName=uniqueName, fileName=uniqueName,
mimeType=mimeType, mimeType=mimeType,
fileSize=fileSize, fileSize=fileSize,
fileHash=fileHash fileHash=fileHash,
folderId="",
) )
# Store in database # Store in database
@ -1147,15 +1149,36 @@ class ComponentObjects:
return fileItem 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]: def updateFile(self, fileId: str, updateData: Dict[str, Any]) -> Dict[str, Any]:
"""Updates file metadata if user has access.""" """Updates file metadata if user has access and is owner (or has ALL)."""
# Check if the file exists and user has access
file = self.getFile(fileId) file = self.getFile(fileId)
if not file: if not file:
raise FileNotFoundError(f"File with ID {fileId} not found") raise FileNotFoundError(f"File with ID {fileId} not found")
if not self.checkRbacPermission(FileItem, "update", fileId): self._requireFileWriteAccess(file, fileId, "update")
raise PermissionError(f"No permission to update file {fileId}")
# If fileName is being updated, ensure it's unique # If fileName is being updated, ensure it's unique
if "fileName" in updateData: if "fileName" in updateData:
@ -1168,16 +1191,14 @@ class ComponentObjects:
return success return success
def deleteFile(self, fileId: str) -> bool: 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: try:
# Check if the file exists and user has access
file = self.getFile(fileId) file = self.getFile(fileId)
if not file: if not file:
raise FileNotFoundError(f"File with ID {fileId} not found") raise FileNotFoundError(f"File with ID {fileId} not found")
if not self.checkRbacPermission(FileItem, "update", fileId): self._requireFileWriteAccess(file, fileId, "delete")
raise PermissionError(f"No permission to delete file {fileId}")
# Check for other references to this file (by hash) - user-scoped check # Check for other references to this file (by hash) - user-scoped check
fileHash = file.fileHash fileHash = file.fileHash
@ -1211,7 +1232,8 @@ class ComponentObjects:
raise FileDeletionError(f"Error deleting file: {str(e)}") raise FileDeletionError(f"Error deleting file: {str(e)}")
def deleteFilesBatch(self, fileIds: List[str]) -> Dict[str, Any]: 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] uniqueIds = [str(fid) for fid in dict.fromkeys(fileIds or []) if fid]
if not uniqueIds: if not uniqueIds:
return {"deletedFiles": 0} return {"deletedFiles": 0}
@ -1220,20 +1242,21 @@ class ComponentObjects:
self.db._ensure_connection() self.db._ensure_connection()
with self.db.connection.cursor() as cursor: with self.db.connection.cursor() as cursor:
cursor.execute( cursor.execute(
'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s', 'SELECT "id", "sysCreatedBy" FROM "FileItem" WHERE "id" = ANY(%s)',
(uniqueIds, self.userId or ""), (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): for row in rows:
missingIds = sorted(set(uniqueIds) - set(accessibleIds)) self._requireFileWriteAccess(row, row["id"], "delete")
raise FileNotFoundError(f"Files not found or not accessible: {missingIds}")
accessibleIds = [row["id"] for row in rows]
cursor.execute('DELETE FROM "FileData" WHERE "id" = ANY(%s)', (accessibleIds,)) cursor.execute('DELETE FROM "FileData" WHERE "id" = ANY(%s)', (accessibleIds,))
cursor.execute( cursor.execute('DELETE FROM "FileItem" WHERE "id" = ANY(%s)', (accessibleIds,))
'DELETE FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(accessibleIds, self.userId or ""),
)
deletedFiles = cursor.rowcount deletedFiles = cursor.rowcount
self.db.connection.commit() self.db.connection.commit()
@ -1274,6 +1297,40 @@ class ComponentObjects:
currentId = folders[0].get("parentId") currentId = folders[0].get("parentId")
return False 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]]: def getFolder(self, folderId: str) -> Optional[Dict[str, Any]]:
"""Returns a folder by ID if it belongs to the current user.""" """Returns a folder by ID if it belongs to the current user."""
folders = self.db.getRecordset(FileFolder, recordFilter={"id": folderId, "sysCreatedBy": self.userId or ""}) 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]]: def listFolders(self, parentId: Optional[str] = None) -> List[Dict[str, Any]]:
"""List folders for current user, optionally filtered by parentId. """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 ""} recordFilter = {"sysCreatedBy": self.userId or ""}
if parentId is not None: if parentId is not None:
recordFilter["parentId"] = parentId recordFilter["parentId"] = parentId
@ -1293,23 +1351,32 @@ class ComponentObjects:
folderIds = [f["id"] for f in folders if f.get("id")] folderIds = [f["id"] for f in folders if f.get("id")]
fileCounts: Dict[str, int] = {} fileCounts: Dict[str, int] = {}
try: try:
mandateClause = "" # Count files per folder that the user can see (RBAC scope-aware).
mandateValues: list = [] # Own files are always counted; shared files (global/mandate/featureInstance)
if self.mandateId: # that happen to be in one of the user's folders are also counted.
mandateClause = ( from modules.interfaces.interfaceRbac import _buildFilesScopeWhereClause
' AND ("mandateId" = %s OR "mandateId" IS NULL OR "mandateId" = \'\')' from modules.datamodels.datamodelUam import User as UserModel
) scopeClause = _buildFilesScopeWhereClause(
mandateValues = [self.mandateId] self.currentUser, "FileItem", self.db,
self.mandateId, self.featureInstanceId,
[], [],
)
self.db._ensure_connection()
with self.db.connection.cursor() as cursor: with self.db.connection.cursor() as cursor:
cursor.execute( baseQuery = (
'SELECT "folderId", COUNT(*) AS cnt ' 'SELECT "folderId", COUNT(*) AS cnt '
'FROM "FileItem" ' 'FROM "FileItem" '
'WHERE "sysCreatedBy" = %s AND "folderId" = ANY(%s)' 'WHERE "folderId" = ANY(%s)'
+ mandateClause +
' GROUP BY "folderId"',
[self.userId or "", folderIds] + mandateValues,
) )
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(): for row in cursor.fetchall():
fileCounts[row["folderId"]] = row["cnt"] fileCounts[row["folderId"]] = row["cnt"]
except Exception as e: except Exception as e:
@ -1350,7 +1417,8 @@ class ComponentObjects:
return self.db.recordModify(FileFolder, folderId, {"parentId": targetParentId}) return self.db.recordModify(FileFolder, folderId, {"parentId": targetParentId})
def moveFilesBatch(self, fileIds: List[str], targetFolderId: Optional[str] = None) -> Dict[str, Any]: 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] uniqueIds = [str(fid) for fid in dict.fromkeys(fileIds or []) if fid]
if not uniqueIds: if not uniqueIds:
return {"movedFiles": 0} return {"movedFiles": 0}
@ -1364,18 +1432,23 @@ class ComponentObjects:
self.db._ensure_connection() self.db._ensure_connection()
with self.db.connection.cursor() as cursor: with self.db.connection.cursor() as cursor:
cursor.execute( cursor.execute(
'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s', 'SELECT "id", "sysCreatedBy" FROM "FileItem" WHERE "id" = ANY(%s)',
(uniqueIds, self.userId or ""), (uniqueIds,),
) )
accessibleIds = [row["id"] for row in cursor.fetchall()] rows = cursor.fetchall()
if len(accessibleIds) != len(uniqueIds): foundIds = {row["id"] for row in rows}
missingIds = sorted(set(uniqueIds) - set(accessibleIds)) missing = sorted(set(uniqueIds) - foundIds)
raise FileNotFoundError(f"Files not found or not accessible: {missingIds}") 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( cursor.execute(
'UPDATE "FileItem" SET "folderId" = %s, "sysModifiedAt" = %s, "sysModifiedBy" = %s ' 'UPDATE "FileItem" SET "folderId" = %s, "sysModifiedAt" = %s, "sysModifiedBy" = %s '
'WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s', 'WHERE "id" = ANY(%s)',
(targetFolderId, getUtcTimestamp(), self.userId or "", accessibleIds, self.userId or ""), (targetFolderId, getUtcTimestamp(), self.userId or "", accessibleIds),
) )
movedFiles = cursor.rowcount movedFiles = cursor.rowcount

View file

@ -17,7 +17,8 @@ Data Namespace Structure:
GROUP-Berechtigung: GROUP-Berechtigung:
- data.uam.*: GROUP filtert nach Mandant (via UserMandate) - 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 - data.feature.*: GROUP filtert nach mandateId/featureInstanceId
""" """
@ -93,7 +94,8 @@ TABLE_NAMESPACE = {
} }
# Namespaces ohne Mandantenkontext - GROUP wird auf MY gemappt # 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: def buildDataObjectKey(tableName: str, featureCode: Optional[str] = None) -> str:
@ -612,6 +614,82 @@ def getDistinctColumnValuesWithRBAC(
return [] 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( def buildRbacWhereClause(
permissions: UserPermissions, permissions: UserPermissions,
currentUser: User, currentUser: User,
@ -648,12 +726,15 @@ def buildRbacWhereClause(
return {"condition": "1 = 0", "values": []} return {"condition": "1 = 0", "values": []}
# CRITICAL: featureInstanceId filter is ALWAYS required when provided # 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 = [] baseConditions = []
baseValues = [] baseValues = []
if featureInstanceId: namespace = TABLE_NAMESPACE.get(table, "system")
# Strict filter: only records for this exact feature instance if featureInstanceId and namespace != "files":
baseConditions.append('"featureInstanceId" = %s') baseConditions.append('"featureInstanceId" = %s')
baseValues.append(featureInstanceId) baseValues.append(featureInstanceId)
@ -703,7 +784,19 @@ def buildRbacWhereClause(
# Determine namespace for this table # Determine namespace for this table
namespace = TABLE_NAMESPACE.get(table, "system") 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 # GROUP has no meaning - these tables have no mandate context
# But still apply featureInstanceId filter if provided # But still apply featureInstanceId filter if provided
if namespace in USER_OWNED_NAMESPACES: if namespace in USER_OWNED_NAMESPACES:

View file

@ -866,9 +866,13 @@ def update_file(
) -> FileItem: ) -> FileItem:
"""Update file info""" """Update file info"""
try: 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) managementInterface = interfaceDbManagement.getInterface(currentUser)
# Get the file from the database
file = managementInterface.getFile(fileId) file = managementInterface.getFile(fileId)
if not file: if not file:
raise HTTPException( raise HTTPException(
@ -876,21 +880,19 @@ def update_file(
detail=f"File with ID {fileId} not found" 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Only sysadmins can set global scope"), 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): if not managementInterface.checkRbacPermission(FileItem, "update", fileId):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Not authorized to update this file") detail=routeApiMsg("Not authorized to update this file")
) )
# Update the file result = managementInterface.updateFile(fileId, safeData)
result = managementInterface.updateFile(fileId, file_info)
if not result: if not result:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,

View file

@ -86,12 +86,10 @@ class RbacClass:
# NOTE: sysadmin ROLE users get full access via AccessRules (DATA: ALL) # NOTE: sysadmin ROLE users get full access via AccessRules (DATA: ALL)
# This flag bypass is kept as fallback for true system-level operations # This flag bypass is kept as fallback for true system-level operations
if hasattr(user, 'isSysAdmin') and user.isSysAdmin: if hasattr(user, 'isSysAdmin') and user.isSysAdmin:
# User-owned namespaces: SysAdmin gets MY access only (own data). # Chat namespace: SysAdmin gets MY access only (own data).
# Every user -- including SysAdmin -- only has CRUD for their own # Files namespace: SysAdmin gets ALL (can manage all files in system).
# chat workflows and files. Automation is excluded because it's _CHAT_PREFIXES = ("data.chat.",)
# managed by admins and the system event user needs ALL access. if item and any(item.startswith(p) for p in _CHAT_PREFIXES):
_USER_OWNED_PREFIXES = ("data.chat.", "data.files.")
if item and any(item.startswith(p) for p in _USER_OWNED_PREFIXES):
return UserPermissions( return UserPermissions(
view=True, view=True,
read=AccessLevel.MY, read=AccessLevel.MY,

View file

@ -5,6 +5,8 @@
import logging import logging
from typing import Optional from typing import Optional
logger = logging.getLogger(__name__)
_MAX_TOOL_RESULT_CHARS = 50_000 _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") _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 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]: def _getOrCreateTempFolder(chatService) -> Optional[str]:
"""Return the ID of the root-level 'Temp' folder, creating it if it doesn't exist.""" """Return the ID of the root-level 'Temp' folder, creating it if it doesn't exist."""
try: try:

View file

@ -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.toolRegistry import ToolRegistry
from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( from modules.serviceCenter.services.serviceAgent.coreTools._helpers import (
_getOrCreateInstanceFolder,
_getOrCreateTempFolder, _getOrCreateTempFolder,
_looksLikeBinary, _looksLikeBinary,
_resolveFileScope, _resolveFileScope,
@ -421,6 +422,10 @@ def _registerWorkspaceTools(registry: ToolRegistry, services):
dbMgmt.updateFile(fileItem.id, {"featureInstanceId": fiId}) dbMgmt.updateFile(fileItem.id, {"featureInstanceId": fiId})
if args.get("folderId"): if args.get("folderId"):
dbMgmt.updateFile(fileItem.id, {"folderId": args["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"): if args.get("tags"):
dbMgmt.updateFile(fileItem.id, {"tags": args["tags"]}) dbMgmt.updateFile(fileItem.id, {"tags": args["tags"]})
return ToolResult( return ToolResult(

View file

@ -63,15 +63,22 @@ def getModelLabels(modelName: str) -> Dict[str, str]:
def _resolveOptionLabels(options): 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): if not isinstance(options, list):
return options return options
import copy
from modules.shared.i18nRegistry import resolveText 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: if not isinstance(opt, dict) or "label" not in opt:
continue continue
opt["label"] = resolveText(opt["label"]) opt["label"] = resolveText(opt["label"])
return options return resolved
def _mergedAttributeLabels(modelClass: Type[BaseModel]) -> Dict[str, str]: def _mergedAttributeLabels(modelClass: Type[BaseModel]) -> Dict[str, str]: