📜 feat: Implement System Grants for Capability-Based Authorization (#11896)

* feat: Implement System Grants for Role-Based Capabilities

- Added a new `systemGrant` model and associated methods to manage role-based capabilities within the application.
- Introduced middleware functions `hasCapability` and `requireCapability` to check user permissions based on their roles.
- Updated the database seeding process to include system grants for the ADMIN role, ensuring all necessary capabilities are assigned on startup.
- Enhanced type definitions and schemas to support the new system grant functionality, improving overall type safety and clarity in the codebase.

* test: Add unit tests for capabilities middleware and system grant methods

- Introduced comprehensive unit tests for the capabilities middleware, including `hasCapability` and `requireCapability`, ensuring proper permission checks based on user roles.
- Added tests for the `SystemGrant` methods, verifying the seeding of system grants, capability granting, and revocation processes.
- Enhanced test coverage for edge cases, including idempotency of grant operations and handling of unexpected errors in middleware.
- Utilized mocks for database interactions to isolate tests and improve reliability.

* refactor: Transition to Capability-Based Access Control

- Replaced role-based access checks with capability-based checks across various middleware and routes, enhancing permission management.
- Introduced `hasCapability` and `requireCapability` functions to streamline capability verification for user actions.
- Updated relevant routes and middleware to utilize the new capability system, ensuring consistent permission enforcement.
- Enhanced type definitions and added tests for the new capability functions, improving overall code reliability and maintainability.

* test: Enhance capability-based access tests for ADMIN role

- Updated tests to reflect the new capability-based access control, specifically for the ADMIN role.
- Modified test descriptions to clarify that users with the MANAGE_AGENTS capability can bypass permission checks.
- Seeded capabilities for the ADMIN role in multiple test files to ensure consistent permission checks across different routes and middleware.
- Improved overall test coverage for capability verification, ensuring robust permission management.

* test: Update capability tests for MCP server access

- Renamed test to reflect the correct capability for bypassing permission checks, changing from MANAGE_AGENTS to MANAGE_MCP_SERVERS.
- Updated seeding of capabilities for the ADMIN role to align with the new capability structure.
- Ensured consistency in capability definitions across tests and middleware for improved permission management.

* feat: Add hasConfigCapability for enhanced config access control

- Introduced `hasConfigCapability` function to check user permissions for managing or reading specific config sections.
- Updated middleware to export the new capability function, ensuring consistent access control across the application.
- Enhanced unit tests to cover various scenarios for the new capability, improving overall test coverage and reliability.

* fix: Update tenantId filter in createSystemGrantMethods

- Added a condition to set tenantId filter to { $exists: false } when tenantId is null, ensuring proper handling of cases where tenantId is not provided.
- This change improves the robustness of the system grant methods by explicitly managing the absence of tenantId in the filter logic.

* fix: account deletion capability check

- Updated the `canDeleteAccount` middleware to ensure that the `hasManageUsers` capability check only occurs if a user is present, preventing potential errors when the user object is undefined.
- This change improves the robustness of the account deletion logic by ensuring proper handling of user permissions.

* refactor: Optimize seeding of system grants for ADMIN role

- Replaced sequential capability granting with parallel execution using Promise.all in the seedSystemGrants function.
- This change improves performance and efficiency during the initialization of system grants, ensuring all capabilities are granted concurrently.

* refactor: Simplify systemGrantSchema index definition

- Removed the sparse option from the unique index on principalType, principalId, capability, and tenantId in the systemGrantSchema.
- This change streamlines the index definition, potentially improving query performance and clarity in the schema design.

* refactor: Reorganize role capability check in roles route

- Moved the capability check for reading roles to occur after parsing the roleName, improving code clarity and structure.
- This change ensures that the authorization logic is consistently applied before fetching role details, enhancing overall permission management.

* refactor: Remove unused ISystemGrant interface from systemCapabilities.ts

- Deleted the ISystemGrant interface as it was no longer needed, streamlining the code and improving clarity.
- This change helps reduce clutter in the file and focuses on relevant capabilities for the system.

* refactor: Migrate SystemCapabilities to data-schemas

- Replaced imports of SystemCapabilities from 'librechat-data-provider' with imports from '@librechat/data-schemas' across multiple files.
- This change centralizes the management of system capabilities, improving code organization and maintainability.

* refactor: Update account deletion middleware and capability checks

- Modified the `canDeleteAccount` middleware to ensure that the account deletion permission is only granted to users with the `MANAGE_USERS` capability, improving security and clarity in permission management.
- Enhanced error logging for unauthorized account deletion attempts, providing better insights into permission issues.
- Updated the `capabilities.ts` file to ensure consistent handling of user authentication checks, improving robustness in capability verification.
- Refined type definitions in `systemGrant.ts` and `systemGrantMethods.ts` to utilize the `PrincipalType` enum, enhancing type safety and code clarity.

* refactor: Extract principal ID normalization into a separate function

- Introduced `normalizePrincipalId` function to streamline the normalization of principal IDs based on their type, enhancing code clarity and reusability.
- Updated references in `createSystemGrantMethods` to utilize the new normalization function, improving maintainability and reducing code duplication.

* test: Add unit tests for principalId normalization in systemGrant

- Introduced tests for the `grantCapability`, `revokeCapability`, and `getCapabilitiesForPrincipal` methods to verify correct handling of principalId normalization between string and ObjectId formats.
- Enhanced the `capabilities.ts` middleware to utilize the `PrincipalType` enum for improved type safety.
- Added a new utility function `normalizePrincipalId` to streamline principal ID normalization logic, ensuring consistent behavior across the application.

* feat: Introduce capability implications and enhance system grant methods

- Added `CapabilityImplications` to define relationships between broader and implied capabilities, allowing for more intuitive permission checks.
- Updated `createSystemGrantMethods` to expand capability queries to include implied capabilities, improving authorization logic.
- Enhanced `systemGrantSchema` to include an `expiresAt` field for future TTL enforcement of grants, and added validation to ensure `tenantId` is not set to null.
- Documented authorization requirements for prompt group and prompt deletion methods to clarify access control expectations.

* test: Add unit tests for canDeleteAccount middleware

- Introduced unit tests for the `canDeleteAccount` middleware to verify account deletion permissions based on user roles and capabilities.
- Covered scenarios for both allowed and blocked account deletions, including checks for ADMIN users with the `MANAGE_USERS` capability and handling of undefined user cases.
- Enhanced test structure to ensure clarity and maintainability of permission checks in the middleware.

* fix: Add principalType enum validation to SystemGrant schema

Without enum validation, any string value was accepted for principalType
and silently stored. Invalid documents would never match capability
queries, creating phantom grants impossible to diagnose without raw DB
inspection. All other ACL models in the codebase validate this field.

* fix: Replace seedSystemGrants Promise.all with bulkWrite for concurrency safety

When two server instances start simultaneously (K8s rolling deploy, PM2
cluster), both call seedSystemGrants. With Promise.all + findOneAndUpdate
upsert, both instances may attempt to insert the same documents, causing
E11000 duplicate key errors that crash server startup.

bulkWrite with ordered:false handles concurrent upserts gracefully and
reduces 17 individual round trips to a single network call. The returned
documents (previously discarded) are no longer fetched.

* perf: Add AsyncLocalStorage per-request cache for capability checks

Every hasCapability call previously required 2 DB round trips
(getUserPrincipals + SystemGrant.exists) — replacing what were O(1)
string comparisons. Routes like patchPromptGroup triggered this twice,
and hasConfigCapability's fallback path resolved principals twice.

This adds a per-request AsyncLocalStorage cache that:
- Caches resolved principals (same for all checks within one request)
- Caches capability check results (same user+cap = same answer)
- Automatically scoped to request lifetime (no stale grants)
- Falls through to DB when no store exists (background jobs, tests)
- Requires no signature changes to hasCapability

The capabilityContextMiddleware is registered at the app level before
all routes, initializing a fresh store per request.

* fix: Add error handling for inline hasCapability calls

canDeleteAccount, fetchAssistants, and validateAuthor all call
hasCapability without try-catch. These were previously O(1) string
comparisons that could never throw. Now they hit the database and can
fail on connection timeout or transient errors.

Wrap each call in try-catch, defaulting to deny (false) on error.
This ensures a DB hiccup returns a clean 403 instead of an unhandled
500 with a stack trace.

* test: Add canDeleteAccount DB-error resilience test

Tests that hasCapability rejection (e.g., DB timeout) results in a clean
403 rather than an unhandled exception. Validates the error handling
added in the previous commit.

* refactor: Use barrel import for hasCapability in validateAuthor

Import from ~/server/middleware barrel instead of directly from
~/server/middleware/roles/capabilities for consistency with other
non-middleware consumers. Files within the middleware barrel itself
must continue using direct imports to avoid circular requires.

* refactor: Remove misleading pre('save') hook from SystemGrant schema

The pre('save') hook normalized principalId for USER/GROUP principals,
but the primary write path (grantCapability) uses findOneAndUpdate —
which does not trigger save hooks. The normalization was already handled
explicitly in grantCapability itself. The hook created a false impression
of schema-level enforcement that only covered save()/create() paths.

Replace with a comment documenting that all writes must go through
grantCapability.

* feat: Add READ_ASSISTANTS capability to complete manage/read pair

Every other managed resource had a paired READ_X / MANAGE_X capability
except assistants. This adds READ_ASSISTANTS and registers the
MANAGE_ASSISTANTS → READ_ASSISTANTS implication in CapabilityImplications,
enabling future read-only assistant visibility grants.

* chore: Reorder systemGrant methods for clarity

Moved hasCapabilityForPrincipals to a more logical position in the returned object of createSystemGrantMethods, improving code readability. This change also maintains the inclusion of seedSystemGrants in the export, ensuring all necessary methods are available.

* fix: Wrap seedSystemGrants in try-catch to avoid blocking startup

Seeding capabilities is idempotent and will succeed on the next restart.
A transient DB error during seeding should not prevent the server from
starting — log the error and continue.

* refactor: Improve capability check efficiency and add audit logging

Move hasCapability calls after cheap early-exits in validateAuthor and
fetchAssistants so the DB check only runs when its result matters. Add
logger.debug on every capability bypass grant across all 7 call sites
for auditability, and log errors in catch blocks instead of silently
swallowing them.

* test: Add integration tests for AsyncLocalStorage capability caching

Exercises the full vertical — ALS context, generateCapabilityCheck,
real getUserPrincipals, real hasCapabilityForPrincipals, real MongoDB
via MongoMemoryServer. Covers per-request caching, cross-context
isolation, concurrent request isolation, negative caching, capability
implications, tenant scoping, group-based grants, and requireCapability
middleware.

* test: Add systemGrant data-layer and ALS edge-case integration tests

systemGrant.spec.ts (51 tests): Full integration tests for all
systemGrant methods against real MongoDB — grant/revoke lifecycle,
principalId normalization (string→ObjectId for USER/GROUP, string for
ROLE), capability implications (both directions), tenant scoping,
schema validation (null tenantId, invalid enum, required fields,
unique compound index).

capabilities.integration.spec.ts (27 tests): Adds ALS edge cases —
missing context degrades gracefully with no caching (background jobs,
child processes), nested middleware creates independent inner context,
optional-chaining safety when store is undefined, mid-request grant
changes are invisible due to result caching, requireCapability works
without ALS, and interleaved concurrent contexts maintain isolation.

* fix: Add worker thread guards to capability ALS usage

Detect when hasCapability or capabilityContextMiddleware is called from
a worker thread (where ALS context does not propagate from the parent).
hasCapability logs a warn-once per factory instance; the middleware logs
an error since mounting Express middleware in a worker is likely a
misconfiguration. Both continue to function correctly — the guard is
observability, not a hard block.

* fix: Include tenantId in ALS principal cache key for tenant isolation

The principal cache key was user.id:user.role, which would reuse
cached principals across tenants for the same user within a request.
When getUserPrincipals gains tenant-scoped group resolution, principals
from tenant-a would incorrectly serve tenant-b checks. Changed to
user.id:user.role:user.tenantId to prevent cross-tenant cache hits.

Adds integration test proving separate principal lookups per tenantId.

* test: Remove redundant mocked capabilities.spec.js

The JS wrapper test (7 tests, all mocked) is a strict subset of
capabilities.integration.spec.ts (28 tests, real MongoDB). Every
scenario it covered — hasCapability true/false, tenantId passthrough,
requireCapability 403/500, error handling — is tested with higher
fidelity in the integration suite.

* test: Replace mocked canDeleteAccount tests with real MongoDB integration

Remove hasCapability mock — tests now exercise the full capability
chain against real MongoDB (getUserPrincipals, hasCapabilityForPrincipals,
SystemGrant collection). Only mocks remaining are logger and cache.

Adds new coverage: admin role without grant is blocked, user-level
grant bypasses deletion restriction, null user handling.

* test: Add comprehensive tests for ACL entry management and user group methods

Introduces new tests for `deleteAclEntries`, `bulkWriteAclEntries`, and `findPublicResourceIds` in `aclEntry.spec.ts`, ensuring proper functionality for deleting and bulk managing ACL entries. Additionally, enhances `userGroup.spec.ts` with tests for finding groups by ID and name pattern, including external ID matching and source filtering. These changes improve coverage and validate the integrity of ACL and user group operations against real MongoDB interactions.

* refactor: Update capability checks and logging for better clarity and error handling

Replaced `MANAGE_USERS` with `ACCESS_ADMIN` in the `canDeleteAccount` middleware and related tests to align with updated permission structure. Enhanced logging in various middleware functions to use `logger.warn` for capability check failures, providing clearer error messages. Additionally, refactored capability checks in the `patchPromptGroup` and `validateAuthor` functions to improve readability and maintainability. This commit also includes adjustments to the `systemGrant` methods to implement retry logic for transient failures during capability seeding, ensuring robustness in the face of database errors.

* refactor: Enhance logging and retry logic in seedSystemGrants method

Updated the logging format in the seedSystemGrants method to include error messages for better clarity. Improved the retry mechanism by explicitly mocking multiple failures in tests, ensuring robust error handling during transient database issues. Additionally, refined imports in the systemGrant schema for better type management.

* refactor: Consolidate imports in canDeleteAccount middleware

Merged logger and SystemCapabilities imports from the data-schemas module into a single line for improved readability and maintainability of the code. This change streamlines the import statements in the canDeleteAccount middleware.

* test: Enhance systemGrant tests for error handling and capability validation

Added tests to the systemGrant methods to handle various error scenarios, including E11000 race conditions, invalid ObjectId strings for USER and GROUP principals, and invalid capability strings. These enhancements improve the robustness of the capability granting and revoking logic, ensuring proper error propagation and validation of inputs.

* fix: Wrap hasCapability calls in deny-by-default try-catch at remaining sites

canAccessResource, files.js, and roles.js all had hasCapability inside
outer try-catch blocks that returned 500 on DB failure instead of
falling through to the regular ACL check. This contradicts the
deny-by-default pattern used everywhere else.

Also removes raw error.message from the roles.js 500 response to
prevent internal host/connection info leaking to clients.

* fix: Normalize user ID in canDeleteAccount before passing to hasCapability

requireCapability normalizes req.user.id via _id?.toString() fallback,
but canDeleteAccount passed raw req.user directly. If req.user.id is
absent (some auth layers only populate _id), getUserPrincipals received
undefined, silently returning empty principals and blocking the bypass.

* fix: Harden systemGrant schema and type safety

- Reject empty string tenantId in schema validator (was only blocking
  null; empty string silently orphaned documents)
- Fix reverseImplications to use BaseSystemCapability[] instead of
  string[], preserving the narrow discriminated type
- Document READ_ASSISTANTS as reserved/unenforced

* test: Use fake timers for seedSystemGrants retry tests and add tenantId validation

- Switch retry tests to jest.useFakeTimers() to eliminate 3+ seconds
  of real setTimeout delays per test run
- Add regression test for empty-string tenantId rejection

* docs: Add TODO(#12091) comments for tenant-scoped capability gaps

In multi-tenant mode, platform-level grants (no tenantId) won't match
tenant-scoped queries, breaking admin access. getUserPrincipals also
returns cross-tenant group memberships. Both need fixes in #12091.
This commit is contained in:
Danny Avila 2026-03-07 13:56:32 -05:00
parent abf3742efb
commit 530b401e7b
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
39 changed files with 4200 additions and 532 deletions

View file

@ -0,0 +1,659 @@
import mongoose, { Types } from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { PrincipalType, SystemRoles } from 'librechat-data-provider';
import {
createModels,
createMethods,
SystemCapabilities,
CapabilityImplications,
} from '@librechat/data-schemas';
import type { SystemCapability } from '@librechat/data-schemas';
import type { AllMethods } from '@librechat/data-schemas';
import {
generateCapabilityCheck,
capabilityStore,
capabilityContextMiddleware,
} from './capabilities';
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
},
}));
let mongoServer: MongoMemoryServer;
let methods: AllMethods;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
createModels(mongoose);
methods = createMethods(mongoose);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
});
/**
* Runs `fn` inside an AsyncLocalStorage context identical to what
* capabilityContextMiddleware sets up for real Express requests.
*/
function withinRequestContext<T>(fn: () => Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
capabilityContextMiddleware(
{} as Parameters<typeof capabilityContextMiddleware>[0],
{} as Parameters<typeof capabilityContextMiddleware>[1],
() => {
fn().then(resolve, reject);
},
);
});
}
describe('capabilities integration (real MongoDB)', () => {
let adminUser: { _id: Types.ObjectId; id: string; role: string };
let regularUser: { _id: Types.ObjectId; id: string; role: string };
beforeEach(async () => {
const User = mongoose.models.User;
const admin = await User.create({
name: 'Admin',
email: 'admin@test.com',
password: 'password123',
provider: 'local',
role: SystemRoles.ADMIN,
});
adminUser = { _id: admin._id, id: admin._id.toString(), role: SystemRoles.ADMIN };
const user = await User.create({
name: 'Regular',
email: 'user@test.com',
password: 'password123',
provider: 'local',
role: SystemRoles.USER,
});
regularUser = { _id: user._id, id: user._id.toString(), role: SystemRoles.USER };
});
describe('end-to-end with real getUserPrincipals + hasCapabilityForPrincipals', () => {
let hasCapability: ReturnType<typeof generateCapabilityCheck>['hasCapability'];
let hasConfigCapability: ReturnType<typeof generateCapabilityCheck>['hasConfigCapability'];
beforeEach(() => {
({ hasCapability, hasConfigCapability } = generateCapabilityCheck({
getUserPrincipals: methods.getUserPrincipals,
hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals,
}));
});
it('returns true for ADMIN after seedSystemGrants', async () => {
await methods.seedSystemGrants();
const result = await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN);
expect(result).toBe(true);
});
it('returns false for regular USER (no grants)', async () => {
await methods.seedSystemGrants();
const result = await hasCapability(regularUser, SystemCapabilities.ACCESS_ADMIN);
expect(result).toBe(false);
});
it('resolves all seeded capabilities for ADMIN', async () => {
await methods.seedSystemGrants();
for (const cap of Object.values(SystemCapabilities)) {
const result = await hasCapability(adminUser, cap);
expect(result).toBe(true);
}
});
it('resolves capability implications (MANAGE_X implies READ_X)', async () => {
await methods.grantCapability({
principalType: PrincipalType.ROLE,
principalId: SystemRoles.USER,
capability: SystemCapabilities.MANAGE_USERS,
});
const hasManage = await hasCapability(regularUser, SystemCapabilities.MANAGE_USERS);
const hasRead = await hasCapability(regularUser, SystemCapabilities.READ_USERS);
expect(hasManage).toBe(true);
expect(hasRead).toBe(true);
});
it('implication is one-directional (READ_X does NOT imply MANAGE_X)', async () => {
await methods.grantCapability({
principalType: PrincipalType.ROLE,
principalId: SystemRoles.USER,
capability: SystemCapabilities.READ_USERS,
});
const hasRead = await hasCapability(regularUser, SystemCapabilities.READ_USERS);
const hasManage = await hasCapability(regularUser, SystemCapabilities.MANAGE_USERS);
expect(hasRead).toBe(true);
expect(hasManage).toBe(false);
});
it('grants to a specific user work independently of role', async () => {
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: regularUser.id,
capability: SystemCapabilities.READ_AGENTS,
});
const result = await hasCapability(regularUser, SystemCapabilities.READ_AGENTS);
expect(result).toBe(true);
});
it('grants via group membership are resolved', async () => {
const Group = mongoose.models.Group;
const group = await Group.create({
name: 'Editors',
source: 'local',
memberIds: [regularUser.id],
});
await methods.grantCapability({
principalType: PrincipalType.GROUP,
principalId: group._id,
capability: SystemCapabilities.MANAGE_PROMPTS,
});
const result = await hasCapability(regularUser, SystemCapabilities.MANAGE_PROMPTS);
expect(result).toBe(true);
});
it('revoked capability is no longer granted', async () => {
await methods.grantCapability({
principalType: PrincipalType.ROLE,
principalId: SystemRoles.USER,
capability: SystemCapabilities.READ_USAGE,
});
expect(await hasCapability(regularUser, SystemCapabilities.READ_USAGE)).toBe(true);
await methods.revokeCapability({
principalType: PrincipalType.ROLE,
principalId: SystemRoles.USER,
capability: SystemCapabilities.READ_USAGE,
});
expect(await hasCapability(regularUser, SystemCapabilities.READ_USAGE)).toBe(false);
});
it('tenant-scoped grant does not leak to platform-level check', async () => {
await methods.grantCapability({
principalType: PrincipalType.ROLE,
principalId: SystemRoles.USER,
capability: SystemCapabilities.ACCESS_ADMIN,
tenantId: 'tenant-a',
});
const platformResult = await hasCapability(regularUser, SystemCapabilities.ACCESS_ADMIN);
expect(platformResult).toBe(false);
const tenantResult = await hasCapability(
{ ...regularUser, tenantId: 'tenant-a' },
SystemCapabilities.ACCESS_ADMIN,
);
expect(tenantResult).toBe(true);
});
it('hasConfigCapability falls back to section-specific grant', async () => {
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: regularUser.id,
capability: 'manage:configs:endpoints' as SystemCapability,
});
const hasBroad = await hasConfigCapability(regularUser, 'endpoints');
expect(hasBroad).toBe(true);
const hasOtherSection = await hasConfigCapability(regularUser, 'balance');
expect(hasOtherSection).toBe(false);
});
});
describe('AsyncLocalStorage per-request caching', () => {
it('caches getUserPrincipals within a single request context', async () => {
await methods.seedSystemGrants();
const getUserPrincipals = jest.fn(methods.getUserPrincipals);
const { hasCapability } = generateCapabilityCheck({
getUserPrincipals,
hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals,
});
await withinRequestContext(async () => {
await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN);
await hasCapability(adminUser, SystemCapabilities.MANAGE_USERS);
await hasCapability(adminUser, SystemCapabilities.READ_CONFIGS);
});
expect(getUserPrincipals).toHaveBeenCalledTimes(1);
});
it('caches capability results within a single request context', async () => {
await methods.seedSystemGrants();
const hasCapabilityForPrincipals = jest.fn(methods.hasCapabilityForPrincipals);
const { hasCapability } = generateCapabilityCheck({
getUserPrincipals: methods.getUserPrincipals,
hasCapabilityForPrincipals,
});
await withinRequestContext(async () => {
const r1 = await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN);
const r2 = await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN);
expect(r1).toBe(true);
expect(r2).toBe(true);
});
const accessAdminCalls = hasCapabilityForPrincipals.mock.calls.filter(
(args) => args[0].capability === SystemCapabilities.ACCESS_ADMIN,
);
expect(accessAdminCalls).toHaveLength(1);
});
it('does NOT share cache across separate request contexts', async () => {
await methods.seedSystemGrants();
const getUserPrincipals = jest.fn(methods.getUserPrincipals);
const { hasCapability } = generateCapabilityCheck({
getUserPrincipals,
hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals,
});
await withinRequestContext(async () => {
await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN);
});
await withinRequestContext(async () => {
await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN);
});
expect(getUserPrincipals).toHaveBeenCalledTimes(2);
});
it('isolates cache between concurrent request contexts', async () => {
await methods.seedSystemGrants();
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: regularUser.id,
capability: SystemCapabilities.READ_AGENTS,
});
const { hasCapability } = generateCapabilityCheck({
getUserPrincipals: methods.getUserPrincipals,
hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals,
});
const results = await Promise.all([
withinRequestContext(async () => {
const admin = await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN);
const agents = await hasCapability(adminUser, SystemCapabilities.READ_AGENTS);
return { admin, agents, who: 'admin' };
}),
withinRequestContext(async () => {
const admin = await hasCapability(regularUser, SystemCapabilities.ACCESS_ADMIN);
const agents = await hasCapability(regularUser, SystemCapabilities.READ_AGENTS);
return { admin, agents, who: 'regular' };
}),
]);
const adminResult = results.find((r) => r.who === 'admin')!;
const regularResult = results.find((r) => r.who === 'regular')!;
expect(adminResult.admin).toBe(true);
expect(adminResult.agents).toBe(true);
expect(regularResult.admin).toBe(false);
expect(regularResult.agents).toBe(true);
});
it('falls through to DB when outside request context (no ALS)', async () => {
await methods.seedSystemGrants();
const getUserPrincipals = jest.fn(methods.getUserPrincipals);
const { hasCapability } = generateCapabilityCheck({
getUserPrincipals,
hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals,
});
await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN);
await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN);
expect(getUserPrincipals).toHaveBeenCalledTimes(2);
});
it('caches false results correctly (negative caching)', async () => {
const hasCapabilityForPrincipals = jest.fn(methods.hasCapabilityForPrincipals);
const { hasCapability } = generateCapabilityCheck({
getUserPrincipals: methods.getUserPrincipals,
hasCapabilityForPrincipals,
});
await withinRequestContext(async () => {
const r1 = await hasCapability(regularUser, SystemCapabilities.MANAGE_USERS);
const r2 = await hasCapability(regularUser, SystemCapabilities.MANAGE_USERS);
expect(r1).toBe(false);
expect(r2).toBe(false);
});
const manageUserCalls = hasCapabilityForPrincipals.mock.calls.filter(
(args) => args[0].capability === SystemCapabilities.MANAGE_USERS,
);
expect(manageUserCalls).toHaveLength(1);
});
it('uses separate principal cache keys for different users in same context', async () => {
await methods.seedSystemGrants();
const getUserPrincipals = jest.fn(methods.getUserPrincipals);
const { hasCapability } = generateCapabilityCheck({
getUserPrincipals,
hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals,
});
await withinRequestContext(async () => {
await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN);
await hasCapability(regularUser, SystemCapabilities.ACCESS_ADMIN);
});
expect(getUserPrincipals).toHaveBeenCalledTimes(2);
});
it('uses separate principal cache keys for different tenantIds (same user)', async () => {
await methods.seedSystemGrants();
const getUserPrincipals = jest.fn(methods.getUserPrincipals);
const { hasCapability } = generateCapabilityCheck({
getUserPrincipals,
hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals,
});
await withinRequestContext(async () => {
await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN);
await hasCapability(
{ ...adminUser, tenantId: 'tenant-a' },
SystemCapabilities.ACCESS_ADMIN,
);
});
expect(getUserPrincipals).toHaveBeenCalledTimes(2);
});
});
describe('requireCapability middleware (real DB, real ALS)', () => {
it('calls next() for granted capability inside request context', async () => {
await methods.seedSystemGrants();
const { requireCapability } = generateCapabilityCheck({
getUserPrincipals: methods.getUserPrincipals,
hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals,
});
const middleware = requireCapability(SystemCapabilities.ACCESS_ADMIN);
const next = jest.fn();
const jsonMock = jest.fn();
const statusMock = jest.fn().mockReturnValue({ json: jsonMock });
const req = { user: { id: adminUser.id, role: adminUser.role } };
const res = { status: statusMock };
await withinRequestContext(async () => {
await middleware(req as never, res as never, next);
});
expect(next).toHaveBeenCalled();
expect(statusMock).not.toHaveBeenCalled();
});
it('returns 403 for denied capability inside request context', async () => {
await methods.seedSystemGrants();
const { requireCapability } = generateCapabilityCheck({
getUserPrincipals: methods.getUserPrincipals,
hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals,
});
const middleware = requireCapability(SystemCapabilities.MANAGE_USERS);
const next = jest.fn();
const jsonMock = jest.fn();
const statusMock = jest.fn().mockReturnValue({ json: jsonMock });
const req = { user: { id: regularUser.id, role: regularUser.role } };
const res = { status: statusMock };
await withinRequestContext(async () => {
await middleware(req as never, res as never, next);
});
expect(next).not.toHaveBeenCalled();
expect(statusMock).toHaveBeenCalledWith(403);
});
});
describe('ALS edge cases', () => {
it('returns correct results when ALS context is missing (background job / child process)', async () => {
await methods.seedSystemGrants();
const { hasCapability } = generateCapabilityCheck({
getUserPrincipals: methods.getUserPrincipals,
hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals,
});
expect(capabilityStore.getStore()).toBeUndefined();
const adminResult = await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN);
const userResult = await hasCapability(regularUser, SystemCapabilities.ACCESS_ADMIN);
expect(adminResult).toBe(true);
expect(userResult).toBe(false);
});
it('every DB call executes (no caching) when ALS context is missing', async () => {
await methods.seedSystemGrants();
const getUserPrincipals = jest.fn(methods.getUserPrincipals);
const hasCapabilityForPrincipals = jest.fn(methods.hasCapabilityForPrincipals);
const { hasCapability } = generateCapabilityCheck({
getUserPrincipals,
hasCapabilityForPrincipals,
});
expect(capabilityStore.getStore()).toBeUndefined();
await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN);
await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN);
await hasCapability(adminUser, SystemCapabilities.MANAGE_USERS);
expect(getUserPrincipals).toHaveBeenCalledTimes(3);
expect(hasCapabilityForPrincipals).toHaveBeenCalledTimes(3);
});
it('nested capabilityContextMiddleware creates an independent inner context', async () => {
await methods.seedSystemGrants();
const getUserPrincipals = jest.fn(methods.getUserPrincipals);
const { hasCapability } = generateCapabilityCheck({
getUserPrincipals,
hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals,
});
await withinRequestContext(async () => {
await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN);
await withinRequestContext(async () => {
await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN);
});
await hasCapability(adminUser, SystemCapabilities.MANAGE_USERS);
});
/**
* Outer context: 1 call (ACCESS_ADMIN) principals cached, MANAGE_USERS reuses them.
* Inner context: 1 call (ACCESS_ADMIN) fresh context, no cache from outer.
* Total: 2 getUserPrincipals calls.
*/
expect(getUserPrincipals).toHaveBeenCalledTimes(2);
});
it('store.results.set with undefined store is a no-op (optional chaining safety)', async () => {
await methods.seedSystemGrants();
const { hasCapability } = generateCapabilityCheck({
getUserPrincipals: methods.getUserPrincipals,
hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals,
});
expect(capabilityStore.getStore()).toBeUndefined();
await expect(hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN)).resolves.toBe(true);
});
it('grant change mid-request is invisible due to result caching', async () => {
await methods.seedSystemGrants();
const { hasCapability } = generateCapabilityCheck({
getUserPrincipals: methods.getUserPrincipals,
hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals,
});
await withinRequestContext(async () => {
const before = await hasCapability(regularUser, SystemCapabilities.READ_AGENTS);
expect(before).toBe(false);
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: regularUser.id,
capability: SystemCapabilities.READ_AGENTS,
});
const after = await hasCapability(regularUser, SystemCapabilities.READ_AGENTS);
expect(after).toBe(false);
});
const afterContext = await hasCapability(regularUser, SystemCapabilities.READ_AGENTS);
expect(afterContext).toBe(true);
});
it('requireCapability works correctly without ALS context', async () => {
await methods.seedSystemGrants();
const { requireCapability } = generateCapabilityCheck({
getUserPrincipals: methods.getUserPrincipals,
hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals,
});
expect(capabilityStore.getStore()).toBeUndefined();
const middleware = requireCapability(SystemCapabilities.ACCESS_ADMIN);
const next = jest.fn();
const jsonMock = jest.fn();
const statusMock = jest.fn().mockReturnValue({ json: jsonMock });
const req = { user: { id: adminUser.id, role: adminUser.role } };
const res = { status: statusMock };
await middleware(req as never, res as never, next);
expect(next).toHaveBeenCalled();
expect(statusMock).not.toHaveBeenCalled();
});
it('concurrent contexts with interleaved awaits maintain isolation', async () => {
await methods.seedSystemGrants();
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: regularUser.id,
capability: SystemCapabilities.READ_AGENTS,
});
const getUserPrincipals = jest.fn(methods.getUserPrincipals);
const { hasCapability } = generateCapabilityCheck({
getUserPrincipals,
hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals,
});
let adminResolve: () => void;
const adminGate = new Promise<void>((r) => {
adminResolve = r;
});
let userResolve: () => void;
const userGate = new Promise<void>((r) => {
userResolve = r;
});
const adminPromise = withinRequestContext(async () => {
const r1 = await hasCapability(adminUser, SystemCapabilities.ACCESS_ADMIN);
adminResolve!();
await userGate;
const r2 = await hasCapability(adminUser, SystemCapabilities.READ_AGENTS);
return { r1, r2 };
});
const userPromise = withinRequestContext(async () => {
await adminGate;
const r1 = await hasCapability(regularUser, SystemCapabilities.ACCESS_ADMIN);
userResolve!();
const r2 = await hasCapability(regularUser, SystemCapabilities.READ_AGENTS);
return { r1, r2 };
});
const [adminResults, userResults] = await Promise.all([adminPromise, userPromise]);
expect(adminResults.r1).toBe(true);
expect(adminResults.r2).toBe(true);
expect(userResults.r1).toBe(false);
expect(userResults.r2).toBe(true);
expect(getUserPrincipals).toHaveBeenCalledTimes(2);
});
});
describe('CapabilityImplications consistency', () => {
it('every implication pair resolves correctly through the full stack', async () => {
const pairs = Object.entries(CapabilityImplications) as [
SystemCapability,
SystemCapability[],
][];
const { hasCapability } = generateCapabilityCheck({
getUserPrincipals: methods.getUserPrincipals,
hasCapabilityForPrincipals: methods.hasCapabilityForPrincipals,
});
for (const [broadCap, impliedCaps] of pairs) {
await mongoose.connection.dropDatabase();
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: regularUser.id,
capability: broadCap,
});
for (const impliedCap of impliedCaps) {
const result = await hasCapability(regularUser, impliedCap);
expect(result).toBe(true);
}
const hasBroad = await hasCapability(regularUser, broadCap);
expect(hasBroad).toBe(true);
}
});
});
});

