diff --git a/packages/data-schemas/src/methods/config.spec.ts b/packages/data-schemas/src/methods/config.spec.ts index 5111a915e6..f22684fa67 100644 --- a/packages/data-schemas/src/methods/config.spec.ts +++ b/packages/data-schemas/src/methods/config.spec.ts @@ -14,6 +14,7 @@ beforeAll(async () => { if (!mongoose.models.Config) { mongoose.model('Config', configSchema); } + await mongoose.models.Config.init(); methods = createConfigMethods(mongoose); }); diff --git a/packages/data-schemas/src/methods/role.methods.spec.ts b/packages/data-schemas/src/methods/role.methods.spec.ts index 2cd9d152f8..0255227160 100644 --- a/packages/data-schemas/src/methods/role.methods.spec.ts +++ b/packages/data-schemas/src/methods/role.methods.spec.ts @@ -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' }); diff --git a/packages/data-schemas/src/methods/role.ts b/packages/data-schemas/src/methods/role.ts index dea9da6fa9..1d7bf9cbbf 100644 --- a/packages/data-schemas/src/methods/role.ts +++ b/packages/data-schemas/src/methods/role.ts @@ -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 = {}; + const unset: Record = {}; + 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 = { $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 | null | undefined; + if (existingBlock == null) { + role.permissions[permType] = defaultBlock; + continue; } + const mergedBlock: Record = { ...existingBlock }; + for (const field of Object.keys(defaultBlock)) { + if (mergedBlock[field] == null) { + mergedBlock[field] = defaultBlock[field]; + } + } + role.permissions[permType] = mergedBlock; } } await role.save();