Compare commits

...

18 Commits

Author SHA1 Message Date
Ruben Talstra
c696b935b8 Merge branch 'main' into feat/user-groups 2025-03-19 14:36:27 +01:00
Ruben Talstra
b1147b6409 🔧 feat: Import groupSchema into index.ts for improved schema management 2025-03-10 17:50:16 +01:00
Ruben Talstra
4448c13684 🔧 refactor: Update Group Schema to use TypeScript and import from data-schemas 2025-03-10 15:19:52 +01:00
Ruben Talstra
2fd04b6d65 Merge branch 'main' into feat/user-groups 2025-03-10 15:11:30 +01:00
Ruben Talstra
a8955e51a3 refactor: Remove groups property from user schema to streamline user model 2025-03-10 15:11:12 +01:00
Ruben Talstra
d2559ba977 feat: Add example model specifications with badges to librechat configuration 2025-02-22 12:40:54 +01:00
Ruben Talstra
b50406ab9e feat: Add badge support to model specifications with TBadge type and schema 2025-02-22 12:38:12 +01:00
Ruben Talstra
acafdb54c2 fix: Update Group import path in groupMethods.js for consistency 2025-02-22 12:22:30 +01:00
Ruben Talstra
ca3237c7be feat: Implement role extraction and user group update logic in OpenID strategy 2025-02-22 12:18:00 +01:00
Ruben Talstra
d3764fd9fe feat: Enhance group search functionality to support multiple group retrieval 2025-02-22 12:02:09 +01:00
Ruben Talstra
24fc57fd01 fix: Update Group import path in openidStrategy.js for consistency 2025-02-22 11:49:10 +01:00
Ruben Talstra
e0ab2c666a fix: Adjust User import path in groupMethods.js for consistency 2025-02-22 11:46:40 +01:00
Ruben Talstra
e8702e104d feat: Add Group Access Control to Model Specifications and Update User Filtering Logic 2025-02-22 11:44:59 +01:00
Ruben Talstra
dd762f7223 feat: Refactor Group Assignment and Creation Logic with Enhanced Input Handling 2025-02-22 11:00:15 +01:00
Ruben Talstra
4166eac99d feat: Implement Group Management Methods for CRUD Operations 2025-02-22 11:00:09 +01:00
Ruben Talstra
82a1f554b5 feat: Enhance Group and User Schemas with OpenID Support and Documentation 2025-02-22 11:00:02 +01:00
Ruben Talstra
0cdccb617c Merge branch 'main' into feat/user-groups 2025-02-22 10:08:12 +01:00
Ruben Talstra
39649ce523 feat: Implement Group Management with Create and Assign Functionality 2025-02-18 23:02:35 +01:00
16 changed files with 481 additions and 39 deletions

6
api/models/Group.js Normal file
View File

@@ -0,0 +1,6 @@
const mongoose = require('mongoose');
const { groupSchema } = require('@librechat/data-schemas');
const Group = mongoose.model('Group', groupSchema);
module.exports = Group;

127
api/models/groupMethods.js Normal file
View File

