From 65c81955f02aa7386d28d0ed1ada270b6bc0d21d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 20 Jun 2025 18:03:58 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=90=20feat:=20Granular=20Role-based=20?= =?UTF-8?q?Permissions=20+=20Entra=20ID=20Group=20Discovery=20(#7804)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * fix(data-schemas): use partial index for group idOnTheSource uniqueness Replace sparse index with partial filter expression to allow multiple local groups while maintaining unique constraint for external source IDs. The sparse option on compound indexes doesn't work as expected when one field is always present. * fix: imports in migrate-agent-permissions.js * chore(data-schemas): add comprehensive README for data schemas package - Introduced a detailed README.md file outlining the structure, architecture patterns, and best practices for the LibreChat Data Schemas package. - Included guidelines for creating new entities, type definitions, schema files, model factory functions, and database methods. - Added examples and common patterns to enhance understanding and usage of the package. * chore: remove unused translation keys from localization file * ci: fix existing tests based off new permission handling - Renamed test cases to reflect changes in permission checks being handled at the route level. - Updated assertions to verify that agents are returned regardless of user permissions due to the new permission system. - Adjusted mocks in AppService and PermissionService tests to ensure proper functionality without relying on actual implementations. * ci: add unit tests for access control middleware - Introduced tests for the `canAccessAgentResource` middleware to validate permission checks for agent resources. - Implemented tests for various scenarios including user roles, ACL entries, and permission levels. - Added tests for the `checkAccess` function to ensure proper permission handling based on user roles and permissions. - Utilized MongoDB in-memory server for isolated test environments. * refactor: remove unused mocks from GraphApiService tests * ci: enhance AgentFooter tests with improved mocks and permission handling - Updated mocks for `useWatch`, `useAuthContext`, `useHasAccess`, and `useResourcePermissions` to streamline test setup. - Adjusted assertions to reflect changes in UI based on agent ID and user roles. - Replaced `share-agent` component with `grant-access-dialog` in tests to align with recent UI updates. - Added tests for handling null agent data and permissions loading scenarios. * ci: enhance GraphApiService tests with MongoDB in-memory server - Updated test setup to use MongoDB in-memory server for isolated testing. - Refactored beforeEach to beforeAll for database connection management. - Cleared database before each test to ensure a clean state. - Retained existing mocks while improving test structure for better clarity. * ci: enhance GraphApiService tests with additional logger mocks - Added mock implementation for logger methods in GraphApiService tests to improve error and debug logging during test execution. - Ensured existing mocks remain intact while enhancing test coverage and clarity. * chore: address ESLint Warnings * - add cursor-based pagination to getListAgentsByAccess and update handler - add index on updatedAt and _id in agent schema for improved query performance * refactor permission service with reuse of model methods from data-schema package * - Fix ObjectId comparison in getListAgentsHandler using .equals() method instead of strict equality - Add findPubliclyAccessibleResources function to PermissionService for bulk public resource queries - Add hasPublicPermission function to PermissionService for individual resource public permission checks - Update getAgentHandler to use hasPublicPermission for accurate individual agent public status - Replace instanceProjectId-based global checks with isPublic property from backend in client code - Add isPublic property to Agent type definition - Add NODE_TLS_REJECT_UNAUTHORIZED debug setting to VS Code launch config * feat: add check for People.Read scope in searchContacts * fix: add roleId parameter to grantPermission and update tests for GraphApiService * refactor: remove problematic projection pipelines in getResourcePermissions for document db aws compatibility * feat: enhance agent permissions migration with DocumentDB compatibility and add dry-run script * feat: add support for including Entra ID group owners as members in permissions management + fix Group members paging * feat: enforce at least one owner requirement for permission updates and add corresponding localization messages * refactor: remove German locale (must be added via i18n) * chore: linting in `api/models/Agent.js` and removed unused variables * chore: linting, remove unused vars, and remove project-related parameters from `updateAgentHandler` * chore: address ESLint errors * chore: revert removal of unused vars for versioning --------- Co-authored-by: Atef Bellaaj --- .env.example | 15 + .vscode/launch.json | 3 +- api/models/Agent.js | 145 ++- api/models/Agent.spec.js | 16 +- api/package.json | 1 + .../controllers/PermissionsController.js | 437 +++++++ api/server/controllers/agents/v1.js | 151 ++- api/server/index.js | 2 + .../accessResources/canAccessAgentFromBody.js | 97 ++ .../accessResources/canAccessAgentResource.js | 58 + .../canAccessAgentResource.spec.js | 384 ++++++ .../accessResources/canAccessResource.js | 157 +++ .../middleware/accessResources/index.js | 9 + api/server/middleware/index.js | 2 + api/server/middleware/roles/access.spec.js | 251 ++++ 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 | 1 + api/server/services/AppService.js | 3 +- api/server/services/AppService.spec.js | 1 + api/server/services/GraphApiService.js | 525 ++++++++ api/server/services/GraphApiService.spec.js | 720 +++++++++++ api/server/services/PermissionService.js | 721 +++++++++++ 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/AgentSelect.tsx | 4 +- .../SidePanel/Agents/ShareAgent.tsx | 272 ----- .../Agents/Sharing/AccessRolesPicker.tsx | 99 ++ .../Agents/Sharing/GrantAccessDialog.tsx | 266 +++++ .../Sharing/ManagePermissionsDialog.tsx | 349 ++++++ .../Sharing/PeoplePicker/PeoplePicker.tsx | 101 ++ .../PeoplePicker/PeoplePickerSearchItem.tsx | 57 + .../PeoplePicker/SelectedPrincipalsList.tsx | 149 +++ .../Agents/Sharing/PrincipalAvatar.tsx | 101 ++ .../Agents/Sharing/PublicSharingToggle.tsx | 59 + .../Agents/__tests__/AgentFooter.spec.tsx | 251 +++- client/src/components/ui/Dropdown.tsx | 8 +- client/src/components/ui/SearchPicker.tsx | 192 +++ .../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/Endpoint/useEndpoints.ts | 3 +- client/src/hooks/index.ts | 1 + client/src/hooks/useResourcePermissions.ts | 25 + client/src/locales/de/translation.json | 2 +- client/src/locales/en/translation.json | 53 +- client/src/utils/forms.tsx | 3 +- config/migrate-agent-permissions.js | 273 +++++ package-lock.json | 28 + package.json | 5 +- .../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 | 3 + packages/data-provider/src/types/graph.ts | 145 +++ packages/data-provider/src/types/queries.ts | 41 + packages/data-schemas/README.md | 318 +++++ 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 | 294 +++++ .../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/agent.ts | 2 + packages/data-schemas/src/schema/group.ts | 56 + 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 + 99 files changed, 11322 insertions(+), 624 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/canAccessAgentResource.spec.js create mode 100644 api/server/middleware/accessResources/canAccessResource.js create mode 100644 api/server/middleware/accessResources/index.js create mode 100644 api/server/middleware/roles/access.spec.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/README.md 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..c06e812b86 100644 --- a/.env.example +++ b/.env.example @@ -485,6 +485,21 @@ 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 + +# When enabled, entra id groups owners will be considered as members of the group +ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS=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/.vscode/launch.json b/.vscode/launch.json index e393568b16..4b6930f07a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,8 @@ "skipFiles": ["/**"], "program": "${workspaceFolder}/api/server/index.js", "env": { - "NODE_ENV": "production" + "NODE_ENV": "production", + "NODE_TLS_REJECT_UNAUTHORIZED": "0" }, "console": "integratedTerminal", "envFile": "${workspaceFolder}/.env" diff --git a/api/models/Agent.js b/api/models/Agent.js index 04ba8b020e..9bc95c2be0 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'); @@ -23,7 +21,7 @@ const { Agent } = require('~/db/models'); * @throws {Error} If the agent creation fails. */ const createAgent = async (agentData) => { - const { author, ...versionData } = agentData; + const { author: _author, ...versionData } = agentData; const timestamp = new Date(); const initialAgentData = { ...agentData, @@ -126,29 +124,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; }; /** @@ -178,7 +154,7 @@ const isDuplicateVersion = (updateData, currentData, versions, actionsHash = nul 'actionsHash', // Exclude actionsHash from direct comparison ]; - const { $push, $pull, $addToSet, ...directUpdates } = updateData; + const { $push: _$push, $pull: _$pull, $addToSet: _$addToSet, ...directUpdates } = updateData; if (Object.keys(directUpdates).length === 0 && !actionsHash) { return null; @@ -273,7 +249,14 @@ const updateAgent = async (searchParameter, updateData, options = {}) => { const currentAgent = await Agent.findOne(searchParameter); if (currentAgent) { - const { __v, _id, id, versions, author, ...versionData } = currentAgent.toObject(); + const { + __v, + _id, + id: __id, + versions, + author: _author, + ...versionData + } = currentAgent.toObject(); const { $push, $pull, $addToSet, ...directUpdates } = updateData; let actionsHash = null; @@ -464,8 +447,110 @@ const deleteAgent = async (searchParameter) => { return agent; }; +/** + * Get agents by accessible IDs with optional cursor-based pagination. + * @param {Object} params - The parameters for getting accessible agents. + * @param {Array} [params.accessibleIds] - Array of agent ObjectIds the user has ACL access to. + * @param {Object} [params.otherParams] - Additional query parameters (including author filter). + * @param {number} [params.limit] - Number of agents to return (max 100). If not provided, returns all agents. + * @param {string} [params.after] - Cursor for pagination - get agents after this cursor. // base64 encoded JSON string with updatedAt and _id. + * @returns {Promise} A promise that resolves to an object containing the agents data and pagination info. + */ +const getListAgentsByAccess = async ({ + accessibleIds = [], + otherParams = {}, + limit = null, + after = null, +}) => { + const isPaginated = limit !== null && limit !== undefined; + const normalizedLimit = isPaginated ? Math.min(Math.max(1, parseInt(limit) || 20), 100) : null; + + // Build base query combining ACL accessible agents with other filters + const baseQuery = { ...otherParams }; + + if (accessibleIds.length > 0) { + baseQuery._id = { $in: accessibleIds }; + } + + // Add cursor condition + if (after) { + try { + const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8')); + const { updatedAt, _id } = cursor; + + const cursorCondition = { + $or: [ + { updatedAt: { $lt: new Date(updatedAt) } }, + { updatedAt: new Date(updatedAt), _id: { $gt: mongoose.Types.ObjectId(_id) } }, + ], + }; + + // Merge cursor condition with base query + if (Object.keys(baseQuery).length > 0) { + baseQuery.$and = [{ ...baseQuery }, cursorCondition]; + // Remove the original conditions from baseQuery to avoid duplication + Object.keys(baseQuery).forEach((key) => { + if (key !== '$and') delete baseQuery[key]; + }); + } else { + Object.assign(baseQuery, cursorCondition); + } + } catch (error) { + logger.warn('Invalid cursor:', error.message); + } + } + + let query = Agent.find(baseQuery, { + id: 1, + _id: 1, + name: 1, + avatar: 1, + author: 1, + projectIds: 1, + description: 1, + updatedAt: 1, + }).sort({ updatedAt: -1, _id: 1 }); + + // Only apply limit if pagination is requested + if (isPaginated) { + query = query.limit(normalizedLimit + 1); + } + + const agents = await query.lean(); + + const hasMore = isPaginated ? agents.length > normalizedLimit : false; + const data = (isPaginated ? agents.slice(0, normalizedLimit) : agents).map((agent) => { + if (agent.author) { + agent.author = agent.author.toString(); + } + return agent; + }); + + // Generate next cursor only if paginated + let nextCursor = null; + if (isPaginated && hasMore && data.length > 0) { + const lastAgent = agents[normalizedLimit - 1]; + nextCursor = Buffer.from( + JSON.stringify({ + updatedAt: lastAgent.updatedAt.toISOString(), + _id: lastAgent._id.toString(), + }), + ).toString('base64'); + } + + return { + object: 'list', + data, + first_id: data.length > 0 ? data[0].id : null, + last_id: data.length > 0 ? data[data.length - 1].id : null, + has_more: hasMore, + after: nextCursor, + }; +}; + /** * 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. @@ -484,12 +569,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) => { @@ -673,6 +759,7 @@ module.exports = { revertAgentVersion, updateAgentProjects, addAgentResourceFile, + getListAgentsByAccess, removeAgentResourceFiles, generateActionMetadataHash, }; diff --git a/api/models/Agent.spec.js b/api/models/Agent.spec.js index 8953ae0482..18b10f9485 100644 --- a/api/models/Agent.spec.js +++ b/api/models/Agent.spec.js @@ -1633,7 +1633,7 @@ describe('models/Agent', () => { expect(result.version).toBe(1); }); - test('should return null when user is not author and agent has no projectIds', async () => { + test('should return agent even when user is not author (permissions checked at route level)', async () => { const authorId = new mongoose.Types.ObjectId(); const userId = new mongoose.Types.ObjectId(); const agentId = `agent_${uuidv4()}`; @@ -1654,7 +1654,11 @@ describe('models/Agent', () => { model_parameters: { model: 'gpt-4' }, }); - expect(result).toBeFalsy(); + // With the new permission system, loadAgent returns the agent regardless of permissions + // Permission checks are handled at the route level via middleware + expect(result).toBeTruthy(); + expect(result.id).toBe(agentId); + expect(result.name).toBe('Test Agent'); }); test('should handle ephemeral agent with no MCP servers', async () => { @@ -1762,7 +1766,7 @@ describe('models/Agent', () => { } }); - test('should handle loadAgent with agent from different project', async () => { + test('should return agent from different project (permissions checked at route level)', async () => { const authorId = new mongoose.Types.ObjectId(); const userId = new mongoose.Types.ObjectId(); const agentId = `agent_${uuidv4()}`; @@ -1785,7 +1789,11 @@ describe('models/Agent', () => { model_parameters: { model: 'gpt-4' }, }); - expect(result).toBeFalsy(); + // With the new permission system, loadAgent returns the agent regardless of permissions + // Permission checks are handled at the route level via middleware + expect(result).toBeTruthy(); + expect(result.id).toBe(agentId); + expect(result.name).toBe('Project Agent'); }); }); }); 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..97d01284ff --- /dev/null +++ b/api/server/controllers/PermissionsController.js @@ -0,0 +1,437 @@ +/** + * @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', + }, + }, + // Lookup Group information (for group principals) + { + $lookup: { + from: 'groups', + localField: 'principalId', + foreignField: '_id', + as: 'groupInfo', + }, + }, + // 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..ebdf329343 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,16 +13,20 @@ const { createAgent, updateAgent, deleteAgent, - getListAgents, + getListAgentsByAccess, } = require('~/models/Agent'); +const { + grantPermission, + findAccessibleResources, + findPubliclyAccessibleResources, + hasPublicPermission, +} = require('~/server/services/PermissionService'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); 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'); @@ -69,6 +72,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 +111,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 +135,45 @@ const getAgentHandler = async (req, res) => { } agent.author = agent.author.toString(); + + // @deprecated - isCollaborative replaced by ACL permissions agent.isCollaborative = !!agent.isCollaborative; + // Check if agent is public + const isPublic = await hasPublicPermission({ + resourceType: 'agent', + resourceId: agent._id, + requiredPermissions: PermissionBits.VIEW, + }); + agent.isPublic = isPublic; + 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, + isPublic: agent.isPublic, 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,42 +193,20 @@ 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 { _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 ? await updateAgent({ id }, updateData, { updatingUserId: req.user.id, - skipVersioning: isProjectUpdate, }) : existingAgent; - if (isProjectUpdate) { - updatedAgent = await updateAgentProjects({ - user: req.user, - agentId: id, - projectIds, - removeProjectIds, - }); - } - if (updatedAgent.author) { updatedAgent.author = updatedAgent.author.toString(); } @@ -307,6 +324,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 +370,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 +379,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 +388,31 @@ 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, }); + const publiclyAccessibleIds = await findPubliclyAccessibleResources({ + resourceType: 'agent', + requiredPermissions: PermissionBits.VIEW, + }); + // Use the new ACL-aware function + const data = await getListAgentsByAccess({ + accessibleIds, + otherParams: {}, // Can add query params here if needed + }); + if (data?.data?.length) { + data.data = data.data.map((agent) => { + if (publiclyAccessibleIds.some((id) => id.equals(agent._id))) { + agent.isPublic = true; + } + return agent; + }); + } return res.json(data); } catch (error) { logger.error('[/Agents] Error listing Agents', error); @@ -431,7 +490,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 ac79a627e9..3122f2ce10 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -118,6 +118,8 @@ const startServer = async () => { app.use('/api/agents', routes.agents); app.use('/api/banner', routes.banner); 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..9c2d24ca84 --- /dev/null +++ b/api/server/middleware/accessResources/canAccessAgentFromBody.js @@ -0,0 +1,97 @@ +const { logger } = require('@librechat/data-schemas'); +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) { + logger.error('Failed to validate agent access permissions', 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/canAccessAgentResource.spec.js b/api/server/middleware/accessResources/canAccessAgentResource.spec.js new file mode 100644 index 0000000000..4e4a0b7de0 --- /dev/null +++ b/api/server/middleware/accessResources/canAccessAgentResource.spec.js @@ -0,0 +1,384 @@ +const mongoose = require('mongoose'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { canAccessAgentResource } = require('./canAccessAgentResource'); +const { User, Role, AclEntry } = require('~/db/models'); +const { createAgent } = require('~/models/Agent'); + +describe('canAccessAgentResource middleware', () => { + let mongoServer; + let req, res, next; + let testUser; + + 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(); + await Role.create({ + name: 'test-role', + permissions: { + AGENTS: { + USE: true, + CREATE: true, + SHARED_GLOBAL: false, + }, + }, + }); + + // Create a test user + testUser = await User.create({ + email: 'test@example.com', + name: 'Test User', + username: 'testuser', + role: 'test-role', + }); + + req = { + user: { id: testUser._id.toString(), role: 'test-role' }, + params: {}, + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + next = jest.fn(); + + jest.clearAllMocks(); + }); + + describe('middleware factory', () => { + test('should throw error if requiredPermission is not provided', () => { + expect(() => canAccessAgentResource({})).toThrow( + 'canAccessAgentResource: requiredPermission is required and must be a number', + ); + }); + + test('should throw error if requiredPermission is not a number', () => { + expect(() => canAccessAgentResource({ requiredPermission: '1' })).toThrow( + 'canAccessAgentResource: requiredPermission is required and must be a number', + ); + }); + + test('should create middleware with default resourceIdParam', () => { + const middleware = canAccessAgentResource({ requiredPermission: 1 }); + expect(typeof middleware).toBe('function'); + expect(middleware.length).toBe(3); // Express middleware signature + }); + + test('should create middleware with custom resourceIdParam', () => { + const middleware = canAccessAgentResource({ + requiredPermission: 2, + resourceIdParam: 'agent_id', + }); + expect(typeof middleware).toBe('function'); + expect(middleware.length).toBe(3); + }); + }); + + describe('permission checking with real agents', () => { + test('should allow access when user is the agent author', async () => { + // Create an agent owned by the test user + const agent = await createAgent({ + id: `agent_${Date.now()}`, + name: 'Test Agent', + provider: 'openai', + model: 'gpt-4', + author: testUser._id, + }); + + // Create ACL entry for the author (owner permissions) + await AclEntry.create({ + principalType: 'user', + principalId: testUser._id, + principalModel: 'User', + resourceType: 'agent', + resourceId: agent._id, + permBits: 15, // All permissions (1+2+4+8) + grantedBy: testUser._id, + }); + + req.params.id = agent.id; + + const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('should deny access when user is not the author and has no ACL entry', async () => { + // Create an agent owned by a different user + const otherUser = await User.create({ + email: 'other@example.com', + name: 'Other User', + username: 'otheruser', + role: 'test-role', + }); + + const agent = await createAgent({ + id: `agent_${Date.now()}`, + name: 'Other User Agent', + provider: 'openai', + model: 'gpt-4', + author: otherUser._id, + }); + + // Create ACL entry for the other user (owner) + await AclEntry.create({ + principalType: 'user', + principalId: otherUser._id, + principalModel: 'User', + resourceType: 'agent', + resourceId: agent._id, + permBits: 15, // All permissions + grantedBy: otherUser._id, + }); + + req.params.id = agent.id; + + const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: 'Forbidden', + message: 'Insufficient permissions to access this agent', + }); + }); + + test('should allow access when user has ACL entry with sufficient permissions', async () => { + // Create an agent owned by a different user + const otherUser = await User.create({ + email: 'other2@example.com', + name: 'Other User 2', + username: 'otheruser2', + role: 'test-role', + }); + + const agent = await createAgent({ + id: `agent_${Date.now()}`, + name: 'Shared Agent', + provider: 'openai', + model: 'gpt-4', + author: otherUser._id, + }); + + // Create ACL entry granting view permission to test user + await AclEntry.create({ + principalType: 'user', + principalId: testUser._id, + principalModel: 'User', + resourceType: 'agent', + resourceId: agent._id, + permBits: 1, // VIEW permission + grantedBy: otherUser._id, + }); + + req.params.id = agent.id; + + const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('should deny access when ACL permissions are insufficient', async () => { + // Create an agent owned by a different user + const otherUser = await User.create({ + email: 'other3@example.com', + name: 'Other User 3', + username: 'otheruser3', + role: 'test-role', + }); + + const agent = await createAgent({ + id: `agent_${Date.now()}`, + name: 'Limited Access Agent', + provider: 'openai', + model: 'gpt-4', + author: otherUser._id, + }); + + // Create ACL entry granting only view permission + await AclEntry.create({ + principalType: 'user', + principalId: testUser._id, + principalModel: 'User', + resourceType: 'agent', + resourceId: agent._id, + permBits: 1, // VIEW permission only + grantedBy: otherUser._id, + }); + + req.params.id = agent.id; + + const middleware = canAccessAgentResource({ requiredPermission: 2 }); // EDIT permission required + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: 'Forbidden', + message: 'Insufficient permissions to access this agent', + }); + }); + + test('should handle non-existent agent', async () => { + req.params.id = 'agent_nonexistent'; + + const middleware = canAccessAgentResource({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + error: 'Not Found', + message: 'agent not found', + }); + }); + + test('should use custom resourceIdParam', async () => { + const agent = await createAgent({ + id: `agent_${Date.now()}`, + name: 'Custom Param Agent', + provider: 'openai', + model: 'gpt-4', + author: testUser._id, + }); + + // Create ACL entry for the author + await AclEntry.create({ + principalType: 'user', + principalId: testUser._id, + principalModel: 'User', + resourceType: 'agent', + resourceId: agent._id, + permBits: 15, // All permissions + grantedBy: testUser._id, + }); + + req.params.agent_id = agent.id; // Using custom param name + + const middleware = canAccessAgentResource({ + requiredPermission: 1, + resourceIdParam: 'agent_id', + }); + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + }); + + describe('permission levels', () => { + let agent; + + beforeEach(async () => { + agent = await createAgent({ + id: `agent_${Date.now()}`, + name: 'Permission Test Agent', + provider: 'openai', + model: 'gpt-4', + author: testUser._id, + }); + + // Create ACL entry with all permissions for the owner + await AclEntry.create({ + principalType: 'user', + principalId: testUser._id, + principalModel: 'User', + resourceType: 'agent', + resourceId: agent._id, + permBits: 15, // All permissions (1+2+4+8) + grantedBy: testUser._id, + }); + + req.params.id = agent.id; + }); + + test('should support view permission (1)', async () => { + const middleware = canAccessAgentResource({ requiredPermission: 1 }); + await middleware(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should support edit permission (2)', async () => { + const middleware = canAccessAgentResource({ requiredPermission: 2 }); + await middleware(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should support delete permission (4)', async () => { + const middleware = canAccessAgentResource({ requiredPermission: 4 }); + await middleware(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should support share permission (8)', async () => { + const middleware = canAccessAgentResource({ requiredPermission: 8 }); + await middleware(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should support combined permissions', async () => { + const viewAndEdit = 1 | 2; // 3 + const middleware = canAccessAgentResource({ requiredPermission: viewAndEdit }); + await middleware(req, res, next); + expect(next).toHaveBeenCalled(); + }); + }); + + describe('integration with agent operations', () => { + test('should work with agent CRUD operations', async () => { + const agentId = `agent_${Date.now()}`; + + // Create agent + const agent = await createAgent({ + id: agentId, + name: 'Integration Test Agent', + provider: 'openai', + model: 'gpt-4', + author: testUser._id, + description: 'Testing integration', + }); + + // Create ACL entry for the author + await AclEntry.create({ + principalType: 'user', + principalId: testUser._id, + principalModel: 'User', + resourceType: 'agent', + resourceId: agent._id, + permBits: 15, // All permissions + grantedBy: testUser._id, + }); + + req.params.id = agentId; + + // Test view access + const viewMiddleware = canAccessAgentResource({ requiredPermission: 1 }); + await viewMiddleware(req, res, next); + expect(next).toHaveBeenCalled(); + jest.clearAllMocks(); + + // Update the agent + const { updateAgent } = require('~/models/Agent'); + await updateAgent({ id: agentId }, { description: 'Updated description' }); + + // Test edit access + const editMiddleware = canAccessAgentResource({ requiredPermission: 2 }); + await editMiddleware(req, res, next); + expect(next).toHaveBeenCalled(); + }); + }); +}); 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/middleware/roles/access.spec.js b/api/server/middleware/roles/access.spec.js new file mode 100644 index 0000000000..435b20e9fb --- /dev/null +++ b/api/server/middleware/roles/access.spec.js @@ -0,0 +1,251 @@ +const mongoose = require('mongoose'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { checkAccess, generateCheckAccess } = require('./access'); +const { PermissionTypes, Permissions } = require('librechat-data-provider'); +const { Role } = require('~/db/models'); + +// Mock only the logger +jest.mock('~/config', () => ({ + logger: { + warn: jest.fn(), + error: jest.fn(), + }, +})); + +describe('Access Middleware', () => { + let mongoServer; + let req, res, next; + + 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(); + + // Create test roles + await Role.create({ + name: 'user', + permissions: { + [PermissionTypes.AGENTS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: false, + [Permissions.SHARED_GLOBAL]: false, + }, + }, + }); + + await Role.create({ + name: 'admin', + permissions: { + [PermissionTypes.AGENTS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARED_GLOBAL]: true, + }, + }, + }); + + req = { + user: { id: 'user123', role: 'user' }, + body: {}, + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + next = jest.fn(); + jest.clearAllMocks(); + }); + + describe('checkAccess', () => { + test('should return false if user is not provided', async () => { + const result = await checkAccess(null, PermissionTypes.AGENTS, [Permissions.USE]); + expect(result).toBe(false); + }); + + test('should return true if user has required permission', async () => { + const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.USE]); + expect(result).toBe(true); + }); + + test('should return false if user lacks required permission', async () => { + const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.CREATE]); + expect(result).toBe(false); + }); + + test('should return true if user has any of multiple permissions', async () => { + const result = await checkAccess(req.user, PermissionTypes.AGENTS, [ + Permissions.USE, + Permissions.CREATE, + ]); + expect(result).toBe(true); + }); + + test('should check body properties when permission is not directly granted', async () => { + // User role doesn't have CREATE permission, but bodyProps allows it + const bodyProps = { + [Permissions.CREATE]: ['agentId', 'name'], + }; + + const checkObject = { agentId: 'agent123' }; + + const result = await checkAccess( + req.user, + PermissionTypes.AGENTS, + [Permissions.CREATE], + bodyProps, + checkObject, + ); + expect(result).toBe(true); + }); + + test('should return false if role is not found', async () => { + req.user.role = 'nonexistent'; + const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.USE]); + expect(result).toBe(false); + }); + + test('should return false if role has no permissions for the requested type', async () => { + await Role.create({ + name: 'limited', + permissions: { + // Explicitly set AGENTS permissions to false + [PermissionTypes.AGENTS]: { + [Permissions.USE]: false, + [Permissions.CREATE]: false, + [Permissions.SHARED_GLOBAL]: false, + }, + // Has permissions for other types + [PermissionTypes.PROMPTS]: { + [Permissions.USE]: true, + }, + }, + }); + req.user.role = 'limited'; + + const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.USE]); + expect(result).toBe(false); + }); + + test('should handle admin role with all permissions', async () => { + req.user.role = 'admin'; + + const createResult = await checkAccess(req.user, PermissionTypes.AGENTS, [ + Permissions.CREATE, + ]); + expect(createResult).toBe(true); + + const shareResult = await checkAccess(req.user, PermissionTypes.AGENTS, [ + Permissions.SHARED_GLOBAL, + ]); + expect(shareResult).toBe(true); + }); + }); + + describe('generateCheckAccess', () => { + test('should call next() when user has required permission', async () => { + const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('should return 403 when user lacks permission', async () => { + const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.CREATE]); + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' }); + }); + + test('should check body properties when configured', async () => { + req.body = { agentId: 'agent123', description: 'test' }; + + const bodyProps = { + [Permissions.CREATE]: ['agentId'], + }; + + const middleware = generateCheckAccess( + PermissionTypes.AGENTS, + [Permissions.CREATE], + bodyProps, + ); + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('should handle database errors gracefully', async () => { + // Create a user with an invalid role that will cause getRoleByName to fail + req.user.role = { invalid: 'object' }; // This will cause an error when querying + + const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + message: expect.stringContaining('Server error:'), + }); + }); + + test('should work with multiple permission types', async () => { + req.user.role = 'admin'; + + const middleware = generateCheckAccess(PermissionTypes.AGENTS, [ + Permissions.USE, + Permissions.CREATE, + Permissions.SHARED_GLOBAL, + ]); + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + test('should handle missing user gracefully', async () => { + req.user = null; + + const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + message: expect.stringContaining('Server error:'), + }); + }); + + test('should handle role with no AGENTS permissions', async () => { + await Role.create({ + name: 'noaccess', + permissions: { + // Explicitly set AGENTS with all permissions false + [PermissionTypes.AGENTS]: { + [Permissions.USE]: false, + [Permissions.CREATE]: false, + [Permissions.SHARED_GLOBAL]: false, + }, + }, + }); + req.user.role = 'noaccess'; + + const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' }); + }); + }); +}); 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 ec97ba3986..adaca3859a 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -1,3 +1,4 @@ +const accessPermissions = require('./accessPermissions'); const assistants = require('./assistants'); const categories = require('./categories'); const tokenizer = require('./tokenizer'); @@ -28,6 +29,7 @@ const user = require('./user'); const mcp = require('./mcp'); module.exports = { + mcp, edit, auth, keys, @@ -55,5 +57,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..f35e706a2c 100644 --- a/api/server/services/AppService.interface.spec.js +++ b/api/server/services/AppService.interface.spec.js @@ -1,5 +1,6 @@ jest.mock('~/models', () => ({ initializeRoles: jest.fn(), + seedDefaultRoles: jest.fn(), })); jest.mock('~/models/Role', () => ({ updateAccessPermissions: jest.fn(), 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..82fa245d58 --- /dev/null +++ b/api/server/services/GraphApiService.js @@ -0,0 +1,525 @@ +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 current user's owned Entra ID groups from Microsoft Graph + * Uses /me/ownedObjects/microsoft.graph.group endpoint to get groups the user owns + * @param {string} accessToken - OpenID Connect access token + * @param {string} sub - Subject identifier + * @returns {Promise>} Array of group ID strings (GUIDs) + */ +const getUserOwnedEntraGroups = async (accessToken, sub) => { + try { + const graphClient = await createGraphClient(accessToken, sub); + + const groupsResponse = await graphClient + .api('/me/ownedObjects/microsoft.graph.group') + .select('id') + .get(); + + return (groupsResponse.value || []).map((group) => group.id); + } catch (error) { + logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error); + return []; + } +}; + +/** + * Get group members from Microsoft Graph API + * Recursively fetches all members using pagination (@odata.nextLink) + * @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 allMembers = []; + let nextLink = `/groups/${groupId}/members`; + + while (nextLink) { + const membersResponse = await graphClient.api(nextLink).select('id').top(999).get(); + + const members = membersResponse.value || []; + allMembers.push(...members.map((member) => member.id)); + + nextLink = membersResponse['@odata.nextLink'] + ? membersResponse['@odata.nextLink'].split('/v1.0')[1] + : null; + } + + return allMembers; + } catch (error) { + logger.error('[getGroupMembers] Error fetching group members:', error); + return []; + } +}; +/** + * Get group owners from Microsoft Graph API + * Recursively fetches all owners using pagination (@odata.nextLink) + * @param {string} accessToken - OpenID Connect access token + * @param {string} sub - Subject identifier + * @param {string} groupId - Entra ID group object ID + * @returns {Promise} Array of owner IDs (idOnTheSource values) + */ +const getGroupOwners = async (accessToken, sub, groupId) => { + try { + const graphClient = await createGraphClient(accessToken, sub); + const allOwners = []; + let nextLink = `/groups/${groupId}/owners`; + + while (nextLink) { + const ownersResponse = await graphClient.api(nextLink).select('id').top(999).get(); + + const owners = ownersResponse.value || []; + allOwners.push(...owners.map((member) => member.id)); + + nextLink = ownersResponse['@odata.nextLink'] + ? ownersResponse['@odata.nextLink'].split('/v1.0')[1] + : null; + } + + return allOwners; + } catch (error) { + logger.error('[getGroupOwners] Error fetching group owners:', 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 []; + } + if ( + process.env.OPENID_GRAPH_SCOPES && + !process.env.OPENID_GRAPH_SCOPES.toLowerCase().includes('people.read') + ) { + logger.warn('[searchContacts] People.Read scope is not enabled, skipping contact search'); + 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, + getGroupOwners, + createGraphClient, + getUserEntraGroups, + getUserOwnedEntraGroups, + 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..5d8dd62cf5 --- /dev/null +++ b/api/server/services/GraphApiService.spec.js @@ -0,0 +1,720 @@ +jest.mock('@microsoft/microsoft-graph-client'); +jest.mock('~/strategies/openidStrategy'); +jest.mock('~/cache/getLogStores'); +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { + error: jest.fn(), + debug: jest.fn(), + }, +})); +jest.mock('~/config', () => ({ + logger: { + error: jest.fn(), + debug: jest.fn(), + }, + createAxiosInstance: jest.fn(() => ({ + create: jest.fn(), + defaults: {}, + })), +})); +jest.mock('~/utils', () => ({ + logAxiosError: jest.fn(), +})); + +jest.mock('~/server/services/Config', () => ({})); +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: jest.fn(), +})); + +const mongoose = require('mongoose'); +const client = require('openid-client'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { Client } = require('@microsoft/microsoft-graph-client'); +const { getOpenIdConfig } = require('~/strategies/openidStrategy'); +const getLogStores = require('~/cache/getLogStores'); +const GraphApiService = require('./GraphApiService'); + +describe('GraphApiService', () => { + let mongoServer; + let mockGraphClient; + let mockTokensCache; + let mockOpenIdConfig; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + await mongoose.connect(mongoUri); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + afterEach(() => { + // Clean up environment variables + delete process.env.OPENID_GRAPH_SCOPES; + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await mongoose.connection.dropDatabase(); + + // Set up environment variable for People.Read scope + process.env.OPENID_GRAPH_SCOPES = 'User.Read,People.Read,Group.Read.All'; + + // 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.search).toHaveBeenCalledWith('"john"'); + 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 first with user filter + expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people'); + expect(mockGraphClient.search).toHaveBeenCalledWith('"test"'); + 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'); + expect(mockGraphClient.search).toHaveBeenCalledWith('"test"'); + // 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..85a263d455 --- /dev/null +++ b/api/server/services/PermissionService.js @@ -0,0 +1,721 @@ +const mongoose = require('mongoose'); +const { getTransactionSupport, logger } = require('@librechat/data-schemas'); +const { isEnabled } = require('~/server/utils'); +const { + entraIdPrincipalFeatureEnabled, + getUserEntraGroups, + getUserOwnedEntraGroups, + getGroupMembers, + getGroupOwners, +} = require('~/server/services/GraphApiService'); +const { + findGroupByExternalId, + findRoleByIdentifier, + getUserPrincipals, + createGroup, + createUser, + updateUser, + findUser, + grantPermission: grantPermissionACL, + findAccessibleResources: findAccessibleResourcesACL, + hasPermission, + getEffectivePermissions: getEffectivePermissionsACL, + findEntriesByPrincipalsAndResource, +} = 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}`, + ); + } + return await grantPermissionACL( + principalType, + principalId, + resourceType, + resourceId, + role.permBits, + grantedBy, + session, + role._id, + ); + } 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; + } + + return await hasPermission(principals, resourceType, resourceId, requiredPermission); + } 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; + } + return await getEffectivePermissionsACL(principals, resourceType, resourceId); + } 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 principalsList = await getUserPrincipals(userId); + + if (principalsList.length === 0) { + return []; + } + return await findAccessibleResourcesACL(principalsList, resourceType, requiredPermissions); + } catch (error) { + logger.error(`[PermissionService.findAccessibleResources] Error: ${error.message}`); + // Re-throw validation errors + if (error.message.includes('requiredPermissions must be')) { + throw error; + } + return []; + } +}; + +/** + * Find all publicly accessible resources of a specific type + * @param {Object} params - Parameters for finding publicly accessible resources + * @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 findPubliclyAccessibleResources = async ({ resourceType, requiredPermissions }) => { + try { + if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) { + throw new Error('requiredPermissions must be a positive number'); + } + + // Find all public ACL entries where the public principal has at least the required permission bits + const entries = await AclEntry.find({ + principalType: 'public', + resourceType, + permBits: { $bitsAllSet: requiredPermissions }, + }).distinct('resourceId'); + + return entries; + } catch (error) { + logger.error(`[PermissionService.findPubliclyAccessibleResources] 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, + ); + + // Include group owners as members if feature is enabled + if (isEnabled(process.env.ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS)) { + const ownerIds = await getGroupOwners( + authContext.accessToken, + authContext.sub, + principal.idOnTheSource, + ); + if (ownerIds && ownerIds.length > 0) { + memberIds.push(...ownerIds); + // Remove duplicates + memberIds = [...new Set(memberIds)]; + } + } + } 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 + * Optionally includes groups the user owns if ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS is enabled + * @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 memberGroupIds = await getUserEntraGroups(accessToken, user.openidId); + let allGroupIds = [...(memberGroupIds || [])]; + + // Include owned groups if feature is enabled + if (isEnabled(process.env.ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS)) { + const ownedGroupIds = await getUserOwnedEntraGroups(accessToken, user.openidId); + if (ownedGroupIds && ownedGroupIds.length > 0) { + allGroupIds.push(...ownedGroupIds); + // Remove duplicates + allGroupIds = [...new Set(allGroupIds)]; + } + } + + if (!allGroupIds || allGroupIds.length === 0) { + return; + } + + const sessionOptions = session ? { session } : {}; + + await Group.updateMany( + { + idOnTheSource: { $in: allGroupIds }, + source: 'entra', + memberIds: { $ne: user.idOnTheSource }, + }, + { $addToSet: { memberIds: user.idOnTheSource } }, + sessionOptions, + ); + + await Group.updateMany( + { + source: 'entra', + memberIds: user.idOnTheSource, + idOnTheSource: { $nin: allGroupIds }, + }, + { $pull: { memberIds: user.idOnTheSource } }, + sessionOptions, + ); + } catch (error) { + logger.error(`[PermissionService.syncUserEntraGroupMemberships] Error syncing groups:`, error); + } +}; + +/** + * Check if public has a specific permission on a resource + * @param {Object} params - Parameters for checking public permission + * @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 public has the required permission bits + */ +const hasPublicPermission = async ({ resourceType, resourceId, requiredPermissions }) => { + try { + if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) { + throw new Error('requiredPermissions must be a positive number'); + } + + // Use public principal to check permissions + const publicPrincipal = [{ principalType: 'public' }]; + + const entries = await findEntriesByPrincipalsAndResource( + publicPrincipal, + resourceType, + resourceId, + ); + + // Check if any entry has the required permission bits + return entries.some((entry) => (entry.permBits & requiredPermissions) === requiredPermissions); + } catch (error) { + logger.error(`[PermissionService.hasPublicPermission] Error: ${error.message}`); + // Re-throw validation errors + if (error.message.includes('requiredPermissions must be')) { + throw error; + } + return false; + } +}; + +/** + * 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, + findPubliclyAccessibleResources, + hasPublicPermission, + 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..13adc47dda --- /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', () => ({ + ...jest.requireActual('~/models'), + 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(); + + 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