fixed udb issues
This commit is contained in:
parent
c30c18fc71
commit
091a3672de
7 changed files with 309 additions and 114 deletions
|
|
@ -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]
|
||||
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))
|
||||
allFiles = getRecordsetWithRBAC(
|
||||
self.db, FileItem, self.currentUser,
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId,
|
||||
)
|
||||
return _convertFileItems(_applyFolderFilter(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")]
|
||||
|
||||
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=items,
|
||||
totalItems=result["totalItems"],
|
||||
totalPages=result["totalPages"]
|
||||
items=_convertFileItems(filtered),
|
||||
totalItems=len(filtered),
|
||||
totalPages=max(1, -(-len(filtered) // (pagination.pageSize or 20))),
|
||||
)
|
||||
|
||||
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" = \'\')'
|
||||
# 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,
|
||||
[], [],
|
||||
)
|
||||
mandateValues = [self.mandateId]
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
Loading…
Reference in a new issue