diff --git a/api/server/index.js b/api/server/index.js index 4b919b1ceb..79776587b5 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -154,6 +154,7 @@ const startServer = async () => { app.use('/api/auth', preAuthTenantMiddleware, routes.auth); app.use('/api/admin', routes.adminAuth); app.use('/api/admin/config', routes.adminConfig); + app.use('/api/admin/grants', routes.adminGrants); app.use('/api/admin/groups', routes.adminGroups); app.use('/api/admin/roles', routes.adminRoles); app.use('/api/actions', routes.actions); diff --git a/api/server/routes/__tests__/grants.spec.js b/api/server/routes/__tests__/grants.spec.js new file mode 100644 index 0000000000..c7b5b6bdda --- /dev/null +++ b/api/server/routes/__tests__/grants.spec.js @@ -0,0 +1,185 @@ +const express = require('express'); +const request = require('supertest'); +const mongoose = require('mongoose'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { createModels, createMethods } = require('@librechat/data-schemas'); +const { PrincipalType, SystemRoles } = require('librechat-data-provider'); + +/** + * Integration test for the admin grants routes. + * + * Validates the full Express wiring: route registration → middleware → + * handler → real MongoDB. Auth middleware is injected (matching the repo + * pattern in keys.spec.js) so we can control the caller identity without + * a real JWT, while the handler DI deps use real DB methods. + */ + +jest.mock('~/server/middleware', () => ({ + requireJwtAuth: (_req, _res, next) => next(), +})); + +jest.mock('~/server/middleware/roles/capabilities', () => ({ + requireCapability: () => (_req, _res, next) => next(), +})); + +let mongoServer; +let db; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + createModels(mongoose); + db = createMethods(mongoose); + await db.seedSystemGrants(); + await db.initializeRoles(); + await db.seedDefaultRoles(); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +afterEach(async () => { + const SystemGrant = mongoose.models.SystemGrant; + // Clean non-seed grants (keep admin seed) + await SystemGrant.deleteMany({ + $or: [ + { principalId: { $ne: SystemRoles.ADMIN } }, + { principalType: { $ne: PrincipalType.ROLE } }, + ], + }); +}); + +function createApp(user) { + const { createAdminGrantsHandlers, getCachedPrincipals } = require('@librechat/api'); + + const handlers = createAdminGrantsHandlers({ + listGrants: db.listGrants, + countGrants: db.countGrants, + getCapabilitiesForPrincipal: db.getCapabilitiesForPrincipal, + getCapabilitiesForPrincipals: db.getCapabilitiesForPrincipals, + grantCapability: db.grantCapability, + revokeCapability: db.revokeCapability, + getUserPrincipals: db.getUserPrincipals, + hasCapabilityForPrincipals: db.hasCapabilityForPrincipals, + getHeldCapabilities: db.getHeldCapabilities, + getCachedPrincipals, + checkRoleExists: async (name) => (await db.getRoleByName(name)) != null, + }); + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = user; + next(); + }); + + const router = express.Router(); + router.get('/', handlers.listGrants); + router.get('/effective', handlers.getEffectiveCapabilities); + router.get('/:principalType/:principalId', handlers.getPrincipalGrants); + router.post('/', handlers.assignGrant); + router.delete('/:principalType/:principalId/:capability', handlers.revokeGrant); + app.use('/api/admin/grants', router); + + return app; +} + +describe('Admin Grants Routes — Integration', () => { + const adminUserId = new mongoose.Types.ObjectId(); + const adminUser = { + _id: adminUserId, + id: adminUserId.toString(), + role: SystemRoles.ADMIN, + }; + + it('GET / returns seeded admin grants', async () => { + const app = createApp(adminUser); + const res = await request(app).get('/api/admin/grants').expect(200); + + expect(res.body).toHaveProperty('grants'); + expect(res.body).toHaveProperty('total'); + expect(res.body.grants.length).toBeGreaterThan(0); + // Seeded grants are for the ADMIN role + expect(res.body.grants[0].principalType).toBe(PrincipalType.ROLE); + }); + + it('GET /effective returns capabilities for admin', async () => { + const app = createApp(adminUser); + const res = await request(app).get('/api/admin/grants/effective').expect(200); + + expect(res.body).toHaveProperty('capabilities'); + expect(res.body.capabilities).toContain('access:admin'); + expect(res.body.capabilities).toContain('manage:roles'); + }); + + it('POST / assigns a grant and DELETE / revokes it', async () => { + const app = createApp(adminUser); + + // Assign + const assignRes = await request(app) + .post('/api/admin/grants') + .send({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.USER, + capability: 'read:users', + }) + .expect(201); + + expect(assignRes.body.grant).toMatchObject({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.USER, + capability: 'read:users', + }); + + // Verify via GET + const getRes = await request(app) + .get(`/api/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}`) + .expect(200); + + expect(getRes.body.grants.some((g) => g.capability === 'read:users')).toBe(true); + + // Revoke + await request(app) + .delete(`/api/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}/read:users`) + .expect(200); + + // Verify revoked + const afterRes = await request(app) + .get(`/api/admin/grants/${PrincipalType.ROLE}/${SystemRoles.USER}`) + .expect(200); + + expect(afterRes.body.grants.some((g) => g.capability === 'read:users')).toBe(false); + }); + + it('POST / returns 400 for non-existent role when checkRoleExists is wired', async () => { + const app = createApp(adminUser); + + const res = await request(app) + .post('/api/admin/grants') + .send({ + principalType: PrincipalType.ROLE, + principalId: 'nonexistent-role', + capability: 'read:users', + }) + .expect(400); + + expect(res.body.error).toBe('Role not found'); + }); + + it('POST / returns 401 without authenticated user', async () => { + const app = createApp(undefined); + + const res = await request(app) + .post('/api/admin/grants') + .send({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.USER, + capability: 'read:users', + }) + .expect(401); + + expect(res.body).toHaveProperty('error', 'Authentication required'); + }); +}); diff --git a/api/server/routes/admin/grants.js b/api/server/routes/admin/grants.js new file mode 100644 index 0000000000..a0fa73dc43 --- /dev/null +++ b/api/server/routes/admin/grants.js @@ -0,0 +1,35 @@ +const express = require('express'); +const { createAdminGrantsHandlers, getCachedPrincipals } = require('@librechat/api'); +const { SystemCapabilities } = require('@librechat/data-schemas'); +const { requireCapability } = require('~/server/middleware/roles/capabilities'); +const { requireJwtAuth } = require('~/server/middleware'); +const db = require('~/models'); + +const router = express.Router(); + +const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN); + +const handlers = createAdminGrantsHandlers({ + listGrants: db.listGrants, + countGrants: db.countGrants, + getCapabilitiesForPrincipal: db.getCapabilitiesForPrincipal, + getCapabilitiesForPrincipals: db.getCapabilitiesForPrincipals, + grantCapability: db.grantCapability, + revokeCapability: db.revokeCapability, + getUserPrincipals: db.getUserPrincipals, + hasCapabilityForPrincipals: db.hasCapabilityForPrincipals, + getHeldCapabilities: db.getHeldCapabilities, + getCachedPrincipals, + checkRoleExists: async (name) => (await db.getRoleByName(name)) != null, +}); + +router.use(requireJwtAuth, requireAdminAccess); + +router.get('/', handlers.listGrants); +router.get('/effective', handlers.getEffectiveCapabilities); +router.get('/:principalType/:principalId', handlers.getPrincipalGrants); +router.post('/', handlers.assignGrant); +/** Callers should encodeURIComponent the capability for client compatibility (e.g. manage%3Aconfigs%3Aendpoints). */ +router.delete('/:principalType/:principalId/:capability', handlers.revokeGrant); + +module.exports = router; diff --git a/api/server/routes/admin/roles.js b/api/server/routes/admin/roles.js index 2d0f1b1128..f2bbd7f7ea 100644 --- a/api/server/routes/admin/roles.js +++ b/api/server/routes/admin/roles.js @@ -26,6 +26,9 @@ const handlers = createAdminRolesHandlers({ updateUsersRoleByIds: db.updateUsersRoleByIds, listUsersByRole: db.listUsersByRole, countUsersByRole: db.countUsersByRole, + deleteConfig: db.deleteConfig, + deleteAclEntries: db.deleteAclEntries, + deleteGrantsForPrincipal: db.deleteGrantsForPrincipal, }); router.use(requireJwtAuth, requireAdminAccess); diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 71ae041fc2..245a7db8c6 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -3,6 +3,7 @@ const assistants = require('./assistants'); const categories = require('./categories'); const adminAuth = require('./admin/auth'); const adminConfig = require('./admin/config'); +const adminGrants = require('./admin/grants'); const adminGroups = require('./admin/groups'); const adminRoles = require('./admin/roles'); const endpoints = require('./endpoints'); @@ -35,6 +36,7 @@ module.exports = { auth, adminAuth, adminConfig, + adminGrants, adminGroups, adminRoles, keys, diff --git a/packages/api/src/admin/grants.spec.ts b/packages/api/src/admin/grants.spec.ts new file mode 100644 index 0000000000..a11103741f --- /dev/null +++ b/packages/api/src/admin/grants.spec.ts @@ -0,0 +1,1207 @@ +import { Types } from 'mongoose'; +import { PrincipalType } from 'librechat-data-provider'; +import { SystemCapabilities, expandImplications } from '@librechat/data-schemas'; +import type { ISystemGrant } from '@librechat/data-schemas'; +import type { Response } from 'express'; +import type { ServerRequest } from '~/types/http'; +import type { AdminGrantsDeps } from './grants'; +import { createAdminGrantsHandlers } from './grants'; + +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const validObjectId = new Types.ObjectId().toString(); + +function mockGrant(overrides: Partial = {}): ISystemGrant { + return { + _id: new Types.ObjectId(), + principalType: PrincipalType.ROLE, + principalId: 'editor', + capability: SystemCapabilities.READ_USERS, + grantedAt: new Date(), + ...overrides, + } as ISystemGrant; +} + +function createReqRes( + overrides: { + params?: Record; + query?: Record; + body?: Record; + user?: { _id: Types.ObjectId; role: string; tenantId?: string }; + } = {}, +) { + const req = { + params: overrides.params ?? {}, + query: overrides.query ?? {}, + body: overrides.body ?? {}, + user: overrides.user ?? { _id: new Types.ObjectId(), role: 'admin' }, + } as unknown as ServerRequest; + + const json = jest.fn(); + const status = jest.fn().mockReturnValue({ json }); + const res = { status, json } as unknown as Response; + + return { req, res, status, json }; +} + +function createDeps(overrides: Partial = {}): AdminGrantsDeps { + return { + listGrants: jest.fn().mockResolvedValue([]), + countGrants: jest.fn().mockResolvedValue(0), + getCapabilitiesForPrincipal: jest.fn().mockResolvedValue([]), + getCapabilitiesForPrincipals: jest.fn().mockResolvedValue([]), + grantCapability: jest.fn().mockResolvedValue(mockGrant()), + revokeCapability: jest.fn().mockResolvedValue(undefined), + getUserPrincipals: jest.fn().mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: new Types.ObjectId() }, + { principalType: PrincipalType.ROLE, principalId: 'admin' }, + ]), + hasCapabilityForPrincipals: jest.fn().mockResolvedValue(true), + getHeldCapabilities: jest + .fn() + .mockResolvedValue( + new Set([ + SystemCapabilities.READ_ROLES, + SystemCapabilities.READ_GROUPS, + SystemCapabilities.READ_USERS, + SystemCapabilities.MANAGE_ROLES, + SystemCapabilities.MANAGE_GROUPS, + SystemCapabilities.MANAGE_USERS, + ]), + ), + getCachedPrincipals: jest.fn().mockReturnValue(undefined), + ...overrides, + }; +} + +describe('createAdminGrantsHandlers', () => { + describe('listGrants', () => { + it('returns grants with pagination metadata', async () => { + const grants = [mockGrant(), mockGrant({ capability: SystemCapabilities.MANAGE_ROLES })]; + const deps = createDeps({ + listGrants: jest.fn().mockResolvedValue(grants), + countGrants: jest.fn().mockResolvedValue(2), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes(); + + await handlers.listGrants(req, res); + + expect(status).toHaveBeenCalledWith(200); + const response = json.mock.calls[0][0]; + expect(response.grants).toEqual(grants); + expect(response).toHaveProperty('total', 2); + expect(response).toHaveProperty('limit'); + expect(response).toHaveProperty('offset'); + }); + + it('returns empty array when no grants exist', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes(); + + await handlers.listGrants(req, res); + + expect(status).toHaveBeenCalledWith(200); + const response = json.mock.calls[0][0]; + expect(response.grants).toEqual([]); + expect(response.total).toBe(0); + }); + + it('passes principalTypes filter based on caller read permissions', async () => { + const deps = createDeps({ + getHeldCapabilities: jest.fn().mockResolvedValue(new Set([SystemCapabilities.READ_ROLES])), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res } = createReqRes(); + + await handlers.listGrants(req, res); + + expect(deps.listGrants).toHaveBeenCalledWith( + expect.objectContaining({ principalTypes: [PrincipalType.ROLE] }), + ); + expect(deps.countGrants).toHaveBeenCalledWith( + expect.objectContaining({ principalTypes: [PrincipalType.ROLE] }), + ); + }); + + it('returns empty grants when caller has no read permissions', async () => { + const deps = createDeps({ + getHeldCapabilities: jest.fn().mockResolvedValue(new Set()), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes(); + + await handlers.listGrants(req, res); + + expect(status).toHaveBeenCalledWith(200); + const response = json.mock.calls[0][0]; + expect(response.grants).toEqual([]); + expect(response.total).toBe(0); + expect(deps.listGrants).not.toHaveBeenCalled(); + expect(deps.countGrants).not.toHaveBeenCalled(); + }); + + it('passes limit and offset from query params', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res } = createReqRes({ query: { limit: '10', offset: '20' } }); + + await handlers.listGrants(req, res); + + expect(deps.listGrants).toHaveBeenCalledWith( + expect.objectContaining({ limit: 10, offset: 20 }), + ); + }); + + it('passes tenantId to dep calls', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res } = createReqRes({ + user: { _id: new Types.ObjectId(), role: 'admin', tenantId: 'tenant-1' }, + }); + + await handlers.listGrants(req, res); + + expect(deps.getHeldCapabilities).toHaveBeenCalledWith( + expect.objectContaining({ tenantId: 'tenant-1' }), + ); + expect(deps.listGrants).toHaveBeenCalledWith( + expect.objectContaining({ tenantId: 'tenant-1' }), + ); + }); + + it('returns 401 when user is not authenticated', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes(); + (req as unknown as Record).user = undefined; + + await handlers.listGrants(req, res); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Authentication required' }); + }); + + it('returns 500 on error', async () => { + const deps = createDeps({ + listGrants: jest.fn().mockRejectedValue(new Error('db error')), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes(); + + await handlers.listGrants(req, res); + + expect(status).toHaveBeenCalledWith(500); + expect(json).toHaveBeenCalledWith({ error: 'Failed to list grants' }); + }); + + it('returns 500 when getHeldCapabilities throws', async () => { + const deps = createDeps({ + getHeldCapabilities: jest.fn().mockRejectedValue(new Error('db error')), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes(); + + await handlers.listGrants(req, res); + + expect(status).toHaveBeenCalledWith(500); + expect(json).toHaveBeenCalledWith({ error: 'Failed to list grants' }); + }); + + it('uses cached principals when available', async () => { + const cachedPrincipals = [ + { principalType: PrincipalType.USER, principalId: new Types.ObjectId() }, + { principalType: PrincipalType.ROLE, principalId: 'admin' }, + ]; + const deps = createDeps({ + getCachedPrincipals: jest.fn().mockReturnValue(cachedPrincipals), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res } = createReqRes(); + + await handlers.listGrants(req, res); + + expect(deps.getUserPrincipals).not.toHaveBeenCalled(); + expect(deps.getHeldCapabilities).toHaveBeenCalledWith( + expect.objectContaining({ principals: cachedPrincipals }), + ); + }); + }); + + describe('getEffectiveCapabilities', () => { + it('uses cached principals when available', async () => { + const cachedPrincipals = [ + { principalType: PrincipalType.USER, principalId: new Types.ObjectId() }, + { principalType: PrincipalType.ROLE, principalId: 'admin' }, + ]; + const deps = createDeps({ + getCachedPrincipals: jest.fn().mockReturnValue(cachedPrincipals), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res } = createReqRes(); + + await handlers.getEffectiveCapabilities(req, res); + + expect(deps.getUserPrincipals).not.toHaveBeenCalled(); + expect(deps.getCapabilitiesForPrincipals).toHaveBeenCalledWith( + expect.objectContaining({ principals: cachedPrincipals }), + ); + }); + + it('returns expanded capabilities for the user', async () => { + const manageRolesGrant = mockGrant({ capability: SystemCapabilities.MANAGE_ROLES }); + const deps = createDeps({ + getCapabilitiesForPrincipals: jest.fn().mockResolvedValue([manageRolesGrant]), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes(); + + await handlers.getEffectiveCapabilities(req, res); + + expect(status).toHaveBeenCalledWith(200); + const response = json.mock.calls[0][0]; + const expected = expandImplications([SystemCapabilities.MANAGE_ROLES]); + expect(response.capabilities).toEqual(expect.arrayContaining(expected)); + expect(response.capabilities).toContain(SystemCapabilities.READ_ROLES); + }); + + it('returns empty capabilities when user has no grants', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes(); + + await handlers.getEffectiveCapabilities(req, res); + + expect(status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith({ capabilities: [] }); + }); + + it('queries all principals in a single batch', async () => { + const userId = new Types.ObjectId(); + const principals = [ + { principalType: PrincipalType.USER, principalId: userId }, + { principalType: PrincipalType.ROLE, principalId: 'editor' }, + ]; + const deps = createDeps({ + getUserPrincipals: jest.fn().mockResolvedValue(principals), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res } = createReqRes(); + + await handlers.getEffectiveCapabilities(req, res); + + expect(deps.getCapabilitiesForPrincipals).toHaveBeenCalledTimes(1); + expect(deps.getCapabilitiesForPrincipals).toHaveBeenCalledWith( + expect.objectContaining({ + principals: [ + { principalType: PrincipalType.USER, principalId: userId }, + { principalType: PrincipalType.ROLE, principalId: 'editor' }, + ], + }), + ); + }); + + it('passes tenantId to getCapabilitiesForPrincipals', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res } = createReqRes({ + user: { _id: new Types.ObjectId(), role: 'admin', tenantId: 'tenant-1' }, + }); + + await handlers.getEffectiveCapabilities(req, res); + + expect(deps.getCapabilitiesForPrincipals).toHaveBeenCalledWith( + expect.objectContaining({ tenantId: 'tenant-1' }), + ); + }); + + it('skips principals without principalId', async () => { + const principals = [ + { principalType: PrincipalType.PUBLIC }, + { principalType: PrincipalType.ROLE, principalId: 'editor' }, + ]; + const deps = createDeps({ + getUserPrincipals: jest.fn().mockResolvedValue(principals), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res } = createReqRes(); + + await handlers.getEffectiveCapabilities(req, res); + + expect(deps.getCapabilitiesForPrincipals).toHaveBeenCalledWith( + expect.objectContaining({ + principals: [{ principalType: PrincipalType.ROLE, principalId: 'editor' }], + }), + ); + }); + + it('returns empty capabilities when all principals lack principalId', async () => { + const deps = createDeps({ + getUserPrincipals: jest.fn().mockResolvedValue([{ principalType: PrincipalType.PUBLIC }]), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes(); + + await handlers.getEffectiveCapabilities(req, res); + + expect(status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith({ capabilities: [] }); + expect(deps.getCapabilitiesForPrincipals).not.toHaveBeenCalled(); + }); + + it('deduplicates capabilities across principals', async () => { + const readUsersGrant = mockGrant({ capability: SystemCapabilities.READ_USERS }); + const deps = createDeps({ + getUserPrincipals: jest.fn().mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: new Types.ObjectId() }, + { principalType: PrincipalType.ROLE, principalId: 'editor' }, + ]), + getCapabilitiesForPrincipals: jest.fn().mockResolvedValue([readUsersGrant, readUsersGrant]), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes(); + + await handlers.getEffectiveCapabilities(req, res); + + expect(status).toHaveBeenCalledWith(200); + const response = json.mock.calls[0][0]; + const readUsersCount = response.capabilities.filter( + (c: string) => c === SystemCapabilities.READ_USERS, + ).length; + expect(readUsersCount).toBe(1); + }); + + it('deduplicates when user holds both parent and implied capability', async () => { + const manageGrant = mockGrant({ capability: SystemCapabilities.MANAGE_ROLES }); + const readGrant = mockGrant({ capability: SystemCapabilities.READ_ROLES }); + const deps = createDeps({ + getCapabilitiesForPrincipals: jest.fn().mockResolvedValue([manageGrant, readGrant]), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes(); + + await handlers.getEffectiveCapabilities(req, res); + + expect(status).toHaveBeenCalledWith(200); + const response = json.mock.calls[0][0]; + const readRolesCount = response.capabilities.filter( + (c: string) => c === SystemCapabilities.READ_ROLES, + ).length; + expect(readRolesCount).toBe(1); + }); + + it('returns 401 when user is not authenticated', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes(); + (req as unknown as Record).user = undefined; + + await handlers.getEffectiveCapabilities(req, res); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Authentication required' }); + }); + + it('returns 500 on unexpected error', async () => { + const deps = createDeps({ + getUserPrincipals: jest.fn().mockRejectedValue(new Error('db error')), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes(); + + await handlers.getEffectiveCapabilities(req, res); + + expect(status).toHaveBeenCalledWith(500); + expect(json).toHaveBeenCalledWith({ error: 'Failed to get effective capabilities' }); + }); + + it('returns 500 when getCapabilitiesForPrincipals throws', async () => { + const deps = createDeps({ + getCapabilitiesForPrincipals: jest.fn().mockRejectedValue(new Error('db error')), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes(); + + await handlers.getEffectiveCapabilities(req, res); + + expect(status).toHaveBeenCalledWith(500); + expect(json).toHaveBeenCalledWith({ error: 'Failed to get effective capabilities' }); + }); + }); + + describe('getPrincipalGrants', () => { + it('returns grants for a role principal', async () => { + const grants = [mockGrant()]; + const deps = createDeps({ + getCapabilitiesForPrincipal: jest.fn().mockResolvedValue(grants), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + params: { principalType: PrincipalType.ROLE, principalId: 'editor' }, + }); + + await handlers.getPrincipalGrants(req, res); + + expect(deps.getCapabilitiesForPrincipal).toHaveBeenCalledWith( + expect.objectContaining({ + principalType: PrincipalType.ROLE, + principalId: 'editor', + }), + ); + expect(status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith({ grants }); + }); + + it('returns grants for a group principal', async () => { + const deps = createDeps({ + getCapabilitiesForPrincipal: jest.fn().mockResolvedValue([]), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + params: { principalType: PrincipalType.GROUP, principalId: validObjectId }, + }); + + await handlers.getPrincipalGrants(req, res); + + expect(status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith({ grants: [] }); + }); + + it('returns grants for a user principal', async () => { + const grants = [mockGrant({ principalType: PrincipalType.USER, principalId: validObjectId })]; + const deps = createDeps({ + getCapabilitiesForPrincipal: jest.fn().mockResolvedValue(grants), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + params: { principalType: PrincipalType.USER, principalId: validObjectId }, + }); + + await handlers.getPrincipalGrants(req, res); + + expect(deps.hasCapabilityForPrincipals).toHaveBeenCalledWith( + expect.objectContaining({ capability: SystemCapabilities.READ_USERS }), + ); + expect(deps.getCapabilitiesForPrincipal).toHaveBeenCalledWith( + expect.objectContaining({ + principalType: PrincipalType.USER, + principalId: validObjectId, + }), + ); + expect(status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith({ grants }); + }); + + it('passes tenantId to dep calls', async () => { + const deps = createDeps({ + getCapabilitiesForPrincipal: jest.fn().mockResolvedValue([]), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res } = createReqRes({ + params: { principalType: PrincipalType.ROLE, principalId: 'editor' }, + user: { _id: new Types.ObjectId(), role: 'admin', tenantId: 'tenant-1' }, + }); + + await handlers.getPrincipalGrants(req, res); + + expect(deps.hasCapabilityForPrincipals).toHaveBeenCalledWith( + expect.objectContaining({ tenantId: 'tenant-1' }), + ); + expect(deps.getCapabilitiesForPrincipal).toHaveBeenCalledWith( + expect.objectContaining({ tenantId: 'tenant-1' }), + ); + }); + + it('returns 400 for invalid principal type', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + params: { principalType: 'invalid', principalId: 'abc' }, + }); + + await handlers.getPrincipalGrants(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ error: 'Invalid principal type' }); + }); + + it('returns 400 when principalId is missing', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + params: { principalType: PrincipalType.ROLE, principalId: '' }, + }); + + await handlers.getPrincipalGrants(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ error: 'Principal ID is required' }); + }); + + it('returns 400 for non-ObjectId group principalId', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + params: { principalType: PrincipalType.GROUP, principalId: 'not-an-objectid' }, + }); + + await handlers.getPrincipalGrants(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ error: 'Invalid principalId format' }); + }); + + it('returns 400 for non-ObjectId user principalId', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + params: { principalType: PrincipalType.USER, principalId: 'not-an-objectid' }, + }); + + await handlers.getPrincipalGrants(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ error: 'Invalid principalId format' }); + }); + + it('accepts string principalId for role type without ObjectId validation', async () => { + const deps = createDeps({ + getCapabilitiesForPrincipal: jest.fn().mockResolvedValue([]), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status } = createReqRes({ + params: { principalType: PrincipalType.ROLE, principalId: 'custom-role-name' }, + }); + + await handlers.getPrincipalGrants(req, res); + + expect(status).toHaveBeenCalledWith(200); + }); + + it('returns 401 when user is not authenticated', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + params: { principalType: PrincipalType.ROLE, principalId: 'editor' }, + }); + (req as unknown as Record).user = undefined; + + await handlers.getPrincipalGrants(req, res); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Authentication required' }); + }); + + it('returns 403 when caller lacks READ capability', async () => { + const deps = createDeps({ + hasCapabilityForPrincipals: jest.fn().mockResolvedValue(false), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + params: { principalType: PrincipalType.ROLE, principalId: 'editor' }, + }); + + await handlers.getPrincipalGrants(req, res); + + expect(status).toHaveBeenCalledWith(403); + expect(json).toHaveBeenCalledWith({ error: 'Insufficient permissions' }); + expect(deps.hasCapabilityForPrincipals).toHaveBeenCalledWith( + expect.objectContaining({ capability: SystemCapabilities.READ_ROLES }), + ); + }); + + it('returns 500 on unexpected error', async () => { + const deps = createDeps({ + getCapabilitiesForPrincipal: jest.fn().mockRejectedValue(new Error('db error')), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + params: { principalType: PrincipalType.ROLE, principalId: 'editor' }, + }); + + await handlers.getPrincipalGrants(req, res); + + expect(status).toHaveBeenCalledWith(500); + expect(json).toHaveBeenCalledWith({ error: 'Failed to get grants' }); + }); + }); + + describe('assignGrant', () => { + const validBody = { + principalType: PrincipalType.ROLE, + principalId: 'editor', + capability: SystemCapabilities.READ_USERS, + }; + + it('assigns a grant and returns 201', async () => { + const grant = mockGrant(); + const deps = createDeps({ grantCapability: jest.fn().mockResolvedValue(grant) }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ body: validBody }); + + await handlers.assignGrant(req, res); + + expect(deps.grantCapability).toHaveBeenCalledWith( + expect.objectContaining({ + principalType: PrincipalType.ROLE, + principalId: 'editor', + capability: SystemCapabilities.READ_USERS, + }), + ); + expect(status).toHaveBeenCalledWith(201); + expect(json).toHaveBeenCalledWith({ grant }); + }); + + it('passes grantedBy from the authenticated user', async () => { + const userId = new Types.ObjectId(); + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res } = createReqRes({ body: validBody, user: { _id: userId, role: 'admin' } }); + + await handlers.assignGrant(req, res); + + expect(deps.grantCapability).toHaveBeenCalledWith( + expect.objectContaining({ grantedBy: userId.toString() }), + ); + }); + + it('passes tenantId to all dep calls', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res } = createReqRes({ + body: validBody, + user: { _id: new Types.ObjectId(), role: 'admin', tenantId: 'tenant-1' }, + }); + + await handlers.assignGrant(req, res); + + expect(deps.getHeldCapabilities).toHaveBeenCalledWith( + expect.objectContaining({ tenantId: 'tenant-1' }), + ); + expect(deps.grantCapability).toHaveBeenCalledWith( + expect.objectContaining({ tenantId: 'tenant-1' }), + ); + }); + + it('accepts section-level config capabilities', async () => { + const grant = mockGrant({ + capability: 'manage:configs:endpoints' as ISystemGrant['capability'], + }); + const deps = createDeps({ + grantCapability: jest.fn().mockResolvedValue(grant), + getHeldCapabilities: jest + .fn() + .mockResolvedValue( + new Set([SystemCapabilities.MANAGE_ROLES, 'manage:configs:endpoints']), + ), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status } = createReqRes({ + body: { ...validBody, capability: 'manage:configs:endpoints' }, + }); + + await handlers.assignGrant(req, res); + + expect(status).toHaveBeenCalledWith(201); + }); + + it('accepts config assignment capabilities', async () => { + const grant = mockGrant({ capability: 'assign:configs:group' as ISystemGrant['capability'] }); + const deps = createDeps({ + grantCapability: jest.fn().mockResolvedValue(grant), + getHeldCapabilities: jest + .fn() + .mockResolvedValue(new Set([SystemCapabilities.MANAGE_ROLES, 'assign:configs:group'])), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status } = createReqRes({ + body: { ...validBody, capability: 'assign:configs:group' }, + }); + + await handlers.assignGrant(req, res); + + expect(status).toHaveBeenCalledWith(201); + }); + + it('returns 400 for invalid extended capability string', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + body: { ...validBody, capability: 'manage:configs:' }, + }); + + await handlers.assignGrant(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ error: 'Invalid capability' }); + }); + + it('returns 400 for missing principalType', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + body: { principalId: 'editor', capability: SystemCapabilities.READ_USERS }, + }); + + await handlers.assignGrant(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ error: 'Invalid principal type' }); + }); + + it('returns 400 for invalid principalType', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + body: { ...validBody, principalType: 'invalid' }, + }); + + await handlers.assignGrant(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ error: 'Invalid principal type' }); + }); + + it('returns 400 for missing principalId', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + body: { principalType: PrincipalType.ROLE, capability: SystemCapabilities.READ_USERS }, + }); + + await handlers.assignGrant(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ error: 'Principal ID is required' }); + }); + + it('returns 400 for invalid capability', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + body: { ...validBody, capability: 'not:a:real:capability' }, + }); + + await handlers.assignGrant(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ error: 'Invalid capability' }); + }); + + it('returns 400 for invalid ObjectId on group principalId', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + body: { + principalType: PrincipalType.GROUP, + principalId: 'not-an-objectid', + capability: SystemCapabilities.READ_USERS, + }, + }); + + await handlers.assignGrant(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ error: 'Invalid principalId format' }); + }); + + it('returns 401 when user is not authenticated', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ body: validBody }); + (req as unknown as Record).user = undefined; + + await handlers.assignGrant(req, res); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Authentication required' }); + expect(deps.grantCapability).not.toHaveBeenCalled(); + }); + + it('returns 403 when caller lacks manage capability', async () => { + const deps = createDeps({ + getHeldCapabilities: jest.fn().mockResolvedValue(new Set()), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ body: validBody }); + + await handlers.assignGrant(req, res); + + expect(status).toHaveBeenCalledWith(403); + expect(json).toHaveBeenCalledWith({ error: 'Insufficient permissions' }); + expect(deps.grantCapability).not.toHaveBeenCalled(); + }); + + it('returns 403 when caller lacks the capability being granted', async () => { + const deps = createDeps({ + getHeldCapabilities: jest + .fn() + .mockResolvedValue(new Set([SystemCapabilities.MANAGE_ROLES])), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ body: validBody }); + + await handlers.assignGrant(req, res); + + expect(status).toHaveBeenCalledWith(403); + expect(json).toHaveBeenCalledWith({ error: 'Cannot grant a capability you do not possess' }); + expect(deps.grantCapability).not.toHaveBeenCalled(); + }); + + it('allows granting an implied capability the caller holds transitively', async () => { + const deps = createDeps({ + getHeldCapabilities: jest + .fn() + .mockResolvedValue( + new Set([SystemCapabilities.MANAGE_ROLES, SystemCapabilities.READ_ROLES]), + ), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status } = createReqRes({ + body: { + principalType: PrincipalType.ROLE, + principalId: 'editor', + capability: SystemCapabilities.READ_ROLES, + }, + }); + + await handlers.assignGrant(req, res); + + expect(status).toHaveBeenCalledWith(201); + expect(deps.getHeldCapabilities).toHaveBeenCalledWith( + expect.objectContaining({ + capabilities: expect.arrayContaining([SystemCapabilities.READ_ROLES]), + }), + ); + }); + + it('checks MANAGE_ROLES for role principal type', async () => { + const deps = createDeps({ + getHeldCapabilities: jest + .fn() + .mockResolvedValue( + new Set([SystemCapabilities.MANAGE_ROLES, SystemCapabilities.READ_USERS]), + ), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res } = createReqRes({ body: validBody }); + + await handlers.assignGrant(req, res); + + expect(deps.getHeldCapabilities).toHaveBeenCalledWith( + expect.objectContaining({ + capabilities: expect.arrayContaining([SystemCapabilities.MANAGE_ROLES]), + }), + ); + }); + + it('checks MANAGE_GROUPS for group principal type', async () => { + const deps = createDeps({ + getHeldCapabilities: jest + .fn() + .mockResolvedValue( + new Set([SystemCapabilities.MANAGE_GROUPS, SystemCapabilities.READ_USERS]), + ), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res } = createReqRes({ + body: { + principalType: PrincipalType.GROUP, + principalId: validObjectId, + capability: SystemCapabilities.READ_USERS, + }, + }); + + await handlers.assignGrant(req, res); + + expect(deps.getHeldCapabilities).toHaveBeenCalledWith( + expect.objectContaining({ + capabilities: expect.arrayContaining([SystemCapabilities.MANAGE_GROUPS]), + }), + ); + }); + + it('checks MANAGE_USERS for user principal type', async () => { + const deps = createDeps({ + getHeldCapabilities: jest + .fn() + .mockResolvedValue( + new Set([SystemCapabilities.MANAGE_USERS, SystemCapabilities.READ_USERS]), + ), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res } = createReqRes({ + body: { + principalType: PrincipalType.USER, + principalId: validObjectId, + capability: SystemCapabilities.READ_USERS, + }, + }); + + await handlers.assignGrant(req, res); + + expect(deps.getHeldCapabilities).toHaveBeenCalledWith( + expect.objectContaining({ + capabilities: expect.arrayContaining([SystemCapabilities.MANAGE_USERS]), + }), + ); + }); + + it('returns 400 when role does not exist', async () => { + const deps = createDeps({ + checkRoleExists: jest.fn().mockResolvedValue(false), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ body: validBody }); + + await handlers.assignGrant(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ error: 'Role not found' }); + expect(deps.grantCapability).not.toHaveBeenCalled(); + }); + + it('skips role existence check when checkRoleExists is not provided', async () => { + const { checkRoleExists: _, ...depsWithoutCheck } = createDeps(); + const deps = { ...depsWithoutCheck, checkRoleExists: undefined }; + const handlers = createAdminGrantsHandlers(deps as AdminGrantsDeps); + const { req, res, status } = createReqRes({ body: validBody }); + + await handlers.assignGrant(req, res); + + expect(status).toHaveBeenCalledWith(201); + }); + + it('returns 500 when checkRoleExists throws', async () => { + const deps = createDeps({ + checkRoleExists: jest.fn().mockRejectedValue(new Error('db error')), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ body: validBody }); + + await handlers.assignGrant(req, res); + + expect(status).toHaveBeenCalledWith(500); + expect(json).toHaveBeenCalledWith({ error: 'Failed to assign grant' }); + }); + + it('returns 500 when grantCapability returns null', async () => { + const deps = createDeps({ + grantCapability: jest.fn().mockResolvedValue(null), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ body: validBody }); + + await handlers.assignGrant(req, res); + + expect(status).toHaveBeenCalledWith(500); + expect(json).toHaveBeenCalledWith({ error: 'Grant operation returned no result' }); + }); + + it('returns 500 on unexpected error', async () => { + const deps = createDeps({ + grantCapability: jest.fn().mockRejectedValue(new Error('db error')), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ body: validBody }); + + await handlers.assignGrant(req, res); + + expect(status).toHaveBeenCalledWith(500); + expect(json).toHaveBeenCalledWith({ error: 'Failed to assign grant' }); + }); + }); + + describe('revokeGrant', () => { + const validParams = { + principalType: PrincipalType.ROLE, + principalId: 'editor', + capability: SystemCapabilities.READ_USERS, + }; + + it('returns 200 idempotently even if the grant does not exist', async () => { + const deps = createDeps({ + revokeCapability: jest.fn().mockResolvedValue(undefined), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ params: validParams }); + + await handlers.revokeGrant(req, res); + + expect(status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith({ success: true }); + }); + + it('revokes a grant and returns 200', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ params: validParams }); + + await handlers.revokeGrant(req, res); + + expect(deps.revokeCapability).toHaveBeenCalledWith( + expect.objectContaining({ + principalType: PrincipalType.ROLE, + principalId: 'editor', + capability: SystemCapabilities.READ_USERS, + }), + ); + expect(status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith({ success: true }); + }); + + it('passes tenantId to dep calls', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res } = createReqRes({ + params: validParams, + user: { _id: new Types.ObjectId(), role: 'admin', tenantId: 'tenant-1' }, + }); + + await handlers.revokeGrant(req, res); + + expect(deps.hasCapabilityForPrincipals).toHaveBeenCalledWith( + expect.objectContaining({ tenantId: 'tenant-1' }), + ); + expect(deps.revokeCapability).toHaveBeenCalledWith( + expect.objectContaining({ tenantId: 'tenant-1' }), + ); + }); + + it('accepts section-level config capability with colons', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status } = createReqRes({ + params: { ...validParams, capability: 'manage:configs:endpoints' }, + }); + + await handlers.revokeGrant(req, res); + + expect(status).toHaveBeenCalledWith(200); + expect(deps.revokeCapability).toHaveBeenCalledWith( + expect.objectContaining({ capability: 'manage:configs:endpoints' }), + ); + }); + + it('returns 400 for missing principalType', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + params: { + principalType: '', + principalId: 'editor', + capability: SystemCapabilities.READ_USERS, + }, + }); + + await handlers.revokeGrant(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ error: 'Invalid principal type' }); + }); + + it('returns 400 for missing principalId', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + params: { + principalType: PrincipalType.ROLE, + principalId: '', + capability: SystemCapabilities.READ_USERS, + }, + }); + + await handlers.revokeGrant(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ error: 'Principal ID is required' }); + }); + + it('returns 400 for invalid capability', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + params: { ...validParams, capability: 'fake' }, + }); + + await handlers.revokeGrant(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ error: 'Invalid capability' }); + }); + + it('returns 400 for missing capability param', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + params: { principalType: PrincipalType.ROLE, principalId: 'editor' }, + }); + + await handlers.revokeGrant(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ error: 'Invalid capability' }); + }); + + it('returns 401 when user is not authenticated', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ params: validParams }); + (req as unknown as Record).user = undefined; + + await handlers.revokeGrant(req, res); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith({ error: 'Authentication required' }); + expect(deps.revokeCapability).not.toHaveBeenCalled(); + }); + + it('returns 403 when caller lacks manage capability', async () => { + const deps = createDeps({ + hasCapabilityForPrincipals: jest.fn().mockResolvedValue(false), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ params: validParams }); + + await handlers.revokeGrant(req, res); + + expect(status).toHaveBeenCalledWith(403); + expect(json).toHaveBeenCalledWith({ error: 'Insufficient permissions' }); + expect(deps.revokeCapability).not.toHaveBeenCalled(); + }); + + it('returns 400 for invalid user ObjectId', async () => { + const deps = createDeps(); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ + params: { + principalType: PrincipalType.USER, + principalId: 'bad-id', + capability: SystemCapabilities.READ_USERS, + }, + }); + + await handlers.revokeGrant(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ error: 'Invalid principalId format' }); + }); + + it('returns 500 on unexpected error', async () => { + const deps = createDeps({ + revokeCapability: jest.fn().mockRejectedValue(new Error('db error')), + }); + const handlers = createAdminGrantsHandlers(deps); + const { req, res, status, json } = createReqRes({ params: validParams }); + + await handlers.revokeGrant(req, res); + + expect(status).toHaveBeenCalledWith(500); + expect(json).toHaveBeenCalledWith({ error: 'Failed to revoke grant' }); + }); + }); +}); diff --git a/packages/api/src/admin/grants.ts b/packages/api/src/admin/grants.ts new file mode 100644 index 0000000000..6e0b607778 --- /dev/null +++ b/packages/api/src/admin/grants.ts @@ -0,0 +1,422 @@ +import { PrincipalType } from 'librechat-data-provider'; +import { + logger, + isValidCapability, + isValidObjectIdString, + SystemCapabilities, + expandImplications, +} from '@librechat/data-schemas'; +import type { ISystemGrant, SystemCapability } from '@librechat/data-schemas'; +import type { Response } from 'express'; +import type { Types } from 'mongoose'; +import type { ResolvedPrincipal } from '~/types/principal'; +import type { ServerRequest } from '~/types/http'; +import { parsePagination } from './pagination'; + +interface GrantRequestBody { + principalType?: string; + principalId?: string | null; + capability?: string; +} + +export interface AdminGrantsDeps { + listGrants: (options?: { + tenantId?: string; + principalTypes?: PrincipalType[]; + limit?: number; + offset?: number; + }) => Promise; + countGrants: (options?: { + tenantId?: string; + principalTypes?: PrincipalType[]; + }) => Promise; + getCapabilitiesForPrincipal: (params: { + principalType: PrincipalType; + principalId: string | Types.ObjectId; + tenantId?: string; + }) => Promise; + getCapabilitiesForPrincipals: (params: { + principals: Array<{ principalType: PrincipalType; principalId: string | Types.ObjectId }>; + tenantId?: string; + }) => Promise; + grantCapability: (params: { + principalType: PrincipalType; + principalId: string | Types.ObjectId; + capability: SystemCapability; + tenantId?: string; + grantedBy?: string | Types.ObjectId; + }) => Promise; + revokeCapability: (params: { + principalType: PrincipalType; + principalId: string | Types.ObjectId; + capability: SystemCapability; + tenantId?: string; + }) => Promise; + getUserPrincipals: (params: { + userId: string; + role?: string | null; + tenantId?: string; + }) => Promise; + hasCapabilityForPrincipals: (params: { + principals: ResolvedPrincipal[]; + capability: SystemCapability; + tenantId?: string; + }) => Promise; + getHeldCapabilities: (params: { + principals: ResolvedPrincipal[]; + capabilities: SystemCapability[]; + tenantId?: string; + }) => Promise>; + getCachedPrincipals?: (user: { + id: string; + role: string; + tenantId?: string; + }) => ResolvedPrincipal[] | undefined; + checkRoleExists?: (roleId: string) => Promise; +} + +export type GrantPrincipalType = PrincipalType.ROLE | PrincipalType.GROUP | PrincipalType.USER; + +/** Creates admin grant handlers with dependency injection for the /api/admin/grants routes. */ +export function createAdminGrantsHandlers(deps: AdminGrantsDeps) { + const { + listGrants, + countGrants, + getCapabilitiesForPrincipal, + getCapabilitiesForPrincipals, + grantCapability, + revokeCapability, + getUserPrincipals, + hasCapabilityForPrincipals, + getHeldCapabilities, + getCachedPrincipals, + checkRoleExists, + } = deps; + + const MANAGE_CAPABILITY_BY_TYPE: Record = { + [PrincipalType.ROLE]: SystemCapabilities.MANAGE_ROLES, + [PrincipalType.GROUP]: SystemCapabilities.MANAGE_GROUPS, + [PrincipalType.USER]: SystemCapabilities.MANAGE_USERS, + }; + + const READ_CAPABILITY_BY_TYPE: Record = { + [PrincipalType.ROLE]: SystemCapabilities.READ_ROLES, + [PrincipalType.GROUP]: SystemCapabilities.READ_GROUPS, + [PrincipalType.USER]: SystemCapabilities.READ_USERS, + }; + + const VALID_PRINCIPAL_TYPES = new Set( + Object.keys(MANAGE_CAPABILITY_BY_TYPE) as GrantPrincipalType[], + ); + + function resolveUser( + req: ServerRequest, + ): { userId: string; role: string; tenantId?: string } | null { + const user = req.user; + if (!user) { + return null; + } + const userId = user._id?.toString() ?? user.id; + if (!userId || !user.role) { + return null; + } + return { userId, role: user.role, tenantId: user.tenantId }; + } + + async function resolvePrincipals(user: { + userId: string; + role: string; + tenantId?: string; + }): Promise { + if (getCachedPrincipals) { + const cached = getCachedPrincipals({ + id: user.userId, + role: user.role, + tenantId: user.tenantId, + }); + if (cached) { + return cached; + } + } + return getUserPrincipals({ userId: user.userId, role: user.role, tenantId: user.tenantId }); + } + + function validatePrincipal(principalType: string, principalId: string): string | null { + if (!principalType || !VALID_PRINCIPAL_TYPES.has(principalType as GrantPrincipalType)) { + return 'Invalid principal type'; + } + if (!principalId) { + return 'Principal ID is required'; + } + if (principalType !== PrincipalType.ROLE && !isValidObjectIdString(principalId)) { + return 'Invalid principalId format'; + } + return null; + } + + function validateGrantBody(body: GrantRequestBody): string | null { + const { principalType, principalId, capability } = body; + if (typeof principalType !== 'string') { + return 'Invalid principal type'; + } + if (principalId == null) { + return 'Principal ID is required'; + } + if (typeof principalId !== 'string') { + return 'Principal ID must be a string'; + } + const principalError = validatePrincipal(principalType, principalId); + if (principalError) { + return principalError; + } + if (!capability || typeof capability !== 'string' || !isValidCapability(capability)) { + return 'Invalid capability'; + } + return null; + } + + async function listGrantsHandler(req: ServerRequest, res: Response) { + try { + const user = resolveUser(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { limit, offset } = parsePagination(req.query as { limit?: string; offset?: string }); + const { tenantId } = user; + const principals = await resolvePrincipals(user); + const entries = Object.entries(READ_CAPABILITY_BY_TYPE) as [ + GrantPrincipalType, + SystemCapability, + ][]; + + const heldCaps = await getHeldCapabilities({ + principals, + capabilities: entries.map(([, cap]) => cap), + tenantId, + }); + const allowedTypes = entries + .filter(([, cap]) => heldCaps.has(cap)) + .map(([type]) => type) as PrincipalType[]; + + if (!allowedTypes.length) { + return res.status(200).json({ grants: [], total: 0, limit, offset }); + } + const [grants, total] = await Promise.all([ + listGrants({ tenantId, principalTypes: allowedTypes, limit, offset }), + countGrants({ tenantId, principalTypes: allowedTypes }), + ]); + return res.status(200).json({ grants, total, limit, offset }); + } catch (error) { + logger.error('[adminGrants] listGrants error:', error); + return res.status(500).json({ error: 'Failed to list grants' }); + } + } + + /** + * Returns the caller's effective capabilities: direct grants plus base-level + * implications (e.g. manage:roles → read:roles). + * + * Note: this endpoint does NOT expand parent capabilities into their + * section-level children (e.g. manage:configs does NOT expand into + * manage:configs:endpoints, manage:configs:models, etc.). Section-level + * capabilities are resolved dynamically by the authorization layer + * (hasCapabilityForPrincipals / getHeldCapabilities) at check time via + * getParentCapabilities. The admin UI should treat a base capability like + * manage:configs as implying authority over all its sections. + */ + async function getEffectiveCapabilitiesHandler(req: ServerRequest, res: Response) { + try { + const user = resolveUser(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { tenantId } = user; + const principals = await resolvePrincipals(user); + const filteredPrincipals = principals.filter( + (p): p is ResolvedPrincipal & { principalId: string | Types.ObjectId } => + p.principalId != null, + ); + + if (!filteredPrincipals.length) { + return res.status(200).json({ capabilities: [] }); + } + + const grants = await getCapabilitiesForPrincipals({ + principals: filteredPrincipals, + tenantId, + }); + + const directCaps = new Set(); + for (const grant of grants) { + directCaps.add(grant.capability); + } + + return res.status(200).json({ capabilities: expandImplications(Array.from(directCaps)) }); + } catch (error) { + logger.error('[adminGrants] getEffectiveCapabilities error:', error); + return res.status(500).json({ error: 'Failed to get effective capabilities' }); + } + } + + async function getPrincipalGrantsHandler(req: ServerRequest, res: Response) { + try { + const user = resolveUser(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { principalType, principalId } = req.params as { + principalType: string; + principalId: string; + }; + + const principalError = validatePrincipal(principalType, principalId); + if (principalError) { + return res.status(400).json({ error: principalError }); + } + + const { tenantId } = user; + const readCap = READ_CAPABILITY_BY_TYPE[principalType as GrantPrincipalType]; + const principals = await resolvePrincipals(user); + const allowed = await hasCapabilityForPrincipals({ + principals, + capability: readCap, + tenantId, + }); + if (!allowed) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + + const grants = await getCapabilitiesForPrincipal({ + principalType: principalType as PrincipalType, + principalId, + tenantId, + }); + return res.status(200).json({ grants }); + } catch (error) { + logger.error('[adminGrants] getPrincipalGrants error:', error); + return res.status(500).json({ error: 'Failed to get grants' }); + } + } + + async function assignGrantHandler(req: ServerRequest, res: Response) { + try { + const caller = resolveUser(req); + if (!caller) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const bodyError = validateGrantBody(req.body as GrantRequestBody); + if (bodyError) { + return res.status(400).json({ error: bodyError }); + } + + const { principalType, principalId, capability } = req.body as { + principalType: GrantPrincipalType; + principalId: string; + capability: SystemCapability; + }; + + const { tenantId } = caller; + const principals = await resolvePrincipals(caller); + + const manageCap = MANAGE_CAPABILITY_BY_TYPE[principalType]; + const held = await getHeldCapabilities({ + principals, + capabilities: [manageCap, capability], + tenantId, + }); + if (!held.has(manageCap)) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + if (!held.has(capability)) { + return res.status(403).json({ error: 'Cannot grant a capability you do not possess' }); + } + + /* + * Role existence is validated when checkRoleExists is provided. + * GROUP and USER principals are ObjectId-validated by validatePrincipal + * but not existence-checked — orphan grants for deleted principals are + * accepted as a trade-off. Cascade cleanup on group/user deletion + * (deleteGrantsForPrincipal) handles the removal path. + */ + if (principalType === PrincipalType.ROLE && checkRoleExists) { + const exists = await checkRoleExists(principalId); + if (!exists) { + return res.status(400).json({ error: 'Role not found' }); + } + } + + const grant = await grantCapability({ + principalType, + principalId, + capability, + tenantId, + grantedBy: caller.userId, + }); + if (!grant) { + return res.status(500).json({ error: 'Grant operation returned no result' }); + } + return res.status(201).json({ grant }); + } catch (error) { + logger.error('[adminGrants] assignGrant error:', error); + return res.status(500).json({ error: 'Failed to assign grant' }); + } + } + + /** + * Revocation requires MANAGE on the target principal type but does NOT + * require the caller to possess the capability being revoked. This avoids + * a bootstrap deadlock where no one can clean up grants they don't hold. + */ + async function revokeGrantHandler(req: ServerRequest, res: Response) { + try { + const caller = resolveUser(req); + if (!caller) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { principalType, principalId, capability } = req.params as { + principalType: string; + principalId: string; + capability?: string; + }; + + const principalError = validatePrincipal(principalType, principalId); + if (principalError) { + return res.status(400).json({ error: principalError }); + } + if (!capability || !isValidCapability(capability)) { + return res.status(400).json({ error: 'Invalid capability' }); + } + + const { tenantId } = caller; + const principals = await resolvePrincipals(caller); + const manageCap = MANAGE_CAPABILITY_BY_TYPE[principalType as GrantPrincipalType]; + if (!(await hasCapabilityForPrincipals({ principals, capability: manageCap, tenantId }))) { + return res.status(403).json({ error: 'Insufficient permissions' }); + } + + await revokeCapability({ + principalType: principalType as PrincipalType, + principalId, + capability: capability as SystemCapability, + tenantId, + }); + return res.status(200).json({ success: true }); + } catch (error) { + logger.error('[adminGrants] revokeGrant error:', error); + return res.status(500).json({ error: 'Failed to revoke grant' }); + } + } + + return { + listGrants: listGrantsHandler, + getEffectiveCapabilities: getEffectiveCapabilitiesHandler, + getPrincipalGrants: getPrincipalGrantsHandler, + assignGrant: assignGrantHandler, + revokeGrant: revokeGrantHandler, + }; +} diff --git a/packages/api/src/admin/groups.spec.ts b/packages/api/src/admin/groups.spec.ts index 42e32152d9..44371acdf8 100644 --- a/packages/api/src/admin/groups.spec.ts +++ b/packages/api/src/admin/groups.spec.ts @@ -8,7 +8,7 @@ import { createAdminGroupsHandlers } from './groups'; jest.mock('@librechat/data-schemas', () => ({ ...jest.requireActual('@librechat/data-schemas'), - logger: { error: jest.fn(), warn: jest.fn() }, + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, })); describe('createAdminGroupsHandlers', () => { @@ -809,7 +809,9 @@ describe('createAdminGroupsHandlers', () => { principalType: PrincipalType.GROUP, principalId: new Types.ObjectId(validId), }); - expect(deps.deleteGrantsForPrincipal).toHaveBeenCalledWith(PrincipalType.GROUP, validId); + expect(deps.deleteGrantsForPrincipal).toHaveBeenCalledWith(PrincipalType.GROUP, validId, { + tenantId: undefined, + }); }); it('cleans up Config, AclEntry, and SystemGrant on group delete', async () => { @@ -825,7 +827,9 @@ describe('createAdminGroupsHandlers', () => { principalType: PrincipalType.GROUP, principalId: new Types.ObjectId(validId), }); - expect(deps.deleteGrantsForPrincipal).toHaveBeenCalledWith(PrincipalType.GROUP, validId); + expect(deps.deleteGrantsForPrincipal).toHaveBeenCalledWith(PrincipalType.GROUP, validId, { + tenantId: undefined, + }); }); }); diff --git a/packages/api/src/admin/groups.ts b/packages/api/src/admin/groups.ts index ab4490e05f..4fc968949f 100644 --- a/packages/api/src/admin/groups.ts +++ b/packages/api/src/admin/groups.ts @@ -85,6 +85,7 @@ export interface AdminGroupsDeps { deleteGrantsForPrincipal: ( principalType: PrincipalType, principalId: string | Types.ObjectId, + options?: { tenantId?: string }, ) => Promise; } @@ -310,13 +311,14 @@ export function createAdminGroupsHandlers(deps: AdminGroupsDeps) { * grantPermission stores group principalId as ObjectId, so we must * cast here. deleteConfig and deleteGrantsForPrincipal normalize internally. */ + const tenantId = req.user?.tenantId; const cleanupResults = await Promise.allSettled([ deleteConfig(PrincipalType.GROUP, id), deleteAclEntries({ principalType: PrincipalType.GROUP, principalId: new Types.ObjectId(id), }), - deleteGrantsForPrincipal(PrincipalType.GROUP, id), + deleteGrantsForPrincipal(PrincipalType.GROUP, id, { tenantId }), ]); for (const result of cleanupResults) { if (result.status === 'rejected') { diff --git a/packages/api/src/admin/index.ts b/packages/api/src/admin/index.ts index fe60f1d993..f32fb057e8 100644 --- a/packages/api/src/admin/index.ts +++ b/packages/api/src/admin/index.ts @@ -1,6 +1,8 @@ export { createAdminConfigHandlers } from './config'; +export { createAdminGrantsHandlers } from './grants'; export { createAdminGroupsHandlers } from './groups'; export { createAdminRolesHandlers } from './roles'; export type { AdminConfigDeps } from './config'; +export type { AdminGrantsDeps, GrantPrincipalType } from './grants'; export type { AdminGroupsDeps } from './groups'; export type { AdminRolesDeps } from './roles'; diff --git a/packages/api/src/admin/roles.spec.ts b/packages/api/src/admin/roles.spec.ts index 3f43079bfb..edbd14ba0b 100644 --- a/packages/api/src/admin/roles.spec.ts +++ b/packages/api/src/admin/roles.spec.ts @@ -1,5 +1,5 @@ import { Types } from 'mongoose'; -import { SystemRoles } from 'librechat-data-provider'; +import { PrincipalType, SystemRoles } from 'librechat-data-provider'; import type { IRole, IUser } from '@librechat/data-schemas'; import type { Response } from 'express'; import type { ServerRequest } from '~/types/http'; @@ -10,7 +10,7 @@ const { RoleConflictError } = jest.requireActual('@librechat/data-schemas'); jest.mock('@librechat/data-schemas', () => ({ ...jest.requireActual('@librechat/data-schemas'), - logger: { error: jest.fn() }, + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, })); const validUserId = new Types.ObjectId().toString(); @@ -40,12 +40,14 @@ function createReqRes( params?: Record; query?: Record; body?: Record; + user?: { _id: Types.ObjectId; role: string; tenantId?: string }; } = {}, ) { const req = { params: overrides.params ?? {}, query: overrides.query ?? {}, body: overrides.body ?? {}, + user: overrides.user, } as unknown as ServerRequest; const json = jest.fn(); @@ -71,6 +73,9 @@ function createDeps(overrides: Partial = {}): AdminRolesDeps { updateUsersRoleByIds: jest.fn().mockResolvedValue(undefined), listUsersByRole: jest.fn().mockResolvedValue([]), countUsersByRole: jest.fn().mockResolvedValue(0), + deleteConfig: jest.fn().mockResolvedValue(null), + deleteAclEntries: jest.fn().mockResolvedValue(undefined), + deleteGrantsForPrincipal: jest.fn().mockResolvedValue(undefined), ...overrides, }; } @@ -925,6 +930,81 @@ describe('createAdminRolesHandlers', () => { expect(json).toHaveBeenCalledWith({ success: true }); }); + it('cleans up grants after successful deletion', async () => { + const deps = createDeps(); + const handlers = createAdminRolesHandlers(deps); + const { req, res, status } = createReqRes({ params: { name: 'editor' } }); + + await handlers.deleteRole(req, res); + + expect(status).toHaveBeenCalledWith(200); + expect(deps.deleteConfig).toHaveBeenCalledWith(PrincipalType.ROLE, 'editor'); + expect(deps.deleteAclEntries).toHaveBeenCalledWith({ + principalType: PrincipalType.ROLE, + principalId: 'editor', + }); + expect(deps.deleteGrantsForPrincipal).toHaveBeenCalledWith(PrincipalType.ROLE, 'editor', { + tenantId: undefined, + }); + }); + + it('passes tenantId to grant cleanup', async () => { + const deps = createDeps(); + const handlers = createAdminRolesHandlers(deps); + const { req, res, status } = createReqRes({ + params: { name: 'editor' }, + user: { _id: new Types.ObjectId(), role: 'admin', tenantId: 'tenant-1' }, + }); + + await handlers.deleteRole(req, res); + + expect(status).toHaveBeenCalledWith(200); + expect(deps.deleteGrantsForPrincipal).toHaveBeenCalledWith(PrincipalType.ROLE, 'editor', { + tenantId: 'tenant-1', + }); + }); + + it('does not clean up when role not found', async () => { + const deps = createDeps({ deleteRoleByName: jest.fn().mockResolvedValue(null) }); + const handlers = createAdminRolesHandlers(deps); + const { req, res, status } = createReqRes({ params: { name: 'nonexistent' } }); + + await handlers.deleteRole(req, res); + + expect(status).toHaveBeenCalledWith(404); + expect(deps.deleteConfig).not.toHaveBeenCalled(); + expect(deps.deleteAclEntries).not.toHaveBeenCalled(); + expect(deps.deleteGrantsForPrincipal).not.toHaveBeenCalled(); + }); + + it('succeeds even when grant cleanup fails', async () => { + const deps = createDeps({ + deleteGrantsForPrincipal: jest.fn().mockRejectedValue(new Error('cleanup failed')), + }); + const handlers = createAdminRolesHandlers(deps); + const { req, res, status, json } = createReqRes({ params: { name: 'editor' } }); + + await handlers.deleteRole(req, res); + + expect(status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith({ success: true }); + }); + + it('succeeds even when all cascade operations fail', async () => { + const deps = createDeps({ + deleteConfig: jest.fn().mockRejectedValue(new Error('config cleanup failed')), + deleteAclEntries: jest.fn().mockRejectedValue(new Error('acl cleanup failed')), + deleteGrantsForPrincipal: jest.fn().mockRejectedValue(new Error('grant cleanup failed')), + }); + const handlers = createAdminRolesHandlers(deps); + const { req, res, status, json } = createReqRes({ params: { name: 'editor' } }); + + await handlers.deleteRole(req, res); + + expect(status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith({ success: true }); + }); + it('returns 403 for system role', async () => { const deps = createDeps(); const handlers = createAdminRolesHandlers(deps); diff --git a/packages/api/src/admin/roles.ts b/packages/api/src/admin/roles.ts index b8c87c23ea..db592c02d0 100644 --- a/packages/api/src/admin/roles.ts +++ b/packages/api/src/admin/roles.ts @@ -1,6 +1,6 @@ -import { SystemRoles } from 'librechat-data-provider'; +import { PrincipalType, SystemRoles } from 'librechat-data-provider'; import { logger, isValidObjectIdString, RoleConflictError } from '@librechat/data-schemas'; -import type { IRole, IUser, AdminMember } from '@librechat/data-schemas'; +import type { IRole, IUser, IConfig, AdminMember } from '@librechat/data-schemas'; import type { FilterQuery, Types } from 'mongoose'; import type { Response } from 'express'; import type { ServerRequest } from '~/types/http'; @@ -105,6 +105,22 @@ export interface AdminRolesDeps { options?: { limit?: number; offset?: number }, ) => Promise; countUsersByRole: (roleName: string) => Promise; + /** Removes the per-principal config override (keyed by type + name, not ObjectId). */ + deleteConfig: ( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + ) => Promise; + /** Removes all ACL entries scoped to this principal. */ + deleteAclEntries: (filter: { + principalType: PrincipalType; + principalId: string | Types.ObjectId; + }) => Promise; + /** Removes all system capability grants held by this principal. */ + deleteGrantsForPrincipal: ( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + options?: { tenantId?: string }, + ) => Promise; } export function createAdminRolesHandlers(deps: AdminRolesDeps) { @@ -123,6 +139,9 @@ export function createAdminRolesHandlers(deps: AdminRolesDeps) { updateUsersRoleByIds, listUsersByRole, countUsersByRole, + deleteConfig, + deleteAclEntries, + deleteGrantsForPrincipal, } = deps; async function listRolesHandler(req: ServerRequest, res: Response) { @@ -367,6 +386,19 @@ export function createAdminRolesHandlers(deps: AdminRolesDeps) { if (!deleted) { return res.status(404).json({ error: 'Role not found' }); } + + const tenantId = req.user?.tenantId; + const cleanupResults = await Promise.allSettled([ + deleteConfig(PrincipalType.ROLE, name), + deleteAclEntries({ principalType: PrincipalType.ROLE, principalId: name }), + deleteGrantsForPrincipal(PrincipalType.ROLE, name, { tenantId }), + ]); + for (const result of cleanupResults) { + if (result.status === 'rejected') { + logger.error('[adminRoles] cascade cleanup failed for role:', name, result.reason); + } + } + return res.status(200).json({ success: true }); } catch (error) { logger.error('[adminRoles] deleteRole error:', error); diff --git a/packages/api/src/middleware/capabilities.ts b/packages/api/src/middleware/capabilities.ts index a3f1fe9038..6e784a9aa7 100644 --- a/packages/api/src/middleware/capabilities.ts +++ b/packages/api/src/middleware/capabilities.ts @@ -6,17 +6,12 @@ import { 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, ClientSession } from 'mongoose'; +import type { ResolvedPrincipal } from '~/types/principal'; import type { ServerRequest } from '~/types/http'; -interface ResolvedPrincipal { - principalType: PrincipalType; - principalId?: string | Types.ObjectId; -} - interface CapabilityDeps { getUserPrincipals: ( params: { userId: string | Types.ObjectId; role?: string | null }, @@ -80,6 +75,20 @@ export function capabilityContextMiddleware( capabilityStore.run({ principals: new Map(), results: new Map() }, next); } +/** + * Reads principals from the per-request ALS cache without side effects. + * Returns `undefined` when called outside a request context or before + * `requireCapability` has populated the cache for this user. + */ +export function getCachedPrincipals(user: CapabilityUser): ResolvedPrincipal[] | undefined { + const store = capabilityStore.getStore(); + if (!store) { + return undefined; + } + const key = `${user.id}:${user.role}:${user.tenantId ?? ''}`; + return store.principals.get(key); +} + /** * Factory that creates `hasCapability` and `requireCapability` with injected * database methods. Follows the same dependency-injection pattern as diff --git a/packages/api/src/types/index.ts b/packages/api/src/types/index.ts index 31adc3b9bb..62267a3b53 100644 --- a/packages/api/src/types/index.ts +++ b/packages/api/src/types/index.ts @@ -14,3 +14,4 @@ export * from './prompts'; export * from './run'; export * from './tokens'; export * from './stream'; +export * from './principal'; diff --git a/packages/api/src/types/principal.ts b/packages/api/src/types/principal.ts new file mode 100644 index 0000000000..d95ff9abc4 --- /dev/null +++ b/packages/api/src/types/principal.ts @@ -0,0 +1,7 @@ +import type { PrincipalType } from 'librechat-data-provider'; +import type { Types } from 'mongoose'; + +export interface ResolvedPrincipal { + principalType: PrincipalType; + principalId?: string | Types.ObjectId; +} diff --git a/packages/data-schemas/src/admin/capabilities.spec.ts b/packages/data-schemas/src/admin/capabilities.spec.ts new file mode 100644 index 0000000000..fe6222a5d4 --- /dev/null +++ b/packages/data-schemas/src/admin/capabilities.spec.ts @@ -0,0 +1,38 @@ +import { SystemCapabilities, isValidCapability } from './capabilities'; + +describe('isValidCapability', () => { + it.each(Object.values(SystemCapabilities))('accepts base capability: %s', (cap) => { + expect(isValidCapability(cap)).toBe(true); + }); + + it.each(['manage:configs:endpoints', 'read:configs:registration', 'manage:configs:speech'])( + 'accepts section-level capability: %s', + (cap) => { + expect(isValidCapability(cap)).toBe(true); + }, + ); + + it.each(['assign:configs:user', 'assign:configs:group', 'assign:configs:role'])( + 'accepts assignment capability: %s', + (cap) => { + expect(isValidCapability(cap)).toBe(true); + }, + ); + + it.each([ + '', + 'fake', + 'god:mode', + 'manage:configs:', + 'manage:configs: spaces', + 'manage:configs:a:b', + 'delete:configs:endpoints', + 'assign:configs:admin', + 'assign:configs:', + 'MANAGE:USERS', + 'manage:users:extra', + 'read:configs:end points', + ])('rejects invalid capability: "%s"', (cap) => { + expect(isValidCapability(cap)).toBe(false); + }); +}); diff --git a/packages/data-schemas/src/admin/capabilities.ts b/packages/data-schemas/src/admin/capabilities.ts index 447db235a2..44b9eaab4c 100644 --- a/packages/data-schemas/src/admin/capabilities.ts +++ b/packages/data-schemas/src/admin/capabilities.ts @@ -55,6 +55,24 @@ export const CapabilityImplications: Partial(Object.values(SystemCapabilities)); +const sectionCapPattern = /^(?:manage|read):configs:\w+$/; +const assignCapPattern = /^assign:configs:(?:user|group|role)$/; + +/** + * Runtime validator for the full `SystemCapability` union: + * base capabilities, section-level config capabilities, and config assignment capabilities. + */ +export function isValidCapability(value: string): boolean { + return ( + baseCapabilitySet.has(value) || sectionCapPattern.test(value) || assignCapPattern.test(value) + ); +} + // --------------------------------------------------------------------------- // Capability utility functions // --------------------------------------------------------------------------- diff --git a/packages/data-schemas/src/methods/systemGrant.spec.ts b/packages/data-schemas/src/methods/systemGrant.spec.ts index 49b4f7269e..1eef781fa9 100644 --- a/packages/data-schemas/src/methods/systemGrant.spec.ts +++ b/packages/data-schemas/src/methods/systemGrant.spec.ts @@ -542,6 +542,74 @@ describe('systemGrant methods', () => { }); }); + describe('hierarchical config capabilities', () => { + it('manage:configs satisfies manage:configs:
', async () => { + const userId = new Types.ObjectId(); + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.MANAGE_CONFIGS, + }); + + const result = await methods.hasCapabilityForPrincipals({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], + capability: 'manage:configs:endpoints' as SystemCapability, + }); + expect(result).toBe(true); + }); + + it('manage:configs satisfies read:configs:
transitively', async () => { + const userId = new Types.ObjectId(); + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.MANAGE_CONFIGS, + }); + + const result = await methods.hasCapabilityForPrincipals({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], + capability: 'read:configs:endpoints' as SystemCapability, + }); + expect(result).toBe(true); + }); + + it('read:configs satisfies read:configs:
but NOT manage:configs:
', async () => { + const userId = new Types.ObjectId(); + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_CONFIGS, + }); + + const readResult = await methods.hasCapabilityForPrincipals({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], + capability: 'read:configs:endpoints' as SystemCapability, + }); + expect(readResult).toBe(true); + + const manageResult = await methods.hasCapabilityForPrincipals({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], + capability: 'manage:configs:endpoints' as SystemCapability, + }); + expect(manageResult).toBe(false); + }); + + it('assign:configs satisfies assign:configs:', async () => { + const userId = new Types.ObjectId(); + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.ASSIGN_CONFIGS, + }); + + const result = await methods.hasCapabilityForPrincipals({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], + capability: 'assign:configs:user' as SystemCapability, + }); + expect(result).toBe(true); + }); + }); + describe('tenant scoping', () => { it('tenant-scoped grant does not match platform-level query', async () => { const userId = new Types.ObjectId(); @@ -762,6 +830,63 @@ describe('systemGrant methods', () => { expect(remainingA).toBe(0); expect(remainingB).toBe(1); }); + + it('with tenantId deletes only tenant-scoped grants, not platform-level grants', async () => { + // Platform-level grant (no tenantId) + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'editor', + capability: SystemCapabilities.READ_USERS, + }); + // Tenant-scoped grant + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'editor', + capability: SystemCapabilities.READ_CONFIGS, + tenantId: 'tenant-1', + }); + // Different tenant grant + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'editor', + capability: SystemCapabilities.READ_GROUPS, + tenantId: 'tenant-2', + }); + + await methods.deleteGrantsForPrincipal(PrincipalType.ROLE, 'editor', { + tenantId: 'tenant-1', + }); + + const remaining = await SystemGrant.find({ + principalType: PrincipalType.ROLE, + principalId: 'editor', + }).lean(); + const caps = remaining.map((g) => g.capability).sort(); + // Platform-level and tenant-2 grants survive + expect(caps).toEqual([SystemCapabilities.READ_GROUPS, SystemCapabilities.READ_USERS]); + }); + + it('without tenantId deletes all grants across all tenants', async () => { + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'temp-role', + capability: SystemCapabilities.READ_USERS, + }); + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'temp-role', + capability: SystemCapabilities.READ_CONFIGS, + tenantId: 'tenant-a', + }); + + await methods.deleteGrantsForPrincipal(PrincipalType.ROLE, 'temp-role'); + + const remaining = await SystemGrant.countDocuments({ + principalType: PrincipalType.ROLE, + principalId: 'temp-role', + }); + expect(remaining).toBe(0); + }); }); describe('schema validation', () => { @@ -912,4 +1037,438 @@ describe('systemGrant methods', () => { expect(count).toBe(2); }); }); + + describe('listGrants', () => { + beforeEach(async () => { + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'admin', + capability: SystemCapabilities.ACCESS_ADMIN, + }); + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'editor', + capability: SystemCapabilities.READ_USERS, + }); + await methods.grantCapability({ + principalType: PrincipalType.GROUP, + principalId: new Types.ObjectId(), + capability: SystemCapabilities.READ_CONFIGS, + }); + }); + + it('returns all platform-level grants when called without options', async () => { + const grants = await methods.listGrants(); + expect(grants).toHaveLength(3); + }); + + it('respects limit parameter', async () => { + const grants = await methods.listGrants({ limit: 2 }); + expect(grants).toHaveLength(2); + }); + + it('respects offset parameter', async () => { + const all = await methods.listGrants(); + const page2 = await methods.listGrants({ offset: 2, limit: 10 }); + expect(page2).toHaveLength(1); + expect(page2[0].capability).toBe(all[2].capability); + }); + + it('filters by principalTypes', async () => { + const grants = await methods.listGrants({ + principalTypes: [PrincipalType.ROLE], + }); + expect(grants).toHaveLength(2); + for (const g of grants) { + expect(g.principalType).toBe(PrincipalType.ROLE); + } + }); + + it('returns empty array for principalTypes with no grants', async () => { + const grants = await methods.listGrants({ + principalTypes: [PrincipalType.USER], + }); + expect(grants).toHaveLength(0); + }); + + it('excludes tenant-scoped grants when no tenantId provided', async () => { + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'admin', + capability: SystemCapabilities.MANAGE_USERS, + tenantId: 'tenant-1', + }); + + const grants = await methods.listGrants(); + expect(grants.every((g) => !('tenantId' in g && g.tenantId))).toBe(true); + }); + + it('includes tenant and platform grants when tenantId provided', async () => { + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'admin', + capability: SystemCapabilities.MANAGE_USERS, + tenantId: 'tenant-1', + }); + + const grants = await methods.listGrants({ tenantId: 'tenant-1' }); + expect(grants).toHaveLength(4); + }); + + it('sorts by principalType then capability', async () => { + const grants = await methods.listGrants(); + for (let i = 1; i < grants.length; i++) { + const prev = `${grants[i - 1].principalType}:${grants[i - 1].capability}`; + const curr = `${grants[i].principalType}:${grants[i].capability}`; + expect(prev <= curr).toBe(true); + } + }); + }); + + describe('countGrants', () => { + it('returns total count matching the filter', async () => { + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'admin', + capability: SystemCapabilities.ACCESS_ADMIN, + }); + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'editor', + capability: SystemCapabilities.READ_USERS, + }); + await methods.grantCapability({ + principalType: PrincipalType.GROUP, + principalId: new Types.ObjectId(), + capability: SystemCapabilities.READ_CONFIGS, + }); + + const total = await methods.countGrants(); + expect(total).toBe(3); + }); + + it('filters by principalTypes', async () => { + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'admin', + capability: SystemCapabilities.ACCESS_ADMIN, + }); + await methods.grantCapability({ + principalType: PrincipalType.GROUP, + principalId: new Types.ObjectId(), + capability: SystemCapabilities.READ_CONFIGS, + }); + + const count = await methods.countGrants({ + principalTypes: [PrincipalType.ROLE], + }); + expect(count).toBe(1); + }); + + it('returns 0 when no grants match', async () => { + const count = await methods.countGrants(); + expect(count).toBe(0); + }); + }); + + describe('getCapabilitiesForPrincipals', () => { + it('returns grants across multiple principals in a single query', async () => { + const userId = new Types.ObjectId(); + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_USERS, + }); + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'editor', + capability: SystemCapabilities.READ_ROLES, + }); + + const grants = await methods.getCapabilitiesForPrincipals({ + principals: [ + { principalType: PrincipalType.USER, principalId: userId }, + { principalType: PrincipalType.ROLE, principalId: 'editor' }, + ], + }); + + expect(grants).toHaveLength(2); + const caps = grants.map((g) => g.capability).sort(); + expect(caps).toEqual([SystemCapabilities.READ_ROLES, SystemCapabilities.READ_USERS]); + }); + + it('returns empty array for empty principals list', async () => { + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'admin', + capability: SystemCapabilities.ACCESS_ADMIN, + }); + + const grants = await methods.getCapabilitiesForPrincipals({ principals: [] }); + expect(grants).toEqual([]); + }); + + it('returns only matching principals, not all grants', async () => { + const userId = new Types.ObjectId(); + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_USERS, + }); + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'unrelated', + capability: SystemCapabilities.MANAGE_ROLES, + }); + + const grants = await methods.getCapabilitiesForPrincipals({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], + }); + + expect(grants).toHaveLength(1); + expect(grants[0].capability).toBe(SystemCapabilities.READ_USERS); + }); + + it('returns multiple grants for the same principal', async () => { + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'admin', + capability: SystemCapabilities.ACCESS_ADMIN, + }); + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'admin', + capability: SystemCapabilities.MANAGE_ROLES, + }); + + const grants = await methods.getCapabilitiesForPrincipals({ + principals: [{ principalType: PrincipalType.ROLE, principalId: 'admin' }], + }); + + expect(grants).toHaveLength(2); + }); + + it('excludes tenant-scoped grants when no tenantId provided', async () => { + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'admin', + capability: SystemCapabilities.ACCESS_ADMIN, + }); + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'admin', + capability: SystemCapabilities.MANAGE_USERS, + tenantId: 'tenant-1', + }); + + const grants = await methods.getCapabilitiesForPrincipals({ + principals: [{ principalType: PrincipalType.ROLE, principalId: 'admin' }], + }); + + expect(grants).toHaveLength(1); + expect(grants[0].capability).toBe(SystemCapabilities.ACCESS_ADMIN); + }); + + it('includes both platform and tenant grants when tenantId provided', async () => { + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'admin', + capability: SystemCapabilities.ACCESS_ADMIN, + }); + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'admin', + capability: SystemCapabilities.MANAGE_USERS, + tenantId: 'tenant-1', + }); + + const grants = await methods.getCapabilitiesForPrincipals({ + principals: [{ principalType: PrincipalType.ROLE, principalId: 'admin' }], + tenantId: 'tenant-1', + }); + + expect(grants).toHaveLength(2); + }); + + it('filters out PUBLIC principals before querying', async () => { + const userId = new Types.ObjectId(); + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_USERS, + }); + + const grants = await methods.getCapabilitiesForPrincipals({ + principals: [ + { principalType: PrincipalType.PUBLIC, principalId: '' }, + { principalType: PrincipalType.USER, principalId: userId }, + ], + }); + + expect(grants).toHaveLength(1); + expect(grants[0].capability).toBe(SystemCapabilities.READ_USERS); + }); + + it('returns empty array when all principals are PUBLIC', async () => { + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'admin', + capability: SystemCapabilities.ACCESS_ADMIN, + }); + + const grants = await methods.getCapabilitiesForPrincipals({ + principals: [{ principalType: PrincipalType.PUBLIC, principalId: '' }], + }); + + expect(grants).toEqual([]); + }); + }); + + describe('getHeldCapabilities', () => { + const userId = new Types.ObjectId(); + + it('returns the subset of capabilities the principals hold', async () => { + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: userId, + capability: SystemCapabilities.READ_ROLES, + }); + + const held = await methods.getHeldCapabilities({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], + capabilities: [SystemCapabilities.READ_ROLES, SystemCapabilities.READ_GROUPS], + }); + + expect(held).toEqual(new Set([SystemCapabilities.READ_ROLES])); + }); + + it('returns empty set when no capabilities match', async () => { + const held = await methods.getHeldCapabilities({ + principals: [{ principalType: PrincipalType.USER, principalId: new Types.ObjectId() }], + capabilities: [SystemCapabilities.MANAGE_ROLES], + }); + + expect(held.size).toBe(0); + }); + + it('returns empty set for empty principals', async () => { + const held = await methods.getHeldCapabilities({ + principals: [], + capabilities: [SystemCapabilities.READ_ROLES], + }); + + expect(held.size).toBe(0); + }); + + it('returns empty set for empty capabilities', async () => { + const held = await methods.getHeldCapabilities({ + principals: [{ principalType: PrincipalType.USER, principalId: userId }], + capabilities: [], + }); + + expect(held.size).toBe(0); + }); + + it('resolves implied capabilities via reverse implication map', async () => { + const implUser = new Types.ObjectId(); + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: implUser, + capability: SystemCapabilities.MANAGE_ROLES, + }); + + const held = await methods.getHeldCapabilities({ + principals: [{ principalType: PrincipalType.USER, principalId: implUser }], + capabilities: [SystemCapabilities.READ_ROLES, SystemCapabilities.MANAGE_GROUPS], + }); + + expect(held).toEqual(new Set([SystemCapabilities.READ_ROLES])); + }); + + it('excludes principals with undefined principalId', async () => { + await methods.grantCapability({ + principalType: PrincipalType.ROLE, + principalId: 'admin', + capability: SystemCapabilities.READ_ROLES, + }); + + const held = await methods.getHeldCapabilities({ + principals: [{ principalType: PrincipalType.ROLE }], + capabilities: [SystemCapabilities.READ_ROLES], + }); + + expect(held.size).toBe(0); + }); + + it('filters out PUBLIC principals', async () => { + const held = await methods.getHeldCapabilities({ + principals: [{ principalType: PrincipalType.PUBLIC, principalId: '' }], + capabilities: [SystemCapabilities.READ_ROLES], + }); + + expect(held.size).toBe(0); + }); + + it('respects tenant scoping', async () => { + const tenantUser = new Types.ObjectId(); + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: tenantUser, + capability: SystemCapabilities.READ_ROLES, + tenantId: 'tenant-a', + }); + + const held = await methods.getHeldCapabilities({ + principals: [{ principalType: PrincipalType.USER, principalId: tenantUser }], + capabilities: [SystemCapabilities.READ_ROLES], + tenantId: 'tenant-a', + }); + expect(held).toEqual(new Set([SystemCapabilities.READ_ROLES])); + + const heldOther = await methods.getHeldCapabilities({ + principals: [{ principalType: PrincipalType.USER, principalId: tenantUser }], + capabilities: [SystemCapabilities.READ_ROLES], + tenantId: 'tenant-b', + }); + expect(heldOther.size).toBe(0); + + const heldNoTenant = await methods.getHeldCapabilities({ + principals: [{ principalType: PrincipalType.USER, principalId: tenantUser }], + capabilities: [SystemCapabilities.READ_ROLES], + }); + expect(heldNoTenant.size).toBe(0); + }); + + it('resolves extended capability when principal holds the parent base capability', async () => { + const extUser = new Types.ObjectId(); + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: extUser, + capability: SystemCapabilities.MANAGE_CONFIGS, + }); + + const held = await methods.getHeldCapabilities({ + principals: [{ principalType: PrincipalType.USER, principalId: extUser }], + capabilities: ['manage:configs:endpoints' as SystemCapability], + }); + + expect(held).toEqual(new Set(['manage:configs:endpoints'])); + }); + + it('does not resolve extended capability when principal holds a different verb parent', async () => { + const readOnlyUser = new Types.ObjectId(); + await methods.grantCapability({ + principalType: PrincipalType.USER, + principalId: readOnlyUser, + capability: SystemCapabilities.READ_CONFIGS, + }); + + const held = await methods.getHeldCapabilities({ + principals: [{ principalType: PrincipalType.USER, principalId: readOnlyUser }], + capabilities: ['manage:configs:endpoints' as SystemCapability], + }); + + expect(held.size).toBe(0); + }); + }); }); diff --git a/packages/data-schemas/src/methods/systemGrant.ts b/packages/data-schemas/src/methods/systemGrant.ts index 4954f50c16..c43597c140 100644 --- a/packages/data-schemas/src/methods/systemGrant.ts +++ b/packages/data-schemas/src/methods/systemGrant.ts @@ -1,5 +1,5 @@ import { PrincipalType, SystemRoles } from 'librechat-data-provider'; -import type { Types, Model, ClientSession } from 'mongoose'; +import type { Types, Model, ClientSession, FilterQuery } from 'mongoose'; import type { SystemCapability } from '~/types/admin'; import type { ISystemGrant } from '~/types'; import { SystemCapabilities, CapabilityImplications } from '~/admin/capabilities'; @@ -18,7 +18,40 @@ for (const [broad, implied] of Object.entries(CapabilityImplications)) { } } +const baseCapabilityValues = new Set(Object.values(SystemCapabilities)); + +/** + * For a section/assignment capability like `manage:configs:endpoints` or + * `assign:configs:user`, returns all base capabilities that subsume it: + * the direct parent (`manage:configs`) plus any that imply the parent + * via `reverseImplications` (`manage:configs` has no reverse, but + * `read:configs` is implied by `manage:configs`—so `read:configs:endpoints` + * is satisfied by holding `manage:configs`). + */ +function getParentCapabilities(capability: string): string[] { + const lastColon = capability.lastIndexOf(':'); + if (lastColon === -1) { + return []; + } + const parent = capability.slice(0, lastColon); + if (!baseCapabilityValues.has(parent)) { + return []; + } + const parents = [parent]; + const implied = reverseImplications[parent as keyof typeof reverseImplications]; + if (implied) { + parents.push(...implied); + } + return parents; +} + export function createSystemGrantMethods(mongoose: typeof import('mongoose')) { + function tenantCondition(tenantId?: string): FilterQuery { + return tenantId != null + ? { $and: [{ $or: [{ tenantId }, { tenantId: { $exists: false } }] }] } + : { tenantId: { $exists: false } }; + } + /** * Check if any of the given principals holds a specific capability. * Follows the same principal-resolution pattern as AclEntry: @@ -39,31 +72,99 @@ export function createSystemGrantMethods(mongoose: typeof import('mongoose')) { }): 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 })); + .filter( + (p): p is typeof p & { principalId: string | Types.ObjectId } => + p.principalType !== PrincipalType.PUBLIC && p.principalId != null, + ) + .map((p) => ({ + principalType: p.principalType, + principalId: normalizePrincipalId(p.principalId, p.principalType), + })); if (!principalsQuery.length) { return false; } const impliedBy = reverseImplications[capability as keyof typeof reverseImplications] ?? []; - const capabilityQuery = impliedBy.length ? { $in: [capability, ...impliedBy] } : capability; + const parents = getParentCapabilities(capability); + const allMatches = [capability, ...impliedBy, ...parents]; + const capabilityQuery = allMatches.length > 1 ? { $in: allMatches } : capability; - const query: Record = { + const query: FilterQuery = { $or: principalsQuery, capability: capabilityQuery, + ...tenantCondition(tenantId), }; - if (tenantId != null) { - query.$and = [{ $or: [{ tenantId }, { tenantId: { $exists: false } }] }]; - } else { - query.tenantId = { $exists: false }; - } - const doc = await SystemGrant.exists(query); return doc != null; } + /** + * Returns the subset of `capabilities` that any of the given principals hold. + * Single DB round-trip — replaces N parallel `hasCapabilityForPrincipals` calls. + */ + async function getHeldCapabilities({ + principals, + capabilities, + tenantId, + }: { + principals: Array<{ principalType: PrincipalType; principalId?: string | Types.ObjectId }>; + capabilities: SystemCapability[]; + tenantId?: string; + }): Promise> { + const SystemGrant = mongoose.models.SystemGrant as Model; + const principalsQuery = principals + .filter( + (p): p is typeof p & { principalId: string | Types.ObjectId } => + p.principalType !== PrincipalType.PUBLIC && p.principalId != null, + ) + .map((p) => ({ + principalType: p.principalType, + principalId: normalizePrincipalId(p.principalId, p.principalType as PrincipalType), + })); + + if (!principalsQuery.length || !capabilities.length) { + return new Set(); + } + + const allCaps = new Set([ + ...capabilities, + ...capabilities.flatMap( + (cap) => reverseImplications[cap as keyof typeof reverseImplications] ?? [], + ), + ...capabilities.flatMap(getParentCapabilities), + ]); + + const docs = await SystemGrant.find( + { + $or: principalsQuery, + capability: { $in: [...allCaps] }, + ...tenantCondition(tenantId), + }, + { capability: 1, _id: 0 }, + ).lean(); + + const held = new Set(docs.map((d) => d.capability)); + const result = new Set(); + for (const cap of capabilities) { + if (held.has(cap)) { + result.add(cap); + continue; + } + const implied = reverseImplications[cap as keyof typeof reverseImplications]; + if (implied?.some((imp) => held.has(imp))) { + result.add(cap); + continue; + } + if (getParentCapabilities(cap).some((p) => held.has(p))) { + result.add(cap); + } + } + + return result; + } + /** * Grant a capability to a principal. Upsert — idempotent. */ @@ -87,18 +188,13 @@ export function createSystemGrantMethods(mongoose: typeof import('mongoose')) { const normalizedPrincipalId = normalizePrincipalId(principalId, principalType); - const filter: Record = { + const filter: FilterQuery = { principalType, principalId: normalizedPrincipalId, capability, + tenantId: tenantId != null ? tenantId : { $exists: false }, }; - if (tenantId != null) { - filter.tenantId = tenantId; - } else { - filter.tenantId = { $exists: false }; - } - const update = { $set: { grantedAt: new Date(), @@ -149,18 +245,13 @@ export function createSystemGrantMethods(mongoose: typeof import('mongoose')) { const normalizedPrincipalId = normalizePrincipalId(principalId, principalType); - const filter: Record = { + const filter: FilterQuery = { principalType, principalId: normalizedPrincipalId, capability, + tenantId: tenantId != null ? tenantId : { $exists: false }, }; - if (tenantId != null) { - filter.tenantId = tenantId; - } else { - filter.tenantId = { $exists: false }; - } - const options = session ? { session } : {}; await SystemGrant.deleteOne(filter, options); } @@ -180,17 +271,80 @@ export function createSystemGrantMethods(mongoose: typeof import('mongoose')) { }): Promise { const SystemGrant = mongoose.models.SystemGrant as Model; - const filter: Record = { + const filter: FilterQuery = { principalType, principalId: normalizePrincipalId(principalId, principalType), + ...tenantCondition(tenantId), }; - if (tenantId != null) { - filter.$or = [{ tenantId }, { tenantId: { $exists: false } }]; - } else { - filter.tenantId = { $exists: false }; + return await SystemGrant.find(filter).lean(); + } + + const GRANTS_DEFAULT_LIMIT = 50; + const GRANTS_MAX_LIMIT = 200; + + async function listGrants(options?: { + tenantId?: string; + principalTypes?: PrincipalType[]; + limit?: number; + offset?: number; + }): Promise { + const SystemGrant = mongoose.models.SystemGrant as Model; + const limit = Math.min(GRANTS_MAX_LIMIT, Math.max(1, options?.limit ?? GRANTS_DEFAULT_LIMIT)); + const offset = options?.offset ?? 0; + const filter: FilterQuery = { + ...(options?.principalTypes?.length && { principalType: { $in: options.principalTypes } }), + ...tenantCondition(options?.tenantId), + }; + + return SystemGrant.find(filter) + .sort({ principalType: 1, capability: 1 }) + .skip(offset) + .limit(limit) + .lean(); + } + + async function countGrants(options?: { + tenantId?: string; + principalTypes?: PrincipalType[]; + }): Promise { + const SystemGrant = mongoose.models.SystemGrant as Model; + const filter: FilterQuery = { + ...(options?.principalTypes?.length && { principalType: { $in: options.principalTypes } }), + ...tenantCondition(options?.tenantId), + }; + + return SystemGrant.countDocuments(filter); + } + + async function getCapabilitiesForPrincipals({ + principals, + tenantId, + }: { + principals: Array<{ principalType: PrincipalType; principalId: string | Types.ObjectId }>; + tenantId?: string; + }): Promise { + if (!principals.length) { + return []; } + const SystemGrant = mongoose.models.SystemGrant as Model; + const principalsQuery = principals + .filter((p) => p.principalType !== PrincipalType.PUBLIC) + .map((p) => ({ + principalType: p.principalType, + principalId: normalizePrincipalId(p.principalId, p.principalType), + })); + + if (!principalsQuery.length) { + return []; + } + + const filter: FilterQuery = { + $or: principalsQuery, + ...tenantCondition(tenantId), + }; + return await SystemGrant.find(filter).lean(); } @@ -247,18 +401,30 @@ export function createSystemGrantMethods(mongoose: typeof import('mongoose')) { } /** - * Delete all system grants for a principal. + * Delete system grants for a principal. * Used for cascade cleanup when a principal (group, role) is deleted. + * + * When `tenantId` is provided, only grants scoped to **exactly** that + * tenant are removed — platform-level grants (no tenantId) are left + * intact so they continue to serve other tenants. + * When `tenantId` is omitted, ALL grants for the principal are removed + * regardless of tenant scope. */ async function deleteGrantsForPrincipal( principalType: PrincipalType, principalId: string | Types.ObjectId, - session?: ClientSession, + options?: { tenantId?: string; session?: ClientSession }, ): Promise { const SystemGrant = mongoose.models.SystemGrant as Model; const normalizedPrincipalId = normalizePrincipalId(principalId, principalType); - const options = session ? { session } : {}; - await SystemGrant.deleteMany({ principalType, principalId: normalizedPrincipalId }, options); + + const filter: FilterQuery = { + principalType, + principalId: normalizedPrincipalId, + ...(options?.tenantId != null && { tenantId: options.tenantId }), + }; + const queryOptions = options?.session ? { session: options.session } : {}; + await SystemGrant.deleteMany(filter, queryOptions); } return { @@ -266,7 +432,11 @@ export function createSystemGrantMethods(mongoose: typeof import('mongoose')) { seedSystemGrants, revokeCapability, hasCapabilityForPrincipals, + getHeldCapabilities, + listGrants, + countGrants, getCapabilitiesForPrincipal, + getCapabilitiesForPrincipals, deleteGrantsForPrincipal, }; } diff --git a/packages/data-schemas/src/schema/systemGrant.ts b/packages/data-schemas/src/schema/systemGrant.ts index a20a407bf1..61ba319ba4 100644 --- a/packages/data-schemas/src/schema/systemGrant.ts +++ b/packages/data-schemas/src/schema/systemGrant.ts @@ -1,13 +1,8 @@ import { Schema } from 'mongoose'; import { PrincipalType } from 'librechat-data-provider'; -import { SystemCapabilities } from '~/admin/capabilities'; -import type { SystemCapability } from '~/types/admin'; +import { isValidCapability } from '~/admin/capabilities'; 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: { @@ -23,8 +18,7 @@ const systemGrantSchema = new Schema( type: String, required: true, validate: { - validator: (v: SystemCapability) => - baseCapabilities.has(v) || sectionCapPattern.test(v) || assignCapPattern.test(v), + validator: isValidCapability, message: 'Invalid capability string: "{VALUE}"', }, }, @@ -72,5 +66,6 @@ systemGrantSchema.index( ); systemGrantSchema.index({ capability: 1, tenantId: 1 }); +systemGrantSchema.index({ principalType: 1, capability: 1, tenantId: 1 }); export default systemGrantSchema;