@@ -0,0 +1,127 @@
const User = require('./User');
const Group = require('~/models/Group');
/**
* Retrieve a group by ID and convert the found group document to a plain object.
*
* @param {string} groupId - The ID of the group to find and return as a plain object.
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
* @returns {Promise<Object|null>} A plain object representing the group document, or `null` if no group is found.
*/
const getGroupById = (groupId, fieldsToSelect = null) => {
const query = Group.findById(groupId);
if (fieldsToSelect) {
query.select(fieldsToSelect);
}
return query.lean();
};
/**
* Search for a single group or multiple groups based on partial data and return them as plain objects.
*
* @param {Partial<Object>} searchCriteria - The partial data to use for searching groups.
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned documents.
* @returns {Promise<Object[]>} An array of plain objects representing the group documents.
*/
const findGroup = (searchCriteria, fieldsToSelect = null) => {
const query = Group.find(searchCriteria);
if (fieldsToSelect) {
query.select(fieldsToSelect);
}
return query.lean();
};
/**
* Update a group with new data without overwriting existing properties.
*
* @param {string} groupId - The ID of the group to update.
* @param {Object} updateData - An object containing the properties to update.
* @returns {Promise<Object|null>} The updated group document as a plain object, or `null` if no group is found.
*/
const updateGroup = (groupId, updateData) => {
return Group.findByIdAndUpdate(
groupId,
{ $set: updateData },
{ new: true, runValidators: true },
).lean();
};
/**
* Create a new group.
*
* @param {Object} data - The group data to be created.
* @returns {Promise<Object>} The created group document.
*/
const createGroup = async (data) => {
return await Group.create(data);
};
/**
* Count the number of group documents in the collection based on the provided filter.
*
* @param {Object} [filter={}] - The filter to apply when counting the documents.
* @returns {Promise<number>} The count of documents that match the filter.
*/
const countGroups = (filter = {}) => {
return Group.countDocuments(filter);
};
/**
* Delete a group by its unique ID only if no user is assigned to it.
*
* @param {string} groupId - The ID of the group to delete.
* @returns {Promise<{ deletedCount: number, message: string }>} An object indicating the number of deleted documents.
*/
const deleteGroupById = async (groupId) => {
// Check if any users reference the group
const userCount = await User.countDocuments({ groups: groupId });
if (userCount > 0) {
return { deletedCount: 0, message: `Cannot delete group; it is assigned to ${userCount} user(s).` };
}
try {
const result = await Group.deleteOne({ _id: groupId });
if (result.deletedCount === 0) {
return { deletedCount: 0, message: 'No group found with that ID.' };
}
return { deletedCount: result.deletedCount, message: 'Group was deleted successfully.' };
} catch (error) {
throw new Error('Error deleting group: ' + error.message);
}
};
/**
* Override deletion of a group by its unique ID.
* This function first removes the group ObjectId from all users' groups arrays,
* then proceeds to delete the group document.
*
* @param {string} groupId - The ID of the group to delete.
* @returns {Promise<{ deletedCount: number, message: string }>} An object indicating the deletion result.
*/
const overrideDeleteGroupById = async (groupId) => {
// Remove group references from all users
await User.updateMany(
{ groups: groupId },
{ $pull: { groups: groupId } },
);
try {
const result = await Group.deleteOne({ _id: groupId });
if (result.deletedCount === 0) {
return { deletedCount: 0, message: 'No group found with that ID.' };
}
return { deletedCount: result.deletedCount, message: 'Group was deleted successfully (override).' };
} catch (error) {
throw new Error('Error deleting group: ' + error.message);
}
};
module.exports = {
getGroupById,
findGroup,
updateGroup,
createGroup,
countGroups,
deleteGroupById,
overrideDeleteGroupById,
};

View File

