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:
matt burnett 2026-07-01 05:21:46 -07:00 committed by GitHub
parent 68bb533083
commit 38ab4add3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 215 additions and 4 deletions

View file

@ -14,6 +14,7 @@ beforeAll(async () => {
if (!mongoose.models.Config) {
mongoose.model<IConfig>('Config', configSchema);
}
await mongoose.models.Config.init();
methods = createConfigMethods(mongoose);
});

View file

@ -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' });

View file

@ -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();