mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 04:12:36 +00:00
fix: preserve role SHARE permissions across boot in initializeRoles (#14022)
* fix: preserve role SHARE permissions across boot in initializeRoles * chore: sort role method spec imports --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
68bb533083
commit
38ab4add3d
3 changed files with 215 additions and 4 deletions
|
|
@ -14,6 +14,7 @@ beforeAll(async () => {
|
|||
if (!mongoose.models.Config) {
|
||||
mongoose.model<IConfig>('Config', configSchema);
|
||||
}
|
||||
await mongoose.models.Config.init();
|
||||
methods = createConfigMethods(mongoose);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import mongoose from 'mongoose';
|
|||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import { SystemRoles, Permissions, roleDefaults, PermissionTypes } from 'librechat-data-provider';
|
||||
import type { IRole, IUser, RolePermissions } from '..';
|
||||
import { createRoleMethods } from './role';
|
||||
import { createModels } from '../models';
|
||||
import { _resetStrictCache } from '../models/plugins/tenantIsolation';
|
||||
import { tenantStorage } from '~/config/tenantContext';
|
||||
import { createRoleMethods } from './role';
|
||||
import { createModels } from '../models';
|
||||
|
||||
jest.mock('~/config/winston', () => ({
|
||||
error: jest.fn(),
|
||||
|
|
@ -510,6 +510,7 @@ describe('initializeRoles', () => {
|
|||
[Permissions.USE]: false,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.SHARE]: true,
|
||||
[Permissions.SHARE_PUBLIC]: false,
|
||||
},
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
},
|
||||
|
|
@ -575,6 +576,7 @@ describe('initializeRoles', () => {
|
|||
[Permissions.USE]: false,
|
||||
[Permissions.CREATE]: false,
|
||||
[Permissions.SHARE]: false,
|
||||
[Permissions.SHARE_PUBLIC]: false,
|
||||
},
|
||||
[PermissionTypes.BOOKMARKS]:
|
||||
roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.BOOKMARKS],
|
||||
|
|
@ -630,6 +632,177 @@ describe('initializeRoles', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('initializeRoles - SHARE permission preservation', () => {
|
||||
beforeEach(async () => {
|
||||
await Role.deleteMany({});
|
||||
});
|
||||
|
||||
it('preserves a fully-populated USER AGENTS block (SHARE stays true)', async () => {
|
||||
await new Role({
|
||||
name: SystemRoles.USER,
|
||||
permissions: {
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.SHARE]: true,
|
||||
[Permissions.SHARE_PUBLIC]: false,
|
||||
},
|
||||
},
|
||||
}).save();
|
||||
|
||||
await initializeRoles();
|
||||
|
||||
const userRole = await getRoleByName(SystemRoles.USER);
|
||||
expect(userRole.permissions[PermissionTypes.AGENTS]?.SHARE).toBe(true);
|
||||
expect(userRole.permissions[PermissionTypes.AGENTS]?.SHARE_PUBLIC).toBe(false);
|
||||
expect(userRole.permissions[PermissionTypes.AGENTS]?.USE).toBe(true);
|
||||
expect(userRole.permissions[PermissionTypes.AGENTS]?.CREATE).toBe(true);
|
||||
});
|
||||
|
||||
it('fills missing fields without clobbering a present SHARE:true on USER AGENTS', async () => {
|
||||
await new Role({
|
||||
name: SystemRoles.USER,
|
||||
permissions: {
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.SHARE]: true,
|
||||
},
|
||||
},
|
||||
}).save();
|
||||
|
||||
await initializeRoles();
|
||||
|
||||
const userRole = await getRoleByName(SystemRoles.USER);
|
||||
// Present field preserved.
|
||||
expect(userRole.permissions[PermissionTypes.AGENTS]?.SHARE).toBe(true);
|
||||
// Genuinely-missing fields filled from the USER default.
|
||||
expect(userRole.permissions[PermissionTypes.AGENTS]?.USE).toBe(true);
|
||||
expect(userRole.permissions[PermissionTypes.AGENTS]?.CREATE).toBe(true);
|
||||
expect(userRole.permissions[PermissionTypes.AGENTS]?.SHARE_PUBLIC).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves an all-true ADMIN AGENTS block unchanged', async () => {
|
||||
await new Role({
|
||||
name: SystemRoles.ADMIN,
|
||||
permissions: {
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.SHARE]: true,
|
||||
[Permissions.SHARE_PUBLIC]: true,
|
||||
},
|
||||
},
|
||||
}).save();
|
||||
|
||||
await initializeRoles();
|
||||
|
||||
const adminRole = await getRoleByName(SystemRoles.ADMIN);
|
||||
expect(adminRole.permissions[PermissionTypes.AGENTS]).toEqual({
|
||||
USE: true,
|
||||
CREATE: true,
|
||||
SHARE: true,
|
||||
SHARE_PUBLIC: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('documents that hardening alone does not rescue a stripped (empty) block', async () => {
|
||||
// An empty block carries no SHARED_GLOBAL to migrate, so the per-field merge fills USER defaults (SHARE:false).
|
||||
await new Role({
|
||||
name: SystemRoles.USER,
|
||||
permissions: {
|
||||
[PermissionTypes.AGENTS]: {},
|
||||
},
|
||||
}).save();
|
||||
|
||||
await initializeRoles();
|
||||
|
||||
const userRole = await getRoleByName(SystemRoles.USER);
|
||||
expect(userRole.permissions[PermissionTypes.AGENTS]?.SHARE).toBe(false);
|
||||
expect(userRole.permissions[PermissionTypes.AGENTS]?.USE).toBe(true);
|
||||
expect(userRole.permissions[PermissionTypes.AGENTS]?.CREATE).toBe(true);
|
||||
expect(userRole.permissions[PermissionTypes.AGENTS]?.SHARE_PUBLIC).toBe(false);
|
||||
});
|
||||
|
||||
it('migrates AGENTS.SHARED_GLOBAL:true to SHARE:true and removes SHARED_GLOBAL', async () => {
|
||||
// Raw insert keeps the off-schema SHARED_GLOBAL field that strict mode would strip.
|
||||
await Role.collection.insertOne({
|
||||
name: SystemRoles.USER,
|
||||
permissions: { AGENTS: { SHARED_GLOBAL: true } },
|
||||
});
|
||||
|
||||
await initializeRoles();
|
||||
|
||||
const doc = await Role.collection.findOne({ name: SystemRoles.USER });
|
||||
expect(doc?.permissions.AGENTS.SHARE).toBe(true);
|
||||
expect(doc?.permissions.AGENTS.SHARED_GLOBAL).toBeUndefined();
|
||||
// Migration maps SHARED_GLOBAL to SHARE only, never SHARE_PUBLIC.
|
||||
expect(doc?.permissions.AGENTS.SHARE_PUBLIC).toBe(false);
|
||||
});
|
||||
|
||||
it('migrates AGENTS.SHARED_GLOBAL:false to SHARE:false and removes SHARED_GLOBAL', async () => {
|
||||
await Role.collection.insertOne({
|
||||
name: SystemRoles.USER,
|
||||
permissions: { AGENTS: { SHARED_GLOBAL: false } },
|
||||
});
|
||||
|
||||
await initializeRoles();
|
||||
|
||||
const doc = await Role.collection.findOne({ name: SystemRoles.USER });
|
||||
expect(doc?.permissions.AGENTS.SHARE).toBe(false);
|
||||
expect(doc?.permissions.AGENTS.SHARED_GLOBAL).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves an existing SHARE:false when SHARED_GLOBAL:true is also present', async () => {
|
||||
await Role.collection.insertOne({
|
||||
name: SystemRoles.USER,
|
||||
permissions: { AGENTS: { SHARE: false, SHARED_GLOBAL: true } },
|
||||
});
|
||||
|
||||
await initializeRoles();
|
||||
|
||||
const doc = await Role.collection.findOne({ name: SystemRoles.USER });
|
||||
expect(doc?.permissions.AGENTS.SHARE).toBe(false);
|
||||
expect(doc?.permissions.AGENTS.SHARED_GLOBAL).toBeUndefined();
|
||||
});
|
||||
|
||||
it('migrates PROMPTS.SHARED_GLOBAL:true to PROMPTS.SHARE:true', async () => {
|
||||
await Role.collection.insertOne({
|
||||
name: SystemRoles.USER,
|
||||
permissions: { PROMPTS: { SHARED_GLOBAL: true } },
|
||||
});
|
||||
|
||||
await initializeRoles();
|
||||
|
||||
const doc = await Role.collection.findOne({ name: SystemRoles.USER });
|
||||
expect(doc?.permissions.PROMPTS.SHARE).toBe(true);
|
||||
expect(doc?.permissions.PROMPTS.SHARED_GLOBAL).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is a no-op on a clean DB with no legacy SHARED_GLOBAL', async () => {
|
||||
await initializeRoles();
|
||||
|
||||
const userDoc = await Role.collection.findOne({ name: SystemRoles.USER });
|
||||
const adminDoc = await Role.collection.findOne({ name: SystemRoles.ADMIN });
|
||||
expect('SHARED_GLOBAL' in (userDoc?.permissions.AGENTS ?? {})).toBe(false);
|
||||
expect('SHARED_GLOBAL' in (userDoc?.permissions.PROMPTS ?? {})).toBe(false);
|
||||
expect('SHARED_GLOBAL' in (adminDoc?.permissions.AGENTS ?? {})).toBe(false);
|
||||
expect('SHARED_GLOBAL' in (adminDoc?.permissions.PROMPTS ?? {})).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the migrated SHARE:true across a second initializeRoles run', async () => {
|
||||
await Role.collection.insertOne({
|
||||
name: SystemRoles.USER,
|
||||
permissions: { AGENTS: { SHARED_GLOBAL: true } },
|
||||
});
|
||||
|
||||
await initializeRoles();
|
||||
await initializeRoles();
|
||||
|
||||
const doc = await Role.collection.findOne({ name: SystemRoles.USER });
|
||||
expect(doc?.permissions.AGENTS.SHARE).toBe(true);
|
||||
expect(doc?.permissions.AGENTS.SHARED_GLOBAL).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRoleByName', () => {
|
||||
it('creates a custom role and caches it', async () => {
|
||||
const role = await createRoleByName({ name: 'editor', description: 'Can edit' });
|
||||
|
|
|
|||
|
|
@ -75,6 +75,30 @@ export function createRoleMethods(
|
|||
const Role = mongoose.models.Role;
|
||||
|
||||
for (const roleName of [SystemRoles.ADMIN, SystemRoles.USER]) {
|
||||
// Strict mode hides off-schema SHARED_GLOBAL and won't $unset it on save; migrate it via the raw driver.
|
||||
// eslint-disable-next-line no-restricted-syntax -- Role is a global (non-tenant) collection; raw read is required to see the off-schema field.
|
||||
const legacyDoc = await Role.collection.findOne({ name: roleName });
|
||||
if (legacyDoc?.permissions) {
|
||||
const set: Record<string, unknown> = {};
|
||||
const unset: Record<string, ''> = {};
|
||||
for (const permType of ['PROMPTS', 'AGENTS']) {
|
||||
const block = legacyDoc.permissions[permType];
|
||||
if (block && 'SHARED_GLOBAL' in block) {
|
||||
if (!('SHARE' in block)) {
|
||||
set[`permissions.${permType}.SHARE`] = block.SHARED_GLOBAL;
|
||||
}
|
||||
unset[`permissions.${permType}.SHARED_GLOBAL`] = '';
|
||||
}
|
||||
}
|
||||
if (Object.keys(unset).length) {
|
||||
const update: Record<string, unknown> = { $unset: unset };
|
||||
if (Object.keys(set).length) {
|
||||
update.$set = set;
|
||||
}
|
||||
// eslint-disable-next-line no-restricted-syntax -- Role is a global (non-tenant) collection; raw $unset is required to drop the off-schema field.
|
||||
await Role.collection.updateOne({ name: roleName }, update);
|
||||
}
|
||||
}
|
||||
let role = await Role.findOne({ name: roleName });
|
||||
const defaultPerms = roleDefaults[roleName].permissions;
|
||||
|
||||
|
|
@ -87,9 +111,22 @@ export function createRoleMethods(
|
|||
const permissions = role.toObject()?.permissions ?? {};
|
||||
role.permissions = role.permissions || {};
|
||||
for (const permType of Object.keys(defaultPerms)) {
|
||||
if (permissions[permType] == null || Object.keys(permissions[permType]).length === 0) {
|
||||
role.permissions[permType] = defaultPerms[permType as keyof typeof defaultPerms];
|
||||
const defaultBlock = defaultPerms[permType as keyof typeof defaultPerms] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const existingBlock = permissions[permType] as Record<string, unknown> | null | undefined;
|
||||
if (existingBlock == null) {
|
||||
role.permissions[permType] = defaultBlock;
|
||||
continue;
|
||||
}
|
||||
const mergedBlock: Record<string, unknown> = { ...existingBlock };
|
||||
for (const field of Object.keys(defaultBlock)) {
|
||||
if (mergedBlock[field] == null) {
|
||||
mergedBlock[field] = defaultBlock[field];
|
||||
}
|
||||
}
|
||||
role.permissions[permType] = mergedBlock;
|
||||
}
|
||||
}
|
||||
await role.save();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue