🛂 feat: Role as Permission Principal Type

WIP: Role as Permission Principal Type

WIP: add user role check optimization to user principal check, update type comparisons

WIP: cover edge cases for string vs ObjectId handling in permission granting and checking

chore: Update people picker access middleware to use PrincipalType constants

feat: Enhance people picker access control to include roles permissions

chore: add missing default role schema values for people picker perms, cleanup typing

feat: Enhance PeoplePicker component with role-specific UI and localization updates

chore: Add missing `VIEW_ROLES` permission to role schema
This commit is contained in:
Danny Avila 2025-08-03 19:24:40 -04:00
parent 28d63dab71
commit 39346d6b8e
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
49 changed files with 2879 additions and 258 deletions

View file

@ -394,6 +394,192 @@ describe('AclEntry Model Tests', () => {
});
});
describe('String vs ObjectId Edge Cases', () => {
test('should handle string userId in grantPermission', async () => {
const userIdString = userId.toString();
const entry = await methods.grantPermission(
PrincipalType.USER,
userIdString, // Pass string instead of ObjectId
ResourceType.AGENT,
resourceId,
PermissionBits.VIEW,
grantedById,
);
expect(entry).toBeDefined();
expect(entry?.principalType).toBe(PrincipalType.USER);
// Should be stored as ObjectId
expect(entry?.principalId).toBeInstanceOf(mongoose.Types.ObjectId);
expect(entry?.principalId?.toString()).toBe(userIdString);
});
test('should handle string groupId in grantPermission', async () => {
const groupIdString = groupId.toString();
const entry = await methods.grantPermission(
PrincipalType.GROUP,
groupIdString, // Pass string instead of ObjectId
ResourceType.AGENT,
resourceId,
PermissionBits.VIEW,
grantedById,
);
expect(entry).toBeDefined();
expect(entry?.principalType).toBe(PrincipalType.GROUP);
// Should be stored as ObjectId
expect(entry?.principalId).toBeInstanceOf(mongoose.Types.ObjectId);
expect(entry?.principalId?.toString()).toBe(groupIdString);
});
test('should handle string roleId in grantPermission for ROLE type', async () => {
const roleString = 'admin';
const entry = await methods.grantPermission(
PrincipalType.ROLE,
roleString,
ResourceType.AGENT,
resourceId,
PermissionBits.VIEW,
grantedById,
);
expect(entry).toBeDefined();
expect(entry?.principalType).toBe(PrincipalType.ROLE);
// Should remain as string for ROLE type
expect(typeof entry?.principalId).toBe('string');
expect(entry?.principalId).toBe(roleString);
expect(entry?.principalModel).toBe(PrincipalModel.ROLE);
});
test('should handle string principalId in revokePermission', async () => {
// First grant permission with ObjectId
await methods.grantPermission(
PrincipalType.USER,
userId,
ResourceType.AGENT,
resourceId,
PermissionBits.VIEW,
grantedById,
);
// Then revoke with string ID
const result = await methods.revokePermission(
PrincipalType.USER,
userId.toString(), // Pass string
ResourceType.AGENT,
resourceId,
);
expect(result.deletedCount).toBe(1);
// Verify it's actually deleted
const entries = await methods.findEntriesByPrincipal(PrincipalType.USER, userId);
expect(entries).toHaveLength(0);
});
test('should handle string principalId in modifyPermissionBits', async () => {
// First grant permission with ObjectId
await methods.grantPermission(
PrincipalType.USER,
userId,
ResourceType.AGENT,
resourceId,
PermissionBits.VIEW,
grantedById,
);
// Then modify with string ID
const updated = await methods.modifyPermissionBits(
PrincipalType.USER,
userId.toString(), // Pass string
ResourceType.AGENT,
resourceId,
PermissionBits.EDIT,
null,
);
expect(updated).toBeDefined();
expect(updated?.permBits).toBe(PermissionBits.VIEW | PermissionBits.EDIT);
});
test('should handle mixed string and ObjectId in hasPermission', async () => {
// Grant permission with string ID
await methods.grantPermission(
PrincipalType.USER,
userId.toString(),
ResourceType.AGENT,
resourceId,
PermissionBits.VIEW,
grantedById,
);
// Check permission with ObjectId in principals list
const hasPermWithObjectId = await methods.hasPermission(
[{ principalType: PrincipalType.USER, principalId: userId }],
ResourceType.AGENT,
resourceId,
PermissionBits.VIEW,
);
expect(hasPermWithObjectId).toBe(true);
// Check permission with string in principals list
const hasPermWithString = await methods.hasPermission(
[{ principalType: PrincipalType.USER, principalId: userId.toString() }],
ResourceType.AGENT,
resourceId,
PermissionBits.VIEW,
);
expect(hasPermWithString).toBe(false); // This should fail because hasPermission doesn't convert
// Check with converted ObjectId
const hasPermWithConvertedId = await methods.hasPermission(
[
{
principalType: PrincipalType.USER,
principalId: new mongoose.Types.ObjectId(userId.toString()),
},
],
ResourceType.AGENT,
resourceId,
PermissionBits.VIEW,
);
expect(hasPermWithConvertedId).toBe(true);
});
test('should update existing permission when granting with string ID', async () => {
// First grant with ObjectId
await methods.grantPermission(
PrincipalType.USER,
userId,
ResourceType.AGENT,
resourceId,
PermissionBits.VIEW,
grantedById,
);
// Grant again with string ID and different permissions
const updated = await methods.grantPermission(
PrincipalType.USER,
userId.toString(),
ResourceType.AGENT,
resourceId,
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE,
grantedById,
);
expect(updated).toBeDefined();
expect(updated?.permBits).toBe(
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE,
);
// Should still only be one entry
const entries = await methods.findEntriesByPrincipal(PrincipalType.USER, userId);
expect(entries).toHaveLength(1);
});
});
describe('Resource Access Queries', () => {
test('should find accessible resources', async () => {
/** Create multiple resources with different permissions */

View file

@ -1,5 +1,6 @@
import { Types } from 'mongoose';
import { PrincipalType, PrincipalModel } from 'librechat-data-provider';
import type { Model, Types, DeleteResult, ClientSession } from 'mongoose';
import type { Model, DeleteResult, ClientSession } from 'mongoose';
import type { IAclEntry } from '~/types';
export function createAclEntryMethods(mongoose: typeof import('mongoose')) {
@ -147,9 +148,17 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) {
};
if (principalType !== PrincipalType.PUBLIC) {
query.principalId = principalId;
query.principalModel =
principalType === PrincipalType.USER ? PrincipalModel.USER : PrincipalModel.GROUP;
query.principalId =
typeof principalId === 'string' && principalType !== PrincipalType.ROLE
? new Types.ObjectId(principalId)
: principalId;
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 = {
@ -194,7 +203,10 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) {
};
if (principalType !== PrincipalType.PUBLIC) {
query.principalId = principalId;
query.principalId =
typeof principalId === 'string' && principalType !== PrincipalType.ROLE
? new Types.ObjectId(principalId)
: principalId;
}
const options = session ? { session } : {};
@ -230,7 +242,10 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) {
};
if (principalType !== PrincipalType.PUBLIC) {
query.principalId = principalId;
query.principalId =
typeof principalId === 'string' && principalType !== PrincipalType.ROLE
? new Types.ObjectId(principalId)
: principalId;
}
const update: Record<string, unknown> = {};

View file

@ -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<t.IGroup>;
let User: mongoose.Model<t.IUser>;
let methods: ReturnType<typeof createUserGroupMethods>;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
/** Register models */
Group = mongoose.models.Group || mongoose.model<t.IGroup>('Group', groupSchema);
User = mongoose.models.User || mongoose.model<t.IUser>('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<t.IUser>;
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<t.IGroup> = {
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<t.IUser>;
let userWithExternal: mongoose.HydratedDocument<t.IUser>;
let group: mongoose.HydratedDocument<t.IGroup>;
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<t.IUser>;
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<t.IUser>;
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);
});
});
});

