⛩️ feat: Admin Grants API Endpoints (#12438)

* 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<string, unknown> 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<string, unknown> with FilterQuery<ISystemGrant> 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<GrantPrincipalType>. 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:<section> and transitively
read:configs:<section>. 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 <danny@librechat.ai>
This commit is contained in:
Dustin Healy 2026-03-30 13:49:23 -07:00 committed by GitHub
parent 56d994e9ec
commit a4a17ac771
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2828 additions and 56 deletions

File diff suppressed because it is too large Load diff

View file

@ -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<ISystemGrant[]>;
countGrants: (options?: {
tenantId?: string;
principalTypes?: PrincipalType[];
}) => Promise<number>;
getCapabilitiesForPrincipal: (params: {
principalType: PrincipalType;
principalId: string | Types.ObjectId;
tenantId?: string;
}) => Promise<ISystemGrant[]>;
getCapabilitiesForPrincipals: (params: {
principals: Array<{ principalType: PrincipalType; principalId: string | Types.ObjectId }>;
tenantId?: string;
}) => Promise<ISystemGrant[]>;
grantCapability: (params: {
principalType: PrincipalType;
principalId: string | Types.ObjectId;
capability: SystemCapability;
tenantId?: string;
grantedBy?: string | Types.ObjectId;
}) => Promise<ISystemGrant | null>;
revokeCapability: (params: {
principalType: PrincipalType;
principalId: string | Types.ObjectId;
capability: SystemCapability;
tenantId?: string;
}) => Promise<void>;
getUserPrincipals: (params: {
userId: string;
role?: string | null;
tenantId?: string;
}) => Promise<ResolvedPrincipal[]>;
hasCapabilityForPrincipals: (params: {
principals: ResolvedPrincipal[];
capability: SystemCapability;
tenantId?: string;
}) => Promise<boolean>;
getHeldCapabilities: (params: {
principals: ResolvedPrincipal[];
capabilities: SystemCapability[];
tenantId?: string;
}) => Promise<Set<SystemCapability>>;
getCachedPrincipals?: (user: {
id: string;
role: string;
tenantId?: string;
}) => ResolvedPrincipal[] | undefined;
checkRoleExists?: (roleId: string) => Promise<boolean>;
}
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<GrantPrincipalType, SystemCapability> = {
[PrincipalType.ROLE]: SystemCapabilities.MANAGE_ROLES,
[PrincipalType.GROUP]: SystemCapabilities.MANAGE_GROUPS,
[PrincipalType.USER]: SystemCapabilities.MANAGE_USERS,
};
const READ_CAPABILITY_BY_TYPE: Record<GrantPrincipalType, SystemCapability> = {
[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<ResolvedPrincipal[]> {
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<string>();
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,
};
}

View file

@ -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,
});
});
});

View file

@ -85,6 +85,7 @@ export interface AdminGroupsDeps {
deleteGrantsForPrincipal: (
principalType: PrincipalType,
principalId: string | Types.ObjectId,
options?: { tenantId?: string },
) => Promise<void>;
}
@ -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') {

View file

@ -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';

View file

@ -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<string, string>;
query?: Record<string, string>;
body?: Record<string, unknown>;
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> = {}): 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);

View file

@ -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<IUser[]>;
countUsersByRole: (roleName: string) => Promise<number>;
/** Removes the per-principal config override (keyed by type + name, not ObjectId). */
deleteConfig: (
principalType: PrincipalType,
principalId: string | Types.ObjectId,
) => Promise<IConfig | null>;
/** Removes all ACL entries scoped to this principal. */
deleteAclEntries: (filter: {
principalType: PrincipalType;
principalId: string | Types.ObjectId;
}) => Promise<void>;
/** Removes all system capability grants held by this principal. */
deleteGrantsForPrincipal: (
principalType: PrincipalType,
principalId: string | Types.ObjectId,
options?: { tenantId?: string },
) => Promise<void>;
}
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);

View file

@ -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

View file

@ -14,3 +14,4 @@ export * from './prompts';
export * from './run';
export * from './tokens';
export * from './stream';
export * from './principal';

View file

@ -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;
}

View file

@ -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);
});
});

View file

@ -55,6 +55,24 @@ export const CapabilityImplications: Partial<Record<BaseSystemCapability, BaseSy
[SystemCapabilities.MANAGE_ASSISTANTS]: [SystemCapabilities.READ_ASSISTANTS],
};
// ---------------------------------------------------------------------------
// Capability validation
// ---------------------------------------------------------------------------
const baseCapabilitySet = new Set<string>(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
// ---------------------------------------------------------------------------

View file

@ -542,6 +542,74 @@ describe('systemGrant methods', () => {
});
});
describe('hierarchical config capabilities', () => {
it('manage:configs satisfies manage:configs:<section>', 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:<section> 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:<section> but NOT manage:configs:<section>', 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:<target>', 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);
});
});
});

View file

@ -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<string>(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<ISystemGrant> {
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<boolean> {
const SystemGrant = mongoose.models.SystemGrant as Model<ISystemGrant>;
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<string, unknown> = {
const query: FilterQuery<ISystemGrant> = {
$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<Set<SystemCapability>> {
const SystemGrant = mongoose.models.SystemGrant as Model<ISystemGrant>;
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<string>([
...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<string>(docs.map((d) => d.capability));
const result = new Set<SystemCapability>();
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<string, unknown> = {
const filter: FilterQuery<ISystemGrant> = {
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<string, unknown> = {
const filter: FilterQuery<ISystemGrant> = {
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<ISystemGrant[]> {
const SystemGrant = mongoose.models.SystemGrant as Model<ISystemGrant>;
const filter: Record<string, unknown> = {
const filter: FilterQuery<ISystemGrant> = {
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<ISystemGrant[]> {
const SystemGrant = mongoose.models.SystemGrant as Model<ISystemGrant>;
const limit = Math.min(GRANTS_MAX_LIMIT, Math.max(1, options?.limit ?? GRANTS_DEFAULT_LIMIT));
const offset = options?.offset ?? 0;
const filter: FilterQuery<ISystemGrant> = {
...(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<number> {
const SystemGrant = mongoose.models.SystemGrant as Model<ISystemGrant>;
const filter: FilterQuery<ISystemGrant> = {
...(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<ISystemGrant[]> {
if (!principals.length) {
return [];
}
const SystemGrant = mongoose.models.SystemGrant as Model<ISystemGrant>;
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<ISystemGrant> = {
$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<void> {
const SystemGrant = mongoose.models.SystemGrant as Model<ISystemGrant>;
const normalizedPrincipalId = normalizePrincipalId(principalId, principalType);
const options = session ? { session } : {};
await SystemGrant.deleteMany({ principalType, principalId: normalizedPrincipalId }, options);
const filter: FilterQuery<ISystemGrant> = {
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,
};
}

View file

@ -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<SystemCapability>(Object.values(SystemCapabilities));
const sectionCapPattern = /^(?:manage|read):configs:\w+$/;
const assignCapPattern = /^assign:configs:(?:user|group|role)$/;
const systemGrantSchema = new Schema<ISystemGrant>(
{
principalType: {
@ -23,8 +18,7 @@ const systemGrantSchema = new Schema<ISystemGrant>(
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;