From a4a17ac771bd71f01bfc70552259cd2bdca60ccf Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:49:23 -0700 Subject: [PATCH] =?UTF-8?q?=E2=9B=A9=EF=B8=8F=20feat:=20Admin=20Grants=20A?= =?UTF-8?q?PI=20Endpoints=20(#12438)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add System Grants handler factory with tests Handler factory with 4 endpoints: getEffectiveCapabilities (expanded capability set for authenticated user), getPrincipalGrants (list grants for a specific principal), assignGrant, and revokeGrant. Write ops dynamically check MANAGE_ROLES/GROUPS/USERS based on target principal type. 31 unit tests covering happy paths, validation, 403, and errors. * feat: wire System Grants REST routes Mount /api/admin/grants with requireJwtAuth + ACCESS_ADMIN gate. Add barrel export for createAdminGrantsHandlers and AdminGrantsDeps. * fix: cascade grant cleanup on role deletion Add deleteGrantsForPrincipal to AdminRolesDeps and call it in deleteRoleHandler via Promise.allSettled after successful deletion, matching the groups cleanup pattern. 3 tests added for cleanup call, skip on 404, and resilience to cleanup failure. * fix: simplify cascade grant cleanup on role deletion Replace Promise.allSettled wrapper with a direct try/catch for the single deleteGrantsForPrincipal call. * fix: harden grant handlers with auth, validation, types, and RESTful revoke - Add per-handler auth checks (401) and granular capability gates (READ_* for getPrincipalGrants, possession check for assignGrant) - Extract validatePrincipal helper; rewrite validateGrantBody to use direct type checks instead of unsafe `as string` casts - Align DI types with data layer (ResolvedPrincipal.principalType widened to string, getUserPrincipals role made optional) - Switch revoke route from DELETE body to RESTful URL params - Return 201 for assignGrant to match roles/groups create convention - Handle null grantCapability return with 500 - Add comprehensive test coverage for new auth/validation paths * fix: deduplicate ResolvedPrincipal, typed body, defensive auth checks - Remove duplicate ResolvedPrincipal from capabilities.ts; import the canonical export from grants.ts - Replace Record with explicit GrantRequestBody interface - Add defensive 403 when READ_CAPABILITY_BY_TYPE lookup misses - Document revoke asymmetry (no possession check) with JSDoc - Use _id only in resolveUser (avoid Mongoose virtual reliance) - Improve null-grant error message - Complete logger mock in tests * refactor: move ResolvedPrincipal to shared types to fix circular dep Extract ResolvedPrincipal from admin/grants.ts to types/principal.ts so middleware/capabilities.ts imports from shared types rather than depending upward on the admin handler layer. * chore: remove dead re-export, align logger mocks across admin tests - Remove unused ResolvedPrincipal re-export from grants.ts (canonical source is types/principal.ts) - Align logger mocks in roles.spec.ts and groups.spec.ts to include all log levels (error, warn, info, debug) matching grants.spec.ts * fix: cascade Config and AclEntry cleanup on role deletion Add deleteConfig and deleteAclEntries to role deletion cascade, matching the group deletion pattern. Previously only grants were cleaned up, leaving orphaned config overrides and ACL entries. * perf: single-query batch for getEffectiveCapabilities Add getCapabilitiesForPrincipals (plural) to the data layer — a single $or query across all principals instead of N+1 parallel queries. Wire it into the grants handler so getEffectiveCapabilities hits the DB once regardless of how many principals the user has. * fix: defer SystemCapabilities access to factory call time Move all SystemCapabilities usage (VALID_CAPABILITIES, MANAGE_CAPABILITY_BY_TYPE, READ_CAPABILITY_BY_TYPE) inside the createAdminGrantsHandlers factory. External test suites that mock @librechat/data-schemas without providing SystemCapabilities crashed at import time when grants.ts was loaded transitively. * test: add data-layer and handler test coverage for review findings - Add 6 mongodb-memory-server tests for getCapabilitiesForPrincipals: multi-principal batch, empty array, filtering, tenant scoping - Add handler test: all principals filtered (only PUBLIC) - Add handler test: granting an implied capability succeeds - Add handler test: all cascade cleanup operations fail simultaneously - Document platform-scope-only tenantId behavior in JSDoc * fix: resolveUser fallback to user.id, early-return empty principals - Match capabilities middleware pattern: _id?.toString() ?? user.id to handle JWT-deserialized users without Mongoose _id - Move empty-array guard before principals.map() to skip unnecessary normalizePrincipalId calls - Add comment explaining VALID_PRINCIPAL_TYPES module-scope asymmetry * refactor: derive VALID_PRINCIPAL_TYPES from capability maps Make MANAGE_CAPABILITY_BY_TYPE and READ_CAPABILITY_BY_TYPE non-Partial Records over a shared GrantPrincipalType union, then derive VALID_PRINCIPAL_TYPES from the map keys. This makes divergence between the three data structures structurally impossible. * feat: add GET /api/admin/grants list-all-grants endpoint Add listAllGrants data-layer method and handler so the admin panel can fetch all grants in a single request instead of fanning out N+M calls per role and group. Response is filtered to only include grants for principal types the caller has read access to. * fix: update principalType to use GrantPrincipalType for consistency in grants handling - Refactor principalType in createAdminGrantsHandlers to use GrantPrincipalType instead of PrincipalType for better type accuracy. - Ensure type consistency across the grants handling logic in the API. * fix: address admin grants review findings — tenantId propagation, capability validation, pagination, and test coverage Propagate tenantId through all grant operations for multi-tenancy support. Extract isValidCapability to accept full SystemCapability union (base, section, assign) and reuse it in both Mongoose schema validation and handler input checks. Replace listAllGrants with paginated listGrants + countGrants. Filter PUBLIC principals from getCapabilitiesForPrincipals queries. Export getCachedPrincipals from ALS store for fast-path principal resolution. Move DELETE capability param to query string to avoid colon-in-URL issues. Remove dead code and add comprehensive handler and data-layer test coverage. * refactor: harden admin grants — FilterQuery types, auth-first ordering, DELETE path param, isValidCapability tests Replace Record with FilterQuery across all data-layer query filters. Refactor buildTenantFilter to a pure tenantCondition function that returns a composable FilterQuery fragment, eliminating the $or collision between tenant and principal queries. Move auth check before input validation in getPrincipalGrantsHandler, assignGrantHandler, and revokeGrantHandler to avoid leaking valid type names to unauthenticated callers. Switch DELETE route from query param back to path param (/:capability) with encodeURIComponent per project conventions. Add compound index for listGrants sort. Type VALID_PRINCIPAL_TYPES as Set. Remove unused GetCachedPrincipalsFn type export. Add dedicated isValidCapability unit tests and revokeGrant idempotency test. * refactor: batch capability checks in listGrantsHandler via getHeldCapabilities Replace 3 parallel hasCapabilityForPrincipals DB calls with a single getHeldCapabilities query that returns the subset of capabilities any principal holds. Also: defensive limit(0) clamp, parallelized assignGrant auth checks, principalId type-vs-required error split, tenantCondition hoisted to factory top, JSDoc on cascade deps, DELETE route encoding note. * fix: normalize principalId and filter undefined in getHeldCapabilities Add normalizePrincipalId + null guard to getHeldCapabilities, matching the contract of getCapabilitiesForPrincipals. Simplify allCaps build with flatMap, add no-tenantId cross-check and undefined-principalId test cases. * refactor: use concrete types in GrantRequestBody, rename encoding test Replace unknown fields with explicit string types in GrantRequestBody, matching the established pattern in roles/groups/config handlers. Rename misleading 'encoded' test to 'with colons' since Express auto-decodes req.params. * fix: support hierarchical parent capabilities in possession checks hasCapabilityForPrincipals and getHeldCapabilities now resolve parent base capabilities for section/assignment grants. An admin holding manage:configs can now grant manage:configs:
and transitively read:configs:
. Fixes anti-escalation 403 blocking config capability delegation. * perf: use getHeldCapabilities in assignGrant to halve DB round-trips assignGrantHandler was making two parallel hasCapabilityForPrincipals calls to check manage + capability possession. getHeldCapabilities was introduced in this PR specifically for this pattern. Replace with a single batched call. Update corresponding spec assertions. * fix: validate role existence before granting capabilities Grants for non-existent role names were silently persisted, creating orphaned grants that could surprise-activate if a role with that name was later created. Add optional checkRoleExists dep to assignGrant and wire it to getRoleByName in the route file. * refactor: tighten principalType typing and use grantCapability in tests Narrow getCapabilitiesForPrincipals parameter from string to PrincipalType, removing the redundant cast. Replace direct SystemGrant.create() calls in getCapabilitiesForPrincipals tests with methods.grantCapability() to honor the schema's normalization invariant. Add getHeldCapabilities extended capability tests. * test: rename misleading cascade cleanup test name The test only injects failure into deleteGrantsForPrincipal, not all cascade operations. Rename from 'cascade cleanup fails' to 'grant cleanup fails' to match the actual scope. * fix: reorder role check after permission guard, add tenantId to index Move checkRoleExists after the getHeldCapabilities permission check so that a sub-MANAGE_ROLES admin cannot probe role name existence via 400 vs 403 response codes. Add tenantId to the { principalType, capability } index so listGrants queries in multi-tenant deployments can use a covering index instead of post-scanning for tenant condition. Add missing test for checkRoleExists throwing. * fix: scope deleteGrantsForPrincipal to tenant on role deletion deleteGrantsForPrincipal previously filtered only on principalType + principalId, deleting grants across all tenants. Since the role schema supports multi-tenancy (compound unique index on name + tenantId), two tenants can share a role name like 'editor'. Deleting that role in one tenant would wipe grants for identically-named roles in other tenants. Add optional tenantId parameter to deleteGrantsForPrincipal. When provided, scopes the delete to that tenant plus platform-level grants. Propagate req.user.tenantId through the role deletion cascade. * fix: scope grant cleanup to tenant on group deletion Same cross-tenant gap as the role deletion path: deleteGroupHandler called deleteGrantsForPrincipal without tenantId, so deleting a group would wipe its grants across all tenants. Extract req.user.tenantId and pass it through. * test: add HTTP integration test for admin grants routes Supertest-based test with real MongoMemoryServer exercising the full Express wiring: route registration, injected auth middleware, handler DI deps, and real DB round-trips. Covers GET /, GET /effective, POST / + DELETE / lifecycle, role existence validation, and 401 for unauthenticated callers. Also documents the expandImplications scope: the /effective endpoint returns base-level capabilities only; section-level resolution is handled at authorization check time by getParentCapabilities. * fix: use exact tenant match in deleteGrantsForPrincipal, normalize principalId, harden API CRITICAL: deleteGrantsForPrincipal was using tenantCondition (a read-query helper) for deleteMany, which includes the { tenantId: { $exists: false } } arm. This silently destroyed platform-level grants when a tenant-scoped role/group deletion occurred. Replace with exact { tenantId } match for deletes so platform-level grants survive tenant-scoped cascade cleanup. Refactor deleteGrantsForPrincipal signature from fragile positional overload (sessionOrTenantId union + maybeSession) to a clean options object: { tenantId?, session? }. Update all callers and test assertions. Add normalizePrincipalId to hasCapabilityForPrincipals to match the pattern already used by getHeldCapabilities — prevents string/ObjectId type mismatch on USER/GROUP principal queries. Also: export GrantPrincipalType from barrel, add upper-bound cap to listGrants, document GROUP/USER existence check trade-off, add integration tests for tenant-isolation property of deleteGrantsForPrincipal. * fix: forward tenantId to getUserPrincipals in resolvePrincipals resolvePrincipals had tenantId available from the caller but only forwarded it to getCachedPrincipals (cache lookup). The DB fallback via getUserPrincipals omitted it. While the Group schema's applyTenantIsolation Mongoose plugin handles scoping via AsyncLocalStorage in HTTP request context, explicitly passing tenantId makes the contract visible and prevents silent cross-tenant group resolution if called outside request context. * fix: remove unused import and add assertion to 401 integration test Remove unused SystemCapabilities import flagged by ESLint. Add explicit body assertion to the 401 test so it has a jest expect() call. * chore: hoist grant limit constants to scope, remove dead isolateModules Move GRANTS_DEFAULT_LIMIT / GRANTS_MAX_LIMIT from inside listGrants function body to createSystemGrantMethods scope so they are evaluated once at module load. Remove dead jest.isolateModules + jest.doMock block in integration test — the ~/models mock was never exercised since handlers are built with explicit DI deps. --------- Co-authored-by: Danny Avila --- api/server/index.js | 1 + api/server/routes/__tests__/grants.spec.js | 185 +++ api/server/routes/admin/grants.js | 35 + api/server/routes/admin/roles.js | 3 + api/server/routes/index.js | 2 + packages/api/src/admin/grants.spec.ts | 1207 +++++++++++++++++ packages/api/src/admin/grants.ts | 422 ++++++ packages/api/src/admin/groups.spec.ts | 10 +- packages/api/src/admin/groups.ts | 4 +- packages/api/src/admin/index.ts | 2 + packages/api/src/admin/roles.spec.ts | 84 +- packages/api/src/admin/roles.ts | 36 +- packages/api/src/middleware/capabilities.ts | 21 +- packages/api/src/types/index.ts | 1 + packages/api/src/types/principal.ts | 7 + .../src/admin/capabilities.spec.ts | 38 + .../data-schemas/src/admin/capabilities.ts | 18 + .../src/methods/systemGrant.spec.ts | 559 ++++++++ .../data-schemas/src/methods/systemGrant.ts | 238 +++- .../data-schemas/src/schema/systemGrant.ts | 11 +- 20 files changed, 2828 insertions(+), 56 deletions(-) create mode 100644 api/server/routes/__tests__/grants.spec.js create mode 100644 api/server/routes/admin/grants.js create mode 100644 packages/api/src/admin/grants.spec.ts create mode 100644 packages/api/src/admin/grants.ts create mode 100644 packages/api/src/types/principal.ts create mode 100644 packages/data-schemas/src/admin/capabilities.spec.ts 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;