View file

@ -0,0 +1,212 @@
import { PrincipalType } from 'librechat-data-provider';
import {
configCapability,
SystemCapabilities,
readConfigCapability,
} from '@librechat/data-schemas';
import type { Response } from 'express';
import type { ServerRequest } from '~/types/http';
import { generateCapabilityCheck } from './capabilities';
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
error: jest.fn(),
},
}));
const adminPrincipals = [
{ principalType: PrincipalType.USER, principalId: 'user-123' },
{ principalType: PrincipalType.ROLE, principalId: 'ADMIN' },
{ principalType: PrincipalType.PUBLIC },
];
const userPrincipals = [
{ principalType: PrincipalType.USER, principalId: 'user-456' },
{ principalType: PrincipalType.ROLE, principalId: 'USER' },
{ principalType: PrincipalType.PUBLIC },
];
describe('generateCapabilityCheck', () => {
const mockGetUserPrincipals = jest.fn();
const mockHasCapabilityForPrincipals = jest.fn();
const { hasCapability, requireCapability, hasConfigCapability } = generateCapabilityCheck({
getUserPrincipals: mockGetUserPrincipals,
hasCapabilityForPrincipals: mockHasCapabilityForPrincipals,
});
beforeEach(() => {
mockGetUserPrincipals.mockReset();
mockHasCapabilityForPrincipals.mockReset();
});
describe('hasCapability', () => {
it('returns true for a user with the capability', async () => {
mockGetUserPrincipals.mockResolvedValue(adminPrincipals);
mockHasCapabilityForPrincipals.mockResolvedValue(true);
const result = await hasCapability(
{ id: 'user-123', role: 'ADMIN' },
SystemCapabilities.ACCESS_ADMIN,
);
expect(result).toBe(true);
expect(mockGetUserPrincipals).toHaveBeenCalledWith({ userId: 'user-123', role: 'ADMIN' });
expect(mockHasCapabilityForPrincipals).toHaveBeenCalledWith({
principals: adminPrincipals,
capability: SystemCapabilities.ACCESS_ADMIN,
tenantId: undefined,
});
});
it('returns false for a user without the capability', async () => {
mockGetUserPrincipals.mockResolvedValue(userPrincipals);
mockHasCapabilityForPrincipals.mockResolvedValue(false);
const result = await hasCapability(
{ id: 'user-456', role: 'USER' },
SystemCapabilities.MANAGE_USERS,
);
expect(result).toBe(false);
});
it('passes tenantId when present on user', async () => {
mockGetUserPrincipals.mockResolvedValue(adminPrincipals);
mockHasCapabilityForPrincipals.mockResolvedValue(true);
await hasCapability(
{ id: 'user-123', role: 'ADMIN', tenantId: 'tenant-1' },
SystemCapabilities.READ_CONFIGS,
);
expect(mockHasCapabilityForPrincipals).toHaveBeenCalledWith(
expect.objectContaining({ tenantId: 'tenant-1' }),
);
});
});
describe('requireCapability', () => {
let mockReq: Partial<ServerRequest>;
let mockRes: Partial<Response>;
let mockNext: jest.Mock;
let jsonMock: jest.Mock;
let statusMock: jest.Mock;
beforeEach(() => {
jsonMock = jest.fn();
statusMock = jest.fn().mockReturnValue({ json: jsonMock });
mockReq = {
user: { id: 'user-123', role: 'ADMIN' } as ServerRequest['user'],
};
mockRes = { status: statusMock };
mockNext = jest.fn();
});
it('calls next() when user has the capability', async () => {
mockGetUserPrincipals.mockResolvedValue(adminPrincipals);
mockHasCapabilityForPrincipals.mockResolvedValue(true);
const middleware = requireCapability(SystemCapabilities.ACCESS_ADMIN);
await middleware(mockReq as ServerRequest, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenCalled();
expect(statusMock).not.toHaveBeenCalled();
});
it('returns 403 when user lacks the capability', async () => {
mockReq.user = { id: 'user-456', role: 'USER' } as ServerRequest['user'];
mockGetUserPrincipals.mockResolvedValue(userPrincipals);
mockHasCapabilityForPrincipals.mockResolvedValue(false);
const middleware = requireCapability(SystemCapabilities.MANAGE_USERS);
await middleware(mockReq as ServerRequest, mockRes as Response, mockNext);
expect(mockNext).not.toHaveBeenCalled();
expect(statusMock).toHaveBeenCalledWith(403);
expect(jsonMock).toHaveBeenCalledWith({ message: 'Forbidden' });
});
it('returns 401 when no user is present', async () => {
mockReq.user = undefined;
const middleware = requireCapability(SystemCapabilities.ACCESS_ADMIN);
await middleware(mockReq as ServerRequest, mockRes as Response, mockNext);
expect(mockNext).not.toHaveBeenCalled();
expect(statusMock).toHaveBeenCalledWith(401);
expect(jsonMock).toHaveBeenCalledWith({ message: 'Authentication required' });
});
it('returns 500 on unexpected error', async () => {
mockGetUserPrincipals.mockRejectedValue(new Error('DB down'));
const middleware = requireCapability(SystemCapabilities.ACCESS_ADMIN);
await middleware(mockReq as ServerRequest, mockRes as Response, mockNext);
expect(mockNext).not.toHaveBeenCalled();
expect(statusMock).toHaveBeenCalledWith(500);
expect(jsonMock).toHaveBeenCalledWith({ message: 'Internal Server Error' });
});
});
describe('hasConfigCapability', () => {
const adminUser = { id: 'user-123', role: 'ADMIN' };
const delegatedUser = { id: 'user-789', role: 'MANAGER' };
it('returns true when user has broad manage:configs capability', async () => {
mockGetUserPrincipals.mockResolvedValue(adminPrincipals);
mockHasCapabilityForPrincipals.mockResolvedValue(true);
const result = await hasConfigCapability(adminUser, 'endpoints');
expect(result).toBe(true);
expect(mockHasCapabilityForPrincipals).toHaveBeenCalledWith(
expect.objectContaining({ capability: SystemCapabilities.MANAGE_CONFIGS }),
);
});
it('falls back to section-specific capability when broad check fails', async () => {
mockGetUserPrincipals.mockResolvedValue(userPrincipals);
// First call (broad) returns false, second call (section) returns true
mockHasCapabilityForPrincipals.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
const result = await hasConfigCapability(delegatedUser, 'endpoints');
expect(result).toBe(true);
expect(mockHasCapabilityForPrincipals).toHaveBeenCalledTimes(2);
expect(mockHasCapabilityForPrincipals).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ capability: configCapability('endpoints') }),
);
});
it('returns false when user has neither broad nor section capability', async () => {
mockGetUserPrincipals.mockResolvedValue(userPrincipals);
mockHasCapabilityForPrincipals.mockResolvedValue(false);
const result = await hasConfigCapability(delegatedUser, 'balance');
expect(result).toBe(false);
});
it('checks read:configs when verb is "read"', async () => {
mockGetUserPrincipals.mockResolvedValue(userPrincipals);
mockHasCapabilityForPrincipals.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
const result = await hasConfigCapability(delegatedUser, 'endpoints', 'read');
expect(result).toBe(true);
expect(mockHasCapabilityForPrincipals).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ capability: SystemCapabilities.READ_CONFIGS }),
);
expect(mockHasCapabilityForPrincipals).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ capability: readConfigCapability('endpoints') }),
);
});
});
});

View file

@ -0,0 +1,188 @@
import { isMainThread } from 'node:worker_threads';
import { AsyncLocalStorage } from 'node:async_hooks';
import {
logger,
configCapability,
SystemCapabilities,
readConfigCapability,
} from '@librechat/data-schemas';
import type { PrincipalType } from 'librechat-data-provider';
import type { SystemCapability, ConfigSection } from '@librechat/data-schemas';
import type { NextFunction, Response } from 'express';
import type { Types } from 'mongoose';
import type { ServerRequest } from '~/types/http';
interface ResolvedPrincipal {
principalType: PrincipalType;
principalId?: string | Types.ObjectId;
}
interface CapabilityDeps {
getUserPrincipals: (params: { userId: string; role: string }) => Promise<ResolvedPrincipal[]>;
hasCapabilityForPrincipals: (params: {
principals: ResolvedPrincipal[];
capability: SystemCapability;
tenantId?: string;
}) => Promise<boolean>;
}
interface CapabilityUser {
id: string;
role: string;
tenantId?: string;
}
interface CapabilityStore {
principals: Map<string, ResolvedPrincipal[]>;
results: Map<string, boolean>;
}
export type HasCapabilityFn = (
user: CapabilityUser,
capability: SystemCapability,
) => Promise<boolean>;
export type RequireCapabilityFn = (
capability: SystemCapability,
) => (req: ServerRequest, res: Response, next: NextFunction) => Promise<void>;
export type HasConfigCapabilityFn = (
user: CapabilityUser,
section: ConfigSection,
verb?: 'manage' | 'read',
) => Promise<boolean>;
/**
* Per-request store for caching resolved principals and capability check results.
* When running inside an Express request (via `capabilityContextMiddleware`),
* duplicate `hasCapability` calls within the same request are served from
* the in-memory Map instead of hitting the database again.
* Outside a request context (background jobs, tests), the store is undefined
* and every check falls through to the database correct behavior.
*/
export const capabilityStore = new AsyncLocalStorage<CapabilityStore>();
export function capabilityContextMiddleware(
_req: ServerRequest,
_res: Response,
next: NextFunction,
): void {
if (!isMainThread) {
logger.error(
'[capabilityContextMiddleware] Mounted in a worker thread — ' +
'ALS context will not propagate to the main thread or other workers. ' +
'This middleware should only run in the main Express process.',
);
}
capabilityStore.run({ principals: new Map(), results: new Map() }, next);
}
/**
* Factory that creates `hasCapability` and `requireCapability` with injected
* database methods. Follows the same dependency-injection pattern as
* `generateCheckAccess`.
*/
export function generateCapabilityCheck(deps: CapabilityDeps): {
hasCapability: HasCapabilityFn;
requireCapability: RequireCapabilityFn;
hasConfigCapability: HasConfigCapabilityFn;
} {
const { getUserPrincipals, hasCapabilityForPrincipals } = deps;
let workerWarned = false;
async function hasCapability(
user: CapabilityUser,
capability: SystemCapability,
): Promise<boolean> {
if (!isMainThread && !workerWarned) {
workerWarned = true;
logger.warn(
'[hasCapability] Called from a worker thread — ALS context is unavailable. ' +
'Capability checks will hit the database on every call (no per-request caching). ' +
'If this is intentional, no action needed.',
);
}
const store = capabilityStore.getStore();
const resultKey = `${user.id}:${user.tenantId ?? ''}:${capability}`;
const cached = store?.results.get(resultKey);
if (cached !== undefined) {
return cached;
}
const principalKey = `${user.id}:${user.role}:${user.tenantId ?? ''}`;
let principals: ResolvedPrincipal[];
const cachedPrincipals = store?.principals.get(principalKey);
if (cachedPrincipals) {
principals = cachedPrincipals;
} else {
principals = await getUserPrincipals({ userId: user.id, role: user.role });
store?.principals.set(principalKey, principals);
}
const result = await hasCapabilityForPrincipals({
principals,
capability,
tenantId: user.tenantId,
});
store?.results.set(resultKey, result);
return result;
}
/**
* Checks if a user can manage or read a specific config section.
* First checks the broad capability (manage:configs / read:configs),
* then falls back to the section-specific capability (manage:configs:<section>).
*/
async function hasConfigCapability(
user: CapabilityUser,
section: ConfigSection,
verb: 'manage' | 'read' = 'manage',
): Promise<boolean> {
const broadCap =
verb === 'manage' ? SystemCapabilities.MANAGE_CONFIGS : SystemCapabilities.READ_CONFIGS;
if (await hasCapability(user, broadCap)) {
return true;
}
const sectionCap =
verb === 'manage' ? configCapability(section) : readConfigCapability(section);
return hasCapability(user, sectionCap);
}
function requireCapability(capability: SystemCapability) {
return async (req: ServerRequest, res: Response, next: NextFunction) => {
try {
if (!req.user) {
res.status(401).json({ message: 'Authentication required' });
return;
}
const id = req.user.id ?? req.user._id?.toString();
if (!id) {
res.status(401).json({ message: 'Authentication required' });
return;
}
const user: CapabilityUser = {
id,
role: req.user.role ?? '',
tenantId: (req.user as CapabilityUser).tenantId,
};
if (await hasCapability(user, capability)) {
next();
return;
}
res.status(403).json({ message: 'Forbidden' });
} catch (err) {
logger.error(`[requireCapability] Error checking capability: ${capability}`, err);
res.status(500).json({ message: 'Internal Server Error' });
}
};
}
return { hasCapability, requireCapability, hasConfigCapability };
}

View file

@ -4,5 +4,6 @@ export * from './error';
export * from './notFound';
export * from './balance';
export * from './json';
export * from './capabilities';
export * from './concurrency';
export * from './checkBalance';

View file

@ -1,4 +1,5 @@
export * from './app';
export * from './systemCapabilities';
export * from './common';
export * from './crypto';
export * from './schema';

View file

@ -959,4 +959,285 @@ describe('AclEntry Model Tests', () => {
expect(permissionsMap.get(resource2.toString())).toBe(PermissionBits.EDIT);
});
});
describe('deleteAclEntries', () => {
test('should delete entries matching the filter', async () => {
await methods.grantPermission(
PrincipalType.USER,
userId,
ResourceType.AGENT,
resourceId,
PermissionBits.VIEW,
grantedById,
);
await methods.grantPermission(
PrincipalType.USER,
userId,
ResourceType.MCPSERVER,
resourceId,
PermissionBits.EDIT,
grantedById,
);
const result = await methods.deleteAclEntries({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
});
expect(result.deletedCount).toBe(1);
const remaining = await AclEntry.countDocuments({ principalId: userId });
expect(remaining).toBe(1);
});
test('should delete all entries when filter matches multiple', async () => {
await methods.grantPermission(
PrincipalType.USER,
userId,
ResourceType.AGENT,
new mongoose.Types.ObjectId(),
PermissionBits.VIEW,
grantedById,
);
await methods.grantPermission(
PrincipalType.USER,
userId,
ResourceType.AGENT,
new mongoose.Types.ObjectId(),
PermissionBits.EDIT,
grantedById,
);
const result = await methods.deleteAclEntries({
principalType: PrincipalType.USER,
principalId: userId,
});
expect(result.deletedCount).toBe(2);
});
test('should return zero deletedCount when no match', async () => {
const result = await methods.deleteAclEntries({
principalId: new mongoose.Types.ObjectId(),
});
expect(result.deletedCount).toBe(0);
});
});
describe('bulkWriteAclEntries', () => {
test('should perform bulk inserts', async () => {
const res1 = new mongoose.Types.ObjectId();
const res2 = new mongoose.Types.ObjectId();
const result = await methods.bulkWriteAclEntries([
{
insertOne: {
document: {
principalType: PrincipalType.USER,
principalId: userId,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: res1,
permBits: PermissionBits.VIEW,
grantedBy: grantedById,
grantedAt: new Date(),
},
},
},
{
insertOne: {
document: {
principalType: PrincipalType.USER,
principalId: userId,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: res2,
permBits: PermissionBits.EDIT,
grantedBy: grantedById,
grantedAt: new Date(),
},
},
},
]);
expect(result.insertedCount).toBe(2);
const entries = await AclEntry.countDocuments({ principalId: userId });
expect(entries).toBe(2);
});
test('should perform bulk updates', async () => {
await methods.grantPermission(
PrincipalType.USER,
userId,
ResourceType.AGENT,
resourceId,
PermissionBits.VIEW,
grantedById,
);
await methods.bulkWriteAclEntries([
{
updateOne: {
filter: {
principalType: PrincipalType.USER,
principalId: userId,
resourceId,
},
update: { $set: { permBits: PermissionBits.VIEW | PermissionBits.EDIT } },
},
},
]);
const entry = await AclEntry.findOne({ principalId: userId, resourceId }).lean();
expect(entry?.permBits).toBe(PermissionBits.VIEW | PermissionBits.EDIT);
});
});
describe('findPublicResourceIds', () => {
test('should find resources with public VIEW access', async () => {
const publicRes1 = new mongoose.Types.ObjectId();
const publicRes2 = new mongoose.Types.ObjectId();
const privateRes = new mongoose.Types.ObjectId();
await methods.grantPermission(
PrincipalType.PUBLIC,
null,
ResourceType.AGENT,
publicRes1,
PermissionBits.VIEW,
grantedById,
);
await methods.grantPermission(
PrincipalType.PUBLIC,
null,
ResourceType.AGENT,
publicRes2,
PermissionBits.VIEW | PermissionBits.EDIT,
grantedById,
);
await methods.grantPermission(
PrincipalType.USER,
userId,
ResourceType.AGENT,
privateRes,
PermissionBits.VIEW,
grantedById,
);
const publicIds = await methods.findPublicResourceIds(
ResourceType.AGENT,
PermissionBits.VIEW,
);
expect(publicIds).toHaveLength(2);
const idStrings = publicIds.map((id) => id.toString()).sort();
expect(idStrings).toEqual([publicRes1.toString(), publicRes2.toString()].sort());
});
test('should filter by required permission bits', async () => {
const viewOnly = new mongoose.Types.ObjectId();
const viewEdit = new mongoose.Types.ObjectId();
await methods.grantPermission(
PrincipalType.PUBLIC,
null,
ResourceType.AGENT,
viewOnly,
PermissionBits.VIEW,
grantedById,
);
await methods.grantPermission(
PrincipalType.PUBLIC,
null,
ResourceType.AGENT,
viewEdit,
PermissionBits.VIEW | PermissionBits.EDIT,
grantedById,
);
const editableIds = await methods.findPublicResourceIds(
ResourceType.AGENT,
PermissionBits.EDIT,
);
expect(editableIds).toHaveLength(1);
expect(editableIds[0].toString()).toBe(viewEdit.toString());
});
test('should return empty array when no public resources exist', async () => {
const ids = await methods.findPublicResourceIds(ResourceType.AGENT, PermissionBits.VIEW);
expect(ids).toEqual([]);
});
test('should filter by resource type', async () => {
const agentRes = new mongoose.Types.ObjectId();
const mcpRes = new mongoose.Types.ObjectId();
await methods.grantPermission(
PrincipalType.PUBLIC,
null,
ResourceType.AGENT,
agentRes,
PermissionBits.VIEW,
grantedById,
);
await methods.grantPermission(
PrincipalType.PUBLIC,
null,
ResourceType.MCPSERVER,
mcpRes,
PermissionBits.VIEW,
grantedById,
);
const agentIds = await methods.findPublicResourceIds(ResourceType.AGENT, PermissionBits.VIEW);
expect(agentIds).toHaveLength(1);
expect(agentIds[0].toString()).toBe(agentRes.toString());
});
});
describe('aggregateAclEntries', () => {
test('should run an aggregation pipeline and return results', async () => {
await methods.grantPermission(
PrincipalType.USER,
userId,
ResourceType.AGENT,
resourceId,
PermissionBits.VIEW,
grantedById,
);
await methods.grantPermission(
PrincipalType.GROUP,
groupId,
ResourceType.AGENT,
resourceId,
PermissionBits.EDIT,
grantedById,
);
await methods.grantPermission(
PrincipalType.USER,
userId,
ResourceType.MCPSERVER,
new mongoose.Types.ObjectId(),
PermissionBits.VIEW,
grantedById,
);
const results = await methods.aggregateAclEntries([
{ $group: { _id: '$resourceType', count: { $sum: 1 } } },
{ $sort: { _id: 1 } },
]);
expect(results).toHaveLength(2);
const agentResult = results.find((r: { _id: string }) => r._id === ResourceType.AGENT);
expect(agentResult.count).toBe(2);
});
test('should return empty array for non-matching pipeline', async () => {
const results = await methods.aggregateAclEntries([
{ $match: { principalType: 'nonexistent' } },
]);
expect(results).toEqual([]);
});
});
});

View file

@ -20,6 +20,7 @@ import { createPluginAuthMethods, type PluginAuthMethods } from './pluginAuth';
import { createAccessRoleMethods, type AccessRoleMethods } from './accessRole';
import { createUserGroupMethods, type UserGroupMethods } from './userGroup';
import { createAclEntryMethods, type AclEntryMethods } from './aclEntry';
import { createSystemGrantMethods, type SystemGrantMethods } from './systemGrant';
import { createShareMethods, type ShareMethods } from './share';
/* Tier 1 — Simple CRUD */
import { createActionMethods, type ActionMethods } from './action';
@ -62,6 +63,7 @@ export type AllMethods = UserMethods &
MCPServerMethods &
UserGroupMethods &
AclEntryMethods &
SystemGrantMethods &
ShareMethods &
AccessRoleMethods &
PluginAuthMethods &
@ -133,6 +135,8 @@ export function createMethods(
// ACL entry methods (used internally for removeAllPermissions)
const aclEntryMethods = createAclEntryMethods(mongoose);
const systemGrantMethods = createSystemGrantMethods(mongoose);
// Internal removeAllPermissions: use deleteAclEntries from aclEntryMethods
// instead of requiring it as an external dep from PermissionService
const removeAllPermissions =
@ -172,6 +176,7 @@ export function createMethods(
...createAccessRoleMethods(mongoose),
...createUserGroupMethods(mongoose),
...aclEntryMethods,
...systemGrantMethods,
...createShareMethods(mongoose),
...createPluginAuthMethods(mongoose),
/* Tier 1 */
@ -208,6 +213,7 @@ export type {
MCPServerMethods,
UserGroupMethods,
AclEntryMethods,
SystemGrantMethods,
ShareMethods,
AccessRoleMethods,
PluginAuthMethods,

View file

@ -582,8 +582,6 @@ describe('Prompt ACL Permissions', () => {
await methods.deletePrompt({
promptId: testPromptId,
groupId: testPromptGroup._id,
author: testUsers.owner._id,
role: SystemRoles.USER,
});
// Verify ACL entries are removed

View file

@ -1,5 +1,5 @@
import type { Model, Types } from 'mongoose';
import { SystemRoles, ResourceType, SystemCategories } from 'librechat-data-provider';
import { ResourceType, SystemCategories } from 'librechat-data-provider';
import type { IPrompt, IPromptGroup, IPromptGroupDocument } from '~/types';
import { escapeRegExp } from '~/utils/string';
import logger from '~/config/winston';
@ -144,27 +144,18 @@ export function createPromptMethods(mongoose: typeof import('mongoose'), deps: P
/**
* Delete a prompt group and its prompts, cleaning up ACL permissions.
*
* **Authorization is enforced upstream.** This method performs no ownership
* check it deletes any group by ID. Callers must gate access via
* `canAccessPromptGroupResource` middleware before invoking this.
*/
async function deletePromptGroup({
_id,
author,
role,
}: {
_id: string;
author?: string;
role?: string;
}) {
async function deletePromptGroup({ _id }: { _id: string }) {
const PromptGroup = mongoose.models.PromptGroup as Model<IPromptGroupDocument>;
const Prompt = mongoose.models.Prompt as Model<IPrompt>;
const query: Record<string, unknown> = { _id };
const groupQuery: Record<string, unknown> = { groupId: new ObjectId(_id) };
if (author && role !== SystemRoles.ADMIN) {
query.author = author;
groupQuery.author = author;
}
const response = await PromptGroup.deleteOne(query);
if (!response || response.deletedCount === 0) {
@ -472,25 +463,22 @@ export function createPromptMethods(mongoose: typeof import('mongoose'), deps: P
/**
* Delete a prompt, potentially removing the group if it's the last prompt.
*
* **Authorization is enforced upstream.** This method performs no ownership
* check it deletes any prompt by ID. Callers must gate access via
* `canAccessPromptViaGroup` middleware before invoking this.
*/
async function deletePrompt({
promptId,
groupId,
author,
role,
}: {
promptId: string | Types.ObjectId;
groupId: string | Types.ObjectId;
author: string | Types.ObjectId;
role?: string;
}) {
const Prompt = mongoose.models.Prompt as Model<IPrompt>;
const PromptGroup = mongoose.models.PromptGroup as Model<IPromptGroupDocument>;
const query: Record<string, unknown> = { _id: promptId, groupId, author };
if (role === SystemRoles.ADMIN) {
delete query.author;
}
const query: Record<string, unknown> = { _id: promptId, groupId };
const { deletedCount } = await Prompt.deleteOne(query);
if (deletedCount === 0) {
throw new Error('Failed to delete the prompt');

View file

@ -0,0 +1,840 @@
import mongoose, { Types } from 'mongoose';
import { PrincipalType, SystemRoles } from 'librechat-data-provider';
import { MongoMemoryServer } from 'mongodb-memory-server';
import type * as t from '~/types';
import type { SystemCapability } from '~/systemCapabilities';
import { SystemCapabilities, CapabilityImplications } from '~/systemCapabilities';
import { createSystemGrantMethods } from './systemGrant';
import systemGrantSchema from '~/schema/systemGrant';
import logger from '~/config/winston';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
let mongoServer: MongoMemoryServer;
let SystemGrant: mongoose.Model<t.ISystemGrant>;
let methods: ReturnType<typeof createSystemGrantMethods>;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
SystemGrant =
mongoose.models.SystemGrant || mongoose.model<t.ISystemGrant>('SystemGrant', systemGrantSchema);
methods = createSystemGrantMethods(mongoose);
await mongoose.connect(mongoServer.getUri());
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await SystemGrant.deleteMany({});
});
describe('systemGrant methods', () => {
describe('seedSystemGrants', () => {
it('seeds every SystemCapabilities value for the ADMIN role', async () => {
await methods.seedSystemGrants();
const grants = await SystemGrant.find({
principalType: PrincipalType.ROLE,
principalId: SystemRoles.ADMIN,
}).lean();
const expected = Object.values(SystemCapabilities).sort();
const actual = grants.map((g) => g.capability).sort();
expect(actual).toEqual(expected);
});
it('is idempotent — duplicate calls produce no extra documents', async () => {
await methods.seedSystemGrants();
await methods.seedSystemGrants();
await methods.seedSystemGrants();
const count = await SystemGrant.countDocuments({
principalType: PrincipalType.ROLE,
principalId: SystemRoles.ADMIN,
});
expect(count).toBe(Object.values(SystemCapabilities).length);
});
it('seeds platform-level grants (no tenantId field)', async () => {
await methods.seedSystemGrants();
const withTenant = await SystemGrant.countDocuments({
principalType: PrincipalType.ROLE,
principalId: SystemRoles.ADMIN,
tenantId: { $exists: true },
});
expect(withTenant).toBe(0);
});
it('does not throw when called (try-catch protects startup)', async () => {
await expect(methods.seedSystemGrants()).resolves.not.toThrow();
});
it('retries on transient failure and succeeds', async () => {
jest.useFakeTimers();
jest.spyOn(SystemGrant, 'bulkWrite').mockRejectedValueOnce(new Error('disk full'));
const seedPromise = methods.seedSystemGrants();
await jest.advanceTimersByTimeAsync(5000);
await seedPromise;
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Attempt 1/3 failed'));
jest.useRealTimers();
});
it('logs error after all retries exhausted', async () => {
jest.useFakeTimers();
jest
.spyOn(SystemGrant, 'bulkWrite')
.mockRejectedValueOnce(new Error('disk full'))
.mockRejectedValueOnce(new Error('disk full'))
.mockRejectedValueOnce(new Error('disk full'));
const seedPromise = methods.seedSystemGrants();
await jest.advanceTimersByTimeAsync(10000);
await seedPromise;
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Failed to seed capabilities after all retries'),
expect.any(Error),
);
jest.useRealTimers();
});
});
describe('grantCapability', () => {
it('creates a grant and returns the document', async () => {
const userId = new Types.ObjectId();
const doc = await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: userId,
capability: SystemCapabilities.READ_USERS,
});
expect(doc).toBeTruthy();
expect(doc!.principalType).toBe(PrincipalType.USER);
expect(doc!.capability).toBe(SystemCapabilities.READ_USERS);
expect(doc!.grantedAt).toBeInstanceOf(Date);
});
it('is idempotent — second call does not create a duplicate', async () => {
const userId = new Types.ObjectId();
const params = {
principalType: PrincipalType.USER as const,
principalId: userId,
capability: SystemCapabilities.READ_USERS as SystemCapability,
};
await methods.grantCapability(params);
await methods.grantCapability(params);
const count = await SystemGrant.countDocuments({
principalType: PrincipalType.USER,
principalId: userId,
capability: SystemCapabilities.READ_USERS,
});
expect(count).toBe(1);
});
it('stores grantedBy when provided', async () => {
const userId = new Types.ObjectId();
const grantedBy = new Types.ObjectId();
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: userId,
capability: SystemCapabilities.READ_CONFIGS,
grantedBy,
});
const grant = await SystemGrant.findOne({
principalType: PrincipalType.USER,
principalId: userId,
}).lean();
expect(grant!.grantedBy!.toString()).toBe(grantedBy.toString());
});
it('stores tenant-scoped grants with tenantId field present', async () => {
const userId = new Types.ObjectId();
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: userId,
capability: SystemCapabilities.READ_USAGE,
tenantId: 'tenant-abc',
});
const grant = await SystemGrant.findOne({
principalType: PrincipalType.USER,
principalId: userId,
tenantId: 'tenant-abc',
}).lean();
expect(grant).toBeTruthy();
expect(grant!.tenantId).toBe('tenant-abc');
});
it('normalizes string userId to ObjectId for USER principal', async () => {
const userId = new Types.ObjectId();
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: userId.toString(),
capability: SystemCapabilities.READ_USERS,
});
const grant = await SystemGrant.findOne({ capability: SystemCapabilities.READ_USERS }).lean();
expect(grant!.principalId.toString()).toBe(userId.toString());
expect(grant!.principalId).toBeInstanceOf(Types.ObjectId);
});
it('normalizes string groupId to ObjectId for GROUP principal', async () => {
const groupId = new Types.ObjectId();
await methods.grantCapability({
principalType: PrincipalType.GROUP,
principalId: groupId.toString(),
capability: SystemCapabilities.READ_AGENTS,
});
const grant = await SystemGrant.findOne({
capability: SystemCapabilities.READ_AGENTS,
}).lean();
expect(grant!.principalId).toBeInstanceOf(Types.ObjectId);
});
it('keeps ROLE principalId as a string (no ObjectId cast)', async () => {
await methods.grantCapability({
principalType: PrincipalType.ROLE,
principalId: 'CUSTOM_ROLE',
capability: SystemCapabilities.READ_CONFIGS,
});
const grant = await SystemGrant.findOne({
principalType: PrincipalType.ROLE,
principalId: 'CUSTOM_ROLE',
}).lean();
expect(grant).toBeTruthy();
expect(typeof grant!.principalId).toBe('string');
});
it('allows same capability for same principal in different tenants', async () => {
const userId = new Types.ObjectId();
const params = {
principalType: PrincipalType.USER as const,
principalId: userId,
capability: SystemCapabilities.ACCESS_ADMIN as SystemCapability,
};
await methods.grantCapability({ ...params, tenantId: 'tenant-1' });
await methods.grantCapability({ ...params, tenantId: 'tenant-2' });
const count = await SystemGrant.countDocuments({
principalType: PrincipalType.USER,
principalId: userId,
capability: SystemCapabilities.ACCESS_ADMIN,
});
expect(count).toBe(2);
});
it('handles E11000 race condition — returns existing doc instead of throwing', async () => {
const userId = new Types.ObjectId();
const params = {
principalType: PrincipalType.USER as const,
principalId: userId,
capability: SystemCapabilities.READ_USERS as SystemCapability,
};
const original = await methods.grantCapability(params);
// Simulate a race: findOneAndUpdate upserts but hits a duplicate key
const model = mongoose.models.SystemGrant;
jest
.spyOn(model, 'findOneAndUpdate')
.mockRejectedValueOnce(
Object.assign(new Error('E11000 duplicate key error'), { code: 11000 }),
);
const result = await methods.grantCapability(params);
expect(result).toBeTruthy();
expect(result!.capability).toBe(SystemCapabilities.READ_USERS);
expect(result!.principalId.toString()).toBe(original!.principalId.toString());
});
it('re-throws non-E11000 errors from findOneAndUpdate', async () => {
const model = mongoose.models.SystemGrant;
jest.spyOn(model, 'findOneAndUpdate').mockRejectedValueOnce(new Error('connection timeout'));
await expect(
methods.grantCapability({
principalType: PrincipalType.USER,
principalId: new Types.ObjectId(),
capability: SystemCapabilities.READ_USERS,
}),
).rejects.toThrow('connection timeout');
});
it('throws TypeError for invalid ObjectId string on USER principal', async () => {
await expect(
methods.grantCapability({
principalType: PrincipalType.USER,
principalId: 'not-a-valid-objectid',
capability: SystemCapabilities.READ_USERS,
}),
).rejects.toThrow(TypeError);
});
it('throws TypeError for invalid ObjectId string on GROUP principal', async () => {
await expect(
methods.grantCapability({
principalType: PrincipalType.GROUP,
principalId: 'also-invalid',
capability: SystemCapabilities.READ_AGENTS,
}),
).rejects.toThrow(TypeError);
});
it('accepts any string for ROLE principal without ObjectId validation', async () => {
const doc = await methods.grantCapability({
principalType: PrincipalType.ROLE,
principalId: 'ANY_STRING_HERE',
capability: SystemCapabilities.READ_CONFIGS,
});
expect(doc).toBeTruthy();
expect(doc!.principalId).toBe('ANY_STRING_HERE');
});
});
describe('revokeCapability', () => {
it('removes the grant document', async () => {
const userId = new Types.ObjectId();
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: userId,
capability: SystemCapabilities.READ_USERS,
});
await methods.revokeCapability({
principalType: PrincipalType.USER,
principalId: userId,
capability: SystemCapabilities.READ_USERS,
});
const grant = await SystemGrant.findOne({
principalType: PrincipalType.USER,
principalId: userId,
}).lean();
expect(grant).toBeNull();
});
it('is a no-op when the grant does not exist', async () => {
await expect(
methods.revokeCapability({
principalType: PrincipalType.USER,
principalId: new Types.ObjectId(),
capability: SystemCapabilities.MANAGE_USERS,
}),
).resolves.not.toThrow();
});
it('normalizes string userId when revoking', async () => {
const userId = new Types.ObjectId();
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: userId,
capability: SystemCapabilities.READ_USAGE,
});
await methods.revokeCapability({
principalType: PrincipalType.USER,
principalId: userId.toString(),
capability: SystemCapabilities.READ_USAGE,
});
const count = await SystemGrant.countDocuments({
principalType: PrincipalType.USER,
principalId: userId,
});
expect(count).toBe(0);
});
it('only revokes the specified tenant grant', async () => {
const userId = new Types.ObjectId();
const params = {
principalType: PrincipalType.USER as const,
principalId: userId,
capability: SystemCapabilities.READ_CONFIGS as SystemCapability,
};
await methods.grantCapability({ ...params, tenantId: 'tenant-1' });
await methods.grantCapability({ ...params, tenantId: 'tenant-2' });
await methods.revokeCapability({ ...params, tenantId: 'tenant-1' });
const remaining = await SystemGrant.find({
principalType: PrincipalType.USER,
principalId: userId,
}).lean();
expect(remaining).toHaveLength(1);
expect(remaining[0].tenantId).toBe('tenant-2');
});
it('throws TypeError for invalid ObjectId string on USER principal', async () => {
await expect(
methods.revokeCapability({
principalType: PrincipalType.USER,
principalId: 'bad-id',
capability: SystemCapabilities.READ_USERS,
}),
).rejects.toThrow(TypeError);
});
});
describe('hasCapabilityForPrincipals', () => {
it('returns true when a role principal holds the capability', async () => {
await methods.seedSystemGrants();
const result = await methods.hasCapabilityForPrincipals({
principals: [
{ principalType: PrincipalType.USER, principalId: new Types.ObjectId() },
{ principalType: PrincipalType.ROLE, principalId: SystemRoles.ADMIN },
{ principalType: PrincipalType.PUBLIC },
],
capability: SystemCapabilities.ACCESS_ADMIN,
});
expect(result).toBe(true);
});
it('returns false when no principal has the capability', async () => {
const result = await methods.hasCapabilityForPrincipals({
principals: [
{ principalType: PrincipalType.USER, principalId: new Types.ObjectId() },
{ principalType: PrincipalType.ROLE, principalId: SystemRoles.USER },
{ principalType: PrincipalType.PUBLIC },
],
capability: SystemCapabilities.ACCESS_ADMIN,
});
expect(result).toBe(false);
});
it('returns false for an empty principals array', async () => {
const result = await methods.hasCapabilityForPrincipals({
principals: [],
capability: SystemCapabilities.ACCESS_ADMIN,
});
expect(result).toBe(false);
});
it('returns false when only PUBLIC principals are present', async () => {
const result = await methods.hasCapabilityForPrincipals({
principals: [{ principalType: PrincipalType.PUBLIC }],
capability: SystemCapabilities.ACCESS_ADMIN,
});
expect(result).toBe(false);
});
it('matches user-level grants', async () => {
const userId = new Types.ObjectId();
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: userId,
capability: SystemCapabilities.READ_CONFIGS,
});
const result = await methods.hasCapabilityForPrincipals({
principals: [
{ principalType: PrincipalType.USER, principalId: userId },
{ principalType: PrincipalType.ROLE, principalId: SystemRoles.USER },
],
capability: SystemCapabilities.READ_CONFIGS,
});
expect(result).toBe(true);
});
it('matches group-level grants', async () => {
const groupId = new Types.ObjectId();
await methods.grantCapability({
principalType: PrincipalType.GROUP,
principalId: groupId,
capability: SystemCapabilities.READ_USAGE,
});
const result = await methods.hasCapabilityForPrincipals({
principals: [
{ principalType: PrincipalType.USER, principalId: new Types.ObjectId() },
{ principalType: PrincipalType.GROUP, principalId: groupId },
],
capability: SystemCapabilities.READ_USAGE,
});
expect(result).toBe(true);
});
it('finds grant when string userId was used to create it and ObjectId to query', async () => {
const userId = new Types.ObjectId();
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: userId.toString(),
capability: SystemCapabilities.READ_USAGE,
});
const result = await methods.hasCapabilityForPrincipals({
principals: [{ principalType: PrincipalType.USER, principalId: userId }],
capability: SystemCapabilities.READ_USAGE,
});
expect(result).toBe(true);
});
describe('capability implications', () => {
it.each(
(
Object.entries(CapabilityImplications) as [SystemCapability, SystemCapability[]][]
).flatMap(([broad, implied]) => implied.map((imp) => [broad, imp] as const)),
)('%s implies %s', async (broadCap, impliedCap) => {
const userId = new Types.ObjectId();
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: userId,
capability: broadCap,
});
const result = await methods.hasCapabilityForPrincipals({
principals: [{ principalType: PrincipalType.USER, principalId: userId }],
capability: impliedCap,
});
expect(result).toBe(true);
});
it.each(
(
Object.entries(CapabilityImplications) as [SystemCapability, SystemCapability[]][]
).flatMap(([broad, implied]) => implied.map((imp) => [imp, broad] as const)),
)('%s does NOT imply %s (reverse)', async (narrowCap, broadCap) => {
const userId = new Types.ObjectId();
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: userId,
capability: narrowCap,
});
const result = await methods.hasCapabilityForPrincipals({
principals: [{ principalType: PrincipalType.USER, principalId: userId }],
capability: broadCap,
});
expect(result).toBe(false);
});
});
describe('tenant scoping', () => {
it('tenant-scoped grant does not match platform-level query', async () => {
const userId = new Types.ObjectId();
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: userId,
capability: SystemCapabilities.READ_CONFIGS,
tenantId: 'tenant-1',
});
const result = await methods.hasCapabilityForPrincipals({
principals: [{ principalType: PrincipalType.USER, principalId: userId }],
capability: SystemCapabilities.READ_CONFIGS,
});
expect(result).toBe(false);
});
it('platform-level grant does not match tenant-scoped query', async () => {
const userId = new Types.ObjectId();
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: userId,
capability: SystemCapabilities.READ_CONFIGS,
});
const result = await methods.hasCapabilityForPrincipals({
principals: [{ principalType: PrincipalType.USER, principalId: userId }],
capability: SystemCapabilities.READ_CONFIGS,
tenantId: 'tenant-1',
});
expect(result).toBe(false);
});
it('tenant-scoped grant matches same-tenant query', async () => {
const userId = new Types.ObjectId();
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: userId,
capability: SystemCapabilities.READ_CONFIGS,
tenantId: 'tenant-1',
});
const result = await methods.hasCapabilityForPrincipals({
principals: [{ principalType: PrincipalType.USER, principalId: userId }],
capability: SystemCapabilities.READ_CONFIGS,
tenantId: 'tenant-1',
});
expect(result).toBe(true);
});
it('tenant-scoped grant does not match different tenant', async () => {
const userId = new Types.ObjectId();
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: userId,
capability: SystemCapabilities.READ_CONFIGS,
tenantId: 'tenant-1',
});
const result = await methods.hasCapabilityForPrincipals({
principals: [{ principalType: PrincipalType.USER, principalId: userId }],
capability: SystemCapabilities.READ_CONFIGS,
tenantId: 'tenant-2',
});
expect(result).toBe(false);
});
});
});
describe('getCapabilitiesForPrincipal', () => {
it('lists all capabilities for the ADMIN role after seeding', async () => {
await methods.seedSystemGrants();
const grants = await methods.getCapabilitiesForPrincipal({
principalType: PrincipalType.ROLE,
principalId: SystemRoles.ADMIN,
});
expect(grants).toHaveLength(Object.values(SystemCapabilities).length);
const caps = grants.map((g) => g.capability).sort();
expect(caps).toEqual(Object.values(SystemCapabilities).sort());
});
it('returns empty array when principal has no grants', async () => {
const grants = await methods.getCapabilitiesForPrincipal({
principalType: PrincipalType.ROLE,
principalId: SystemRoles.USER,
});
expect(grants).toHaveLength(0);
});
it('normalizes string userId for lookup', async () => {
const userId = new Types.ObjectId();
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: userId,
capability: SystemCapabilities.READ_USAGE,
});
const grants = await methods.getCapabilitiesForPrincipal({
principalType: PrincipalType.USER,
principalId: userId.toString(),
});
expect(grants).toHaveLength(1);
expect(grants[0].capability).toBe(SystemCapabilities.READ_USAGE);
});
it('only returns grants for the specified tenant', async () => {
const userId = new Types.ObjectId();
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: userId,
capability: SystemCapabilities.READ_CONFIGS,
tenantId: 'tenant-1',
});
await methods.grantCapability({
principalType: PrincipalType.USER,
principalId: userId,
capability: SystemCapabilities.READ_USAGE,
tenantId: 'tenant-2',
});
const grants = await methods.getCapabilitiesForPrincipal({
principalType: PrincipalType.USER,
principalId: userId,
tenantId: 'tenant-1',
});
expect(grants).toHaveLength(1);
expect(grants[0].capability).toBe(SystemCapabilities.READ_CONFIGS);
});
it('throws TypeError for invalid ObjectId string on USER principal', async () => {
await expect(
methods.getCapabilitiesForPrincipal({
principalType: PrincipalType.USER,
principalId: 'not-valid',
}),
).rejects.toThrow(TypeError);
});
});
describe('schema validation', () => {
it('rejects null tenantId at the schema level', async () => {
await expect(
SystemGrant.create({
principalType: PrincipalType.USER,
principalId: new Types.ObjectId(),
capability: SystemCapabilities.READ_USERS,
tenantId: null,
}),
).rejects.toThrow(/tenantId/);
});
it('rejects empty string tenantId at the schema level', async () => {
await expect(
SystemGrant.create({
principalType: PrincipalType.USER,
principalId: new Types.ObjectId(),
capability: SystemCapabilities.READ_USERS,
tenantId: '',
}),
).rejects.toThrow(/tenantId/);
});
it('rejects invalid principalType values', async () => {
await expect(
SystemGrant.create({
principalType: 'INVALID_TYPE',
principalId: new Types.ObjectId(),
capability: SystemCapabilities.READ_USERS,
}),
).rejects.toThrow(/principalType/);
});
it('requires principalType field', async () => {
await expect(
SystemGrant.create({
principalId: new Types.ObjectId(),
capability: SystemCapabilities.READ_USERS,
}),
).rejects.toThrow(/principalType/);
});
it('requires principalId field', async () => {
await expect(
SystemGrant.create({
principalType: PrincipalType.USER,
capability: SystemCapabilities.READ_USERS,
}),
).rejects.toThrow(/principalId/);
});
it('requires capability field', async () => {
await expect(
SystemGrant.create({
principalType: PrincipalType.USER,
principalId: new Types.ObjectId(),
}),
).rejects.toThrow(/capability/);
});
it('rejects invalid capability strings', async () => {
await expect(
SystemGrant.create({
principalType: PrincipalType.USER,
principalId: new Types.ObjectId(),
capability: 'god:mode',
}),
).rejects.toThrow(/Invalid capability string/);
});
it('accepts valid section-level config capabilities', async () => {
const doc = await SystemGrant.create({
principalType: PrincipalType.USER,
principalId: new Types.ObjectId(),
capability: 'manage:configs:endpoints',
});
expect(doc.capability).toBe('manage:configs:endpoints');
});
it('accepts valid assign config capabilities', async () => {
const doc = await SystemGrant.create({
principalType: PrincipalType.USER,
principalId: new Types.ObjectId(),
capability: 'assign:configs:group',
});
expect(doc.capability).toBe('assign:configs:group');
});
it('enforces unique compound index (principalType + principalId + capability + tenantId)', async () => {
const doc = {
principalType: PrincipalType.USER,
principalId: new Types.ObjectId(),
capability: SystemCapabilities.READ_USERS,
};
await SystemGrant.create(doc);
await expect(SystemGrant.create(doc)).rejects.toThrow(/duplicate key|E11000/);
});
it('rejects duplicate platform-level grants (absent tenantId) — non-sparse index', async () => {
const principalId = new Types.ObjectId();
await SystemGrant.create({
principalType: PrincipalType.USER,
principalId,
capability: SystemCapabilities.ACCESS_ADMIN,
});
await expect(
SystemGrant.create({
principalType: PrincipalType.USER,
principalId,
capability: SystemCapabilities.ACCESS_ADMIN,
}),
).rejects.toThrow(/duplicate key|E11000/);
});
it('allows same grant for different tenants (tenantId is part of unique key)', async () => {
const principalId = new Types.ObjectId();
const base = {
principalType: PrincipalType.USER,
principalId,
capability: SystemCapabilities.ACCESS_ADMIN,
};
await SystemGrant.create({ ...base, tenantId: 'tenant-a' });
await SystemGrant.create({ ...base, tenantId: 'tenant-b' });
const count = await SystemGrant.countDocuments({ principalId });
expect(count).toBe(2);
});
it('platform-level and tenant-scoped grants coexist (different unique key values)', async () => {
const principalId = new Types.ObjectId();
const base = {
principalType: PrincipalType.USER,
principalId,
capability: SystemCapabilities.ACCESS_ADMIN,
};
await SystemGrant.create(base);
await SystemGrant.create({ ...base, tenantId: 'tenant-1' });
const count = await SystemGrant.countDocuments({ principalId });
expect(count).toBe(2);
});
});
});

View file

@ -0,0 +1,266 @@
import { PrincipalType, SystemRoles } from 'librechat-data-provider';
import type { Types, Model, ClientSession } from 'mongoose';
import type { SystemCapability } from '~/systemCapabilities';
import type { ISystemGrant } from '~/types';
import { SystemCapabilities, CapabilityImplications } from '~/systemCapabilities';
import { normalizePrincipalId } from '~/utils/principal';
import logger from '~/config/winston';
/**
* Precomputed reverse map: for each capability, which broader capabilities imply it.
* Built once at module load so `hasCapabilityForPrincipals` avoids O(N×M) per call.
*/
type BaseSystemCapability = (typeof SystemCapabilities)[keyof typeof SystemCapabilities];
const reverseImplications: Partial<Record<BaseSystemCapability, BaseSystemCapability[]>> = {};
for (const [broad, implied] of Object.entries(CapabilityImplications)) {
for (const cap of implied as BaseSystemCapability[]) {
(reverseImplications[cap] ??= []).push(broad as BaseSystemCapability);
}
}
export function createSystemGrantMethods(mongoose: typeof import('mongoose')) {
/**
* Check if any of the given principals holds a specific capability.
* Follows the same principal-resolution pattern as AclEntry:
* getUserPrincipals $or query.
*
* @param principals - Resolved principal list from getUserPrincipals
* @param capability - The capability to check
* @param tenantId - If present, checks tenant-scoped grant; if absent, checks platform-level
*/
async function hasCapabilityForPrincipals({
principals,
capability,
tenantId,
}: {
principals: Array<{ principalType: PrincipalType; principalId?: string | Types.ObjectId }>;
capability: SystemCapability;
tenantId?: string;
}): Promise<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 }));
if (!principalsQuery.length) {
return false;
}
const impliedBy = reverseImplications[capability as keyof typeof reverseImplications] ?? [];
const capabilityQuery = impliedBy.length ? { $in: [capability, ...impliedBy] } : capability;
const query: Record<string, unknown> = {
$or: principalsQuery,
capability: capabilityQuery,
};
/*
* TODO(#12091): In multi-tenant mode, platform-level grants (tenantId absent)
* should also satisfy tenant-scoped checks so that seeded ADMIN grants remain
* effective. When tenantId is set, query both tenant-scoped AND platform-level:
* query.$or = [{ tenantId }, { tenantId: { $exists: false } }]
* Also: getUserPrincipals currently has no tenantId param, so group memberships
* are returned across all tenants. Filter by tenant there too.
*/
if (tenantId != null) {
query.tenantId = tenantId;
} else {
query.tenantId = { $exists: false };
}
const doc = await SystemGrant.exists(query);
return doc != null;
}
/**
* Grant a capability to a principal. Upsert idempotent.
*/
async function grantCapability(
{
principalType,
principalId,
capability,
tenantId,
grantedBy,
}: {
principalType: PrincipalType;
principalId: string | Types.ObjectId;
capability: SystemCapability;
tenantId?: string;
grantedBy?: string | Types.ObjectId;
},
session?: ClientSession,
): Promise<ISystemGrant | null> {
const SystemGrant = mongoose.models.SystemGrant as Model<ISystemGrant>;
const normalizedPrincipalId = normalizePrincipalId(principalId, principalType);
const filter: Record<string, unknown> = {
principalType,
principalId: normalizedPrincipalId,
capability,
};
if (tenantId != null) {
filter.tenantId = tenantId;
} else {
filter.tenantId = { $exists: false };
}
const update = {
$set: {
grantedAt: new Date(),
...(grantedBy != null && { grantedBy }),
},
$setOnInsert: {
principalType,
principalId: normalizedPrincipalId,
capability,
...(tenantId != null && { tenantId }),
},
};
const options = {
upsert: true,
new: true,
...(session ? { session } : {}),
};
try {
return await SystemGrant.findOneAndUpdate(filter, update, options);
} catch (err) {
if ((err as { code?: number }).code === 11000) {
return (await SystemGrant.findOne(filter).lean()) as ISystemGrant | null;
}
throw err;
}
}
/**
* Revoke a capability from a principal.
*/
async function revokeCapability(
{
principalType,
principalId,
capability,
tenantId,
}: {
principalType: PrincipalType;
principalId: string | Types.ObjectId;
capability: SystemCapability;
tenantId?: string;
},
session?: ClientSession,
): Promise<void> {
const SystemGrant = mongoose.models.SystemGrant as Model<ISystemGrant>;
const normalizedPrincipalId = normalizePrincipalId(principalId, principalType);
const filter: Record<string, unknown> = {
principalType,
principalId: normalizedPrincipalId,
capability,
};
if (tenantId != null) {
filter.tenantId = tenantId;
} else {
filter.tenantId = { $exists: false };
}
const options = session ? { session } : {};
await SystemGrant.deleteOne(filter, options);
}
/**
* List all capabilities held by a principal used by the capabilities
* introspection endpoint.
*/
async function getCapabilitiesForPrincipal({
principalType,
principalId,
tenantId,
}: {
principalType: PrincipalType;
principalId: string | Types.ObjectId;
tenantId?: string;
}): Promise<ISystemGrant[]> {
const SystemGrant = mongoose.models.SystemGrant as Model<ISystemGrant>;
const filter: Record<string, unknown> = {
principalType,
principalId: normalizePrincipalId(principalId, principalType),
};
if (tenantId != null) {
filter.tenantId = tenantId;
} else {
filter.tenantId = { $exists: false };
}
return await SystemGrant.find(filter).lean();
}
/**
* Seed the ADMIN role with all system capabilities (no tenantId single-instance mode).
* Idempotent and concurrency-safe: uses bulkWrite with ordered:false so parallel
* server instances (K8s rolling deploy, PM2 cluster) do not race on E11000.
* Retries up to 3 times with exponential backoff on transient failures.
*/
async function seedSystemGrants(): Promise<void> {
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const SystemGrant = mongoose.models.SystemGrant as Model<ISystemGrant>;
const now = new Date();
const ops = Object.values(SystemCapabilities).map((capability) => ({
updateOne: {
filter: {
principalType: PrincipalType.ROLE,
principalId: SystemRoles.ADMIN,
capability,
tenantId: { $exists: false },
},
update: {
$setOnInsert: {
principalType: PrincipalType.ROLE,
principalId: SystemRoles.ADMIN,
capability,
grantedAt: now,
},
},
upsert: true,
},
}));
await SystemGrant.bulkWrite(ops, { ordered: false });
return;
} catch (err) {
if (attempt < maxRetries) {
const delay = 1000 * Math.pow(2, attempt - 1);
logger.warn(
`[seedSystemGrants] Attempt ${attempt}/${maxRetries} failed, retrying in ${delay}ms: ${(err as Error).message ?? String(err)}`,
);
await new Promise((resolve) => setTimeout(resolve, delay));
} else {
logger.error(
'[seedSystemGrants] Failed to seed capabilities after all retries. ' +
'Admin panel access requires these grants. Manual recovery: ' +
'db.systemgrants.insertMany([...]) with ADMIN role grants for each capability.',
err,
);
}
}
}
}
return {
grantCapability,
seedSystemGrants,
revokeCapability,
hasCapabilityForPrincipals,
getCapabilitiesForPrincipal,
};
}
export type SystemGrantMethods = ReturnType<typeof createSystemGrantMethods>;

