🪪 fix: Resolve Group-Scoped Config Overrides (#13176)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions

* fix: resolve group-scoped config overrides

* test: fix endpoint config request mock typing

* fix: keep remote agent preauth config tenant-scoped

* test: align config scoping expectations

* test: reproduce group endpoint override resolution
This commit is contained in:
Danny Avila 2026-05-18 10:16:20 -04:00 • committed by GitHub
parent ac163c2212
commit c342e2345b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 311 additions and 40 deletions

View file

@ -6,7 +6,13 @@ const { getAppConfig } = require('~/server/services/Config');
const getAvailablePluginsController = async (req, res) => {
try {
const appConfig = await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId });
const appConfig =
req.config ??
(await getAppConfig({
role: req.user?.role,
userId: req.user?.id,
tenantId: req.user?.tenantId,
}));
const { filteredTools = [], includedTools = [] } = appConfig;
const uniquePlugins = filterUniquePlugins(availableTools);
@ -41,7 +47,12 @@ const getAvailableTools = async (req, res) => {
}
const appConfig =
req.config ?? (await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId }));
req.config ??
(await getAppConfig({
role: req.user?.role,
userId: req.user?.id,
tenantId: req.user?.tenantId,
}));
let toolDefinitions = await getCachedTools();

View file

@ -98,10 +98,10 @@ describe('PluginController', () => {
require('~/app/clients/tools').availableTools.push(...mockPlugins);
getAppConfig.mockResolvedValueOnce({
mockReq.config = {
filteredTools: [],
includedTools: ['key1'],
});
};
await getAvailablePluginsController(mockReq, mockRes);
@ -118,10 +118,10 @@ describe('PluginController', () => {
require('~/app/clients/tools').availableTools.push(...mockPlugins);
getAppConfig.mockResolvedValueOnce({
mockReq.config = {
filteredTools: ['key2'],
includedTools: [],
});
};
await getAvailablePluginsController(mockReq, mockRes);
@ -139,10 +139,10 @@ describe('PluginController', () => {
require('~/app/clients/tools').availableTools.push(...mockPlugins);
getAppConfig.mockResolvedValueOnce({
mockReq.config = {
includedTools: ['key1', 'key2'],
filteredTools: ['key2'],
});
};
await getAvailablePluginsController(mockReq, mockRes);

View file

@ -26,7 +26,13 @@ const { getLogStores } = require('~/cache');
const db = require('~/models');
const getUserController = async (req, res) => {
const appConfig = await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId });
const appConfig =
req.config ??
(await getAppConfig({
role: req.user?.role,
userId: req.user?.id,
tenantId: req.user?.tenantId,
}));
/** @type {IUser} */
const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user };
/**
@ -165,7 +171,13 @@ const deleteUserMcpServers = async (userId) => {
};
const updateUserPluginsController = async (req, res) => {
const appConfig = await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId });
const appConfig =
req.config ??
(await getAppConfig({
role: req.user?.role,
userId: req.user?.id,
tenantId: req.user?.tenantId,
}));
const { user } = req;
const { pluginKey, action, auth, isEntityTool } = req.body;
try {

View file

@ -18,6 +18,7 @@ const checkDomainAllowed = async (req, res, next) => {
const email = req?.user?.email;
const appConfig = await getAppConfig({
role: req?.user?.role,
userId: req?.user?.id,
tenantId: req?.user?.tenantId,
});

View file

@ -93,6 +93,41 @@ describe('loadConfigModels', () => {
expect(result).toEqual({});
});
it('passes userId when resolving scoped model config', async () => {
getAppConfig.mockResolvedValue({});
await loadConfigModels({
user: { id: 'testUserId', role: 'USER', tenantId: 'tenant-a' },
});
expect(getAppConfig).toHaveBeenCalledWith({
role: 'USER',
userId: 'testUserId',
tenantId: 'tenant-a',
});
});
it('uses req.config when available instead of calling getAppConfig', async () => {
const result = await loadConfigModels({
user: { id: 'testUserId' },
config: {
endpoints: {
custom: [
{
name: 'LocalOnly',
apiKey: 'local-key',
baseURL: 'https://example.com/v1',
models: { default: ['local-model'], fetch: false },
},
],
},
},
});
expect(getAppConfig).not.toHaveBeenCalled();
expect(result.LocalOnly).toEqual(['local-model']);
});
it('handles azure models and endpoint correctly', async () => {
getAppConfig.mockResolvedValue({
endpoints: {

View file

@ -17,7 +17,12 @@ const { getAppConfig } = require('./app');
async function loadDefaultModels(req) {
try {
const appConfig =
req.config ?? (await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId }));
req.config ??
(await getAppConfig({
role: req.user?.role,
userId: req.user?.id,
tenantId: req.user?.tenantId,
}));
const vertexConfig = appConfig?.endpoints?.[EModelEndpoint.anthropic]?.vertexConfig;
const [openAI, anthropic, azureOpenAI, assistants, azureAssistants, google, bedrock] =

View file

@ -142,6 +142,7 @@ class STTService {
req.config ??
(await getAppConfig({
role: req?.user?.role,
userId: req?.user?.id,
tenantId: req?.user?.tenantId,
}));
const sttSchema = appConfig?.speech?.stt;

View file

@ -297,6 +297,7 @@ class TTSService {
req.config ??
(await getAppConfig({
role: req.user?.role,
userId: req.user?.id,
tenantId: req.user?.tenantId,
}));
try {
@ -366,6 +367,7 @@ class TTSService {
req.config ??
(await getAppConfig({
role: req.user?.role,
userId: req.user?.id,
tenantId: req.user?.tenantId,
}));
const provider = this.getProvider(appConfig);

View file

@ -15,10 +15,13 @@ const { getAppConfig } = require('~/server/services/Config');
*/
async function getCustomConfigSpeech(req, res) {
try {
const appConfig = await getAppConfig({
role: req.user?.role,
tenantId: req.user?.tenantId,
});
const appConfig =
req.config ??
(await getAppConfig({
role: req.user?.role,
userId: req.user?.id,
tenantId: req.user?.tenantId,
}));
if (!appConfig) {
return res.status(200).send({

View file

@ -18,6 +18,7 @@ async function getVoices(req, res) {
req.config ??
(await getAppConfig({
role: req.user?.role,
userId: req.user?.id,
tenantId: req.user?.tenantId,
}));

View file

@ -415,7 +415,11 @@ async function createMCPTools({
const serverConfig =
config ?? (await getMCPServersRegistry().getServerConfig(serverName, user?.id, configServers));
if (serverConfig?.url) {
const appConfig = await getAppConfig({ role: user?.role, tenantId: user?.tenantId });
const appConfig = await getAppConfig({
role: user?.role,
tenantId: user?.tenantId,
userId: user?.id,
});
const allowedDomains = appConfig?.mcpSettings?.allowedDomains;
const allowedAddresses = appConfig?.mcpSettings?.allowedAddresses;
const isDomainAllowed = await isMCPDomainAllowed(
@ -503,7 +507,11 @@ async function createMCPTool({
const serverConfig =
config ?? (await getMCPServersRegistry().getServerConfig(serverName, user?.id, configServers));
if (serverConfig?.url) {
const appConfig = await getAppConfig({ role: user?.role, tenantId: user?.tenantId });
const appConfig = await getAppConfig({
role: user?.role,
tenantId: user?.tenantId,
userId: user?.id,
});
const allowedDomains = appConfig?.mcpSettings?.allowedDomains;
const allowedAddresses = appConfig?.mcpSettings?.allowedAddresses;
const isDomainAllowed = await isMCPDomainAllowed(

View file

@ -922,8 +922,12 @@ describe('User parameter passing tests', () => {
// Should not call reinitMCPServer since domain check failed
expect(mockReinitMCPServer).not.toHaveBeenCalled();
// Verify getAppConfig was called with user role
expect(mockGetAppConfig).toHaveBeenCalledWith({ role: 'user' });
// Verify getAppConfig was called with the user scope
expect(mockGetAppConfig).toHaveBeenCalledWith({
role: 'user',
tenantId: undefined,
userId: 'domain-test-user',
});
// Verify domain validation was called with correct parameters
expect(mockIsMCPDomainAllowed).toHaveBeenCalledWith(
@ -971,8 +975,12 @@ describe('User parameter passing tests', () => {
// Should create tool successfully
expect(result).toBeDefined();
// Verify getAppConfig was called with user role
expect(mockGetAppConfig).toHaveBeenCalledWith({ role: 'admin' });
// Verify getAppConfig was called with the user scope
expect(mockGetAppConfig).toHaveBeenCalledWith({
role: 'admin',
tenantId: undefined,
userId: 'domain-test-user',
});
});
it('should skip domain validation for stdio transports (no URL)', async () => {
@ -1047,8 +1055,12 @@ describe('User parameter passing tests', () => {
// Should not call reinitMCPServer since domain check failed early
expect(mockReinitMCPServer).not.toHaveBeenCalled();
// Verify getAppConfig was called with user role
expect(mockGetAppConfig).toHaveBeenCalledWith({ role: 'user' });
// Verify getAppConfig was called with the user scope
expect(mockGetAppConfig).toHaveBeenCalledWith({
role: 'user',
tenantId: undefined,
userId: 'domain-test-user',
});
});
it('should use user role when fetching domain restrictions', async () => {
@ -1100,9 +1112,17 @@ describe('User parameter passing tests', () => {
availableTools,
});
// Verify getAppConfig was called with correct roles
expect(mockGetAppConfig).toHaveBeenNthCalledWith(1, { role: 'admin' });
expect(mockGetAppConfig).toHaveBeenNthCalledWith(2, { role: 'user' });
// Verify getAppConfig was called with the correct user scopes
expect(mockGetAppConfig).toHaveBeenNthCalledWith(1, {
role: 'admin',
tenantId: undefined,
userId: 'admin-user',
});
expect(mockGetAppConfig).toHaveBeenNthCalledWith(2, {
role: 'user',
tenantId: undefined,
userId: 'regular-user',
});
});
});

View file

@ -1,17 +1,46 @@
import { Types } from 'mongoose';
import {
AgentCapabilities,
EModelEndpoint,
PrincipalType,
defaultAgentCapabilities,
} from 'librechat-data-provider';
import { createEndpointsConfigService } from './endpoints';
import type { AppConfig } from '@librechat/data-schemas';
import type { AppConfig, IConfig } from '@librechat/data-schemas';
import type { AppConfigServiceDeps } from '~/app/service';
import type { EndpointsConfigDeps } from './endpoints';
import type { ServerRequest } from '~/types';
import { createEndpointsConfigService } from './endpoints';
import { createAppConfigService } from '~/app/service';
function appConfig(partial: Record<string, unknown>): AppConfig {
return partial as unknown as AppConfig;
}
function configDoc(partial: Record<string, unknown>): IConfig {
return partial as unknown as IConfig;
}
function createAppConfigCache() {
const store = new Map<string, unknown>();
return {
get: jest.fn((key: string) => Promise.resolve(store.get(key))),
set: jest.fn((key: string, value: unknown) => {
store.set(key, value);
return Promise.resolve(undefined);
}),
delete: jest.fn((key: string) => {
store.delete(key);
return Promise.resolve(true);
}),
};
}
type ConfigPrincipals = NonNullable<
Parameters<AppConfigServiceDeps['getApplicableConfigs']>[0]
>;
function createMockDeps(overrides: Partial<EndpointsConfigDeps> = {}): EndpointsConfigDeps {
return {
getAppConfig: jest.fn().mockResolvedValue(appConfig({ endpoints: {} })),
@ -23,7 +52,13 @@ function createMockDeps(overrides: Partial<EndpointsConfigDeps> = {}): Endpoints
};
}
function fakeReq(overrides: Partial<ServerRequest> = {}): ServerRequest {
type TestRequestOverrides = {
body?: Partial<ServerRequest['body']>;
config?: AppConfig;
user?: { id?: string; role?: string; tenantId?: string };
};
function fakeReq(overrides: TestRequestOverrides = {}): ServerRequest {
return { user: { id: 'u1', role: 'USER' }, ...overrides } as ServerRequest;
}
@ -172,6 +207,83 @@ describe('createEndpointsConfigService', () => {
expect(mockGetAppConfig).not.toHaveBeenCalled();
});
it('passes userId when resolving scoped endpoint config', async () => {
const mockGetAppConfig = jest.fn().mockResolvedValue(appConfig({ endpoints: {} }));
const deps = createMockDeps({ getAppConfig: mockGetAppConfig });
const { getEndpointsConfig } = createEndpointsConfigService(deps);
await getEndpointsConfig(
fakeReq({ user: { id: 'u1', role: 'USER', tenantId: 'tenant-a' } }),
);
expect(mockGetAppConfig).toHaveBeenCalledWith({
role: 'USER',
userId: 'u1',
tenantId: 'tenant-a',
});
});
it('exposes custom endpoints from group-scoped overrides for grouped users', async () => {
const groupId = new Types.ObjectId('6a0aea2172e2d59d4658c9d2');
const getUserPrincipals = jest.fn().mockResolvedValue([
{ principalType: PrincipalType.ROLE, principalId: 'USER' },
{ principalType: PrincipalType.USER, principalId: 'u1' },
{ principalType: PrincipalType.GROUP, principalId: groupId },
]);
const getApplicableConfigs = jest.fn((principals: ConfigPrincipals = []) =>
Promise.resolve(
principals.some(
(principal) =>
principal.principalType === PrincipalType.GROUP &&
String(principal.principalId) === groupId.toString(),
)
? [
configDoc({
principalType: PrincipalType.GROUP,
principalId: groupId,
priority: 20,
isActive: true,
overrides: {
endpoints: {
custom: [
{
name: 'FOO',
apiKey: '${FOO_KEY}',
baseURL: '${FOO_URL}',
models: { fetch: true },
},
],
},
},
}),
]
: [],
),
);
const { getAppConfig } = createAppConfigService({
loadBaseConfig: jest.fn().mockResolvedValue(appConfig({ endpoints: {} })),
setCachedTools: jest.fn().mockResolvedValue(undefined),
getCache: jest.fn().mockReturnValue(createAppConfigCache()),
cacheKeys: { APP_CONFIG: 'app_config' },
getApplicableConfigs,
getUserPrincipals,
});
const { getEndpointsConfig } = createEndpointsConfigService({
getAppConfig,
loadDefaultEndpointsConfig: jest.fn().mockResolvedValue({}),
});
const result = await getEndpointsConfig(fakeReq({ user: { id: 'u1', role: 'USER' } }));
expect(getUserPrincipals).toHaveBeenCalledWith({ userId: 'u1', role: 'USER' });
expect(getApplicableConfigs).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ principalType: PrincipalType.GROUP, principalId: groupId }),
]),
);
expect(result?.FOO).toEqual(expect.objectContaining({ type: EModelEndpoint.custom }));
});
});
describe('checkCapability', () => {

View file

@ -14,7 +14,11 @@ type DefaultEndpointsResult = Record<string, PartialEndpointEntry | false | null
type MutableEndpointsConfig = Record<string, PartialEndpointEntry | false | null | undefined>;
export interface EndpointsConfigDeps {
getAppConfig: (params: { role?: string | null; tenantId?: string }) => Promise<AppConfig>;
getAppConfig: (params: {
role?: string;
userId?: string;
tenantId?: string;
}) => Promise<AppConfig>;
loadDefaultEndpointsConfig: (appConfig: AppConfig) => Promise<DefaultEndpointsResult>;
loadCustomEndpointsConfig?: (custom: unknown) => TCustomEndpointsConfig | undefined;
}
@ -28,7 +32,12 @@ export function createEndpointsConfigService(deps: EndpointsConfigDeps) {
async function getEndpointsConfig(req: ServerRequest): Promise<TEndpointsConfig> {
const appConfig =
req.config ?? (await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId }));
req.config ??
(await getAppConfig({
role: req.user?.role,
userId: req.user?.id,
tenantId: req.user?.tenantId,
}));
const defaultEndpointsConfig = await loadDefaultEndpointsConfig(appConfig);
const customEndpointsConfig = loadCustomEndpointsConfig(appConfig?.endpoints?.custom);

View file

@ -22,7 +22,11 @@ interface ResolvedEndpoint {
}
export interface LoadConfigModelsDeps {
getAppConfig: (params: { role?: string | null; tenantId?: string }) => Promise<AppConfig>;
getAppConfig: (params: {
role?: string;
userId?: string;
tenantId?: string;
}) => Promise<AppConfig>;
getUserKeyValues: GetUserKeyValuesFunction;
fetchModels?: (params: FetchModelsParams) => Promise<string[]>;
}
@ -31,10 +35,13 @@ export function createLoadConfigModels(deps: LoadConfigModelsDeps) {
const { getAppConfig, getUserKeyValues, fetchModels = defaultFetchModels } = deps;
return async function loadConfigModels(req: ServerRequest): Promise<TModelsConfig> {
const appConfig = await getAppConfig({
role: req.user?.role,
tenantId: req.user?.tenantId,
});
const appConfig =
req.config ??
(await getAppConfig({
role: req.user?.role,
userId: req.user?.id,
tenantId: req.user?.tenantId,
}));
if (!appConfig) {
return {};
}

View file

@ -381,6 +381,34 @@ describe('createSetBalanceConfig', () => {
// lastRefill should have default value from schema
expect(balanceRecord?.lastRefill).toBeInstanceOf(Date);
});
test('should pass user identity when resolving balance config', async () => {
const userId = new mongoose.Types.ObjectId();
const getAppConfig = jest.fn().mockResolvedValue({
balance: { enabled: false },
});
const middleware = createSetBalanceConfig({
getAppConfig,
findBalanceByUser,
upsertBalanceFields,
});
const req = createMockRequest(userId);
req.user = {
...req.user,
role: 'USER',
tenantId: 'tenant-a',
} as typeof req.user;
await middleware(req as ServerRequest, createMockResponse() as ServerResponse, mockNext);
expect(getAppConfig).toHaveBeenCalledWith({
role: 'USER',
userId: userId.toString(),
tenantId: 'tenant-a',
});
});
});
describe('Update Scenarios', () => {

View file

@ -14,6 +14,7 @@ import { getBalanceConfig } from '~/app/config';
export interface BalanceMiddlewareOptions {
getAppConfig: (options?: {
role?: string;
userId?: string;
tenantId?: string;
refresh?: boolean;
}) => Promise<AppConfig>;
@ -119,6 +120,7 @@ export function createSetBalanceConfig({
const user = req.user as IUser & { _id: string | ObjectId };
const appConfig = await getAppConfig({
role: user?.role,
userId: user?.id,
tenantId: user?.tenantId,
});
const balanceConfig = getBalanceConfig(appConfig);

View file

@ -429,7 +429,11 @@ describe('createRemoteAgentAuth', () => {
await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext);
expect(deps.getAppConfig).toHaveBeenNthCalledWith(1, { baseOnly: true });
expect(deps.getAppConfig).toHaveBeenNthCalledWith(2, { tenantId: 'tenant-strict' });
expect(deps.getAppConfig).toHaveBeenNthCalledWith(2, {
role: 'user',
userId: 'uid123',
tenantId: 'tenant-strict',
});
expect(jwt.verify).toHaveBeenCalledTimes(2);
expect((jwt.verify as jest.Mock).mock.calls[1][2]).toEqual(
expect.objectContaining({ audience: 'tenant-audience' }),
@ -461,7 +465,11 @@ describe('createRemoteAgentAuth', () => {
await createRemoteAgentAuth(deps)(req as Request, res, mockNext);
expect(deps.getAppConfig).toHaveBeenNthCalledWith(1, { baseOnly: true });
expect(deps.getAppConfig).toHaveBeenNthCalledWith(2, { tenantId: 'tenant-strict' });
expect(deps.getAppConfig).toHaveBeenNthCalledWith(2, {
role: 'user',
userId: 'uid123',
tenantId: 'tenant-strict',
});
expect(jwt.verify).toHaveBeenCalledTimes(2);
expect(status).toHaveBeenCalledWith(401);
expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' });
@ -493,7 +501,11 @@ describe('createRemoteAgentAuth', () => {
await createRemoteAgentAuth(deps)(req as Request, res, mockNext);
expect(deps.getAppConfig).toHaveBeenNthCalledWith(1, { baseOnly: true });
expect(deps.getAppConfig).toHaveBeenNthCalledWith(2, { tenantId: 'tenant-api-key-only' });
expect(deps.getAppConfig).toHaveBeenNthCalledWith(2, {
role: 'user',
userId: 'uid123',
tenantId: 'tenant-api-key-only',
});
expect(jwt.verify).toHaveBeenCalledTimes(1);
expect(status).toHaveBeenCalledWith(401);
expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' });

View file

@ -247,7 +247,7 @@ function getConfigOptions(req: Request): GetAppConfigOptions {
}
function getUserConfigOptions(user: IUser): GetAppConfigOptions {
if (user.tenantId) return { tenantId: user.tenantId };
if (user.tenantId) return { role: user.role, userId: user.id, tenantId: user.tenantId };
return { baseOnly: true };
}
@ -255,6 +255,8 @@ function isResolvedUserConfigScope(initialOptions: GetAppConfigOptions, user: IU
const userOptions = getUserConfigOptions(user);
return (
initialOptions.tenantId === userOptions.tenantId &&
initialOptions.userId === userOptions.userId &&
initialOptions.role === userOptions.role &&
initialOptions.baseOnly === userOptions.baseOnly
);
}