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 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,
|
||||||
|
featureInstanceId=self.featureInstanceId,
|
||||||
|
)
|
||||||
|
return _convertFileItems(_applyFolderFilter(allFiles))
|
||||||
|
|
||||||
# Mandate scoping cannot be expressed as a single recordFilter (OR logic),
|
result = getRecordsetPaginatedWithRBAC(
|
||||||
# so we get the IDs of allowed files first, then use DB pagination on those.
|
self.db, FileItem, self.currentUser,
|
||||||
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,
|
|
||||||
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(
|
return PaginatedResult(
|
||||||
items=items,
|
items=_convertFileItems(filtered),
|
||||||
totalItems=result["totalItems"],
|
totalItems=len(filtered),
|
||||||
totalPages=result["totalPages"]
|
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]:
|
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(
|
||||||
|
self.currentUser, "FileItem", self.db,
|
||||||
|
self.mandateId, self.featureInstanceId,
|
||||||
|
[], [],
|
||||||
)
|
)
|
||||||
mandateValues = [self.mandateId]
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue