mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-28 18:31:24 +00:00
🛂 refactor: Accept Targeted assign:configs for Config Scope-Lifecycle Endpoints (#13773)
* 🔓 fix: Accept Targeted assign:configs for Config Scope-Lifecycle Endpoints Three admin-config endpoints currently require broad manage:configs: PUT /:principalType/:principalId for empty-overrides scope creation, DELETE /:principalType/:principalId for scope removal, and PATCH /:principalType/:principalId/active for the active toggle. The capability model already defines assign:configs:user|group|role for delegated administrators and validates that shape in isValidCapability, but no handler accepts it, so a delegate granted assign:configs:role via /api/admin/grants cannot manage scope lifecycle for the principal type they were explicitly delegated. This aligns the server-side auth with the documented capability surface. Every destructive lateral path stays behind broad manage:configs: operations against the base config principal (__base__), non-empty PUT payloads that $set the full overrides field, and DELETE or toggle on a document whose existing overrides are non-empty (which would erase or neutralize sections the caller could not author). The new hasCapability dep on AdminConfigDeps is optional with a false default, so external consumers continue to get pre-PR behavior until they wire the resolver. * 🛡️ fix: Block Assign-Only Scope-Lifecycle When Existing Doc Has Tombstones The existing-overrides guard introduced in the prior commit only checked overrides, but configs also carry tombstones (suppressed inherited field paths) which are iterated during cascade resolution. An assign-only caller could delete, toggle, or empty-upsert a doc whose overrides is empty but whose tombstones is non-empty, which would erase or neutralize suppressions on fields they could not author. Extends the guard at all three call sites to treat a non-empty tombstones array as destructive state. * 🚨 fix: Log TOCTOU Race When Assign-Only Lifecycle Op Hits Non-Empty Doc The empty-state guard for assign-only callers performs a read-then-write across two DB roundtrips, so a concurrent broad-manage write can land between the guard and the destructive op. Adds post-write detection on the delete and toggle handlers: when the destructive op returns a doc whose state was non-empty at write time, emit logger.warn with the caller id, principal, and observed-state counts so ops can detect the race and restore from audit logs. A fully atomic fix would require extending deleteConfig, toggleConfigActive, and upsertConfig in packages/data-schemas/src/methods/config.ts to support compare-and-swap filters, which is a wider design change than this PR's auth scope. Empty-payload upsert is not covered because $set replaces overrides, so the post-write doc no longer reflects pre-write state. * 🔒 fix: Atomic Empty-State Filter for Assign-Only Scope-Lifecycle Writes Replaces the read-then-check guard with an atomic Mongo filter on the destructive write itself. Adds an options.expectEmpty parameter to deleteConfig, toggleConfigActive, and upsertConfig in the shared data-schemas layer. When set, the filter requires both overrides and tombstones to be empty before the write matches. The TOCTOU race window is eliminated: a concurrent write cannot land between the empty-state check and the destructive op because they are now a single atomic operation. For upsertConfig, the E11000 retry path returns null instead of falling back to a filterless update when expectEmpty is set, preserving the atomic property. Handlers fall back to findConfigByPrincipal only to disambiguate the null return between 404 (doc absent) and 403 (doc exists with non-empty state). The post-write logger.warn race detection added in the prior commit is removed as unreachable.
This commit is contained in:
parent
84886c56fb
commit
fa20003952
5 changed files with 671 additions and 28 deletions
|
|
@ -2,6 +2,7 @@ const express = require('express');
|
|||
const { createAdminConfigHandlers } = require('@librechat/api');
|
||||
const { SystemCapabilities } = require('@librechat/data-schemas');
|
||||
const {
|
||||
hasCapability,
|
||||
hasConfigCapability,
|
||||
requireCapability,
|
||||
} = require('~/server/middleware/roles/capabilities');
|
||||
|
|
@ -23,6 +24,7 @@ const handlers = createAdminConfigHandlers({
|
|||
deleteConfig: db.deleteConfig,
|
||||
toggleConfigActive: db.toggleConfigActive,
|
||||
hasConfigCapability,
|
||||
hasCapability,
|
||||
getAppConfig,
|
||||
invalidateConfigCaches,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ function createHandlers(overrides = {}) {
|
|||
deleteConfig: jest.fn().mockResolvedValue({ _id: 'c1' }),
|
||||
toggleConfigActive: jest.fn().mockResolvedValue({ _id: 'c1', isActive: false }),
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(true),
|
||||
hasCapability: jest.fn().mockResolvedValue(true),
|
||||
|
||||
getAppConfig: jest.fn().mockResolvedValue({ interface: { modelSelect: true } }),
|
||||
...overrides,
|
||||
|
|
@ -819,6 +820,7 @@ describe('createAdminConfigHandlers', () => {
|
|||
it('returns 403 for empty overrides when user lacks MANAGE_CONFIGS', async () => {
|
||||
const { handlers } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn().mockResolvedValue(false),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
|
|
@ -853,7 +855,15 @@ describe('createAdminConfigHandlers', () => {
|
|||
expect(res.statusCode).toBe(201);
|
||||
expect(res.body).toHaveProperty('config');
|
||||
expect(res.body?.config).toHaveProperty('_id', 'c1');
|
||||
expect(upsertConfig).toHaveBeenCalledWith('role', 'admin', expect.anything(), {}, 5);
|
||||
expect(upsertConfig).toHaveBeenCalledWith(
|
||||
'role',
|
||||
'admin',
|
||||
expect.anything(),
|
||||
{},
|
||||
5,
|
||||
undefined,
|
||||
expect.objectContaining({ expectEmpty: expect.any(Boolean) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns no-op message when overrides is empty and no priority is provided', async () => {
|
||||
|
|
@ -892,6 +902,7 @@ describe('createAdminConfigHandlers', () => {
|
|||
it('returns 403 for empty overrides with priority when user lacks MANAGE_CONFIGS', async () => {
|
||||
const { handlers } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn().mockResolvedValue(false),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
|
|
@ -968,6 +979,418 @@ describe('createAdminConfigHandlers', () => {
|
|||
},
|
||||
];
|
||||
|
||||
describe('upsertConfigOverrides: scope-lifecycle auth (ASSIGN_CONFIGS)', () => {
|
||||
it('allows empty-overrides scope creation when caller has ASSIGN_CONFIGS only', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn().mockResolvedValue(true),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { overrides: {}, priority: 5 },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.upsertConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(deps.upsertConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects non-empty overrides for ASSIGN_CONFIGS-only caller', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn().mockResolvedValue(true),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { overrides: { memory: { enabled: true } } },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.upsertConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(deps.upsertConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects empty-overrides scope creation when caller has neither grant', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn().mockResolvedValue(false),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { overrides: {}, priority: 5 },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.upsertConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(deps.upsertConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteConfigOverrides: accepts ASSIGN_CONFIGS', () => {
|
||||
it('allows delete when caller has ASSIGN_CONFIGS only', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn().mockResolvedValue(true),
|
||||
});
|
||||
const req = mockReq({ params: { principalType: 'role', principalId: 'admin' } });
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.deleteConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(deps.deleteConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects delete when caller has neither broad manage nor ASSIGN_CONFIGS', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn().mockResolvedValue(false),
|
||||
});
|
||||
const req = mockReq({ params: { principalType: 'role', principalId: 'admin' } });
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.deleteConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(deps.deleteConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleConfig: accepts ASSIGN_CONFIGS', () => {
|
||||
it('allows toggle when caller has ASSIGN_CONFIGS only', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn().mockResolvedValue(true),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { isActive: false },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.toggleConfig(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(deps.toggleConfigActive).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects toggle when caller has neither broad manage nor ASSIGN_CONFIGS', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn().mockResolvedValue(false),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { isActive: false },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.toggleConfig(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(deps.toggleConfigActive).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scope-lifecycle: parameterized assign:configs check', () => {
|
||||
it('allows upsert when caller holds assign:configs:<principalType>', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn(async (_user, cap) => cap === 'assign:configs:role'),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { overrides: {}, priority: 5 },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.upsertConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(deps.upsertConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects upsert when parameterized grant targets a different principalType', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn(async (_user, cap) => cap === 'assign:configs:user'),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { overrides: {}, priority: 5 },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.upsertConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(deps.upsertConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queries hasCapability with the principalType-parameterized form', async () => {
|
||||
const hasCap = jest.fn().mockResolvedValue(true);
|
||||
const { handlers } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: hasCap,
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'group', principalId: 'engineers' },
|
||||
body: { isActive: false },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.toggleConfig(req, res);
|
||||
|
||||
const queriedCapabilities = hasCap.mock.calls.map((call) => call[1]);
|
||||
expect(queriedCapabilities).toContain('assign:configs:group');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scope-lifecycle: __base__ short-circuit', () => {
|
||||
it('upsert against __base__ returns 403 for assign-only caller', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn().mockResolvedValue(true),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: '__base__' },
|
||||
body: { overrides: {}, priority: 5 },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.upsertConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(deps.upsertConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delete against __base__ returns 403 for assign-only caller', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn().mockResolvedValue(true),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: '__base__' },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.deleteConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(deps.deleteConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggle against __base__ returns 403 for assign-only caller', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn().mockResolvedValue(true),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: '__base__' },
|
||||
body: { isActive: false },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.toggleConfig(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(deps.toggleConfigActive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upsert against __base__ succeeds for broad-manage caller', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(true),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: '__base__' },
|
||||
body: { overrides: {}, priority: 5 },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.upsertConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(deps.upsertConfig).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scope-lifecycle: atomic empty-state guard for assign-only callers', () => {
|
||||
it('empty-overrides upsert is rejected when atomic filter mismatches (existing doc not empty)', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn().mockResolvedValue(true),
|
||||
upsertConfig: jest.fn(async (_pt, _pi, _pm, _o, _p, _session, opts) => {
|
||||
return opts?.expectEmpty ? null : { _id: 'c1', configVersion: 1 };
|
||||
}),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { overrides: {}, priority: 5 },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.upsertConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(deps.upsertConfig).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
{ expectEmpty: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('delete is rejected with 403 when atomic filter mismatches and doc still exists', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn().mockResolvedValue(true),
|
||||
deleteConfig: jest.fn(async (_pt, _pi, _session, opts) => {
|
||||
return opts?.expectEmpty ? null : { _id: 'c1' };
|
||||
}),
|
||||
findConfigByPrincipal: jest.fn().mockResolvedValue({
|
||||
_id: 'c1',
|
||||
overrides: { endpoints: { custom: true } },
|
||||
}),
|
||||
});
|
||||
const req = mockReq({ params: { principalType: 'role', principalId: 'admin' } });
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.deleteConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(deps.deleteConfig).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
{ expectEmpty: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('toggle is rejected with 403 when atomic filter mismatches and doc still exists', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn().mockResolvedValue(true),
|
||||
toggleConfigActive: jest.fn(async (_pt, _pi, _isActive, _session, opts) => {
|
||||
return opts?.expectEmpty ? null : { _id: 'c1', isActive: false };
|
||||
}),
|
||||
findConfigByPrincipal: jest.fn().mockResolvedValue({
|
||||
_id: 'c1',
|
||||
tombstones: ['endpoints.openai.apiKey'],
|
||||
}),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { isActive: false },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.toggleConfig(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(deps.toggleConfigActive).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
{ expectEmpty: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('delete returns 404 when atomic filter mismatches and doc does not exist', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn().mockResolvedValue(true),
|
||||
deleteConfig: jest.fn().mockResolvedValue(null),
|
||||
findConfigByPrincipal: jest.fn().mockResolvedValue(null),
|
||||
});
|
||||
const req = mockReq({ params: { principalType: 'role', principalId: 'admin' } });
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.deleteConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(deps.findConfigByPrincipal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delete succeeds for assign-only caller when atomic filter matches', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn().mockResolvedValue(true),
|
||||
deleteConfig: jest.fn().mockResolvedValue({ _id: 'c1', overrides: {} }),
|
||||
});
|
||||
const req = mockReq({ params: { principalType: 'role', principalId: 'admin' } });
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.deleteConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(deps.deleteConfig).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
{ expectEmpty: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('broad-manage caller calls destructive op without expectEmpty option', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(true),
|
||||
deleteConfig: jest.fn().mockResolvedValue({
|
||||
_id: 'c1',
|
||||
overrides: { endpoints: { custom: true } },
|
||||
}),
|
||||
});
|
||||
const req = mockReq({ params: { principalType: 'role', principalId: 'admin' } });
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.deleteConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(deps.deleteConfig).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
{ expectEmpty: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminConfigDeps.hasCapability is optional', () => {
|
||||
it('factory accepts deps without hasCapability and falls back to broad-manage-only auth', async () => {
|
||||
const deps = {
|
||||
listAllConfigs: jest.fn().mockResolvedValue([]),
|
||||
findConfigByPrincipal: jest.fn().mockResolvedValue(null),
|
||||
upsertConfig: jest.fn().mockResolvedValue({ _id: 'c1', configVersion: 1 }),
|
||||
patchConfigFields: jest.fn(),
|
||||
tombstoneConfigField: jest.fn(),
|
||||
unsetConfigField: jest.fn(),
|
||||
deleteConfig: jest.fn().mockResolvedValue({ _id: 'c1' }),
|
||||
toggleConfigActive: jest.fn().mockResolvedValue({ _id: 'c1', isActive: false }),
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
const handlers = createAdminConfigHandlers(deps);
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { overrides: {}, priority: 5 },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.upsertConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(deps.upsertConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invariant: all mutation handlers return 401 without auth', () => {
|
||||
for (const { name, reqOverrides } of MUTATION_HANDLERS) {
|
||||
it(`${name} returns 401 when user is missing`, async () => {
|
||||
|
|
@ -990,6 +1413,7 @@ describe('createAdminConfigHandlers', () => {
|
|||
it(`${name} returns 403 when user lacks capability`, async () => {
|
||||
const { handlers } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
hasCapability: jest.fn().mockResolvedValue(false),
|
||||
});
|
||||
const req = mockReq(reqOverrides);
|
||||
const res = mockRes();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { logger, BASE_CONFIG_PRINCIPAL_ID } from '@librechat/data-schemas';
|
||||
import {
|
||||
BASE_ONLY_CONFIG_SECTIONS,
|
||||
PrincipalType,
|
||||
|
|
@ -6,7 +6,7 @@ import {
|
|||
INTERFACE_PERMISSION_FIELDS,
|
||||
PERMISSION_SUB_KEYS,
|
||||
} from 'librechat-data-provider';
|
||||
import type { AppConfig, ConfigSection, IConfig } from '@librechat/data-schemas';
|
||||
import type { AppConfig, ConfigSection, IConfig, SystemCapability } from '@librechat/data-schemas';
|
||||
import type { TCustomConfig } from 'librechat-data-provider';
|
||||
import type { Types, ClientSession } from 'mongoose';
|
||||
import type { Response } from 'express';
|
||||
|
|
@ -79,6 +79,7 @@ export interface AdminConfigDeps {
|
|||
overrides: Partial<TCustomConfig>,
|
||||
priority: number,
|
||||
session?: ClientSession,
|
||||
options?: { expectEmpty?: boolean },
|
||||
) => Promise<IConfig | null>;
|
||||
patchConfigFields: (
|
||||
principalType: PrincipalType,
|
||||
|
|
@ -106,18 +107,21 @@ export interface AdminConfigDeps {
|
|||
principalType: PrincipalType,
|
||||
principalId: string | Types.ObjectId,
|
||||
session?: ClientSession,
|
||||
options?: { expectEmpty?: boolean },
|
||||
) => Promise<IConfig | null>;
|
||||
toggleConfigActive: (
|
||||
principalType: PrincipalType,
|
||||
principalId: string | Types.ObjectId,
|
||||
isActive: boolean,
|
||||
session?: ClientSession,
|
||||
options?: { expectEmpty?: boolean },
|
||||
) => Promise<IConfig | null>;
|
||||
hasConfigCapability: (
|
||||
user: CapabilityUser,
|
||||
section: ConfigSection | null,
|
||||
verb?: 'manage' | 'read',
|
||||
) => Promise<boolean>;
|
||||
hasCapability?: (user: CapabilityUser, capability: SystemCapability) => Promise<boolean>;
|
||||
getAppConfig?: (options?: {
|
||||
role?: string;
|
||||
userId?: string;
|
||||
|
|
@ -192,6 +196,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
|
|||
deleteConfig,
|
||||
toggleConfigActive,
|
||||
hasConfigCapability,
|
||||
hasCapability = async () => false,
|
||||
getAppConfig,
|
||||
invalidateConfigCaches,
|
||||
} = deps;
|
||||
|
|
@ -318,7 +323,17 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
|
|||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!(await hasConfigCapability(user, null, 'manage'))) {
|
||||
const hasBroadManage = await hasConfigCapability(user, null, 'manage');
|
||||
|
||||
if (principalId === BASE_CONFIG_PRINCIPAL_ID && !hasBroadManage) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
const hasAssignConfigs =
|
||||
hasBroadManage ||
|
||||
(await hasCapability(user, `assign:configs:${principalType}` as SystemCapability));
|
||||
|
||||
if (!hasAssignConfigs) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
|
|
@ -374,16 +389,8 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
|
|||
return res.status(200).json({ message: 'No actionable override sections provided' });
|
||||
}
|
||||
|
||||
if (overrideSections.length > 0) {
|
||||
const allowed = await Promise.all(
|
||||
overrideSections.map((s) => hasConfigCapability(user, s as ConfigSection, 'manage')),
|
||||
);
|
||||
const denied = overrideSections.find((_, i) => !allowed[i]);
|
||||
if (denied) {
|
||||
return res.status(403).json({
|
||||
error: `Insufficient permissions for config section: ${denied}`,
|
||||
});
|
||||
}
|
||||
if (overrideSections.length > 0 && !hasBroadManage) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
const config = await upsertConfig(
|
||||
|
|
@ -392,7 +399,12 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
|
|||
principalModel(principalType),
|
||||
filteredOverrides,
|
||||
priority ?? DEFAULT_PRIORITY,
|
||||
undefined,
|
||||
{ expectEmpty: !hasBroadManage },
|
||||
);
|
||||
if (!config && !hasBroadManage) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
invalidateConfigCaches?.(user.tenantId)?.catch((err) =>
|
||||
logger.error('[adminConfig] Cache invalidation failed after upsert:', err),
|
||||
|
|
@ -697,12 +709,31 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
|
|||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!(await hasConfigCapability(user, null, 'manage'))) {
|
||||
const hasBroadManage = await hasConfigCapability(user, null, 'manage');
|
||||
|
||||
if (principalId === BASE_CONFIG_PRINCIPAL_ID && !hasBroadManage) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
const config = await deleteConfig(principalType, principalId);
|
||||
const allowed =
|
||||
hasBroadManage ||
|
||||
(await hasCapability(user, `assign:configs:${principalType}` as SystemCapability));
|
||||
if (!allowed) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
const config = await deleteConfig(principalType, principalId, undefined, {
|
||||
expectEmpty: !hasBroadManage,
|
||||
});
|
||||
if (!config) {
|
||||
if (!hasBroadManage) {
|
||||
const exists = await findConfigByPrincipal(principalType, principalId, {
|
||||
includeInactive: true,
|
||||
});
|
||||
if (exists) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
}
|
||||
return res.status(404).json({ error: 'Config not found' });
|
||||
}
|
||||
|
||||
|
|
@ -740,12 +771,31 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): {
|
|||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!(await hasConfigCapability(user, null, 'manage'))) {
|
||||
const hasBroadManage = await hasConfigCapability(user, null, 'manage');
|
||||
|
||||
if (principalId === BASE_CONFIG_PRINCIPAL_ID && !hasBroadManage) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
const config = await toggleConfigActive(principalType, principalId, isActive);
|
||||
const allowed =
|
||||
hasBroadManage ||
|
||||
(await hasCapability(user, `assign:configs:${principalType}` as SystemCapability));
|
||||
if (!allowed) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
const config = await toggleConfigActive(principalType, principalId, isActive, undefined, {
|
||||
expectEmpty: !hasBroadManage,
|
||||
});
|
||||
if (!config) {
|
||||
if (!hasBroadManage) {
|
||||
const exists = await findConfigByPrincipal(principalType, principalId, {
|
||||
includeInactive: true,
|
||||
});
|
||||
if (exists) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
}
|
||||
return res.status(404).json({ error: 'Config not found' });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import mongoose, { Types } from 'mongoose';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import { PrincipalType, PrincipalModel } from 'librechat-data-provider';
|
||||
import type { IConfig } from '~/types';
|
||||
import { createConfigMethods } from './config';
|
||||
import configSchema from '~/schema/config';
|
||||
import type { IConfig } from '~/types';
|
||||
|
||||
let mongoServer: MongoMemoryServer;
|
||||
let methods: ReturnType<typeof createConfigMethods>;
|
||||
|
|
@ -476,3 +476,139 @@ describe('toggleConfigActive', () => {
|
|||
expect(result!.isActive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expectEmpty atomic guard', () => {
|
||||
it('deleteConfig with expectEmpty matches and deletes an empty doc', async () => {
|
||||
await methods.upsertConfig(PrincipalType.ROLE, 'admin', PrincipalModel.ROLE, {}, 10);
|
||||
|
||||
const result = await methods.deleteConfig(PrincipalType.ROLE, 'admin', undefined, {
|
||||
expectEmpty: true,
|
||||
});
|
||||
expect(result).toBeTruthy();
|
||||
|
||||
const remaining = await mongoose.models.Config.countDocuments({});
|
||||
expect(remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('deleteConfig with expectEmpty returns null when overrides is non-empty (doc preserved)', async () => {
|
||||
await methods.upsertConfig(
|
||||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
PrincipalModel.ROLE,
|
||||
{ interface: { modelSelect: false } },
|
||||
10,
|
||||
);
|
||||
|
||||
const result = await methods.deleteConfig(PrincipalType.ROLE, 'admin', undefined, {
|
||||
expectEmpty: true,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
|
||||
const remaining = await mongoose.models.Config.countDocuments({});
|
||||
expect(remaining).toBe(1);
|
||||
});
|
||||
|
||||
it('deleteConfig with expectEmpty returns null when tombstones is non-empty (doc preserved)', async () => {
|
||||
await mongoose.models.Config.create({
|
||||
principalType: PrincipalType.ROLE,
|
||||
principalId: 'admin',
|
||||
principalModel: PrincipalModel.ROLE,
|
||||
overrides: {},
|
||||
tombstones: ['endpoints.openai.apiKey'],
|
||||
priority: 10,
|
||||
isActive: true,
|
||||
configVersion: 1,
|
||||
});
|
||||
|
||||
const result = await methods.deleteConfig(PrincipalType.ROLE, 'admin', undefined, {
|
||||
expectEmpty: true,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
|
||||
const remaining = await mongoose.models.Config.countDocuments({});
|
||||
expect(remaining).toBe(1);
|
||||
});
|
||||
|
||||
it('toggleConfigActive with expectEmpty matches and toggles an empty doc', async () => {
|
||||
await methods.upsertConfig(PrincipalType.ROLE, 'admin', PrincipalModel.ROLE, {}, 10);
|
||||
|
||||
const result = await methods.toggleConfigActive(PrincipalType.ROLE, 'admin', false, undefined, {
|
||||
expectEmpty: true,
|
||||
});
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('toggleConfigActive with expectEmpty returns null on non-empty doc (isActive preserved)', async () => {
|
||||
await methods.upsertConfig(
|
||||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
PrincipalModel.ROLE,
|
||||
{ interface: { modelSelect: false } },
|
||||
10,
|
||||
);
|
||||
|
||||
const result = await methods.toggleConfigActive(PrincipalType.ROLE, 'admin', false, undefined, {
|
||||
expectEmpty: true,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
|
||||
const doc = await methods.findConfigByPrincipal(PrincipalType.ROLE, 'admin');
|
||||
expect(doc!.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('upsertConfig with expectEmpty inserts when no doc exists', async () => {
|
||||
const result = await methods.upsertConfig(
|
||||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
PrincipalModel.ROLE,
|
||||
{},
|
||||
10,
|
||||
undefined,
|
||||
{ expectEmpty: true },
|
||||
);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.principalId).toBe('admin');
|
||||
});
|
||||
|
||||
it('upsertConfig with expectEmpty updates an empty existing doc', async () => {
|
||||
await methods.upsertConfig(PrincipalType.ROLE, 'admin', PrincipalModel.ROLE, {}, 5);
|
||||
|
||||
const result = await methods.upsertConfig(
|
||||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
PrincipalModel.ROLE,
|
||||
{},
|
||||
99,
|
||||
undefined,
|
||||
{ expectEmpty: true },
|
||||
);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result!.priority).toBe(99);
|
||||
});
|
||||
|
||||
it('upsertConfig with expectEmpty returns null when existing doc has non-empty overrides', async () => {
|
||||
await methods.upsertConfig(
|
||||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
PrincipalModel.ROLE,
|
||||
{ interface: { modelSelect: false } },
|
||||
10,
|
||||
);
|
||||
|
||||
const result = await methods.upsertConfig(
|
||||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
PrincipalModel.ROLE,
|
||||
{},
|
||||
99,
|
||||
undefined,
|
||||
{ expectEmpty: true },
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
|
||||
const doc = await methods.findConfigByPrincipal(PrincipalType.ROLE, 'admin');
|
||||
expect(doc!.priority).toBe(10);
|
||||
expect(Object.keys(doc!.overrides ?? {}).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Types } from 'mongoose';
|
||||
import { PrincipalType, PrincipalModel } from 'librechat-data-provider';
|
||||
import type { FilterQuery, Model, ClientSession } from 'mongoose';
|
||||
import type { TCustomConfig } from 'librechat-data-provider';
|
||||
import type { Model, ClientSession } from 'mongoose';
|
||||
import type { IConfig } from '~/types';
|
||||
import { BASE_CONFIG_PRINCIPAL_ID } from '~/admin/capabilities';
|
||||
import { escapeRegExp } from '~/utils/string';
|
||||
|
|
@ -37,6 +37,7 @@ export function createConfigMethods(mongoose: typeof import('mongoose')): {
|
|||
overrides: Partial<TCustomConfig>,
|
||||
priority: number,
|
||||
session?: ClientSession,
|
||||
options?: { expectEmpty?: boolean },
|
||||
) => Promise<IConfig | null>;
|
||||
patchConfigFields: (
|
||||
principalType: PrincipalType,
|
||||
|
|
@ -64,12 +65,14 @@ export function createConfigMethods(mongoose: typeof import('mongoose')): {
|
|||
principalType: PrincipalType,
|
||||
principalId: string | Types.ObjectId,
|
||||
session?: ClientSession,
|
||||
options?: { expectEmpty?: boolean },
|
||||
) => Promise<IConfig | null>;
|
||||
toggleConfigActive: (
|
||||
principalType: PrincipalType,
|
||||
principalId: string | Types.ObjectId,
|
||||
isActive: boolean,
|
||||
session?: ClientSession,
|
||||
options?: { expectEmpty?: boolean },
|
||||
) => Promise<IConfig | null>;
|
||||
} {
|
||||
async function findConfigByPrincipal(
|
||||
|
|
@ -146,13 +149,20 @@ export function createConfigMethods(mongoose: typeof import('mongoose')): {
|
|||
overrides: Partial<TCustomConfig>,
|
||||
priority: number,
|
||||
session?: ClientSession,
|
||||
options?: { expectEmpty?: boolean },
|
||||
): Promise<IConfig | null> {
|
||||
const Config = mongoose.models.Config as Model<IConfig>;
|
||||
|
||||
const query = {
|
||||
const query: FilterQuery<IConfig> = {
|
||||
principalType,
|
||||
principalId: principalId.toString(),
|
||||
};
|
||||
if (options?.expectEmpty) {
|
||||
query.$and = [
|
||||
{ $or: [{ overrides: { $eq: {} } }, { overrides: { $exists: false } }] },
|
||||
{ $or: [{ tombstones: { $size: 0 } }, { tombstones: { $exists: false } }] },
|
||||
];
|
||||
}
|
||||
|
||||
const update = {
|
||||
$set: {
|
||||
|
|
@ -164,7 +174,7 @@ export function createConfigMethods(mongoose: typeof import('mongoose')): {
|
|||
$inc: { configVersion: 1 },
|
||||
};
|
||||
|
||||
const options = {
|
||||
const mongoOptions = {
|
||||
upsert: true,
|
||||
new: true,
|
||||
setDefaultsOnInsert: true,
|
||||
|
|
@ -172,11 +182,14 @@ export function createConfigMethods(mongoose: typeof import('mongoose')): {
|
|||
};
|
||||
|
||||
try {
|
||||
return await Config.findOneAndUpdate(query, update, options);
|
||||
return await Config.findOneAndUpdate(query, update, mongoOptions);
|
||||
} catch (err: unknown) {
|
||||
if ((err as { code?: number }).code === 11000) {
|
||||
if (options?.expectEmpty) {
|
||||
return null;
|
||||
}
|
||||
return await Config.findOneAndUpdate(
|
||||
query,
|
||||
{ principalType, principalId: principalId.toString() },
|
||||
{ $set: update.$set, $inc: update.$inc },
|
||||
{ new: true, ...(session ? { session } : {}) },
|
||||
);
|
||||
|
|
@ -289,13 +302,20 @@ export function createConfigMethods(mongoose: typeof import('mongoose')): {
|
|||
principalType: PrincipalType,
|
||||
principalId: string | Types.ObjectId,
|
||||
session?: ClientSession,
|
||||
options?: { expectEmpty?: boolean },
|
||||
): Promise<IConfig | null> {
|
||||
const Config = mongoose.models.Config as Model<IConfig>;
|
||||
|
||||
return await Config.findOneAndDelete({
|
||||
const filter: FilterQuery<IConfig> = {
|
||||
principalType,
|
||||
principalId: principalId.toString(),
|
||||
}).session(session ?? null);
|
||||
};
|
||||
if (options?.expectEmpty) {
|
||||
filter.$and = [
|
||||
{ $or: [{ overrides: { $eq: {} } }, { overrides: { $exists: false } }] },
|
||||
{ $or: [{ tombstones: { $size: 0 } }, { tombstones: { $exists: false } }] },
|
||||
];
|
||||
}
|
||||
return await Config.findOneAndDelete(filter).session(session ?? null);
|
||||
}
|
||||
|
||||
async function toggleConfigActive(
|
||||
|
|
@ -303,10 +323,21 @@ export function createConfigMethods(mongoose: typeof import('mongoose')): {
|
|||
principalId: string | Types.ObjectId,
|
||||
isActive: boolean,
|
||||
session?: ClientSession,
|
||||
options?: { expectEmpty?: boolean },
|
||||
): Promise<IConfig | null> {
|
||||
const Config = mongoose.models.Config as Model<IConfig>;
|
||||
const filter: FilterQuery<IConfig> = {
|
||||
principalType,
|
||||
principalId: principalId.toString(),
|
||||
};
|
||||
if (options?.expectEmpty) {
|
||||
filter.$and = [
|
||||
{ $or: [{ overrides: { $eq: {} } }, { overrides: { $exists: false } }] },
|
||||
{ $or: [{ tombstones: { $size: 0 } }, { tombstones: { $exists: false } }] },
|
||||
];
|
||||
}
|
||||
return await Config.findOneAndUpdate(
|
||||
{ principalType, principalId: principalId.toString() },
|
||||
filter,
|
||||
{ $set: { isActive } },
|
||||
{ new: true, ...(session ? { session } : {}) },
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue