mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-01 03:27:01 +00:00
🧠 refactor: Memoize MCP Permission Checks Per Request (#13419)
This commit is contained in:
parent
100871c3ec
commit
479e9d59b7
9 changed files with 314 additions and 25 deletions
|
|
@ -6,7 +6,12 @@ import {
|
|||
EndpointURLs,
|
||||
} from 'librechat-data-provider';
|
||||
import type { IRole, IUser } from '@librechat/data-schemas';
|
||||
import { checkAccess, generateCheckAccess, skipAgentCheck } from './access';
|
||||
import {
|
||||
checkAccess,
|
||||
checkAccessWithRequestCache,
|
||||
generateCheckAccess,
|
||||
skipAgentCheck,
|
||||
} from './access';
|
||||
|
||||
// Mock logger
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
|
|
@ -269,6 +274,100 @@ describe('access middleware', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('checkAccessWithRequestCache', () => {
|
||||
const defaultParams = {
|
||||
user: {
|
||||
id: 'user123',
|
||||
role: 'user',
|
||||
email: 'test@example.com',
|
||||
emailVerified: true,
|
||||
provider: 'local',
|
||||
} as IUser,
|
||||
permissionType: PermissionTypes.MCP_SERVERS,
|
||||
permissions: [Permissions.USE],
|
||||
getRoleByName: jest.fn(),
|
||||
};
|
||||
|
||||
const allowedRole = {
|
||||
name: 'user',
|
||||
permissions: {
|
||||
[PermissionTypes.MCP_SERVERS]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
},
|
||||
},
|
||||
} as unknown as IRole;
|
||||
|
||||
it('should memoize permission checks for the same request', async () => {
|
||||
defaultParams.getRoleByName.mockResolvedValue(allowedRole);
|
||||
|
||||
const params = {
|
||||
...defaultParams,
|
||||
req: mockReq as Request,
|
||||
};
|
||||
|
||||
await expect(checkAccessWithRequestCache(params)).resolves.toBe(true);
|
||||
await expect(checkAccessWithRequestCache(params)).resolves.toBe(true);
|
||||
|
||||
expect(defaultParams.getRoleByName).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should share an in-flight permission check for the same request', async () => {
|
||||
defaultParams.getRoleByName.mockResolvedValue(allowedRole);
|
||||
|
||||
const params = {
|
||||
...defaultParams,
|
||||
req: mockReq as Request,
|
||||
};
|
||||
|
||||
await expect(
|
||||
Promise.all([checkAccessWithRequestCache(params), checkAccessWithRequestCache(params)]),
|
||||
).resolves.toEqual([true, true]);
|
||||
|
||||
expect(defaultParams.getRoleByName).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should isolate memoized checks between requests', async () => {
|
||||
defaultParams.getRoleByName.mockResolvedValue(allowedRole);
|
||||
|
||||
await expect(
|
||||
checkAccessWithRequestCache({
|
||||
...defaultParams,
|
||||
req: mockReq as Request,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
checkAccessWithRequestCache({
|
||||
...defaultParams,
|
||||
req: { ...mockReq } as Request,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(defaultParams.getRoleByName).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should use separate cache entries for different permissions', async () => {
|
||||
defaultParams.getRoleByName.mockResolvedValue(allowedRole);
|
||||
|
||||
await expect(
|
||||
checkAccessWithRequestCache({
|
||||
...defaultParams,
|
||||
req: mockReq as Request,
|
||||
permissions: [Permissions.USE],
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
checkAccessWithRequestCache({
|
||||
...defaultParams,
|
||||
req: mockReq as Request,
|
||||
permissions: [Permissions.CREATE],
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(defaultParams.getRoleByName).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCheckAccess', () => {
|
||||
it('should create middleware that allows access when user has permissions', async () => {
|
||||
const mockRole = {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,54 @@ export function skipAgentCheck(req?: ServerRequest): boolean {
|
|||
return !isAgentsEndpoint(req.body.endpoint);
|
||||
}
|
||||
|
||||
export interface CheckAccessParams {
|
||||
user: IUser;
|
||||
req?: ServerRequest;
|
||||
permissionType: PermissionTypes;
|
||||
permissions: Permissions[];
|
||||
bodyProps?: Record<Permissions, string[]>;
|
||||
checkObject?: object;
|
||||
/** If skipCheck function is provided and returns true, skip permission checking */
|
||||
skipCheck?: (req?: ServerRequest) => boolean;
|
||||
getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise<IRole | null>;
|
||||
}
|
||||
|
||||
export type CheckAccessWithRequestCacheParams = Omit<
|
||||
CheckAccessParams,
|
||||
'bodyProps' | 'checkObject' | 'skipCheck'
|
||||
>;
|
||||
|
||||
type RequestPermissionCache = Map<string, Promise<boolean>>;
|
||||
|
||||
const requestPermissionCacheKey = '__librechatRequestPermissionCache';
|
||||
|
||||
function getRequestPermissionCache(req?: ServerRequest): RequestPermissionCache | null {
|
||||
if (!req) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reqWithCache = req as ServerRequest & {
|
||||
[requestPermissionCacheKey]?: RequestPermissionCache;
|
||||
};
|
||||
|
||||
if (!reqWithCache[requestPermissionCacheKey]) {
|
||||
Object.defineProperty(reqWithCache, requestPermissionCacheKey, {
|
||||
value: new Map<string, Promise<boolean>>(),
|
||||
enumerable: false,
|
||||
});
|
||||
}
|
||||
|
||||
return reqWithCache[requestPermissionCacheKey] ?? null;
|
||||
}
|
||||
|
||||
function getRequestPermissionCacheKey({
|
||||
user,
|
||||
permissionType,
|
||||
permissions,
|
||||
}: CheckAccessWithRequestCacheParams): string {
|
||||
return [permissionType, [...permissions].sort().join(','), user.id, user.role].join(':');
|
||||
}
|
||||
|
||||
/**
|
||||
* Core function to check if a user has one or more required permissions
|
||||
* @param user - The user object
|
||||
|
|
@ -43,17 +91,7 @@ export const checkAccess = async ({
|
|||
bodyProps = {} as Record<Permissions, string[]>,
|
||||
checkObject = {},
|
||||
skipCheck,
|
||||
}: {
|
||||
user: IUser;
|
||||
req?: ServerRequest;
|
||||
permissionType: PermissionTypes;
|
||||
permissions: Permissions[];
|
||||
bodyProps?: Record<Permissions, string[]>;
|
||||
checkObject?: object;
|
||||
/** If skipCheck function is provided and returns true, skip permission checking */
|
||||
skipCheck?: (req?: ServerRequest) => boolean;
|
||||
getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise<IRole | null>;
|
||||
}): Promise<boolean> => {
|
||||
}: CheckAccessParams): Promise<boolean> => {
|
||||
if (skipCheck && skipCheck(req)) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -85,6 +123,35 @@ export const checkAccess = async ({
|
|||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks simple role permissions using a per-request promise cache.
|
||||
* Use this only for checks whose result is fully described by user, role, permission type, and permissions.
|
||||
*/
|
||||
export const checkAccessWithRequestCache = async (
|
||||
params: CheckAccessWithRequestCacheParams,
|
||||
): Promise<boolean> => {
|
||||
if (!params.req || !params.user?.id || !params.user?.role) {
|
||||
return await checkAccess(params);
|
||||
}
|
||||
|
||||
const cache = getRequestPermissionCache(params.req);
|
||||
if (!cache) {
|
||||
return await checkAccess(params);
|
||||
}
|
||||
|
||||
const cacheKey = getRequestPermissionCacheKey(params);
|
||||
let cachedCheck = cache.get(cacheKey);
|
||||
if (!cachedCheck) {
|
||||
cachedCheck = checkAccess(params).catch((error) => {
|
||||
cache.delete(cacheKey);
|
||||
throw error;
|
||||
});
|
||||
cache.set(cacheKey, cachedCheck);
|
||||
}
|
||||
|
||||
return await cachedCheck;
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to check if a user has one or more required permissions, optionally based on `req.body` properties.
|
||||
* @param permissionType - The type of permission to check.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue