mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-22 11:20:15 +01:00
WIP: Role as Permission Principal Type
This commit is contained in:
parent
7c35d17e3d
commit
89f0a4e02f
11 changed files with 1167 additions and 38 deletions
|
|
@ -72,7 +72,7 @@ const updateResourcePermissions = async (req, res) => {
|
||||||
// Add public permission if enabled
|
// Add public permission if enabled
|
||||||
if (isPublic && publicAccessRoleId) {
|
if (isPublic && publicAccessRoleId) {
|
||||||
updatedPrincipals.push({
|
updatedPrincipals.push({
|
||||||
type: 'public',
|
type: PrincipalType.PUBLIC,
|
||||||
id: null,
|
id: null,
|
||||||
accessRoleId: publicAccessRoleId,
|
accessRoleId: publicAccessRoleId,
|
||||||
});
|
});
|
||||||
|
|
@ -97,11 +97,13 @@ const updateResourcePermissions = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
let principalId;
|
let principalId;
|
||||||
|
|
||||||
if (principal.type === 'public') {
|
if (principal.type === PrincipalType.PUBLIC) {
|
||||||
principalId = null; // Public principals don't need database records
|
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);
|
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
|
// Pass authContext to enable member fetching for Entra ID groups when available
|
||||||
principalId = await ensureGroupPrincipalExists(principal, authContext);
|
principalId = await ensureGroupPrincipalExists(principal, authContext);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -137,7 +139,7 @@ const updateResourcePermissions = async (req, res) => {
|
||||||
// If public is disabled, add public to revoked list
|
// If public is disabled, add public to revoked list
|
||||||
if (!isPublic) {
|
if (!isPublic) {
|
||||||
revokedPrincipals.push({
|
revokedPrincipals.push({
|
||||||
type: 'public',
|
type: PrincipalType.PUBLIC,
|
||||||
id: null,
|
id: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -263,6 +265,16 @@ const getResourcePermissions = async (req, res) => {
|
||||||
idOnTheSource: result.groupInfo.idOnTheSource || result.groupInfo._id.toString(),
|
idOnTheSource: result.groupInfo.idOnTheSource || result.groupInfo._id.toString(),
|
||||||
accessRoleId: result.accessRoleId,
|
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 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);
|
const localResults = await searchLocalPrincipals(query.trim(), searchLimit, typeFilter);
|
||||||
let allPrincipals = [...localResults];
|
let allPrincipals = [...localResults];
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const { isEnabled } = require('@librechat/api');
|
const { isEnabled } = require('@librechat/api');
|
||||||
const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider');
|
|
||||||
const { getTransactionSupport, logger } = require('@librechat/data-schemas');
|
const { getTransactionSupport, logger } = require('@librechat/data-schemas');
|
||||||
|
const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
entraIdPrincipalFeatureEnabled,
|
entraIdPrincipalFeatureEnabled,
|
||||||
getUserOwnedEntraGroups,
|
getUserOwnedEntraGroups,
|
||||||
|
|
@ -70,10 +70,21 @@ const grantPermission = async ({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (principalType !== PrincipalType.PUBLIC && !principalId) {
|
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}`);
|
throw new Error(`Invalid principal ID: ${principalId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -616,6 +627,12 @@ const bulkUpdateResourcePermissions = async ({
|
||||||
query.principalId = principal.id;
|
query.principalId = principal.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const principalModelMap = {
|
||||||
|
[PrincipalType.USER]: PrincipalModel.USER,
|
||||||
|
[PrincipalType.GROUP]: PrincipalModel.GROUP,
|
||||||
|
[PrincipalType.ROLE]: PrincipalModel.ROLE,
|
||||||
|
};
|
||||||
|
|
||||||
const update = {
|
const update = {
|
||||||
$set: {
|
$set: {
|
||||||
permBits: role.permBits,
|
permBits: role.permBits,
|
||||||
|
|
@ -629,8 +646,7 @@ const bulkUpdateResourcePermissions = async ({
|
||||||
resourceId,
|
resourceId,
|
||||||
...(principal.type !== PrincipalType.PUBLIC && {
|
...(principal.type !== PrincipalType.PUBLIC && {
|
||||||
principalId: principal.id,
|
principalId: principal.id,
|
||||||
principalModel:
|
principalModel: principalModelMap[principal.type],
|
||||||
principal.type === PrincipalType.USER ? PrincipalModel.USER : PrincipalModel.GROUP,
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ describe('PermissionService', () => {
|
||||||
const groupId = new mongoose.Types.ObjectId();
|
const groupId = new mongoose.Types.ObjectId();
|
||||||
const resourceId = new mongoose.Types.ObjectId();
|
const resourceId = new mongoose.Types.ObjectId();
|
||||||
const grantedById = new mongoose.Types.ObjectId();
|
const grantedById = new mongoose.Types.ObjectId();
|
||||||
|
const roleResourceId = new mongoose.Types.ObjectId();
|
||||||
|
|
||||||
describe('grantPermission', () => {
|
describe('grantPermission', () => {
|
||||||
test('should grant permission to a user with a role', async () => {
|
test('should grant permission to a user with a role', async () => {
|
||||||
|
|
@ -171,7 +172,7 @@ describe('PermissionService', () => {
|
||||||
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||||
grantedBy: grantedById,
|
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 () => {
|
test('should throw error for non-existent role', async () => {
|
||||||
|
|
@ -1000,6 +1001,72 @@ describe('PermissionService', () => {
|
||||||
expect(publicEntry.roleId.accessRoleId).toBe(AccessRoleIds.AGENT_EDITOR);
|
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('should work with different resource types', async () => {
|
||||||
// Test with promptGroup resources
|
// Test with promptGroup resources
|
||||||
const promptGroupResourceId = new mongoose.Types.ObjectId();
|
const promptGroupResourceId = new mongoose.Types.ObjectId();
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export enum PrincipalType {
|
||||||
USER = 'user',
|
USER = 'user',
|
||||||
GROUP = 'group',
|
GROUP = 'group',
|
||||||
PUBLIC = 'public',
|
PUBLIC = 'public',
|
||||||
|
ROLE = 'role',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -25,6 +26,7 @@ export enum PrincipalType {
|
||||||
export enum PrincipalModel {
|
export enum PrincipalModel {
|
||||||
USER = 'User',
|
USER = 'User',
|
||||||
GROUP = 'Group',
|
GROUP = 'Group',
|
||||||
|
ROLE = 'Role',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -74,16 +76,16 @@ export enum AccessRoleIds {
|
||||||
// ===== ZOD SCHEMAS =====
|
// ===== 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({
|
export const principalSchema = z.object({
|
||||||
type: z.nativeEnum(PrincipalType),
|
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(),
|
name: z.string().optional(),
|
||||||
email: z.string().optional(), // for user and group types
|
email: z.string().optional(), // for user and group types
|
||||||
source: z.enum(['local', 'entra']).optional(),
|
source: z.enum(['local', 'entra']).optional(),
|
||||||
avatar: z.string().optional(), // for user and group types
|
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
|
idOnTheSource: z.string().optional(), // Entra ID for users/groups
|
||||||
accessRoleId: z.nativeEnum(AccessRoleIds).optional(), // Access role ID for permissions
|
accessRoleId: z.nativeEnum(AccessRoleIds).optional(), // Access role ID for permissions
|
||||||
memberCount: z.number().optional(), // for group type
|
memberCount: z.number().optional(), // for group type
|
||||||
|
|
@ -192,7 +194,7 @@ export type TUpdateResourcePermissionsResponse = z.infer<
|
||||||
export type TPrincipalSearchParams = {
|
export type TPrincipalSearchParams = {
|
||||||
q: string; // search query (required)
|
q: string; // search query (required)
|
||||||
limit?: number; // max results (1-50, default 10)
|
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 = {
|
export type TPrincipalSearchResult = {
|
||||||
id?: string | null; // null for Entra ID principals that don't exist locally yet
|
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;
|
name: string;
|
||||||
email?: string; // for users and groups
|
email?: string; // for users and groups
|
||||||
username?: string; // for users
|
username?: string; // for users
|
||||||
|
|
@ -218,7 +220,7 @@ export type TPrincipalSearchResult = {
|
||||||
export type TPrincipalSearchResponse = {
|
export type TPrincipalSearchResponse = {
|
||||||
query: string;
|
query: string;
|
||||||
limit: number;
|
limit: number;
|
||||||
type?: 'user' | 'group';
|
type?: PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE;
|
||||||
results: TPrincipalSearchResult[];
|
results: TPrincipalSearchResult[];
|
||||||
count: number;
|
count: number;
|
||||||
sources: {
|
sources: {
|
||||||
|
|
|
||||||
|
|
@ -148,8 +148,13 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) {
|
||||||
|
|
||||||
if (principalType !== PrincipalType.PUBLIC) {
|
if (principalType !== PrincipalType.PUBLIC) {
|
||||||
query.principalId = principalId;
|
query.principalId = principalId;
|
||||||
query.principalModel =
|
if (principalType === PrincipalType.USER) {
|
||||||
principalType === PrincipalType.USER ? PrincipalModel.USER : PrincipalModel.GROUP;
|
query.principalModel = PrincipalModel.USER;
|
||||||
|
} else if (principalType === PrincipalType.GROUP) {
|
||||||
|
query.principalModel = PrincipalModel.GROUP;
|
||||||
|
} else if (principalType === PrincipalType.ROLE) {
|
||||||
|
query.principalModel = PrincipalModel.ROLE;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const update = {
|
const update = {
|
||||||
|
|
|
||||||
620
packages/data-schemas/src/methods/userGroup.methods.spec.ts
Normal file
620
packages/data-schemas/src/methods/userGroup.methods.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
367
packages/data-schemas/src/methods/userGroup.roles.spec.ts
Normal file
367
packages/data-schemas/src/methods/userGroup.roles.spec.ts
Normal file
|
|
@ -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<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(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<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(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -21,8 +21,8 @@ let methods: ReturnType<typeof createUserGroupMethods>;
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
mongoServer = await MongoMemoryServer.create();
|
mongoServer = await MongoMemoryServer.create();
|
||||||
const mongoUri = mongoServer.getUri();
|
const mongoUri = mongoServer.getUri();
|
||||||
Group = mongoose.models.Group || mongoose.model('Group', groupSchema);
|
Group = mongoose.models.Group || mongoose.model<t.IGroup>('Group', groupSchema);
|
||||||
User = mongoose.models.User || mongoose.model('User', userSchema);
|
User = mongoose.models.User || mongoose.model<t.IUser>('User', userSchema);
|
||||||
methods = createUserGroupMethods(mongoose);
|
methods = createUserGroupMethods(mongoose);
|
||||||
await mongoose.connect(mongoUri);
|
await mongoose.connect(mongoUri);
|
||||||
});
|
});
|
||||||
|
|
@ -327,8 +327,8 @@ describe('User Group Methods Tests', () => {
|
||||||
/** Get user principals */
|
/** Get user principals */
|
||||||
const principals = await methods.getUserPrincipals(testUser1._id as mongoose.Types.ObjectId);
|
const principals = await methods.getUserPrincipals(testUser1._id as mongoose.Types.ObjectId);
|
||||||
|
|
||||||
/** Should include user, group, and public principals */
|
/** Should include user, role (default USER), group, and public principals */
|
||||||
expect(principals).toHaveLength(3);
|
expect(principals).toHaveLength(4);
|
||||||
|
|
||||||
/** Check principal types */
|
/** Check principal types */
|
||||||
const userPrincipal = principals.find((p) => p.principalType === PrincipalType.USER);
|
const userPrincipal = principals.find((p) => p.principalType === PrincipalType.USER);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { PrincipalType } from 'librechat-data-provider';
|
import { PrincipalType } from 'librechat-data-provider';
|
||||||
import type { TUser, TPrincipalSearchResult } from 'librechat-data-provider';
|
import type { TUser, TPrincipalSearchResult } from 'librechat-data-provider';
|
||||||
import type { Model, Types, ClientSession } from 'mongoose';
|
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')) {
|
export function createUserGroupMethods(mongoose: typeof import('mongoose')) {
|
||||||
/**
|
/**
|
||||||
|
|
@ -249,6 +249,19 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) {
|
||||||
{ principalType: PrincipalType.USER, principalId: userId },
|
{ principalType: PrincipalType.USER, principalId: userId },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Get user to check their role
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 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);
|
const userGroups = await getUserGroups(userId, session);
|
||||||
if (userGroups && userGroups.length > 0) {
|
if (userGroups && userGroups.length > 0) {
|
||||||
userGroups.forEach((group) => {
|
userGroups.forEach((group) => {
|
||||||
|
|
@ -374,7 +387,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) {
|
||||||
|
|
||||||
/** Get searchable text based on type */
|
/** Get searchable text based on type */
|
||||||
const searchableFields =
|
const searchableFields =
|
||||||
item.type === 'user'
|
item.type === PrincipalType.USER
|
||||||
? [item.name, item.email, item.username].filter(Boolean)
|
? [item.name, item.email, item.username].filter(Boolean)
|
||||||
: [item.name, item.email, item.description].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);
|
return (b._searchScore || 0) - (a._searchScore || 0);
|
||||||
}
|
}
|
||||||
if (a.type !== b.type) {
|
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 aName = a.name || a.email || '';
|
||||||
const bName = b.name || b.email || '';
|
const bName = b.name || b.email || '';
|
||||||
|
|
@ -434,7 +447,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) {
|
||||||
function transformUserToTPrincipalSearchResult(user: TUser): TPrincipalSearchResult {
|
function transformUserToTPrincipalSearchResult(user: TUser): TPrincipalSearchResult {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
type: 'user',
|
type: PrincipalType.USER,
|
||||||
name: user.name || user.email,
|
name: user.name || user.email,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
|
@ -453,7 +466,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) {
|
||||||
function transformGroupToTPrincipalSearchResult(group: IGroup): TPrincipalSearchResult {
|
function transformGroupToTPrincipalSearchResult(group: IGroup): TPrincipalSearchResult {
|
||||||
return {
|
return {
|
||||||
id: group._id?.toString(),
|
id: group._id?.toString(),
|
||||||
type: 'group',
|
type: PrincipalType.GROUP,
|
||||||
name: group.name,
|
name: group.name,
|
||||||
email: group.email,
|
email: group.email,
|
||||||
avatar: group.avatar,
|
avatar: group.avatar,
|
||||||
|
|
@ -469,14 +482,14 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) {
|
||||||
* Returns combined results in TPrincipalSearchResult format without sorting
|
* Returns combined results in TPrincipalSearchResult format without sorting
|
||||||
* @param searchPattern - The pattern to search for
|
* @param searchPattern - The pattern to search for
|
||||||
* @param limitPerType - Maximum number of results to return
|
* @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
|
* @param session - Optional MongoDB session for transactions
|
||||||
* @returns Array of principals in TPrincipalSearchResult format
|
* @returns Array of principals in TPrincipalSearchResult format
|
||||||
*/
|
*/
|
||||||
async function searchPrincipals(
|
async function searchPrincipals(
|
||||||
searchPattern: string,
|
searchPattern: string,
|
||||||
limitPerType: number = 10,
|
limitPerType: number = 10,
|
||||||
typeFilter: 'user' | 'group' | null = null,
|
typeFilter: PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE | null = null,
|
||||||
session?: ClientSession,
|
session?: ClientSession,
|
||||||
): Promise<TPrincipalSearchResult[]> {
|
): Promise<TPrincipalSearchResult[]> {
|
||||||
if (!searchPattern || searchPattern.trim().length === 0) {
|
if (!searchPattern || searchPattern.trim().length === 0) {
|
||||||
|
|
@ -486,7 +499,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) {
|
||||||
const trimmedPattern = searchPattern.trim();
|
const trimmedPattern = searchPattern.trim();
|
||||||
const promises: Promise<TPrincipalSearchResult[]>[] = [];
|
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 */
|
/** Note: searchUsers is imported from ~/models and needs to be passed in or implemented */
|
||||||
const userFields = 'name email username avatar provider idOnTheSource';
|
const userFields = 'name email username avatar provider idOnTheSource';
|
||||||
/** For now, we'll use a direct query instead of searchUsers */
|
/** 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([]));
|
promises.push(Promise.resolve([]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!typeFilter || typeFilter === 'group') {
|
if (!typeFilter || typeFilter === PrincipalType.GROUP) {
|
||||||
promises.push(
|
promises.push(
|
||||||
findGroupsByNamePattern(trimmedPattern, null, limitPerType, session).then((groups) =>
|
findGroupsByNamePattern(trimmedPattern, null, limitPerType, session).then((groups) =>
|
||||||
groups.map(transformGroupToTPrincipalSearchResult),
|
groups.map(transformGroupToTPrincipalSearchResult),
|
||||||
|
|
@ -531,9 +544,34 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) {
|
||||||
promises.push(Promise.resolve([]));
|
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;
|
return combined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ const aclEntrySchema = new Schema<IAclEntry>(
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
principalId: {
|
principalId: {
|
||||||
type: Schema.Types.ObjectId,
|
type: Schema.Types.Mixed, // Can be ObjectId for users/groups or String for roles
|
||||||
refPath: 'principalModel',
|
refPath: 'principalModel',
|
||||||
required: function (this: IAclEntry) {
|
required: function (this: IAclEntry) {
|
||||||
return this.principalType !== PrincipalType.PUBLIC;
|
return this.principalType !== PrincipalType.PUBLIC;
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import { PrincipalType, PrincipalModel, ResourceType } from 'librechat-data-prov
|
||||||
export type AclEntry = {
|
export type AclEntry = {
|
||||||
/** The type of principal ('user', 'group', 'public') */
|
/** The type of principal ('user', 'group', 'public') */
|
||||||
principalType: PrincipalType;
|
principalType: PrincipalType;
|
||||||
/** The ID of the principal (null for 'public') */
|
/** The ID of the principal (null for 'public', string for 'role') */
|
||||||
principalId?: Types.ObjectId;
|
principalId?: Types.ObjectId | string;
|
||||||
/** The model name for the principal ('User' or 'Group') */
|
/** The model name for the principal ('User' or 'Group') */
|
||||||
principalModel?: PrincipalModel;
|
principalModel?: PrincipalModel;
|
||||||
/** The type of resource ('agent', 'project', 'file', 'promptGroup') */
|
/** The type of resource ('agent', 'project', 'file', 'promptGroup') */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue