From 89f0a4e02fe7a138aa5248031d1e93f00495d48d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 3 Aug 2025 19:24:40 -0400 Subject: [PATCH] WIP: Role as Permission Principal Type --- .../controllers/PermissionsController.js | 26 +- api/server/services/PermissionService.js | 26 +- api/server/services/PermissionService.spec.js | 69 +- .../data-provider/src/accessPermissions.ts | 14 +- packages/data-schemas/src/methods/aclEntry.ts | 9 +- .../src/methods/userGroup.methods.spec.ts | 620 ++++++++++++++++++ .../src/methods/userGroup.roles.spec.ts | 367 +++++++++++ .../src/methods/userGroup.spec.ts | 8 +- .../data-schemas/src/methods/userGroup.ts | 60 +- packages/data-schemas/src/schema/aclEntry.ts | 2 +- packages/data-schemas/src/types/aclEntry.ts | 4 +- 11 files changed, 1167 insertions(+), 38 deletions(-) create mode 100644 packages/data-schemas/src/methods/userGroup.methods.spec.ts create mode 100644 packages/data-schemas/src/methods/userGroup.roles.spec.ts diff --git a/api/server/controllers/PermissionsController.js b/api/server/controllers/PermissionsController.js index 7562beb100..219b57e8ec 100644 --- a/api/server/controllers/PermissionsController.js +++ b/api/server/controllers/PermissionsController.js @@ -72,7 +72,7 @@ const updateResourcePermissions = async (req, res) => { // Add public permission if enabled if (isPublic && publicAccessRoleId) { updatedPrincipals.push({ - type: 'public', + type: PrincipalType.PUBLIC, id: null, accessRoleId: publicAccessRoleId, }); @@ -97,11 +97,13 @@ const updateResourcePermissions = async (req, res) => { try { let principalId; - if (principal.type === 'public') { + if (principal.type === PrincipalType.PUBLIC) { principalId = null; // Public principals don't need database records - } else if (principal.type === 'user') { + } else if (principal.type === PrincipalType.ROLE) { + principalId = principal.id; // Role principals use role name as ID + } else if (principal.type === PrincipalType.USER) { principalId = await ensurePrincipalExists(principal); - } else if (principal.type === 'group') { + } else if (principal.type === PrincipalType.GROUP) { // Pass authContext to enable member fetching for Entra ID groups when available principalId = await ensureGroupPrincipalExists(principal, authContext); } else { @@ -137,7 +139,7 @@ const updateResourcePermissions = async (req, res) => { // If public is disabled, add public to revoked list if (!isPublic) { revokedPrincipals.push({ - type: 'public', + type: PrincipalType.PUBLIC, id: null, }); } @@ -263,6 +265,16 @@ const getResourcePermissions = async (req, res) => { idOnTheSource: result.groupInfo.idOnTheSource || result.groupInfo._id.toString(), accessRoleId: result.accessRoleId, }); + } else if (result.principalType === PrincipalType.ROLE) { + principals.push({ + type: PrincipalType.ROLE, + /** Role name as ID */ + id: result.principalId, + /** Display the role name */ + name: result.principalId, + description: `System role: ${result.principalId}`, + accessRoleId: result.accessRoleId, + }); } } @@ -366,7 +378,9 @@ const searchPrincipals = async (req, res) => { } const searchLimit = Math.min(Math.max(1, parseInt(limit) || 10), 50); - const typeFilter = ['user', 'group'].includes(type) ? type : null; + const typeFilter = [PrincipalType.USER, PrincipalType.GROUP, PrincipalType.ROLE].includes(type) + ? type + : null; const localResults = await searchLocalPrincipals(query.trim(), searchLimit, typeFilter); let allPrincipals = [...localResults]; diff --git a/api/server/services/PermissionService.js b/api/server/services/PermissionService.js index b525f82890..ef1665949a 100644 --- a/api/server/services/PermissionService.js +++ b/api/server/services/PermissionService.js @@ -1,7 +1,7 @@ const mongoose = require('mongoose'); const { isEnabled } = require('@librechat/api'); -const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider'); const { getTransactionSupport, logger } = require('@librechat/data-schemas'); +const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider'); const { entraIdPrincipalFeatureEnabled, getUserOwnedEntraGroups, @@ -70,10 +70,21 @@ const grantPermission = async ({ } if (principalType !== PrincipalType.PUBLIC && !principalId) { - throw new Error('Principal ID is required for user and group principals'); + throw new Error('Principal ID is required for user, group, and role principals'); } - if (principalId && !mongoose.Types.ObjectId.isValid(principalId)) { + // Validate principalId based on type + if (principalId && principalType === PrincipalType.ROLE) { + // Role IDs are strings (role names) + if (typeof principalId !== 'string' || principalId.trim().length === 0) { + throw new Error(`Invalid role ID: ${principalId}`); + } + } else if ( + principalType && + principalType !== PrincipalType.PUBLIC && + !mongoose.Types.ObjectId.isValid(principalId) + ) { + // User and Group IDs must be valid ObjectIds throw new Error(`Invalid principal ID: ${principalId}`); } @@ -616,6 +627,12 @@ const bulkUpdateResourcePermissions = async ({ query.principalId = principal.id; } + const principalModelMap = { + [PrincipalType.USER]: PrincipalModel.USER, + [PrincipalType.GROUP]: PrincipalModel.GROUP, + [PrincipalType.ROLE]: PrincipalModel.ROLE, + }; + const update = { $set: { permBits: role.permBits, @@ -629,8 +646,7 @@ const bulkUpdateResourcePermissions = async ({ resourceId, ...(principal.type !== PrincipalType.PUBLIC && { principalId: principal.id, - principalModel: - principal.type === PrincipalType.USER ? PrincipalModel.USER : PrincipalModel.GROUP, + principalModel: principalModelMap[principal.type], }), }, }; diff --git a/api/server/services/PermissionService.spec.js b/api/server/services/PermissionService.spec.js index 30b4460975..9b73cd79f0 100644 --- a/api/server/services/PermissionService.spec.js +++ b/api/server/services/PermissionService.spec.js @@ -79,6 +79,7 @@ describe('PermissionService', () => { const groupId = new mongoose.Types.ObjectId(); const resourceId = new mongoose.Types.ObjectId(); const grantedById = new mongoose.Types.ObjectId(); + const roleResourceId = new mongoose.Types.ObjectId(); describe('grantPermission', () => { test('should grant permission to a user with a role', async () => { @@ -171,7 +172,7 @@ describe('PermissionService', () => { accessRoleId: AccessRoleIds.AGENT_VIEWER, grantedBy: grantedById, }), - ).rejects.toThrow('Principal ID is required for user and group principals'); + ).rejects.toThrow('Principal ID is required for user, group, and role principals'); }); test('should throw error for non-existent role', async () => { @@ -1000,6 +1001,72 @@ describe('PermissionService', () => { expect(publicEntry.roleId.accessRoleId).toBe(AccessRoleIds.AGENT_EDITOR); }); + test('should grant permission to a role', async () => { + const entry = await grantPermission({ + principalType: PrincipalType.ROLE, + principalId: 'admin', + resourceType: ResourceType.AGENT, + resourceId: roleResourceId, + accessRoleId: AccessRoleIds.AGENT_EDITOR, + grantedBy: grantedById, + }); + + expect(entry).toBeDefined(); + expect(entry.principalType).toBe(PrincipalType.ROLE); + expect(entry.principalId).toBe('admin'); + expect(entry.principalModel).toBe(PrincipalModel.ROLE); + expect(entry.resourceType).toBe(ResourceType.AGENT); + expect(entry.resourceId.toString()).toBe(roleResourceId.toString()); + + // Get the role to verify the permission bits are correctly set + const role = await findRoleByIdentifier(AccessRoleIds.AGENT_EDITOR); + expect(entry.permBits).toBe(role.permBits); + expect(entry.roleId.toString()).toBe(role._id.toString()); + }); + + test('should check permissions for user with role', async () => { + // Grant permission to admin role + await grantPermission({ + principalType: PrincipalType.ROLE, + principalId: 'admin', + resourceType: ResourceType.AGENT, + resourceId: roleResourceId, + accessRoleId: AccessRoleIds.AGENT_EDITOR, + grantedBy: grantedById, + }); + + // Mock getUserPrincipals to return user with admin role + getUserPrincipals.mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: userId }, + { principalType: PrincipalType.ROLE, principalId: 'admin' }, + { principalType: PrincipalType.PUBLIC }, + ]); + + const hasPermission = await checkPermission({ + userId, + resourceType: ResourceType.AGENT, + resourceId: roleResourceId, + requiredPermission: 1, // VIEW + }); + + expect(hasPermission).toBe(true); + + // Check that user without admin role cannot access + getUserPrincipals.mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: userId }, + { principalType: PrincipalType.PUBLIC }, + ]); + + const hasNoPermission = await checkPermission({ + userId, + resourceType: ResourceType.AGENT, + resourceId: roleResourceId, + requiredPermission: 1, // VIEW + }); + + expect(hasNoPermission).toBe(false); + }); + test('should work with different resource types', async () => { // Test with promptGroup resources const promptGroupResourceId = new mongoose.Types.ObjectId(); diff --git a/packages/data-provider/src/accessPermissions.ts b/packages/data-provider/src/accessPermissions.ts index 4ea1d5e4e3..3c5a232c65 100644 --- a/packages/data-provider/src/accessPermissions.ts +++ b/packages/data-provider/src/accessPermissions.ts @@ -17,6 +17,7 @@ export enum PrincipalType { USER = 'user', GROUP = 'group', PUBLIC = 'public', + ROLE = 'role', } /** @@ -25,6 +26,7 @@ export enum PrincipalType { export enum PrincipalModel { USER = 'User', GROUP = 'Group', + ROLE = 'Role', } /** @@ -74,16 +76,16 @@ export enum AccessRoleIds { // ===== ZOD SCHEMAS ===== /** - * Principal schema - represents a user, group, or public access + * Principal schema - represents a user, group, role, or public access */ export const principalSchema = z.object({ type: z.nativeEnum(PrincipalType), - id: z.string().optional(), // undefined for 'public' type + id: z.string().optional(), // undefined for 'public' type, role name for 'role' type name: z.string().optional(), email: z.string().optional(), // for user and group types source: z.enum(['local', 'entra']).optional(), avatar: z.string().optional(), // for user and group types - description: z.string().optional(), // for group type + description: z.string().optional(), // for group and role types idOnTheSource: z.string().optional(), // Entra ID for users/groups accessRoleId: z.nativeEnum(AccessRoleIds).optional(), // Access role ID for permissions memberCount: z.number().optional(), // for group type @@ -192,7 +194,7 @@ export type TUpdateResourcePermissionsResponse = z.infer< export type TPrincipalSearchParams = { q: string; // search query (required) limit?: number; // max results (1-50, default 10) - type?: 'user' | 'group'; // filter by type (optional) + type?: PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE; // filter by type (optional) }; /** @@ -200,7 +202,7 @@ export type TPrincipalSearchParams = { */ export type TPrincipalSearchResult = { id?: string | null; // null for Entra ID principals that don't exist locally yet - type: 'user' | 'group'; + type: PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE; name: string; email?: string; // for users and groups username?: string; // for users @@ -218,7 +220,7 @@ export type TPrincipalSearchResult = { export type TPrincipalSearchResponse = { query: string; limit: number; - type?: 'user' | 'group'; + type?: PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE; results: TPrincipalSearchResult[]; count: number; sources: { diff --git a/packages/data-schemas/src/methods/aclEntry.ts b/packages/data-schemas/src/methods/aclEntry.ts index 2284214259..eed20deaa4 100644 --- a/packages/data-schemas/src/methods/aclEntry.ts +++ b/packages/data-schemas/src/methods/aclEntry.ts @@ -148,8 +148,13 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) { if (principalType !== PrincipalType.PUBLIC) { query.principalId = principalId; - query.principalModel = - principalType === PrincipalType.USER ? PrincipalModel.USER : PrincipalModel.GROUP; + if (principalType === PrincipalType.USER) { + query.principalModel = PrincipalModel.USER; + } else if (principalType === PrincipalType.GROUP) { + query.principalModel = PrincipalModel.GROUP; + } else if (principalType === PrincipalType.ROLE) { + query.principalModel = PrincipalModel.ROLE; + } } const update = { diff --git a/packages/data-schemas/src/methods/userGroup.methods.spec.ts b/packages/data-schemas/src/methods/userGroup.methods.spec.ts new file mode 100644 index 0000000000..8a31544018 --- /dev/null +++ b/packages/data-schemas/src/methods/userGroup.methods.spec.ts @@ -0,0 +1,620 @@ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import type * as t from '~/types'; +import { createUserGroupMethods } from './userGroup'; +import groupSchema from '~/schema/group'; +import userSchema from '~/schema/user'; + +/** Mocking logger */ +jest.mock('~/config/winston', () => ({ + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +})); + +let mongoServer: MongoMemoryServer; +let Group: mongoose.Model; +let User: mongoose.Model; +let methods: ReturnType; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + await mongoose.connect(mongoUri); + + /** Register models */ + Group = mongoose.models.Group || mongoose.model('Group', groupSchema); + User = mongoose.models.User || mongoose.model('User', userSchema); + + /** Initialize methods */ + methods = createUserGroupMethods(mongoose); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await mongoose.connection.dropDatabase(); +}); + +describe('UserGroup Methods - Detailed Tests', () => { + describe('findGroupById', () => { + test('should find group by ObjectId', async () => { + const group = await Group.create({ + name: 'Test Group', + source: 'local', + memberIds: [], + }); + + const found = await methods.findGroupById(group._id as mongoose.Types.ObjectId); + + expect(found).toBeDefined(); + expect(found?._id.toString()).toBe(group._id.toString()); + expect(found?.name).toBe('Test Group'); + }); + + test('should find group by string ID', async () => { + const group = await Group.create({ + name: 'Test Group', + source: 'local', + memberIds: [], + }); + + const found = await methods.findGroupById(group._id as mongoose.Types.ObjectId); + + expect(found).toBeDefined(); + expect(found?._id.toString()).toBe(group._id.toString()); + }); + + test('should apply projection correctly', async () => { + const group = await Group.create({ + name: 'Test Group', + source: 'local', + description: 'Test Description', + memberIds: ['user1', 'user2'], + }); + + const found = await methods.findGroupById(group._id as mongoose.Types.ObjectId, { + name: 1, + }); + + expect(found).toBeDefined(); + expect(found?.name).toBe('Test Group'); + expect(found?.description).toBeUndefined(); + expect(found?.memberIds).toBeUndefined(); + }); + + test('should return null for non-existent group', async () => { + const fakeId = new mongoose.Types.ObjectId(); + const found = await methods.findGroupById(fakeId as mongoose.Types.ObjectId); + + expect(found).toBeNull(); + }); + }); + + describe('findGroupByExternalId', () => { + test('should find group by external ID and source', async () => { + await Group.create({ + name: 'Entra Group', + source: 'entra', + idOnTheSource: 'entra-123', + memberIds: [], + }); + + const found = await methods.findGroupByExternalId('entra-123', 'entra'); + + expect(found).toBeDefined(); + expect(found?.idOnTheSource).toBe('entra-123'); + expect(found?.source).toBe('entra'); + }); + + test('should not find group with wrong source', async () => { + await Group.create({ + name: 'Entra Group', + source: 'entra', + idOnTheSource: 'entra-123', + memberIds: [], + }); + + const found = await methods.findGroupByExternalId('entra-123', 'local'); + + expect(found).toBeNull(); + }); + + test('should handle multiple groups with same external ID but different sources', async () => { + const id = 'shared-id'; + + await Group.create({ + name: 'Entra Group', + source: 'entra', + idOnTheSource: id, + memberIds: [], + }); + + await Group.create({ + name: 'Local Group', + source: 'local', + memberIds: [], + }); + + const entraGroup = await methods.findGroupByExternalId(id, 'entra'); + const localGroup = await methods.findGroupByExternalId(id, 'local'); + + expect(entraGroup?.name).toBe('Entra Group'); + expect(localGroup).toBeNull(); // local groups don't use idOnTheSource by default + }); + }); + + describe('findGroupsByNamePattern', () => { + beforeEach(async () => { + await Group.create([ + { name: 'Engineering Team', source: 'local', memberIds: [] }, + { name: 'Engineering Managers', source: 'local', memberIds: [] }, + { name: 'Marketing Team', source: 'local', memberIds: [] }, + { + name: 'Remote Engineering', + source: 'entra', + idOnTheSource: 'entra-remote-eng', + memberIds: [], + }, + ]); + }); + + test('should find groups by name pattern', async () => { + const groups = await methods.findGroupsByNamePattern('Engineering'); + + expect(groups).toHaveLength(3); + expect(groups.every((g) => g.name.includes('Engineering'))).toBe(true); + }); + + test('should respect case insensitive search', async () => { + const groups = await methods.findGroupsByNamePattern('engineering'); + + expect(groups).toHaveLength(3); + }); + + test('should filter by source when provided', async () => { + const groups = await methods.findGroupsByNamePattern('Engineering', 'local'); + + expect(groups).toHaveLength(2); + expect(groups.every((g) => g.source === 'local')).toBe(true); + }); + + test('should respect limit parameter', async () => { + const groups = await methods.findGroupsByNamePattern('Engineering', null, 2); + + expect(groups).toHaveLength(2); + }); + + test('should return empty array for no matches', async () => { + const groups = await methods.findGroupsByNamePattern('NonExistent'); + + expect(groups).toEqual([]); + }); + }); + + describe('findGroupsByMemberId', () => { + let user1: mongoose.HydratedDocument; + + beforeEach(async () => { + user1 = await User.create({ + name: 'User 1', + email: 'user1@test.com', + provider: 'local', + }); + }); + + test('should find groups by member ObjectId', async () => { + await Group.create([ + { + name: 'Group 1', + source: 'local', + memberIds: [(user1._id as mongoose.Types.ObjectId).toString(), 'other-user'], + }, + { + name: 'Group 2', + source: 'local', + memberIds: [(user1._id as mongoose.Types.ObjectId).toString()], + }, + { + name: 'Group 3', + source: 'local', + memberIds: ['other-user'], + }, + ]); + + const groups = await methods.findGroupsByMemberId(user1._id as mongoose.Types.ObjectId); + + expect(groups).toHaveLength(2); + expect(groups.map((g) => g.name).sort()).toEqual(['Group 1', 'Group 2']); + }); + + test('should find groups by member string ID', async () => { + await Group.create([ + { + name: 'Group 1', + source: 'local', + memberIds: [(user1._id as mongoose.Types.ObjectId).toString()], + }, + ]); + + const groups = await methods.findGroupsByMemberId(user1._id as mongoose.Types.ObjectId); + + expect(groups).toHaveLength(1); + expect(groups[0].name).toBe('Group 1'); + }); + + test('should return empty array for user with no groups', async () => { + const groups = await methods.findGroupsByMemberId(user1._id as mongoose.Types.ObjectId); + + expect(groups).toEqual([]); + }); + }); + + describe('createGroup', () => { + test('should create a group with all fields', async () => { + const groupData: Partial = { + name: 'New Group', + source: 'local', + description: 'A test group', + email: 'group@test.com', + avatar: 'avatar-url', + memberIds: ['user1', 'user2'], + }; + + const group = await methods.createGroup(groupData); + + expect(group).toBeDefined(); + expect(group.name).toBe(groupData.name); + expect(group.source).toBe(groupData.source); + expect(group.description).toBe(groupData.description); + expect(group.email).toBe(groupData.email); + expect(group.avatar).toBe(groupData.avatar); + expect(group.memberIds).toEqual(groupData.memberIds); + }); + + test('should create group with minimal data', async () => { + const group = await methods.createGroup({ + name: 'Minimal Group', + }); + + expect(group).toBeDefined(); + expect(group.name).toBe('Minimal Group'); + expect(group.source).toBe('local'); // default + expect(group.memberIds).toEqual([]); // default + }); + }); + + describe('upsertGroupByExternalId', () => { + test('should create new group when not exists', async () => { + const group = await methods.upsertGroupByExternalId('new-external-id', 'entra', { + name: 'New External Group', + description: 'Created by upsert', + }); + + expect(group).toBeDefined(); + expect(group?.idOnTheSource).toBe('new-external-id'); + expect(group?.source).toBe('entra'); + expect(group?.name).toBe('New External Group'); + expect(group?.description).toBe('Created by upsert'); + }); + + test('should update existing group', async () => { + // Create initial group + await Group.create({ + name: 'Original Name', + source: 'entra', + idOnTheSource: 'existing-id', + description: 'Original description', + memberIds: ['user1'], + }); + + // Upsert with updates + const updated = await methods.upsertGroupByExternalId('existing-id', 'entra', { + name: 'Updated Name', + description: 'Updated description', + memberIds: ['user1', 'user2'], + }); + + expect(updated).toBeDefined(); + expect(updated?.name).toBe('Updated Name'); + expect(updated?.description).toBe('Updated description'); + expect(updated?.memberIds).toEqual(['user1', 'user2']); + expect(updated?.idOnTheSource).toBe('existing-id'); // unchanged + }); + + test('should not update group from different source', async () => { + await Group.create({ + name: 'Entra Group', + source: 'entra', + idOnTheSource: 'shared-id', + }); + + const result = await methods.upsertGroupByExternalId('shared-id', 'local', { + name: 'Azure Group', + }); + + // Should create new group + expect(result?.name).toBe('Azure Group'); + expect(result?.source).toBe('local'); + + // Verify both exist + const groups = await Group.find({ idOnTheSource: 'shared-id' }); + expect(groups).toHaveLength(2); + }); + }); + + describe('addUserToGroup and removeUserFromGroup', () => { + let user: mongoose.HydratedDocument; + let userWithExternal: mongoose.HydratedDocument; + let group: mongoose.HydratedDocument; + + beforeEach(async () => { + user = await User.create({ + name: 'Test User', + email: 'user@test.com', + provider: 'local', + }); + + userWithExternal = await User.create({ + name: 'External User', + email: 'external@test.com', + provider: 'entra', + idOnTheSource: 'external-123', + }); + + group = await Group.create({ + name: 'Test Group', + source: 'local', + memberIds: [], + }); + }); + + test('should add user to group using user ID', async () => { + const result = await methods.addUserToGroup( + user._id as mongoose.Types.ObjectId, + group._id as mongoose.Types.ObjectId, + ); + + expect(result.user).toBeDefined(); + expect(result.group).toBeDefined(); + expect(result.group?.memberIds).toContain((user._id as mongoose.Types.ObjectId).toString()); + }); + + test('should add user to group using idOnTheSource if available', async () => { + const result = await methods.addUserToGroup( + userWithExternal._id as mongoose.Types.ObjectId, + group._id as mongoose.Types.ObjectId, + ); + + expect(result.group?.memberIds).toContain('external-123'); + expect(result.group?.memberIds).not.toContain( + (userWithExternal._id as mongoose.Types.ObjectId).toString(), + ); + }); + + test('should not duplicate user in group', async () => { + // Add user first time + await methods.addUserToGroup( + user._id as mongoose.Types.ObjectId, + group._id as mongoose.Types.ObjectId, + ); + + // Add same user again + const result = await methods.addUserToGroup( + user._id as mongoose.Types.ObjectId, + group._id as mongoose.Types.ObjectId, + ); + + expect(result.group?.memberIds).toHaveLength(1); + expect(result.group?.memberIds).toContain((user._id as mongoose.Types.ObjectId).toString()); + }); + + test('should remove user from group', async () => { + // First add user + await methods.addUserToGroup( + user._id as mongoose.Types.ObjectId, + group._id as mongoose.Types.ObjectId, + ); + + // Then remove + const result = await methods.removeUserFromGroup( + user._id as mongoose.Types.ObjectId, + group._id as mongoose.Types.ObjectId, + ); + + expect(result.group?.memberIds).toHaveLength(0); + expect(result.group?.memberIds).not.toContain( + (user._id as mongoose.Types.ObjectId).toString(), + ); + }); + + test('should handle removing user not in group', async () => { + const result = await methods.removeUserFromGroup( + user._id as mongoose.Types.ObjectId, + group._id as mongoose.Types.ObjectId, + ); + + expect(result.group?.memberIds).toHaveLength(0); + }); + }); + + describe('getUserGroups', () => { + let user: mongoose.HydratedDocument; + + beforeEach(async () => { + user = await User.create({ + name: 'Test User', + email: 'user@test.com', + provider: 'local', + }); + }); + + test('should get all groups for a user', async () => { + // Create groups with user as member + await Group.create([ + { + name: 'Group 1', + source: 'local', + memberIds: [(user._id as mongoose.Types.ObjectId).toString()], + }, + { + name: 'Group 2', + source: 'local', + memberIds: [(user._id as mongoose.Types.ObjectId).toString(), 'other-user'], + }, + { + name: 'Group 3', + source: 'local', + memberIds: ['other-user'], + }, + ]); + + const groups = await methods.getUserGroups(user._id as mongoose.Types.ObjectId); + + expect(groups).toHaveLength(2); + expect(groups.map((g) => g.name).sort()).toEqual(['Group 1', 'Group 2']); + }); + + test('should return empty array for user with no groups', async () => { + const groups = await methods.getUserGroups(user._id as mongoose.Types.ObjectId); + + expect(groups).toEqual([]); + }); + + test('should handle user with idOnTheSource', async () => { + const externalUser = await User.create({ + name: 'External User', + email: 'external@test.com', + provider: 'entra', + idOnTheSource: 'external-456', + }); + + await Group.create({ + name: 'External Group', + source: 'entra', + idOnTheSource: 'entra-external-group', + memberIds: ['external-456'], // Using idOnTheSource + }); + + const groups = await methods.getUserGroups(externalUser._id as mongoose.Types.ObjectId); + + expect(groups).toHaveLength(1); + expect(groups[0].name).toBe('External Group'); + }); + }); + + describe('syncUserEntraGroups', () => { + let user: mongoose.HydratedDocument; + + beforeEach(async () => { + user = await User.create({ + name: 'Entra User', + email: 'entra@test.com', + provider: 'entra', + idOnTheSource: 'entra-user-123', + }); + }); + + test('should create new groups and add user', async () => { + const entraGroups = [ + { id: 'group-1', name: 'Entra Group 1' }, + { id: 'group-2', name: 'Entra Group 2' }, + ]; + + const result = await methods.syncUserEntraGroups( + user._id as mongoose.Types.ObjectId, + entraGroups, + ); + + expect(result.user).toBeDefined(); + expect(result.addedGroups).toHaveLength(2); + expect(result.removedGroups).toHaveLength(0); + + // Verify groups were created + const groups = await Group.find({ source: 'entra' }); + expect(groups).toHaveLength(2); + + // Verify user is member of both + for (const group of groups) { + expect(group.memberIds).toContain('entra-user-123'); + } + }); + + test('should remove user from groups not in sync list', async () => { + // Create existing groups + const group1 = await Group.create({ + name: 'Keep Group', + source: 'entra', + idOnTheSource: 'keep-group', + memberIds: ['entra-user-123'], + }); + + const group2 = await Group.create({ + name: 'Remove Group', + source: 'entra', + idOnTheSource: 'remove-group', + memberIds: ['entra-user-123'], + }); + + // Sync with only one group + const result = await methods.syncUserEntraGroups(user._id as mongoose.Types.ObjectId, [ + { id: 'keep-group', name: 'Keep Group' }, + ]); + + expect(result.addedGroups).toHaveLength(0); + expect(result.removedGroups).toHaveLength(1); + + // Verify membership + const keepGroup = await Group.findById(group1._id); + const removeGroup = await Group.findById(group2._id); + + expect(keepGroup?.memberIds).toContain('entra-user-123'); + expect(removeGroup?.memberIds).not.toContain('entra-user-123'); + }); + + test('should not affect local groups', async () => { + // Create local group + const localGroup = await Group.create({ + name: 'Local Group', + source: 'local', + memberIds: ['entra-user-123'], + }); + + // Sync entra groups + await methods.syncUserEntraGroups(user._id as mongoose.Types.ObjectId, [ + { id: 'entra-group', name: 'Entra Group' }, + ]); + + // Verify local group unchanged + const savedLocalGroup = await Group.findById(localGroup._id); + expect(savedLocalGroup?.memberIds).toContain('entra-user-123'); + }); + + test('should throw error for non-existent user', async () => { + const fakeId = new mongoose.Types.ObjectId(); + + await expect(methods.syncUserEntraGroups(fakeId, [])).rejects.toThrow('User not found'); + }); + }); + + describe('sortPrincipalsByRelevance', () => { + test('should sort principals by relevance score', async () => { + const principals = [ + { id: '1', name: 'Test User', type: 'user' as const, source: 'local' as const }, + { id: '2', name: 'Admin Test', type: 'user' as const, source: 'local' as const }, + { id: '3', name: 'Test Group', type: 'group' as const, source: 'local' as const }, + ]; + + // Store original query in closure or pass it through + const sorted = methods.sortPrincipalsByRelevance(principals); + + // Since we can't pass the query directly, the method should maintain + // the original order or have been called in a context where it knows the query + expect(sorted).toBeDefined(); + expect(sorted).toHaveLength(3); + }); + }); +}); diff --git a/packages/data-schemas/src/methods/userGroup.roles.spec.ts b/packages/data-schemas/src/methods/userGroup.roles.spec.ts new file mode 100644 index 0000000000..5307f2dc34 --- /dev/null +++ b/packages/data-schemas/src/methods/userGroup.roles.spec.ts @@ -0,0 +1,367 @@ +import mongoose from 'mongoose'; +import { PrincipalType } from 'librechat-data-provider'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import type * as t from '~/types'; +import { createUserGroupMethods } from './userGroup'; +import groupSchema from '~/schema/group'; +import userSchema from '~/schema/user'; +import roleSchema from '~/schema/role'; + +/** Mocking logger */ +jest.mock('~/config/winston', () => ({ + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +})); + +let mongoServer: MongoMemoryServer; +let Group: mongoose.Model; +let User: mongoose.Model; +let Role: mongoose.Model; +let methods: ReturnType; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + await mongoose.connect(mongoUri); + + /** Register models */ + Group = mongoose.models.Group || mongoose.model('Group', groupSchema); + User = mongoose.models.User || mongoose.model('User', userSchema); + Role = mongoose.models.Role || mongoose.model('Role', roleSchema); + + /** Initialize methods */ + methods = createUserGroupMethods(mongoose); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await mongoose.connection.dropDatabase(); +}); + +describe('Role-based Permissions Integration', () => { + describe('getUserPrincipals with roles', () => { + test('should include role principal for user with role', async () => { + const adminUser = await User.create({ + name: 'Admin User', + email: 'admin@test.com', + provider: 'local', + role: 'admin', + }); + + const principals = await methods.getUserPrincipals(adminUser._id as mongoose.Types.ObjectId); + + // Should have user, role, and public principals + expect(principals).toHaveLength(3); + + const userPrincipal = principals.find((p) => p.principalType === PrincipalType.USER); + const rolePrincipal = principals.find((p) => p.principalType === PrincipalType.ROLE); + const publicPrincipal = principals.find((p) => p.principalType === PrincipalType.PUBLIC); + + expect(userPrincipal).toBeDefined(); + expect(userPrincipal?.principalId?.toString()).toBe( + (adminUser._id as mongoose.Types.ObjectId).toString(), + ); + + expect(rolePrincipal).toBeDefined(); + expect(rolePrincipal?.principalType).toBe(PrincipalType.ROLE); + expect(rolePrincipal?.principalId).toBe('admin'); + + expect(publicPrincipal).toBeDefined(); + expect(publicPrincipal?.principalType).toBe(PrincipalType.PUBLIC); + expect(publicPrincipal?.principalId).toBeUndefined(); + }); + + test('should not include role principal for user without role', async () => { + const regularUser = await User.create({ + name: 'Regular User', + email: 'user@test.com', + provider: 'local', + role: null, // Explicitly set to null to override default + }); + + const principals = await methods.getUserPrincipals( + regularUser._id as mongoose.Types.ObjectId, + ); + + // Should only have user and public principals + expect(principals).toHaveLength(2); + + const rolePrincipal = principals.find((p) => p.principalType === PrincipalType.ROLE); + expect(rolePrincipal).toBeUndefined(); + }); + + test('should include all principal types for user with role and groups', async () => { + const user = await User.create({ + name: 'Complete User', + email: 'complete@test.com', + provider: 'local', + role: 'moderator', + }); + + // Add user to groups + const group1 = await Group.create({ + name: 'Group 1', + source: 'local', + memberIds: [(user._id as mongoose.Types.ObjectId).toString()], + }); + + const group2 = await Group.create({ + name: 'Group 2', + source: 'local', + memberIds: [(user._id as mongoose.Types.ObjectId).toString()], + }); + + const principals = await methods.getUserPrincipals(user._id as mongoose.Types.ObjectId); + + // Should have user, role, 2 groups, and public + expect(principals).toHaveLength(5); + + const principalTypes = principals.map((p) => p.principalType); + expect(principalTypes).toContain(PrincipalType.USER); + expect(principalTypes).toContain(PrincipalType.ROLE); + expect(principalTypes).toContain(PrincipalType.GROUP); + expect(principalTypes).toContain(PrincipalType.PUBLIC); + + // Check role principal + const rolePrincipal = principals.find((p) => p.principalType === PrincipalType.ROLE); + expect(rolePrincipal?.principalId).toBe('moderator'); + + // Check group principals + const groupPrincipals = principals.filter((p) => p.principalType === PrincipalType.GROUP); + expect(groupPrincipals).toHaveLength(2); + const groupIds = groupPrincipals.map((p) => p.principalId?.toString()); + expect(groupIds).toContain(group1._id.toString()); + expect(groupIds).toContain(group2._id.toString()); + }); + + test('should handle different role values', async () => { + const testCases = [ + { role: 'admin', expected: 'admin' }, + { role: 'moderator', expected: 'moderator' }, + { role: 'editor', expected: 'editor' }, + { role: 'viewer', expected: 'viewer' }, + { role: 'custom_role', expected: 'custom_role' }, + ]; + + for (const testCase of testCases) { + const user = await User.create({ + name: `User with ${testCase.role}`, + email: `${testCase.role}@test.com`, + provider: 'local', + role: testCase.role, + }); + + const principals = await methods.getUserPrincipals(user._id as mongoose.Types.ObjectId); + const rolePrincipal = principals.find((p) => p.principalType === PrincipalType.ROLE); + + expect(rolePrincipal).toBeDefined(); + expect(rolePrincipal?.principalId).toBe(testCase.expected); + } + }); + }); + + describe('searchPrincipals with role support', () => { + beforeEach(async () => { + // Create some roles in the database + await Role.create([ + { name: 'admin', description: 'Administrator role' }, + { name: 'moderator', description: 'Moderator role' }, + { name: 'editor', description: 'Editor role' }, + { name: 'viewer', description: 'Viewer role' }, + { name: 'guest', description: 'Guest role' }, + ]); + + // Create some users + await User.create([ + { + name: 'Admin User', + email: 'admin@test.com', + username: 'adminuser', + provider: 'local', + role: 'admin', + }, + { + name: 'Moderator User', + email: 'moderator@test.com', + username: 'moduser', + provider: 'local', + role: 'moderator', + }, + ]); + + // Create some groups + await Group.create([ + { + name: 'Admin Group', + source: 'local', + memberIds: [], + }, + { + name: 'Moderator Group', + source: 'local', + memberIds: [], + }, + ]); + }); + + test('should search for roles when Role model exists', async () => { + const results = await methods.searchPrincipals('admin'); + + const roleResults = results.filter((r) => r.type === PrincipalType.ROLE); + const userResults = results.filter((r) => r.type === PrincipalType.USER); + const groupResults = results.filter((r) => r.type === PrincipalType.GROUP); + + // Should find the admin role + expect(roleResults).toHaveLength(1); + expect(roleResults[0].id).toBe('admin'); + expect(roleResults[0].name).toBe('admin'); + expect(roleResults[0].type).toBe(PrincipalType.ROLE); + + // Should also find admin user and group + expect(userResults.some((u) => u.name === 'Admin User')).toBe(true); + expect(groupResults.some((g) => g.name === 'Admin Group')).toBe(true); + }); + + test('should filter search results by role type', async () => { + const results = await methods.searchPrincipals('mod', 10, PrincipalType.ROLE); + + expect(results.every((r) => r.type === PrincipalType.ROLE)).toBe(true); + expect(results).toHaveLength(1); + expect(results[0].name).toBe('moderator'); + }); + + test('should respect limit for role search', async () => { + // Create many roles + for (let i = 0; i < 10; i++) { + await Role.create({ name: `testrole${i}` }); + } + + const results = await methods.searchPrincipals('testrole', 5, PrincipalType.ROLE); + + expect(results).toHaveLength(5); + expect(results.every((r) => r.type === PrincipalType.ROLE)).toBe(true); + }); + + test('should search across all principal types', async () => { + const results = await methods.searchPrincipals('mod'); + + // Should find moderator role, user, and group + const types = new Set(results.map((r) => r.type)); + expect(types.has(PrincipalType.ROLE)).toBe(true); + expect(types.has(PrincipalType.USER)).toBe(true); + expect(types.has(PrincipalType.GROUP)).toBe(true); + + // Check specific results + expect(results.some((r) => r.type === PrincipalType.ROLE && r.name === 'moderator')).toBe( + true, + ); + expect( + results.some((r) => r.type === PrincipalType.USER && r.name === 'Moderator User'), + ).toBe(true); + expect( + results.some((r) => r.type === PrincipalType.GROUP && r.name === 'Moderator Group'), + ).toBe(true); + }); + + test('should handle case-insensitive role search', async () => { + const results = await methods.searchPrincipals('ADMIN', 10, PrincipalType.ROLE); + + expect(results).toHaveLength(1); + expect(results[0].name).toBe('admin'); + }); + + test('should return empty array for no role matches', async () => { + const results = await methods.searchPrincipals('nonexistentrole', 10, PrincipalType.ROLE); + + expect(results).toEqual([]); + }); + }); + + describe('Role principals in complex scenarios', () => { + test('should handle user role changes', async () => { + const user = await User.create({ + name: 'Changing User', + email: 'change@test.com', + provider: 'local', + role: 'viewer', + }); + + // Initial principals + let principals = await methods.getUserPrincipals(user._id as mongoose.Types.ObjectId); + let rolePrincipal = principals.find((p) => p.principalType === PrincipalType.ROLE); + expect(rolePrincipal?.principalId).toBe('viewer'); + + // Change role + user.role = 'editor'; + await user.save(); + + // Get principals again + principals = await methods.getUserPrincipals(user._id as mongoose.Types.ObjectId); + rolePrincipal = principals.find((p) => p.principalType === PrincipalType.ROLE); + expect(rolePrincipal?.principalId).toBe('editor'); + }); + + test('should handle user role removal', async () => { + const user = await User.create({ + name: 'Demoted User', + email: 'demoted@test.com', + provider: 'local', + role: 'admin', + }); + + // Initial check + let principals = await methods.getUserPrincipals(user._id as mongoose.Types.ObjectId); + expect(principals).toHaveLength(3); // user, role, public + + // Remove role + user.role = undefined; + await user.save(); + + // Check again + principals = await methods.getUserPrincipals(user._id as mongoose.Types.ObjectId); + expect(principals).toHaveLength(2); // user, public + const rolePrincipal = principals.find((p) => p.principalType === PrincipalType.ROLE); + expect(rolePrincipal).toBeUndefined(); + }); + + test('should handle empty or null role values', async () => { + const testCases = [ + { role: '', expected: false }, + { role: null, expected: false }, + { role: undefined, expected: true, expectedRole: 'USER' }, // undefined gets default 'USER' + { role: ' ', expected: false }, // whitespace-only is not a valid role + { role: 'valid_role', expected: true, expectedRole: 'valid_role' }, + ]; + + for (const testCase of testCases) { + const userData: Partial = { + name: `User ${Math.random()}`, + email: `test${Math.random()}@test.com`, + provider: 'local', + }; + + // Only set role if it's not undefined (to test undefined case) + if (testCase.role !== undefined) { + userData.role = testCase.role as string; + } + + const user = await User.create(userData); + + const principals = await methods.getUserPrincipals(user._id as mongoose.Types.ObjectId); + const rolePrincipal = principals.find((p) => p.principalType === PrincipalType.ROLE); + + if (testCase.expected) { + expect(rolePrincipal).toBeDefined(); + expect(rolePrincipal?.principalId).toBe(testCase.expectedRole || testCase.role); + } else { + expect(rolePrincipal).toBeUndefined(); + } + } + }); + }); +}); diff --git a/packages/data-schemas/src/methods/userGroup.spec.ts b/packages/data-schemas/src/methods/userGroup.spec.ts index fb2dfd7ab0..bb7514c67e 100644 --- a/packages/data-schemas/src/methods/userGroup.spec.ts +++ b/packages/data-schemas/src/methods/userGroup.spec.ts @@ -21,8 +21,8 @@ let methods: ReturnType; beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); const mongoUri = mongoServer.getUri(); - Group = mongoose.models.Group || mongoose.model('Group', groupSchema); - User = mongoose.models.User || mongoose.model('User', userSchema); + Group = mongoose.models.Group || mongoose.model('Group', groupSchema); + User = mongoose.models.User || mongoose.model('User', userSchema); methods = createUserGroupMethods(mongoose); await mongoose.connect(mongoUri); }); @@ -327,8 +327,8 @@ describe('User Group Methods Tests', () => { /** Get user principals */ const principals = await methods.getUserPrincipals(testUser1._id as mongoose.Types.ObjectId); - /** Should include user, group, and public principals */ - expect(principals).toHaveLength(3); + /** Should include user, role (default USER), group, and public principals */ + expect(principals).toHaveLength(4); /** Check principal types */ const userPrincipal = principals.find((p) => p.principalType === PrincipalType.USER); diff --git a/packages/data-schemas/src/methods/userGroup.ts b/packages/data-schemas/src/methods/userGroup.ts index 0545bc1fbc..dc49e9b8cd 100644 --- a/packages/data-schemas/src/methods/userGroup.ts +++ b/packages/data-schemas/src/methods/userGroup.ts @@ -1,7 +1,7 @@ import { PrincipalType } from 'librechat-data-provider'; import type { TUser, TPrincipalSearchResult } from 'librechat-data-provider'; import type { Model, Types, ClientSession } from 'mongoose'; -import type { IGroup, IUser } from '~/types'; +import type { IGroup, IRole, IUser } from '~/types'; export function createUserGroupMethods(mongoose: typeof import('mongoose')) { /** @@ -249,6 +249,19 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { { principalType: PrincipalType.USER, principalId: userId }, ]; + // Get user to check their role + const User = mongoose.models.User as Model; + const query = User.findById(userId).select('role'); + if (session) { + query.session(session); + } + const user = await query.lean(); + + // Add role as a principal if user has one + if (user?.role && user.role.trim()) { + principals.push({ principalType: PrincipalType.ROLE, principalId: user.role }); + } + const userGroups = await getUserGroups(userId, session); if (userGroups && userGroups.length > 0) { userGroups.forEach((group) => { @@ -374,7 +387,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { /** Get searchable text based on type */ const searchableFields = - item.type === 'user' + item.type === PrincipalType.USER ? [item.name, item.email, item.username].filter(Boolean) : [item.name, item.email, item.description].filter(Boolean); @@ -418,7 +431,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { return (b._searchScore || 0) - (a._searchScore || 0); } if (a.type !== b.type) { - return a.type === 'user' ? -1 : 1; + return a.type === PrincipalType.USER ? -1 : 1; } const aName = a.name || a.email || ''; const bName = b.name || b.email || ''; @@ -434,7 +447,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { function transformUserToTPrincipalSearchResult(user: TUser): TPrincipalSearchResult { return { id: user.id, - type: 'user', + type: PrincipalType.USER, name: user.name || user.email, email: user.email, username: user.username, @@ -453,7 +466,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { function transformGroupToTPrincipalSearchResult(group: IGroup): TPrincipalSearchResult { return { id: group._id?.toString(), - type: 'group', + type: PrincipalType.GROUP, name: group.name, email: group.email, avatar: group.avatar, @@ -469,14 +482,14 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { * Returns combined results in TPrincipalSearchResult format without sorting * @param searchPattern - The pattern to search for * @param limitPerType - Maximum number of results to return - * @param typeFilter - Optional filter: 'user', 'group', or null for all + * @param typeFilter - Optional filter: PrincipalType.USER, PrincipalType.GROUP, or null for all * @param session - Optional MongoDB session for transactions * @returns Array of principals in TPrincipalSearchResult format */ async function searchPrincipals( searchPattern: string, limitPerType: number = 10, - typeFilter: 'user' | 'group' | null = null, + typeFilter: PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE | null = null, session?: ClientSession, ): Promise { if (!searchPattern || searchPattern.trim().length === 0) { @@ -486,7 +499,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { const trimmedPattern = searchPattern.trim(); const promises: Promise[] = []; - if (!typeFilter || typeFilter === 'user') { + if (!typeFilter || typeFilter === PrincipalType.USER) { /** Note: searchUsers is imported from ~/models and needs to be passed in or implemented */ const userFields = 'name email username avatar provider idOnTheSource'; /** For now, we'll use a direct query instead of searchUsers */ @@ -521,7 +534,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { promises.push(Promise.resolve([])); } - if (!typeFilter || typeFilter === 'group') { + if (!typeFilter || typeFilter === PrincipalType.GROUP) { promises.push( findGroupsByNamePattern(trimmedPattern, null, limitPerType, session).then((groups) => groups.map(transformGroupToTPrincipalSearchResult), @@ -531,9 +544,34 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { promises.push(Promise.resolve([])); } - const [users, groups] = await Promise.all(promises); + if (!typeFilter || typeFilter === PrincipalType.ROLE) { + const Role = mongoose.models.Role as Model; + if (Role) { + const regex = new RegExp(trimmedPattern, 'i'); + const roleQuery = Role.find({ name: regex }).select('name').limit(limitPerType); - const combined = [...users, ...groups]; + if (session) { + roleQuery.session(session); + } + + promises.push( + roleQuery.lean().then((roles) => + roles.map((role) => ({ + /** Role name as ID */ + id: role.name, + type: PrincipalType.ROLE, + name: role.name, + source: 'local' as const, + })), + ), + ); + } + } else { + promises.push(Promise.resolve([])); + } + + const results = await Promise.all(promises); + const combined = results.flat(); return combined; } diff --git a/packages/data-schemas/src/schema/aclEntry.ts b/packages/data-schemas/src/schema/aclEntry.ts index 597e6d9d5c..dbaf73b466 100644 --- a/packages/data-schemas/src/schema/aclEntry.ts +++ b/packages/data-schemas/src/schema/aclEntry.ts @@ -10,7 +10,7 @@ const aclEntrySchema = new Schema( required: true, }, principalId: { - type: Schema.Types.ObjectId, + type: Schema.Types.Mixed, // Can be ObjectId for users/groups or String for roles refPath: 'principalModel', required: function (this: IAclEntry) { return this.principalType !== PrincipalType.PUBLIC; diff --git a/packages/data-schemas/src/types/aclEntry.ts b/packages/data-schemas/src/types/aclEntry.ts index 13fa69ad5f..e2ac769c27 100644 --- a/packages/data-schemas/src/types/aclEntry.ts +++ b/packages/data-schemas/src/types/aclEntry.ts @@ -4,8 +4,8 @@ import { PrincipalType, PrincipalModel, ResourceType } from 'librechat-data-prov export type AclEntry = { /** The type of principal ('user', 'group', 'public') */ principalType: PrincipalType; - /** The ID of the principal (null for 'public') */ - principalId?: Types.ObjectId; + /** The ID of the principal (null for 'public', string for 'role') */ + principalId?: Types.ObjectId | string; /** The model name for the principal ('User' or 'Group') */ principalModel?: PrincipalModel; /** The type of resource ('agent', 'project', 'file', 'promptGroup') */