diff --git a/api/app/clients/tools/util/fileSearch.js b/api/app/clients/tools/util/fileSearch.js index 27686e0480..e937d6b9a8 100644 --- a/api/app/clients/tools/util/fileSearch.js +++ b/api/app/clients/tools/util/fileSearch.js @@ -3,6 +3,7 @@ const axios = require('axios'); const { tool } = require('@langchain/core/tools'); const { logger } = require('@librechat/data-schemas'); const { Tools, EToolResources } = require('librechat-data-provider'); +const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); const { generateShortLivedToken } = require('~/server/services/AuthService'); const { getFiles } = require('~/models/File'); @@ -22,14 +23,19 @@ const primeFiles = async (options) => { const file_ids = tool_resources?.[EToolResources.file_search]?.file_ids ?? []; const agentResourceIds = new Set(file_ids); const resourceFiles = tool_resources?.[EToolResources.file_search]?.files ?? []; - const dbFiles = ( - (await getFiles( - { file_id: { $in: file_ids } }, - null, - { text: 0 }, - { userId: req?.user?.id, agentId }, - )) ?? [] - ).concat(resourceFiles); + + // Get all files first + const allFiles = (await getFiles({ file_id: { $in: file_ids } }, null, { text: 0 })) ?? []; + + // Filter by access if user and agent are provided + let dbFiles; + if (req?.user?.id && agentId) { + dbFiles = await filterFilesByAgentAccess(allFiles, req.user.id, agentId); + } else { + dbFiles = allFiles; + } + + dbFiles = dbFiles.concat(resourceFiles); let toolContext = `- Note: Semantic search is available through the ${Tools.file_search} tool but no files are currently loaded. Request the user to upload documents to search through.`; diff --git a/api/models/File.js b/api/models/File.js index 1703e5bba0..1ee943131d 100644 --- a/api/models/File.js +++ b/api/models/File.js @@ -1,7 +1,5 @@ const { logger } = require('@librechat/data-schemas'); -const { EToolResources, FileContext, PERMISSION_BITS } = require('librechat-data-provider'); -const { checkPermission } = require('~/server/services/PermissionService'); -const { getAgent } = require('./Agent'); +const { EToolResources, FileContext } = require('librechat-data-provider'); const { File } = require('~/db/models'); /** @@ -14,131 +12,17 @@ const findFileById = async (file_id, options = {}) => { return await File.findOne({ file_id, ...options }).lean(); }; -/** - * Checks if a user has access to multiple files through a shared agent (batch operation) - * @param {string} userId - The user ID to check access for - * @param {string[]} fileIds - Array of file IDs to check - * @param {string} agentId - The agent ID that might grant access - * @returns {Promise>} Map of fileId to access status - */ -const hasAccessToFilesViaAgent = async (userId, fileIds, agentId, checkCollaborative = true) => { - const accessMap = new Map(); - - // Initialize all files as no access - fileIds.forEach((fileId) => accessMap.set(fileId, false)); - - try { - const agent = await getAgent({ id: agentId }); - - if (!agent) { - return accessMap; - } - - // Check if user is the author - if so, grant access to all files - if (agent.author.toString() === userId) { - fileIds.forEach((fileId) => accessMap.set(fileId, true)); - return accessMap; - } - - // Check if user has at least VIEW permission on the agent - const hasViewPermission = await checkPermission({ - userId, - resourceType: 'agent', - resourceId: agent._id, - requiredPermission: PERMISSION_BITS.VIEW, - }); - - if (!hasViewPermission) { - return accessMap; - } - - // Check if user has EDIT permission (which would indicate collaborative access) - const hasEditPermission = await checkPermission({ - userId, - resourceType: 'agent', - resourceId: agent._id, - requiredPermission: PERMISSION_BITS.EDIT, - }); - - // If user only has VIEW permission, they can't access files - // Only users with EDIT permission or higher can access agent files - if (!hasEditPermission) { - return accessMap; - } - - // User has edit permissions - check which files are actually attached - const attachedFileIds = new Set(); - if (agent.tool_resources) { - for (const [_resourceType, resource] of Object.entries(agent.tool_resources)) { - if (resource?.file_ids && Array.isArray(resource.file_ids)) { - resource.file_ids.forEach((fileId) => attachedFileIds.add(fileId)); - } - } - } - - // Grant access only to files that are attached to this agent - fileIds.forEach((fileId) => { - if (attachedFileIds.has(fileId)) { - accessMap.set(fileId, true); - } - }); - - return accessMap; - } catch (error) { - logger.error('[hasAccessToFilesViaAgent] Error checking file access:', error); - return accessMap; - } -}; - /** * Retrieves files matching a given filter, sorted by the most recently updated. * @param {Object} filter - The filter criteria to apply. * @param {Object} [_sortOptions] - Optional sort parameters. * @param {Object|String} [selectFields={ text: 0 }] - Fields to include/exclude in the query results. * Default excludes the 'text' field. - * @param {Object} [options] - Additional options - * @param {string} [options.userId] - User ID for access control - * @param {string} [options.agentId] - Agent ID that might grant access to files * @returns {Promise>} A promise that resolves to an array of file documents. */ -const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }, options = {}) => { +const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => { const sortOptions = { updatedAt: -1, ..._sortOptions }; - const files = await File.find(filter).select(selectFields).sort(sortOptions).lean(); - - // If userId and agentId are provided, filter files based on access - if (options.userId && options.agentId) { - // Collect file IDs that need access check - const filesToCheck = []; - const ownedFiles = []; - - for (const file of files) { - if (file.user && file.user.toString() === options.userId) { - ownedFiles.push(file); - } else { - filesToCheck.push(file); - } - } - - if (filesToCheck.length === 0) { - return ownedFiles; - } - - // Batch check access for all non-owned files - const fileIds = filesToCheck.map((f) => f.file_id); - const accessMap = await hasAccessToFilesViaAgent( - options.userId, - fileIds, - options.agentId, - false, - ); - - // Filter files based on access - const accessibleFiles = filesToCheck.filter((file) => accessMap.get(file.file_id)); - - return [...ownedFiles, ...accessibleFiles]; - } - - return files; + return await File.find(filter).select(selectFields).sort(sortOptions).lean(); }; /** @@ -292,5 +176,4 @@ module.exports = { deleteFiles, deleteFileByFilter, batchUpdateFiles, - hasAccessToFilesViaAgent, }; diff --git a/api/models/File.spec.js b/api/models/File.spec.js index 62388dbef6..7eabc37553 100644 --- a/api/models/File.spec.js +++ b/api/models/File.spec.js @@ -125,7 +125,7 @@ describe('File Access Control', () => { }); // Check access for all files - const { hasAccessToFilesViaAgent } = require('./File'); + const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions'); const accessMap = await hasAccessToFilesViaAgent(userId.toString(), fileIds, agentId); // Should have access only to the first two files @@ -163,7 +163,7 @@ describe('File Access Control', () => { }); // Check access as the author - const { hasAccessToFilesViaAgent } = require('./File'); + const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions'); const accessMap = await hasAccessToFilesViaAgent(authorId.toString(), fileIds, agentId); // Author should have access to all files @@ -184,7 +184,7 @@ describe('File Access Control', () => { provider: 'local', }); - const { hasAccessToFilesViaAgent } = require('./File'); + const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions'); const accessMap = await hasAccessToFilesViaAgent( userId.toString(), fileIds, @@ -242,7 +242,7 @@ describe('File Access Control', () => { }); // Check access for files - const { hasAccessToFilesViaAgent } = require('./File'); + const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions'); const accessMap = await hasAccessToFilesViaAgent(userId.toString(), fileIds, agentId); // Should have no access to any files when only VIEW permission @@ -328,14 +328,17 @@ describe('File Access Control', () => { bytes: 300, }); - // Get files with access control - const files = await getFiles( + // Get all files first + const allFiles = await getFiles( { file_id: { $in: [ownedFileId, sharedFileId, inaccessibleFileId] } }, null, { text: 0 }, - { userId: userId.toString(), agentId }, ); + // Then filter by access control + const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); + const files = await filterFilesByAgentAccess(allFiles, userId.toString(), agentId); + expect(files).toHaveLength(2); expect(files.map((f) => f.file_id)).toContain(ownedFileId); expect(files.map((f) => f.file_id)).toContain(sharedFileId); diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index 5b92abcbe9..c95257e6f6 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -17,12 +17,13 @@ const { processDeleteRequest, processAgentFileUpload, } = require('~/server/services/Files/process'); -const { getFiles, batchUpdateFiles, hasAccessToFilesViaAgent } = require('~/models/File'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getOpenAIClient } = require('~/server/controllers/assistants/helpers'); const { checkPermission } = require('~/server/services/PermissionService'); const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud'); +const { hasAccessToFilesViaAgent } = require('~/server/services/Files'); +const { getFiles, batchUpdateFiles } = require('~/models/File'); const { getAssistant } = require('~/models/Assistant'); const { getAgent } = require('~/models/Agent'); const { getLogStores } = require('~/cache'); diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 22947379c5..3ad478f1fd 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -11,6 +11,7 @@ const { imageExtRegex, EToolResources, } = require('librechat-data-provider'); +const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { convertImage } = require('~/server/services/Files/images/convert'); const { createFile, getFiles, updateFile } = require('~/models/File'); @@ -164,14 +165,19 @@ const primeFiles = async (options, apiKey) => { const file_ids = tool_resources?.[EToolResources.execute_code]?.file_ids ?? []; const agentResourceIds = new Set(file_ids); const resourceFiles = tool_resources?.[EToolResources.execute_code]?.files ?? []; - const dbFiles = ( - (await getFiles( - { file_id: { $in: file_ids } }, - null, - { text: 0 }, - { userId: req?.user?.id, agentId }, - )) ?? [] - ).concat(resourceFiles); + + // Get all files first + const allFiles = (await getFiles({ file_id: { $in: file_ids } }, null, { text: 0 })) ?? []; + + // Filter by access if user and agent are provided + let dbFiles; + if (req?.user?.id && agentId) { + dbFiles = await filterFilesByAgentAccess(allFiles, req.user.id, agentId); + } else { + dbFiles = allFiles; + } + + dbFiles = dbFiles.concat(resourceFiles); const files = []; const sessions = new Map(); diff --git a/api/server/services/Files/index.js b/api/server/services/Files/index.js new file mode 100644 index 0000000000..872e8a0e81 --- /dev/null +++ b/api/server/services/Files/index.js @@ -0,0 +1,12 @@ +const { processCodeFile } = require('./Code/process'); +const { processFileUpload } = require('./process'); +const { uploadImageBuffer } = require('./images'); +const { hasAccessToFilesViaAgent, filterFilesByAgentAccess } = require('./permissions'); + +module.exports = { + processCodeFile, + processFileUpload, + uploadImageBuffer, + hasAccessToFilesViaAgent, + filterFilesByAgentAccess, +}; diff --git a/api/server/services/Files/permissions.js b/api/server/services/Files/permissions.js new file mode 100644 index 0000000000..f71a707cab --- /dev/null +++ b/api/server/services/Files/permissions.js @@ -0,0 +1,123 @@ +const { logger } = require('@librechat/data-schemas'); +const { PERMISSION_BITS } = require('librechat-data-provider'); +const { checkPermission } = require('~/server/services/PermissionService'); +const { getAgent } = require('~/models/Agent'); + +/** + * Checks if a user has access to multiple files through a shared agent (batch operation) + * @param {string} userId - The user ID to check access for + * @param {string[]} fileIds - Array of file IDs to check + * @param {string} agentId - The agent ID that might grant access + * @returns {Promise>} Map of fileId to access status + */ +const hasAccessToFilesViaAgent = async (userId, fileIds, agentId) => { + const accessMap = new Map(); + + // Initialize all files as no access + fileIds.forEach((fileId) => accessMap.set(fileId, false)); + + try { + const agent = await getAgent({ id: agentId }); + + if (!agent) { + return accessMap; + } + + // Check if user is the author - if so, grant access to all files + if (agent.author.toString() === userId) { + fileIds.forEach((fileId) => accessMap.set(fileId, true)); + return accessMap; + } + + // Check if user has at least VIEW permission on the agent + const hasViewPermission = await checkPermission({ + userId, + resourceType: 'agent', + resourceId: agent._id, + requiredPermission: PERMISSION_BITS.VIEW, + }); + + if (!hasViewPermission) { + return accessMap; + } + + // Check if user has EDIT permission (which would indicate collaborative access) + const hasEditPermission = await checkPermission({ + userId, + resourceType: 'agent', + resourceId: agent._id, + requiredPermission: PERMISSION_BITS.EDIT, + }); + + // If user only has VIEW permission, they can't access files + // Only users with EDIT permission or higher can access agent files + if (!hasEditPermission) { + return accessMap; + } + + // User has edit permissions - check which files are actually attached + const attachedFileIds = new Set(); + if (agent.tool_resources) { + for (const [_resourceType, resource] of Object.entries(agent.tool_resources)) { + if (resource?.file_ids && Array.isArray(resource.file_ids)) { + resource.file_ids.forEach((fileId) => attachedFileIds.add(fileId)); + } + } + } + + // Grant access only to files that are attached to this agent + fileIds.forEach((fileId) => { + if (attachedFileIds.has(fileId)) { + accessMap.set(fileId, true); + } + }); + + return accessMap; + } catch (error) { + logger.error('[hasAccessToFilesViaAgent] Error checking file access:', error); + return accessMap; + } +}; + +/** + * Filter files based on user access through agents + * @param {Array} files - Array of file documents + * @param {string} userId - User ID for access control + * @param {string} agentId - Agent ID that might grant access to files + * @returns {Promise>} Filtered array of accessible files + */ +const filterFilesByAgentAccess = async (files, userId, agentId) => { + if (!userId || !agentId || !files || files.length === 0) { + return files; + } + + // Separate owned files from files that need access check + const filesToCheck = []; + const ownedFiles = []; + + for (const file of files) { + if (file.user && file.user.toString() === userId) { + ownedFiles.push(file); + } else { + filesToCheck.push(file); + } + } + + if (filesToCheck.length === 0) { + return ownedFiles; + } + + // Batch check access for all non-owned files + const fileIds = filesToCheck.map((f) => f.file_id); + const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId); + + // Filter files based on access + const accessibleFiles = filesToCheck.filter((file) => accessMap.get(file.file_id)); + + return [...ownedFiles, ...accessibleFiles]; +}; + +module.exports = { + hasAccessToFilesViaAgent, + filterFilesByAgentAccess, +};