@@ -40,6 +40,7 @@ const { getPreset, getPresets, savePreset, deletePresets } = require('./Preset')
const { createToken, findToken, updateToken, deleteTokens } = require('./Token');
const Balance = require('./Balance');
const User = require('./User');
const Group = require('./Group');
const Key = require('./Key');
module.exports = {
@@ -92,6 +93,7 @@ module.exports = {
countActiveSessions,
User,
Group,
Key,
Balance,
};

View File

@@ -5,6 +5,7 @@ const { HttpsProxyAgent } = require('https-proxy-agent');
const { Issuer, Strategy: OpenIDStrategy, custom } = require('openid-client');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { findUser, createUser, updateUser } = require('~/models/userMethods');
const { findGroup } = require('~/models/groupMethods');
const { hashToken } = require('~/server/utils/crypto');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
@@ -105,6 +106,71 @@ function convertToUsername(input, defaultValue = '') {
return defaultValue;
}
/**
* Extracts roles from the specified token using configuration from environment variables.
* @param {object} tokenset - The token set returned by the OpenID provider.
* @returns {Array} The roles extracted from the token.
*/
function extractRoles(tokenset) {
const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND;
const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
const token =
requiredRoleTokenKind === 'access'
? jwtDecode(tokenset.access_token)
: jwtDecode(tokenset.id_token);
const pathParts = requiredRoleParameterPath.split('.');
let found = true;
const roles = pathParts.reduce((acc, key) => {
if (!acc || !(key in acc)) {
found = false;
return [];
}
return acc[key];
}, token);
if (!found) {
logger.error(
`[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`,
);
}
return roles;
}
/**
* Updates the user's groups based on the provided roles.
* It removes any existing OpenID group references and then adds the groups
* that match the roles from the external group collection.
*
* @param {object} user - The user object.
* @param {Array} roles - The roles extracted from the token.
* @returns {Promise<Array>} The updated groups array.
*/
async function updateUserGroups(user, roles) {
user.groups = user.groups || [];
// Remove existing OpenID group references.
const currentOpenIdGroups = await findGroup({
_id: { $in: user.groups },
provider: 'openid',
});
const currentOpenIdGroupIds = new Set(
currentOpenIdGroups.map((g) => g._id.toString()),
);
user.groups = user.groups.filter(
(id) => !currentOpenIdGroupIds.has(id.toString()),
);
// Look up groups matching the roles.
const matchingGroups = await findGroup({
provider: 'openid',
externalId: { $in: roles },
});
matchingGroups.forEach((group) => {
if (!user.groups.some((id) => id.toString() === group._id.toString())) {
user.groups.push(group._id);
}
});
return user.groups;
}
async function setupOpenId() {
try {
if (process.env.PROXY) {
@@ -134,8 +200,6 @@ async function setupOpenId() {
}
const client = new issuer.Client(clientMetadata);
const requiredRole = process.env.OPENID_REQUIRED_ROLE;
const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND;
const openidLogin = new OpenIDStrategy(
{
client,
@@ -145,8 +209,13 @@ async function setupOpenId() {
},
async (tokenset, userinfo, done) => {
try {
logger.info(`[openidStrategy] verify login openidId: ${userinfo.sub}`);
logger.debug('[openidStrategy] very login tokenset and userinfo', { tokenset, userinfo });
logger.info(
`[openidStrategy] verify login openidId: ${userinfo.sub}`,
);
logger.debug('[openidStrategy] verify login tokenset and userinfo', {
tokenset,
userinfo,
});
let user = await findUser({ openidId: userinfo.sub });
logger.info(
@@ -164,29 +233,10 @@ async function setupOpenId() {
const fullName = getFullName(userinfo);
// Check for the required role using extracted roles.
let roles = [];
if (requiredRole) {
let decodedToken = '';
if (requiredRoleTokenKind === 'access') {
decodedToken = jwtDecode(tokenset.access_token);
} else if (requiredRoleTokenKind === 'id') {
decodedToken = jwtDecode(tokenset.id_token);
}
const pathParts = requiredRoleParameterPath.split('.');
let found = true;
let roles = pathParts.reduce((o, key) => {
if (o === null || o === undefined || !(key in o)) {
found = false;
return [];
}
return o[key];
}, decodedToken);
if (!found) {
logger.error(
`[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`,
);
}
roles = extractRoles(tokenset);
if (!roles.includes(requiredRole)) {
return done(null, false, {
message: `You must have the "${requiredRole}" role to log in.`,
@@ -243,6 +293,10 @@ async function setupOpenId() {
}
}
if (requiredRole) {
await updateUserGroups(user, roles);
}
user = await updateUser(user._id, user);
logger.info(

View File

@@ -19,6 +19,9 @@ jest.mock('~/models/userMethods', () => ({
createUser: jest.fn(),
updateUser: jest.fn(),
}));
jest.mock('~/models/groupMethods', () => ({
findGroup: jest.fn().mockResolvedValue([]),
}));
jest.mock('~/server/utils/crypto', () => ({
hashToken: jest.fn().mockResolvedValue('hashed-token'),
}));

View File

@@ -76,6 +76,19 @@ const MenuItem: FC<MenuItemProps> = ({
<div>
{title}
<div className="text-text-secondary">{description}</div>
{spec.badges && spec.badges.length > 0 && (
<div className="mt-1 flex gap-2">
{spec.badges.map((badge, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold shadow-sm"
style={{ backgroundColor: badge.color, color: '#fff' }}
>
{badge.text}
</span>
))}
</div>
)}
</div>
</div>
</div>

View File

@@ -19,9 +19,22 @@ export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs?: TModelSpec
const localize = useLocalize();
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
const modularChat = useRecoilValue(store.modularChat);
const user = useRecoilValue(store.user);
const getDefaultConversation = useDefaultConvo();
const assistantMap = useAssistantsMapContext();
const allowedModelSpecs = useMemo(() => {
if (!modelSpecs) {return [];}
return modelSpecs.filter(spec => {
// If no groups defined for spec, allow it.
if (!spec.groups || spec.groups.length === 0) {return true;}
// Otherwise, check if the user exists and has groups.
if (!user || !user.groups || user.groups.length === 0) {return false;}
// Check if at least one of the spec's groups is in the user's groups.
return spec.groups.some(groupId => user.groups.includes(groupId));
});
}, [modelSpecs, user]);
const onSelectSpec = (spec: TModelSpec) => {
const { preset } = spec;
preset.iconURL = getModelSpecIconURL(spec);
@@ -82,21 +95,15 @@ export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs?: TModelSpec
};
const selected = useMemo(() => {
const spec = modelSpecs?.find((spec) => spec.name === conversation?.spec);
if (!spec) {
return undefined;
}
return spec;
}, [modelSpecs, conversation?.spec]);
const spec = allowedModelSpecs.find((spec) => spec.name === conversation?.spec);
return spec || undefined;
}, [allowedModelSpecs, conversation?.spec]);
const menuRef = useRef<HTMLDivElement>(null);
const handleKeyDown = useCallback((event: KeyboardEvent) => {
const menuItems = menuRef.current?.querySelectorAll('[role="option"]');
if (!menuItems) {
return;
}
if (!menuItems.length) {
if (!menuItems || !menuItems.length) {
return;
}
@@ -132,7 +139,7 @@ export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs?: TModelSpec
endpointsConfig={endpointsConfig}
/>
<Portal>
{modelSpecs && modelSpecs.length && (
{allowedModelSpecs && allowedModelSpecs.length > 0 && (
<div
style={{
position: 'fixed',
@@ -154,7 +161,7 @@ export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs?: TModelSpec
className="models-scrollbar mt-2 max-h-[65vh] min-w-[340px] max-w-xs overflow-y-auto rounded-lg border border-gray-100 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white lg:max-h-[75vh]"
>
<ModelSpecs
specs={modelSpecs}
specs={allowedModelSpecs}
selected={selected}
setSelected={onSelectSpec}
endpointsConfig={endpointsConfig}

69
config/assign-group.js Normal file
View File

@@ -0,0 +1,69 @@
const path = require('path');
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
const { askQuestion, silentExit } = require('./helpers');
const User = require('~/models/User');
const Group = require('~/models/Group');
const connect = require('./connect');
(async () => {
await connect();
console.purple('---------------------------------------');
console.purple('Assign a Group to a User');
console.purple('---------------------------------------');
// Read arguments from CLI or prompt the user
const userEmail = process.argv[2] || (await askQuestion('User email: '));
const groupName = process.argv[3] || (await askQuestion('Group name to assign: '));
// Validate email format
if (!userEmail.includes('@')) {
console.red('Error: Invalid email address!');
silentExit(1);
}
// Find the group by name
const group = await Group.findOne({ name: groupName });
if (!group) {
console.red('Error: No group with that name was found!');
silentExit(1);
}
// Find the user by email
const user = await User.findOne({ email: userEmail });
if (!user) {
console.red('Error: No user with that email was found!');
silentExit(1);
}
console.purple(`Found user: ${user.email}`);
// Assign the group to the user if not already assigned
try {
if (!Array.isArray(user.groups)) {
user.groups = [];
}
// Convert both user group IDs and the target group ID to strings for comparison
const groupIdStr = group._id.toString();
const userGroupIds = user.groups.map(id => id.toString());
if (!userGroupIds.includes(groupIdStr)) {
user.groups.push(group._id);
await user.save();
console.green(`User ${user.email} successfully assigned to group ${group.name}!`);
} else {
console.yellow(`User ${user.email} is already assigned to group ${group.name}.`);
}
} catch (error) {
console.red('Error assigning group to user: ' + error.message);
silentExit(1);
}
silentExit(0);
})();
process.on('uncaughtException', (err) => {
console.error('There was an uncaught error:');
console.error(err);
process.exit(1);
});

62
config/create-group.js Normal file
View File

@@ -0,0 +1,62 @@
const path = require('path');
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
const { askQuestion, silentExit } = require('./helpers');
const Group = require('~/models/Group');
const connect = require('./connect');
(async () => {
await connect();
console.purple('---------------------------------------');
console.purple('Create a New Group');
console.purple('---------------------------------------');
// Prompt for basic group info.
const groupName = process.argv[2] || (await askQuestion('Group name: '));
const groupDescription =
process.argv[3] || (await askQuestion('Group description (optional): '));
// Ask for the group type (local or openid; defaults to local)
let groupType =
process.argv[4] ||
(await askQuestion('Group type (local/openid, default is local): '));
groupType = groupType.trim().toLowerCase() || 'local';
let groupData;
if (groupType === 'openid') {
// For OpenID groups, prompt for an external ID.
const externalId =
process.argv[5] ||
(await askQuestion('External ID for OpenID group: '));
groupData = {
name: groupName,
description: groupDescription,
provider: 'openid',
externalId: externalId.trim(),
};
} else {
// For local groups, we only need name and description.
groupData = {
name: groupName,
description: groupDescription,
provider: 'local',
};
}
// Create the group document
let group;
try {
group = await Group.create(groupData);
} catch (error) {
console.red('Error creating group: ' + error.message);
silentExit(1);
}
console.green(`Group created successfully with id: ${group._id}`);
silentExit(0);
})();
process.on('uncaughtException', (err) => {
console.error('There was an uncaught error:');
console.error(err);
process.exit(1);
});

View File

@@ -104,6 +104,31 @@ registration:
# userMax: 50
# userWindowInMinutes: 60 # Rate limit window for conversation imports per user
# Example Model Specifications
#modelSpecs:
# enforce: true
# prioritize: true
# list:
# - name: "4o-mini"
# label: "4o-mini"
# groups:
# - "67b9a5c64165f31925e9b25a"
# - "67b9a5c64165f31925e9b25f"
# description: "The most advanced frontier model from Azure OpenAI, suitable to solve complex multi-step problems."
# iconURL: "https://www.librechat.ai/librechat.png"
# badges:
# - text: "Test"
# color: "#FF0000"
# - text: "Beta"
# color: "#00FF00"
# preset:
# default: true
# endpoint: "azureOpenAI"
# model: "gpt-4o-mini"
# modelLabel: "4o-mini"
# Example Actions Object Structure
actions:
allowedDomains:

View File

@@ -32,6 +32,8 @@
"reset-password": "node config/reset-password.js",
"ban-user": "node config/ban-user.js",
"delete-user": "node config/delete-user.js",
"create-group": "node config/create-group.js",
"assign-group": "node config/assign-group.js",
"update-banner": "node config/update-banner.js",
"delete-banner": "node config/delete-banner.js",
"backend": "cross-env NODE_ENV=production node api/server/index.js",

View File

@@ -8,6 +8,11 @@ import {
authTypeSchema,
} from './schemas';
export type TBadge = {
text: string;
color: string;
};
export type TModelSpec = {
name: string;
label: string;
@@ -19,8 +24,15 @@ export type TModelSpec = {
showIconInHeader?: boolean;
iconURL?: string | EModelEndpoint; // Allow using project-included icons
authType?: AuthType;
groups?: string[];
badges?: TBadge[];
};
export const tBadgeSchema = z.object({
text: z.string(),
color: z.string(),
});
export const tModelSpecSchema = z.object({
name: z.string(),
label: z.string(),
@@ -32,6 +44,8 @@ export const tModelSpecSchema = z.object({
showIconInHeader: z.boolean().optional(),
iconURL: z.union([z.string(), eModelEndpointSchema]).optional(),
authType: authTypeSchema.optional(),
groups: z.array(z.string()).optional(),
badges: z.array(tBadgeSchema).optional(),
});
export const specsConfigSchema = z.object({

View File

@@ -106,6 +106,16 @@ export type TBackupCode = {
usedAt: Date | null;
};
export type TGroup = {
id: string;
name: string;
description?: string;
externalId?: string;
provider: 'local' | 'openid';
createdAt?: string;
updatedAt?: string;
};
export type TUser = {
id: string;
username: string;
@@ -117,6 +127,7 @@ export type TUser = {
plugins?: string[];
twoFactorEnabled?: boolean;
backupCodes?: TBackupCode[];
groups: string[];
createdAt: string;
updatedAt: string;
};

View File

@@ -7,6 +7,7 @@ import categoriesSchema from './schema/categories';
import conversationTagSchema from './schema/conversationTag';
import convoSchema from './schema/convo';
import fileSchema from './schema/file';
import groupSchema from './schema/group';
import keySchema from './schema/key';
import messageSchema from './schema/message';
import pluginAuthSchema from './schema/pluginAuth';
@@ -32,6 +33,7 @@ export {
conversationTagSchema,
convoSchema,
fileSchema,
groupSchema,
keySchema,
messageSchema,
pluginAuthSchema,

View File

@@ -0,0 +1,39 @@
import { Schema, Document } from 'mongoose';
export interface IGroup extends Document {
name: string;
description?: string;
externalId?: string;
provider: 'local' | 'openid';
createdAt?: Date;
updatedAt?: Date;
}
const groupSchema = new Schema<IGroup>(
{
name: {
type: String,
required: true,
unique: true,
},
description: {
type: String,
},
externalId: {
type: String,
unique: true,
required: function (this: IGroup) {
return this.provider !== 'local';
},
},
provider: {
type: String,
required: true,
default: 'local',
enum: ['local', 'openid'],
},
},
{ timestamps: true },
);
export default groupSchema;

View File

@@ -1,4 +1,4 @@
import { Schema, Document } from 'mongoose';
import { Schema, Document, Types } from 'mongoose';
import { SystemRoles } from 'librechat-data-provider';
export interface IUser extends Document {
@@ -18,6 +18,7 @@ export interface IUser extends Document {
discordId?: string;
appleId?: string;
plugins?: unknown[];
groups?: Types.ObjectId[];
twoFactorEnabled?: boolean;
totpSecret?: string;
backupCodes?: Array<{
@@ -135,6 +136,11 @@ const User = new Schema<IUser>(
plugins: {
type: Array,
},
groups: {
type: [Schema.Types.ObjectId],
ref: 'Group',
default: [],
},
twoFactorEnabled: {
type: Boolean,
default: false,