From eed43e6662f0f858ec35e2e3b9dca8774960f7cc Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 9 Jun 2025 15:48:10 -0400 Subject: [PATCH] feat: Add granular role-based permissions system with Entra ID integration - Implement RBAC with viewer/editor/owner roles using bitwise permissions - Add AccessRole, AclEntry, and Group models for permission management - Create PermissionService for core permission logic and validation - Integrate Microsoft Graph API for Entra ID user/group search - Add middleware for resource access validation with custom ID resolvers - Implement bulk permission updates with transaction support - Create permission management UI with people picker and role selection - Add public sharing capabilities for resources - Include database migration for existing agent ownership - Support hybrid local/Entra ID identity management - Add comprehensive test coverage for all new services chore: Update @librechat/data-schemas to version 0.0.9 and export common module in index.ts fix: Update userGroup tests to mock logger correctly and change principalId expectation from null to undefined --- .env.example | 12 + api/models/Agent.js | 85 +- api/package.json | 1 + .../controllers/PermissionsController.js | 463 ++++++++ api/server/controllers/agents/v1.js | 120 +- api/server/index.js | 2 + .../accessResources/canAccessAgentFromBody.js | 95 ++ .../accessResources/canAccessAgentResource.js | 58 + .../accessResources/canAccessResource.js | 157 +++ .../middleware/accessResources/index.js | 9 + api/server/middleware/index.js | 2 + api/server/routes/accessPermissions.js | 62 + api/server/routes/agents/actions.js | 297 ++--- api/server/routes/agents/chat.js | 6 + api/server/routes/agents/v1.js | 78 +- api/server/routes/index.js | 4 +- api/server/routes/oauth.js | 2 + .../services/AppService.interface.spec.js | 3 + api/server/services/AppService.js | 3 +- api/server/services/AppService.spec.js | 1 + api/server/services/GraphApiService.js | 453 +++++++ api/server/services/GraphApiService.spec.js | 732 ++++++++++++ api/server/services/PermissionService.js | 680 +++++++++++ api/server/services/PermissionService.spec.js | 1058 +++++++++++++++++ api/strategies/openidStrategy.js | 2 + client/src/common/agents-types.ts | 1 + .../SidePanel/Agents/AgentFooter.tsx | 43 +- .../SidePanel/Agents/AgentPanel.tsx | 45 +- .../SidePanel/Agents/ShareAgent.tsx | 272 ----- .../Agents/Sharing/AccessRolesPicker.tsx | 99 ++ .../Agents/Sharing/GrantAccessDialog.tsx | 266 +++++ .../Sharing/ManagePermissionsDialog.tsx | 307 +++++ .../Sharing/PeoplePicker/PeoplePicker.tsx | 101 ++ .../PeoplePicker/PeoplePickerSearchItem.tsx | 57 + .../PeoplePicker/SelectedPrincipalsList.tsx | 149 +++ .../Agents/Sharing/PrincipalAvatar.tsx | 101 ++ .../Agents/Sharing/PublicSharingToggle.tsx | 59 + client/src/components/ui/Dropdown.tsx | 8 +- client/src/components/ui/SearchPicker.tsx | 185 +++ .../src/components/ui/SelectDropDownPop.tsx | 35 +- client/src/data-provider/Agents/mutations.ts | 19 +- client/src/data-provider/Agents/queries.ts | 25 +- client/src/hooks/index.ts | 1 + client/src/hooks/useResourcePermissions.ts | 25 + client/src/locales/de/translation.json | 49 +- client/src/locales/en/translation.json | 46 +- config/migrate-agent-permissions.js | 268 +++++ package-lock.json | 28 + .../data-provider/src/accessPermissions.ts | 292 +++++ packages/data-provider/src/api-endpoints.ts | 26 + packages/data-provider/src/data-service.ts | 41 + packages/data-provider/src/index.ts | 3 + packages/data-provider/src/keys.ts | 4 + .../src/react-query/react-query-service.ts | 104 ++ packages/data-provider/src/schemas.ts | 1 + .../data-provider/src/types/assistants.ts | 2 + packages/data-provider/src/types/graph.ts | 145 +++ packages/data-provider/src/types/queries.ts | 41 + packages/data-schemas/package.json | 1 + packages/data-schemas/src/common/enum.ts | 27 + packages/data-schemas/src/common/index.ts | 1 + packages/data-schemas/src/index.ts | 2 + .../src/methods/accessRole.spec.ts | 312 +++++ .../data-schemas/src/methods/accessRole.ts | 180 +++ .../data-schemas/src/methods/aclEntry.spec.ts | 504 ++++++++ packages/data-schemas/src/methods/aclEntry.ts | 308 +++++ .../data-schemas/src/methods/group.spec.ts | 345 ++++++ packages/data-schemas/src/methods/group.ts | 142 +++ packages/data-schemas/src/methods/index.ts | 13 + packages/data-schemas/src/methods/user.ts | 84 +- .../src/methods/userGroup.spec.ts | 502 ++++++++ .../data-schemas/src/methods/userGroup.ts | 557 +++++++++ .../data-schemas/src/models/accessRole.ts | 11 + packages/data-schemas/src/models/aclEntry.ts | 9 + packages/data-schemas/src/models/group.ts | 9 + packages/data-schemas/src/models/index.ts | 6 + .../data-schemas/src/schema/accessRole.ts | 31 + packages/data-schemas/src/schema/aclEntry.ts | 65 + packages/data-schemas/src/schema/group.ts | 51 + packages/data-schemas/src/schema/user.ts | 5 + packages/data-schemas/src/types/accessRole.ts | 18 + packages/data-schemas/src/types/aclEntry.ts | 29 + packages/data-schemas/src/types/agent.ts | 1 + packages/data-schemas/src/types/group.ts | 23 + packages/data-schemas/src/types/index.ts | 4 + packages/data-schemas/src/types/user.ts | 2 + packages/data-schemas/src/utils/index.ts | 1 + .../data-schemas/src/utils/transactions.ts | 55 + 88 files changed, 9992 insertions(+), 539 deletions(-) create mode 100644 api/server/controllers/PermissionsController.js create mode 100644 api/server/middleware/accessResources/canAccessAgentFromBody.js create mode 100644 api/server/middleware/accessResources/canAccessAgentResource.js create mode 100644 api/server/middleware/accessResources/canAccessResource.js create mode 100644 api/server/middleware/accessResources/index.js create mode 100644 api/server/routes/accessPermissions.js create mode 100644 api/server/services/GraphApiService.js create mode 100644 api/server/services/GraphApiService.spec.js create mode 100644 api/server/services/PermissionService.js create mode 100644 api/server/services/PermissionService.spec.js delete mode 100644 client/src/components/SidePanel/Agents/ShareAgent.tsx create mode 100644 client/src/components/SidePanel/Agents/Sharing/AccessRolesPicker.tsx create mode 100644 client/src/components/SidePanel/Agents/Sharing/GrantAccessDialog.tsx create mode 100644 client/src/components/SidePanel/Agents/Sharing/ManagePermissionsDialog.tsx create mode 100644 client/src/components/SidePanel/Agents/Sharing/PeoplePicker/PeoplePicker.tsx create mode 100644 client/src/components/SidePanel/Agents/Sharing/PeoplePicker/PeoplePickerSearchItem.tsx create mode 100644 client/src/components/SidePanel/Agents/Sharing/PeoplePicker/SelectedPrincipalsList.tsx create mode 100644 client/src/components/SidePanel/Agents/Sharing/PrincipalAvatar.tsx create mode 100644 client/src/components/SidePanel/Agents/Sharing/PublicSharingToggle.tsx create mode 100644 client/src/components/ui/SearchPicker.tsx create mode 100644 client/src/hooks/useResourcePermissions.ts create mode 100644 config/migrate-agent-permissions.js create mode 100644 packages/data-provider/src/accessPermissions.ts create mode 100644 packages/data-provider/src/types/graph.ts create mode 100644 packages/data-schemas/src/common/enum.ts create mode 100644 packages/data-schemas/src/common/index.ts create mode 100644 packages/data-schemas/src/methods/accessRole.spec.ts create mode 100644 packages/data-schemas/src/methods/accessRole.ts create mode 100644 packages/data-schemas/src/methods/aclEntry.spec.ts create mode 100644 packages/data-schemas/src/methods/aclEntry.ts create mode 100644 packages/data-schemas/src/methods/group.spec.ts create mode 100644 packages/data-schemas/src/methods/group.ts create mode 100644 packages/data-schemas/src/methods/userGroup.spec.ts create mode 100644 packages/data-schemas/src/methods/userGroup.ts create mode 100644 packages/data-schemas/src/models/accessRole.ts create mode 100644 packages/data-schemas/src/models/aclEntry.ts create mode 100644 packages/data-schemas/src/models/group.ts create mode 100644 packages/data-schemas/src/schema/accessRole.ts create mode 100644 packages/data-schemas/src/schema/aclEntry.ts create mode 100644 packages/data-schemas/src/schema/group.ts create mode 100644 packages/data-schemas/src/types/accessRole.ts create mode 100644 packages/data-schemas/src/types/aclEntry.ts create mode 100644 packages/data-schemas/src/types/group.ts create mode 100644 packages/data-schemas/src/utils/index.ts create mode 100644 packages/data-schemas/src/utils/transactions.ts diff --git a/.env.example b/.env.example index 876535b345..070cc4b270 100644 --- a/.env.example +++ b/.env.example @@ -485,6 +485,18 @@ SAML_IMAGE_URL= # SAML_USE_AUTHN_RESPONSE_SIGNED= +#===============================================# +# Microsoft Graph API / Entra ID Integration # +#===============================================# + +# Enable Entra ID people search integration in permissions/sharing system +# When enabled, the people picker will search both local database and Entra ID +USE_ENTRA_ID_FOR_PEOPLE_SEARCH=false + +# Microsoft Graph API scopes needed for people/group search +# Default scopes provide access to user profiles and group memberships +OPENID_GRAPH_SCOPES=User.Read,People.Read,GroupMember.Read.All + # LDAP LDAP_URL= LDAP_BIND_DN= diff --git a/api/models/Agent.js b/api/models/Agent.js index d33ca8a8bf..e7921e709c 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -4,7 +4,6 @@ const { logger } = require('@librechat/data-schemas'); const { SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider'); const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } = require('librechat-data-provider').Constants; -const { CONFIG_STORE, STARTUP_CONFIG } = require('librechat-data-provider').CacheKeys; const { getProjectByName, addAgentIdsToProject, @@ -12,7 +11,6 @@ const { removeAgentFromAllProjects, } = require('./Project'); const { getCachedTools } = require('~/server/services/Config'); -const getLogStores = require('~/cache/getLogStores'); const { getActions } = require('./Action'); const { Agent } = require('~/db/models'); @@ -123,29 +121,7 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => { } agent.version = agent.versions ? agent.versions.length : 0; - - if (agent.author.toString() === req.user.id) { - return agent; - } - - if (!agent.projectIds) { - return null; - } - - const cache = getLogStores(CONFIG_STORE); - /** @type {TStartupConfig} */ - const cachedStartupConfig = await cache.get(STARTUP_CONFIG); - let { instanceProjectId } = cachedStartupConfig ?? {}; - if (!instanceProjectId) { - instanceProjectId = (await getProjectByName(GLOBAL_PROJECT_NAME, '_id'))._id.toString(); - } - - for (const projectObjectId of agent.projectIds) { - const projectId = projectObjectId.toString(); - if (projectId === instanceProjectId) { - return agent; - } - } + return agent; }; /** @@ -461,8 +437,63 @@ const deleteAgent = async (searchParameter) => { return agent; }; +/** + * Get agents by accessible IDs (combines ownership and ACL permissions). + * @param {Object} params - The parameters for getting accessible agents. + * @param {string} params.userId - The user ID to get agents for. + * @param {Array} [params.accessibleIds] - Array of agent ObjectIds the user has ACL access to. + * @param {Object} [params.otherParams] - Additional query parameters. + * @returns {Promise} A promise that resolves to an object containing the agents data and pagination info. + */ +const getListAgentsByAccess = async ({ userId, accessibleIds = [], otherParams = {} }) => { + // Build query for owned agents and ACL accessible agents + const queries = [ + // Agents where user is author (owned) + { author: userId, ...otherParams }, + ]; + + // Add ACL accessible agents if any + if (accessibleIds.length > 0) { + queries.push({ _id: { $in: accessibleIds }, ...otherParams }); + } + + const query = queries.length > 1 ? { $or: queries } : queries[0]; + + const agents = ( + await Agent.find(query, { + id: 1, + _id: 1, + name: 1, + avatar: 1, + author: 1, + projectIds: 1, + description: 1, + }).lean() + ).map((agent) => { + if (agent.author?.toString() !== userId) { + delete agent.author; + } + if (agent.author) { + agent.author = agent.author.toString(); + } + return agent; + }); + + const hasMore = agents.length > 0; + const firstId = agents.length > 0 ? agents[0].id : null; + const lastId = agents.length > 0 ? agents[agents.length - 1].id : null; + + return { + data: agents, + has_more: hasMore, + first_id: firstId, + last_id: lastId, + }; +}; + /** * Get all agents. + * @deprecated Use getListAgentsByAccess for ACL-aware agent listing * @param {Object} searchParameter - The search parameters to find matching agents. * @param {string} searchParameter.author - The user ID of the agent's author. * @returns {Promise} A promise that resolves to an object containing the agents data and pagination info. @@ -481,12 +512,13 @@ const getListAgents = async (searchParameter) => { const agents = ( await Agent.find(query, { id: 1, - _id: 0, + _id: 1, name: 1, avatar: 1, author: 1, projectIds: 1, description: 1, + // @deprecated - isCollaborative replaced by ACL permissions isCollaborative: 1, }).lean() ).map((agent) => { @@ -670,6 +702,7 @@ module.exports = { revertAgentVersion, updateAgentProjects, addAgentResourceFile, + getListAgentsByAccess, removeAgentResourceFiles, generateActionMetadataHash, }; diff --git a/api/package.json b/api/package.json index 6633a99c3f..261c4faa43 100644 --- a/api/package.json +++ b/api/package.json @@ -52,6 +52,7 @@ "@librechat/api": "*", "@librechat/data-schemas": "*", "@node-saml/passport-saml": "^5.0.0", + "@microsoft/microsoft-graph-client": "^3.0.7", "@waylaidwanderer/fetch-event-source": "^3.0.1", "axios": "^1.8.2", "bcryptjs": "^2.4.3", diff --git a/api/server/controllers/PermissionsController.js b/api/server/controllers/PermissionsController.js new file mode 100644 index 0000000000..1f8c90c8d9 --- /dev/null +++ b/api/server/controllers/PermissionsController.js @@ -0,0 +1,463 @@ +/** + * @import { TUpdateResourcePermissionsRequest, TUpdateResourcePermissionsResponse } from 'librechat-data-provider' + */ + +const mongoose = require('mongoose'); +const { logger } = require('@librechat/data-schemas'); +const { + getAvailableRoles, + ensurePrincipalExists, + getEffectivePermissions, + ensureGroupPrincipalExists, + bulkUpdateResourcePermissions, +} = require('~/server/services/PermissionService'); +const { AclEntry } = require('~/db/models'); +const { + searchPrincipals: searchLocalPrincipals, + sortPrincipalsByRelevance, + calculateRelevanceScore, +} = require('~/models'); +const { + searchEntraIdPrincipals, + entraIdPrincipalFeatureEnabled, +} = require('~/server/services/GraphApiService'); + +/** + * Generic controller for resource permission endpoints + * Delegates validation and logic to PermissionService + */ + +/** + * Bulk update permissions for a resource (grant, update, remove) + * @route PUT /api/{resourceType}/{resourceId}/permissions + * @param {Object} req - Express request object + * @param {Object} req.params - Route parameters + * @param {string} req.params.resourceType - Resource type (e.g., 'agent') + * @param {string} req.params.resourceId - Resource ID + * @param {TUpdateResourcePermissionsRequest} req.body - Request body + * @param {Object} res - Express response object + * @returns {Promise} Updated permissions response + */ +const updateResourcePermissions = async (req, res) => { + try { + const { resourceType, resourceId } = req.params; + /** @type {TUpdateResourcePermissionsRequest} */ + const { updated, removed, public: isPublic, publicAccessRoleId } = req.body; + const { id: userId } = req.user; + + // Prepare principals for the service call + const updatedPrincipals = []; + const revokedPrincipals = []; + + // Add updated principals + if (updated && Array.isArray(updated)) { + updatedPrincipals.push(...updated); + } + + // Add public permission if enabled + if (isPublic && publicAccessRoleId) { + updatedPrincipals.push({ + type: 'public', + id: null, + accessRoleId: publicAccessRoleId, + }); + } + + // Prepare authentication context for enhanced group member fetching + const useEntraId = entraIdPrincipalFeatureEnabled(req.user); + const authHeader = req.headers.authorization; + const accessToken = + authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null; + const authContext = + useEntraId && accessToken + ? { + accessToken, + sub: req.user.openidId, + } + : null; + + // Ensure updated principals exist in the database before processing permissions + const validatedPrincipals = []; + for (const principal of updatedPrincipals) { + try { + let principalId; + + if (principal.type === 'public') { + principalId = null; // Public principals don't need database records + } else if (principal.type === 'user') { + principalId = await ensurePrincipalExists(principal); + } else if (principal.type === 'group') { + // Pass authContext to enable member fetching for Entra ID groups when available + principalId = await ensureGroupPrincipalExists(principal, authContext); + } else { + logger.error(`Unsupported principal type: ${principal.type}`); + continue; // Skip invalid principal types + } + + // Update the principal with the validated ID for ACL operations + validatedPrincipals.push({ + ...principal, + id: principalId, + }); + } catch (error) { + logger.error('Error ensuring principal exists:', { + principal: { + type: principal.type, + id: principal.id, + name: principal.name, + source: principal.source, + }, + error: error.message, + }); + // Continue with other principals instead of failing the entire operation + continue; + } + } + + // Add removed principals + if (removed && Array.isArray(removed)) { + revokedPrincipals.push(...removed); + } + + // If public is disabled, add public to revoked list + if (!isPublic) { + revokedPrincipals.push({ + type: 'public', + id: null, + }); + } + + const results = await bulkUpdateResourcePermissions({ + resourceType, + resourceId, + updatedPrincipals: validatedPrincipals, + revokedPrincipals, + grantedBy: userId, + }); + + /** @type {TUpdateResourcePermissionsResponse} */ + const response = { + message: 'Permissions updated successfully', + results: { + principals: results.granted, + public: isPublic || false, + publicAccessRoleId: isPublic ? publicAccessRoleId : undefined, + }, + }; + + res.status(200).json(response); + } catch (error) { + logger.error('Error updating resource permissions:', error); + res.status(400).json({ + error: 'Failed to update permissions', + details: error.message, + }); + } +}; + +/** + * Get principals with their permission roles for a resource (UI-friendly format) + * Uses efficient aggregation pipeline to join User/Group data in single query + * @route GET /api/permissions/{resourceType}/{resourceId} + */ +const getResourcePermissions = async (req, res) => { + try { + const { resourceType, resourceId } = req.params; + + // Use aggregation pipeline for efficient single-query data retrieval + const results = await AclEntry.aggregate([ + // Match ACL entries for this resource + { + $match: { + resourceType, + resourceId: mongoose.Types.ObjectId.isValid(resourceId) + ? mongoose.Types.ObjectId.createFromHexString(resourceId) + : resourceId, + }, + }, + // Lookup AccessRole information + { + $lookup: { + from: 'accessroles', + localField: 'roleId', + foreignField: '_id', + as: 'role', + }, + }, + // Lookup User information (for user principals) + { + $lookup: { + from: 'users', + localField: 'principalId', + foreignField: '_id', + as: 'userInfo', + pipeline: [ + { + $project: { + _id: 1, + name: 1, + username: 1, + email: 1, + avatar: 1, + idOnTheSource: 1, + provider: 1, + }, + }, + ], + }, + }, + // Lookup Group information (for group principals) + { + $lookup: { + from: 'groups', + localField: 'principalId', + foreignField: '_id', + as: 'groupInfo', + pipeline: [ + { + $project: { + _id: 1, + name: 1, + email: 1, + description: 1, + avatar: 1, + idOnTheSource: 1, + source: 1, + }, + }, + ], + }, + }, + // Project final structure + { + $project: { + principalType: 1, + principalId: 1, + accessRoleId: { $arrayElemAt: ['$role.accessRoleId', 0] }, + userInfo: { $arrayElemAt: ['$userInfo', 0] }, + groupInfo: { $arrayElemAt: ['$groupInfo', 0] }, + }, + }, + ]); + + const principals = []; + let publicPermission = null; + + // Process aggregation results + for (const result of results) { + if (result.principalType === 'public') { + publicPermission = { + public: true, + publicAccessRoleId: result.accessRoleId, + }; + } else if (result.principalType === 'user' && result.userInfo) { + principals.push({ + type: 'user', + id: result.userInfo._id.toString(), + name: result.userInfo.name || result.userInfo.username, + email: result.userInfo.email, + avatar: result.userInfo.avatar, + source: !result.userInfo._id ? 'entra' : 'local', + idOnTheSource: result.userInfo.idOnTheSource || result.userInfo._id.toString(), + accessRoleId: result.accessRoleId, + }); + } else if (result.principalType === 'group' && result.groupInfo) { + principals.push({ + type: 'group', + id: result.groupInfo._id.toString(), + name: result.groupInfo.name, + email: result.groupInfo.email, + description: result.groupInfo.description, + avatar: result.groupInfo.avatar, + source: result.groupInfo.source || 'local', + idOnTheSource: result.groupInfo.idOnTheSource || result.groupInfo._id.toString(), + accessRoleId: result.accessRoleId, + }); + } + } + + // Return response in format expected by frontend + const response = { + resourceType, + resourceId, + principals, + public: publicPermission?.public || false, + ...(publicPermission?.publicAccessRoleId && { + publicAccessRoleId: publicPermission.publicAccessRoleId, + }), + }; + + res.status(200).json(response); + } catch (error) { + logger.error('Error getting resource permissions principals:', error); + res.status(500).json({ + error: 'Failed to get permissions principals', + details: error.message, + }); + } +}; + +/** + * Get available roles for a resource type + * @route GET /api/{resourceType}/roles + */ +const getResourceRoles = async (req, res) => { + try { + const { resourceType } = req.params; + + const roles = await getAvailableRoles({ resourceType }); + + res.status(200).json( + roles.map((role) => ({ + accessRoleId: role.accessRoleId, + name: role.name, + description: role.description, + permBits: role.permBits, + })), + ); + } catch (error) { + logger.error('Error getting resource roles:', error); + res.status(500).json({ + error: 'Failed to get roles', + details: error.message, + }); + } +}; + +/** + * Get user's effective permission bitmask for a resource + * @route GET /api/{resourceType}/{resourceId}/effective + */ +const getUserEffectivePermissions = async (req, res) => { + try { + const { resourceType, resourceId } = req.params; + const { id: userId } = req.user; + + const permissionBits = await getEffectivePermissions({ + userId, + resourceType, + resourceId, + }); + + res.status(200).json({ + permissionBits, + }); + } catch (error) { + logger.error('Error getting user effective permissions:', error); + res.status(500).json({ + error: 'Failed to get effective permissions', + details: error.message, + }); + } +}; + +/** + * Search for users and groups to grant permissions + * Supports hybrid local database + Entra ID search when configured + * @route GET /api/permissions/search-principals + */ +const searchPrincipals = async (req, res) => { + try { + const { q: query, limit = 20, type } = req.query; + + if (!query || query.trim().length === 0) { + return res.status(400).json({ + error: 'Query parameter "q" is required and must not be empty', + }); + } + + if (query.trim().length < 2) { + return res.status(400).json({ + error: 'Query must be at least 2 characters long', + }); + } + + const searchLimit = Math.min(Math.max(1, parseInt(limit) || 10), 50); + const typeFilter = ['user', 'group'].includes(type) ? type : null; + + const localResults = await searchLocalPrincipals(query.trim(), searchLimit, typeFilter); + let allPrincipals = [...localResults]; + + const useEntraId = entraIdPrincipalFeatureEnabled(req.user); + + if (useEntraId && localResults.length < searchLimit) { + try { + const graphTypeMap = { + user: 'users', + group: 'groups', + null: 'all', + }; + + const authHeader = req.headers.authorization; + const accessToken = + authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null; + + if (accessToken) { + const graphResults = await searchEntraIdPrincipals( + accessToken, + req.user.openidId, + query.trim(), + graphTypeMap[typeFilter], + searchLimit - localResults.length, + ); + + const localEmails = new Set( + localResults.map((p) => p.email?.toLowerCase()).filter(Boolean), + ); + const localGroupSourceIds = new Set( + localResults.map((p) => p.idOnTheSource).filter(Boolean), + ); + + for (const principal of graphResults) { + const isDuplicateByEmail = + principal.email && localEmails.has(principal.email.toLowerCase()); + const isDuplicateBySourceId = + principal.idOnTheSource && localGroupSourceIds.has(principal.idOnTheSource); + + if (!isDuplicateByEmail && !isDuplicateBySourceId) { + allPrincipals.push(principal); + } + } + } + } catch (graphError) { + logger.warn('Graph API search failed, falling back to local results:', graphError.message); + } + } + const scoredResults = allPrincipals.map((item) => ({ + ...item, + _searchScore: calculateRelevanceScore(item, query.trim()), + })); + + allPrincipals = sortPrincipalsByRelevance(scoredResults) + .slice(0, searchLimit) + .map((result) => { + const { _searchScore, ...resultWithoutScore } = result; + return resultWithoutScore; + }); + res.status(200).json({ + query: query.trim(), + limit: searchLimit, + type: typeFilter, + results: allPrincipals, + count: allPrincipals.length, + sources: { + local: allPrincipals.filter((r) => r.source === 'local').length, + entra: allPrincipals.filter((r) => r.source === 'entra').length, + }, + }); + } catch (error) { + logger.error('Error searching principals:', error); + res.status(500).json({ + error: 'Failed to search principals', + details: error.message, + }); + } +}; + +module.exports = { + updateResourcePermissions, + getResourcePermissions, + getResourceRoles, + getUserEffectivePermissions, + searchPrincipals, +}; diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 18bd7190f0..d03697b2a4 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -1,11 +1,10 @@ const fs = require('fs').promises; const { nanoid } = require('nanoid'); -const { logger } = require('@librechat/data-schemas'); +const { logger, PermissionBits } = require('@librechat/data-schemas'); const { Tools, - Constants, - FileSources, SystemRoles, + FileSources, EToolResources, actionDelimiter, } = require('librechat-data-provider'); @@ -14,17 +13,16 @@ const { createAgent, updateAgent, deleteAgent, - getListAgents, + getListAgentsByAccess, } = require('~/models/Agent'); +const { grantPermission, findAccessibleResources } = require('~/server/services/PermissionService'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); +const { updateAgentProjects, revertAgentVersion } = require('~/models/Agent'); const { resizeAvatar } = require('~/server/services/Files/images/avatar'); const { refreshS3Url } = require('~/server/services/Files/S3/crud'); const { filterFile } = require('~/server/services/Files/process'); const { updateAction, getActions } = require('~/models/Action'); const { getCachedTools } = require('~/server/services/Config'); -const { updateAgentProjects } = require('~/models/Agent'); -const { getProjectByName } = require('~/models/Project'); -const { revertAgentVersion } = require('~/models/Agent'); const { deleteFileByFilter } = require('~/models/File'); const systemTools = { @@ -69,6 +67,27 @@ const createAgentHandler = async (req, res) => { agentData.id = `agent_${nanoid()}`; const agent = await createAgent(agentData); + + // Automatically grant owner permissions to the creator + try { + await grantPermission({ + principalType: 'user', + principalId: userId, + resourceType: 'agent', + resourceId: agent._id, + accessRoleId: 'agent_owner', + grantedBy: userId, + }); + logger.debug( + `[createAgent] Granted owner permissions to user ${userId} for agent ${agent.id}`, + ); + } catch (permissionError) { + logger.error( + `[createAgent] Failed to grant owner permissions for agent ${agent.id}:`, + permissionError, + ); + } + res.status(201).json(agent); } catch (error) { logger.error('[/Agents] Error creating agent', error); @@ -87,21 +106,14 @@ const createAgentHandler = async (req, res) => { * @returns {Promise} 200 - success response - application/json * @returns {Error} 404 - Agent not found */ -const getAgentHandler = async (req, res) => { +const getAgentHandler = async (req, res, expandProperties = false) => { try { const id = req.params.id; const author = req.user.id; - let query = { id, author }; - - const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, ['agentIds']); - if (globalProject && (globalProject.agentIds?.length ?? 0) > 0) { - query = { - $or: [{ id, $in: globalProject.agentIds }, query], - }; - } - - const agent = await getAgent(query); + // Permissions are validated by middleware before calling this function + // Simply load the agent by ID + const agent = await getAgent({ id }); if (!agent) { return res.status(404).json({ error: 'Agent not found' }); @@ -118,23 +130,36 @@ const getAgentHandler = async (req, res) => { } agent.author = agent.author.toString(); + + // @deprecated - isCollaborative replaced by ACL permissions agent.isCollaborative = !!agent.isCollaborative; if (agent.author !== author) { delete agent.author; } - if (!agent.isCollaborative && agent.author !== author && req.user.role !== SystemRoles.ADMIN) { + if (!expandProperties) { + // VIEW permission: Basic agent info only return res.status(200).json({ + _id: agent._id, id: agent.id, name: agent.name, + description: agent.description, avatar: agent.avatar, author: agent.author, + provider: agent.provider, + model: agent.model, projectIds: agent.projectIds, + // @deprecated - isCollaborative replaced by ACL permissions isCollaborative: agent.isCollaborative, version: agent.version, + // Safe metadata + createdAt: agent.createdAt, + updatedAt: agent.updatedAt, }); } + + // EDIT permission: Full agent details including sensitive configuration return res.status(200).json(agent); } catch (error) { logger.error('[/Agents/:id] Error retrieving agent', error); @@ -154,24 +179,12 @@ const getAgentHandler = async (req, res) => { const updateAgentHandler = async (req, res) => { try { const id = req.params.id; - const { projectIds, removeProjectIds, ...updateData } = req.body; - const isAdmin = req.user.role === SystemRoles.ADMIN; + const { projectIds, removeProjectIds, _id, ...updateData } = req.body; const existingAgent = await getAgent({ id }); - const isAuthor = existingAgent.author.toString() === req.user.id; if (!existingAgent) { return res.status(404).json({ error: 'Agent not found' }); } - const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor; - - if (!hasEditPermission) { - return res.status(403).json({ - error: 'You do not have permission to modify this non-collaborative agent', - }); - } - - /** @type {boolean} */ - const isProjectUpdate = (projectIds?.length ?? 0) > 0 || (removeProjectIds?.length ?? 0) > 0; let updatedAgent = Object.keys(updateData).length > 0 @@ -307,6 +320,26 @@ const duplicateAgentHandler = async (req, res) => { newAgentData.actions = agentActions; const newAgent = await createAgent(newAgentData); + // Automatically grant owner permissions to the duplicator + try { + await grantPermission({ + principalType: 'user', + principalId: userId, + resourceType: 'agent', + resourceId: newAgent._id, + accessRoleId: 'agent_owner', + grantedBy: userId, + }); + logger.debug( + `[duplicateAgent] Granted owner permissions to user ${userId} for duplicated agent ${newAgent.id}`, + ); + } catch (permissionError) { + logger.error( + `[duplicateAgent] Failed to grant owner permissions for duplicated agent ${newAgent.id}:`, + permissionError, + ); + } + return res.status(201).json({ agent: newAgent, actions: newActionsList, @@ -333,7 +366,7 @@ const deleteAgentHandler = async (req, res) => { if (!agent) { return res.status(404).json({ error: 'Agent not found' }); } - await deleteAgent({ id, author: req.user.id }); + await deleteAgent({ id }); return res.json({ message: 'Agent deleted' }); } catch (error) { logger.error('[/Agents/:id] Error deleting Agent', error); @@ -342,7 +375,7 @@ const deleteAgentHandler = async (req, res) => { }; /** - * + * Lists agents using ACL-aware permissions (ownership + explicit shares). * @route GET /Agents * @param {object} req - Express Request * @param {object} req.query - Request query @@ -351,9 +384,22 @@ const deleteAgentHandler = async (req, res) => { */ const getListAgentsHandler = async (req, res) => { try { - const data = await getListAgents({ - author: req.user.id, + const userId = req.user.id; + + // Get agent IDs the user has VIEW access to via ACL + const accessibleIds = await findAccessibleResources({ + userId, + resourceType: 'agent', + requiredPermissions: PermissionBits.VIEW, }); + + // Use the new ACL-aware function + const data = await getListAgentsByAccess({ + userId, + accessibleIds, + otherParams: {}, // Can add query params here if needed + }); + return res.json(data); } catch (error) { logger.error('[/Agents] Error listing Agents', error); @@ -431,7 +477,7 @@ const uploadAgentAvatarHandler = async (req, res) => { }; promises.push( - await updateAgent({ id: agent_id, author: req.user.id }, data, { + await updateAgent({ id: agent_id }, data, { updatingUserId: req.user.id, }), ); diff --git a/api/server/index.js b/api/server/index.js index b1132873c7..49b6be28fd 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -118,6 +118,8 @@ const startServer = async () => { app.use('/api/banner', routes.banner); app.use('/api/bedrock', routes.bedrock); app.use('/api/memories', routes.memories); + app.use('/api/permissions', routes.accessPermissions); + app.use('/api/tags', routes.tags); app.use('/api/mcp', routes.mcp); diff --git a/api/server/middleware/accessResources/canAccessAgentFromBody.js b/api/server/middleware/accessResources/canAccessAgentFromBody.js new file mode 100644 index 0000000000..8149af9055 --- /dev/null +++ b/api/server/middleware/accessResources/canAccessAgentFromBody.js @@ -0,0 +1,95 @@ +const { Constants, isAgentsEndpoint } = require('librechat-data-provider'); +const { canAccessResource } = require('./canAccessResource'); +const { getAgent } = require('~/models/Agent'); + +/** + * Agent ID resolver function for agent_id from request body + * Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId + * This is used specifically for chat routes where agent_id comes from request body + * + * @param {string} agentCustomId - Custom agent ID from request body + * @returns {Promise} Agent document with _id field, or null if not found + */ +const resolveAgentIdFromBody = async (agentCustomId) => { + // Handle ephemeral agents - they don't need permission checks + if (agentCustomId === Constants.EPHEMERAL_AGENT_ID) { + return null; // No permission check needed for ephemeral agents + } + + return await getAgent({ id: agentCustomId }); +}; + +/** + * Middleware factory that creates middleware to check agent access permissions from request body. + * This middleware is specifically designed for chat routes where the agent_id comes from req.body + * instead of route parameters. + * + * @param {Object} options - Configuration options + * @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share) + * @returns {Function} Express middleware function + * + * @example + * // Basic usage for agent chat (requires VIEW permission) + * router.post('/chat', + * canAccessAgentFromBody({ requiredPermission: PermissionBits.VIEW }), + * buildEndpointOption, + * chatController + * ); + */ +const canAccessAgentFromBody = (options) => { + const { requiredPermission } = options; + + // Validate required options + if (!requiredPermission || typeof requiredPermission !== 'number') { + throw new Error('canAccessAgentFromBody: requiredPermission is required and must be a number'); + } + + return async (req, res, next) => { + try { + const { endpoint, agent_id } = req.body; + let agentId = agent_id; + + if (!isAgentsEndpoint(endpoint)) { + agentId = Constants.EPHEMERAL_AGENT_ID; + } + + if (!agentId) { + return res.status(400).json({ + error: 'Bad Request', + message: 'agent_id is required in request body', + }); + } + + // Skip permission checks for ephemeral agents + if (agentId === Constants.EPHEMERAL_AGENT_ID) { + return next(); + } + + const agentAccessMiddleware = canAccessResource({ + resourceType: 'agent', + requiredPermission, + resourceIdParam: 'agent_id', // This will be ignored since we use custom resolver + idResolver: () => resolveAgentIdFromBody(agentId), + }); + + const tempReq = { + ...req, + params: { + ...req.params, + agent_id: agentId, + }, + }; + + return agentAccessMiddleware(tempReq, res, next); + } catch (error) { + return res.status(500).json({ + error: 'Internal Server Error', + message: 'Failed to validate agent access permissions', + }); + } + }; +}; + +module.exports = { + canAccessAgentFromBody, +}; diff --git a/api/server/middleware/accessResources/canAccessAgentResource.js b/api/server/middleware/accessResources/canAccessAgentResource.js new file mode 100644 index 0000000000..4bb6af5a7b --- /dev/null +++ b/api/server/middleware/accessResources/canAccessAgentResource.js @@ -0,0 +1,58 @@ +const { getAgent } = require('~/models/Agent'); +const { canAccessResource } = require('./canAccessResource'); + +/** + * Agent ID resolver function + * Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId + * + * @param {string} agentCustomId - Custom agent ID from route parameter + * @returns {Promise} Agent document with _id field, or null if not found + */ +const resolveAgentId = async (agentCustomId) => { + return await getAgent({ id: agentCustomId }); +}; + +/** + * Agent-specific middleware factory that creates middleware to check agent access permissions. + * This middleware extends the generic canAccessResource to handle agent custom ID resolution. + * + * @param {Object} options - Configuration options + * @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share) + * @param {string} [options.resourceIdParam='id'] - The name of the route parameter containing the agent custom ID + * @returns {Function} Express middleware function + * + * @example + * // Basic usage for viewing agents + * router.get('/agents/:id', + * canAccessAgentResource({ requiredPermission: 1 }), + * getAgent + * ); + * + * @example + * // Custom resource ID parameter and edit permission + * router.patch('/agents/:agent_id', + * canAccessAgentResource({ + * requiredPermission: 2, + * resourceIdParam: 'agent_id' + * }), + * updateAgent + * ); + */ +const canAccessAgentResource = (options) => { + const { requiredPermission, resourceIdParam = 'id' } = options; + + if (!requiredPermission || typeof requiredPermission !== 'number') { + throw new Error('canAccessAgentResource: requiredPermission is required and must be a number'); + } + + return canAccessResource({ + resourceType: 'agent', + requiredPermission, + resourceIdParam, + idResolver: resolveAgentId, + }); +}; + +module.exports = { + canAccessAgentResource, +}; diff --git a/api/server/middleware/accessResources/canAccessResource.js b/api/server/middleware/accessResources/canAccessResource.js new file mode 100644 index 0000000000..a236d3cffe --- /dev/null +++ b/api/server/middleware/accessResources/canAccessResource.js @@ -0,0 +1,157 @@ +const { logger } = require('@librechat/data-schemas'); +const { SystemRoles } = require('librechat-data-provider'); +const { checkPermission } = require('~/server/services/PermissionService'); + +/** + * Generic base middleware factory that creates middleware to check resource access permissions. + * This middleware expects MongoDB ObjectIds as resource identifiers for ACL permission checks. + * + * @param {Object} options - Configuration options + * @param {string} options.resourceType - The type of resource (e.g., 'agent', 'file', 'project') + * @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share) + * @param {string} [options.resourceIdParam='resourceId'] - The name of the route parameter containing the resource ID + * @param {Function} [options.idResolver] - Optional function to resolve custom IDs to ObjectIds + * @returns {Function} Express middleware function + * + * @example + * // Direct usage with ObjectId (for resources that use MongoDB ObjectId in routes) + * router.get('/prompts/:promptId', + * canAccessResource({ resourceType: 'prompt', requiredPermission: 1 }), + * getPrompt + * ); + * + * @example + * // Usage with custom ID resolver (for resources that use custom string IDs) + * router.get('/agents/:id', + * canAccessResource({ + * resourceType: 'agent', + * requiredPermission: 1, + * resourceIdParam: 'id', + * idResolver: (customId) => resolveAgentId(customId) + * }), + * getAgent + * ); + */ +const canAccessResource = (options) => { + const { + resourceType, + requiredPermission, + resourceIdParam = 'resourceId', + idResolver = null, + } = options; + + if (!resourceType || typeof resourceType !== 'string') { + throw new Error('canAccessResource: resourceType is required and must be a string'); + } + + if (!requiredPermission || typeof requiredPermission !== 'number') { + throw new Error('canAccessResource: requiredPermission is required and must be a number'); + } + + return async (req, res, next) => { + try { + // Extract resource ID from route parameters + const rawResourceId = req.params[resourceIdParam]; + + if (!rawResourceId) { + logger.warn(`[canAccessResource] Missing ${resourceIdParam} in route parameters`); + return res.status(400).json({ + error: 'Bad Request', + message: `${resourceIdParam} is required`, + }); + } + + // Check if user is authenticated + if (!req.user || !req.user.id) { + logger.warn( + `[canAccessResource] Unauthenticated request for ${resourceType} ${rawResourceId}`, + ); + return res.status(401).json({ + error: 'Unauthorized', + message: 'Authentication required', + }); + } + // if system admin let through + if (req.user.role === SystemRoles.ADMIN) { + return next(); + } + const userId = req.user.id; + let resourceId = rawResourceId; + let resourceInfo = null; + + // Resolve custom ID to ObjectId if resolver is provided + if (idResolver) { + logger.debug( + `[canAccessResource] Resolving ${resourceType} custom ID ${rawResourceId} to ObjectId`, + ); + + const resolutionResult = await idResolver(rawResourceId); + + if (!resolutionResult) { + logger.warn(`[canAccessResource] ${resourceType} not found: ${rawResourceId}`); + return res.status(404).json({ + error: 'Not Found', + message: `${resourceType} not found`, + }); + } + + // Handle different resolver return formats + if (typeof resolutionResult === 'string' || resolutionResult._id) { + resourceId = resolutionResult._id || resolutionResult; + resourceInfo = typeof resolutionResult === 'object' ? resolutionResult : null; + } else { + resourceId = resolutionResult; + } + + logger.debug( + `[canAccessResource] Resolved ${resourceType} ${rawResourceId} to ObjectId ${resourceId}`, + ); + } + + // Check permissions using PermissionService with ObjectId + const hasPermission = await checkPermission({ + userId, + resourceType, + resourceId, + requiredPermission, + }); + + if (hasPermission) { + logger.debug( + `[canAccessResource] User ${userId} has permission ${requiredPermission} on ${resourceType} ${rawResourceId} (${resourceId})`, + ); + + req.resourceAccess = { + resourceType, + resourceId, // MongoDB ObjectId for ACL operations + customResourceId: rawResourceId, // Original ID from route params + permission: requiredPermission, + userId, + ...(resourceInfo && { resourceInfo }), + }; + + return next(); + } + + logger.warn( + `[canAccessResource] User ${userId} denied access to ${resourceType} ${rawResourceId} ` + + `(required permission: ${requiredPermission})`, + ); + + return res.status(403).json({ + error: 'Forbidden', + message: `Insufficient permissions to access this ${resourceType}`, + }); + } catch (error) { + logger.error(`[canAccessResource] Error checking access for ${resourceType}:`, error); + return res.status(500).json({ + error: 'Internal Server Error', + message: 'Failed to check resource access permissions', + }); + } + }; +}; + +module.exports = { + canAccessResource, +}; diff --git a/api/server/middleware/accessResources/index.js b/api/server/middleware/accessResources/index.js new file mode 100644 index 0000000000..b1bffa94e3 --- /dev/null +++ b/api/server/middleware/accessResources/index.js @@ -0,0 +1,9 @@ +const { canAccessResource } = require('./canAccessResource'); +const { canAccessAgentResource } = require('./canAccessAgentResource'); +const { canAccessAgentFromBody } = require('./canAccessAgentFromBody'); + +module.exports = { + canAccessResource, + canAccessAgentResource, + canAccessAgentFromBody, +}; diff --git a/api/server/middleware/index.js b/api/server/middleware/index.js index 6a41d6f157..af826d582c 100644 --- a/api/server/middleware/index.js +++ b/api/server/middleware/index.js @@ -8,6 +8,7 @@ const concurrentLimiter = require('./concurrentLimiter'); const validateEndpoint = require('./validateEndpoint'); const requireLocalAuth = require('./requireLocalAuth'); const canDeleteAccount = require('./canDeleteAccount'); +const accessResources = require('./accessResources'); const setBalanceConfig = require('./setBalanceConfig'); const requireLdapAuth = require('./requireLdapAuth'); const abortMiddleware = require('./abortMiddleware'); @@ -29,6 +30,7 @@ module.exports = { ...validate, ...limiters, ...roles, + ...accessResources, noIndex, checkBan, uaParser, diff --git a/api/server/routes/accessPermissions.js b/api/server/routes/accessPermissions.js new file mode 100644 index 0000000000..e5720de81f --- /dev/null +++ b/api/server/routes/accessPermissions.js @@ -0,0 +1,62 @@ +const express = require('express'); +const { PermissionBits } = require('@librechat/data-schemas'); +const { + getUserEffectivePermissions, + updateResourcePermissions, + getResourcePermissions, + getResourceRoles, + searchPrincipals, +} = require('~/server/controllers/PermissionsController'); +const { requireJwtAuth, checkBan, uaParser, canAccessResource } = require('~/server/middleware'); + +const router = express.Router(); + +// Apply common middleware +router.use(requireJwtAuth); +router.use(checkBan); +router.use(uaParser); + +/** + * Generic routes for resource permissions + * Pattern: /api/permissions/{resourceType}/{resourceId} + */ + +/** + * GET /api/permissions/search-principals + * Search for users and groups to grant permissions + */ +router.get('/search-principals', searchPrincipals); + +/** + * GET /api/permissions/{resourceType}/roles + * Get available roles for a resource type + */ +router.get('/:resourceType/roles', getResourceRoles); + +/** + * GET /api/permissions/{resourceType}/{resourceId} + * Get all permissions for a specific resource + */ +router.get('/:resourceType/:resourceId', getResourcePermissions); + +/** + * PUT /api/permissions/{resourceType}/{resourceId} + * Bulk update permissions for a specific resource + */ +router.put( + '/:resourceType/:resourceId', + canAccessResource({ + resourceType: 'agent', + requiredPermission: PermissionBits.SHARE, + resourceIdParam: 'resourceId', + }), + updateResourcePermissions, +); + +/** + * GET /api/permissions/{resourceType}/{resourceId}/effective + * Get user's effective permissions for a specific resource + */ +router.get('/:resourceType/:resourceId/effective', getUserEffectivePermissions); + +module.exports = router; diff --git a/api/server/routes/agents/actions.js b/api/server/routes/agents/actions.js index 89d6a9dc42..b20f34aa5d 100644 --- a/api/server/routes/agents/actions.js +++ b/api/server/routes/agents/actions.js @@ -1,20 +1,15 @@ const express = require('express'); const { nanoid } = require('nanoid'); -const { actionDelimiter, SystemRoles, removeNullishValues } = require('librechat-data-provider'); +const { logger, PermissionBits } = require('@librechat/data-schemas'); +const { actionDelimiter, removeNullishValues } = require('librechat-data-provider'); const { encryptMetadata, domainParser } = require('~/server/services/ActionService'); const { updateAction, getActions, deleteAction } = require('~/models/Action'); const { isActionDomainAllowed } = require('~/server/services/domains'); +const { canAccessAgentResource } = require('~/server/middleware'); const { getAgent, updateAgent } = require('~/models/Agent'); -const { logger } = require('~/config'); const router = express.Router(); -// If the user has ADMIN role -// then action edition is possible even if not owner of the assistant -const isAdmin = (req) => { - return req.user.role === SystemRoles.ADMIN; -}; - /** * Retrieves all user's actions * @route GET /actions/ @@ -23,9 +18,8 @@ const isAdmin = (req) => { */ router.get('/', async (req, res) => { try { - const admin = isAdmin(req); - // If admin, get all actions, otherwise only user's actions - const searchParams = admin ? {} : { user: req.user.id }; + // Get all actions for the user (admin permissions handled by middleware if needed) + const searchParams = { user: req.user.id }; res.json(await getActions(searchParams)); } catch (error) { res.status(500).json({ error: error.message }); @@ -41,106 +35,110 @@ router.get('/', async (req, res) => { * @param {ActionMetadata} req.body.metadata - Metadata for the action. * @returns {Object} 200 - success response - application/json */ -router.post('/:agent_id', async (req, res) => { - try { - const { agent_id } = req.params; +router.post( + '/:agent_id', + canAccessAgentResource({ + requiredPermission: PermissionBits.EDIT, + resourceIdParam: 'agent_id', + }), + async (req, res) => { + try { + const { agent_id } = req.params; - /** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */ - const { functions, action_id: _action_id, metadata: _metadata } = req.body; - if (!functions.length) { - return res.status(400).json({ message: 'No functions provided' }); - } - - let metadata = await encryptMetadata(removeNullishValues(_metadata, true)); - const isDomainAllowed = await isActionDomainAllowed(metadata.domain); - if (!isDomainAllowed) { - return res.status(400).json({ message: 'Domain not allowed' }); - } - - let { domain } = metadata; - domain = await domainParser(domain, true); - - if (!domain) { - return res.status(400).json({ message: 'No domain provided' }); - } - - const action_id = _action_id ?? nanoid(); - const initialPromises = []; - const admin = isAdmin(req); - - // If admin, can edit any agent, otherwise only user's agents - const agentQuery = admin ? { id: agent_id } : { id: agent_id, author: req.user.id }; - // TODO: share agents - initialPromises.push(getAgent(agentQuery)); - if (_action_id) { - initialPromises.push(getActions({ action_id }, true)); - } - - /** @type {[Agent, [Action|undefined]]} */ - const [agent, actions_result] = await Promise.all(initialPromises); - if (!agent) { - return res.status(404).json({ message: 'Agent not found for adding action' }); - } - - if (actions_result && actions_result.length) { - const action = actions_result[0]; - metadata = { ...action.metadata, ...metadata }; - } - - const { actions: _actions = [], author: agent_author } = agent ?? {}; - const actions = []; - for (const action of _actions) { - const [_action_domain, current_action_id] = action.split(actionDelimiter); - if (current_action_id === action_id) { - continue; + /** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */ + const { functions, action_id: _action_id, metadata: _metadata } = req.body; + if (!functions.length) { + return res.status(400).json({ message: 'No functions provided' }); } - actions.push(action); - } - - actions.push(`${domain}${actionDelimiter}${action_id}`); - - /** @type {string[]}} */ - const { tools: _tools = [] } = agent; - - const tools = _tools - .filter((tool) => !(tool && (tool.includes(domain) || tool.includes(action_id)))) - .concat(functions.map((tool) => `${tool.function.name}${actionDelimiter}${domain}`)); - - // Force version update since actions are changing - const updatedAgent = await updateAgent( - agentQuery, - { tools, actions }, - { - updatingUserId: req.user.id, - forceVersion: true, - }, - ); - - // Only update user field for new actions - const actionUpdateData = { metadata, agent_id }; - if (!actions_result || !actions_result.length) { - // For new actions, use the agent owner's user ID - actionUpdateData.user = agent_author || req.user.id; - } - - /** @type {[Action]} */ - const updatedAction = await updateAction({ action_id }, actionUpdateData); - - const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret']; - for (let field of sensitiveFields) { - if (updatedAction.metadata[field]) { - delete updatedAction.metadata[field]; + let metadata = await encryptMetadata(removeNullishValues(_metadata, true)); + const isDomainAllowed = await isActionDomainAllowed(metadata.domain); + if (!isDomainAllowed) { + return res.status(400).json({ message: 'Domain not allowed' }); } - } - res.json([updatedAgent, updatedAction]); - } catch (error) { - const message = 'Trouble updating the Agent Action'; - logger.error(message, error); - res.status(500).json({ message }); - } -}); + let { domain } = metadata; + domain = await domainParser(domain, true); + + if (!domain) { + return res.status(400).json({ message: 'No domain provided' }); + } + + const action_id = _action_id ?? nanoid(); + const initialPromises = []; + + // Permissions already validated by middleware - load agent directly + initialPromises.push(getAgent({ id: agent_id })); + if (_action_id) { + initialPromises.push(getActions({ action_id }, true)); + } + + /** @type {[Agent, [Action|undefined]]} */ + const [agent, actions_result] = await Promise.all(initialPromises); + if (!agent) { + return res.status(404).json({ message: 'Agent not found for adding action' }); + } + + if (actions_result && actions_result.length) { + const action = actions_result[0]; + metadata = { ...action.metadata, ...metadata }; + } + + const { actions: _actions = [], author: agent_author } = agent ?? {}; + const actions = []; + for (const action of _actions) { + const [_action_domain, current_action_id] = action.split(actionDelimiter); + if (current_action_id === action_id) { + continue; + } + + actions.push(action); + } + + actions.push(`${domain}${actionDelimiter}${action_id}`); + + /** @type {string[]}} */ + const { tools: _tools = [] } = agent; + + const tools = _tools + .filter((tool) => !(tool && (tool.includes(domain) || tool.includes(action_id)))) + .concat(functions.map((tool) => `${tool.function.name}${actionDelimiter}${domain}`)); + + // Force version update since actions are changing + const updatedAgent = await updateAgent( + { id: agent_id }, + { tools, actions }, + { + updatingUserId: req.user.id, + forceVersion: true, + }, + ); + + // Only update user field for new actions + const actionUpdateData = { metadata, agent_id }; + if (!actions_result || !actions_result.length) { + // For new actions, use the agent owner's user ID + actionUpdateData.user = agent_author || req.user.id; + } + + /** @type {[Action]} */ + const updatedAction = await updateAction({ action_id }, actionUpdateData); + + const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret']; + for (let field of sensitiveFields) { + if (updatedAction.metadata[field]) { + delete updatedAction.metadata[field]; + } + } + + res.json([updatedAgent, updatedAction]); + } catch (error) { + const message = 'Trouble updating the Agent Action'; + logger.error(message, error); + res.status(500).json({ message }); + } + }, +); /** * Deletes an action for a specific agent. @@ -149,52 +147,55 @@ router.post('/:agent_id', async (req, res) => { * @param {string} req.params.action_id - The ID of the action to delete. * @returns {Object} 200 - success response - application/json */ -router.delete('/:agent_id/:action_id', async (req, res) => { - try { - const { agent_id, action_id } = req.params; - const admin = isAdmin(req); +router.delete( + '/:agent_id/:action_id', + canAccessAgentResource({ + requiredPermission: PermissionBits.EDIT, + resourceIdParam: 'agent_id', + }), + async (req, res) => { + try { + const { agent_id, action_id } = req.params; - // If admin, can delete any agent, otherwise only user's agents - const agentQuery = admin ? { id: agent_id } : { id: agent_id, author: req.user.id }; - const agent = await getAgent(agentQuery); - if (!agent) { - return res.status(404).json({ message: 'Agent not found for deleting action' }); - } - - const { tools = [], actions = [] } = agent; - - let domain = ''; - const updatedActions = actions.filter((action) => { - if (action.includes(action_id)) { - [domain] = action.split(actionDelimiter); - return false; + // Permissions already validated by middleware - load agent directly + const agent = await getAgent({ id: agent_id }); + if (!agent) { + return res.status(404).json({ message: 'Agent not found for deleting action' }); } - return true; - }); - domain = await domainParser(domain, true); + const { tools = [], actions = [] } = agent; - if (!domain) { - return res.status(400).json({ message: 'No domain provided' }); + let domain = ''; + const updatedActions = actions.filter((action) => { + if (action.includes(action_id)) { + [domain] = action.split(actionDelimiter); + return false; + } + return true; + }); + + domain = await domainParser(domain, true); + + if (!domain) { + return res.status(400).json({ message: 'No domain provided' }); + } + + const updatedTools = tools.filter((tool) => !(tool && tool.includes(domain))); + + // Force version update since actions are being removed + await updateAgent( + { id: agent_id }, + { tools: updatedTools, actions: updatedActions }, + { updatingUserId: req.user.id, forceVersion: true }, + ); + await deleteAction({ action_id }); + res.status(200).json({ message: 'Action deleted successfully' }); + } catch (error) { + const message = 'Trouble deleting the Agent Action'; + logger.error(message, error); + res.status(500).json({ message }); } - - const updatedTools = tools.filter((tool) => !(tool && tool.includes(domain))); - - // Force version update since actions are being removed - await updateAgent( - agentQuery, - { tools: updatedTools, actions: updatedActions }, - { updatingUserId: req.user.id, forceVersion: true }, - ); - // If admin, can delete any action, otherwise only user's actions - const actionQuery = admin ? { action_id } : { action_id, user: req.user.id }; - await deleteAction(actionQuery); - res.status(200).json({ message: 'Action deleted successfully' }); - } catch (error) { - const message = 'Trouble deleting the Agent Action'; - logger.error(message, error); - res.status(500).json({ message }); - } -}); + }, +); module.exports = router; diff --git a/api/server/routes/agents/chat.js b/api/server/routes/agents/chat.js index ef66ef7896..fd06be8173 100644 --- a/api/server/routes/agents/chat.js +++ b/api/server/routes/agents/chat.js @@ -1,4 +1,5 @@ const express = require('express'); +const { PermissionBits } = require('@librechat/data-schemas'); const { PermissionTypes, Permissions } = require('librechat-data-provider'); const { setHeaders, @@ -7,6 +8,7 @@ const { generateCheckAccess, validateConvoAccess, buildEndpointOption, + canAccessAgentFromBody, } = require('~/server/middleware'); const { initializeClient } = require('~/server/services/Endpoints/agents'); const AgentController = require('~/server/controllers/agents/request'); @@ -17,8 +19,12 @@ const router = express.Router(); router.use(moderateText); const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); +const checkAgentResourceAccess = canAccessAgentFromBody({ + requiredPermission: PermissionBits.VIEW, +}); router.use(checkAgentAccess); +router.use(checkAgentResourceAccess); router.use(validateConvoAccess); router.use(buildEndpointOption); router.use(setHeaders); diff --git a/api/server/routes/agents/v1.js b/api/server/routes/agents/v1.js index 657aa79414..783f36a8fb 100644 --- a/api/server/routes/agents/v1.js +++ b/api/server/routes/agents/v1.js @@ -1,6 +1,11 @@ const express = require('express'); +const { PermissionBits } = require('@librechat/data-schemas'); const { PermissionTypes, Permissions } = require('librechat-data-provider'); -const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware'); +const { + requireJwtAuth, + generateCheckAccess, + canAccessAgentResource, +} = require('~/server/middleware'); const v1 = require('~/server/controllers/agents/v1'); const actions = require('./actions'); const tools = require('./tools'); @@ -46,13 +51,38 @@ router.use('/tools', tools); router.post('/', checkAgentCreate, v1.createAgent); /** - * Retrieves an agent. + * Retrieves basic agent information (VIEW permission required). + * Returns safe, non-sensitive agent data for viewing purposes. * @route GET /agents/:id * @param {string} req.params.id - Agent identifier. - * @returns {Agent} 200 - Success response - application/json + * @returns {Agent} 200 - Basic agent info - application/json */ -router.get('/:id', checkAgentAccess, v1.getAgent); +router.get( + '/:id', + checkAgentAccess, + canAccessAgentResource({ + requiredPermission: PermissionBits.VIEW, + resourceIdParam: 'id', + }), + v1.getAgent, +); +/** + * Retrieves full agent details including sensitive configuration (EDIT permission required). + * Returns complete agent data for editing/configuration purposes. + * @route GET /agents/:id/expanded + * @param {string} req.params.id - Agent identifier. + * @returns {Agent} 200 - Full agent details - application/json + */ +router.get( + '/:id/expanded', + checkAgentAccess, + canAccessAgentResource({ + requiredPermission: PermissionBits.EDIT, + resourceIdParam: 'id', + }), + (req, res) => v1.getAgent(req, res, true), // Expanded version +); /** * Updates an agent. * @route PATCH /agents/:id @@ -60,7 +90,15 @@ router.get('/:id', checkAgentAccess, v1.getAgent); * @param {AgentUpdateParams} req.body - The agent update parameters. * @returns {Agent} 200 - Success response - application/json */ -router.patch('/:id', checkGlobalAgentShare, v1.updateAgent); +router.patch( + '/:id', + checkGlobalAgentShare, + canAccessAgentResource({ + requiredPermission: PermissionBits.EDIT, + resourceIdParam: 'id', + }), + v1.updateAgent, +); /** * Duplicates an agent. @@ -68,7 +106,15 @@ router.patch('/:id', checkGlobalAgentShare, v1.updateAgent); * @param {string} req.params.id - Agent identifier. * @returns {Agent} 201 - Success response - application/json */ -router.post('/:id/duplicate', checkAgentCreate, v1.duplicateAgent); +router.post( + '/:id/duplicate', + checkAgentCreate, + canAccessAgentResource({ + requiredPermission: PermissionBits.VIEW, + resourceIdParam: 'id', + }), + v1.duplicateAgent, +); /** * Deletes an agent. @@ -76,7 +122,15 @@ router.post('/:id/duplicate', checkAgentCreate, v1.duplicateAgent); * @param {string} req.params.id - Agent identifier. * @returns {Agent} 200 - success response - application/json */ -router.delete('/:id', checkAgentCreate, v1.deleteAgent); +router.delete( + '/:id', + checkAgentCreate, + canAccessAgentResource({ + requiredPermission: PermissionBits.DELETE, + resourceIdParam: 'id', + }), + v1.deleteAgent, +); /** * Reverts an agent to a previous version. @@ -103,6 +157,14 @@ router.get('/', checkAgentAccess, v1.getListAgents); * @param {string} [req.body.metadata] - Optional metadata for the agent's avatar. * @returns {Object} 200 - success response - application/json */ -avatar.post('/:agent_id/avatar/', checkAgentAccess, v1.uploadAgentAvatar); +avatar.post( + '/:agent_id/avatar/', + checkAgentAccess, + canAccessAgentResource({ + requiredPermission: PermissionBits.EDIT, + resourceIdParam: 'agent_id', + }), + v1.uploadAgentAvatar, +); module.exports = { v1: router, avatar }; diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 7c1b5de0fa..cc8017955f 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -27,10 +27,12 @@ const edit = require('./edit'); const keys = require('./keys'); const user = require('./user'); const ask = require('./ask'); +const accessPermissions = require('./accessPermissions'); const mcp = require('./mcp'); module.exports = { ask, + mcp, edit, auth, keys, @@ -59,5 +61,5 @@ module.exports = { assistants, categories, staticRoute, - mcp, + accessPermissions, }; diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index afc4a05b75..206bcbf4c4 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -9,6 +9,7 @@ const { setBalanceConfig, checkDomainAllowed, } = require('~/server/middleware'); +const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService'); const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService'); const { isEnabled } = require('~/server/utils'); const { logger } = require('~/config'); @@ -35,6 +36,7 @@ const oauthHandler = async (req, res) => { req.user.provider == 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS) === true ) { + await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token); setOpenIDAuthTokens(req.user.tokenset, res); } else { await setAuthTokens(req.user._id, res); diff --git a/api/server/services/AppService.interface.spec.js b/api/server/services/AppService.interface.spec.js index 90168d4778..4dc5dc375e 100644 --- a/api/server/services/AppService.interface.spec.js +++ b/api/server/services/AppService.interface.spec.js @@ -6,6 +6,9 @@ jest.mock('~/models/Role', () => ({ getRoleByName: jest.fn(), updateRoleByName: jest.fn(), })); +jest.mock('~/models/AccessRole', () => ({ + seedDefaultRoles: jest.fn(), +})); jest.mock('~/config', () => ({ logger: { diff --git a/api/server/services/AppService.js b/api/server/services/AppService.js index 6b7ff7417f..da984b2c3e 100644 --- a/api/server/services/AppService.js +++ b/api/server/services/AppService.js @@ -17,6 +17,7 @@ const { const { azureAssistantsDefaults, assistantsConfigSetup } = require('./start/assistants'); const { initializeAzureBlobService } = require('./Files/Azure/initialize'); const { initializeFirebase } = require('./Files/Firebase/initialize'); +const { seedDefaultRoles, initializeRoles } = require('~/models'); const loadCustomConfig = require('./Config/loadCustomConfig'); const handleRateLimits = require('./Config/handleRateLimits'); const { loadDefaultInterface } = require('./start/interface'); @@ -26,7 +27,6 @@ const { processModelSpecs } = require('./start/modelSpecs'); const { initializeS3 } = require('./Files/S3/initialize'); const { loadAndFormatTools } = require('./ToolService'); const { isEnabled } = require('~/server/utils'); -const { initializeRoles } = require('~/models'); const { setCachedTools } = require('./Config'); const paths = require('~/config/paths'); @@ -37,6 +37,7 @@ const paths = require('~/config/paths'); */ const AppService = async (app) => { await initializeRoles(); + await seedDefaultRoles(); /** @type {TCustomConfig} */ const config = (await loadCustomConfig()) ?? {}; const configDefaults = getConfigDefaults(); diff --git a/api/server/services/AppService.spec.js b/api/server/services/AppService.spec.js index 7edccc2c0d..70460574af 100644 --- a/api/server/services/AppService.spec.js +++ b/api/server/services/AppService.spec.js @@ -28,6 +28,7 @@ jest.mock('./Files/Firebase/initialize', () => ({ })); jest.mock('~/models', () => ({ initializeRoles: jest.fn(), + seedDefaultRoles: jest.fn(), })); jest.mock('~/models/Role', () => ({ updateAccessPermissions: jest.fn(), diff --git a/api/server/services/GraphApiService.js b/api/server/services/GraphApiService.js new file mode 100644 index 0000000000..03f1ff4366 --- /dev/null +++ b/api/server/services/GraphApiService.js @@ -0,0 +1,453 @@ +const client = require('openid-client'); +const { isEnabled } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); +const { CacheKeys } = require('librechat-data-provider'); +const { Client } = require('@microsoft/microsoft-graph-client'); +const { getOpenIdConfig } = require('~/strategies/openidStrategy'); +const getLogStores = require('~/cache/getLogStores'); + +/** + * @import { TPrincipalSearchResult, TGraphPerson, TGraphUser, TGraphGroup, TGraphPeopleResponse, TGraphUsersResponse, TGraphGroupsResponse } from 'librechat-data-provider' + */ + +/** + * Checks if Entra ID principal search feature is enabled based on environment variables and user authentication + * @param {Object} user - User object from request + * @param {string} user.provider - Authentication provider + * @param {string} user.openidId - OpenID subject identifier + * @returns {boolean} True if Entra ID principal search is enabled and user is authenticated via OpenID + */ +const entraIdPrincipalFeatureEnabled = (user) => { + return ( + isEnabled(process.env.USE_ENTRA_ID_FOR_PEOPLE_SEARCH) && + isEnabled(process.env.OPENID_REUSE_TOKENS) && + user?.provider === 'openid' && + user?.openidId + ); +}; + +/** + * Creates a Microsoft Graph client with on-behalf-of token exchange + * @param {string} accessToken - OpenID Connect access token from user + * @param {string} sub - Subject identifier from token claims + * @returns {Promise} Authenticated Graph API client + */ +const createGraphClient = async (accessToken, sub) => { + try { + // Reason: Use existing OpenID configuration and token exchange pattern from openidStrategy.js + const openidConfig = getOpenIdConfig(); + const exchangedToken = await exchangeTokenForGraphAccess(openidConfig, accessToken, sub); + + const graphClient = Client.init({ + authProvider: (done) => { + done(null, exchangedToken); + }, + }); + + return graphClient; + } catch (error) { + logger.error('[createGraphClient] Error creating Graph client:', error); + throw error; + } +}; + +/** + * Exchange OpenID token for Graph API access using on-behalf-of flow + * Similar to exchangeAccessTokenIfNeeded in openidStrategy.js but for Graph scopes + * @param {Configuration} config - OpenID configuration + * @param {string} accessToken - Original access token + * @param {string} sub - Subject identifier + * @returns {Promise} Graph API access token + */ +const exchangeTokenForGraphAccess = async (config, accessToken, sub) => { + try { + const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS); + const cacheKey = `${sub}:graph`; + + const cachedToken = await tokensCache.get(cacheKey); + if (cachedToken) { + return cachedToken.access_token; + } + + const graphScopes = process.env.OPENID_GRAPH_SCOPES || 'User.Read,People.Read,Group.Read.All'; + const scopeString = graphScopes + .split(',') + .map((scope) => `https://graph.microsoft.com/${scope}`) + .join(' '); + + const grantResponse = await client.genericGrantRequest( + config, + 'urn:ietf:params:oauth:grant-type:jwt-bearer', + { + scope: scopeString, + assertion: accessToken, + requested_token_use: 'on_behalf_of', + }, + ); + + await tokensCache.set( + cacheKey, + { + access_token: grantResponse.access_token, + }, + grantResponse.expires_in * 1000, + ); + + return grantResponse.access_token; + } catch (error) { + logger.error('[exchangeTokenForGraphAccess] Token exchange failed:', error); + throw error; + } +}; + +/** + * Search for principals (people and groups) using Microsoft Graph API + * Uses searchContacts first, then searchUsers and searchGroups to fill remaining slots + * @param {string} accessToken - OpenID Connect access token + * @param {string} sub - Subject identifier + * @param {string} query - Search query string + * @param {string} type - Type filter ('users', 'groups', or 'all') + * @param {number} limit - Maximum number of results + * @returns {Promise} Array of principal search results + */ +const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', limit = 10) => { + try { + if (!query || query.trim().length < 2) { + return []; + } + const graphClient = await createGraphClient(accessToken, sub); + let allResults = []; + + if (type === 'users' || type === 'all') { + const contactResults = await searchContacts(graphClient, query, limit); + allResults.push(...contactResults); + } + if (allResults.length >= limit) { + return allResults.slice(0, limit); + } + + if (type === 'users') { + const userResults = await searchUsers(graphClient, query, limit); + allResults.push(...userResults); + } else if (type === 'groups') { + const groupResults = await searchGroups(graphClient, query, limit); + allResults.push(...groupResults); + } else if (type === 'all') { + const [userResults, groupResults] = await Promise.all([ + searchUsers(graphClient, query, limit), + searchGroups(graphClient, query, limit), + ]); + + allResults.push(...userResults, ...groupResults); + } + + const seenIds = new Set(); + const uniqueResults = allResults.filter((result) => { + if (seenIds.has(result.idOnTheSource)) { + return false; + } + seenIds.add(result.idOnTheSource); + return true; + }); + + return uniqueResults.slice(0, limit); + } catch (error) { + logger.error('[searchEntraIdPrincipals] Error searching principals:', error); + return []; + } +}; + +/** + * Get current user's Entra ID group memberships from Microsoft Graph + * Uses /me/memberOf endpoint to get groups the user is a member of + * @param {string} accessToken - OpenID Connect access token + * @param {string} sub - Subject identifier + * @returns {Promise>} Array of group ID strings (GUIDs) + */ +const getUserEntraGroups = async (accessToken, sub) => { + try { + const graphClient = await createGraphClient(accessToken, sub); + + const groupsResponse = await graphClient.api('/me/memberOf').select('id').get(); + + return (groupsResponse.value || []).map((group) => group.id); + } catch (error) { + logger.error('[getUserEntraGroups] Error fetching user groups:', error); + return []; + } +}; + +/** + * Get group members from Microsoft Graph API + * @param {string} accessToken - OpenID Connect access token + * @param {string} sub - Subject identifier + * @param {string} groupId - Entra ID group object ID + * @returns {Promise} Array of member IDs (idOnTheSource values) + */ +const getGroupMembers = async (accessToken, sub, groupId) => { + try { + const graphClient = await createGraphClient(accessToken, sub); + + const membersResponse = await graphClient.api(`/groups/${groupId}/members`).select('id').get(); + + const members = membersResponse.value || []; + return members.map((member) => member.id); + } catch (error) { + logger.error('[getGroupMembers] Error fetching group members:', error); + return []; + } +}; + +/** + * Search for contacts (users only) using Microsoft Graph /me/people endpoint + * Returns mapped TPrincipalSearchResult objects for users only + * @param {Client} graphClient - Authenticated Microsoft Graph client + * @param {string} query - Search query string + * @param {number} limit - Maximum number of results (default: 10) + * @returns {Promise} Array of mapped user contact results + */ +const searchContacts = async (graphClient, query, limit = 10) => { + try { + if (!query || query.trim().length < 2) { + return []; + } + + // Reason: Search only for OrganizationUser (person) type, not groups + const filter = "personType/subclass eq 'OrganizationUser'"; + + let apiCall = graphClient + .api('/me/people') + .search(`"${query}"`) + .select( + 'id,displayName,givenName,surname,userPrincipalName,jobTitle,department,companyName,scoredEmailAddresses,personType,phones', + ) + .header('ConsistencyLevel', 'eventual') + .filter(filter) + .top(limit); + + const contactsResponse = await apiCall.get(); + return (contactsResponse.value || []).map(mapContactToTPrincipalSearchResult); + } catch (error) { + logger.error('[searchContacts] Error searching contacts:', error); + return []; + } +}; + +/** + * Search for users using Microsoft Graph /users endpoint + * Returns mapped TPrincipalSearchResult objects + * @param {Client} graphClient - Authenticated Microsoft Graph client + * @param {string} query - Search query string + * @param {number} limit - Maximum number of results (default: 10) + * @returns {Promise} Array of mapped user results + */ +const searchUsers = async (graphClient, query, limit = 10) => { + try { + if (!query || query.trim().length < 2) { + return []; + } + + // Reason: Search users by display name, email, and user principal name + const usersResponse = await graphClient + .api('/users') + .search( + `"displayName:${query}" OR "userPrincipalName:${query}" OR "mail:${query}" OR "givenName:${query}" OR "surname:${query}"`, + ) + .select( + 'id,displayName,givenName,surname,userPrincipalName,jobTitle,department,companyName,mail,phones', + ) + .header('ConsistencyLevel', 'eventual') + .top(limit) + .get(); + + return (usersResponse.value || []).map(mapUserToTPrincipalSearchResult); + } catch (error) { + logger.error('[searchUsers] Error searching users:', error); + return []; + } +}; + +/** + * Search for groups using Microsoft Graph /groups endpoint + * Returns mapped TPrincipalSearchResult objects, includes all group types + * @param {Client} graphClient - Authenticated Microsoft Graph client + * @param {string} query - Search query string + * @param {number} limit - Maximum number of results (default: 10) + * @returns {Promise} Array of mapped group results + */ +const searchGroups = async (graphClient, query, limit = 10) => { + try { + if (!query || query.trim().length < 2) { + return []; + } + + // Reason: Search all groups by display name and email without filtering group types + const groupsResponse = await graphClient + .api('/groups') + .search(`"displayName:${query}" OR "mail:${query}" OR "mailNickname:${query}"`) + .select('id,displayName,mail,mailNickname,description,groupTypes,resourceProvisioningOptions') + .header('ConsistencyLevel', 'eventual') + .top(limit) + .get(); + + return (groupsResponse.value || []).map(mapGroupToTPrincipalSearchResult); + } catch (error) { + logger.error('[searchGroups] Error searching groups:', error); + return []; + } +}; + +/** + * Test Graph API connectivity and permissions + * @param {string} accessToken - OpenID Connect access token + * @param {string} sub - Subject identifier + * @returns {Promise} Test results with available permissions + */ +const testGraphApiAccess = async (accessToken, sub) => { + try { + const graphClient = await createGraphClient(accessToken, sub); + const results = { + userAccess: false, + peopleAccess: false, + groupsAccess: false, + usersEndpointAccess: false, + groupsEndpointAccess: false, + errors: [], + }; + + // Test User.Read permission + try { + await graphClient.api('/me').select('id,displayName').get(); + results.userAccess = true; + } catch (error) { + results.errors.push(`User.Read: ${error.message}`); + } + + // Test People.Read permission with OrganizationUser filter + try { + await graphClient + .api('/me/people') + .filter("personType/subclass eq 'OrganizationUser'") + .top(1) + .get(); + results.peopleAccess = true; + } catch (error) { + results.errors.push(`People.Read (OrganizationUser): ${error.message}`); + } + + // Test People.Read permission with UnifiedGroup filter + try { + await graphClient + .api('/me/people') + .filter("personType/subclass eq 'UnifiedGroup'") + .top(1) + .get(); + results.groupsAccess = true; + } catch (error) { + results.errors.push(`People.Read (UnifiedGroup): ${error.message}`); + } + + // Test /users endpoint access (requires User.Read.All or similar) + try { + await graphClient + .api('/users') + .search('"displayName:test"') + .select('id,displayName,userPrincipalName') + .top(1) + .get(); + results.usersEndpointAccess = true; + } catch (error) { + results.errors.push(`Users endpoint: ${error.message}`); + } + + // Test /groups endpoint access (requires Group.Read.All or similar) + try { + await graphClient + .api('/groups') + .search('"displayName:test"') + .select('id,displayName,mail') + .top(1) + .get(); + results.groupsEndpointAccess = true; + } catch (error) { + results.errors.push(`Groups endpoint: ${error.message}`); + } + + return results; + } catch (error) { + logger.error('[testGraphApiAccess] Error testing Graph API access:', error); + return { + userAccess: false, + peopleAccess: false, + groupsAccess: false, + usersEndpointAccess: false, + groupsEndpointAccess: false, + errors: [error.message], + }; + } +}; + +/** + * Map Graph API user object to TPrincipalSearchResult format + * @param {TGraphUser} user - Raw user object from Graph API + * @returns {TPrincipalSearchResult} Mapped user result + */ +const mapUserToTPrincipalSearchResult = (user) => { + return { + id: null, + type: 'user', + name: user.displayName, + email: user.mail || user.userPrincipalName, + username: user.userPrincipalName, + source: 'entra', + idOnTheSource: user.id, + }; +}; + +/** + * Map Graph API group object to TPrincipalSearchResult format + * @param {TGraphGroup} group - Raw group object from Graph API + * @returns {TPrincipalSearchResult} Mapped group result + */ +const mapGroupToTPrincipalSearchResult = (group) => { + return { + id: null, + type: 'group', + name: group.displayName, + email: group.mail || group.userPrincipalName, + description: group.description, + source: 'entra', + idOnTheSource: group.id, + }; +}; + +/** + * Map Graph API /me/people contact object to TPrincipalSearchResult format + * Handles both user and group contacts from the people endpoint + * @param {TGraphPerson} contact - Raw contact object from Graph API /me/people + * @returns {TPrincipalSearchResult} Mapped contact result + */ +const mapContactToTPrincipalSearchResult = (contact) => { + const isGroup = contact.personType?.class === 'Group'; + const primaryEmail = contact.scoredEmailAddresses?.[0]?.address; + + return { + id: null, + type: isGroup ? 'group' : 'user', + name: contact.displayName, + email: primaryEmail, + username: !isGroup ? contact.userPrincipalName : undefined, + source: 'entra', + idOnTheSource: contact.id, + }; +}; + +module.exports = { + getGroupMembers, + createGraphClient, + getUserEntraGroups, + testGraphApiAccess, + searchEntraIdPrincipals, + exchangeTokenForGraphAccess, + entraIdPrincipalFeatureEnabled, +}; diff --git a/api/server/services/GraphApiService.spec.js b/api/server/services/GraphApiService.spec.js new file mode 100644 index 0000000000..a4e05e5f60 --- /dev/null +++ b/api/server/services/GraphApiService.spec.js @@ -0,0 +1,732 @@ +// Mock all dependencies before importing anything +jest.mock('@microsoft/microsoft-graph-client'); +jest.mock('~/strategies/openidStrategy'); +jest.mock('~/cache/getLogStores'); +jest.mock('~/config', () => ({ + logger: { + error: jest.fn(), + debug: jest.fn(), + }, + createAxiosInstance: jest.fn(() => ({ + create: jest.fn(), + defaults: {}, + })), +})); +jest.mock('~/utils', () => ({ + logAxiosError: jest.fn(), +})); + +// Mock deeper dependencies to prevent loading entire dependency tree +jest.mock('~/server/services/Config', () => ({})); +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: jest.fn(), +})); +jest.mock('~/models', () => ({ + User: {}, + Group: {}, + updateUser: jest.fn(), + findUser: jest.fn(), + createUser: jest.fn(), +})); +jest.mock('librechat-data-provider', () => ({ + CacheKeys: { + OPENID_EXCHANGED_TOKENS: 'openid:exchanged:tokens', + PENDING_REQ: 'pending_req', + CONFIG_STORE: 'config_store', + ROLES: 'roles', + AUDIO_RUNS: 'audio_runs', + MESSAGES: 'messages', + FLOWS: 'flows', + TOKEN_CONFIG: 'token_config', + GEN_TITLE: 'gen_title', + S3_EXPIRY_INTERVAL: 's3_expiry_interval', + MODEL_QUERIES: 'model_queries', + ABORT_KEYS: 'abort_keys', + ENCODED_DOMAINS: 'encoded_domains', + BANS: 'bans', + }, + Time: { + ONE_MINUTE: 60000, + TWO_MINUTES: 120000, + TEN_MINUTES: 600000, + THIRTY_MINUTES: 1800000, + THIRTY_SECONDS: 30000, + }, + ViolationTypes: { + BAN: 'ban', + TOKEN_BALANCE: 'token_balance', + TTS_LIMIT: 'tts_limit', + STT_LIMIT: 'stt_limit', + CONVO_ACCESS: 'convo_access', + TOOL_CALL_LIMIT: 'tool_call_limit', + FILE_UPLOAD_LIMIT: 'file_upload_limit', + VERIFY_EMAIL_LIMIT: 'verify_email_limit', + RESET_PASSWORD_LIMIT: 'reset_password_limit', + ILLEGAL_MODEL_REQUEST: 'illegal_model_request', + }, +})); + +const GraphApiService = require('./GraphApiService'); +const { Client } = require('@microsoft/microsoft-graph-client'); +const getLogStores = require('~/cache/getLogStores'); +const { getOpenIdConfig } = require('~/strategies/openidStrategy'); +const client = require('openid-client'); + +describe('GraphApiService', () => { + let mockGraphClient; + let mockTokensCache; + let mockOpenIdConfig; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock Graph client + mockGraphClient = { + api: jest.fn().mockReturnThis(), + search: jest.fn().mockReturnThis(), + filter: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + header: jest.fn().mockReturnThis(), + top: jest.fn().mockReturnThis(), + get: jest.fn(), + }; + + Client.init.mockReturnValue(mockGraphClient); + + // Mock tokens cache + mockTokensCache = { + get: jest.fn(), + set: jest.fn(), + }; + getLogStores.mockReturnValue(mockTokensCache); + + // Mock OpenID config + mockOpenIdConfig = { + client_id: 'test-client-id', + issuer: 'https://test-issuer.com', + }; + getOpenIdConfig.mockReturnValue(mockOpenIdConfig); + + // Mock openid-client (using the existing jest mock configuration) + if (client.genericGrantRequest) { + client.genericGrantRequest.mockResolvedValue({ + access_token: 'mocked-graph-token', + expires_in: 3600, + }); + } + }); + + describe('Dependency Contract Tests', () => { + it('should fail if getOpenIdConfig interface changes', () => { + // Reason: Ensure getOpenIdConfig returns expected structure + const config = getOpenIdConfig(); + + expect(config).toBeDefined(); + expect(typeof config).toBe('object'); + // Add specific property checks that GraphApiService depends on + expect(config).toHaveProperty('client_id'); + expect(config).toHaveProperty('issuer'); + + // Ensure the function is callable + expect(typeof getOpenIdConfig).toBe('function'); + }); + + it('should fail if openid-client.genericGrantRequest interface changes', () => { + // Reason: Ensure client.genericGrantRequest maintains expected signature + if (client.genericGrantRequest) { + expect(typeof client.genericGrantRequest).toBe('function'); + + // Test that it accepts the expected parameters + const mockCall = client.genericGrantRequest( + mockOpenIdConfig, + 'urn:ietf:params:oauth:grant-type:jwt-bearer', + { + scope: 'test-scope', + assertion: 'test-token', + requested_token_use: 'on_behalf_of', + }, + ); + + expect(mockCall).toBeDefined(); + } + }); + + it('should fail if Microsoft Graph Client interface changes', () => { + // Reason: Ensure Graph Client maintains expected fluent API + expect(typeof Client.init).toBe('function'); + + const client = Client.init({ authProvider: jest.fn() }); + expect(client).toHaveProperty('api'); + expect(typeof client.api).toBe('function'); + }); + }); + + describe('createGraphClient', () => { + it('should create graph client with exchanged token', async () => { + const accessToken = 'test-access-token'; + const sub = 'test-user-id'; + + const result = await GraphApiService.createGraphClient(accessToken, sub); + + expect(getOpenIdConfig).toHaveBeenCalled(); + expect(Client.init).toHaveBeenCalledWith({ + authProvider: expect.any(Function), + }); + expect(result).toBe(mockGraphClient); + }); + + it('should handle token exchange errors gracefully', async () => { + if (client.genericGrantRequest) { + client.genericGrantRequest.mockRejectedValue(new Error('Token exchange failed')); + } + + await expect(GraphApiService.createGraphClient('invalid-token', 'test-user')).rejects.toThrow( + 'Token exchange failed', + ); + }); + }); + + describe('exchangeTokenForGraphAccess', () => { + it('should return cached token if available', async () => { + const cachedToken = { access_token: 'cached-token' }; + mockTokensCache.get.mockResolvedValue(cachedToken); + + const result = await GraphApiService.exchangeTokenForGraphAccess( + mockOpenIdConfig, + 'test-token', + 'test-user', + ); + + expect(result).toBe('cached-token'); + expect(mockTokensCache.get).toHaveBeenCalledWith('test-user:graph'); + if (client.genericGrantRequest) { + expect(client.genericGrantRequest).not.toHaveBeenCalled(); + } + }); + + it('should exchange token and cache result', async () => { + mockTokensCache.get.mockResolvedValue(null); + + const result = await GraphApiService.exchangeTokenForGraphAccess( + mockOpenIdConfig, + 'test-token', + 'test-user', + ); + + if (client.genericGrantRequest) { + expect(client.genericGrantRequest).toHaveBeenCalledWith( + mockOpenIdConfig, + 'urn:ietf:params:oauth:grant-type:jwt-bearer', + { + scope: + 'https://graph.microsoft.com/User.Read https://graph.microsoft.com/People.Read https://graph.microsoft.com/Group.Read.All', + assertion: 'test-token', + requested_token_use: 'on_behalf_of', + }, + ); + } + + expect(mockTokensCache.set).toHaveBeenCalledWith( + 'test-user:graph', + { access_token: 'mocked-graph-token' }, + 3600000, + ); + + expect(result).toBe('mocked-graph-token'); + }); + + it('should use custom scopes from environment', async () => { + const originalEnv = process.env.OPENID_GRAPH_SCOPES; + process.env.OPENID_GRAPH_SCOPES = 'Custom.Read,Custom.Write'; + + mockTokensCache.get.mockResolvedValue(null); + + await GraphApiService.exchangeTokenForGraphAccess( + mockOpenIdConfig, + 'test-token', + 'test-user', + ); + + if (client.genericGrantRequest) { + expect(client.genericGrantRequest).toHaveBeenCalledWith( + mockOpenIdConfig, + 'urn:ietf:params:oauth:grant-type:jwt-bearer', + { + scope: + 'https://graph.microsoft.com/Custom.Read https://graph.microsoft.com/Custom.Write', + assertion: 'test-token', + requested_token_use: 'on_behalf_of', + }, + ); + } + + process.env.OPENID_GRAPH_SCOPES = originalEnv; + }); + }); + + describe('searchEntraIdPrincipals', () => { + // Mock data used by multiple tests + const mockContactsResponse = { + value: [ + { + id: 'contact-user-1', + displayName: 'John Doe', + userPrincipalName: 'john@company.com', + mail: 'john@company.com', + personType: { class: 'Person', subclass: 'OrganizationUser' }, + scoredEmailAddresses: [{ address: 'john@company.com', relevanceScore: 0.9 }], + }, + { + id: 'contact-group-1', + displayName: 'Marketing Team', + mail: 'marketing@company.com', + personType: { class: 'Group', subclass: 'UnifiedGroup' }, + scoredEmailAddresses: [{ address: 'marketing@company.com', relevanceScore: 0.8 }], + }, + ], + }; + + const mockUsersResponse = { + value: [ + { + id: 'dir-user-1', + displayName: 'Jane Smith', + userPrincipalName: 'jane@company.com', + mail: 'jane@company.com', + }, + ], + }; + + const mockGroupsResponse = { + value: [ + { + id: 'dir-group-1', + displayName: 'Development Team', + mail: 'dev@company.com', + }, + ], + }; + + beforeEach(() => { + // Reset mock call history for each test + jest.clearAllMocks(); + + // Re-apply the Client.init mock after clearAllMocks + Client.init.mockReturnValue(mockGraphClient); + + // Re-apply openid-client mock + if (client.genericGrantRequest) { + client.genericGrantRequest.mockResolvedValue({ + access_token: 'mocked-graph-token', + expires_in: 3600, + }); + } + + // Re-apply cache mock + mockTokensCache.get.mockResolvedValue(null); // Force token exchange + mockTokensCache.set.mockResolvedValue(); + getLogStores.mockReturnValue(mockTokensCache); + getOpenIdConfig.mockReturnValue(mockOpenIdConfig); + }); + + it('should return empty results for short queries', async () => { + const result = await GraphApiService.searchEntraIdPrincipals('token', 'user', 'a', 'all', 10); + + expect(result).toEqual([]); + expect(mockGraphClient.api).not.toHaveBeenCalled(); + }); + + it('should search contacts first and additional users for users type', async () => { + // Mock responses for this specific test + const contactsFilteredResponse = { + value: [ + { + id: 'contact-user-1', + displayName: 'John Doe', + userPrincipalName: 'john@company.com', + mail: 'john@company.com', + personType: { class: 'Person', subclass: 'OrganizationUser' }, + scoredEmailAddresses: [{ address: 'john@company.com', relevanceScore: 0.9 }], + }, + ], + }; + + mockGraphClient.get + .mockResolvedValueOnce(contactsFilteredResponse) // contacts call + .mockResolvedValueOnce(mockUsersResponse); // users call + + const result = await GraphApiService.searchEntraIdPrincipals( + 'token', + 'user', + 'john', + 'users', + 10, + ); + + // Should call contacts first with user filter + expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people'); + expect(mockGraphClient.filter).toHaveBeenCalledWith( + "personType/subclass eq 'OrganizationUser'", + ); + + // Should call users endpoint for additional results + expect(mockGraphClient.api).toHaveBeenCalledWith('/users'); + expect(mockGraphClient.search).toHaveBeenCalledWith( + '"displayName:john" OR "userPrincipalName:john" OR "mail:john" OR "givenName:john" OR "surname:john"', + ); + + // Should return TPrincipalSearchResult array + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); // 1 from contacts + 1 from users + expect(result[0]).toMatchObject({ + id: null, + type: 'user', + name: 'John Doe', + email: 'john@company.com', + source: 'entra', + idOnTheSource: 'contact-user-1', + }); + }); + + it('should search groups endpoint only for groups type', async () => { + // Mock responses for this specific test - only groups endpoint called + mockGraphClient.get.mockResolvedValueOnce(mockGroupsResponse); // only groups call + + const result = await GraphApiService.searchEntraIdPrincipals( + 'token', + 'user', + 'team', + 'groups', + 10, + ); + + // Should NOT call contacts for groups type + expect(mockGraphClient.api).not.toHaveBeenCalledWith('/me/people'); + + // Should call groups endpoint only + expect(mockGraphClient.api).toHaveBeenCalledWith('/groups'); + expect(mockGraphClient.search).toHaveBeenCalledWith( + '"displayName:team" OR "mail:team" OR "mailNickname:team"', + ); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(1); // 1 from groups only + }); + + it('should search all endpoints for all type', async () => { + // Mock responses for this specific test + mockGraphClient.get + .mockResolvedValueOnce(mockContactsResponse) // contacts call (both user and group) + .mockResolvedValueOnce(mockUsersResponse) // users call + .mockResolvedValueOnce(mockGroupsResponse); // groups call + + const result = await GraphApiService.searchEntraIdPrincipals( + 'token', + 'user', + 'test', + 'all', + 10, + ); + + // Should call contacts with user filter only + expect(mockGraphClient.filter).toHaveBeenCalledWith( + "personType/subclass eq 'OrganizationUser'", + ); + + // Should call both users and groups endpoints + expect(mockGraphClient.api).toHaveBeenCalledWith('/users'); + expect(mockGraphClient.api).toHaveBeenCalledWith('/groups'); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(4); // 2 from contacts + 1 from users + 1 from groups + }); + + it('should early exit if contacts reach limit', async () => { + // Mock contacts to return exactly the limit + const limitedContactsResponse = { + value: Array(10).fill({ + id: 'contact-1', + displayName: 'Contact User', + mail: 'contact@company.com', + personType: { class: 'Person', subclass: 'OrganizationUser' }, + }), + }; + + mockGraphClient.get.mockResolvedValueOnce(limitedContactsResponse); + + const result = await GraphApiService.searchEntraIdPrincipals( + 'token', + 'user', + 'test', + 'all', + 10, + ); + + // Should call contacts first + expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people'); + // Should not call users endpoint since limit was reached + expect(mockGraphClient.api).not.toHaveBeenCalledWith('/users'); + + expect(result).toHaveLength(10); + }); + + it('should deduplicate results based on idOnTheSource', async () => { + // Mock responses with duplicate IDs + const duplicateContactsResponse = { + value: [ + { + id: 'duplicate-id', + displayName: 'John Doe', + mail: 'john@company.com', + personType: { class: 'Person', subclass: 'OrganizationUser' }, + }, + ], + }; + + const duplicateUsersResponse = { + value: [ + { + id: 'duplicate-id', // Same ID as contact + displayName: 'John Doe', + mail: 'john@company.com', + }, + ], + }; + + mockGraphClient.get + .mockResolvedValueOnce(duplicateContactsResponse) + .mockResolvedValueOnce(duplicateUsersResponse); + + const result = await GraphApiService.searchEntraIdPrincipals( + 'token', + 'user', + 'john', + 'users', + 10, + ); + + // Should only return one result despite duplicate IDs + expect(result).toHaveLength(1); + expect(result[0].idOnTheSource).toBe('duplicate-id'); + }); + + it('should handle Graph API errors gracefully', async () => { + mockGraphClient.get.mockRejectedValue(new Error('Graph API error')); + + const result = await GraphApiService.searchEntraIdPrincipals( + 'token', + 'user', + 'test', + 'all', + 10, + ); + + expect(result).toEqual([]); + }); + }); + + describe('getUserEntraGroups', () => { + it('should fetch user groups from memberOf endpoint', async () => { + const mockGroupsResponse = { + value: [ + { + id: 'group-1', + }, + { + id: 'group-2', + }, + ], + }; + + mockGraphClient.get.mockResolvedValue(mockGroupsResponse); + + const result = await GraphApiService.getUserEntraGroups('token', 'user'); + + expect(mockGraphClient.api).toHaveBeenCalledWith('/me/memberOf'); + expect(mockGraphClient.select).toHaveBeenCalledWith('id'); + + expect(result).toHaveLength(2); + expect(result).toEqual(['group-1', 'group-2']); + }); + + it('should return empty array on error', async () => { + mockGraphClient.get.mockRejectedValue(new Error('API error')); + + const result = await GraphApiService.getUserEntraGroups('token', 'user'); + + expect(result).toEqual([]); + }); + + it('should handle empty response', async () => { + const mockGroupsResponse = { + value: [], + }; + + mockGraphClient.get.mockResolvedValue(mockGroupsResponse); + + const result = await GraphApiService.getUserEntraGroups('token', 'user'); + + expect(result).toEqual([]); + }); + + it('should handle missing value property', async () => { + mockGraphClient.get.mockResolvedValue({}); + + const result = await GraphApiService.getUserEntraGroups('token', 'user'); + + expect(result).toEqual([]); + }); + }); + + describe('testGraphApiAccess', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should test all permissions and return success results', async () => { + // Mock successful responses for all tests + mockGraphClient.get + .mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me test + .mockResolvedValueOnce({ value: [] }) // people OrganizationUser test + .mockResolvedValueOnce({ value: [] }) // people UnifiedGroup test + .mockResolvedValueOnce({ value: [] }) // /users endpoint test + .mockResolvedValueOnce({ value: [] }); // /groups endpoint test + + const result = await GraphApiService.testGraphApiAccess('token', 'user'); + + expect(result).toEqual({ + userAccess: true, + peopleAccess: true, + groupsAccess: true, + usersEndpointAccess: true, + groupsEndpointAccess: true, + errors: [], + }); + + // Verify all endpoints were tested + expect(mockGraphClient.api).toHaveBeenCalledWith('/me'); + expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people'); + expect(mockGraphClient.api).toHaveBeenCalledWith('/users'); + expect(mockGraphClient.api).toHaveBeenCalledWith('/groups'); + expect(mockGraphClient.filter).toHaveBeenCalledWith( + "personType/subclass eq 'OrganizationUser'", + ); + expect(mockGraphClient.filter).toHaveBeenCalledWith("personType/subclass eq 'UnifiedGroup'"); + expect(mockGraphClient.search).toHaveBeenCalledWith('"displayName:test"'); + }); + + it('should handle partial failures and record errors', async () => { + // Mock mixed success/failure responses + mockGraphClient.get + .mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me success + .mockRejectedValueOnce(new Error('People access denied')) // people OrganizationUser fail + .mockResolvedValueOnce({ value: [] }) // people UnifiedGroup success + .mockRejectedValueOnce(new Error('Users endpoint access denied')) // /users fail + .mockResolvedValueOnce({ value: [] }); // /groups success + + const result = await GraphApiService.testGraphApiAccess('token', 'user'); + + expect(result).toEqual({ + userAccess: true, + peopleAccess: false, + groupsAccess: true, + usersEndpointAccess: false, + groupsEndpointAccess: true, + errors: [ + 'People.Read (OrganizationUser): People access denied', + 'Users endpoint: Users endpoint access denied', + ], + }); + }); + + it('should handle complete Graph client creation failure', async () => { + // Mock token exchange failure to test error handling + if (client.genericGrantRequest) { + client.genericGrantRequest.mockRejectedValue(new Error('Token exchange failed')); + } + + const result = await GraphApiService.testGraphApiAccess('invalid-token', 'user'); + + expect(result).toEqual({ + userAccess: false, + peopleAccess: false, + groupsAccess: false, + usersEndpointAccess: false, + groupsEndpointAccess: false, + errors: ['Token exchange failed'], + }); + }); + + it('should record all permission errors', async () => { + // Mock all requests to fail + mockGraphClient.get + .mockRejectedValueOnce(new Error('User.Read denied')) + .mockRejectedValueOnce(new Error('People.Read OrganizationUser denied')) + .mockRejectedValueOnce(new Error('People.Read UnifiedGroup denied')) + .mockRejectedValueOnce(new Error('Users directory access denied')) + .mockRejectedValueOnce(new Error('Groups directory access denied')); + + const result = await GraphApiService.testGraphApiAccess('token', 'user'); + + expect(result).toEqual({ + userAccess: false, + peopleAccess: false, + groupsAccess: false, + usersEndpointAccess: false, + groupsEndpointAccess: false, + errors: [ + 'User.Read: User.Read denied', + 'People.Read (OrganizationUser): People.Read OrganizationUser denied', + 'People.Read (UnifiedGroup): People.Read UnifiedGroup denied', + 'Users endpoint: Users directory access denied', + 'Groups endpoint: Groups directory access denied', + ], + }); + }); + + it('should test new endpoints with correct search patterns', async () => { + // Mock successful responses for endpoint testing + mockGraphClient.get + .mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me + .mockResolvedValueOnce({ value: [] }) // people OrganizationUser + .mockResolvedValueOnce({ value: [] }) // people UnifiedGroup + .mockResolvedValueOnce({ value: [] }) // /users + .mockResolvedValueOnce({ value: [] }); // /groups + + await GraphApiService.testGraphApiAccess('token', 'user'); + + // Verify /users endpoint test + expect(mockGraphClient.api).toHaveBeenCalledWith('/users'); + expect(mockGraphClient.search).toHaveBeenCalledWith('"displayName:test"'); + expect(mockGraphClient.select).toHaveBeenCalledWith('id,displayName,userPrincipalName'); + + // Verify /groups endpoint test + expect(mockGraphClient.api).toHaveBeenCalledWith('/groups'); + expect(mockGraphClient.select).toHaveBeenCalledWith('id,displayName,mail'); + }); + + it('should handle endpoint-specific permission failures', async () => { + // Mock specific endpoint failures + mockGraphClient.get + .mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me success + .mockResolvedValueOnce({ value: [] }) // people OrganizationUser success + .mockResolvedValueOnce({ value: [] }) // people UnifiedGroup success + .mockRejectedValueOnce(new Error('Insufficient privileges')) // /users fail (User.Read.All needed) + .mockRejectedValueOnce(new Error('Access denied to groups')); // /groups fail (Group.Read.All needed) + + const result = await GraphApiService.testGraphApiAccess('token', 'user'); + + expect(result).toEqual({ + userAccess: true, + peopleAccess: true, + groupsAccess: true, + usersEndpointAccess: false, + groupsEndpointAccess: false, + errors: [ + 'Users endpoint: Insufficient privileges', + 'Groups endpoint: Access denied to groups', + ], + }); + }); + }); +}); diff --git a/api/server/services/PermissionService.js b/api/server/services/PermissionService.js new file mode 100644 index 0000000000..4295cd375f --- /dev/null +++ b/api/server/services/PermissionService.js @@ -0,0 +1,680 @@ +const mongoose = require('mongoose'); +const { getTransactionSupport, logger } = require('@librechat/data-schemas'); +const { + entraIdPrincipalFeatureEnabled, + getUserEntraGroups, + getGroupMembers, +} = require('~/server/services/GraphApiService'); +const { + findGroupByExternalId, + findRoleByIdentifier, + getUserPrincipals, + createGroup, + createUser, + updateUser, + findUser, +} = require('~/models'); +const { AclEntry, AccessRole, Group } = require('~/db/models'); + +/** @type {boolean|null} */ +let transactionSupportCache = null; + +/** + * @import { TPrincipal } from 'librechat-data-provider' + */ +/** + * Grant a permission to a principal for a resource using a role + * @param {Object} params - Parameters for granting role-based permission + * @param {string} params.principalType - 'user', 'group', or 'public' + * @param {string|mongoose.Types.ObjectId|null} params.principalId - The ID of the principal (null for 'public') + * @param {string} params.resourceType - Type of resource (e.g., 'agent') + * @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource + * @param {string} params.accessRoleId - The ID of the role (e.g., 'agent_viewer', 'agent_editor') + * @param {string|mongoose.Types.ObjectId} params.grantedBy - User ID granting the permission + * @param {mongoose.ClientSession} [params.session] - Optional MongoDB session for transactions + * @returns {Promise} The created or updated ACL entry + */ +const grantPermission = async ({ + principalType, + principalId, + resourceType, + resourceId, + accessRoleId, + grantedBy, + session, +}) => { + try { + if (!['user', 'group', 'public'].includes(principalType)) { + throw new Error(`Invalid principal type: ${principalType}`); + } + + if (principalType !== 'public' && !principalId) { + throw new Error('Principal ID is required for user and group principals'); + } + + if (principalId && !mongoose.Types.ObjectId.isValid(principalId)) { + throw new Error(`Invalid principal ID: ${principalId}`); + } + + if (!resourceId || !mongoose.Types.ObjectId.isValid(resourceId)) { + throw new Error(`Invalid resource ID: ${resourceId}`); + } + + // Get the role to determine permission bits + const role = await findRoleByIdentifier(accessRoleId); + if (!role) { + throw new Error(`Role ${accessRoleId} not found`); + } + + // Ensure the role is for the correct resource type + if (role.resourceType !== resourceType) { + throw new Error( + `Role ${accessRoleId} is for ${role.resourceType} resources, not ${resourceType}`, + ); + } + + const query = { + principalType, + resourceType, + resourceId, + }; + + if (principalType !== 'public') { + query.principalId = principalId; + query.principalModel = principalType === 'user' ? 'User' : 'Group'; + } + + const update = { + $set: { + permBits: role.permBits, + roleId: role._id, + grantedBy, + grantedAt: new Date(), + }, + }; + + const options = { + upsert: true, + new: true, + ...(session ? { session } : {}), + }; + + return await AclEntry.findOneAndUpdate(query, update, options); + } catch (error) { + logger.error(`[PermissionService.grantPermission] Error: ${error.message}`); + throw error; + } +}; + +/** + * Check if a user has specific permission bits on a resource + * @param {Object} params - Parameters for checking permissions + * @param {string|mongoose.Types.ObjectId} params.userId - The ID of the user + * @param {string} params.resourceType - Type of resource (e.g., 'agent') + * @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource + * @param {number} params.requiredPermissions - The permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT) + * @returns {Promise} Whether the user has the required permission bits + */ +const checkPermission = async ({ userId, resourceType, resourceId, requiredPermission }) => { + try { + if (typeof requiredPermission !== 'number' || requiredPermission < 1) { + throw new Error('requiredPermission must be a positive number'); + } + + // Get all principals for the user (user + groups + public) + const principals = await getUserPrincipals(userId); + + if (principals.length === 0) { + return false; + } + + // Find any ACL entry matching the principals, resource, and check if it has all required permission bits + const entry = await AclEntry.findOne({ + $or: principals.map((p) => ({ + principalType: p.principalType, + ...(p.principalType !== 'public' && { principalId: p.principalId }), + })), + resourceType, + resourceId, + permBits: { $bitsAllSet: requiredPermission }, + }).lean(); + + return !!entry; + } catch (error) { + logger.error(`[PermissionService.checkPermission] Error: ${error.message}`); + // Re-throw validation errors + if (error.message.includes('requiredPermission must be')) { + throw error; + } + return false; + } +}; + +/** + * Get effective permission bitmask for a user on a resource + * @param {Object} params - Parameters for getting effective permissions + * @param {string|mongoose.Types.ObjectId} params.userId - The ID of the user + * @param {string} params.resourceType - Type of resource (e.g., 'agent') + * @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource + * @returns {Promise} Effective permission bitmask + */ +const getEffectivePermissions = async ({ userId, resourceType, resourceId }) => { + try { + // Get all principals for the user (user + groups + public) + const principals = await getUserPrincipals(userId); + + if (principals.length === 0) { + return 0; + } + + // Find all matching ACL entries + const aclEntries = await AclEntry.find({ + $or: principals.map((p) => ({ + principalType: p.principalType, + ...(p.principalType !== 'public' && { principalId: p.principalId }), + })), + resourceType, + resourceId, + }).lean(); + + if (aclEntries.length === 0) { + return 0; + } + + let effectiveBits = 0; + for (const entry of aclEntries) { + effectiveBits |= entry.permBits; + } + + return effectiveBits; + } catch (error) { + logger.error(`[PermissionService.getEffectivePermissions] Error: ${error.message}`); + return 0; + } +}; + +/** + * Find all resources of a specific type that a user has access to with specific permission bits + * @param {Object} params - Parameters for finding accessible resources + * @param {string|mongoose.Types.ObjectId} params.userId - The ID of the user + * @param {string} params.resourceType - Type of resource (e.g., 'agent') + * @param {number} params.requiredPermissions - The minimum permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT) + * @returns {Promise} Array of resource IDs + */ +const findAccessibleResources = async ({ userId, resourceType, requiredPermissions }) => { + try { + if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) { + throw new Error('requiredPermissions must be a positive number'); + } + + // Get all principals for the user (user + groups + public) + const principals = await getUserPrincipals(userId); + + if (principals.length === 0) { + return []; + } + + // Find all matching ACL entries where user has at least the required permission bits + const entries = await AclEntry.find({ + $or: principals.map((p) => ({ + principalType: p.principalType, + ...(p.principalType !== 'public' && { principalId: p.principalId }), + })), + resourceType, + permBits: { $bitsAllSet: requiredPermissions }, + }).distinct('resourceId'); + + return entries; + } catch (error) { + logger.error(`[PermissionService.findAccessibleResources] Error: ${error.message}`); + // Re-throw validation errors + if (error.message.includes('requiredPermissions must be')) { + throw error; + } + return []; + } +}; + +/** + * Get available roles for a resource type + * @param {Object} params - Parameters for getting available roles + * @param {string} params.resourceType - Type of resource (e.g., 'agent') + * @returns {Promise} Array of role definitions + */ +const getAvailableRoles = async ({ resourceType }) => { + try { + return await AccessRole.find({ resourceType }).lean(); + } catch (error) { + logger.error(`[PermissionService.getAvailableRoles] Error: ${error.message}`); + return []; + } +}; + +/** + * Ensures a principal exists in the database based on TPrincipal data + * Creates user if it doesn't exist locally (for Entra ID users) + * @param {Object} principal - TPrincipal object from frontend + * @param {string} principal.type - 'user', 'group', or 'public' + * @param {string} [principal.id] - Local database ID (null for Entra ID principals not yet synced) + * @param {string} principal.name - Display name + * @param {string} [principal.email] - Email address + * @param {string} [principal.source] - 'local' or 'entra' + * @param {string} [principal.idOnTheSource] - Entra ID object ID for external principals + * @returns {Promise} Returns the principalId for database operations, null for public + */ +const ensurePrincipalExists = async function (principal) { + if (principal.type === 'public') { + return null; + } + + if (principal.id) { + return principal.id; + } + + if (principal.type === 'user' && principal.source === 'entra') { + if (!principal.email || !principal.idOnTheSource) { + throw new Error('Entra ID user principals must have email and idOnTheSource'); + } + + let existingUser = await findUser({ idOnTheSource: principal.idOnTheSource }); + + if (!existingUser) { + existingUser = await findUser({ email: principal.email.toLowerCase() }); + } + + if (existingUser) { + if (!existingUser.idOnTheSource && principal.idOnTheSource) { + await updateUser(existingUser._id, { + idOnTheSource: principal.idOnTheSource, + provider: 'openid', + }); + } + return existingUser._id.toString(); + } + + const userData = { + name: principal.name, + email: principal.email.toLowerCase(), + emailVerified: false, + provider: 'openid', + idOnTheSource: principal.idOnTheSource, + }; + + const userId = await createUser(userData, true, false); + return userId.toString(); + } + + if (principal.type === 'group') { + throw new Error('Group principals should be handled by group-specific methods'); + } + + throw new Error(`Unsupported principal type: ${principal.type}`); +}; + +/** + * Ensures a group principal exists in the database based on TPrincipal data + * Creates group if it doesn't exist locally (for Entra ID groups) + * For Entra ID groups, always synchronizes member IDs when authentication context is provided + * @param {Object} principal - TPrincipal object from frontend + * @param {string} principal.type - Must be 'group' + * @param {string} [principal.id] - Local database ID (null for Entra ID principals not yet synced) + * @param {string} principal.name - Display name + * @param {string} [principal.email] - Email address + * @param {string} [principal.description] - Group description + * @param {string} [principal.source] - 'local' or 'entra' + * @param {string} [principal.idOnTheSource] - Entra ID object ID for external principals + * @param {Object} [authContext] - Optional authentication context for fetching member data + * @param {string} [authContext.accessToken] - Access token for Graph API calls + * @param {string} [authContext.sub] - Subject identifier + * @returns {Promise} Returns the groupId for database operations + */ +const ensureGroupPrincipalExists = async function (principal, authContext = null) { + if (principal.type !== 'group') { + throw new Error(`Invalid principal type: ${principal.type}. Expected 'group'`); + } + + if (principal.source === 'entra') { + if (!principal.name || !principal.idOnTheSource) { + throw new Error('Entra ID group principals must have name and idOnTheSource'); + } + + let memberIds = []; + if (authContext && authContext.accessToken && authContext.sub) { + try { + memberIds = await getGroupMembers( + authContext.accessToken, + authContext.sub, + principal.idOnTheSource, + ); + } catch (error) { + logger.error('Failed to fetch group members from Graph API:', error); + } + } + + let existingGroup = await findGroupByExternalId(principal.idOnTheSource, 'entra'); + + if (!existingGroup && principal.email) { + existingGroup = await Group.findOne({ email: principal.email.toLowerCase() }).lean(); + } + + if (existingGroup) { + const updateData = {}; + let needsUpdate = false; + + if (!existingGroup.idOnTheSource && principal.idOnTheSource) { + updateData.idOnTheSource = principal.idOnTheSource; + updateData.source = 'entra'; + needsUpdate = true; + } + + if (principal.description && existingGroup.description !== principal.description) { + updateData.description = principal.description; + needsUpdate = true; + } + + if (principal.email && existingGroup.email !== principal.email.toLowerCase()) { + updateData.email = principal.email.toLowerCase(); + needsUpdate = true; + } + + if (authContext && authContext.accessToken && authContext.sub) { + updateData.memberIds = memberIds; + needsUpdate = true; + } + + if (needsUpdate) { + await Group.findByIdAndUpdate(existingGroup._id, { $set: updateData }, { new: true }); + } + + return existingGroup._id.toString(); + } + + const groupData = { + name: principal.name, + source: 'entra', + idOnTheSource: principal.idOnTheSource, + memberIds: memberIds, // Store idOnTheSource values of group members (empty if no auth context) + }; + + if (principal.email) { + groupData.email = principal.email.toLowerCase(); + } + + if (principal.description) { + groupData.description = principal.description; + } + + const newGroup = await createGroup(groupData); + return newGroup._id.toString(); + } + if (principal.id && authContext == null) { + return principal.id; + } + + throw new Error(`Unsupported group principal source: ${principal.source}`); +}; + +/** + * Synchronize user's Entra ID group memberships on sign-in + * Gets user's group IDs from GraphAPI and updates memberships only for existing groups in database + * @param {Object} user - User object with authentication context + * @param {string} user.openidId - User's OpenID subject identifier + * @param {string} user.idOnTheSource - User's Entra ID (oid from token claims) + * @param {string} user.provider - Authentication provider ('openid') + * @param {string} accessToken - Access token for Graph API calls + * @param {mongoose.ClientSession} [session] - Optional MongoDB session for transactions + * @returns {Promise} + */ +const syncUserEntraGroupMemberships = async (user, accessToken, session = null) => { + try { + if (!entraIdPrincipalFeatureEnabled(user) || !accessToken || !user.idOnTheSource) { + return; + } + + const entraGroupIds = await getUserEntraGroups(accessToken, user.openidId); + + if (!entraGroupIds || entraGroupIds.length === 0) { + return; + } + + const sessionOptions = session ? { session } : {}; + const entraGroupIdsList = entraGroupIds; + + await Group.updateMany( + { + idOnTheSource: { $in: entraGroupIdsList }, + source: 'entra', + memberIds: { $ne: user.idOnTheSource }, + }, + { $addToSet: { memberIds: user.idOnTheSource } }, + sessionOptions, + ); + + await Group.updateMany( + { + source: 'entra', + memberIds: user.idOnTheSource, + idOnTheSource: { $nin: entraGroupIdsList }, + }, + { $pull: { memberIds: user.idOnTheSource } }, + sessionOptions, + ); + } catch (error) { + logger.error(`[PermissionService.syncUserEntraGroupMemberships] Error syncing groups:`, error); + } +}; + +/** + * Bulk update permissions for a resource (grant, update, revoke) + * Efficiently handles multiple permission changes in a single transaction + * + * @param {Object} params - Parameters for bulk permission update + * @param {string} params.resourceType - Type of resource (e.g., 'agent') + * @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource + * @param {Array} params.updatedPrincipals - Array of principals to grant/update permissions for + * @param {Array} params.revokedPrincipals - Array of principals to revoke permissions from + * @param {string|mongoose.Types.ObjectId} params.grantedBy - User ID making the changes + * @param {mongoose.ClientSession} [params.session] - Optional MongoDB session for transactions + * @returns {Promise} Results object with granted, updated, revoked arrays and error details + */ +const bulkUpdateResourcePermissions = async ({ + resourceType, + resourceId, + updatedPrincipals = [], + revokedPrincipals = [], + grantedBy, + session, +}) => { + const supportsTransactions = await getTransactionSupport(mongoose, transactionSupportCache); + transactionSupportCache = supportsTransactions; + let localSession = session; + let shouldEndSession = false; + + try { + if (!Array.isArray(updatedPrincipals)) { + throw new Error('updatedPrincipals must be an array'); + } + + if (!Array.isArray(revokedPrincipals)) { + throw new Error('revokedPrincipals must be an array'); + } + + if (!resourceId || !mongoose.Types.ObjectId.isValid(resourceId)) { + throw new Error(`Invalid resource ID: ${resourceId}`); + } + + if (!localSession && supportsTransactions) { + localSession = await mongoose.startSession(); + localSession.startTransaction(); + shouldEndSession = true; + } + + const sessionOptions = localSession ? { session: localSession } : {}; + + const roles = await AccessRole.find({ resourceType }).lean(); + const rolesMap = new Map(); + roles.forEach((role) => { + rolesMap.set(role.accessRoleId, role); + }); + + const results = { + granted: [], + updated: [], + revoked: [], + errors: [], + }; + + const bulkWrites = []; + + for (const principal of updatedPrincipals) { + try { + if (!principal.accessRoleId) { + results.errors.push({ + principal, + error: 'accessRoleId is required for updated principals', + }); + continue; + } + + const role = rolesMap.get(principal.accessRoleId); + if (!role) { + results.errors.push({ + principal, + error: `Role ${principal.accessRoleId} not found`, + }); + continue; + } + + const query = { + principalType: principal.type, + resourceType, + resourceId, + }; + + if (principal.type !== 'public') { + query.principalId = principal.id; + } + + const update = { + $set: { + permBits: role.permBits, + roleId: role._id, + grantedBy, + grantedAt: new Date(), + }, + $setOnInsert: { + principalType: principal.type, + resourceType, + resourceId, + ...(principal.type !== 'public' && { + principalId: principal.id, + principalModel: principal.type === 'user' ? 'User' : 'Group', + }), + }, + }; + + bulkWrites.push({ + updateOne: { + filter: query, + update: update, + upsert: true, + }, + }); + + results.granted.push({ + type: principal.type, + id: principal.id, + name: principal.name, + email: principal.email, + source: principal.source, + avatar: principal.avatar, + description: principal.description, + idOnTheSource: principal.idOnTheSource, + accessRoleId: principal.accessRoleId, + memberCount: principal.memberCount, + memberIds: principal.memberIds, + }); + } catch (error) { + results.errors.push({ + principal, + error: error.message, + }); + } + } + + if (bulkWrites.length > 0) { + await AclEntry.bulkWrite(bulkWrites, sessionOptions); + } + + const deleteQueries = []; + for (const principal of revokedPrincipals) { + try { + const query = { + principalType: principal.type, + resourceType, + resourceId, + }; + + if (principal.type !== 'public') { + query.principalId = principal.id; + } + + deleteQueries.push(query); + + results.revoked.push({ + type: principal.type, + id: principal.id, + name: principal.name, + email: principal.email, + source: principal.source, + avatar: principal.avatar, + description: principal.description, + idOnTheSource: principal.idOnTheSource, + memberCount: principal.memberCount, + }); + } catch (error) { + results.errors.push({ + principal, + error: error.message, + }); + } + } + + if (deleteQueries.length > 0) { + await AclEntry.deleteMany( + { + $or: deleteQueries, + }, + sessionOptions, + ); + } + + if (shouldEndSession && supportsTransactions) { + await localSession.commitTransaction(); + } + + return results; + } catch (error) { + if (shouldEndSession && supportsTransactions) { + await localSession.abortTransaction(); + } + logger.error(`[PermissionService.bulkUpdateResourcePermissions] Error: ${error.message}`); + throw error; + } finally { + if (shouldEndSession && localSession) { + localSession.endSession(); + } + } +}; + +module.exports = { + grantPermission, + checkPermission, + getEffectivePermissions, + findAccessibleResources, + getAvailableRoles, + bulkUpdateResourcePermissions, + ensurePrincipalExists, + ensureGroupPrincipalExists, + syncUserEntraGroupMemberships, +}; diff --git a/api/server/services/PermissionService.spec.js b/api/server/services/PermissionService.spec.js new file mode 100644 index 0000000000..ec39b2ccec --- /dev/null +++ b/api/server/services/PermissionService.spec.js @@ -0,0 +1,1058 @@ +const mongoose = require('mongoose'); +const { RoleBits } = require('@librechat/data-schemas'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { + bulkUpdateResourcePermissions, + getEffectivePermissions, + findAccessibleResources, + getAvailableRoles, + grantPermission, + checkPermission, +} = require('./PermissionService'); +const { findRoleByIdentifier, getUserPrincipals } = require('~/models'); +const { AclEntry, AccessRole } = require('~/db/models'); + +// Mock the getTransactionSupport function for testing +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + getTransactionSupport: jest.fn().mockResolvedValue(false), +})); + +// Mock GraphApiService to prevent config loading issues +jest.mock('~/server/services/GraphApiService', () => ({ + getGroupMembers: jest.fn().mockResolvedValue([]), +})); + +// Mock the logger +jest.mock('~/config', () => ({ + logger: { + error: jest.fn(), + }, +})); + +let mongoServer; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + await mongoose.connect(mongoUri); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await mongoose.connection.dropDatabase(); + // Seed some roles for testing + await AccessRole.create([ + { + accessRoleId: 'agent_viewer', + name: 'Agent Viewer', + description: 'Can view agents', + resourceType: 'agent', + permBits: RoleBits.VIEWER, // VIEW permission + }, + { + accessRoleId: 'agent_editor', + name: 'Agent Editor', + description: 'Can edit agents', + resourceType: 'agent', + permBits: RoleBits.EDITOR, // VIEW + EDIT permissions + }, + { + accessRoleId: 'agent_owner', + name: 'Agent Owner', + description: 'Full control over agents', + resourceType: 'agent', + permBits: RoleBits.OWNER, // VIEW + EDIT + DELETE + SHARE permissions + }, + { + accessRoleId: 'project_viewer', + name: 'Project Viewer', + description: 'Can view projects', + resourceType: 'project', + permBits: RoleBits.VIEWER, + }, + { + accessRoleId: 'project_editor', + name: 'Project Editor', + description: 'Can edit projects', + resourceType: 'project', + permBits: RoleBits.EDITOR, + }, + { + accessRoleId: 'project_manager', + name: 'Project Manager', + description: 'Can manage projects', + resourceType: 'project', + permBits: RoleBits.MANAGER, + }, + { + accessRoleId: 'project_owner', + name: 'Project Owner', + description: 'Full control over projects', + resourceType: 'project', + permBits: RoleBits.OWNER, + }, + ]); +}); + +// Mock getUserPrincipals to avoid depending on the actual implementation +jest.mock('~/models/userGroupMethods', () => ({ + getUserPrincipals: jest.fn(), +})); + +describe('PermissionService', () => { + // Common test data + const userId = new mongoose.Types.ObjectId(); + const groupId = new mongoose.Types.ObjectId(); + const resourceId = new mongoose.Types.ObjectId(); + const grantedById = new mongoose.Types.ObjectId(); + + describe('grantPermission', () => { + test('should grant permission to a user with a role', async () => { + const entry = await grantPermission({ + principalType: 'user', + principalId: userId, + resourceType: 'agent', + resourceId, + accessRoleId: 'agent_viewer', + grantedBy: grantedById, + }); + + expect(entry).toBeDefined(); + expect(entry.principalType).toBe('user'); + expect(entry.principalId.toString()).toBe(userId.toString()); + expect(entry.principalModel).toBe('User'); + expect(entry.resourceType).toBe('agent'); + expect(entry.resourceId.toString()).toBe(resourceId.toString()); + + // Get the role to verify the permission bits are correctly set + const role = await findRoleByIdentifier('agent_viewer'); + expect(entry.permBits).toBe(role.permBits); + expect(entry.roleId.toString()).toBe(role._id.toString()); + expect(entry.grantedBy.toString()).toBe(grantedById.toString()); + expect(entry.grantedAt).toBeInstanceOf(Date); + }); + + test('should grant permission to a group with a role', async () => { + const entry = await grantPermission({ + principalType: 'group', + principalId: groupId, + resourceType: 'agent', + resourceId, + accessRoleId: 'agent_editor', + grantedBy: grantedById, + }); + + expect(entry).toBeDefined(); + expect(entry.principalType).toBe('group'); + expect(entry.principalId.toString()).toBe(groupId.toString()); + expect(entry.principalModel).toBe('Group'); + + // Get the role to verify the permission bits are correctly set + const role = await findRoleByIdentifier('agent_editor'); + expect(entry.permBits).toBe(role.permBits); + expect(entry.roleId.toString()).toBe(role._id.toString()); + }); + + test('should grant public permission with a role', async () => { + const entry = await grantPermission({ + principalType: 'public', + principalId: null, + resourceType: 'agent', + resourceId, + accessRoleId: 'agent_viewer', + grantedBy: grantedById, + }); + + expect(entry).toBeDefined(); + expect(entry.principalType).toBe('public'); + expect(entry.principalId).toBeUndefined(); + expect(entry.principalModel).toBeUndefined(); + + // Get the role to verify the permission bits are correctly set + const role = await findRoleByIdentifier('agent_viewer'); + expect(entry.permBits).toBe(role.permBits); + expect(entry.roleId.toString()).toBe(role._id.toString()); + }); + + test('should throw error for invalid principal type', async () => { + await expect( + grantPermission({ + principalType: 'invalid', + principalId: userId, + resourceType: 'agent', + resourceId, + accessRoleId: 'agent_viewer', + grantedBy: grantedById, + }), + ).rejects.toThrow('Invalid principal type: invalid'); + }); + + test('should throw error for missing principalId with user type', async () => { + await expect( + grantPermission({ + principalType: 'user', + principalId: null, + resourceType: 'agent', + resourceId, + accessRoleId: 'agent_viewer', + grantedBy: grantedById, + }), + ).rejects.toThrow('Principal ID is required for user and group principals'); + }); + + test('should throw error for non-existent role', async () => { + await expect( + grantPermission({ + principalType: 'user', + principalId: userId, + resourceType: 'agent', + resourceId, + accessRoleId: 'non_existent_role', + grantedBy: grantedById, + }), + ).rejects.toThrow('Role non_existent_role not found'); + }); + + test('should throw error for role-resource type mismatch', async () => { + await expect( + grantPermission({ + principalType: 'user', + principalId: userId, + resourceType: 'agent', + resourceId, + accessRoleId: 'project_viewer', // Project role for agent resource + grantedBy: grantedById, + }), + ).rejects.toThrow('Role project_viewer is for project resources, not agent'); + }); + + test('should update existing permission when granting to same principal and resource', async () => { + // First grant with viewer role + await grantPermission({ + principalType: 'user', + principalId: userId, + resourceType: 'agent', + resourceId, + accessRoleId: 'agent_viewer', + grantedBy: grantedById, + }); + + // Then update to editor role + const updated = await grantPermission({ + principalType: 'user', + principalId: userId, + resourceType: 'agent', + resourceId, + accessRoleId: 'agent_editor', + grantedBy: grantedById, + }); + + const editorRole = await findRoleByIdentifier('agent_editor'); + expect(updated.permBits).toBe(editorRole.permBits); + expect(updated.roleId.toString()).toBe(editorRole._id.toString()); + + // Verify there's only one entry + const entries = await AclEntry.find({ + principalType: 'user', + principalId: userId, + resourceType: 'agent', + resourceId, + }); + expect(entries).toHaveLength(1); + }); + }); + + describe('checkPermission', () => { + let otherResourceId; + + beforeEach(async () => { + // Reset the mock implementation for getUserPrincipals + getUserPrincipals.mockReset(); + + // Setup test data + await grantPermission({ + principalType: 'user', + principalId: userId, + resourceType: 'agent', + resourceId, + accessRoleId: 'agent_viewer', + grantedBy: grantedById, + }); + + otherResourceId = new mongoose.Types.ObjectId(); + await grantPermission({ + principalType: 'group', + principalId: groupId, + resourceType: 'agent', + resourceId: otherResourceId, + accessRoleId: 'agent_editor', + grantedBy: grantedById, + }); + }); + + test('should check permission for user principal', async () => { + // Mock getUserPrincipals to return just the user principal + getUserPrincipals.mockResolvedValue([{ principalType: 'user', principalId: userId }]); + + const hasViewPermission = await checkPermission({ + userId, + resourceType: 'agent', + resourceId, + requiredPermission: 1, // RoleBits.VIEWER // 1 = VIEW + }); + + expect(hasViewPermission).toBe(true); + + // Check higher permission level that user doesn't have + const hasEditPermission = await checkPermission({ + userId, + resourceType: 'agent', + resourceId, + requiredPermission: 3, // RoleBits.EDITOR = VIEW + EDIT + }); + + expect(hasEditPermission).toBe(false); + }); + + test('should check permission for user and group principals', async () => { + // Mock getUserPrincipals to return both user and group principals + getUserPrincipals.mockResolvedValue([ + { principalType: 'user', principalId: userId }, + { principalType: 'group', principalId: groupId }, + ]); + + // Check original resource (user has access) + const hasViewOnOriginal = await checkPermission({ + userId, + resourceType: 'agent', + resourceId, + requiredPermission: 1, // RoleBits.VIEWER // 1 = VIEW + }); + + expect(hasViewOnOriginal).toBe(true); + + // Check other resource (group has access) + const hasViewOnOther = await checkPermission({ + userId, + resourceType: 'agent', + resourceId: otherResourceId, + requiredPermission: 1, // RoleBits.VIEWER // 1 = VIEW + }); + + // Group has agent_editor role which includes viewer permissions + expect(hasViewOnOther).toBe(true); + }); + + test('should check permission for public access', async () => { + const publicResourceId = new mongoose.Types.ObjectId(); + + // Grant public access to a resource + await grantPermission({ + principalType: 'public', + principalId: null, + resourceType: 'agent', + resourceId: publicResourceId, + accessRoleId: 'agent_viewer', + grantedBy: grantedById, + }); + + // Mock getUserPrincipals to return user, group, and public principals + getUserPrincipals.mockResolvedValue([ + { principalType: 'user', principalId: userId }, + { principalType: 'group', principalId: groupId }, + { principalType: 'public' }, + ]); + + const hasPublicAccess = await checkPermission({ + userId, + resourceType: 'agent', + resourceId: publicResourceId, + requiredPermission: 1, // RoleBits.VIEWER // 1 = VIEW + }); + + expect(hasPublicAccess).toBe(true); + }); + + test('should return false for invalid permission bits', async () => { + getUserPrincipals.mockResolvedValue([{ principalType: 'user', principalId: userId }]); + + await expect( + checkPermission({ + userId, + resourceType: 'agent', + resourceId, + requiredPermission: 'invalid', + }), + ).rejects.toThrow('requiredPermission must be a positive number'); + + const nonExistentResource = await checkPermission({ + userId, + resourceType: 'agent', + resourceId: new mongoose.Types.ObjectId(), + requiredPermission: 1, // RoleBits.VIEWER + }); + + expect(nonExistentResource).toBe(false); + }); + + test('should return false if user has no principals', async () => { + getUserPrincipals.mockResolvedValue([]); + + const hasPermission = await checkPermission({ + userId, + resourceType: 'agent', + resourceId, + requiredPermission: 1, // RoleBits.VIEWER + }); + + expect(hasPermission).toBe(false); + }); + }); + + describe('getEffectivePermissions', () => { + beforeEach(async () => { + // Reset the mock implementation for getUserPrincipals + getUserPrincipals.mockReset(); + + // Setup test data with multiple permissions from different sources + await grantPermission({ + principalType: 'user', + principalId: userId, + resourceType: 'agent', + resourceId, + accessRoleId: 'agent_viewer', + grantedBy: grantedById, + }); + + await grantPermission({ + principalType: 'group', + principalId: groupId, + resourceType: 'agent', + resourceId, + accessRoleId: 'agent_editor', + grantedBy: grantedById, + }); + + // Create another resource with public permission + const publicResourceId = new mongoose.Types.ObjectId(); + await grantPermission({ + principalType: 'public', + principalId: null, + resourceType: 'agent', + resourceId: publicResourceId, + accessRoleId: 'agent_viewer', + grantedBy: grantedById, + }); + + // Setup a resource with inherited permission + const projectId = new mongoose.Types.ObjectId(); + const childResourceId = new mongoose.Types.ObjectId(); + + await grantPermission({ + principalType: 'user', + principalId: userId, + resourceType: 'project', + resourceId: projectId, + accessRoleId: 'project_viewer', + grantedBy: grantedById, + }); + + await AclEntry.create({ + principalType: 'user', + principalId: userId, + principalModel: 'User', + resourceType: 'agent', + resourceId: childResourceId, + permBits: RoleBits.VIEWER, + roleId: (await findRoleByIdentifier('agent_viewer'))._id, + grantedBy: grantedById, + grantedAt: new Date(), + inheritedFrom: projectId, + }); + }); + + test('should get effective permissions from multiple sources', async () => { + // Mock getUserPrincipals to return both user and group principals + getUserPrincipals.mockResolvedValue([ + { principalType: 'user', principalId: userId }, + { principalType: 'group', principalId: groupId }, + ]); + + const effective = await getEffectivePermissions({ + userId, + resourceType: 'agent', + resourceId, + }); + + // Should return the combined permission bits from both user (VIEWER=1) and group (EDITOR=3) + // EDITOR includes VIEWER, so result should be 3 (VIEW + EDIT) + expect(effective).toBe(RoleBits.EDITOR); // 3 = VIEW + EDIT + }); + + test('should get effective permissions from inherited permissions', async () => { + // Find the child resource ID + const inheritedEntry = await AclEntry.findOne({ inheritedFrom: { $exists: true } }); + const childResourceId = inheritedEntry.resourceId; + + // Mock getUserPrincipals to return user principal + getUserPrincipals.mockResolvedValue([{ principalType: 'user', principalId: userId }]); + + const effective = await getEffectivePermissions({ + userId, + resourceType: 'agent', + resourceId: childResourceId, + }); + + // Should return VIEWER permission bits from inherited permission + expect(effective).toBe(RoleBits.VIEWER); // 1 = VIEW + }); + + test('should return 0 for non-existent permissions', async () => { + getUserPrincipals.mockResolvedValue([{ principalType: 'user', principalId: userId }]); + + const nonExistentResource = new mongoose.Types.ObjectId(); + const effective = await getEffectivePermissions({ + userId, + resourceType: 'agent', + resourceId: nonExistentResource, + }); + + // Should return 0 for no permissions + expect(effective).toBe(0); + }); + + test('should return 0 if user has no principals', async () => { + getUserPrincipals.mockResolvedValue([]); + + const effective = await getEffectivePermissions({ + userId, + resourceType: 'agent', + resourceId, + }); + + // Should return 0 for no permissions + expect(effective).toBe(0); + }); + }); + + describe('findAccessibleResources', () => { + beforeEach(async () => { + // Reset the mock implementation for getUserPrincipals + getUserPrincipals.mockReset(); + + // Setup test data with multiple resources + const resource1 = new mongoose.Types.ObjectId(); + const resource2 = new mongoose.Types.ObjectId(); + const resource3 = new mongoose.Types.ObjectId(); + + // User can view resource 1 + await grantPermission({ + principalType: 'user', + principalId: userId, + resourceType: 'agent', + resourceId: resource1, + accessRoleId: 'agent_viewer', + grantedBy: grantedById, + }); + + // User can edit resource 2 + await grantPermission({ + principalType: 'user', + principalId: userId, + resourceType: 'agent', + resourceId: resource2, + accessRoleId: 'agent_editor', + grantedBy: grantedById, + }); + + // Group can view resource 3 + await grantPermission({ + principalType: 'group', + principalId: groupId, + resourceType: 'agent', + resourceId: resource3, + accessRoleId: 'agent_viewer', + grantedBy: grantedById, + }); + }); + + test('should find resources user can view', async () => { + // Mock getUserPrincipals to return user principal + getUserPrincipals.mockResolvedValue([{ principalType: 'user', principalId: userId }]); + + const viewableResources = await findAccessibleResources({ + userId, + resourceType: 'agent', + requiredPermissions: 1, // RoleBits.VIEWER // 1 = VIEW + }); + + // Should find both resources (viewer role is included in editor role) + expect(viewableResources).toHaveLength(2); + }); + + test('should find resources user can edit', async () => { + // Mock getUserPrincipals to return user principal + getUserPrincipals.mockResolvedValue([{ principalType: 'user', principalId: userId }]); + + const editableResources = await findAccessibleResources({ + userId, + resourceType: 'agent', + requiredPermissions: 3, // RoleBits.EDITOR = VIEW + EDIT + }); + + // Should find only one resource (only the editor resource has EDIT permission) + expect(editableResources).toHaveLength(1); + }); + + test('should find resources accessible via group membership', async () => { + // Mock getUserPrincipals to return user and group principals + getUserPrincipals.mockResolvedValue([ + { principalType: 'user', principalId: userId }, + { principalType: 'group', principalId: groupId }, + ]); + + const viewableResources = await findAccessibleResources({ + userId, + resourceType: 'agent', + requiredPermissions: 1, // RoleBits.VIEWER // 1 = VIEW + }); + + // Should find all three resources + expect(viewableResources).toHaveLength(3); + }); + + test('should return empty array for invalid permissions', async () => { + getUserPrincipals.mockResolvedValue([{ principalType: 'user', principalId: userId }]); + + await expect( + findAccessibleResources({ + userId, + resourceType: 'agent', + requiredPermissions: 'invalid', + }), + ).rejects.toThrow('requiredPermissions must be a positive number'); + + const nonExistentType = await findAccessibleResources({ + userId, + resourceType: 'non_existent_type', + requiredPermissions: 1, // RoleBits.VIEWER + }); + + expect(nonExistentType).toEqual([]); + }); + + test('should return empty array if user has no principals', async () => { + getUserPrincipals.mockResolvedValue([]); + + const resources = await findAccessibleResources({ + userId, + resourceType: 'agent', + requiredPermissions: 1, // RoleBits.VIEWER + }); + + expect(resources).toEqual([]); + }); + }); + + describe('getAvailableRoles', () => { + test('should get all roles for a resource type', async () => { + const roles = await getAvailableRoles({ + resourceType: 'agent', + }); + + expect(roles).toHaveLength(3); + expect(roles.map((r) => r.accessRoleId).sort()).toEqual( + ['agent_editor', 'agent_owner', 'agent_viewer'].sort(), + ); + }); + + test('should return empty array for non-existent resource type', async () => { + const roles = await getAvailableRoles({ + resourceType: 'non_existent_type', + }); + + expect(roles).toEqual([]); + }); + }); + + describe('bulkUpdateResourcePermissions', () => { + const otherUserId = new mongoose.Types.ObjectId(); + const anotherGroupId = new mongoose.Types.ObjectId(); + + beforeEach(async () => { + // Setup existing permissions for testing + await grantPermission({ + principalType: 'user', + principalId: userId, + resourceType: 'agent', + resourceId, + accessRoleId: 'agent_viewer', + grantedBy: grantedById, + }); + + await grantPermission({ + principalType: 'group', + principalId: groupId, + resourceType: 'agent', + resourceId, + accessRoleId: 'agent_editor', + grantedBy: grantedById, + }); + + await grantPermission({ + principalType: 'public', + principalId: null, + resourceType: 'agent', + resourceId, + accessRoleId: 'agent_viewer', + grantedBy: grantedById, + }); + }); + + test('should grant new permissions in bulk', async () => { + const newResourceId = new mongoose.Types.ObjectId(); + const updatedPrincipals = [ + { + type: 'user', + id: userId, + accessRoleId: 'agent_viewer', + }, + { + type: 'user', + id: otherUserId, + accessRoleId: 'agent_editor', + }, + { + type: 'group', + id: groupId, + accessRoleId: 'agent_owner', + }, + ]; + + const results = await bulkUpdateResourcePermissions({ + resourceType: 'agent', + resourceId: newResourceId, + updatedPrincipals, + grantedBy: grantedById, + }); + + expect(results.granted).toHaveLength(3); + expect(results.updated).toHaveLength(0); + expect(results.revoked).toHaveLength(0); + expect(results.errors).toHaveLength(0); + + // Verify permissions were created + const aclEntries = await AclEntry.find({ + resourceType: 'agent', + resourceId: newResourceId, + }); + expect(aclEntries).toHaveLength(3); + }); + + test('should update existing permissions in bulk', async () => { + const updatedPrincipals = [ + { + type: 'user', + id: userId, + accessRoleId: 'agent_editor', // Upgrade from viewer to editor + }, + { + type: 'group', + id: groupId, + accessRoleId: 'agent_owner', // Upgrade from editor to owner + }, + { + type: 'public', + accessRoleId: 'agent_viewer', // Keep same role + }, + ]; + + const results = await bulkUpdateResourcePermissions({ + resourceType: 'agent', + resourceId, + updatedPrincipals, + grantedBy: grantedById, + }); + + // Function puts all updatedPrincipals in granted array since it uses upserts + expect(results.granted).toHaveLength(3); + expect(results.updated).toHaveLength(0); + expect(results.revoked).toHaveLength(0); + expect(results.errors).toHaveLength(0); + + // Verify updates + const userEntry = await AclEntry.findOne({ + principalType: 'user', + principalId: userId, + resourceType: 'agent', + resourceId, + }).populate('roleId', 'accessRoleId'); + expect(userEntry.roleId.accessRoleId).toBe('agent_editor'); + + const groupEntry = await AclEntry.findOne({ + principalType: 'group', + principalId: groupId, + resourceType: 'agent', + resourceId, + }).populate('roleId', 'accessRoleId'); + expect(groupEntry.roleId.accessRoleId).toBe('agent_owner'); + }); + + test('should revoke specified permissions', async () => { + const revokedPrincipals = [ + { + type: 'group', + id: groupId, + }, + { + type: 'public', + }, + ]; + + const results = await bulkUpdateResourcePermissions({ + resourceType: 'agent', + resourceId, + revokedPrincipals, + grantedBy: grantedById, + }); + + expect(results.granted).toHaveLength(0); + expect(results.updated).toHaveLength(0); + expect(results.revoked).toHaveLength(2); // Group and public revoked + expect(results.errors).toHaveLength(0); + + // Verify only user permission remains + const remainingEntries = await AclEntry.find({ + resourceType: 'agent', + resourceId, + }); + expect(remainingEntries).toHaveLength(1); + expect(remainingEntries[0].principalType).toBe('user'); + expect(remainingEntries[0].principalId.toString()).toBe(userId.toString()); + }); + + test('should handle mixed operations (grant, update, revoke)', async () => { + const updatedPrincipals = [ + { + type: 'user', + id: userId, + accessRoleId: 'agent_owner', // Update existing + }, + { + type: 'user', + id: otherUserId, + accessRoleId: 'agent_viewer', // New permission + }, + ]; + + const revokedPrincipals = [ + { + type: 'group', + id: groupId, + }, + { + type: 'public', + }, + ]; + + const results = await bulkUpdateResourcePermissions({ + resourceType: 'agent', + resourceId, + updatedPrincipals, + revokedPrincipals, + grantedBy: grantedById, + }); + + expect(results.granted).toHaveLength(2); // Both users granted (function uses upserts) + expect(results.updated).toHaveLength(0); + expect(results.revoked).toHaveLength(2); // Group and public revoked + expect(results.errors).toHaveLength(0); + + // Verify final state + const finalEntries = await AclEntry.find({ + resourceType: 'agent', + resourceId, + }).populate('roleId', 'accessRoleId'); + + expect(finalEntries).toHaveLength(2); + + const userEntry = finalEntries.find((e) => e.principalId.toString() === userId.toString()); + expect(userEntry.roleId.accessRoleId).toBe('agent_owner'); + + const otherUserEntry = finalEntries.find( + (e) => e.principalId.toString() === otherUserId.toString(), + ); + expect(otherUserEntry.roleId.accessRoleId).toBe('agent_viewer'); + }); + + test('should handle errors for invalid roles gracefully', async () => { + const updatedPrincipals = [ + { + type: 'user', + id: userId, + accessRoleId: 'agent_viewer', // Valid + }, + { + type: 'user', + id: otherUserId, + accessRoleId: 'non_existent_role', // Invalid + }, + { + type: 'group', + id: groupId, + accessRoleId: 'project_viewer', // Wrong resource type + }, + ]; + + const results = await bulkUpdateResourcePermissions({ + resourceType: 'agent', + resourceId, + updatedPrincipals, + grantedBy: grantedById, + }); + + expect(results.granted).toHaveLength(1); // Only valid user permission + expect(results.updated).toHaveLength(0); + expect(results.revoked).toHaveLength(0); + expect(results.errors).toHaveLength(2); // Two invalid permissions + + // Check error details + expect(results.errors[0].error).toContain('Role non_existent_role not found'); + expect(results.errors[1].error).toContain('Role project_viewer not found'); + }); + + test('should handle empty arrays (no operations)', async () => { + const results = await bulkUpdateResourcePermissions({ + resourceType: 'agent', + resourceId, + updatedPrincipals: [], + revokedPrincipals: [], + grantedBy: grantedById, + }); + + expect(results.granted).toHaveLength(0); + expect(results.updated).toHaveLength(0); + expect(results.revoked).toHaveLength(0); + expect(results.errors).toHaveLength(0); + + // Verify no changes to existing permissions (since no operations were performed) + const remainingEntries = await AclEntry.find({ + resourceType: 'agent', + resourceId, + }); + expect(remainingEntries).toHaveLength(3); // Original permissions still exist + }); + + test('should throw error for invalid updatedPrincipals array', async () => { + await expect( + bulkUpdateResourcePermissions({ + resourceType: 'agent', + resourceId, + updatedPrincipals: 'not an array', + grantedBy: grantedById, + }), + ).rejects.toThrow('updatedPrincipals must be an array'); + }); + + test('should throw error for invalid resource ID', async () => { + await expect( + bulkUpdateResourcePermissions({ + resourceType: 'agent', + resourceId: 'invalid-id', + permissions: [], + grantedBy: grantedById, + }), + ).rejects.toThrow('Invalid resource ID: invalid-id'); + }); + + test('should handle public permissions correctly', async () => { + const updatedPrincipals = [ + { + type: 'public', + accessRoleId: 'agent_editor', // Update public permission + }, + { + type: 'user', + id: otherUserId, + accessRoleId: 'agent_viewer', // New user permission + }, + ]; + + const revokedPrincipals = [ + { + type: 'user', + id: userId, + }, + { + type: 'group', + id: groupId, + }, + ]; + + const results = await bulkUpdateResourcePermissions({ + resourceType: 'agent', + resourceId, + updatedPrincipals, + revokedPrincipals, + grantedBy: grantedById, + }); + + expect(results.granted).toHaveLength(2); // Public and new user + expect(results.updated).toHaveLength(0); + expect(results.revoked).toHaveLength(2); // Existing user and group revoked + expect(results.errors).toHaveLength(0); + + // Verify public permission was updated + const publicEntry = await AclEntry.findOne({ + principalType: 'public', + resourceType: 'agent', + resourceId, + }).populate('roleId', 'accessRoleId'); + + expect(publicEntry).toBeDefined(); + expect(publicEntry.roleId.accessRoleId).toBe('agent_editor'); + }); + + test('should work with different resource types', async () => { + // Test with project resources + const projectResourceId = new mongoose.Types.ObjectId(); + const updatedPrincipals = [ + { + type: 'user', + id: userId, + accessRoleId: 'project_viewer', + }, + { + type: 'group', + id: groupId, + accessRoleId: 'project_editor', + }, + ]; + + const results = await bulkUpdateResourcePermissions({ + resourceType: 'project', + resourceId: projectResourceId, + updatedPrincipals, + grantedBy: grantedById, + }); + + expect(results.granted).toHaveLength(2); + expect(results.updated).toHaveLength(0); + expect(results.revoked).toHaveLength(0); + expect(results.errors).toHaveLength(0); + + // Verify permissions were created with correct resource type + const projectEntries = await AclEntry.find({ + resourceType: 'project', + resourceId: projectResourceId, + }); + expect(projectEntries).toHaveLength(2); + expect(projectEntries.every((e) => e.resourceType === 'project')).toBe(true); + }); + }); +}); diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index 2449872a9d..417ee6fd1e 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -365,6 +365,7 @@ async function setupOpenId() { email: userinfo.email || '', emailVerified: userinfo.email_verified || false, name: fullName, + idOnTheSource: userinfo.oid, }; const balanceConfig = await getBalanceConfig(); @@ -375,6 +376,7 @@ async function setupOpenId() { user.openidId = userinfo.sub; user.username = username; user.name = fullName; + user.idOnTheSource = userinfo.oid; } if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) { diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts index 7a6c25d642..55cdbc0455 100644 --- a/client/src/common/agents-types.ts +++ b/client/src/common/agents-types.ts @@ -7,6 +7,7 @@ export type TAgentOption = OptionWithIcon & knowledge_files?: Array<[string, ExtendedFile]>; context_files?: Array<[string, ExtendedFile]>; code_files?: Array<[string, ExtendedFile]>; + _id?: string; }; export type TAgentCapabilities = { diff --git a/client/src/components/SidePanel/Agents/AgentFooter.tsx b/client/src/components/SidePanel/Agents/AgentFooter.tsx index 2b3aa1bcd7..1de71df413 100644 --- a/client/src/components/SidePanel/Agents/AgentFooter.tsx +++ b/client/src/components/SidePanel/Agents/AgentFooter.tsx @@ -1,16 +1,21 @@ import { useWatch, useFormContext } from 'react-hook-form'; -import { SystemRoles, Permissions, PermissionTypes } from 'librechat-data-provider'; +import { + SystemRoles, + Permissions, + PermissionTypes, + PERMISSION_BITS, +} from 'librechat-data-provider'; import type { AgentForm, AgentPanelProps } from '~/common'; -import { useLocalize, useAuthContext, useHasAccess } from '~/hooks'; +import { useLocalize, useAuthContext, useHasAccess, useResourcePermissions } from '~/hooks'; +import GrantAccessDialog from './Sharing/GrantAccessDialog'; import { useUpdateAgentMutation } from '~/data-provider'; import AdvancedButton from './Advanced/AdvancedButton'; +import VersionButton from './Version/VersionButton'; import DuplicateAgent from './DuplicateAgent'; import AdminSettings from './AdminSettings'; import DeleteButton from './DeleteButton'; import { Spinner } from '~/components'; -import ShareAgent from './ShareAgent'; import { Panel } from '~/common'; -import VersionButton from './Version/VersionButton'; export default function AgentFooter({ activePanel, @@ -32,12 +37,17 @@ export default function AgentFooter({ const { control } = methods; const agent = useWatch({ control, name: 'agent' }); const agent_id = useWatch({ control, name: 'id' }); - const hasAccessToShareAgents = useHasAccess({ permissionType: PermissionTypes.AGENTS, permission: Permissions.SHARED_GLOBAL, }); + const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions( + 'agent', + agent?._id || '', + ); + const canShareThisAgent = hasPermission(PERMISSION_BITS.SHARE); + const canDeleteThisAgent = hasPermission(PERMISSION_BITS.DELETE); const renderSaveButton = () => { if (createMutation.isLoading || updateMutation.isLoading) { return