View file

@ -0,0 +1,383 @@
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<t.IGroup>;
let User: mongoose.Model<t.IUser>;
let Role: mongoose.Model<t.IRole>;
let methods: ReturnType<typeof createUserGroupMethods>;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
/** Register models */
Group = mongoose.models.Group || mongoose.model<t.IGroup>('Group', groupSchema);
User = mongoose.models.User || mongoose.model<t.IUser>('User', userSchema);
Role = mongoose.models.Role || mongoose.model<t.IRole>('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({
userId: 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({
userId: 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({
userId: 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({
userId: 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({
userId: 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({
userId: 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({
userId: 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({
userId: 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<t.IUser> = {
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({
userId: 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();
}
}
});
});
});

View file

@ -21,8 +21,8 @@ let methods: ReturnType<typeof createUserGroupMethods>;
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<t.IGroup>('Group', groupSchema);
User = mongoose.models.User || mongoose.model<t.IUser>('User', userSchema);
methods = createUserGroupMethods(mongoose);
await mongoose.connect(mongoUri);
});
@ -325,10 +325,12 @@ describe('User Group Methods Tests', () => {
);
/** Get user principals */
const principals = await methods.getUserPrincipals(testUser1._id as mongoose.Types.ObjectId);
const principals = await methods.getUserPrincipals({
userId: 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);
@ -349,7 +351,9 @@ describe('User Group Methods Tests', () => {
test('should return user and public principals for non-existent user in getUserPrincipals', async () => {
const nonExistentId = new mongoose.Types.ObjectId();
const principals = await methods.getUserPrincipals(nonExistentId);
const principals = await methods.getUserPrincipals({
userId: nonExistentId,
});
/** Should still return user and public principals even for non-existent user */
expect(principals).toHaveLength(2);
@ -358,6 +362,61 @@ describe('User Group Methods Tests', () => {
expect(principals[1].principalType).toBe(PrincipalType.PUBLIC);
expect(principals[1].principalId).toBeUndefined();
});
test('should convert string userId to ObjectId in getUserPrincipals', async () => {
/** Add user to a group */
await methods.addUserToGroup(
testUser1._id as mongoose.Types.ObjectId,
testGroup._id as mongoose.Types.ObjectId,
);
/** Get user principals with string userId */
const principals = await methods.getUserPrincipals({
userId: (testUser1._id as mongoose.Types.ObjectId).toString(),
});
/** Should include user, role (default USER), group, and public principals */
expect(principals).toHaveLength(4);
/** Check that USER principal has ObjectId */
const userPrincipal = principals.find((p) => p.principalType === PrincipalType.USER);
expect(userPrincipal).toBeDefined();
expect(userPrincipal?.principalId).toBeInstanceOf(mongoose.Types.ObjectId);
expect(userPrincipal?.principalId?.toString()).toBe(
(testUser1._id as mongoose.Types.ObjectId).toString(),
);
/** Check that GROUP principal has ObjectId */
const groupPrincipal = principals.find((p) => p.principalType === PrincipalType.GROUP);
expect(groupPrincipal).toBeDefined();
expect(groupPrincipal?.principalId).toBeInstanceOf(mongoose.Types.ObjectId);
expect(groupPrincipal?.principalId?.toString()).toBe(testGroup._id.toString());
});
test('should include role principal as string in getUserPrincipals', async () => {
/** Create user with specific role */
const userWithRole = await User.create({
name: 'Admin User',
email: 'admin@example.com',
password: 'password123',
provider: 'local',
role: 'ADMIN',
});
/** Get user principals */
const principals = await methods.getUserPrincipals({
userId: userWithRole._id as mongoose.Types.ObjectId,
});
/** Should include user, role, and public principals */
expect(principals).toHaveLength(3);
/** Check that ROLE principal has string ID */
const rolePrincipal = principals.find((p) => p.principalType === PrincipalType.ROLE);
expect(rolePrincipal).toBeDefined();
expect(typeof rolePrincipal?.principalId).toBe('string');
expect(rolePrincipal?.principalId).toBe('ADMIN');
});
});
describe('Entra ID Synchronization', () => {

View file

@ -1,7 +1,8 @@
import { Types } from 'mongoose';
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 { Model, ClientSession } from 'mongoose';
import type { IGroup, IRole, IUser } from '~/types';
export function createUserGroupMethods(mongoose: typeof import('mongoose')) {
/**
@ -237,22 +238,47 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) {
/**
* Get a list of all principal identifiers for a user (user ID + group IDs + public)
* For use in permission checks
* @param userId - The user ID
* @param params - Parameters object
* @param params.userId - The user ID
* @param params.role - Optional user role (if not provided, will query from DB)
* @param session - Optional MongoDB session for transactions
* @returns Array of principal objects with type and id
*/
async function getUserPrincipals(
userId: string | Types.ObjectId,
params: {
userId: string | Types.ObjectId;
role?: string | null;
},
session?: ClientSession,
): Promise<Array<{ principalType: string; principalId?: string | Types.ObjectId }>> {
const { userId, role } = params;
/** `userId` must be an `ObjectId` for USER principal since ACL entries store `ObjectId`s */
const userObjectId = typeof userId === 'string' ? new Types.ObjectId(userId) : userId;
const principals: Array<{ principalType: string; principalId?: string | Types.ObjectId }> = [
{ principalType: PrincipalType.USER, principalId: userId },
{ principalType: PrincipalType.USER, principalId: userObjectId },
];
// If role is not provided, query user to get it
let userRole = role;
if (userRole === undefined) {
const User = mongoose.models.User as Model<IUser>;
const query = User.findById(userId).select('role');
if (session) {
query.session(session);
}
const user = await query.lean();
userRole = user?.role;
}
// Add role as a principal if user has one
if (userRole && userRole.trim()) {
principals.push({ principalType: PrincipalType.ROLE, principalId: userRole });
}
const userGroups = await getUserGroups(userId, session);
if (userGroups && userGroups.length > 0) {
userGroups.forEach((group) => {
principals.push({ principalType: PrincipalType.GROUP, principalId: group._id.toString() });
principals.push({ principalType: PrincipalType.GROUP, principalId: group._id });
});
}
@ -374,7 +400,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 +444,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 +460,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 +479,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 +495,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<TPrincipalSearchResult[]> {
if (!searchPattern || searchPattern.trim().length === 0) {
@ -486,7 +512,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) {
const trimmedPattern = searchPattern.trim();
const promises: Promise<TPrincipalSearchResult[]>[] = [];
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 +547,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 +557,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<IRole>;
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;
}

View file

@ -10,7 +10,7 @@ const aclEntrySchema = new Schema<IAclEntry>(
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;

View file

@ -42,6 +42,7 @@ const rolePermissionsSchema = new Schema(
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: { type: Boolean, default: false },
[Permissions.VIEW_GROUPS]: { type: Boolean, default: false },
[Permissions.VIEW_ROLES]: { type: Boolean, default: false },
},
[PermissionTypes.MARKETPLACE]: {
[Permissions.USE]: { type: Boolean, default: false },
@ -85,6 +86,7 @@ const roleSchema: Schema<IRole> = new Schema({
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: false,
[Permissions.VIEW_GROUPS]: false,
[Permissions.VIEW_ROLES]: false,
},
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: false },
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },

View file

@ -2,13 +2,13 @@ import type { Document, Types } from 'mongoose';
import { PrincipalType, PrincipalModel, ResourceType } from 'librechat-data-provider';
export type AclEntry = {
/** The type of principal ('user', 'group', 'public') */
/** The type of principal (PrincipalType.USER, PrincipalType.GROUP, PrincipalType.PUBLIC) */
principalType: PrincipalType;
/** The ID of the principal (null for 'public') */
principalId?: Types.ObjectId;
/** The model name for the principal ('User' or 'Group') */
/** The ID of the principal (null for PrincipalType.PUBLIC, string for PrincipalType.ROLE) */
principalId?: Types.ObjectId | string;
/** The model name for the principal (`PrincipalModel`) */
principalModel?: PrincipalModel;
/** The type of resource ('agent', 'project', 'file', 'promptGroup') */
/** The type of resource (`ResourceType`) */
resourceType: ResourceType;
/** The ID of the resource */
resourceId: Types.ObjectId;

View file

@ -38,6 +38,7 @@ export interface IRole extends Document {
[PermissionTypes.PEOPLE_PICKER]?: {
[Permissions.VIEW_USERS]?: boolean;
[Permissions.VIEW_GROUPS]?: boolean;
[Permissions.VIEW_ROLES]?: boolean;
};
[PermissionTypes.MARKETPLACE]?: {
[Permissions.USE]?: boolean;