diff --git a/api/models/index.js b/api/models/index.js index 03d5d3ec71..2a1cb222f9 100644 --- a/api/models/index.js +++ b/api/models/index.js @@ -13,6 +13,7 @@ const seedDatabase = async () => { await methods.initializeRoles(); await methods.seedDefaultRoles(); await methods.ensureDefaultCategories(); + await methods.seedSystemGrants(); }; module.exports = { diff --git a/api/server/controllers/assistants/helpers.js b/api/server/controllers/assistants/helpers.js index 9183680f1e..6309268770 100644 --- a/api/server/controllers/assistants/helpers.js +++ b/api/server/controllers/assistants/helpers.js @@ -1,14 +1,15 @@ const { - SystemRoles, EModelEndpoint, defaultOrderQuery, defaultAssistantsVersion, } = require('librechat-data-provider'); +const { logger, SystemCapabilities } = require('@librechat/data-schemas'); const { initializeClient: initAzureClient, } = require('~/server/services/Endpoints/azureAssistants'); const { initializeClient } = require('~/server/services/Endpoints/assistants'); const { getEndpointsConfig } = require('~/server/services/Config'); +const { hasCapability } = require('~/server/middleware'); /** * @param {ServerRequest} req @@ -236,9 +237,19 @@ const fetchAssistants = async ({ req, res, overrideEndpoint }) => { body = await listAssistantsForAzure({ req, res, version, azureConfig, query }); } - if (req.user.role === SystemRoles.ADMIN) { + if (!appConfig.endpoints?.[endpoint]) { return body; - } else if (!appConfig.endpoints?.[endpoint]) { + } + + let canManageAssistants = false; + try { + canManageAssistants = await hasCapability(req.user, SystemCapabilities.MANAGE_ASSISTANTS); + } catch (err) { + logger.warn(`[fetchAssistants] capability check failed, denying bypass: ${err.message}`); + } + + if (canManageAssistants) { + logger.debug(`[fetchAssistants] MANAGE_ASSISTANTS bypass for user ${req.user.id}`); return body; } diff --git a/api/server/index.js b/api/server/index.js index 6af829eab8..ba376ab335 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -20,13 +20,14 @@ const { GenerationJobManager, createStreamServices, initializeFileStorage, + updateInterfacePermissions, } = require('@librechat/api'); const { connectDb, indexSync } = require('~/db'); const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager'); +const { getRoleByName, updateAccessPermissions, seedDatabase } = require('~/models'); +const { capabilityContextMiddleware } = require('./middleware/roles/capabilities'); const createValidateImageRequest = require('./middleware/validateImageRequest'); const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies'); -const { updateInterfacePermissions: updateInterfacePerms } = require('@librechat/api'); -const { getRoleByName, updateAccessPermissions, seedDatabase } = require('~/models'); const { checkMigrations } = require('./services/start/migration'); const initializeMCPs = require('./services/initializeMCPs'); const configureSocialLogins = require('./socialLogins'); @@ -62,7 +63,7 @@ const startServer = async () => { const appConfig = await getAppConfig(); initializeFileStorage(appConfig); await performStartupChecks(appConfig); - await updateInterfacePerms({ appConfig, getRoleByName, updateAccessPermissions }); + await updateInterfacePermissions({ appConfig, getRoleByName, updateAccessPermissions }); const indexPath = path.join(appConfig.paths.dist, 'index.html'); let indexHTML = fs.readFileSync(indexPath, 'utf8'); @@ -133,6 +134,9 @@ const startServer = async () => { await configureSocialLogins(app); } + /* Per-request capability cache — must be registered before any route that calls hasCapability */ + app.use(capabilityContextMiddleware); + app.use('/oauth', routes.oauth); /* API Endpoints */ app.use('/api/auth', routes.auth); diff --git a/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js b/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js index 77508be2d1..6f7e4ab506 100644 --- a/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js +++ b/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js @@ -1,8 +1,9 @@ const mongoose = require('mongoose'); const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider'); +const { SystemCapabilities } = require('@librechat/data-schemas'); const { MongoMemoryServer } = require('mongodb-memory-server'); const { canAccessMCPServerResource } = require('./canAccessMCPServerResource'); -const { User, Role, AclEntry } = require('~/db/models'); +const { User, Role, AclEntry, SystemGrant } = require('~/db/models'); const { createMCPServer } = require('~/models'); describe('canAccessMCPServerResource middleware', () => { @@ -511,7 +512,7 @@ describe('canAccessMCPServerResource middleware', () => { }); }); - test('should allow admin users to bypass permission checks', async () => { + test('should allow users with MANAGE_MCP_SERVERS capability to bypass permission checks', async () => { const { SystemRoles } = require('librechat-data-provider'); // Create an MCP server owned by another user @@ -531,6 +532,14 @@ describe('canAccessMCPServerResource middleware', () => { author: otherUser._id, }); + // Seed MANAGE_MCP_SERVERS capability for the ADMIN role + await SystemGrant.create({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.ADMIN, + capability: SystemCapabilities.MANAGE_MCP_SERVERS, + grantedAt: new Date(), + }); + // Set user as admin req.user = { id: testUser._id, role: SystemRoles.ADMIN }; req.params.serverName = mcpServer.serverName; diff --git a/api/server/middleware/accessResources/canAccessResource.js b/api/server/middleware/accessResources/canAccessResource.js index c8bd15ffc2..2431971b2f 100644 --- a/api/server/middleware/accessResources/canAccessResource.js +++ b/api/server/middleware/accessResources/canAccessResource.js @@ -1,5 +1,5 @@ -const { logger } = require('@librechat/data-schemas'); -const { SystemRoles } = require('librechat-data-provider'); +const { logger, ResourceCapabilityMap } = require('@librechat/data-schemas'); +const { hasCapability } = require('~/server/middleware/roles/capabilities'); const { checkPermission } = require('~/server/services/PermissionService'); /** @@ -71,8 +71,17 @@ const canAccessResource = (options) => { message: 'Authentication required', }); } - // if system admin let through - if (req.user.role === SystemRoles.ADMIN) { + const cap = ResourceCapabilityMap[resourceType]; + let hasCap = false; + try { + hasCap = cap != null && (await hasCapability(req.user, cap)); + } catch (err) { + logger.warn(`[canAccessResource] capability check failed, denying bypass: ${err.message}`); + } + if (hasCap) { + logger.debug( + `[canAccessResource] ${cap} bypass for user ${req.user.id} on ${resourceType} ${rawResourceId}`, + ); return next(); } const userId = req.user.id; diff --git a/api/server/middleware/assistants/validateAuthor.js b/api/server/middleware/assistants/validateAuthor.js index 6c15704251..3be1642a71 100644 --- a/api/server/middleware/assistants/validateAuthor.js +++ b/api/server/middleware/assistants/validateAuthor.js @@ -1,4 +1,5 @@ -const { SystemRoles } = require('librechat-data-provider'); +const { logger, SystemCapabilities } = require('@librechat/data-schemas'); +const { hasCapability } = require('~/server/middleware'); const { getAssistant } = require('~/models'); /** @@ -12,10 +13,6 @@ const { getAssistant } = require('~/models'); * @returns {Promise} */ const validateAuthor = async ({ req, openai, overrideEndpoint, overrideAssistantId }) => { - if (req.user.role === SystemRoles.ADMIN) { - return; - } - const endpoint = overrideEndpoint ?? req.body.endpoint ?? req.query.endpoint; const assistant_id = overrideAssistantId ?? req.params.id ?? req.body.assistant_id ?? req.query.assistant_id; @@ -31,6 +28,18 @@ const validateAuthor = async ({ req, openai, overrideEndpoint, overrideAssistant return; } + let canManageAssistants = false; + try { + canManageAssistants = await hasCapability(req.user, SystemCapabilities.MANAGE_ASSISTANTS); + } catch (err) { + logger.warn(`[validateAuthor] capability check failed, denying bypass: ${err.message}`); + } + + if (canManageAssistants) { + logger.debug(`[validateAuthor] MANAGE_ASSISTANTS bypass for user ${req.user.id}`); + return; + } + const assistantDoc = await getAssistant({ assistant_id, user: req.user.id }); if (assistantDoc) { return; diff --git a/api/server/middleware/canDeleteAccount.js b/api/server/middleware/canDeleteAccount.js index a913495287..3c08745d76 100644 --- a/api/server/middleware/canDeleteAccount.js +++ b/api/server/middleware/canDeleteAccount.js @@ -1,6 +1,6 @@ const { isEnabled } = require('@librechat/api'); -const { logger } = require('@librechat/data-schemas'); -const { SystemRoles } = require('librechat-data-provider'); +const { logger, SystemCapabilities } = require('@librechat/data-schemas'); +const { hasCapability } = require('~/server/middleware/roles/capabilities'); /** * Checks if the user can delete their account @@ -17,12 +17,29 @@ const { SystemRoles } = require('librechat-data-provider'); const canDeleteAccount = async (req, res, next = () => {}) => { const { user } = req; const { ALLOW_ACCOUNT_DELETION = true } = process.env; - if (user?.role === SystemRoles.ADMIN || isEnabled(ALLOW_ACCOUNT_DELETION)) { + if (isEnabled(ALLOW_ACCOUNT_DELETION)) { return next(); - } else { - logger.error(`[User] [Delete Account] [User cannot delete account] [User: ${user?.id}]`); - return res.status(403).send({ message: 'You do not have permission to delete this account' }); } + let hasAdminAccess = false; + if (user) { + try { + const id = user.id ?? user._id?.toString(); + if (id) { + hasAdminAccess = await hasCapability( + { id, role: user.role ?? '', tenantId: user.tenantId }, + SystemCapabilities.ACCESS_ADMIN, + ); + } + } catch (err) { + logger.warn(`[canDeleteAccount] capability check failed, denying: ${err.message}`); + } + } + if (hasAdminAccess) { + logger.debug(`[canDeleteAccount] ACCESS_ADMIN bypass for user ${user.id}`); + return next(); + } + logger.error(`[User] [Delete Account] [User cannot delete account] [User: ${user?.id}]`); + return res.status(403).send({ message: 'You do not have permission to delete this account' }); }; module.exports = canDeleteAccount; diff --git a/api/server/middleware/canDeleteAccount.spec.js b/api/server/middleware/canDeleteAccount.spec.js new file mode 100644 index 0000000000..abb888c4a4 --- /dev/null +++ b/api/server/middleware/canDeleteAccount.spec.js @@ -0,0 +1,180 @@ +const mongoose = require('mongoose'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { SystemRoles, PrincipalType } = require('librechat-data-provider'); +const { SystemCapabilities } = require('@librechat/data-schemas'); + +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() }, +})); + +jest.mock('~/cache', () => ({ + getLogStores: jest.fn(() => ({ + get: jest.fn(), + set: jest.fn(), + })), +})); + +const { User, SystemGrant } = require('~/db/models'); +const canDeleteAccount = require('./canDeleteAccount'); + +let mongoServer; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await mongoose.connection.dropDatabase(); + delete process.env.ALLOW_ACCOUNT_DELETION; +}); + +const makeRes = () => { + const send = jest.fn(); + const status = jest.fn().mockReturnValue({ send }); + return { status, send }; +}; + +describe('canDeleteAccount', () => { + describe('ALLOW_ACCOUNT_DELETION=true (default)', () => { + it('calls next without hitting the DB', async () => { + process.env.ALLOW_ACCOUNT_DELETION = 'true'; + const next = jest.fn(); + const req = { user: { id: 'user-1', role: SystemRoles.USER } }; + + await canDeleteAccount(req, makeRes(), next); + + expect(next).toHaveBeenCalled(); + }); + + it('skips capability check entirely when deletion is allowed', async () => { + process.env.ALLOW_ACCOUNT_DELETION = 'true'; + const next = jest.fn(); + const req = { user: { id: 'user-1', role: SystemRoles.USER } }; + + await canDeleteAccount(req, makeRes(), next); + + expect(next).toHaveBeenCalled(); + const grantCount = await SystemGrant.countDocuments(); + expect(grantCount).toBe(0); + }); + }); + + describe('ALLOW_ACCOUNT_DELETION=false', () => { + beforeEach(() => { + process.env.ALLOW_ACCOUNT_DELETION = 'false'; + }); + + it('allows admin with ACCESS_ADMIN grant (real DB check)', async () => { + const admin = await User.create({ + name: 'Admin', + email: 'admin@test.com', + password: 'password123', + provider: 'local', + role: SystemRoles.ADMIN, + }); + + await SystemGrant.create({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.ADMIN, + capability: SystemCapabilities.ACCESS_ADMIN, + grantedAt: new Date(), + }); + + const next = jest.fn(); + const req = { user: { id: admin._id.toString(), role: SystemRoles.ADMIN } }; + + await canDeleteAccount(req, makeRes(), next); + + expect(next).toHaveBeenCalled(); + }); + + it('blocks regular user without ACCESS_ADMIN grant', async () => { + const user = await User.create({ + name: 'Regular', + email: 'user@test.com', + password: 'password123', + provider: 'local', + role: SystemRoles.USER, + }); + + const next = jest.fn(); + const res = makeRes(); + const req = { user: { id: user._id.toString(), role: SystemRoles.USER } }; + + await canDeleteAccount(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('blocks admin role WITHOUT the ACCESS_ADMIN grant', async () => { + const admin = await User.create({ + name: 'Admin No Grant', + email: 'admin2@test.com', + password: 'password123', + provider: 'local', + role: SystemRoles.ADMIN, + }); + + const next = jest.fn(); + const res = makeRes(); + const req = { user: { id: admin._id.toString(), role: SystemRoles.ADMIN } }; + + await canDeleteAccount(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('allows user-level grant (not just role-level)', async () => { + const user = await User.create({ + name: 'Privileged User', + email: 'priv@test.com', + password: 'password123', + provider: 'local', + role: SystemRoles.USER, + }); + + await SystemGrant.create({ + principalType: PrincipalType.USER, + principalId: user._id, + capability: SystemCapabilities.ACCESS_ADMIN, + grantedAt: new Date(), + }); + + const next = jest.fn(); + const req = { user: { id: user._id.toString(), role: SystemRoles.USER } }; + + await canDeleteAccount(req, makeRes(), next); + + expect(next).toHaveBeenCalled(); + }); + + it('blocks when user is undefined — does not throw', async () => { + const next = jest.fn(); + const res = makeRes(); + + await canDeleteAccount({ user: undefined }, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('blocks when user is null — does not throw', async () => { + const next = jest.fn(); + const res = makeRes(); + + await canDeleteAccount({ user: null }, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + }); +}); diff --git a/api/server/middleware/roles/capabilities.js b/api/server/middleware/roles/capabilities.js new file mode 100644 index 0000000000..6f2aa43e96 --- /dev/null +++ b/api/server/middleware/roles/capabilities.js @@ -0,0 +1,14 @@ +const { generateCapabilityCheck, capabilityContextMiddleware } = require('@librechat/api'); +const { getUserPrincipals, hasCapabilityForPrincipals } = require('~/models'); + +const { hasCapability, requireCapability, hasConfigCapability } = generateCapabilityCheck({ + getUserPrincipals, + hasCapabilityForPrincipals, +}); + +module.exports = { + hasCapability, + requireCapability, + hasConfigCapability, + capabilityContextMiddleware, +}; diff --git a/api/server/middleware/roles/index.js b/api/server/middleware/roles/index.js index f01b884e5a..e6c315d007 100644 --- a/api/server/middleware/roles/index.js +++ b/api/server/middleware/roles/index.js @@ -1,5 +1,15 @@ +const { + hasCapability, + requireCapability, + hasConfigCapability, + capabilityContextMiddleware, +} = require('./capabilities'); const checkAdmin = require('./admin'); module.exports = { checkAdmin, + hasCapability, + requireCapability, + hasConfigCapability, + capabilityContextMiddleware, }; diff --git a/api/server/routes/admin/auth.js b/api/server/routes/admin/auth.js index e729f20940..e19adf54a9 100644 --- a/api/server/routes/admin/auth.js +++ b/api/server/routes/admin/auth.js @@ -3,20 +3,19 @@ const passport = require('passport'); const { randomState } = require('openid-client'); const { logger } = require('@librechat/data-schemas'); const { CacheKeys } = require('librechat-data-provider'); -const { - requireAdmin, - getAdminPanelUrl, - exchangeAdminCode, - createSetBalanceConfig, -} = require('@librechat/api'); +const { SystemCapabilities } = require('@librechat/data-schemas'); +const { getAdminPanelUrl, exchangeAdminCode, createSetBalanceConfig } = require('@librechat/api'); const { loginController } = require('~/server/controllers/auth/LoginController'); const { createOAuthHandler } = require('~/server/controllers/auth/oauth'); const { findBalanceByUser, upsertBalanceFields } = require('~/models'); const { getAppConfig } = require('~/server/services/Config'); +const { requireCapability } = require('~/server/middleware'); const getLogStores = require('~/cache/getLogStores'); const { getOpenIdConfig } = require('~/strategies'); const middleware = require('~/server/middleware'); +const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN); + const setBalanceConfig = createSetBalanceConfig({ getAppConfig, findBalanceByUser, @@ -31,12 +30,12 @@ router.post( middleware.loginLimiter, middleware.checkBan, middleware.requireLocalAuth, - requireAdmin, + requireAdminAccess, setBalanceConfig, loginController, ); -router.get('/verify', middleware.requireJwtAuth, requireAdmin, (req, res) => { +router.get('/verify', middleware.requireJwtAuth, requireAdminAccess, (req, res) => { const { password: _p, totpSecret: _t, __v, ...user } = req.user; user.id = user._id.toString(); res.status(200).json({ user }); @@ -67,7 +66,7 @@ router.get( failureMessage: true, session: false, }), - requireAdmin, + requireAdminAccess, setBalanceConfig, middleware.checkDomainAllowed, createOAuthHandler(`${getAdminPanelUrl()}/auth/openid/callback`), diff --git a/api/server/routes/files/files.agents.test.js b/api/server/routes/files/files.agents.test.js index 203c1210fd..e64be9cf4e 100644 --- a/api/server/routes/files/files.agents.test.js +++ b/api/server/routes/files/files.agents.test.js @@ -10,6 +10,7 @@ const { ResourceType, PrincipalType, } = require('librechat-data-provider'); +const { SystemCapabilities } = require('@librechat/data-schemas'); const { createAgent, createFile } = require('~/models'); // Only mock the external dependencies that we don't want to test @@ -82,6 +83,7 @@ describe('File Routes - Agent Files Endpoint', () => { let AclEntry; // eslint-disable-next-line no-unused-vars let AccessRole; + let SystemGrant; let modelsToCleanup = []; beforeAll(async () => { @@ -108,6 +110,7 @@ describe('File Routes - Agent Files Endpoint', () => { AclEntry = models.AclEntry; User = models.User; AccessRole = models.AccessRole; + SystemGrant = models.SystemGrant; // Seed default roles using our methods await methods.seedDefaultRoles(); @@ -532,7 +535,7 @@ describe('File Routes - Agent Files Endpoint', () => { expect(processAgentFileUpload).not.toHaveBeenCalled(); }); - it('should allow file upload for admin user regardless of agent ownership', async () => { + it('should allow file upload for user with MANAGE_AGENTS capability regardless of agent ownership', async () => { // Create an agent owned by authorId await createAgent({ id: agentCustomId, @@ -542,6 +545,14 @@ describe('File Routes - Agent Files Endpoint', () => { author: authorId, }); + // Seed MANAGE_AGENTS capability for the ADMIN role + await SystemGrant.create({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.ADMIN, + capability: SystemCapabilities.MANAGE_AGENTS, + grantedAt: new Date(), + }); + // Create app with admin user (otherUserId as admin) const testApp = createAppWithUser(otherUserId, SystemRoles.ADMIN); diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index 3b2946ef15..0290229900 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -7,13 +7,13 @@ const { isUUID, CacheKeys, FileSources, - SystemRoles, ResourceType, EModelEndpoint, PermissionBits, checkOpenAIStorage, isAssistantsEndpoint, } = require('librechat-data-provider'); +const { SystemCapabilities } = require('@librechat/data-schemas'); const { filterFile, processFileUpload, @@ -28,6 +28,7 @@ const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud'); const { hasAccessToFilesViaAgent } = require('~/server/services/Files'); const { cleanFileName } = require('~/server/utils/files'); +const { hasCapability } = require('~/server/middleware'); const { getLogStores } = require('~/cache'); const { Readable } = require('stream'); const db = require('~/models'); @@ -389,8 +390,17 @@ router.post('/', async (req, res) => { if (metadata.agent_id && metadata.tool_resource && !isMessageAttachment) { const userId = req.user.id; - /** Admin users bypass permission checks */ - if (req.user.role !== SystemRoles.ADMIN) { + let hasManageAgents = false; + try { + hasManageAgents = await hasCapability(req.user, SystemCapabilities.MANAGE_AGENTS); + } catch (err) { + logger.warn(`[/files] capability check failed, denying bypass: ${err.message}`); + } + if (hasManageAgents) { + logger.debug( + `[/files] MANAGE_AGENTS bypass for user ${req.user.id} on agent ${metadata.agent_id}`, + ); + } else { const agent = await db.getAgent({ id: metadata.agent_id }); if (!agent) { diff --git a/api/server/routes/prompts.js b/api/server/routes/prompts.js index d437273df2..c2e15ac6c0 100644 --- a/api/server/routes/prompts.js +++ b/api/server/routes/prompts.js @@ -11,13 +11,13 @@ const { } = require('@librechat/api'); const { Permissions, - SystemRoles, ResourceType, AccessRoleIds, PrincipalType, PermissionBits, PermissionTypes, } = require('librechat-data-provider'); +const { SystemCapabilities } = require('@librechat/data-schemas'); const { getListPromptGroupsByAccess, makePromptProduction, @@ -32,6 +32,7 @@ const { getPrompt, } = require('~/models'); const { + hasCapability, canAccessPromptGroupResource, canAccessPromptViaGroup, requireJwtAuth, @@ -333,7 +334,14 @@ const patchPromptGroup = async (req, res) => { const { groupId } = req.params; const author = req.user.id; const filter = { _id: groupId, author }; - if (req.user.role === SystemRoles.ADMIN) { + let canManagePrompts = false; + try { + canManagePrompts = await hasCapability(req.user, SystemCapabilities.MANAGE_PROMPTS); + } catch (err) { + logger.warn(`[patchPromptGroup] capability check failed, denying bypass: ${err.message}`); + } + if (canManagePrompts) { + logger.debug(`[patchPromptGroup] MANAGE_PROMPTS bypass for user ${req.user.id}`); delete filter.author; } @@ -421,7 +429,14 @@ router.get('/', async (req, res) => { // If no groupId, return user's own prompts const query = { author }; - if (req.user.role === SystemRoles.ADMIN) { + let canReadPrompts = false; + try { + canReadPrompts = await hasCapability(req.user, SystemCapabilities.READ_PROMPTS); + } catch (err) { + logger.warn(`[GET /prompts] capability check failed, denying bypass: ${err.message}`); + } + if (canReadPrompts) { + logger.debug(`[GET /prompts] READ_PROMPTS bypass for user ${req.user.id}`); delete query.author; } const prompts = await getPrompts(query); @@ -445,8 +460,7 @@ const deletePromptController = async (req, res) => { try { const { promptId } = req.params; const { groupId } = req.query; - const author = req.user.id; - const query = { promptId, groupId, author, role: req.user.role }; + const query = { promptId, groupId }; const result = await deletePrompt(query); res.status(200).send(result); } catch (error) { @@ -464,8 +478,8 @@ const deletePromptController = async (req, res) => { const deletePromptGroupController = async (req, res) => { try { const { groupId: _id } = req.params; - // Don't pass author - permissions are now checked by middleware - const message = await deletePromptGroup({ _id, role: req.user.role }); + // Don't pass author or role - permissions are checked by ACL middleware + const message = await deletePromptGroup({ _id }); res.send(message); } catch (error) { logger.error('Error deleting prompt group', error); diff --git a/api/server/routes/prompts.test.js b/api/server/routes/prompts.test.js index 80c973147f..ec162ac1fb 100644 --- a/api/server/routes/prompts.test.js +++ b/api/server/routes/prompts.test.js @@ -10,6 +10,7 @@ const { PrincipalType, PermissionBits, } = require('librechat-data-provider'); +const { SystemCapabilities } = require('@librechat/data-schemas'); // Mock modules before importing jest.mock('~/server/services/Config', () => ({ @@ -35,6 +36,7 @@ jest.mock('~/models', () => { jest.mock('~/server/middleware', () => ({ requireJwtAuth: (req, res, next) => next(), + hasCapability: jest.requireActual('~/server/middleware').hasCapability, canAccessPromptViaGroup: jest.requireActual('~/server/middleware').canAccessPromptViaGroup, canAccessPromptGroupResource: jest.requireActual('~/server/middleware').canAccessPromptGroupResource, @@ -43,7 +45,7 @@ jest.mock('~/server/middleware', () => ({ let app; let mongoServer; let promptRoutes; -let Prompt, PromptGroup, AclEntry, AccessRole, User; +let Prompt, PromptGroup, AclEntry, AccessRole, User, SystemGrant; let testUsers, testRoles; let grantPermission; let currentTestUser; // Track current user for middleware @@ -65,6 +67,7 @@ beforeAll(async () => { AclEntry = dbModels.AclEntry; AccessRole = dbModels.AccessRole; User = dbModels.User; + SystemGrant = dbModels.SystemGrant; // Import permission service const permissionService = require('~/server/services/PermissionService'); @@ -165,6 +168,22 @@ async function setupTestData() { }), }; + // Seed capabilities for the ADMIN role + await SystemGrant.create([ + { + principalType: PrincipalType.ROLE, + principalId: SystemRoles.ADMIN, + capability: SystemCapabilities.MANAGE_PROMPTS, + grantedAt: new Date(), + }, + { + principalType: PrincipalType.ROLE, + principalId: SystemRoles.ADMIN, + capability: SystemCapabilities.READ_PROMPTS, + grantedAt: new Date(), + }, + ]); + // Mock getRoleByName const { getRoleByName } = require('~/models'); getRoleByName.mockImplementation((roleName) => { diff --git a/api/server/routes/roles.js b/api/server/routes/roles.js index 4c0f044f76..1b7e4632e3 100644 --- a/api/server/routes/roles.js +++ b/api/server/routes/roles.js @@ -1,4 +1,5 @@ const express = require('express'); +const { logger, SystemCapabilities } = require('@librechat/data-schemas'); const { SystemRoles, roleDefaults, @@ -11,11 +12,12 @@ const { peoplePickerPermissionsSchema, remoteAgentsPermissionsSchema, } = require('librechat-data-provider'); -const { checkAdmin, requireJwtAuth } = require('~/server/middleware'); +const { hasCapability, requireCapability, requireJwtAuth } = require('~/server/middleware'); const { updateRoleByName, getRoleByName } = require('~/models'); const router = express.Router(); router.use(requireJwtAuth); +const manageRoles = requireCapability(SystemCapabilities.MANAGE_ROLES); /** * Permission configuration mapping @@ -111,14 +113,17 @@ router.get('/:roleName', async (req, res) => { // TODO: TEMP, use a better parsing for roleName const roleName = _r.toUpperCase(); - if ( - (req.user.role !== SystemRoles.ADMIN && roleName === SystemRoles.ADMIN) || - (req.user.role !== SystemRoles.ADMIN && !roleDefaults[roleName]) - ) { - return res.status(403).send({ message: 'Unauthorized' }); - } - try { + let hasReadRoles = false; + try { + hasReadRoles = await hasCapability(req.user, SystemCapabilities.READ_ROLES); + } catch (err) { + logger.warn(`[GET /roles/:roleName] capability check failed: ${err.message}`); + } + if (!hasReadRoles && (roleName === SystemRoles.ADMIN || !roleDefaults[roleName])) { + return res.status(403).send({ message: 'Unauthorized' }); + } + const role = await getRoleByName(roleName, '-_id -__v'); if (!role) { return res.status(404).send({ message: 'Role not found' }); @@ -126,7 +131,8 @@ router.get('/:roleName', async (req, res) => { res.status(200).send(role); } catch (error) { - return res.status(500).send({ message: 'Failed to retrieve role', error: error.message }); + logger.error('[GET /roles/:roleName] Error:', error); + return res.status(500).send({ message: 'Failed to retrieve role' }); } }); @@ -134,42 +140,42 @@ router.get('/:roleName', async (req, res) => { * PUT /api/roles/:roleName/prompts * Update prompt permissions for a specific role */ -router.put('/:roleName/prompts', checkAdmin, createPermissionUpdateHandler('prompts')); +router.put('/:roleName/prompts', manageRoles, createPermissionUpdateHandler('prompts')); /** * PUT /api/roles/:roleName/agents * Update agent permissions for a specific role */ -router.put('/:roleName/agents', checkAdmin, createPermissionUpdateHandler('agents')); +router.put('/:roleName/agents', manageRoles, createPermissionUpdateHandler('agents')); /** * PUT /api/roles/:roleName/memories * Update memory permissions for a specific role */ -router.put('/:roleName/memories', checkAdmin, createPermissionUpdateHandler('memories')); +router.put('/:roleName/memories', manageRoles, createPermissionUpdateHandler('memories')); /** * PUT /api/roles/:roleName/people-picker * Update people picker permissions for a specific role */ -router.put('/:roleName/people-picker', checkAdmin, createPermissionUpdateHandler('people-picker')); +router.put('/:roleName/people-picker', manageRoles, createPermissionUpdateHandler('people-picker')); /** * PUT /api/roles/:roleName/mcp-servers * Update MCP servers permissions for a specific role */ -router.put('/:roleName/mcp-servers', checkAdmin, createPermissionUpdateHandler('mcp-servers')); +router.put('/:roleName/mcp-servers', manageRoles, createPermissionUpdateHandler('mcp-servers')); /** * PUT /api/roles/:roleName/marketplace * Update marketplace permissions for a specific role */ -router.put('/:roleName/marketplace', checkAdmin, createPermissionUpdateHandler('marketplace')); +router.put('/:roleName/marketplace', manageRoles, createPermissionUpdateHandler('marketplace')); /** * PUT /api/roles/:roleName/remote-agents * Update remote agents (API) permissions for a specific role */ -router.put('/:roleName/remote-agents', checkAdmin, createPermissionUpdateHandler('remote-agents')); +router.put('/:roleName/remote-agents', manageRoles, createPermissionUpdateHandler('remote-agents')); module.exports = router; diff --git a/api/server/services/systemGrant.spec.js b/api/server/services/systemGrant.spec.js new file mode 100644 index 0000000000..4e10ee5641 --- /dev/null +++ b/api/server/services/systemGrant.spec.js @@ -0,0 +1,407 @@ +const mongoose = require('mongoose'); +const { createModels, createMethods } = require('@librechat/data-schemas'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { SystemRoles, PrincipalType } = require('librechat-data-provider'); +const { SystemCapabilities } = require('@librechat/data-schemas'); + +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + getTransactionSupport: jest.fn().mockResolvedValue(false), + createModels: jest.requireActual('@librechat/data-schemas').createModels, + createMethods: jest.requireActual('@librechat/data-schemas').createMethods, +})); + +jest.mock('~/server/services/GraphApiService', () => ({ + entraIdPrincipalFeatureEnabled: jest.fn().mockReturnValue(false), + getUserOwnedEntraGroups: jest.fn().mockResolvedValue([]), + getUserEntraGroups: jest.fn().mockResolvedValue([]), + getGroupMembers: jest.fn().mockResolvedValue([]), + getGroupOwners: jest.fn().mockResolvedValue([]), +})); + +jest.mock('~/config', () => ({ + logger: { error: jest.fn() }, +})); + +let mongoServer; +let methods; +let SystemGrant; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + + createModels(mongoose); + const dbModels = require('~/db/models'); + Object.assign(mongoose.models, dbModels); + SystemGrant = dbModels.SystemGrant; + + methods = createMethods(mongoose, { + matchModelName: () => null, + findMatchingPattern: () => null, + getCache: () => ({ + get: async () => null, + set: async () => {}, + }), + }); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await SystemGrant.deleteMany({}); +}); + +describe('SystemGrant methods', () => { + describe('seedSystemGrants', () => { + it('seeds all capabilities for the ADMIN role', async () => { + await methods.seedSystemGrants(); + + const grants = await SystemGrant.find({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.ADMIN, + }).lean(); + + const expectedCount = Object.values(SystemCapabilities).length; + expect(grants).toHaveLength(expectedCount); + + const capabilities = grants.map((g) => g.capability).sort(); + const expected = Object.values(SystemCapabilities).sort(); + expect(capabilities).toEqual(expected); + }); + + it('is idempotent — calling twice does not duplicate grants', async () => { + await methods.seedSystemGrants(); + await methods.seedSystemGrants(); + + const count = await SystemGrant.countDocuments({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.ADMIN, + }); + + expect(count).toBe(Object.values(SystemCapabilities).length); + }); + + it('seeds grants with no tenantId', async () => { + await methods.seedSystemGrants(); + + const withTenant = await SystemGrant.countDocuments({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.ADMIN, + tenantId: { $exists: true }, + }); + + expect(withTenant).toBe(0); + }); + }); + + describe('grantCapability / revokeCapability', () => { + it('grants a capability to a user', async () => { + const userId = new mongoose.Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_USERS, + }); + + const grant = await SystemGrant.findOne({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_USERS, + }).lean(); + + expect(grant).toBeTruthy(); + expect(grant.grantedAt).toBeInstanceOf(Date); + }); + + it('upsert does not create duplicates', async () => { + const userId = new mongoose.Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_USERS, + }); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_USERS, + }); + + const count = await SystemGrant.countDocuments({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_USERS, + }); + + expect(count).toBe(1); + }); + + it('revokes a capability', async () => { + const userId = new mongoose.Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_USERS, + }); + + await methods.revokeCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_USERS, + }); + + const grant = await SystemGrant.findOne({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_USERS, + }).lean(); + + expect(grant).toBeNull(); + }); + }); + + describe('hasCapabilityForPrincipals', () => { + it('returns true when role principal has the capability', async () => { + await methods.seedSystemGrants(); + + const principals = [ + { principalType: PrincipalType.USER, principalId: new mongoose.Types.ObjectId() }, + { principalType: PrincipalType.ROLE, principalId: SystemRoles.ADMIN }, + { principalType: PrincipalType.PUBLIC }, + ]; + + const result = await methods.hasCapabilityForPrincipals({ + principals, + capability: SystemCapabilities.ACCESS_ADMIN, + }); + + expect(result).toBe(true); + }); + + it('returns false when no principal has the capability', async () => { + const principals = [ + { principalType: PrincipalType.USER, principalId: new mongoose.Types.ObjectId() }, + { principalType: PrincipalType.ROLE, principalId: SystemRoles.USER }, + { principalType: PrincipalType.PUBLIC }, + ]; + + const result = await methods.hasCapabilityForPrincipals({ + principals, + capability: SystemCapabilities.ACCESS_ADMIN, + }); + + expect(result).toBe(false); + }); + + it('returns false for an empty principals list', async () => { + const result = await methods.hasCapabilityForPrincipals({ + principals: [], + capability: SystemCapabilities.ACCESS_ADMIN, + }); + + expect(result).toBe(false); + }); + + it('ignores PUBLIC principals', async () => { + const result = await methods.hasCapabilityForPrincipals({ + principals: [{ principalType: PrincipalType.PUBLIC }], + capability: SystemCapabilities.ACCESS_ADMIN, + }); + + expect(result).toBe(false); + }); + + it('matches user-level grants', async () => { + const userId = new mongoose.Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_CONFIGS, + }); + + const principals = [ + { principalType: PrincipalType.USER, principalId: userId }, + { principalType: PrincipalType.ROLE, principalId: SystemRoles.USER }, + { principalType: PrincipalType.PUBLIC }, + ]; + + const result = await methods.hasCapabilityForPrincipals({ + principals, + capability: SystemCapabilities.READ_CONFIGS, + }); + + expect(result).toBe(true); + }); + + it('matches group-level grants', async () => { + const groupId = new mongoose.Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.GROUP, + principalId: groupId, + capability: SystemCapabilities.READ_USAGE, + }); + + const principals = [ + { principalType: PrincipalType.USER, principalId: new mongoose.Types.ObjectId() }, + { principalType: PrincipalType.GROUP, principalId: groupId }, + { principalType: PrincipalType.PUBLIC }, + ]; + + const result = await methods.hasCapabilityForPrincipals({ + principals, + capability: SystemCapabilities.READ_USAGE, + }); + + expect(result).toBe(true); + }); + }); + + describe('getCapabilitiesForPrincipal', () => { + it('lists all capabilities for a principal', async () => { + await methods.seedSystemGrants(); + + const grants = await methods.getCapabilitiesForPrincipal({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.ADMIN, + }); + + expect(grants).toHaveLength(Object.values(SystemCapabilities).length); + }); + + it('returns empty array for a principal with no grants', async () => { + const grants = await methods.getCapabilitiesForPrincipal({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.USER, + }); + + expect(grants).toHaveLength(0); + }); + }); + + describe('principalId normalization', () => { + it('grant with string userId is found by hasCapabilityForPrincipals with ObjectId', async () => { + const userId = new mongoose.Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId.toString(), // string input + capability: SystemCapabilities.READ_USAGE, + }); + + const result = await methods.hasCapabilityForPrincipals({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], // ObjectId input + capability: SystemCapabilities.READ_USAGE, + }); + + expect(result).toBe(true); + }); + + it('revoke with string userId removes the grant stored as ObjectId', async () => { + const userId = new mongoose.Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId.toString(), + capability: SystemCapabilities.READ_USAGE, + }); + + await methods.revokeCapability({ + principalType: PrincipalType.USER, + principalId: userId.toString(), // string revoke + capability: SystemCapabilities.READ_USAGE, + }); + + const result = await methods.hasCapabilityForPrincipals({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], + capability: SystemCapabilities.READ_USAGE, + }); + + expect(result).toBe(false); + }); + + it('getCapabilitiesForPrincipal with string userId returns grants stored as ObjectId', async () => { + const userId = new mongoose.Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId.toString(), + capability: SystemCapabilities.READ_USAGE, + }); + + const grants = await methods.getCapabilitiesForPrincipal({ + principalType: PrincipalType.USER, + principalId: userId.toString(), // string lookup + }); + + expect(grants).toHaveLength(1); + expect(grants[0].capability).toBe(SystemCapabilities.READ_USAGE); + }); + }); + + describe('tenant scoping', () => { + it('tenant-scoped grant does not match platform-level query', async () => { + const userId = new mongoose.Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_CONFIGS, + tenantId: 'tenant-1', + }); + + const result = await methods.hasCapabilityForPrincipals({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], + capability: SystemCapabilities.READ_CONFIGS, + }); + + expect(result).toBe(false); + }); + + it('tenant-scoped grant matches same-tenant query', async () => { + const userId = new mongoose.Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_CONFIGS, + tenantId: 'tenant-1', + }); + + const result = await methods.hasCapabilityForPrincipals({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], + capability: SystemCapabilities.READ_CONFIGS, + tenantId: 'tenant-1', + }); + + expect(result).toBe(true); + }); + + it('tenant-scoped grant does not match different tenant', async () => { + const userId = new mongoose.Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_CONFIGS, + tenantId: 'tenant-1', + }); + + const result = await methods.hasCapabilityForPrincipals({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], + capability: SystemCapabilities.READ_CONFIGS, + tenantId: 'tenant-2', + }); + + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/api/src/middleware/capabilities.integration.spec.ts b/packages/api/src/middleware/capabilities.integration.spec.ts new file mode 100644 index 0000000000..dee1f446e6 --- /dev/null +++ b/packages/api/src/middleware/capabilities.integration.spec.ts @@ -0,0 +1,659 @@ +import mongoose, { Types } from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { PrincipalType, SystemRoles } from 'librechat-data-provider'; +import { + createModels, + createMethods, + SystemCapabilities, + CapabilityImplications, +} from '@librechat/data-schemas'; +import type { SystemCapability } from '@librechat/data-schemas'; +import type { AllMethods } from '@librechat/data-schemas'; +import { + generateCapabilityCheck, + capabilityStore, + capabilityContextMiddleware, +} from './capabilities'; + +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + }, +})); + +let mongoServer: MongoMemoryServer; +let methods: AllMethods; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + createModels(mongoose); + methods = createMethods(mongoose); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await mongoose.connection.dropDatabase(); +}); + +/** + * Runs `fn` inside an AsyncLocalStorage context identical to what + * capabilityContextMiddleware sets up for real Express requests. + */ +function withinRequestContext(fn: () => Promise): Promise { + return new Promise((resolve, reject) => { + capabilityContextMiddleware( + {} as Parameters[0], + {} as Parameters[1], + () => { + fn().then(resolve, reject); + }, + ); + }); +} + +describe('capabilities integration (real MongoDB)', () => { + let adminUser: { _id: Types.ObjectId; id: string; role: string }; + let regularUser: { _id: Types.ObjectId; id: string; role: string }; + + beforeEach(async () => { + const User = mongoose.models.User; + + const admin = await User.create({ + name: 'Admin', + email: 'admin@test.com', + password: 'password123', + provider: 'local', + role: SystemRoles.ADMIN, + }); + adminUser = { _id: admin._id, id: admin._id.toString(), role: SystemRoles.ADMIN }; + + const user = await User.create({ + name: 'Regular', + email: 'user@test.com', + password: 'password123', + provider: 'local', + role: SystemRoles.USER, + }); + regularUser = { _id: user._id, id: user._id.toString(), role: SystemRoles.USER }; + }); + + describe('end-to-end with real getUserPrincipals + hasCapabilityForPrincipals', () => { + let hasCapability: ReturnType['hasCapability']; + let hasConfigCapability: ReturnType['hasConfigCapability']; + + beforeEach(() => { + ({ hasCapability, hasConfigCapability } = generateCapabilityCheck({ + getUserPrincipals: methods.getUserPrincipals, + hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals, + })); + }); + + it('returns true for ADMIN after seedSystemGrants', async () => { + await methods.seedSystemGrants(); + + const result = await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN); + expect(result).toBe(true); + }); + + it('returns false for regular USER (no grants)', async () => { + await methods.seedSystemGrants(); + + const result = await hasCapability(regularUser, SystemCapabilities.ACCESS_ADMIN); + expect(result).toBe(false); + }); + + it('resolves all seeded capabilities for ADMIN', async () => { + await methods.seedSystemGrants(); + + for (const cap of Object.values(SystemCapabilities)) { + const result = await hasCapability(adminUser, cap); + expect(result).toBe(true); + } + }); + + it('resolves capability implications (MANAGE_X implies READ_X)', async () => { + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.USER, + capability: SystemCapabilities.MANAGE_USERS, + }); + + const hasManage = await hasCapability(regularUser, SystemCapabilities.MANAGE_USERS); + const hasRead = await hasCapability(regularUser, SystemCapabilities.READ_USERS); + + expect(hasManage).toBe(true); + expect(hasRead).toBe(true); + }); + + it('implication is one-directional (READ_X does NOT imply MANAGE_X)', async () => { + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.USER, + capability: SystemCapabilities.READ_USERS, + }); + + const hasRead = await hasCapability(regularUser, SystemCapabilities.READ_USERS); + const hasManage = await hasCapability(regularUser, SystemCapabilities.MANAGE_USERS); + + expect(hasRead).toBe(true); + expect(hasManage).toBe(false); + }); + + it('grants to a specific user work independently of role', async () => { + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: regularUser.id, + capability: SystemCapabilities.READ_AGENTS, + }); + + const result = await hasCapability(regularUser, SystemCapabilities.READ_AGENTS); + expect(result).toBe(true); + }); + + it('grants via group membership are resolved', async () => { + const Group = mongoose.models.Group; + const group = await Group.create({ + name: 'Editors', + source: 'local', + memberIds: [regularUser.id], + }); + + await methods.grantCapability({ + principalType: PrincipalType.GROUP, + principalId: group._id, + capability: SystemCapabilities.MANAGE_PROMPTS, + }); + + const result = await hasCapability(regularUser, SystemCapabilities.MANAGE_PROMPTS); + expect(result).toBe(true); + }); + + it('revoked capability is no longer granted', async () => { + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.USER, + capability: SystemCapabilities.READ_USAGE, + }); + expect(await hasCapability(regularUser, SystemCapabilities.READ_USAGE)).toBe(true); + + await methods.revokeCapability({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.USER, + capability: SystemCapabilities.READ_USAGE, + }); + expect(await hasCapability(regularUser, SystemCapabilities.READ_USAGE)).toBe(false); + }); + + it('tenant-scoped grant does not leak to platform-level check', async () => { + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.USER, + capability: SystemCapabilities.ACCESS_ADMIN, + tenantId: 'tenant-a', + }); + + const platformResult = await hasCapability(regularUser, SystemCapabilities.ACCESS_ADMIN); + expect(platformResult).toBe(false); + + const tenantResult = await hasCapability( + { ...regularUser, tenantId: 'tenant-a' }, + SystemCapabilities.ACCESS_ADMIN, + ); + expect(tenantResult).toBe(true); + }); + + it('hasConfigCapability falls back to section-specific grant', async () => { + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: regularUser.id, + capability: 'manage:configs:endpoints' as SystemCapability, + }); + + const hasBroad = await hasConfigCapability(regularUser, 'endpoints'); + expect(hasBroad).toBe(true); + + const hasOtherSection = await hasConfigCapability(regularUser, 'balance'); + expect(hasOtherSection).toBe(false); + }); + }); + + describe('AsyncLocalStorage per-request caching', () => { + it('caches getUserPrincipals within a single request context', async () => { + await methods.seedSystemGrants(); + const getUserPrincipals = jest.fn(methods.getUserPrincipals); + + const { hasCapability } = generateCapabilityCheck({ + getUserPrincipals, + hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals, + }); + + await withinRequestContext(async () => { + await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN); + await hasCapability(adminUser, SystemCapabilities.MANAGE_USERS); + await hasCapability(adminUser, SystemCapabilities.READ_CONFIGS); + }); + + expect(getUserPrincipals).toHaveBeenCalledTimes(1); + }); + + it('caches capability results within a single request context', async () => { + await methods.seedSystemGrants(); + const hasCapabilityForPrincipals = jest.fn(methods.hasCapabilityForPrincipals); + + const { hasCapability } = generateCapabilityCheck({ + getUserPrincipals: methods.getUserPrincipals, + hasCapabilityForPrincipals, + }); + + await withinRequestContext(async () => { + const r1 = await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN); + const r2 = await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN); + expect(r1).toBe(true); + expect(r2).toBe(true); + }); + + const accessAdminCalls = hasCapabilityForPrincipals.mock.calls.filter( + (args) => args[0].capability === SystemCapabilities.ACCESS_ADMIN, + ); + expect(accessAdminCalls).toHaveLength(1); + }); + + it('does NOT share cache across separate request contexts', async () => { + await methods.seedSystemGrants(); + const getUserPrincipals = jest.fn(methods.getUserPrincipals); + + const { hasCapability } = generateCapabilityCheck({ + getUserPrincipals, + hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals, + }); + + await withinRequestContext(async () => { + await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN); + }); + + await withinRequestContext(async () => { + await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN); + }); + + expect(getUserPrincipals).toHaveBeenCalledTimes(2); + }); + + it('isolates cache between concurrent request contexts', async () => { + await methods.seedSystemGrants(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: regularUser.id, + capability: SystemCapabilities.READ_AGENTS, + }); + + const { hasCapability } = generateCapabilityCheck({ + getUserPrincipals: methods.getUserPrincipals, + hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals, + }); + + const results = await Promise.all([ + withinRequestContext(async () => { + const admin = await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN); + const agents = await hasCapability(adminUser, SystemCapabilities.READ_AGENTS); + return { admin, agents, who: 'admin' }; + }), + withinRequestContext(async () => { + const admin = await hasCapability(regularUser, SystemCapabilities.ACCESS_ADMIN); + const agents = await hasCapability(regularUser, SystemCapabilities.READ_AGENTS); + return { admin, agents, who: 'regular' }; + }), + ]); + + const adminResult = results.find((r) => r.who === 'admin')!; + const regularResult = results.find((r) => r.who === 'regular')!; + + expect(adminResult.admin).toBe(true); + expect(adminResult.agents).toBe(true); + expect(regularResult.admin).toBe(false); + expect(regularResult.agents).toBe(true); + }); + + it('falls through to DB when outside request context (no ALS)', async () => { + await methods.seedSystemGrants(); + const getUserPrincipals = jest.fn(methods.getUserPrincipals); + + const { hasCapability } = generateCapabilityCheck({ + getUserPrincipals, + hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals, + }); + + await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN); + await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN); + + expect(getUserPrincipals).toHaveBeenCalledTimes(2); + }); + + it('caches false results correctly (negative caching)', async () => { + const hasCapabilityForPrincipals = jest.fn(methods.hasCapabilityForPrincipals); + + const { hasCapability } = generateCapabilityCheck({ + getUserPrincipals: methods.getUserPrincipals, + hasCapabilityForPrincipals, + }); + + await withinRequestContext(async () => { + const r1 = await hasCapability(regularUser, SystemCapabilities.MANAGE_USERS); + const r2 = await hasCapability(regularUser, SystemCapabilities.MANAGE_USERS); + expect(r1).toBe(false); + expect(r2).toBe(false); + }); + + const manageUserCalls = hasCapabilityForPrincipals.mock.calls.filter( + (args) => args[0].capability === SystemCapabilities.MANAGE_USERS, + ); + expect(manageUserCalls).toHaveLength(1); + }); + + it('uses separate principal cache keys for different users in same context', async () => { + await methods.seedSystemGrants(); + const getUserPrincipals = jest.fn(methods.getUserPrincipals); + + const { hasCapability } = generateCapabilityCheck({ + getUserPrincipals, + hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals, + }); + + await withinRequestContext(async () => { + await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN); + await hasCapability(regularUser, SystemCapabilities.ACCESS_ADMIN); + }); + + expect(getUserPrincipals).toHaveBeenCalledTimes(2); + }); + + it('uses separate principal cache keys for different tenantIds (same user)', async () => { + await methods.seedSystemGrants(); + const getUserPrincipals = jest.fn(methods.getUserPrincipals); + + const { hasCapability } = generateCapabilityCheck({ + getUserPrincipals, + hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals, + }); + + await withinRequestContext(async () => { + await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN); + await hasCapability( + { ...adminUser, tenantId: 'tenant-a' }, + SystemCapabilities.ACCESS_ADMIN, + ); + }); + + expect(getUserPrincipals).toHaveBeenCalledTimes(2); + }); + }); + + describe('requireCapability middleware (real DB, real ALS)', () => { + it('calls next() for granted capability inside request context', async () => { + await methods.seedSystemGrants(); + + const { requireCapability } = generateCapabilityCheck({ + getUserPrincipals: methods.getUserPrincipals, + hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals, + }); + + const middleware = requireCapability(SystemCapabilities.ACCESS_ADMIN); + const next = jest.fn(); + const jsonMock = jest.fn(); + const statusMock = jest.fn().mockReturnValue({ json: jsonMock }); + const req = { user: { id: adminUser.id, role: adminUser.role } }; + const res = { status: statusMock }; + + await withinRequestContext(async () => { + await middleware(req as never, res as never, next); + }); + + expect(next).toHaveBeenCalled(); + expect(statusMock).not.toHaveBeenCalled(); + }); + + it('returns 403 for denied capability inside request context', async () => { + await methods.seedSystemGrants(); + + const { requireCapability } = generateCapabilityCheck({ + getUserPrincipals: methods.getUserPrincipals, + hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals, + }); + + const middleware = requireCapability(SystemCapabilities.MANAGE_USERS); + const next = jest.fn(); + const jsonMock = jest.fn(); + const statusMock = jest.fn().mockReturnValue({ json: jsonMock }); + const req = { user: { id: regularUser.id, role: regularUser.role } }; + const res = { status: statusMock }; + + await withinRequestContext(async () => { + await middleware(req as never, res as never, next); + }); + + expect(next).not.toHaveBeenCalled(); + expect(statusMock).toHaveBeenCalledWith(403); + }); + }); + + describe('ALS edge cases', () => { + it('returns correct results when ALS context is missing (background job / child process)', async () => { + await methods.seedSystemGrants(); + + const { hasCapability } = generateCapabilityCheck({ + getUserPrincipals: methods.getUserPrincipals, + hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals, + }); + + expect(capabilityStore.getStore()).toBeUndefined(); + + const adminResult = await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN); + const userResult = await hasCapability(regularUser, SystemCapabilities.ACCESS_ADMIN); + + expect(adminResult).toBe(true); + expect(userResult).toBe(false); + }); + + it('every DB call executes (no caching) when ALS context is missing', async () => { + await methods.seedSystemGrants(); + const getUserPrincipals = jest.fn(methods.getUserPrincipals); + const hasCapabilityForPrincipals = jest.fn(methods.hasCapabilityForPrincipals); + + const { hasCapability } = generateCapabilityCheck({ + getUserPrincipals, + hasCapabilityForPrincipals, + }); + + expect(capabilityStore.getStore()).toBeUndefined(); + + await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN); + await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN); + await hasCapability(adminUser, SystemCapabilities.MANAGE_USERS); + + expect(getUserPrincipals).toHaveBeenCalledTimes(3); + expect(hasCapabilityForPrincipals).toHaveBeenCalledTimes(3); + }); + + it('nested capabilityContextMiddleware creates an independent inner context', async () => { + await methods.seedSystemGrants(); + const getUserPrincipals = jest.fn(methods.getUserPrincipals); + + const { hasCapability } = generateCapabilityCheck({ + getUserPrincipals, + hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals, + }); + + await withinRequestContext(async () => { + await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN); + + await withinRequestContext(async () => { + await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN); + }); + + await hasCapability(adminUser, SystemCapabilities.MANAGE_USERS); + }); + + /** + * Outer context: 1 call (ACCESS_ADMIN) — principals cached, MANAGE_USERS reuses them. + * Inner context: 1 call (ACCESS_ADMIN) — fresh context, no cache from outer. + * Total: 2 getUserPrincipals calls. + */ + expect(getUserPrincipals).toHaveBeenCalledTimes(2); + }); + + it('store.results.set with undefined store is a no-op (optional chaining safety)', async () => { + await methods.seedSystemGrants(); + + const { hasCapability } = generateCapabilityCheck({ + getUserPrincipals: methods.getUserPrincipals, + hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals, + }); + + expect(capabilityStore.getStore()).toBeUndefined(); + + await expect(hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN)).resolves.toBe(true); + }); + + it('grant change mid-request is invisible due to result caching', async () => { + await methods.seedSystemGrants(); + + const { hasCapability } = generateCapabilityCheck({ + getUserPrincipals: methods.getUserPrincipals, + hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals, + }); + + await withinRequestContext(async () => { + const before = await hasCapability(regularUser, SystemCapabilities.READ_AGENTS); + expect(before).toBe(false); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: regularUser.id, + capability: SystemCapabilities.READ_AGENTS, + }); + + const after = await hasCapability(regularUser, SystemCapabilities.READ_AGENTS); + expect(after).toBe(false); + }); + + const afterContext = await hasCapability(regularUser, SystemCapabilities.READ_AGENTS); + expect(afterContext).toBe(true); + }); + + it('requireCapability works correctly without ALS context', async () => { + await methods.seedSystemGrants(); + + const { requireCapability } = generateCapabilityCheck({ + getUserPrincipals: methods.getUserPrincipals, + hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals, + }); + + expect(capabilityStore.getStore()).toBeUndefined(); + + const middleware = requireCapability(SystemCapabilities.ACCESS_ADMIN); + const next = jest.fn(); + const jsonMock = jest.fn(); + const statusMock = jest.fn().mockReturnValue({ json: jsonMock }); + const req = { user: { id: adminUser.id, role: adminUser.role } }; + const res = { status: statusMock }; + + await middleware(req as never, res as never, next); + + expect(next).toHaveBeenCalled(); + expect(statusMock).not.toHaveBeenCalled(); + }); + + it('concurrent contexts with interleaved awaits maintain isolation', async () => { + await methods.seedSystemGrants(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: regularUser.id, + capability: SystemCapabilities.READ_AGENTS, + }); + + const getUserPrincipals = jest.fn(methods.getUserPrincipals); + + const { hasCapability } = generateCapabilityCheck({ + getUserPrincipals, + hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals, + }); + + let adminResolve: () => void; + const adminGate = new Promise((r) => { + adminResolve = r; + }); + + let userResolve: () => void; + const userGate = new Promise((r) => { + userResolve = r; + }); + + const adminPromise = withinRequestContext(async () => { + const r1 = await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN); + adminResolve!(); + await userGate; + const r2 = await hasCapability(adminUser, SystemCapabilities.READ_AGENTS); + return { r1, r2 }; + }); + + const userPromise = withinRequestContext(async () => { + await adminGate; + const r1 = await hasCapability(regularUser, SystemCapabilities.ACCESS_ADMIN); + userResolve!(); + const r2 = await hasCapability(regularUser, SystemCapabilities.READ_AGENTS); + return { r1, r2 }; + }); + + const [adminResults, userResults] = await Promise.all([adminPromise, userPromise]); + + expect(adminResults.r1).toBe(true); + expect(adminResults.r2).toBe(true); + expect(userResults.r1).toBe(false); + expect(userResults.r2).toBe(true); + + expect(getUserPrincipals).toHaveBeenCalledTimes(2); + }); + }); + + describe('CapabilityImplications consistency', () => { + it('every implication pair resolves correctly through the full stack', async () => { + const pairs = Object.entries(CapabilityImplications) as [ + SystemCapability, + SystemCapability[], + ][]; + + const { hasCapability } = generateCapabilityCheck({ + getUserPrincipals: methods.getUserPrincipals, + hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals, + }); + + for (const [broadCap, impliedCaps] of pairs) { + await mongoose.connection.dropDatabase(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: regularUser.id, + capability: broadCap, + }); + + for (const impliedCap of impliedCaps) { + const result = await hasCapability(regularUser, impliedCap); + expect(result).toBe(true); + } + + const hasBroad = await hasCapability(regularUser, broadCap); + expect(hasBroad).toBe(true); + } + }); + }); +}); diff --git a/packages/api/src/middleware/capabilities.spec.ts b/packages/api/src/middleware/capabilities.spec.ts new file mode 100644 index 0000000000..75d3142369 --- /dev/null +++ b/packages/api/src/middleware/capabilities.spec.ts @@ -0,0 +1,212 @@ +import { PrincipalType } from 'librechat-data-provider'; +import { + configCapability, + SystemCapabilities, + readConfigCapability, +} from '@librechat/data-schemas'; +import type { Response } from 'express'; +import type { ServerRequest } from '~/types/http'; +import { generateCapabilityCheck } from './capabilities'; + +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { + error: jest.fn(), + }, +})); + +const adminPrincipals = [ + { principalType: PrincipalType.USER, principalId: 'user-123' }, + { principalType: PrincipalType.ROLE, principalId: 'ADMIN' }, + { principalType: PrincipalType.PUBLIC }, +]; + +const userPrincipals = [ + { principalType: PrincipalType.USER, principalId: 'user-456' }, + { principalType: PrincipalType.ROLE, principalId: 'USER' }, + { principalType: PrincipalType.PUBLIC }, +]; + +describe('generateCapabilityCheck', () => { + const mockGetUserPrincipals = jest.fn(); + const mockHasCapabilityForPrincipals = jest.fn(); + + const { hasCapability, requireCapability, hasConfigCapability } = generateCapabilityCheck({ + getUserPrincipals: mockGetUserPrincipals, + hasCapabilityForPrincipals: mockHasCapabilityForPrincipals, + }); + + beforeEach(() => { + mockGetUserPrincipals.mockReset(); + mockHasCapabilityForPrincipals.mockReset(); + }); + + describe('hasCapability', () => { + it('returns true for a user with the capability', async () => { + mockGetUserPrincipals.mockResolvedValue(adminPrincipals); + mockHasCapabilityForPrincipals.mockResolvedValue(true); + + const result = await hasCapability( + { id: 'user-123', role: 'ADMIN' }, + SystemCapabilities.ACCESS_ADMIN, + ); + + expect(result).toBe(true); + expect(mockGetUserPrincipals).toHaveBeenCalledWith({ userId: 'user-123', role: 'ADMIN' }); + expect(mockHasCapabilityForPrincipals).toHaveBeenCalledWith({ + principals: adminPrincipals, + capability: SystemCapabilities.ACCESS_ADMIN, + tenantId: undefined, + }); + }); + + it('returns false for a user without the capability', async () => { + mockGetUserPrincipals.mockResolvedValue(userPrincipals); + mockHasCapabilityForPrincipals.mockResolvedValue(false); + + const result = await hasCapability( + { id: 'user-456', role: 'USER' }, + SystemCapabilities.MANAGE_USERS, + ); + + expect(result).toBe(false); + }); + + it('passes tenantId when present on user', async () => { + mockGetUserPrincipals.mockResolvedValue(adminPrincipals); + mockHasCapabilityForPrincipals.mockResolvedValue(true); + + await hasCapability( + { id: 'user-123', role: 'ADMIN', tenantId: 'tenant-1' }, + SystemCapabilities.READ_CONFIGS, + ); + + expect(mockHasCapabilityForPrincipals).toHaveBeenCalledWith( + expect.objectContaining({ tenantId: 'tenant-1' }), + ); + }); + }); + + describe('requireCapability', () => { + let mockReq: Partial; + let mockRes: Partial; + let mockNext: jest.Mock; + let jsonMock: jest.Mock; + let statusMock: jest.Mock; + + beforeEach(() => { + jsonMock = jest.fn(); + statusMock = jest.fn().mockReturnValue({ json: jsonMock }); + + mockReq = { + user: { id: 'user-123', role: 'ADMIN' } as ServerRequest['user'], + }; + mockRes = { status: statusMock }; + mockNext = jest.fn(); + }); + + it('calls next() when user has the capability', async () => { + mockGetUserPrincipals.mockResolvedValue(adminPrincipals); + mockHasCapabilityForPrincipals.mockResolvedValue(true); + + const middleware = requireCapability(SystemCapabilities.ACCESS_ADMIN); + await middleware(mockReq as ServerRequest, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(statusMock).not.toHaveBeenCalled(); + }); + + it('returns 403 when user lacks the capability', async () => { + mockReq.user = { id: 'user-456', role: 'USER' } as ServerRequest['user']; + mockGetUserPrincipals.mockResolvedValue(userPrincipals); + mockHasCapabilityForPrincipals.mockResolvedValue(false); + + const middleware = requireCapability(SystemCapabilities.MANAGE_USERS); + await middleware(mockReq as ServerRequest, mockRes as Response, mockNext); + + expect(mockNext).not.toHaveBeenCalled(); + expect(statusMock).toHaveBeenCalledWith(403); + expect(jsonMock).toHaveBeenCalledWith({ message: 'Forbidden' }); + }); + + it('returns 401 when no user is present', async () => { + mockReq.user = undefined; + + const middleware = requireCapability(SystemCapabilities.ACCESS_ADMIN); + await middleware(mockReq as ServerRequest, mockRes as Response, mockNext); + + expect(mockNext).not.toHaveBeenCalled(); + expect(statusMock).toHaveBeenCalledWith(401); + expect(jsonMock).toHaveBeenCalledWith({ message: 'Authentication required' }); + }); + + it('returns 500 on unexpected error', async () => { + mockGetUserPrincipals.mockRejectedValue(new Error('DB down')); + + const middleware = requireCapability(SystemCapabilities.ACCESS_ADMIN); + await middleware(mockReq as ServerRequest, mockRes as Response, mockNext); + + expect(mockNext).not.toHaveBeenCalled(); + expect(statusMock).toHaveBeenCalledWith(500); + expect(jsonMock).toHaveBeenCalledWith({ message: 'Internal Server Error' }); + }); + }); + + describe('hasConfigCapability', () => { + const adminUser = { id: 'user-123', role: 'ADMIN' }; + const delegatedUser = { id: 'user-789', role: 'MANAGER' }; + + it('returns true when user has broad manage:configs capability', async () => { + mockGetUserPrincipals.mockResolvedValue(adminPrincipals); + mockHasCapabilityForPrincipals.mockResolvedValue(true); + + const result = await hasConfigCapability(adminUser, 'endpoints'); + + expect(result).toBe(true); + expect(mockHasCapabilityForPrincipals).toHaveBeenCalledWith( + expect.objectContaining({ capability: SystemCapabilities.MANAGE_CONFIGS }), + ); + }); + + it('falls back to section-specific capability when broad check fails', async () => { + mockGetUserPrincipals.mockResolvedValue(userPrincipals); + // First call (broad) returns false, second call (section) returns true + mockHasCapabilityForPrincipals.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + const result = await hasConfigCapability(delegatedUser, 'endpoints'); + + expect(result).toBe(true); + expect(mockHasCapabilityForPrincipals).toHaveBeenCalledTimes(2); + expect(mockHasCapabilityForPrincipals).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ capability: configCapability('endpoints') }), + ); + }); + + it('returns false when user has neither broad nor section capability', async () => { + mockGetUserPrincipals.mockResolvedValue(userPrincipals); + mockHasCapabilityForPrincipals.mockResolvedValue(false); + + const result = await hasConfigCapability(delegatedUser, 'balance'); + + expect(result).toBe(false); + }); + + it('checks read:configs when verb is "read"', async () => { + mockGetUserPrincipals.mockResolvedValue(userPrincipals); + mockHasCapabilityForPrincipals.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + const result = await hasConfigCapability(delegatedUser, 'endpoints', 'read'); + + expect(result).toBe(true); + expect(mockHasCapabilityForPrincipals).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ capability: SystemCapabilities.READ_CONFIGS }), + ); + expect(mockHasCapabilityForPrincipals).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ capability: readConfigCapability('endpoints') }), + ); + }); + }); +}); diff --git a/packages/api/src/middleware/capabilities.ts b/packages/api/src/middleware/capabilities.ts new file mode 100644 index 0000000000..c06a90ac8e --- /dev/null +++ b/packages/api/src/middleware/capabilities.ts @@ -0,0 +1,188 @@ +import { isMainThread } from 'node:worker_threads'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { + logger, + configCapability, + SystemCapabilities, + readConfigCapability, +} from '@librechat/data-schemas'; +import type { PrincipalType } from 'librechat-data-provider'; +import type { SystemCapability, ConfigSection } from '@librechat/data-schemas'; +import type { NextFunction, Response } from 'express'; +import type { Types } from 'mongoose'; +import type { ServerRequest } from '~/types/http'; + +interface ResolvedPrincipal { + principalType: PrincipalType; + principalId?: string | Types.ObjectId; +} + +interface CapabilityDeps { + getUserPrincipals: (params: { userId: string; role: string }) => Promise; + hasCapabilityForPrincipals: (params: { + principals: ResolvedPrincipal[]; + capability: SystemCapability; + tenantId?: string; + }) => Promise; +} + +interface CapabilityUser { + id: string; + role: string; + tenantId?: string; +} + +interface CapabilityStore { + principals: Map; + results: Map; +} + +export type HasCapabilityFn = ( + user: CapabilityUser, + capability: SystemCapability, +) => Promise; + +export type RequireCapabilityFn = ( + capability: SystemCapability, +) => (req: ServerRequest, res: Response, next: NextFunction) => Promise; + +export type HasConfigCapabilityFn = ( + user: CapabilityUser, + section: ConfigSection, + verb?: 'manage' | 'read', +) => Promise; + +/** + * Per-request store for caching resolved principals and capability check results. + * When running inside an Express request (via `capabilityContextMiddleware`), + * duplicate `hasCapability` calls within the same request are served from + * the in-memory Map instead of hitting the database again. + * Outside a request context (background jobs, tests), the store is undefined + * and every check falls through to the database — correct behavior. + */ +export const capabilityStore = new AsyncLocalStorage(); + +export function capabilityContextMiddleware( + _req: ServerRequest, + _res: Response, + next: NextFunction, +): void { + if (!isMainThread) { + logger.error( + '[capabilityContextMiddleware] Mounted in a worker thread — ' + + 'ALS context will not propagate to the main thread or other workers. ' + + 'This middleware should only run in the main Express process.', + ); + } + capabilityStore.run({ principals: new Map(), results: new Map() }, next); +} + +/** + * Factory that creates `hasCapability` and `requireCapability` with injected + * database methods. Follows the same dependency-injection pattern as + * `generateCheckAccess`. + */ +export function generateCapabilityCheck(deps: CapabilityDeps): { + hasCapability: HasCapabilityFn; + requireCapability: RequireCapabilityFn; + hasConfigCapability: HasConfigCapabilityFn; +} { + const { getUserPrincipals, hasCapabilityForPrincipals } = deps; + + let workerWarned = false; + + async function hasCapability( + user: CapabilityUser, + capability: SystemCapability, + ): Promise { + if (!isMainThread && !workerWarned) { + workerWarned = true; + logger.warn( + '[hasCapability] Called from a worker thread — ALS context is unavailable. ' + + 'Capability checks will hit the database on every call (no per-request caching). ' + + 'If this is intentional, no action needed.', + ); + } + + const store = capabilityStore.getStore(); + + const resultKey = `${user.id}:${user.tenantId ?? ''}:${capability}`; + const cached = store?.results.get(resultKey); + if (cached !== undefined) { + return cached; + } + + const principalKey = `${user.id}:${user.role}:${user.tenantId ?? ''}`; + let principals: ResolvedPrincipal[]; + const cachedPrincipals = store?.principals.get(principalKey); + if (cachedPrincipals) { + principals = cachedPrincipals; + } else { + principals = await getUserPrincipals({ userId: user.id, role: user.role }); + store?.principals.set(principalKey, principals); + } + + const result = await hasCapabilityForPrincipals({ + principals, + capability, + tenantId: user.tenantId, + }); + store?.results.set(resultKey, result); + return result; + } + + /** + * Checks if a user can manage or read a specific config section. + * First checks the broad capability (manage:configs / read:configs), + * then falls back to the section-specific capability (manage:configs:
). + */ + async function hasConfigCapability( + user: CapabilityUser, + section: ConfigSection, + verb: 'manage' | 'read' = 'manage', + ): Promise { + const broadCap = + verb === 'manage' ? SystemCapabilities.MANAGE_CONFIGS : SystemCapabilities.READ_CONFIGS; + if (await hasCapability(user, broadCap)) { + return true; + } + const sectionCap = + verb === 'manage' ? configCapability(section) : readConfigCapability(section); + return hasCapability(user, sectionCap); + } + + function requireCapability(capability: SystemCapability) { + return async (req: ServerRequest, res: Response, next: NextFunction) => { + try { + if (!req.user) { + res.status(401).json({ message: 'Authentication required' }); + return; + } + + const id = req.user.id ?? req.user._id?.toString(); + if (!id) { + res.status(401).json({ message: 'Authentication required' }); + return; + } + + const user: CapabilityUser = { + id, + role: req.user.role ?? '', + tenantId: (req.user as CapabilityUser).tenantId, + }; + + if (await hasCapability(user, capability)) { + next(); + return; + } + + res.status(403).json({ message: 'Forbidden' }); + } catch (err) { + logger.error(`[requireCapability] Error checking capability: ${capability}`, err); + res.status(500).json({ message: 'Internal Server Error' }); + } + }; + } + + return { hasCapability, requireCapability, hasConfigCapability }; +} diff --git a/packages/api/src/middleware/index.ts b/packages/api/src/middleware/index.ts index 7787d89dfe..a56b8e4a3e 100644 --- a/packages/api/src/middleware/index.ts +++ b/packages/api/src/middleware/index.ts @@ -4,5 +4,6 @@ export * from './error'; export * from './notFound'; export * from './balance'; export * from './json'; +export * from './capabilities'; export * from './concurrency'; export * from './checkBalance'; diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts index ae69fc58bb..3a34b574ae 100644 --- a/packages/data-schemas/src/index.ts +++ b/packages/data-schemas/src/index.ts @@ -1,4 +1,5 @@ export * from './app'; +export * from './systemCapabilities'; export * from './common'; export * from './crypto'; export * from './schema'; diff --git a/packages/data-schemas/src/methods/aclEntry.spec.ts b/packages/data-schemas/src/methods/aclEntry.spec.ts index b6643c416e..df59268db4 100644 --- a/packages/data-schemas/src/methods/aclEntry.spec.ts +++ b/packages/data-schemas/src/methods/aclEntry.spec.ts @@ -959,4 +959,285 @@ describe('AclEntry Model Tests', () => { expect(permissionsMap.get(resource2.toString())).toBe(PermissionBits.EDIT); }); }); + + describe('deleteAclEntries', () => { + test('should delete entries matching the filter', async () => { + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.AGENT, + resourceId, + PermissionBits.VIEW, + grantedById, + ); + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.MCPSERVER, + resourceId, + PermissionBits.EDIT, + grantedById, + ); + + const result = await methods.deleteAclEntries({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.AGENT, + }); + + expect(result.deletedCount).toBe(1); + const remaining = await AclEntry.countDocuments({ principalId: userId }); + expect(remaining).toBe(1); + }); + + test('should delete all entries when filter matches multiple', async () => { + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.AGENT, + new mongoose.Types.ObjectId(), + PermissionBits.VIEW, + grantedById, + ); + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.AGENT, + new mongoose.Types.ObjectId(), + PermissionBits.EDIT, + grantedById, + ); + + const result = await methods.deleteAclEntries({ + principalType: PrincipalType.USER, + principalId: userId, + }); + + expect(result.deletedCount).toBe(2); + }); + + test('should return zero deletedCount when no match', async () => { + const result = await methods.deleteAclEntries({ + principalId: new mongoose.Types.ObjectId(), + }); + expect(result.deletedCount).toBe(0); + }); + }); + + describe('bulkWriteAclEntries', () => { + test('should perform bulk inserts', async () => { + const res1 = new mongoose.Types.ObjectId(); + const res2 = new mongoose.Types.ObjectId(); + + const result = await methods.bulkWriteAclEntries([ + { + insertOne: { + document: { + principalType: PrincipalType.USER, + principalId: userId, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: res1, + permBits: PermissionBits.VIEW, + grantedBy: grantedById, + grantedAt: new Date(), + }, + }, + }, + { + insertOne: { + document: { + principalType: PrincipalType.USER, + principalId: userId, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: res2, + permBits: PermissionBits.EDIT, + grantedBy: grantedById, + grantedAt: new Date(), + }, + }, + }, + ]); + + expect(result.insertedCount).toBe(2); + const entries = await AclEntry.countDocuments({ principalId: userId }); + expect(entries).toBe(2); + }); + + test('should perform bulk updates', async () => { + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.AGENT, + resourceId, + PermissionBits.VIEW, + grantedById, + ); + + await methods.bulkWriteAclEntries([ + { + updateOne: { + filter: { + principalType: PrincipalType.USER, + principalId: userId, + resourceId, + }, + update: { $set: { permBits: PermissionBits.VIEW | PermissionBits.EDIT } }, + }, + }, + ]); + + const entry = await AclEntry.findOne({ principalId: userId, resourceId }).lean(); + expect(entry?.permBits).toBe(PermissionBits.VIEW | PermissionBits.EDIT); + }); + }); + + describe('findPublicResourceIds', () => { + test('should find resources with public VIEW access', async () => { + const publicRes1 = new mongoose.Types.ObjectId(); + const publicRes2 = new mongoose.Types.ObjectId(); + const privateRes = new mongoose.Types.ObjectId(); + + await methods.grantPermission( + PrincipalType.PUBLIC, + null, + ResourceType.AGENT, + publicRes1, + PermissionBits.VIEW, + grantedById, + ); + await methods.grantPermission( + PrincipalType.PUBLIC, + null, + ResourceType.AGENT, + publicRes2, + PermissionBits.VIEW | PermissionBits.EDIT, + grantedById, + ); + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.AGENT, + privateRes, + PermissionBits.VIEW, + grantedById, + ); + + const publicIds = await methods.findPublicResourceIds( + ResourceType.AGENT, + PermissionBits.VIEW, + ); + + expect(publicIds).toHaveLength(2); + const idStrings = publicIds.map((id) => id.toString()).sort(); + expect(idStrings).toEqual([publicRes1.toString(), publicRes2.toString()].sort()); + }); + + test('should filter by required permission bits', async () => { + const viewOnly = new mongoose.Types.ObjectId(); + const viewEdit = new mongoose.Types.ObjectId(); + + await methods.grantPermission( + PrincipalType.PUBLIC, + null, + ResourceType.AGENT, + viewOnly, + PermissionBits.VIEW, + grantedById, + ); + await methods.grantPermission( + PrincipalType.PUBLIC, + null, + ResourceType.AGENT, + viewEdit, + PermissionBits.VIEW | PermissionBits.EDIT, + grantedById, + ); + + const editableIds = await methods.findPublicResourceIds( + ResourceType.AGENT, + PermissionBits.EDIT, + ); + + expect(editableIds).toHaveLength(1); + expect(editableIds[0].toString()).toBe(viewEdit.toString()); + }); + + test('should return empty array when no public resources exist', async () => { + const ids = await methods.findPublicResourceIds(ResourceType.AGENT, PermissionBits.VIEW); + expect(ids).toEqual([]); + }); + + test('should filter by resource type', async () => { + const agentRes = new mongoose.Types.ObjectId(); + const mcpRes = new mongoose.Types.ObjectId(); + + await methods.grantPermission( + PrincipalType.PUBLIC, + null, + ResourceType.AGENT, + agentRes, + PermissionBits.VIEW, + grantedById, + ); + await methods.grantPermission( + PrincipalType.PUBLIC, + null, + ResourceType.MCPSERVER, + mcpRes, + PermissionBits.VIEW, + grantedById, + ); + + const agentIds = await methods.findPublicResourceIds(ResourceType.AGENT, PermissionBits.VIEW); + expect(agentIds).toHaveLength(1); + expect(agentIds[0].toString()).toBe(agentRes.toString()); + }); + }); + + describe('aggregateAclEntries', () => { + test('should run an aggregation pipeline and return results', async () => { + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.AGENT, + resourceId, + PermissionBits.VIEW, + grantedById, + ); + await methods.grantPermission( + PrincipalType.GROUP, + groupId, + ResourceType.AGENT, + resourceId, + PermissionBits.EDIT, + grantedById, + ); + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.MCPSERVER, + new mongoose.Types.ObjectId(), + PermissionBits.VIEW, + grantedById, + ); + + const results = await methods.aggregateAclEntries([ + { $group: { _id: '$resourceType', count: { $sum: 1 } } }, + { $sort: { _id: 1 } }, + ]); + + expect(results).toHaveLength(2); + const agentResult = results.find((r: { _id: string }) => r._id === ResourceType.AGENT); + expect(agentResult.count).toBe(2); + }); + + test('should return empty array for non-matching pipeline', async () => { + const results = await methods.aggregateAclEntries([ + { $match: { principalType: 'nonexistent' } }, + ]); + expect(results).toEqual([]); + }); + }); }); diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index ee4b1cdf9d..798eed78f9 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -20,6 +20,7 @@ import { createPluginAuthMethods, type PluginAuthMethods } from './pluginAuth'; import { createAccessRoleMethods, type AccessRoleMethods } from './accessRole'; import { createUserGroupMethods, type UserGroupMethods } from './userGroup'; import { createAclEntryMethods, type AclEntryMethods } from './aclEntry'; +import { createSystemGrantMethods, type SystemGrantMethods } from './systemGrant'; import { createShareMethods, type ShareMethods } from './share'; /* Tier 1 — Simple CRUD */ import { createActionMethods, type ActionMethods } from './action'; @@ -62,6 +63,7 @@ export type AllMethods = UserMethods & MCPServerMethods & UserGroupMethods & AclEntryMethods & + SystemGrantMethods & ShareMethods & AccessRoleMethods & PluginAuthMethods & @@ -133,6 +135,8 @@ export function createMethods( // ACL entry methods (used internally for removeAllPermissions) const aclEntryMethods = createAclEntryMethods(mongoose); + const systemGrantMethods = createSystemGrantMethods(mongoose); + // Internal removeAllPermissions: use deleteAclEntries from aclEntryMethods // instead of requiring it as an external dep from PermissionService const removeAllPermissions = @@ -172,6 +176,7 @@ export function createMethods( ...createAccessRoleMethods(mongoose), ...createUserGroupMethods(mongoose), ...aclEntryMethods, + ...systemGrantMethods, ...createShareMethods(mongoose), ...createPluginAuthMethods(mongoose), /* Tier 1 */ @@ -208,6 +213,7 @@ export type { MCPServerMethods, UserGroupMethods, AclEntryMethods, + SystemGrantMethods, ShareMethods, AccessRoleMethods, PluginAuthMethods, diff --git a/packages/data-schemas/src/methods/prompt.spec.ts b/packages/data-schemas/src/methods/prompt.spec.ts index 0a8c2c247e..6a02b8bc3b 100644 --- a/packages/data-schemas/src/methods/prompt.spec.ts +++ b/packages/data-schemas/src/methods/prompt.spec.ts @@ -582,8 +582,6 @@ describe('Prompt ACL Permissions', () => { await methods.deletePrompt({ promptId: testPromptId, groupId: testPromptGroup._id, - author: testUsers.owner._id, - role: SystemRoles.USER, }); // Verify ACL entries are removed diff --git a/packages/data-schemas/src/methods/prompt.ts b/packages/data-schemas/src/methods/prompt.ts index b5f757de92..67b0c4e273 100644 --- a/packages/data-schemas/src/methods/prompt.ts +++ b/packages/data-schemas/src/methods/prompt.ts @@ -1,5 +1,5 @@ import type { Model, Types } from 'mongoose'; -import { SystemRoles, ResourceType, SystemCategories } from 'librechat-data-provider'; +import { ResourceType, SystemCategories } from 'librechat-data-provider'; import type { IPrompt, IPromptGroup, IPromptGroupDocument } from '~/types'; import { escapeRegExp } from '~/utils/string'; import logger from '~/config/winston'; @@ -144,27 +144,18 @@ export function createPromptMethods(mongoose: typeof import('mongoose'), deps: P /** * Delete a prompt group and its prompts, cleaning up ACL permissions. + * + * **Authorization is enforced upstream.** This method performs no ownership + * check — it deletes any group by ID. Callers must gate access via + * `canAccessPromptGroupResource` middleware before invoking this. */ - async function deletePromptGroup({ - _id, - author, - role, - }: { - _id: string; - author?: string; - role?: string; - }) { + async function deletePromptGroup({ _id }: { _id: string }) { const PromptGroup = mongoose.models.PromptGroup as Model; const Prompt = mongoose.models.Prompt as Model; const query: Record = { _id }; const groupQuery: Record = { groupId: new ObjectId(_id) }; - if (author && role !== SystemRoles.ADMIN) { - query.author = author; - groupQuery.author = author; - } - const response = await PromptGroup.deleteOne(query); if (!response || response.deletedCount === 0) { @@ -472,25 +463,22 @@ export function createPromptMethods(mongoose: typeof import('mongoose'), deps: P /** * Delete a prompt, potentially removing the group if it's the last prompt. + * + * **Authorization is enforced upstream.** This method performs no ownership + * check — it deletes any prompt by ID. Callers must gate access via + * `canAccessPromptViaGroup` middleware before invoking this. */ async function deletePrompt({ promptId, groupId, - author, - role, }: { promptId: string | Types.ObjectId; groupId: string | Types.ObjectId; - author: string | Types.ObjectId; - role?: string; }) { const Prompt = mongoose.models.Prompt as Model; const PromptGroup = mongoose.models.PromptGroup as Model; - const query: Record = { _id: promptId, groupId, author }; - if (role === SystemRoles.ADMIN) { - delete query.author; - } + const query: Record = { _id: promptId, groupId }; const { deletedCount } = await Prompt.deleteOne(query); if (deletedCount === 0) { throw new Error('Failed to delete the prompt'); diff --git a/packages/data-schemas/src/methods/systemGrant.spec.ts b/packages/data-schemas/src/methods/systemGrant.spec.ts new file mode 100644 index 0000000000..fb886c74d3 --- /dev/null +++ b/packages/data-schemas/src/methods/systemGrant.spec.ts @@ -0,0 +1,840 @@ +import mongoose, { Types } from 'mongoose'; +import { PrincipalType, SystemRoles } from 'librechat-data-provider'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import type * as t from '~/types'; +import type { SystemCapability } from '~/systemCapabilities'; +import { SystemCapabilities, CapabilityImplications } from '~/systemCapabilities'; +import { createSystemGrantMethods } from './systemGrant'; +import systemGrantSchema from '~/schema/systemGrant'; +import logger from '~/config/winston'; + +jest.mock('~/config/winston', () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +})); + +let mongoServer: MongoMemoryServer; +let SystemGrant: mongoose.Model; +let methods: ReturnType; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + SystemGrant = + mongoose.models.SystemGrant || mongoose.model('SystemGrant', systemGrantSchema); + methods = createSystemGrantMethods(mongoose); + await mongoose.connect(mongoServer.getUri()); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await SystemGrant.deleteMany({}); +}); + +describe('systemGrant methods', () => { + describe('seedSystemGrants', () => { + it('seeds every SystemCapabilities value for the ADMIN role', async () => { + await methods.seedSystemGrants(); + + const grants = await SystemGrant.find({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.ADMIN, + }).lean(); + + const expected = Object.values(SystemCapabilities).sort(); + const actual = grants.map((g) => g.capability).sort(); + expect(actual).toEqual(expected); + }); + + it('is idempotent — duplicate calls produce no extra documents', async () => { + await methods.seedSystemGrants(); + await methods.seedSystemGrants(); + await methods.seedSystemGrants(); + + const count = await SystemGrant.countDocuments({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.ADMIN, + }); + expect(count).toBe(Object.values(SystemCapabilities).length); + }); + + it('seeds platform-level grants (no tenantId field)', async () => { + await methods.seedSystemGrants(); + + const withTenant = await SystemGrant.countDocuments({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.ADMIN, + tenantId: { $exists: true }, + }); + expect(withTenant).toBe(0); + }); + + it('does not throw when called (try-catch protects startup)', async () => { + await expect(methods.seedSystemGrants()).resolves.not.toThrow(); + }); + + it('retries on transient failure and succeeds', async () => { + jest.useFakeTimers(); + jest.spyOn(SystemGrant, 'bulkWrite').mockRejectedValueOnce(new Error('disk full')); + + const seedPromise = methods.seedSystemGrants(); + await jest.advanceTimersByTimeAsync(5000); + await seedPromise; + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Attempt 1/3 failed')); + jest.useRealTimers(); + }); + + it('logs error after all retries exhausted', async () => { + jest.useFakeTimers(); + jest + .spyOn(SystemGrant, 'bulkWrite') + .mockRejectedValueOnce(new Error('disk full')) + .mockRejectedValueOnce(new Error('disk full')) + .mockRejectedValueOnce(new Error('disk full')); + + const seedPromise = methods.seedSystemGrants(); + await jest.advanceTimersByTimeAsync(10000); + await seedPromise; + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to seed capabilities after all retries'), + expect.any(Error), + ); + jest.useRealTimers(); + }); + }); + + describe('grantCapability', () => { + it('creates a grant and returns the document', async () => { + const userId = new Types.ObjectId(); + const doc = await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_USERS, + }); + + expect(doc).toBeTruthy(); + expect(doc!.principalType).toBe(PrincipalType.USER); + expect(doc!.capability).toBe(SystemCapabilities.READ_USERS); + expect(doc!.grantedAt).toBeInstanceOf(Date); + }); + + it('is idempotent — second call does not create a duplicate', async () => { + const userId = new Types.ObjectId(); + const params = { + principalType: PrincipalType.USER as const, + principalId: userId, + capability: SystemCapabilities.READ_USERS as SystemCapability, + }; + + await methods.grantCapability(params); + await methods.grantCapability(params); + + const count = await SystemGrant.countDocuments({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_USERS, + }); + expect(count).toBe(1); + }); + + it('stores grantedBy when provided', async () => { + const userId = new Types.ObjectId(); + const grantedBy = new Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_CONFIGS, + grantedBy, + }); + + const grant = await SystemGrant.findOne({ + principalType: PrincipalType.USER, + principalId: userId, + }).lean(); + + expect(grant!.grantedBy!.toString()).toBe(grantedBy.toString()); + }); + + it('stores tenant-scoped grants with tenantId field present', async () => { + const userId = new Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_USAGE, + tenantId: 'tenant-abc', + }); + + const grant = await SystemGrant.findOne({ + principalType: PrincipalType.USER, + principalId: userId, + tenantId: 'tenant-abc', + }).lean(); + + expect(grant).toBeTruthy(); + expect(grant!.tenantId).toBe('tenant-abc'); + }); + + it('normalizes string userId to ObjectId for USER principal', async () => { + const userId = new Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId.toString(), + capability: SystemCapabilities.READ_USERS, + }); + + const grant = await SystemGrant.findOne({ capability: SystemCapabilities.READ_USERS }).lean(); + expect(grant!.principalId.toString()).toBe(userId.toString()); + expect(grant!.principalId).toBeInstanceOf(Types.ObjectId); + }); + + it('normalizes string groupId to ObjectId for GROUP principal', async () => { + const groupId = new Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.GROUP, + principalId: groupId.toString(), + capability: SystemCapabilities.READ_AGENTS, + }); + + const grant = await SystemGrant.findOne({ + capability: SystemCapabilities.READ_AGENTS, + }).lean(); + expect(grant!.principalId).toBeInstanceOf(Types.ObjectId); + }); + + it('keeps ROLE principalId as a string (no ObjectId cast)', async () => { + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'CUSTOM_ROLE', + capability: SystemCapabilities.READ_CONFIGS, + }); + + const grant = await SystemGrant.findOne({ + principalType: PrincipalType.ROLE, + principalId: 'CUSTOM_ROLE', + }).lean(); + + expect(grant).toBeTruthy(); + expect(typeof grant!.principalId).toBe('string'); + }); + + it('allows same capability for same principal in different tenants', async () => { + const userId = new Types.ObjectId(); + const params = { + principalType: PrincipalType.USER as const, + principalId: userId, + capability: SystemCapabilities.ACCESS_ADMIN as SystemCapability, + }; + + await methods.grantCapability({ ...params, tenantId: 'tenant-1' }); + await methods.grantCapability({ ...params, tenantId: 'tenant-2' }); + + const count = await SystemGrant.countDocuments({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.ACCESS_ADMIN, + }); + expect(count).toBe(2); + }); + + it('handles E11000 race condition — returns existing doc instead of throwing', async () => { + const userId = new Types.ObjectId(); + const params = { + principalType: PrincipalType.USER as const, + principalId: userId, + capability: SystemCapabilities.READ_USERS as SystemCapability, + }; + + const original = await methods.grantCapability(params); + + // Simulate a race: findOneAndUpdate upserts but hits a duplicate key + const model = mongoose.models.SystemGrant; + jest + .spyOn(model, 'findOneAndUpdate') + .mockRejectedValueOnce( + Object.assign(new Error('E11000 duplicate key error'), { code: 11000 }), + ); + + const result = await methods.grantCapability(params); + expect(result).toBeTruthy(); + expect(result!.capability).toBe(SystemCapabilities.READ_USERS); + expect(result!.principalId.toString()).toBe(original!.principalId.toString()); + }); + + it('re-throws non-E11000 errors from findOneAndUpdate', async () => { + const model = mongoose.models.SystemGrant; + jest.spyOn(model, 'findOneAndUpdate').mockRejectedValueOnce(new Error('connection timeout')); + + await expect( + methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: new Types.ObjectId(), + capability: SystemCapabilities.READ_USERS, + }), + ).rejects.toThrow('connection timeout'); + }); + + it('throws TypeError for invalid ObjectId string on USER principal', async () => { + await expect( + methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: 'not-a-valid-objectid', + capability: SystemCapabilities.READ_USERS, + }), + ).rejects.toThrow(TypeError); + }); + + it('throws TypeError for invalid ObjectId string on GROUP principal', async () => { + await expect( + methods.grantCapability({ + principalType: PrincipalType.GROUP, + principalId: 'also-invalid', + capability: SystemCapabilities.READ_AGENTS, + }), + ).rejects.toThrow(TypeError); + }); + + it('accepts any string for ROLE principal without ObjectId validation', async () => { + const doc = await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'ANY_STRING_HERE', + capability: SystemCapabilities.READ_CONFIGS, + }); + expect(doc).toBeTruthy(); + expect(doc!.principalId).toBe('ANY_STRING_HERE'); + }); + }); + + describe('revokeCapability', () => { + it('removes the grant document', async () => { + const userId = new Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_USERS, + }); + + await methods.revokeCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_USERS, + }); + + const grant = await SystemGrant.findOne({ + principalType: PrincipalType.USER, + principalId: userId, + }).lean(); + expect(grant).toBeNull(); + }); + + it('is a no-op when the grant does not exist', async () => { + await expect( + methods.revokeCapability({ + principalType: PrincipalType.USER, + principalId: new Types.ObjectId(), + capability: SystemCapabilities.MANAGE_USERS, + }), + ).resolves.not.toThrow(); + }); + + it('normalizes string userId when revoking', async () => { + const userId = new Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_USAGE, + }); + + await methods.revokeCapability({ + principalType: PrincipalType.USER, + principalId: userId.toString(), + capability: SystemCapabilities.READ_USAGE, + }); + + const count = await SystemGrant.countDocuments({ + principalType: PrincipalType.USER, + principalId: userId, + }); + expect(count).toBe(0); + }); + + it('only revokes the specified tenant grant', async () => { + const userId = new Types.ObjectId(); + const params = { + principalType: PrincipalType.USER as const, + principalId: userId, + capability: SystemCapabilities.READ_CONFIGS as SystemCapability, + }; + + await methods.grantCapability({ ...params, tenantId: 'tenant-1' }); + await methods.grantCapability({ ...params, tenantId: 'tenant-2' }); + + await methods.revokeCapability({ ...params, tenantId: 'tenant-1' }); + + const remaining = await SystemGrant.find({ + principalType: PrincipalType.USER, + principalId: userId, + }).lean(); + + expect(remaining).toHaveLength(1); + expect(remaining[0].tenantId).toBe('tenant-2'); + }); + + it('throws TypeError for invalid ObjectId string on USER principal', async () => { + await expect( + methods.revokeCapability({ + principalType: PrincipalType.USER, + principalId: 'bad-id', + capability: SystemCapabilities.READ_USERS, + }), + ).rejects.toThrow(TypeError); + }); + }); + + describe('hasCapabilityForPrincipals', () => { + it('returns true when a role principal holds the capability', async () => { + await methods.seedSystemGrants(); + + const result = await methods.hasCapabilityForPrincipals({ + principals: [ + { principalType: PrincipalType.USER, principalId: new Types.ObjectId() }, + { principalType: PrincipalType.ROLE, principalId: SystemRoles.ADMIN }, + { principalType: PrincipalType.PUBLIC }, + ], + capability: SystemCapabilities.ACCESS_ADMIN, + }); + expect(result).toBe(true); + }); + + it('returns false when no principal has the capability', async () => { + const result = await methods.hasCapabilityForPrincipals({ + principals: [ + { principalType: PrincipalType.USER, principalId: new Types.ObjectId() }, + { principalType: PrincipalType.ROLE, principalId: SystemRoles.USER }, + { principalType: PrincipalType.PUBLIC }, + ], + capability: SystemCapabilities.ACCESS_ADMIN, + }); + expect(result).toBe(false); + }); + + it('returns false for an empty principals array', async () => { + const result = await methods.hasCapabilityForPrincipals({ + principals: [], + capability: SystemCapabilities.ACCESS_ADMIN, + }); + expect(result).toBe(false); + }); + + it('returns false when only PUBLIC principals are present', async () => { + const result = await methods.hasCapabilityForPrincipals({ + principals: [{ principalType: PrincipalType.PUBLIC }], + capability: SystemCapabilities.ACCESS_ADMIN, + }); + expect(result).toBe(false); + }); + + it('matches user-level grants', async () => { + const userId = new Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_CONFIGS, + }); + + const result = await methods.hasCapabilityForPrincipals({ + principals: [ + { principalType: PrincipalType.USER, principalId: userId }, + { principalType: PrincipalType.ROLE, principalId: SystemRoles.USER }, + ], + capability: SystemCapabilities.READ_CONFIGS, + }); + expect(result).toBe(true); + }); + + it('matches group-level grants', async () => { + const groupId = new Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.GROUP, + principalId: groupId, + capability: SystemCapabilities.READ_USAGE, + }); + + const result = await methods.hasCapabilityForPrincipals({ + principals: [ + { principalType: PrincipalType.USER, principalId: new Types.ObjectId() }, + { principalType: PrincipalType.GROUP, principalId: groupId }, + ], + capability: SystemCapabilities.READ_USAGE, + }); + expect(result).toBe(true); + }); + + it('finds grant when string userId was used to create it and ObjectId to query', async () => { + const userId = new Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId.toString(), + capability: SystemCapabilities.READ_USAGE, + }); + + const result = await methods.hasCapabilityForPrincipals({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], + capability: SystemCapabilities.READ_USAGE, + }); + expect(result).toBe(true); + }); + + describe('capability implications', () => { + it.each( + ( + Object.entries(CapabilityImplications) as [SystemCapability, SystemCapability[]][] + ).flatMap(([broad, implied]) => implied.map((imp) => [broad, imp] as const)), + )('%s implies %s', async (broadCap, impliedCap) => { + const userId = new Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: broadCap, + }); + + const result = await methods.hasCapabilityForPrincipals({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], + capability: impliedCap, + }); + expect(result).toBe(true); + }); + + it.each( + ( + Object.entries(CapabilityImplications) as [SystemCapability, SystemCapability[]][] + ).flatMap(([broad, implied]) => implied.map((imp) => [imp, broad] as const)), + )('%s does NOT imply %s (reverse)', async (narrowCap, broadCap) => { + const userId = new Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: narrowCap, + }); + + const result = await methods.hasCapabilityForPrincipals({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], + capability: broadCap, + }); + expect(result).toBe(false); + }); + }); + + describe('tenant scoping', () => { + it('tenant-scoped grant does not match platform-level query', async () => { + const userId = new Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_CONFIGS, + tenantId: 'tenant-1', + }); + + const result = await methods.hasCapabilityForPrincipals({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], + capability: SystemCapabilities.READ_CONFIGS, + }); + expect(result).toBe(false); + }); + + it('platform-level grant does not match tenant-scoped query', async () => { + const userId = new Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_CONFIGS, + }); + + const result = await methods.hasCapabilityForPrincipals({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], + capability: SystemCapabilities.READ_CONFIGS, + tenantId: 'tenant-1', + }); + expect(result).toBe(false); + }); + + it('tenant-scoped grant matches same-tenant query', async () => { + const userId = new Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_CONFIGS, + tenantId: 'tenant-1', + }); + + const result = await methods.hasCapabilityForPrincipals({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], + capability: SystemCapabilities.READ_CONFIGS, + tenantId: 'tenant-1', + }); + expect(result).toBe(true); + }); + + it('tenant-scoped grant does not match different tenant', async () => { + const userId = new Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_CONFIGS, + tenantId: 'tenant-1', + }); + + const result = await methods.hasCapabilityForPrincipals({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], + capability: SystemCapabilities.READ_CONFIGS, + tenantId: 'tenant-2', + }); + expect(result).toBe(false); + }); + }); + }); + + describe('getCapabilitiesForPrincipal', () => { + it('lists all capabilities for the ADMIN role after seeding', async () => { + await methods.seedSystemGrants(); + + const grants = await methods.getCapabilitiesForPrincipal({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.ADMIN, + }); + + expect(grants).toHaveLength(Object.values(SystemCapabilities).length); + const caps = grants.map((g) => g.capability).sort(); + expect(caps).toEqual(Object.values(SystemCapabilities).sort()); + }); + + it('returns empty array when principal has no grants', async () => { + const grants = await methods.getCapabilitiesForPrincipal({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.USER, + }); + expect(grants).toHaveLength(0); + }); + + it('normalizes string userId for lookup', async () => { + const userId = new Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_USAGE, + }); + + const grants = await methods.getCapabilitiesForPrincipal({ + principalType: PrincipalType.USER, + principalId: userId.toString(), + }); + expect(grants).toHaveLength(1); + expect(grants[0].capability).toBe(SystemCapabilities.READ_USAGE); + }); + + it('only returns grants for the specified tenant', async () => { + const userId = new Types.ObjectId(); + + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_CONFIGS, + tenantId: 'tenant-1', + }); + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_USAGE, + tenantId: 'tenant-2', + }); + + const grants = await methods.getCapabilitiesForPrincipal({ + principalType: PrincipalType.USER, + principalId: userId, + tenantId: 'tenant-1', + }); + expect(grants).toHaveLength(1); + expect(grants[0].capability).toBe(SystemCapabilities.READ_CONFIGS); + }); + + it('throws TypeError for invalid ObjectId string on USER principal', async () => { + await expect( + methods.getCapabilitiesForPrincipal({ + principalType: PrincipalType.USER, + principalId: 'not-valid', + }), + ).rejects.toThrow(TypeError); + }); + }); + + describe('schema validation', () => { + it('rejects null tenantId at the schema level', async () => { + await expect( + SystemGrant.create({ + principalType: PrincipalType.USER, + principalId: new Types.ObjectId(), + capability: SystemCapabilities.READ_USERS, + tenantId: null, + }), + ).rejects.toThrow(/tenantId/); + }); + + it('rejects empty string tenantId at the schema level', async () => { + await expect( + SystemGrant.create({ + principalType: PrincipalType.USER, + principalId: new Types.ObjectId(), + capability: SystemCapabilities.READ_USERS, + tenantId: '', + }), + ).rejects.toThrow(/tenantId/); + }); + + it('rejects invalid principalType values', async () => { + await expect( + SystemGrant.create({ + principalType: 'INVALID_TYPE', + principalId: new Types.ObjectId(), + capability: SystemCapabilities.READ_USERS, + }), + ).rejects.toThrow(/principalType/); + }); + + it('requires principalType field', async () => { + await expect( + SystemGrant.create({ + principalId: new Types.ObjectId(), + capability: SystemCapabilities.READ_USERS, + }), + ).rejects.toThrow(/principalType/); + }); + + it('requires principalId field', async () => { + await expect( + SystemGrant.create({ + principalType: PrincipalType.USER, + capability: SystemCapabilities.READ_USERS, + }), + ).rejects.toThrow(/principalId/); + }); + + it('requires capability field', async () => { + await expect( + SystemGrant.create({ + principalType: PrincipalType.USER, + principalId: new Types.ObjectId(), + }), + ).rejects.toThrow(/capability/); + }); + + it('rejects invalid capability strings', async () => { + await expect( + SystemGrant.create({ + principalType: PrincipalType.USER, + principalId: new Types.ObjectId(), + capability: 'god:mode', + }), + ).rejects.toThrow(/Invalid capability string/); + }); + + it('accepts valid section-level config capabilities', async () => { + const doc = await SystemGrant.create({ + principalType: PrincipalType.USER, + principalId: new Types.ObjectId(), + capability: 'manage:configs:endpoints', + }); + expect(doc.capability).toBe('manage:configs:endpoints'); + }); + + it('accepts valid assign config capabilities', async () => { + const doc = await SystemGrant.create({ + principalType: PrincipalType.USER, + principalId: new Types.ObjectId(), + capability: 'assign:configs:group', + }); + expect(doc.capability).toBe('assign:configs:group'); + }); + + it('enforces unique compound index (principalType + principalId + capability + tenantId)', async () => { + const doc = { + principalType: PrincipalType.USER, + principalId: new Types.ObjectId(), + capability: SystemCapabilities.READ_USERS, + }; + + await SystemGrant.create(doc); + + await expect(SystemGrant.create(doc)).rejects.toThrow(/duplicate key|E11000/); + }); + + it('rejects duplicate platform-level grants (absent tenantId) — non-sparse index', async () => { + const principalId = new Types.ObjectId(); + + await SystemGrant.create({ + principalType: PrincipalType.USER, + principalId, + capability: SystemCapabilities.ACCESS_ADMIN, + }); + + await expect( + SystemGrant.create({ + principalType: PrincipalType.USER, + principalId, + capability: SystemCapabilities.ACCESS_ADMIN, + }), + ).rejects.toThrow(/duplicate key|E11000/); + }); + + it('allows same grant for different tenants (tenantId is part of unique key)', async () => { + const principalId = new Types.ObjectId(); + const base = { + principalType: PrincipalType.USER, + principalId, + capability: SystemCapabilities.ACCESS_ADMIN, + }; + + await SystemGrant.create({ ...base, tenantId: 'tenant-a' }); + await SystemGrant.create({ ...base, tenantId: 'tenant-b' }); + + const count = await SystemGrant.countDocuments({ principalId }); + expect(count).toBe(2); + }); + + it('platform-level and tenant-scoped grants coexist (different unique key values)', async () => { + const principalId = new Types.ObjectId(); + const base = { + principalType: PrincipalType.USER, + principalId, + capability: SystemCapabilities.ACCESS_ADMIN, + }; + + await SystemGrant.create(base); + await SystemGrant.create({ ...base, tenantId: 'tenant-1' }); + + const count = await SystemGrant.countDocuments({ principalId }); + expect(count).toBe(2); + }); + }); +}); diff --git a/packages/data-schemas/src/methods/systemGrant.ts b/packages/data-schemas/src/methods/systemGrant.ts new file mode 100644 index 0000000000..f45d9fde9d --- /dev/null +++ b/packages/data-schemas/src/methods/systemGrant.ts @@ -0,0 +1,266 @@ +import { PrincipalType, SystemRoles } from 'librechat-data-provider'; +import type { Types, Model, ClientSession } from 'mongoose'; +import type { SystemCapability } from '~/systemCapabilities'; +import type { ISystemGrant } from '~/types'; +import { SystemCapabilities, CapabilityImplications } from '~/systemCapabilities'; +import { normalizePrincipalId } from '~/utils/principal'; +import logger from '~/config/winston'; + +/** + * Precomputed reverse map: for each capability, which broader capabilities imply it. + * Built once at module load so `hasCapabilityForPrincipals` avoids O(N×M) per call. + */ +type BaseSystemCapability = (typeof SystemCapabilities)[keyof typeof SystemCapabilities]; +const reverseImplications: Partial> = {}; +for (const [broad, implied] of Object.entries(CapabilityImplications)) { + for (const cap of implied as BaseSystemCapability[]) { + (reverseImplications[cap] ??= []).push(broad as BaseSystemCapability); + } +} + +export function createSystemGrantMethods(mongoose: typeof import('mongoose')) { + /** + * Check if any of the given principals holds a specific capability. + * Follows the same principal-resolution pattern as AclEntry: + * getUserPrincipals → $or query. + * + * @param principals - Resolved principal list from getUserPrincipals + * @param capability - The capability to check + * @param tenantId - If present, checks tenant-scoped grant; if absent, checks platform-level + */ + async function hasCapabilityForPrincipals({ + principals, + capability, + tenantId, + }: { + principals: Array<{ principalType: PrincipalType; principalId?: string | Types.ObjectId }>; + capability: SystemCapability; + tenantId?: string; + }): Promise { + const SystemGrant = mongoose.models.SystemGrant as Model; + const principalsQuery = principals + .filter((p) => p.principalType !== PrincipalType.PUBLIC) + .map((p) => ({ principalType: p.principalType, principalId: p.principalId })); + + if (!principalsQuery.length) { + return false; + } + + const impliedBy = reverseImplications[capability as keyof typeof reverseImplications] ?? []; + const capabilityQuery = impliedBy.length ? { $in: [capability, ...impliedBy] } : capability; + + const query: Record = { + $or: principalsQuery, + capability: capabilityQuery, + }; + + /* + * TODO(#12091): In multi-tenant mode, platform-level grants (tenantId absent) + * should also satisfy tenant-scoped checks so that seeded ADMIN grants remain + * effective. When tenantId is set, query both tenant-scoped AND platform-level: + * query.$or = [{ tenantId }, { tenantId: { $exists: false } }] + * Also: getUserPrincipals currently has no tenantId param, so group memberships + * are returned across all tenants. Filter by tenant there too. + */ + if (tenantId != null) { + query.tenantId = tenantId; + } else { + query.tenantId = { $exists: false }; + } + + const doc = await SystemGrant.exists(query); + return doc != null; + } + + /** + * Grant a capability to a principal. Upsert — idempotent. + */ + async function grantCapability( + { + principalType, + principalId, + capability, + tenantId, + grantedBy, + }: { + principalType: PrincipalType; + principalId: string | Types.ObjectId; + capability: SystemCapability; + tenantId?: string; + grantedBy?: string | Types.ObjectId; + }, + session?: ClientSession, + ): Promise { + const SystemGrant = mongoose.models.SystemGrant as Model; + + const normalizedPrincipalId = normalizePrincipalId(principalId, principalType); + + const filter: Record = { + principalType, + principalId: normalizedPrincipalId, + capability, + }; + + if (tenantId != null) { + filter.tenantId = tenantId; + } else { + filter.tenantId = { $exists: false }; + } + + const update = { + $set: { + grantedAt: new Date(), + ...(grantedBy != null && { grantedBy }), + }, + $setOnInsert: { + principalType, + principalId: normalizedPrincipalId, + capability, + ...(tenantId != null && { tenantId }), + }, + }; + + const options = { + upsert: true, + new: true, + ...(session ? { session } : {}), + }; + + try { + return await SystemGrant.findOneAndUpdate(filter, update, options); + } catch (err) { + if ((err as { code?: number }).code === 11000) { + return (await SystemGrant.findOne(filter).lean()) as ISystemGrant | null; + } + throw err; + } + } + + /** + * Revoke a capability from a principal. + */ + async function revokeCapability( + { + principalType, + principalId, + capability, + tenantId, + }: { + principalType: PrincipalType; + principalId: string | Types.ObjectId; + capability: SystemCapability; + tenantId?: string; + }, + session?: ClientSession, + ): Promise { + const SystemGrant = mongoose.models.SystemGrant as Model; + + const normalizedPrincipalId = normalizePrincipalId(principalId, principalType); + + const filter: Record = { + principalType, + principalId: normalizedPrincipalId, + capability, + }; + + if (tenantId != null) { + filter.tenantId = tenantId; + } else { + filter.tenantId = { $exists: false }; + } + + const options = session ? { session } : {}; + await SystemGrant.deleteOne(filter, options); + } + + /** + * List all capabilities held by a principal — used by the capabilities + * introspection endpoint. + */ + async function getCapabilitiesForPrincipal({ + principalType, + principalId, + tenantId, + }: { + principalType: PrincipalType; + principalId: string | Types.ObjectId; + tenantId?: string; + }): Promise { + const SystemGrant = mongoose.models.SystemGrant as Model; + + const filter: Record = { + principalType, + principalId: normalizePrincipalId(principalId, principalType), + }; + + if (tenantId != null) { + filter.tenantId = tenantId; + } else { + filter.tenantId = { $exists: false }; + } + + return await SystemGrant.find(filter).lean(); + } + + /** + * Seed the ADMIN role with all system capabilities (no tenantId — single-instance mode). + * Idempotent and concurrency-safe: uses bulkWrite with ordered:false so parallel + * server instances (K8s rolling deploy, PM2 cluster) do not race on E11000. + * Retries up to 3 times with exponential backoff on transient failures. + */ + async function seedSystemGrants(): Promise { + const maxRetries = 3; + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const SystemGrant = mongoose.models.SystemGrant as Model; + const now = new Date(); + const ops = Object.values(SystemCapabilities).map((capability) => ({ + updateOne: { + filter: { + principalType: PrincipalType.ROLE, + principalId: SystemRoles.ADMIN, + capability, + tenantId: { $exists: false }, + }, + update: { + $setOnInsert: { + principalType: PrincipalType.ROLE, + principalId: SystemRoles.ADMIN, + capability, + grantedAt: now, + }, + }, + upsert: true, + }, + })); + await SystemGrant.bulkWrite(ops, { ordered: false }); + return; + } catch (err) { + if (attempt < maxRetries) { + const delay = 1000 * Math.pow(2, attempt - 1); + logger.warn( + `[seedSystemGrants] Attempt ${attempt}/${maxRetries} failed, retrying in ${delay}ms: ${(err as Error).message ?? String(err)}`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } else { + logger.error( + '[seedSystemGrants] Failed to seed capabilities after all retries. ' + + 'Admin panel access requires these grants. Manual recovery: ' + + 'db.systemgrants.insertMany([...]) with ADMIN role grants for each capability.', + err, + ); + } + } + } + } + + return { + grantCapability, + seedSystemGrants, + revokeCapability, + hasCapabilityForPrincipals, + getCapabilitiesForPrincipal, + }; +} + +export type SystemGrantMethods = ReturnType; diff --git a/packages/data-schemas/src/methods/userGroup.spec.ts b/packages/data-schemas/src/methods/userGroup.spec.ts index 9de8eaf912..675fdb2592 100644 --- a/packages/data-schemas/src/methods/userGroup.spec.ts +++ b/packages/data-schemas/src/methods/userGroup.spec.ts @@ -1,12 +1,12 @@ -import mongoose from 'mongoose'; -import { PrincipalType } from 'librechat-data-provider'; +import mongoose, { Types } from 'mongoose'; +import { PrincipalType, SystemRoles } from 'librechat-data-provider'; import { MongoMemoryServer } from 'mongodb-memory-server'; import type * as t from '~/types'; import { createUserGroupMethods } from './userGroup'; import groupSchema from '~/schema/group'; import userSchema from '~/schema/user'; +import roleSchema from '~/schema/role'; -/** Mocking logger */ jest.mock('~/config/winston', () => ({ error: jest.fn(), info: jest.fn(), @@ -16,15 +16,16 @@ jest.mock('~/config/winston', () => ({ let mongoServer: MongoMemoryServer; let Group: mongoose.Model; let User: mongoose.Model; +let Role: mongoose.Model; let methods: ReturnType; beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); - const mongoUri = mongoServer.getUri(); Group = mongoose.models.Group || mongoose.model('Group', groupSchema); User = mongoose.models.User || mongoose.model('User', userSchema); + Role = mongoose.models.Role || mongoose.model('Role', roleSchema); methods = createUserGroupMethods(mongoose); - await mongoose.connect(mongoUri); + await mongoose.connect(mongoServer.getUri()); }); afterAll(async () => { @@ -33,530 +34,775 @@ afterAll(async () => { }); beforeEach(async () => { - await mongoose.connection.dropDatabase(); + await Group.deleteMany({}); + await User.deleteMany({}); + await Role.deleteMany({}); }); -describe('User Group Methods Tests', () => { - describe('Group Query Methods', () => { - let testGroup: t.IGroup; - let testUser: t.IUser; +async function createTestUser(overrides: Partial = {}) { + return User.create({ + name: 'Test User', + email: `user-${new Types.ObjectId()}@test.com`, + password: 'password123', + provider: 'local', + role: SystemRoles.USER, + ...overrides, + }); +} - beforeEach(async () => { - /** Create a test user */ - testUser = await User.create({ - name: 'Test User', - email: 'test@example.com', - password: 'password123', - provider: 'local', - }); +describe('userGroup methods', () => { + describe('findGroupById', () => { + it('returns the group when it exists', async () => { + const group = await Group.create({ name: 'Engineering', source: 'local' }); + const found = await methods.findGroupById(group._id); + expect(found).toBeTruthy(); + expect(found!.name).toBe('Engineering'); + }); - /** Create a test group */ - testGroup = await Group.create({ - name: 'Test Group', + it('returns null when group does not exist', async () => { + const found = await methods.findGroupById(new Types.ObjectId()); + expect(found).toBeNull(); + }); + + it('respects projection parameter', async () => { + const group = await Group.create({ + name: 'Engineering', + description: 'The eng team', source: 'local', - memberIds: [(testUser._id as mongoose.Types.ObjectId).toString()], }); - - /** No need to add group to user - using one-way relationship via Group.memberIds */ + const found = await methods.findGroupById(group._id, { name: 1 }); + expect(found!.name).toBe('Engineering'); + expect(found!.description).toBeUndefined(); }); + }); - test('should find group by ID', async () => { - const group = await methods.findGroupById(testGroup._id as mongoose.Types.ObjectId); - - expect(group).toBeDefined(); - expect(group?._id.toString()).toBe(testGroup._id.toString()); - expect(group?.name).toBe(testGroup.name); - }); - - test('should find group by ID with specific projection', async () => { - const group = await methods.findGroupById(testGroup._id as mongoose.Types.ObjectId, { - name: 1, - }); - - expect(group).toBeDefined(); - expect(group?._id).toBeDefined(); - expect(group?.name).toBe(testGroup.name); - expect(group?.memberIds).toBeUndefined(); - }); - - test('should find group by external ID', async () => { - /** Create an external ID group first */ - const entraGroup = await Group.create({ + describe('findGroupByExternalId', () => { + it('finds a group by its external Entra ID', async () => { + await Group.create({ name: 'Entra Group', source: 'entra', - idOnTheSource: 'entra-id-12345', + idOnTheSource: 'entra-abc-123', }); - - const group = await methods.findGroupByExternalId('entra-id-12345', 'entra'); - - expect(group).toBeDefined(); - expect(group?._id.toString()).toBe(entraGroup._id.toString()); - expect(group?.idOnTheSource).toBe('entra-id-12345'); + const found = await methods.findGroupByExternalId('entra-abc-123', 'entra'); + expect(found).toBeTruthy(); + expect(found!.name).toBe('Entra Group'); }); - test('should return null for non-existent external ID', async () => { - const group = await methods.findGroupByExternalId('non-existent-id', 'entra'); - expect(group).toBeNull(); + it('returns null when no match', async () => { + const found = await methods.findGroupByExternalId('nonexistent', 'entra'); + expect(found).toBeNull(); + }); + }); + + describe('findGroupsByNamePattern', () => { + beforeEach(async () => { + await Group.create([ + { name: 'Engineering', source: 'local', description: 'Eng team' }, + { name: 'Design', source: 'local', email: 'design@co.com' }, + { name: 'Entra Eng', source: 'entra', idOnTheSource: 'ext-1' }, + ]); }); - test('should find groups by name pattern', async () => { - /** Create additional groups */ - await Group.create({ name: 'Test Group 2', source: 'local' }); - await Group.create({ name: 'Admin Group', source: 'local' }); - await Group.create({ - name: 'Test Entra Group', - source: 'entra', - idOnTheSource: 'entra-id-xyz', - }); - - /** Search for all "Test" groups */ - const testGroups = await methods.findGroupsByNamePattern('Test'); - expect(testGroups).toHaveLength(3); - - /** Search with source filter */ - const localTestGroups = await methods.findGroupsByNamePattern('Test', 'local'); - expect(localTestGroups).toHaveLength(2); - - const entraTestGroups = await methods.findGroupsByNamePattern('Test', 'entra'); - expect(entraTestGroups).toHaveLength(1); + it('finds groups by name pattern (case-insensitive)', async () => { + const results = await methods.findGroupsByNamePattern('eng'); + expect(results.length).toBeGreaterThanOrEqual(2); }); - test('should respect limit parameter in name search', async () => { - /** Create many groups with similar names */ - for (let i = 0; i < 10; i++) { + it('matches on email field', async () => { + const results = await methods.findGroupsByNamePattern('design@'); + expect(results).toHaveLength(1); + expect(results[0].name).toBe('Design'); + }); + + it('matches on description field', async () => { + const results = await methods.findGroupsByNamePattern('Eng team'); + expect(results).toHaveLength(1); + expect(results[0].name).toBe('Engineering'); + }); + + it('filters by source when provided', async () => { + const results = await methods.findGroupsByNamePattern('eng', 'entra'); + expect(results).toHaveLength(1); + expect(results[0].source).toBe('entra'); + }); + + it('respects limit parameter', async () => { + for (let i = 0; i < 5; i++) { await Group.create({ name: `Numbered Group ${i}`, source: 'local' }); } - - const limitedGroups = await methods.findGroupsByNamePattern('Numbered', null, 5); - expect(limitedGroups).toHaveLength(5); - }); - - test('should find groups by member ID', async () => { - /** Create additional groups with the test user as member */ - const group2 = await Group.create({ - name: 'Second Group', - source: 'local', - memberIds: [(testUser._id as mongoose.Types.ObjectId).toString()], - }); - - const group3 = await Group.create({ - name: 'Third Group', - source: 'local', - memberIds: [new mongoose.Types.ObjectId().toString()] /** Different user */, - }); - - const userGroups = await methods.findGroupsByMemberId( - testUser._id as mongoose.Types.ObjectId, - ); - expect(userGroups).toHaveLength(2); - - /** IDs should match the groups where user is a member */ - const groupIds = userGroups.map((g) => g._id.toString()); - expect(groupIds).toContain(testGroup._id.toString()); - expect(groupIds).toContain(group2._id.toString()); - expect(groupIds).not.toContain(group3._id.toString()); + const results = await methods.findGroupsByNamePattern('Numbered', null, 2); + expect(results).toHaveLength(2); }); }); - describe('Group Creation and Update Methods', () => { - test('should create a new group', async () => { - const groupData = { - name: 'New Test Group', - source: 'local' as const, - }; + describe('findGroupsByMemberId', () => { + it('returns groups the user is a member of via idOnTheSource', async () => { + const user = await createTestUser({ idOnTheSource: 'user-ext-1' }); + await Group.create([ + { name: 'Group A', source: 'local', memberIds: ['user-ext-1'] }, + { name: 'Group B', source: 'local', memberIds: ['user-ext-1'] }, + { name: 'Group C', source: 'local', memberIds: ['other-user'] }, + ]); - const group = await methods.createGroup(groupData); - - expect(group).toBeDefined(); - expect(group.name).toBe(groupData.name); - expect(group.source).toBe(groupData.source); - - /** Verify it was saved to the database */ - const savedGroup = await Group.findById(group._id); - expect(savedGroup).toBeDefined(); + const groups = await methods.findGroupsByMemberId(user._id); + expect(groups).toHaveLength(2); + const names = groups.map((g) => g.name).sort(); + expect(names).toEqual(['Group A', 'Group B']); }); - test('should upsert a group by external ID (create new)', async () => { - const groupData = { - name: 'New Entra Group', - idOnTheSource: 'new-entra-id', - }; - - const group = await methods.upsertGroupByExternalId(groupData.idOnTheSource, 'entra', { - name: groupData.name, - }); - - expect(group).toBeDefined(); - expect(group?.name).toBe(groupData.name); - expect(group?.idOnTheSource).toBe(groupData.idOnTheSource); - expect(group?.source).toBe('entra'); - - /** Verify it was saved to the database */ - const savedGroup = await Group.findOne({ idOnTheSource: 'new-entra-id' }); - expect(savedGroup).toBeDefined(); + it('returns empty array when user does not exist', async () => { + const groups = await methods.findGroupsByMemberId(new Types.ObjectId()); + expect(groups).toEqual([]); }); - test('should upsert a group by external ID (update existing)', async () => { - /** Create an existing group */ + it('falls back to userId string when user has no idOnTheSource', async () => { + const user = await createTestUser(); await Group.create({ - name: 'Original Name', - source: 'entra', - idOnTheSource: 'existing-entra-id', + name: 'Group X', + source: 'local', + memberIds: [user._id.toString()], }); - /** Update it */ - const updatedGroup = await methods.upsertGroupByExternalId('existing-entra-id', 'entra', { + const groups = await methods.findGroupsByMemberId(user._id); + expect(groups).toHaveLength(1); + }); + }); + + describe('createGroup', () => { + it('creates a group and returns the document', async () => { + const group = await methods.createGroup({ name: 'New Group', source: 'local' }); + expect(group).toBeTruthy(); + expect(group.name).toBe('New Group'); + expect(group._id).toBeDefined(); + }); + }); + + describe('upsertGroupByExternalId', () => { + it('creates a new group when none exists', async () => { + const group = await methods.upsertGroupByExternalId('ext-new', 'entra', { + name: 'New Entra Group', + }); + expect(group).toBeTruthy(); + expect(group!.name).toBe('New Entra Group'); + expect(group!.idOnTheSource).toBe('ext-new'); + }); + + it('updates existing group when found', async () => { + await Group.create({ name: 'Old Name', source: 'entra', idOnTheSource: 'ext-1' }); + const group = await methods.upsertGroupByExternalId('ext-1', 'entra', { name: 'Updated Name', }); - - expect(updatedGroup).toBeDefined(); - expect(updatedGroup?.name).toBe('Updated Name'); - expect(updatedGroup?.idOnTheSource).toBe('existing-entra-id'); - - /** Verify the update in the database */ - const savedGroup = await Group.findOne({ idOnTheSource: 'existing-entra-id' }); - expect(savedGroup?.name).toBe('Updated Name'); + expect(group!.name).toBe('Updated Name'); + const count = await Group.countDocuments({ idOnTheSource: 'ext-1' }); + expect(count).toBe(1); }); }); - describe('User-Group Relationship Methods', () => { - let testUser1: t.IUser; - let testGroup: t.IGroup; + describe('addUserToGroup', () => { + it('adds user to group using idOnTheSource', async () => { + const user = await createTestUser({ idOnTheSource: 'user-ext-1' }); + const group = await Group.create({ name: 'Team', source: 'local' }); - beforeEach(async () => { - /** Create test users */ - testUser1 = await User.create({ - name: 'User One', - email: 'user1@example.com', - password: 'password123', - provider: 'local', - }); + const { group: updatedGroup } = await methods.addUserToGroup(user._id, group._id); + expect(updatedGroup!.memberIds).toContain('user-ext-1'); + }); - /** Create a test group */ - testGroup = await Group.create({ - name: 'Test Group', + it('falls back to userId string when user has no idOnTheSource', async () => { + const user = await createTestUser(); + const group = await Group.create({ name: 'Team', source: 'local' }); + + const { group: updatedGroup } = await methods.addUserToGroup(user._id, group._id); + expect(updatedGroup!.memberIds).toContain(user._id.toString()); + }); + + it('is idempotent — $addToSet prevents duplicates', async () => { + const user = await createTestUser({ idOnTheSource: 'user-ext-1' }); + const group = await Group.create({ name: 'Team', source: 'local' }); + + await methods.addUserToGroup(user._id, group._id); + const { group: updatedGroup } = await methods.addUserToGroup(user._id, group._id); + expect(updatedGroup!.memberIds!.filter((id) => id === 'user-ext-1')).toHaveLength(1); + }); + + it('throws when user does not exist', async () => { + const group = await Group.create({ name: 'Team', source: 'local' }); + await expect(methods.addUserToGroup(new Types.ObjectId(), group._id)).rejects.toThrow( + /User not found/, + ); + }); + }); + + describe('removeUserFromGroup', () => { + it('removes user from group', async () => { + const user = await createTestUser({ idOnTheSource: 'user-ext-1' }); + const group = await Group.create({ + name: 'Team', source: 'local', - memberIds: [] /** Initialize empty array */, - }); - }); - - test('should add user to group', async () => { - const result = await methods.addUserToGroup( - testUser1._id as mongoose.Types.ObjectId, - testGroup._id as mongoose.Types.ObjectId, - ); - - /** Verify the result */ - expect(result).toBeDefined(); - expect(result.user).toBeDefined(); - expect(result.group).toBeDefined(); - - /** Group should have the user in memberIds (using idOnTheSource or user ID) */ - const userIdOnTheSource = - result.user.idOnTheSource || (testUser1._id as mongoose.Types.ObjectId).toString(); - expect(result.group?.memberIds).toContain(userIdOnTheSource); - - /** Verify in database */ - const updatedGroup = await Group.findById(testGroup._id); - expect(updatedGroup?.memberIds).toContain(userIdOnTheSource); - }); - - test('should remove user from group', async () => { - /** First add the user to the group */ - await methods.addUserToGroup( - testUser1._id as mongoose.Types.ObjectId, - testGroup._id as mongoose.Types.ObjectId, - ); - - /** Then remove them */ - const result = await methods.removeUserFromGroup( - testUser1._id as mongoose.Types.ObjectId, - testGroup._id as mongoose.Types.ObjectId, - ); - - /** Verify the result */ - expect(result).toBeDefined(); - expect(result.user).toBeDefined(); - expect(result.group).toBeDefined(); - - /** Group should not have the user in memberIds */ - const userIdOnTheSource = - result.user.idOnTheSource || (testUser1._id as mongoose.Types.ObjectId).toString(); - expect(result.group?.memberIds).not.toContain(userIdOnTheSource); - - /** Verify in database */ - const updatedGroup = await Group.findById(testGroup._id); - expect(updatedGroup?.memberIds).not.toContain(userIdOnTheSource); - }); - - test('should get all groups for a user', async () => { - /** Add user to multiple groups */ - const group1 = await Group.create({ name: 'Group 1', source: 'local', memberIds: [] }); - const group2 = await Group.create({ name: 'Group 2', source: 'local', memberIds: [] }); - - await methods.addUserToGroup( - testUser1._id as mongoose.Types.ObjectId, - group1._id as mongoose.Types.ObjectId, - ); - await methods.addUserToGroup( - testUser1._id as mongoose.Types.ObjectId, - group2._id as mongoose.Types.ObjectId, - ); - - /** Get the user's groups */ - const userGroups = await methods.getUserGroups(testUser1._id as mongoose.Types.ObjectId); - - expect(userGroups).toHaveLength(2); - const groupIds = userGroups.map((g) => g._id.toString()); - expect(groupIds).toContain(group1._id.toString()); - expect(groupIds).toContain(group2._id.toString()); - }); - - test('should return empty array for getUserGroups when user has no groups', async () => { - const userGroups = await methods.getUserGroups(testUser1._id as mongoose.Types.ObjectId); - expect(userGroups).toEqual([]); - }); - - test('should get user principals', async () => { - /** Add user to a group */ - await methods.addUserToGroup( - testUser1._id as mongoose.Types.ObjectId, - testGroup._id as mongoose.Types.ObjectId, - ); - - /** Get user principals */ - const principals = await methods.getUserPrincipals({ - userId: testUser1._id as mongoose.Types.ObjectId, + memberIds: ['user-ext-1'], }); - /** Should include user, role (default USER), group, and public principals */ - expect(principals).toHaveLength(4); - - /** Check principal types */ - const userPrincipal = principals.find((p) => p.principalType === PrincipalType.USER); - const groupPrincipal = principals.find((p) => p.principalType === PrincipalType.GROUP); - const publicPrincipal = principals.find((p) => p.principalType === PrincipalType.PUBLIC); - - expect(userPrincipal).toBeDefined(); - expect(userPrincipal?.principalId?.toString()).toBe( - (testUser1._id as mongoose.Types.ObjectId).toString(), - ); - - expect(groupPrincipal).toBeDefined(); - expect(groupPrincipal?.principalId?.toString()).toBe(testGroup._id.toString()); - - expect(publicPrincipal).toBeDefined(); - expect(publicPrincipal?.principalId).toBeUndefined(); + const { group: updatedGroup } = await methods.removeUserFromGroup(user._id, group._id); + expect(updatedGroup!.memberIds).not.toContain('user-ext-1'); }); - test('should return user and public principals for non-existent user in getUserPrincipals', async () => { - const nonExistentId = new mongoose.Types.ObjectId(); - const principals = await methods.getUserPrincipals({ - userId: nonExistentId, - }); - - /** Should still return user and public principals even for non-existent user */ - expect(principals).toHaveLength(2); - expect(principals[0].principalType).toBe(PrincipalType.USER); - expect(principals[0].principalId?.toString()).toBe(nonExistentId.toString()); - expect(principals[1].principalType).toBe(PrincipalType.PUBLIC); - expect(principals[1].principalId).toBeUndefined(); + it('throws when user does not exist', async () => { + const group = await Group.create({ name: 'Team', source: 'local' }); + await expect(methods.removeUserFromGroup(new Types.ObjectId(), group._id)).rejects.toThrow( + /User not found/, + ); }); - test('should convert string userId to ObjectId in getUserPrincipals', async () => { - /** Add user to a group */ - await methods.addUserToGroup( - testUser1._id as mongoose.Types.ObjectId, - testGroup._id as mongoose.Types.ObjectId, - ); - - /** Get user principals with string userId */ - const principals = await methods.getUserPrincipals({ - userId: (testUser1._id as mongoose.Types.ObjectId).toString(), + it('is safe when user is not a member', async () => { + const user = await createTestUser({ idOnTheSource: 'user-ext-1' }); + const group = await Group.create({ + name: 'Team', + source: 'local', + memberIds: ['other-user'], }); - /** Should include user, role (default USER), group, and public principals */ - expect(principals).toHaveLength(4); - - /** Check that USER principal has ObjectId */ - const userPrincipal = principals.find((p) => p.principalType === PrincipalType.USER); - expect(userPrincipal).toBeDefined(); - expect(userPrincipal?.principalId).toBeInstanceOf(mongoose.Types.ObjectId); - expect(userPrincipal?.principalId?.toString()).toBe( - (testUser1._id as mongoose.Types.ObjectId).toString(), - ); - - /** Check that GROUP principal has ObjectId */ - const groupPrincipal = principals.find((p) => p.principalType === PrincipalType.GROUP); - expect(groupPrincipal).toBeDefined(); - expect(groupPrincipal?.principalId).toBeInstanceOf(mongoose.Types.ObjectId); - expect(groupPrincipal?.principalId?.toString()).toBe(testGroup._id.toString()); - }); - - test('should include role principal as string in getUserPrincipals', async () => { - /** Create user with specific role */ - const userWithRole = await User.create({ - name: 'Admin User', - email: 'admin@example.com', - password: 'password123', - provider: 'local', - role: 'ADMIN', - }); - - /** Get user principals */ - const principals = await methods.getUserPrincipals({ - userId: userWithRole._id as mongoose.Types.ObjectId, - }); - - /** Should include user, role, and public principals */ - expect(principals).toHaveLength(3); - - /** Check that ROLE principal has string ID */ - const rolePrincipal = principals.find((p) => p.principalType === PrincipalType.ROLE); - expect(rolePrincipal).toBeDefined(); - expect(typeof rolePrincipal?.principalId).toBe('string'); - expect(rolePrincipal?.principalId).toBe('ADMIN'); + const { group: updatedGroup } = await methods.removeUserFromGroup(user._id, group._id); + expect(updatedGroup!.memberIds).toEqual(['other-user']); }); }); - describe('Entra ID Synchronization', () => { - let testUser: t.IUser; + describe('removeUserFromAllGroups', () => { + it('removes user from every group they belong to', async () => { + const userId = new Types.ObjectId(); + await Group.create([ + { name: 'Group A', source: 'local', memberIds: [userId.toString(), 'other'] }, + { name: 'Group B', source: 'local', memberIds: [userId.toString()] }, + { name: 'Group C', source: 'local', memberIds: ['other'] }, + ]); - beforeEach(async () => { - testUser = await User.create({ - name: 'Entra User', - email: 'entra@example.com', - password: 'password123', - provider: 'entra', - idOnTheSource: 'entra-user-123', - }); + await methods.removeUserFromAllGroups(userId.toString()); + + const groups = await Group.find({ memberIds: userId.toString() }); + expect(groups).toHaveLength(0); + + const groupC = await Group.findOne({ name: 'Group C' }); + expect(groupC!.memberIds).toContain('other'); }); - /** Skip the failing tests until they can be fixed properly */ - test.skip('should sync Entra groups for a user (add new groups)', async () => { - /** Mock Entra groups */ - const entraGroups = [ - { id: 'entra-group-1', name: 'Entra Group 1' }, - { id: 'entra-group-2', name: 'Entra Group 2' }, - ]; + it('is a no-op when user is not in any groups', async () => { + await Group.create({ name: 'Group A', source: 'local', memberIds: ['other'] }); + await expect( + methods.removeUserFromAllGroups(new Types.ObjectId().toString()), + ).resolves.not.toThrow(); + }); + }); - const result = await methods.syncUserEntraGroups( - testUser._id as mongoose.Types.ObjectId, - entraGroups, - ); + describe('getUserGroups', () => { + it('delegates to findGroupsByMemberId', async () => { + const user = await createTestUser({ idOnTheSource: 'user-ext-1' }); + await Group.create({ name: 'Team', source: 'local', memberIds: ['user-ext-1'] }); - /** Check result */ - expect(result).toBeDefined(); - expect(result.user).toBeDefined(); - expect(result.addedGroups).toHaveLength(2); - expect(result.removedGroups).toHaveLength(0); + const groups = await methods.getUserGroups(user._id); + expect(groups).toHaveLength(1); + expect(groups[0].name).toBe('Team'); + }); + }); + + describe('getUserPrincipals', () => { + it('returns USER, ROLE, and PUBLIC principals', async () => { + const user = await createTestUser({ role: SystemRoles.ADMIN }); + const principals = await methods.getUserPrincipals({ + userId: user._id.toString(), + role: SystemRoles.ADMIN, + }); + + const types = principals.map((p) => p.principalType); + expect(types).toContain(PrincipalType.USER); + expect(types).toContain(PrincipalType.ROLE); + expect(types).toContain(PrincipalType.PUBLIC); + }); + + it('includes group principals when user is a member', async () => { + const user = await createTestUser({ idOnTheSource: 'user-ext-1' }); + const group = await Group.create({ + name: 'Team', + source: 'local', + memberIds: ['user-ext-1'], + }); + + const principals = await methods.getUserPrincipals({ + userId: user._id.toString(), + role: SystemRoles.USER, + }); + + const groupPrincipal = principals.find((p) => p.principalType === PrincipalType.GROUP); + expect(groupPrincipal).toBeTruthy(); + expect(groupPrincipal!.principalId!.toString()).toBe(group._id.toString()); + }); + + it('queries user role from DB when role param is undefined', async () => { + const user = await createTestUser({ role: SystemRoles.ADMIN }); + const principals = await methods.getUserPrincipals({ userId: user._id.toString() }); + + const rolePrincipal = principals.find((p) => p.principalType === PrincipalType.ROLE); + expect(rolePrincipal).toBeTruthy(); + expect(rolePrincipal!.principalId).toBe(SystemRoles.ADMIN); + }); + + it('omits role principal when role is empty/whitespace', async () => { + const user = await createTestUser({ role: ' ' }); + const principals = await methods.getUserPrincipals({ + userId: user._id.toString(), + role: ' ', + }); + + const rolePrincipal = principals.find((p) => p.principalType === PrincipalType.ROLE); + expect(rolePrincipal).toBeUndefined(); + }); + + it('converts string userId to ObjectId for USER principal', async () => { + const user = await createTestUser(); + const principals = await methods.getUserPrincipals({ + userId: user._id.toString(), + role: SystemRoles.USER, + }); + + const userPrincipal = principals.find((p) => p.principalType === PrincipalType.USER); + expect(userPrincipal!.principalId).toBeInstanceOf(Types.ObjectId); + }); + + it('includes null role when role param is null', async () => { + const user = await createTestUser({ role: SystemRoles.USER }); + const principals = await methods.getUserPrincipals({ + userId: user._id.toString(), + role: null, + }); + + const rolePrincipal = principals.find((p) => p.principalType === PrincipalType.ROLE); + expect(rolePrincipal).toBeUndefined(); + }); + }); + + describe('syncUserEntraGroups', () => { + it('creates new groups and adds user as member', async () => { + const user = await createTestUser({ idOnTheSource: 'user-ext-1' }); + + const { addedGroups, removedGroups } = await methods.syncUserEntraGroups(user._id, [ + { id: 'entra-g1', name: 'Entra Group 1' }, + { id: 'entra-g2', name: 'Entra Group 2', description: 'desc', email: 'g2@co.com' }, + ]); + + expect(addedGroups).toHaveLength(2); + expect(removedGroups).toHaveLength(0); - /** Verify groups were created */ const groups = await Group.find({ source: 'entra' }); expect(groups).toHaveLength(2); - - /** Verify user is a member of both groups - skipping this assertion for now */ - const user = await User.findById(testUser._id); - expect(user).toBeDefined(); - - /** Verify each group has the user as a member */ - for (const group of groups) { - expect(group.memberIds).toContain( - testUser.idOnTheSource || (testUser._id as mongoose.Types.ObjectId).toString(), - ); - } + expect(groups.every((g) => g.memberIds!.includes('user-ext-1'))).toBe(true); }); - test.skip('should sync Entra groups for a user (add and remove groups)', async () => { - /** Create existing Entra groups for the user */ + it('adds user to existing group they are not a member of', async () => { + const user = await createTestUser({ idOnTheSource: 'user-ext-1' }); await Group.create({ - name: 'Existing Group 1', + name: 'Existing Entra Group', source: 'entra', - idOnTheSource: 'existing-1', - memberIds: [testUser.idOnTheSource], + idOnTheSource: 'entra-g1', + memberIds: ['other-user'], }); - const existingGroup2 = await Group.create({ - name: 'Existing Group 2', + const { addedGroups } = await methods.syncUserEntraGroups(user._id, [ + { id: 'entra-g1', name: 'Existing Entra Group' }, + ]); + + expect(addedGroups).toHaveLength(1); + const group = await Group.findOne({ idOnTheSource: 'entra-g1' }); + expect(group!.memberIds).toContain('user-ext-1'); + expect(group!.memberIds).toContain('other-user'); + }); + + it('skips groups the user is already a member of', async () => { + const user = await createTestUser({ idOnTheSource: 'user-ext-1' }); + await Group.create({ + name: 'Already Member', source: 'entra', - idOnTheSource: 'existing-2', - memberIds: [testUser.idOnTheSource], + idOnTheSource: 'entra-g1', + memberIds: ['user-ext-1'], }); - /** Groups already have user in memberIds from creation above */ + const { addedGroups } = await methods.syncUserEntraGroups(user._id, [ + { id: 'entra-g1', name: 'Already Member' }, + ]); - /** New Entra groups (one existing, one new) */ - const entraGroups = [ - { id: 'existing-1', name: 'Existing Group 1' } /** Keep this one */, - { id: 'new-group', name: 'New Group' } /** Add this one */, - /** existing-2 is missing, should be removed */ - ]; - - const result = await methods.syncUserEntraGroups( - testUser._id as mongoose.Types.ObjectId, - entraGroups, - ); - - /** Check result */ - expect(result).toBeDefined(); - expect(result.addedGroups).toHaveLength(1); /** Skipping exact array length expectations */ - expect(result.removedGroups).toHaveLength(1); - - /** Verify existing-2 no longer has user as member */ - const removedGroup = await Group.findById(existingGroup2._id); - expect(removedGroup?.memberIds).toHaveLength(0); - - /** Verify new group was created and has user as member */ - const newGroup = await Group.findOne({ idOnTheSource: 'new-group' }); - expect(newGroup).toBeDefined(); - expect(newGroup?.memberIds).toContain( - testUser.idOnTheSource || (testUser._id as mongoose.Types.ObjectId).toString(), - ); + expect(addedGroups).toHaveLength(0); }); - test('should throw error for non-existent user in syncUserEntraGroups', async () => { - const nonExistentId = new mongoose.Types.ObjectId(); - const entraGroups = [{ id: 'some-id', name: 'Some Group' }]; + it('removes user from stale entra groups', async () => { + const user = await createTestUser({ idOnTheSource: 'user-ext-1' }); + await Group.create({ + name: 'Stale Group', + source: 'entra', + idOnTheSource: 'entra-stale', + memberIds: ['user-ext-1'], + }); - await expect(methods.syncUserEntraGroups(nonExistentId, entraGroups)).rejects.toThrow( - 'User not found', - ); + const { removedGroups } = await methods.syncUserEntraGroups(user._id, []); + + expect(removedGroups).toHaveLength(1); + expect(removedGroups[0].name).toBe('Stale Group'); + const group = await Group.findOne({ idOnTheSource: 'entra-stale' }); + expect(group!.memberIds).not.toContain('user-ext-1'); }); - test.skip('should preserve local groups when syncing Entra groups', async () => { - /** Create a local group for the user */ - const localGroup = await Group.create({ + it('handles add-and-remove in one sync call', async () => { + const user = await createTestUser({ idOnTheSource: 'user-ext-1' }); + + await Group.create({ + name: 'Keep Group', + source: 'entra', + idOnTheSource: 'entra-keep', + memberIds: ['user-ext-1'], + }); + await Group.create({ + name: 'Remove Group', + source: 'entra', + idOnTheSource: 'entra-remove', + memberIds: ['user-ext-1'], + }); + + const { addedGroups, removedGroups } = await methods.syncUserEntraGroups(user._id, [ + { id: 'entra-keep', name: 'Keep Group' }, + { id: 'entra-new', name: 'New Group' }, + ]); + + expect(addedGroups).toHaveLength(1); + expect(addedGroups[0].name).toBe('New Group'); + expect(removedGroups).toHaveLength(1); + expect(removedGroups[0].name).toBe('Remove Group'); + }); + + it('preserves local groups during entra sync', async () => { + const user = await createTestUser({ idOnTheSource: 'user-ext-1' }); + await Group.create({ name: 'Local Group', source: 'local', - memberIds: [testUser.idOnTheSource || (testUser._id as mongoose.Types.ObjectId).toString()], + memberIds: ['user-ext-1'], }); - /** Group already has user in memberIds from creation above */ + await methods.syncUserEntraGroups(user._id, []); - /** Sync with Entra groups */ - const entraGroups = [{ id: 'entra-group', name: 'Entra Group' }]; + const localGroup = await Group.findOne({ name: 'Local Group' }); + expect(localGroup!.memberIds).toContain('user-ext-1'); + }); - const result = await methods.syncUserEntraGroups( - testUser._id as mongoose.Types.ObjectId, - entraGroups, + it('throws when user does not exist', async () => { + await expect( + methods.syncUserEntraGroups(new Types.ObjectId(), [{ id: 'g1', name: 'Group' }]), + ).rejects.toThrow(/User not found/); + }); + + it('returns the updated user document', async () => { + const user = await createTestUser({ idOnTheSource: 'user-ext-1' }); + const { user: updatedUser } = await methods.syncUserEntraGroups(user._id, []); + expect(updatedUser._id.toString()).toBe(user._id.toString()); + }); + }); + + describe('calculateRelevanceScore', () => { + it('returns 100 for exact match', () => { + const score = methods.calculateRelevanceScore( + { type: PrincipalType.USER, name: 'alice', source: 'local' }, + 'alice', + ); + expect(score).toBe(100); + }); + + it('returns 80 for starts-with match', () => { + const score = methods.calculateRelevanceScore( + { type: PrincipalType.USER, name: 'alice-smith', source: 'local' }, + 'alice', + ); + expect(score).toBe(80); + }); + + it('returns 50 for contains match', () => { + const score = methods.calculateRelevanceScore( + { type: PrincipalType.USER, name: 'bob-alice-jones', source: 'local' }, + 'alice', + ); + expect(score).toBe(50); + }); + + it('returns 10 (default) when no substring or exact match — regex fallback', () => { + const score = methods.calculateRelevanceScore( + { type: PrincipalType.USER, name: 'bob', source: 'local' }, + 'zzz', + ); + expect(score).toBe(10); + }); + + it('checks email and username for USER type', () => { + const score = methods.calculateRelevanceScore( + { + type: PrincipalType.USER, + name: 'other', + email: 'alice@test.com', + username: 'alice', + source: 'local', + }, + 'alice', + ); + expect(score).toBe(100); + }); + + it('checks description for GROUP type', () => { + const score = methods.calculateRelevanceScore( + { + type: PrincipalType.GROUP, + name: 'other', + description: 'alice team', + source: 'local', + }, + 'alice', + ); + expect(score).toBe(80); + }); + + it('picks the highest score across multiple fields', () => { + const score = methods.calculateRelevanceScore( + { + type: PrincipalType.USER, + name: 'contains-alice-here', + email: 'alice@test.com', + source: 'local', + }, + 'alice', + ); + expect(score).toBe(80); + }); + + it('returns 100 when regex pattern matches exactly via dot wildcard', () => { + const score = methods.calculateRelevanceScore( + { type: PrincipalType.USER, name: 'xYz', source: 'local' }, + 'x.z', + ); + expect(score).toBe(100); + }); + }); + + describe('sortPrincipalsByRelevance', () => { + it('sorts by score descending', () => { + const items = [ + { type: PrincipalType.USER, name: 'low', _searchScore: 10 }, + { type: PrincipalType.USER, name: 'high', _searchScore: 100 }, + { type: PrincipalType.USER, name: 'mid', _searchScore: 50 }, + ]; + + const sorted = methods.sortPrincipalsByRelevance(items); + expect(sorted.map((i) => i._searchScore)).toEqual([100, 50, 10]); + }); + + it('prioritizes USER over GROUP at equal scores', () => { + const items = [ + { type: PrincipalType.GROUP, name: 'group', _searchScore: 80 }, + { type: PrincipalType.USER, name: 'user', _searchScore: 80 }, + ]; + + const sorted = methods.sortPrincipalsByRelevance(items); + expect(sorted[0].type).toBe(PrincipalType.USER); + }); + + it('sorts alphabetically by name at equal scores and types', () => { + const items = [ + { type: PrincipalType.USER, name: 'charlie', _searchScore: 80 }, + { type: PrincipalType.USER, name: 'alice', _searchScore: 80 }, + { type: PrincipalType.USER, name: 'bob', _searchScore: 80 }, + ]; + + const sorted = methods.sortPrincipalsByRelevance(items); + expect(sorted.map((i) => i.name)).toEqual(['alice', 'bob', 'charlie']); + }); + + it('handles missing _searchScore (falls back to 0)', () => { + const items = [ + { type: PrincipalType.USER, name: 'a' }, + { type: PrincipalType.USER, name: 'b', _searchScore: 50 }, + ]; + + const sorted = methods.sortPrincipalsByRelevance(items); + expect(sorted[0]._searchScore).toBe(50); + }); + + it('uses email as fallback name for sorting', () => { + const items = [ + { type: PrincipalType.USER, email: 'z@test.com', _searchScore: 80 }, + { type: PrincipalType.USER, email: 'a@test.com', _searchScore: 80 }, + ]; + + const sorted = methods.sortPrincipalsByRelevance(items); + expect(sorted[0].email).toBe('a@test.com'); + }); + }); + + describe('searchPrincipals', () => { + beforeEach(async () => { + await User.create([ + { + name: 'Alice Smith', + email: 'alice@test.com', + username: 'alice', + password: 'password123', + provider: 'local', + }, + { + name: 'Bob Jones', + email: 'bob@test.com', + username: 'bob', + password: 'password123', + provider: 'local', + }, + ]); + await Group.create([ + { name: 'Alpha Team', source: 'local' }, + { name: 'Beta Team', source: 'local' }, + ]); + await Role.create([{ name: 'admin' }, { name: 'moderator' }]); + }); + + it('returns empty array for empty search pattern', async () => { + const results = await methods.searchPrincipals(''); + expect(results).toEqual([]); + }); + + it('returns empty array for whitespace-only pattern', async () => { + const results = await methods.searchPrincipals(' '); + expect(results).toEqual([]); + }); + + it('finds matching users', async () => { + const results = await methods.searchPrincipals('alice'); + const userResults = results.filter((r) => r.type === PrincipalType.USER); + expect(userResults.length).toBeGreaterThanOrEqual(1); + expect(userResults[0].name).toBe('Alice Smith'); + }); + + it('finds matching groups', async () => { + const results = await methods.searchPrincipals('alpha'); + const groupResults = results.filter((r) => r.type === PrincipalType.GROUP); + expect(groupResults.length).toBeGreaterThanOrEqual(1); + expect(groupResults[0].name).toBe('Alpha Team'); + }); + + it('finds matching roles', async () => { + const results = await methods.searchPrincipals('admin'); + const roleResults = results.filter((r) => r.type === PrincipalType.ROLE); + expect(roleResults.length).toBeGreaterThanOrEqual(1); + expect(roleResults[0].name).toBe('admin'); + }); + + it('filters by USER type only', async () => { + const results = await methods.searchPrincipals('a', 10, [PrincipalType.USER]); + expect(results.every((r) => r.type === PrincipalType.USER)).toBe(true); + }); + + it('filters by GROUP type only', async () => { + const results = await methods.searchPrincipals('team', 10, [PrincipalType.GROUP]); + expect(results.every((r) => r.type === PrincipalType.GROUP)).toBe(true); + expect(results.length).toBeGreaterThanOrEqual(1); + }); + + it('filters by ROLE type only', async () => { + const results = await methods.searchPrincipals('mod', 10, [PrincipalType.ROLE]); + expect(results.every((r) => r.type === PrincipalType.ROLE)).toBe(true); + expect(results.length).toBeGreaterThanOrEqual(1); + }); + + it('respects limitPerType', async () => { + const results = await methods.searchPrincipals('a', 1); + const userResults = results.filter((r) => r.type === PrincipalType.USER); + expect(userResults.length).toBeLessThanOrEqual(1); + }); + + it('returns combined results across types without filter', async () => { + const results = await methods.searchPrincipals('a'); + const types = new Set(results.map((r) => r.type)); + expect(types.size).toBeGreaterThanOrEqual(2); + }); + + it('finds users by username', async () => { + const results = await methods.searchPrincipals('alice', 10, [PrincipalType.USER]); + expect(results.length).toBeGreaterThanOrEqual(1); + }); + + it('transforms user results to TPrincipalSearchResult format', async () => { + const results = await methods.searchPrincipals('alice', 10, [PrincipalType.USER]); + expect(results[0]).toEqual( + expect.objectContaining({ + type: PrincipalType.USER, + name: 'Alice Smith', + source: 'local', + }), + ); + expect(results[0].id).toBeDefined(); + }); + + it('transforms group results to TPrincipalSearchResult format', async () => { + const results = await methods.searchPrincipals('alpha', 10, [PrincipalType.GROUP]); + expect(results[0]).toEqual( + expect.objectContaining({ + type: PrincipalType.GROUP, + name: 'Alpha Team', + source: 'local', + }), + ); + expect(results[0].id).toBeDefined(); + expect(results[0].memberCount).toBeDefined(); + }); + }); + + describe('findGroupByQuery', () => { + it('finds a group by custom filter', async () => { + await Group.create({ name: 'Target', source: 'local', email: 'target@co.com' }); + const found = await methods.findGroupByQuery({ email: 'target@co.com' }); + expect(found).toBeTruthy(); + expect(found!.name).toBe('Target'); + }); + + it('returns null when no match', async () => { + const found = await methods.findGroupByQuery({ name: 'Nonexistent' }); + expect(found).toBeNull(); + }); + }); + + describe('updateGroupById', () => { + it('updates the group and returns the new document', async () => { + const group = await Group.create({ name: 'Old Name', source: 'local' }); + const updated = await methods.updateGroupById(group._id, { name: 'New Name' }); + expect(updated!.name).toBe('New Name'); + }); + + it('returns null when group does not exist', async () => { + const updated = await methods.updateGroupById(new Types.ObjectId(), { name: 'X' }); + expect(updated).toBeNull(); + }); + }); + + describe('bulkUpdateGroups', () => { + it('updates all groups matching the filter', async () => { + await Group.create([ + { name: 'Group A', source: 'entra', idOnTheSource: 'ext-a' }, + { name: 'Group B', source: 'entra', idOnTheSource: 'ext-b' }, + { name: 'Group C', source: 'local' }, + ]); + + const result = await methods.bulkUpdateGroups( + { source: 'entra' }, + { $set: { description: 'synced' } }, ); - /** Check result */ - expect(result).toBeDefined(); + expect(result.modifiedCount).toBe(2); + const synced = await Group.find({ description: 'synced' }); + expect(synced).toHaveLength(2); + }); - /** Verify the local group entry still exists */ - const savedLocalGroup = await Group.findById(localGroup._id); - expect(savedLocalGroup).toBeDefined(); - expect(savedLocalGroup?.memberIds).toContain( - testUser.idOnTheSource || (testUser._id as mongoose.Types.ObjectId).toString(), - ); - - /** Verify the Entra group was created */ - const entraGroup = await Group.findOne({ idOnTheSource: 'entra-group' }); - expect(entraGroup).toBeDefined(); - expect(entraGroup?.memberIds).toContain( - testUser.idOnTheSource || (testUser._id as mongoose.Types.ObjectId).toString(), + it('returns zero when no groups match', async () => { + const result = await methods.bulkUpdateGroups( + { source: 'entra' }, + { $set: { description: 'x' } }, ); + expect(result.modifiedCount).toBe(0); }); }); }); diff --git a/packages/data-schemas/src/methods/userGroup.ts b/packages/data-schemas/src/methods/userGroup.ts index 5c683268b3..5e11c26135 100644 --- a/packages/data-schemas/src/methods/userGroup.ts +++ b/packages/data-schemas/src/methods/userGroup.ts @@ -244,6 +244,13 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { * @param session - Optional MongoDB session for transactions * @returns Array of principal objects with type and id */ + /** + * TODO(#12091): This method has no tenantId parameter — it returns ALL group + * memberships for a user regardless of tenant. In multi-tenant mode, group + * principals from other tenants will be included in capability checks, which + * could grant cross-tenant capabilities. Add tenantId filtering here when + * tenant isolation is activated. + */ async function getUserPrincipals( params: { userId: string | Types.ObjectId; diff --git a/packages/data-schemas/src/models/index.ts b/packages/data-schemas/src/models/index.ts index 068aba69ed..44d94c6ab4 100644 --- a/packages/data-schemas/src/models/index.ts +++ b/packages/data-schemas/src/models/index.ts @@ -25,6 +25,7 @@ import { createToolCallModel } from './toolCall'; import { createMemoryModel } from './memory'; import { createAccessRoleModel } from './accessRole'; import { createAclEntryModel } from './aclEntry'; +import { createSystemGrantModel } from './systemGrant'; import { createGroupModel } from './group'; /** @@ -59,6 +60,7 @@ export function createModels(mongoose: typeof import('mongoose')) { MemoryEntry: createMemoryModel(mongoose), AccessRole: createAccessRoleModel(mongoose), AclEntry: createAclEntryModel(mongoose), + SystemGrant: createSystemGrantModel(mongoose), Group: createGroupModel(mongoose), }; } diff --git a/packages/data-schemas/src/models/systemGrant.ts b/packages/data-schemas/src/models/systemGrant.ts new file mode 100644 index 0000000000..e439d2af81 --- /dev/null +++ b/packages/data-schemas/src/models/systemGrant.ts @@ -0,0 +1,11 @@ +import systemGrantSchema from '~/schema/systemGrant'; +import type * as t from '~/types'; + +/** + * Creates or returns the SystemGrant model using the provided mongoose instance and schema + */ +export function createSystemGrantModel(mongoose: typeof import('mongoose')) { + return ( + mongoose.models.SystemGrant || mongoose.model('SystemGrant', systemGrantSchema) + ); +} diff --git a/packages/data-schemas/src/schema/index.ts b/packages/data-schemas/src/schema/index.ts index 2a58f7c3cc..456eb03ac2 100644 --- a/packages/data-schemas/src/schema/index.ts +++ b/packages/data-schemas/src/schema/index.ts @@ -24,3 +24,4 @@ export { default as transactionSchema } from './transaction'; export { default as userSchema } from './user'; export { default as memorySchema } from './memory'; export { default as groupSchema } from './group'; +export { default as systemGrantSchema } from './systemGrant'; diff --git a/packages/data-schemas/src/schema/systemGrant.ts b/packages/data-schemas/src/schema/systemGrant.ts new file mode 100644 index 0000000000..0366f6080d --- /dev/null +++ b/packages/data-schemas/src/schema/systemGrant.ts @@ -0,0 +1,76 @@ +import { Schema } from 'mongoose'; +import { PrincipalType } from 'librechat-data-provider'; +import { SystemCapabilities } from '~/systemCapabilities'; +import type { SystemCapability } from '~/systemCapabilities'; +import type { ISystemGrant } from '~/types'; + +const baseCapabilities = new Set(Object.values(SystemCapabilities)); +const sectionCapPattern = /^(?:manage|read):configs:\w+$/; +const assignCapPattern = /^assign:configs:(?:user|group|role)$/; + +const systemGrantSchema = new Schema( + { + principalType: { + type: String, + enum: Object.values(PrincipalType), + required: true, + }, + principalId: { + type: Schema.Types.Mixed, + required: true, + }, + capability: { + type: String, + required: true, + validate: { + validator: (v: SystemCapability) => + baseCapabilities.has(v) || sectionCapPattern.test(v) || assignCapPattern.test(v), + message: 'Invalid capability string: "{VALUE}"', + }, + }, + /** + * Platform-level grants MUST omit this field entirely — never set it to null. + * Queries for platform-level grants use `{ tenantId: { $exists: false } }`, which + * matches absent fields but NOT `null`. A document stored with `{ tenantId: null }` + * would silently match neither platform-level nor tenant-scoped queries. + */ + tenantId: { + type: String, + required: false, + validate: { + validator: (v: unknown) => v !== null && v !== '', + message: 'tenantId must be a non-empty string or omitted entirely — never null or empty', + }, + }, + grantedBy: { + type: Schema.Types.ObjectId, + ref: 'User', + }, + grantedAt: { + type: Date, + default: Date.now, + }, + /** Reserved for future TTL enforcement — time-bounded / temporary grants. Not enforced yet. */ + expiresAt: { + type: Date, + required: false, + }, + }, + { timestamps: true }, +); + +/* + * principalId normalization (string → ObjectId for USER/GROUP) is handled + * explicitly by grantCapability — the only sanctioned write path. + * All writes MUST go through grantCapability; do not use Model.create() + * or save() directly, as there is no schema-level normalization hook. + */ + +systemGrantSchema.index( + { principalType: 1, principalId: 1, capability: 1, tenantId: 1 }, + { unique: true }, +); + +systemGrantSchema.index({ capability: 1, tenantId: 1 }); + +export default systemGrantSchema; diff --git a/packages/data-schemas/src/systemCapabilities.ts b/packages/data-schemas/src/systemCapabilities.ts new file mode 100644 index 0000000000..cf2acfbf88 --- /dev/null +++ b/packages/data-schemas/src/systemCapabilities.ts @@ -0,0 +1,106 @@ +import type { z } from 'zod'; +import type { configSchema } from 'librechat-data-provider'; +import { ResourceType } from 'librechat-data-provider'; + +export const SystemCapabilities = { + ACCESS_ADMIN: 'access:admin', + READ_USERS: 'read:users', + MANAGE_USERS: 'manage:users', + READ_GROUPS: 'read:groups', + MANAGE_GROUPS: 'manage:groups', + READ_ROLES: 'read:roles', + MANAGE_ROLES: 'manage:roles', + READ_CONFIGS: 'read:configs', + MANAGE_CONFIGS: 'manage:configs', + ASSIGN_CONFIGS: 'assign:configs', + READ_USAGE: 'read:usage', + READ_AGENTS: 'read:agents', + MANAGE_AGENTS: 'manage:agents', + MANAGE_MCP_SERVERS: 'manage:mcpservers', + READ_PROMPTS: 'read:prompts', + MANAGE_PROMPTS: 'manage:prompts', + /** Reserved — not yet enforced by any middleware. Grant has no effect until assistant listing is gated. */ + READ_ASSISTANTS: 'read:assistants', + MANAGE_ASSISTANTS: 'manage:assistants', +} as const; + +/** Top-level keys of the configSchema from librechat.yaml. */ +export type ConfigSection = keyof z.infer; + +/** Principal types that can receive config overrides. */ +export type ConfigAssignTarget = 'user' | 'group' | 'role'; + +/** Base capabilities defined in the SystemCapabilities object. */ +type BaseSystemCapability = (typeof SystemCapabilities)[keyof typeof SystemCapabilities]; + +/** Section-level config capabilities derived from configSchema keys. */ +type ConfigSectionCapability = `manage:configs:${ConfigSection}` | `read:configs:${ConfigSection}`; + +/** Principal-scoped config assignment capabilities. */ +type ConfigAssignCapability = `assign:configs:${ConfigAssignTarget}`; + +/** + * Union of all valid capability strings: + * - Base capabilities from SystemCapabilities + * - Section-level config capabilities (manage:configs:
, read:configs:
) + * - Config assignment capabilities (assign:configs:) + */ +export type SystemCapability = + | BaseSystemCapability + | ConfigSectionCapability + | ConfigAssignCapability; + +/** + * Capabilities that are implied by holding a broader capability. + * When `hasCapability` checks for an implied capability, it first expands + * the principal's grant set — so granting `MANAGE_USERS` automatically + * satisfies a `READ_USERS` check without a separate grant. + * + * Implication is one-directional: `MANAGE_USERS` implies `READ_USERS`, + * but `READ_USERS` does NOT imply `MANAGE_USERS`. + */ +export const CapabilityImplications: Partial> = + { + [SystemCapabilities.MANAGE_USERS]: [SystemCapabilities.READ_USERS], + [SystemCapabilities.MANAGE_GROUPS]: [SystemCapabilities.READ_GROUPS], + [SystemCapabilities.MANAGE_ROLES]: [SystemCapabilities.READ_ROLES], + [SystemCapabilities.MANAGE_CONFIGS]: [SystemCapabilities.READ_CONFIGS], + [SystemCapabilities.MANAGE_AGENTS]: [SystemCapabilities.READ_AGENTS], + [SystemCapabilities.MANAGE_PROMPTS]: [SystemCapabilities.READ_PROMPTS], + [SystemCapabilities.MANAGE_ASSISTANTS]: [SystemCapabilities.READ_ASSISTANTS], + }; + +/** + * Maps each ACL ResourceType to the SystemCapability that grants + * unrestricted management access. Typed as `Record` + * so adding a new ResourceType variant causes a compile error until a + * capability is assigned here. + */ +export const ResourceCapabilityMap: Record = { + [ResourceType.AGENT]: SystemCapabilities.MANAGE_AGENTS, + [ResourceType.PROMPTGROUP]: SystemCapabilities.MANAGE_PROMPTS, + [ResourceType.MCPSERVER]: SystemCapabilities.MANAGE_MCP_SERVERS, + [ResourceType.REMOTE_AGENT]: SystemCapabilities.MANAGE_AGENTS, +}; + +/** + * Derives a section-level config management capability from a configSchema key. + * @example configCapability('endpoints') → 'manage:configs:endpoints' + * + * TODO: Section-level config capabilities are scaffolded but not yet active. + * To activate delegated config management: + * 1. Expose POST/DELETE /api/admin/grants endpoints (wiring grantCapability/revokeCapability) + * 2. Seed section-specific grants for delegated admin roles via those endpoints + * 3. Guard config write handlers with hasConfigCapability(user, section) + */ +export function configCapability(section: ConfigSection): `manage:configs:${ConfigSection}` { + return `manage:configs:${section}`; +} + +/** + * Derives a section-level config read capability from a configSchema key. + * @example readConfigCapability('endpoints') → 'read:configs:endpoints' + */ +export function readConfigCapability(section: ConfigSection): `read:configs:${ConfigSection}` { + return `read:configs:${section}`; +} diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index d467d99d21..26238cbda1 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -26,6 +26,7 @@ export * from './prompts'; /* Access Control */ export * from './accessRole'; export * from './aclEntry'; +export * from './systemGrant'; export * from './group'; /* Web */ export * from './web'; diff --git a/packages/data-schemas/src/types/systemGrant.ts b/packages/data-schemas/src/types/systemGrant.ts new file mode 100644 index 0000000000..9f0d576503 --- /dev/null +++ b/packages/data-schemas/src/types/systemGrant.ts @@ -0,0 +1,25 @@ +import type { Document, Types } from 'mongoose'; +import type { PrincipalType } from 'librechat-data-provider'; +import type { SystemCapability } from '~/systemCapabilities'; + +export type SystemGrant = { + /** The type of principal — matches PrincipalType enum values */ + principalType: PrincipalType; + /** ObjectId string for user/group, role name string for role */ + principalId: string | Types.ObjectId; + /** The capability being granted */ + capability: SystemCapability; + /** Absent = platform-operator, present = tenant-scoped */ + tenantId?: string; + /** ID of the user who granted this capability */ + grantedBy?: Types.ObjectId; + /** When this capability was granted */ + grantedAt?: Date; + /** Reserved for future TTL enforcement — time-bounded / temporary grants. */ + expiresAt?: Date; +}; + +export type ISystemGrant = SystemGrant & + Document & { + _id: Types.ObjectId; + }; diff --git a/packages/data-schemas/src/utils/index.ts b/packages/data-schemas/src/utils/index.ts index 626233f1be..a185a096eb 100644 --- a/packages/data-schemas/src/utils/index.ts +++ b/packages/data-schemas/src/utils/index.ts @@ -1,3 +1,4 @@ +export * from './principal'; export * from './string'; export * from './tempChatRetention'; export * from './transactions'; diff --git a/packages/data-schemas/src/utils/principal.ts b/packages/data-schemas/src/utils/principal.ts new file mode 100644 index 0000000000..d8ecb28303 --- /dev/null +++ b/packages/data-schemas/src/utils/principal.ts @@ -0,0 +1,22 @@ +import { Types } from 'mongoose'; +import { PrincipalType } from 'librechat-data-provider'; + +/** + * Normalizes a principalId to the correct type for MongoDB queries and storage. + * USER and GROUP principals are stored as ObjectIds; ROLE principals are strings. + * Ensures a string caller ID is cast to ObjectId so it matches documents written + * by `grantCapability` — which always stores user/group IDs as ObjectIds to match + * what `getUserPrincipals` returns. + */ +export const normalizePrincipalId = ( + principalId: string | Types.ObjectId, + principalType: PrincipalType, +): string | Types.ObjectId => { + if (typeof principalId === 'string' && principalType !== PrincipalType.ROLE) { + if (!Types.ObjectId.isValid(principalId)) { + throw new TypeError(`Invalid ObjectId string for ${principalType}: "${principalId}"`); + } + return new Types.ObjectId(principalId); + } + return principalId; +};