File diff suppressed because it is too large Load diff

View file

@ -244,6 +244,13 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) {
* @param session - Optional MongoDB session for transactions
* @returns Array of principal objects with type and id
*/
/**
* TODO(#12091): This method has no tenantId parameter it returns ALL group
* memberships for a user regardless of tenant. In multi-tenant mode, group
* principals from other tenants will be included in capability checks, which
* could grant cross-tenant capabilities. Add tenantId filtering here when
* tenant isolation is activated.
*/
async function getUserPrincipals(
params: {
userId: string | Types.ObjectId;

View file

@ -25,6 +25,7 @@ import { createToolCallModel } from './toolCall';
import { createMemoryModel } from './memory';
import { createAccessRoleModel } from './accessRole';
import { createAclEntryModel } from './aclEntry';
import { createSystemGrantModel } from './systemGrant';
import { createGroupModel } from './group';
/**
@ -59,6 +60,7 @@ export function createModels(mongoose: typeof import('mongoose')) {
MemoryEntry: createMemoryModel(mongoose),
AccessRole: createAccessRoleModel(mongoose),
AclEntry: createAclEntryModel(mongoose),
SystemGrant: createSystemGrantModel(mongoose),
Group: createGroupModel(mongoose),
};
}

View file

@ -0,0 +1,11 @@
import systemGrantSchema from '~/schema/systemGrant';
import type * as t from '~/types';
/**
* Creates or returns the SystemGrant model using the provided mongoose instance and schema
*/
export function createSystemGrantModel(mongoose: typeof import('mongoose')) {
return (
mongoose.models.SystemGrant || mongoose.model<t.ISystemGrant>('SystemGrant', systemGrantSchema)
);
}

View file

@ -24,3 +24,4 @@ export { default as transactionSchema } from './transaction';
export { default as userSchema } from './user';
export { default as memorySchema } from './memory';
export { default as groupSchema } from './group';
export { default as systemGrantSchema } from './systemGrant';

View file

@ -0,0 +1,76 @@
import { Schema } from 'mongoose';
import { PrincipalType } from 'librechat-data-provider';
import { SystemCapabilities } from '~/systemCapabilities';
import type { SystemCapability } from '~/systemCapabilities';
import type { ISystemGrant } from '~/types';
const baseCapabilities = new Set<SystemCapability>(Object.values(SystemCapabilities));
const sectionCapPattern = /^(?:manage|read):configs:\w+$/;
const assignCapPattern = /^assign:configs:(?:user|group|role)$/;
const systemGrantSchema = new Schema<ISystemGrant>(
{
principalType: {
type: String,
enum: Object.values(PrincipalType),
required: true,
},
principalId: {
type: Schema.Types.Mixed,
required: true,
},
capability: {
type: String,
required: true,
validate: {
validator: (v: SystemCapability) =>
baseCapabilities.has(v) || sectionCapPattern.test(v) || assignCapPattern.test(v),
message: 'Invalid capability string: "{VALUE}"',
},
},
/**
* Platform-level grants MUST omit this field entirely never set it to null.
* Queries for platform-level grants use `{ tenantId: { $exists: false } }`, which
* matches absent fields but NOT `null`. A document stored with `{ tenantId: null }`
* would silently match neither platform-level nor tenant-scoped queries.
*/
tenantId: {
type: String,
required: false,
validate: {
validator: (v: unknown) => v !== null && v !== '',
message: 'tenantId must be a non-empty string or omitted entirely — never null or empty',
},
},
grantedBy: {
type: Schema.Types.ObjectId,
ref: 'User',
},
grantedAt: {
type: Date,
default: Date.now,
},
/** Reserved for future TTL enforcement — time-bounded / temporary grants. Not enforced yet. */
expiresAt: {
type: Date,
required: false,
},
},
{ timestamps: true },
);
/*
* principalId normalization (string ObjectId for USER/GROUP) is handled
* explicitly by grantCapability the only sanctioned write path.
* All writes MUST go through grantCapability; do not use Model.create()
* or save() directly, as there is no schema-level normalization hook.
*/
systemGrantSchema.index(
{ principalType: 1, principalId: 1, capability: 1, tenantId: 1 },
{ unique: true },
);
systemGrantSchema.index({ capability: 1, tenantId: 1 });
export default systemGrantSchema;

View file

@ -0,0 +1,106 @@
import type { z } from 'zod';
import type { configSchema } from 'librechat-data-provider';
import { ResourceType } from 'librechat-data-provider';
export const SystemCapabilities = {
ACCESS_ADMIN: 'access:admin',
READ_USERS: 'read:users',
MANAGE_USERS: 'manage:users',
READ_GROUPS: 'read:groups',
MANAGE_GROUPS: 'manage:groups',
READ_ROLES: 'read:roles',
MANAGE_ROLES: 'manage:roles',
READ_CONFIGS: 'read:configs',
MANAGE_CONFIGS: 'manage:configs',
ASSIGN_CONFIGS: 'assign:configs',
READ_USAGE: 'read:usage',
READ_AGENTS: 'read:agents',
MANAGE_AGENTS: 'manage:agents',
MANAGE_MCP_SERVERS: 'manage:mcpservers',
READ_PROMPTS: 'read:prompts',
MANAGE_PROMPTS: 'manage:prompts',
/** Reserved — not yet enforced by any middleware. Grant has no effect until assistant listing is gated. */
READ_ASSISTANTS: 'read:assistants',
MANAGE_ASSISTANTS: 'manage:assistants',
} as const;
/** Top-level keys of the configSchema from librechat.yaml. */
export type ConfigSection = keyof z.infer<typeof configSchema>;
/** Principal types that can receive config overrides. */
export type ConfigAssignTarget = 'user' | 'group' | 'role';
/** Base capabilities defined in the SystemCapabilities object. */
type BaseSystemCapability = (typeof SystemCapabilities)[keyof typeof SystemCapabilities];
/** Section-level config capabilities derived from configSchema keys. */
type ConfigSectionCapability = `manage:configs:${ConfigSection}` | `read:configs:${ConfigSection}`;
/** Principal-scoped config assignment capabilities. */
type ConfigAssignCapability = `assign:configs:${ConfigAssignTarget}`;
/**
* Union of all valid capability strings:
* - Base capabilities from SystemCapabilities
* - Section-level config capabilities (manage:configs:<section>, read:configs:<section>)
* - Config assignment capabilities (assign:configs:<user|group|role>)
*/
export type SystemCapability =
| BaseSystemCapability
| ConfigSectionCapability
| ConfigAssignCapability;
/**
* Capabilities that are implied by holding a broader capability.
* When `hasCapability` checks for an implied capability, it first expands
* the principal's grant set so granting `MANAGE_USERS` automatically
* satisfies a `READ_USERS` check without a separate grant.
*
* Implication is one-directional: `MANAGE_USERS` implies `READ_USERS`,
* but `READ_USERS` does NOT imply `MANAGE_USERS`.
*/
export const CapabilityImplications: Partial<Record<BaseSystemCapability, BaseSystemCapability[]>> =
{
[SystemCapabilities.MANAGE_USERS]: [SystemCapabilities.READ_USERS],
[SystemCapabilities.MANAGE_GROUPS]: [SystemCapabilities.READ_GROUPS],
[SystemCapabilities.MANAGE_ROLES]: [SystemCapabilities.READ_ROLES],
[SystemCapabilities.MANAGE_CONFIGS]: [SystemCapabilities.READ_CONFIGS],
[SystemCapabilities.MANAGE_AGENTS]: [SystemCapabilities.READ_AGENTS],
[SystemCapabilities.MANAGE_PROMPTS]: [SystemCapabilities.READ_PROMPTS],
[SystemCapabilities.MANAGE_ASSISTANTS]: [SystemCapabilities.READ_ASSISTANTS],
};
/**
* Maps each ACL ResourceType to the SystemCapability that grants
* unrestricted management access. Typed as `Record<ResourceType, …>`
* so adding a new ResourceType variant causes a compile error until a
* capability is assigned here.
*/
export const ResourceCapabilityMap: Record<ResourceType, SystemCapability> = {
[ResourceType.AGENT]: SystemCapabilities.MANAGE_AGENTS,
[ResourceType.PROMPTGROUP]: SystemCapabilities.MANAGE_PROMPTS,
[ResourceType.MCPSERVER]: SystemCapabilities.MANAGE_MCP_SERVERS,
[ResourceType.REMOTE_AGENT]: SystemCapabilities.MANAGE_AGENTS,
};
/**
* Derives a section-level config management capability from a configSchema key.
* @example configCapability('endpoints') 'manage:configs:endpoints'
*
* TODO: Section-level config capabilities are scaffolded but not yet active.
* To activate delegated config management:
* 1. Expose POST/DELETE /api/admin/grants endpoints (wiring grantCapability/revokeCapability)
* 2. Seed section-specific grants for delegated admin roles via those endpoints
* 3. Guard config write handlers with hasConfigCapability(user, section)
*/
export function configCapability(section: ConfigSection): `manage:configs:${ConfigSection}` {
return `manage:configs:${section}`;
}
/**
* Derives a section-level config read capability from a configSchema key.
* @example readConfigCapability('endpoints') 'read:configs:endpoints'
*/
export function readConfigCapability(section: ConfigSection): `read:configs:${ConfigSection}` {
return `read:configs:${section}`;
}

View file

@ -26,6 +26,7 @@ export * from './prompts';
/* Access Control */
export * from './accessRole';
export * from './aclEntry';
export * from './systemGrant';
export * from './group';
/* Web */
export * from './web';

View file

@ -0,0 +1,25 @@
import type { Document, Types } from 'mongoose';
import type { PrincipalType } from 'librechat-data-provider';
import type { SystemCapability } from '~/systemCapabilities';
export type SystemGrant = {
/** The type of principal — matches PrincipalType enum values */
principalType: PrincipalType;
/** ObjectId string for user/group, role name string for role */
principalId: string | Types.ObjectId;
/** The capability being granted */
capability: SystemCapability;
/** Absent = platform-operator, present = tenant-scoped */
tenantId?: string;
/** ID of the user who granted this capability */
grantedBy?: Types.ObjectId;
/** When this capability was granted */
grantedAt?: Date;
/** Reserved for future TTL enforcement — time-bounded / temporary grants. */
expiresAt?: Date;
};
export type ISystemGrant = SystemGrant &
Document & {
_id: Types.ObjectId;
};

View file

@ -1,3 +1,4 @@
export * from './principal';
export * from './string';
export * from './tempChatRetention';
export * from './transactions';

View file

@ -0,0 +1,22 @@
import { Types } from 'mongoose';
import { PrincipalType } from 'librechat-data-provider';
/**
* Normalizes a principalId to the correct type for MongoDB queries and storage.
* USER and GROUP principals are stored as ObjectIds; ROLE principals are strings.
* Ensures a string caller ID is cast to ObjectId so it matches documents written
* by `grantCapability` which always stores user/group IDs as ObjectIds to match
* what `getUserPrincipals` returns.
*/
export const normalizePrincipalId = (
principalId: string | Types.ObjectId,
principalType: PrincipalType,
): string | Types.ObjectId => {
if (typeof principalId === 'string' && principalType !== PrincipalType.ROLE) {
if (!Types.ObjectId.isValid(principalId)) {
throw new TypeError(`Invalid ObjectId string for ${principalType}: "${principalId}"`);
}
return new Types.ObjectId(principalId);
}
return principalId;
};