diff --git a/ROLE_BASED_CONFIG.md b/ROLE_BASED_CONFIG.md new file mode 100644 index 0000000000..88fde8b747 --- /dev/null +++ b/ROLE_BASED_CONFIG.md @@ -0,0 +1,258 @@ +# Role-Based Configuration Architecture + +This document outlines the foundational architecture for role-based configuration management in LibreChat. + +## Overview + +The new architecture extends LibreChat's configuration system to support role, group, and user-specific configuration overrides while maintaining the existing `librechat.yaml` as the base configuration. The system uses a **priority-based merge strategy** where configurations are applied in order from lowest to highest priority. + +## Architecture Components + +### 1. Database Schema + +A single unified MongoDB collection following the ACL pattern: + +#### `Config` +- **Purpose**: Store configurations for any principal (user, group, or role) +- **Key Fields**: + - `principalType`: Type of entity (`PrincipalType.USER`, `PrincipalType.GROUP`, `PrincipalType.ROLE`) + - `principalId`: ID of the entity (ObjectId for users/groups, string for roles) + - `principalModel`: Model reference (`PrincipalModel.USER`, `PrincipalModel.GROUP`, `PrincipalModel.ROLE`) + - `priority`: Priority level for merge order (higher = more specific) + - `overrides`: Object matching `librechat.yaml` structure + - `isActive`: Toggle to enable/disable + - `configVersion`: Auto-increments for cache invalidation + +**Location**: +- Type: `packages/data-schemas/src/types/config.ts` +- Schema: `packages/data-schemas/src/schema/config.ts` +- Model: `packages/data-schemas/src/models/config.ts` + +**Design**: This follows LibreChat's existing ACL pattern (`AclEntry`), using a single collection for all principal types rather than separate collections per type. + +### 2. Database Methods + +**File**: `packages/data-schemas/src/methods/config.ts` (exported via `@librechat/data-schemas`) + +Database operations for config management: + +#### Key Methods: + +- **`getApplicableConfigs(principals)`**: Fetches all configs for given principals + - Takes array of principals from `getUserPrincipals` + - Single optimized `$or` query to DB + - Returns array of `IConfig` documents + +- **`findConfigByPrincipal(principalType, principalId)`**: Find config for specific principal +- **`upsertConfig(...)`**: Create or update a config +- **`deleteConfig(...)`**: Delete a config +- **`toggleConfigActive(...)`**: Enable/disable a config + +### 3. Configuration Resolution Service + +**File**: `packages/api/src/config/resolution.ts` (exported via `@librechat/api`) + +Simple service for merging configurations: + +#### Key Functions: + +- **`buildUserConfig({ baseConfig, cachedConfigs })`**: Merges base config with overrides + - Takes fresh `baseConfig` from YAML + - Takes cached `IConfig[]` documents + - Returns merged `TCustomConfig` + - Uses `deepmerge` npm package for merging + +- **`mergeConfigsFromDB(baseConfig, configs)`**: Internal helper + - Sorts configs by priority (ascending) + - Merges each config's overrides in order + +### 4. Updated `getAppConfig` + +**File**: `api/server/services/Config/app.js` + +The main config accessor with granular caching: +- `userId` or `role`: Determines which configs to apply +- **Granular caching**: Each config cached individually by principal +- Cache keys: `config:{principalType}:{principalId}` +- Graceful fallback to base config on errors + +## Configuration Priority System + +Configurations are applied in order from lowest to highest priority: + +``` +Priority 0: Base YAML config (librechat.yaml) +Priority X: All Config entries sorted by priority field +``` + +**Important**: +- Priority values are **not hardcoded** - each Config has its own priority +- Suggested defaults: Role (10-30), Group (20-50), User (100+) +- Higher priority values always override lower ones +- If a user belongs to multiple groups, each group config is applied in priority order + +## How It Works + +### Request Flow + +1. **Request comes in** with `userId` and/or `role` +2. **Load base config** from `librechat.yaml` (cached as `BASE_CONFIG_KEY`) +3. **Get user principals** via `getUserPrincipals({ userId, role, includeGroups: false })` + - Returns array: user, role (no groups for initial implementation) +4. **Check cache individually** for each principal: + - `config:role:admin` → admin role config + - `config:user:123` → user 123's config +5. **If any cache miss**: + - Single optimized DB query fetches all missing configs + - Cache each returned config individually +6. **Merge fresh baseConfig** with all configs (cached + freshly fetched) +7. **Return** the final merged config + +### Cache Strategy + +**Granular individual caching**: +- `BASE_CONFIG_KEY`: The full processed AppConfig from YAML +- `config:role:{roleName}`: Role config from DB (shared across all users with that role) +- `config:user:{userId}`: User-specific config from DB + +**Benefits**: +- **Efficient**: Role configs shared across users (e.g., all admins share `config:role:admin`) +- **Granular invalidation**: Change role config? Only clear that one cache entry +- **YAML independent**: Base config changes don't affect config caches +- **Optimized queries**: Single DB query for all missing configs + +### Example Scenario + +```javascript +// User "alice" has: +// - Role: "developer" +// Config: { principalType: 'role', principalId: 'developer', priority: 10 } +// - Groups: ["engineering-team", "beta-testers"] +// Configs: [ +// { principalType: 'group', principalId: ObjectId(engineering-team), priority: 25 }, +// { principalType: 'group', principalId: ObjectId(beta-testers), priority: 30 } +// ] +// - User config: +// Config: { principalType: 'user', principalId: ObjectId(alice), priority: 100 } + +// Merge order: +// 1. Base YAML (priority 0) +// 2. Role "developer" config (priority 10) +// 3. Group "engineering-team" config (priority 25) +// 4. Group "beta-testers" config (priority 30) +// 5. User "alice" config (priority 100) +``` + +## Integration Points + +### Current Integration +- Uses existing ACL system for group membership (`getUserGroups`) +- Maintains existing role system (1:1 user-role relationship) +- Preserves existing cache infrastructure (Redis/in-memory) +- Compatible with current `librechat.yaml` structure + +### What's NOT Yet Implemented +This is the **foundation only**. Still needed: + +1. **Admin UI**: Interface to create/edit configs +2. **API Endpoints**: REST API for CRUD operations on configs +3. **Validation**: Schema validation for config objects +4. **Cache Invalidation**: Pub/sub for multi-instance cache invalidation +5. **Testing**: Integration and unit tests +6. **Migration Tools**: Scripts to help migrate existing configs +7. **Documentation**: Admin guide for managing configs +8. **Audit Logging**: Track who changed what configs when + +## Usage Example + +```javascript +// In a request handler +const { getAppConfig } = require('~/server/services/Config/app'); + +// Get config for specific user +const config = await getAppConfig({ userId: req.user.id }); + +// Config will include all applicable overrides merged in priority order +console.log(config.endpoints); // User's effective endpoint config +``` + +## File Structure + +``` +packages/data-schemas/src/ +├── types/ +│ └── config.ts # IConfig type definitions +├── schema/ +│ └── config.ts # Config Mongoose schema +├── models/ +│ └── config.ts # Config model factory +└── methods/ + └── config.ts # Config DB operations + +packages/api/src/ +├── config/ +│ └── resolution.ts # Config resolution service (uses TCustomConfig) +└── types/ + └── config.ts # AppConfig type + +api/server/services/Config/ +└── app.js # Updated getAppConfig (main entry point) +``` + +**Note**: Uses `deepmerge` npm package for merging (not custom implementation) + +## Next Steps + +To build on this foundation, consider implementing in this order: + +1. **Create API endpoints** for managing configs (CRUD) +2. **Add validation** to ensure config objects match expected structure +3. **Build simple CLI tools** to test config creation/assignment +4. **Add cache invalidation** logic (pub/sub for Redis) +5. **Create admin UI** for visual config management +6. **Write tests** for the resolution logic +7. **Document** the config format and best practices + +## Design Decisions + +### Why Priority-Based? +- **Predictable**: Always know which config wins +- **Flexible**: Can adjust priority for special cases +- **Scalable**: Works with complex org hierarchies + +### Why Single Schema (ACL Pattern)? +- **Consistency**: Matches existing LibreChat ACL architecture +- **Simplicity**: One collection instead of three +- **Flexibility**: Easy to add new principal types in the future +- **Queries**: Simple to fetch all overrides for any principal + +### Why MongoDB? +- **Flexible Schema**: `overrides` can match any YAML structure +- **Already in use**: No new dependencies +- **ACL Integration**: Leverages existing group/role system + +### Why Granular Caching? +- **Individual cache entries**: Each config cached by `config:{type}:{id}` +- **Efficient sharing**: All admins share `config:role:admin` cache +- **Granular invalidation**: Change one config? Clear only that cache entry +- **YAML independence**: Base config changes don't invalidate config caches +- **Optimized queries**: Single DB query for all cache misses +- **Fresh merges**: Each request merges fresh baseConfig with cached overrides + +## Type Safety & Architecture + +- **DB Layer** (`data-schemas`): Uses `IConfig` interface for DB documents +- **App Layer** (`api`): Uses `TCustomConfig` from `librechat-data-provider` for type safety +- **No `any` types**: All types properly defined and enforced +- **Separation of Concerns**: DB methods in data-schemas, business logic in api +- **Optimized Queries**: Uses `getUserPrincipals` to batch principal lookup +- **Smart Caching**: Caches raw DB overrides separately from base config + +## Notes + +- The `overrides` field uses `Schema.Types.Mixed` to support any structure matching `librechat.yaml` +- Typed as `Partial` at the app layer for type safety +- Priority values are just defaults - they can be customized per config +- All configs have `isActive` flag for easy enable/disable without deletion +- `configVersion` auto-increments on changes to help with cache invalidation + diff --git a/api/server/services/Config/app.js b/api/server/services/Config/app.js index 75a5cbe56d..4f6e54d6d9 100644 --- a/api/server/services/Config/app.js +++ b/api/server/services/Config/app.js @@ -1,6 +1,8 @@ -const { CacheKeys } = require('librechat-data-provider'); +const { buildUserConfig } = require('@librechat/api'); const { logger, AppService } = require('@librechat/data-schemas'); +const { CacheKeys, PrincipalType } = require('librechat-data-provider'); const { loadAndFormatTools } = require('~/server/services/start/tools'); +const { getUserPrincipals, getApplicableConfigs } = require('~/models'); const loadCustomConfig = require('./loadCustomConfig'); const { setCachedTools } = require('./getCachedTools'); const getLogStores = require('~/cache/getLogStores'); @@ -23,26 +25,19 @@ const loadBaseConfig = async () => { /** * Get the app configuration based on user context * @param {Object} [options] + * @param {string} [options.userId] - User ID (used to determine role if role not provided) * @param {string} [options.role] - User role for role-based config * @param {boolean} [options.refresh] - Force refresh the cache * @returns {Promise} */ async function getAppConfig(options = {}) { - const { role, refresh } = options; + const { userId, role, refresh } = options; const cache = getLogStores(CacheKeys.APP_CONFIG); - const cacheKey = role ? role : BASE_CONFIG_KEY; - - if (!refresh) { - const cached = await cache.get(cacheKey); - if (cached) { - return cached; - } - } let baseConfig = await cache.get(BASE_CONFIG_KEY); - if (!baseConfig) { - logger.info('[getAppConfig] App configuration not initialized. Initializing AppService...'); + if (!baseConfig || refresh) { + logger.info('[getAppConfig] Loading base configuration from YAML...'); baseConfig = await loadBaseConfig(); if (!baseConfig) { @@ -56,16 +51,67 @@ async function getAppConfig(options = {}) { await cache.set(BASE_CONFIG_KEY, baseConfig); } - // For now, return the base config - // In the future, this is where we'll apply role-based modifications - if (role) { - // TODO: Apply role-based config modifications - // const roleConfig = await applyRoleBasedConfig(baseConfig, role); - // await cache.set(cacheKey, roleConfig); - // return roleConfig; + if (!role && !userId) { + return baseConfig; } - return baseConfig; + const principals = await getUserPrincipals({ userId, role, includeGroups: false }); + if (principals.length === 0) { + return baseConfig; + } + + const configs = []; + const missingPrincipals = []; + + for (const principal of principals) { + const configCacheKey = `config:${principal.principalType}:${principal.principalId}`; + + if (!refresh) { + const cachedConfig = await cache.get(configCacheKey); + if (cachedConfig) { + configs.push(cachedConfig); + continue; + } + } + + // Track which principals we need to fetch from DB, only roles for now + if (principal.principalType === PrincipalType.ROLE) { + missingPrincipals.push(principal); + } + } + + // Fetch missing configs from DB with single optimized query + if (missingPrincipals.length > 0) { + try { + // Single $or query fetches all missing configs at once + const dbConfigs = await getApplicableConfigs(missingPrincipals); + + // Cache each returned config individually + for (const config of dbConfigs) { + const configCacheKey = `config:${config.principalType}:${config.principalId}`; + await cache.set(configCacheKey, config); + configs.push(config); + } + } catch (error) { + logger.error('[getAppConfig] Error fetching configs from DB:', error); + } + } + + // No configs found, return base config + if (configs.length === 0) { + return baseConfig; + } + + // Merge fresh baseConfig with configs on each request + try { + return await buildUserConfig({ + baseConfig, + cachedConfigs: configs, + }); + } catch (error) { + logger.error('[getAppConfig] Error merging configs, falling back to base:', error); + return baseConfig; + } } /** diff --git a/client/vite.config.ts b/client/vite.config.ts index b3f6541ab3..666e47b1b7 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -8,15 +8,18 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills'; import { VitePWA } from 'vite-plugin-pwa'; // https://vitejs.dev/config/ -const backendPort = process.env.BACKEND_PORT && Number(process.env.BACKEND_PORT) || 3080; -const backendURL = process.env.HOST ? `http://${process.env.HOST}:${backendPort}` : `http://localhost:${backendPort}`; +const backendPort = (process.env.BACKEND_PORT && Number(process.env.BACKEND_PORT)) || 3080; +const backendURL = process.env.HOST + ? `http://${process.env.HOST}:${backendPort}` + : `http://localhost:${backendPort}`; export default defineConfig(({ command }) => ({ base: '', server: { - allowedHosts: process.env.VITE_ALLOWED_HOSTS && process.env.VITE_ALLOWED_HOSTS.split(',') || [], + allowedHosts: + (process.env.VITE_ALLOWED_HOSTS && process.env.VITE_ALLOWED_HOSTS.split(',')) || [], host: process.env.HOST || 'localhost', - port: process.env.PORT && Number(process.env.PORT) || 3090, + port: (process.env.PORT && Number(process.env.PORT)) || 3090, strictPort: false, proxy: { '/api': { @@ -54,7 +57,7 @@ export default defineConfig(({ command }) => ({ ], globIgnores: ['images/**/*', '**/*.map', 'index.html'], maximumFileSizeToCacheInBytes: 4 * 1024 * 1024, - navigateFallbackDenylist: [/^\/oauth/, /^\/api/], + navigateFallbackDenylist: [/^\/oauth/, /^\/api/, /^\/api\/admin\/oauth/], }, includeAssets: [], manifest: { diff --git a/packages/api/src/app/config.test.ts b/packages/api/src/app/config.test.ts index f85bb8a62c..2df30facce 100644 --- a/packages/api/src/app/config.test.ts +++ b/packages/api/src/app/config.test.ts @@ -1,14 +1,16 @@ -import { getTransactionsConfig, getBalanceConfig } from './config'; import { logger } from '@librechat/data-schemas'; -import { FileSources } from 'librechat-data-provider'; +import { EImageOutputType, FileSources } from 'librechat-data-provider'; import type { TCustomConfig } from 'librechat-data-provider'; import type { AppConfig } from '@librechat/data-schemas'; +import { getTransactionsConfig, getBalanceConfig } from './config'; // Helper function to create a minimal AppConfig for testing const createTestAppConfig = (overrides: Partial = {}): AppConfig => { const minimalConfig: TCustomConfig = { version: '1.0.0', cache: true, + imageOutputType: EImageOutputType.PNG, + fileStrategy: FileSources.local, interface: { endpointsMenu: true, }, @@ -60,7 +62,14 @@ describe('getTransactionsConfig', () => { it('should return transactions config when explicitly set to false', () => { const appConfig = createTestAppConfig({ transactions: { enabled: false }, - balance: { enabled: false }, + balance: { + enabled: false, + startBalance: 0, + autoRefillEnabled: false, + refillIntervalValue: 0, + refillIntervalUnit: 'seconds', + refillAmount: 0, + }, }); const result = getTransactionsConfig(appConfig); expect(result).toEqual({ enabled: false }); @@ -70,7 +79,14 @@ describe('getTransactionsConfig', () => { it('should return transactions config when explicitly set to true', () => { const appConfig = createTestAppConfig({ transactions: { enabled: true }, - balance: { enabled: false }, + balance: { + enabled: false, + startBalance: 0, + autoRefillEnabled: false, + refillIntervalValue: 0, + refillIntervalUnit: 'seconds', + refillAmount: 0, + }, }); const result = getTransactionsConfig(appConfig); expect(result).toEqual({ enabled: true }); @@ -79,7 +95,14 @@ describe('getTransactionsConfig', () => { it('should return default config when transactions is not defined', () => { const appConfig = createTestAppConfig({ - balance: { enabled: false }, + balance: { + enabled: false, + startBalance: 0, + autoRefillEnabled: false, + refillIntervalValue: 0, + refillIntervalUnit: 'seconds', + refillAmount: 0, + }, }); const result = getTransactionsConfig(appConfig); expect(result).toEqual({ enabled: true }); @@ -90,7 +113,14 @@ describe('getTransactionsConfig', () => { it('should force transactions to be enabled when balance is enabled but transactions is disabled', () => { const appConfig = createTestAppConfig({ transactions: { enabled: false }, - balance: { enabled: true }, + balance: { + enabled: true, + startBalance: 0, + autoRefillEnabled: false, + refillIntervalValue: 0, + refillIntervalUnit: 'seconds', + refillAmount: 0, + }, }); const result = getTransactionsConfig(appConfig); expect(result).toEqual({ enabled: true }); @@ -103,7 +133,14 @@ describe('getTransactionsConfig', () => { it('should not override transactions when balance is enabled and transactions is enabled', () => { const appConfig = createTestAppConfig({ transactions: { enabled: true }, - balance: { enabled: true }, + balance: { + enabled: true, + startBalance: 0, + autoRefillEnabled: false, + refillIntervalValue: 0, + refillIntervalUnit: 'seconds', + refillAmount: 0, + }, }); const result = getTransactionsConfig(appConfig); expect(result).toEqual({ enabled: true }); @@ -113,7 +150,14 @@ describe('getTransactionsConfig', () => { it('should allow transactions to be disabled when balance is disabled', () => { const appConfig = createTestAppConfig({ transactions: { enabled: false }, - balance: { enabled: false }, + balance: { + enabled: false, + startBalance: 0, + autoRefillEnabled: false, + refillIntervalValue: 0, + refillIntervalUnit: 'seconds', + refillAmount: 0, + }, }); const result = getTransactionsConfig(appConfig); expect(result).toEqual({ enabled: false }); @@ -122,7 +166,14 @@ describe('getTransactionsConfig', () => { it('should use default when balance is enabled but transactions is not defined', () => { const appConfig = createTestAppConfig({ - balance: { enabled: true }, + balance: { + enabled: true, + startBalance: 0, + autoRefillEnabled: false, + refillIntervalValue: 0, + refillIntervalUnit: 'seconds', + refillAmount: 0, + }, }); const result = getTransactionsConfig(appConfig); expect(result).toEqual({ enabled: true }); @@ -187,8 +238,14 @@ describe('getTransactionsConfig', () => { it('should handle appConfig with balance enabled undefined', () => { const appConfig = createTestAppConfig({ transactions: { enabled: false }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - balance: { enabled: undefined as any }, + balance: { + enabled: undefined, + startBalance: 0, + autoRefillEnabled: false, + refillIntervalValue: 0, + refillIntervalUnit: 'seconds', + refillAmount: 0, + }, }); const result = getTransactionsConfig(appConfig); expect(result).toEqual({ enabled: false }); @@ -248,6 +305,9 @@ describe('getBalanceConfig', () => { enabled: false, startBalance: 2000, autoRefillEnabled: true, + refillIntervalValue: 30, + refillIntervalUnit: 'days', + refillAmount: 1000, }, }); const result = getBalanceConfig(appConfig); @@ -255,6 +315,9 @@ describe('getBalanceConfig', () => { enabled: false, startBalance: 2000, autoRefillEnabled: true, + refillIntervalValue: 30, + refillIntervalUnit: 'days', + refillAmount: 1000, }); }); diff --git a/packages/api/src/config/resolution.ts b/packages/api/src/config/resolution.ts new file mode 100644 index 0000000000..6f687739fa --- /dev/null +++ b/packages/api/src/config/resolution.ts @@ -0,0 +1,51 @@ +import deepmerge from 'deepmerge'; +import { logger } from '@librechat/data-schemas'; +import type { IConfig, AppConfig } from '@librechat/data-schemas'; + +/** + * Merge multiple configs in priority order + * @param baseConfig - The base AppConfig from AppService + * @param configs - Array of config documents from DB + * @returns The merged AppConfig + */ +function mergeConfigsFromDB(baseConfig: AppConfig, configs: IConfig[]): AppConfig { + // Sort by priority (ascending - lowest to highest) + const sortedConfigs = [...configs].sort((a, b) => a.priority - b.priority); + + let finalConfig = { ...baseConfig }; + + for (const config of sortedConfigs) { + const source = `${config.principalType}:${config.principalId}`; + + logger.debug( + `[configResolution] Applying config from: ${source} (priority: ${config.priority})`, + ); + finalConfig = deepmerge(finalConfig, config.overrides); + } + + return finalConfig; +} + +/** + * Build the final merged configuration + * @param params - Parameters object + * @param params.baseConfig - The base AppConfig from AppService (always fresh) + * @param params.cachedConfigs - Cached DB configs to merge + * @returns The merged AppConfig + */ +export async function buildUserConfig({ + baseConfig, + cachedConfigs, +}: { + baseConfig: AppConfig; + cachedConfigs: IConfig[]; +}): Promise { + if (!cachedConfigs || cachedConfigs.length === 0) { + return baseConfig; + } + + logger.debug(`[configResolution] Merging ${cachedConfigs.length} config(s) with base config`); + + // Merge fresh baseConfig with configs + return mergeConfigsFromDB(baseConfig, cachedConfigs); +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 492b59f232..08eefcf4c3 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -9,6 +9,8 @@ export * from './mcp/connection'; export * from './mcp/oauth'; export * from './mcp/auth'; export * from './mcp/zod'; +/* Config */ +export * from './config/resolution'; export * from './mcp/errors'; /* Utilities */ export * from './mcp/utils'; diff --git a/packages/api/src/middleware/error.ts b/packages/api/src/middleware/error.ts index 0d17ff5048..7d044fbbe2 100644 --- a/packages/api/src/middleware/error.ts +++ b/packages/api/src/middleware/error.ts @@ -52,6 +52,14 @@ export const ErrorController = ( } const error = err as CustomError; + if (req.originalUrl && req.originalUrl.includes('/admin/oauth/')) { + return res.redirect( + (process.env.ADMIN_PANEL_URL || 'http://localhost:3000') + + '/login?error=' + + ErrorTypes.AUTH_FAILED, + ); + } + if ( (error.message === ErrorTypes.AUTH_FAILED || error.code === ErrorTypes.AUTH_FAILED) && req.originalUrl && diff --git a/packages/data-schemas/src/methods/config.ts b/packages/data-schemas/src/methods/config.ts new file mode 100644 index 0000000000..57d74df339 --- /dev/null +++ b/packages/data-schemas/src/methods/config.ts @@ -0,0 +1,179 @@ +import { Types } from 'mongoose'; +import { PrincipalType, PrincipalModel } from 'librechat-data-provider'; +import type { Model, ClientSession } from 'mongoose'; +import type { IConfig } from '~/types'; + +export function createConfigMethods(mongoose: typeof import('mongoose')) { + /** + * Find a config by principal + * @param principalType - The type of principal + * @param principalId - The ID of the principal + * @param session - Optional MongoDB session for transactions + * @returns The config document or null if not found + */ + async function findConfigByPrincipal( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + session?: ClientSession, + ): Promise { + const Config = mongoose.models.Config as Model; + const query = Config.findOne({ + principalType, + principalId, + isActive: true, + }); + if (session) { + query.session(session); + } + return await query.lean(); + } + + /** + * Get all applicable configurations based on principals + * @param principals - Optional list of principals (from getUserPrincipals) + * @param session - Optional MongoDB session for transactions + * @returns Array of applicable config documents + */ + async function getApplicableConfigs( + principals?: Array<{ principalType: string; principalId?: string | Types.ObjectId }>, + session?: ClientSession, + ): Promise { + if (!principals || principals.length === 0) { + return []; + } + + const Config = mongoose.models.Config as Model; + + // Build query to get all configs matching any of the principals + const principalsQuery = principals + .map((p) => ({ + principalType: p.principalType, + principalId: p.principalId, + })) + .filter((p) => p.principalId !== undefined); // Filter out PUBLIC since configs don't apply to PUBLIC + + if (principalsQuery.length === 0) { + return []; + } + + // Single query to get all applicable configs + const configQuery = Config.find({ + $or: principalsQuery, + isActive: true, + }); + + if (session) { + configQuery.session(session); + } + + return await configQuery.lean(); + } + + /** + * Create or update a config for a principal + * @param principalType - The type of principal + * @param principalId - The ID of the principal + * @param principalModel - The model reference + * @param overrides - The configuration overrides + * @param priority - The priority level + * @param session - Optional MongoDB session for transactions + * @returns The created or updated config document + */ + async function upsertConfig( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + principalModel: PrincipalModel, + overrides: Record, + priority: number, + session?: ClientSession, + ): Promise { + const Config = mongoose.models.Config as Model; + + const query = { + principalType, + principalId, + }; + + const update = { + $set: { + principalModel, + overrides, + priority, + isActive: true, + }, + }; + + const options = { + upsert: true, + new: true, + ...(session ? { session } : {}), + }; + + return await Config.findOneAndUpdate(query, update, options); + } + + /** + * Delete a config for a principal + * @param principalType - The type of principal + * @param principalId - The ID of the principal + * @param session - Optional MongoDB session for transactions + * @returns The deleted config document or null + */ + async function deleteConfig( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + session?: ClientSession, + ): Promise { + const Config = mongoose.models.Config as Model; + + const query = Config.findOneAndDelete({ + principalType, + principalId, + }); + + if (session) { + query.session(session); + } + + return await query; + } + + /** + * Toggle active status of a config + * @param principalType - The type of principal + * @param principalId - The ID of the principal + * @param isActive - Whether the config should be active + * @param session - Optional MongoDB session for transactions + * @returns The updated config document or null + */ + async function toggleConfigActive( + principalType: PrincipalType, + principalId: string | Types.ObjectId, + isActive: boolean, + session?: ClientSession, + ): Promise { + const Config = mongoose.models.Config as Model; + + const query = Config.findOneAndUpdate( + { principalType, principalId }, + { $set: { isActive } }, + { new: true }, + ); + + if (session) { + query.session(session); + } + + return await query; + } + + return { + findConfigByPrincipal, + getApplicableConfigs, + upsertConfig, + deleteConfig, + toggleConfigActive, + }; +} + +export type ConfigMethods = ReturnType; diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index b6f1be64e9..a7c3150a25 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -19,6 +19,8 @@ import { createAccessRoleMethods, type AccessRoleMethods } from './accessRole'; import { createUserGroupMethods, type UserGroupMethods } from './userGroup'; import { createAclEntryMethods, type AclEntryMethods } from './aclEntry'; import { createShareMethods, type ShareMethods } from './share'; +/* Config */ +import { createConfigMethods, type ConfigMethods } from './config'; export type AllMethods = UserMethods & SessionMethods & @@ -33,7 +35,8 @@ export type AllMethods = UserMethods & AclEntryMethods & ShareMethods & AccessRoleMethods & - PluginAuthMethods; + PluginAuthMethods & + ConfigMethods; /** * Creates all database methods for all collections @@ -55,6 +58,7 @@ export function createMethods(mongoose: typeof import('mongoose')): AllMethods { ...createAclEntryMethods(mongoose), ...createShareMethods(mongoose), ...createPluginAuthMethods(mongoose), + ...createConfigMethods(mongoose), }; } @@ -73,4 +77,5 @@ export type { ShareMethods, AccessRoleMethods, PluginAuthMethods, + ConfigMethods, }; diff --git a/packages/data-schemas/src/methods/userGroup.ts b/packages/data-schemas/src/methods/userGroup.ts index bec28343fe..65a9020130 100644 --- a/packages/data-schemas/src/methods/userGroup.ts +++ b/packages/data-schemas/src/methods/userGroup.ts @@ -241,6 +241,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { * @param params - Parameters object * @param params.userId - The user ID * @param params.role - Optional user role (if not provided, will query from DB) + * @param params.includeGroups - Whether to include group principals (default: true) * @param session - Optional MongoDB session for transactions * @returns Array of principal objects with type and id */ @@ -248,10 +249,11 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { params: { userId: string | Types.ObjectId; role?: string | null; + includeGroups?: boolean; }, session?: ClientSession, ): Promise> { - const { userId, role } = params; + const { userId, role, includeGroups = true } = 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 }> = [ @@ -275,11 +277,14 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { 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 }); - }); + // Optionally include groups + if (includeGroups) { + const userGroups = await getUserGroups(userId, session); + if (userGroups && userGroups.length > 0) { + userGroups.forEach((group) => { + principals.push({ principalType: PrincipalType.GROUP, principalId: group._id }); + }); + } } principals.push({ principalType: PrincipalType.PUBLIC }); diff --git a/packages/data-schemas/src/models/config.ts b/packages/data-schemas/src/models/config.ts new file mode 100644 index 0000000000..bca2c0f999 --- /dev/null +++ b/packages/data-schemas/src/models/config.ts @@ -0,0 +1,6 @@ +import configSchema from '../schema/config'; +import type { IConfig } from '~/types'; + +export function createConfigModel(mongoose: typeof import('mongoose')) { + return mongoose.models.Config || mongoose.model('Config', configSchema); +} diff --git a/packages/data-schemas/src/models/index.ts b/packages/data-schemas/src/models/index.ts index 45516d5a7c..0a008386ca 100644 --- a/packages/data-schemas/src/models/index.ts +++ b/packages/data-schemas/src/models/index.ts @@ -26,6 +26,7 @@ import { createMemoryModel } from './memory'; import { createAccessRoleModel } from './accessRole'; import { createAclEntryModel } from './aclEntry'; import { createGroupModel } from './group'; +import { createConfigModel } from './config'; /** * Creates all database models for all collections @@ -60,5 +61,6 @@ export function createModels(mongoose: typeof import('mongoose')) { AccessRole: createAccessRoleModel(mongoose), AclEntry: createAclEntryModel(mongoose), Group: createGroupModel(mongoose), + Config: createConfigModel(mongoose), }; } diff --git a/packages/data-schemas/src/schema/config.ts b/packages/data-schemas/src/schema/config.ts new file mode 100644 index 0000000000..3899e5e470 --- /dev/null +++ b/packages/data-schemas/src/schema/config.ts @@ -0,0 +1,58 @@ +import { Schema } from 'mongoose'; +import { PrincipalType, PrincipalModel } from 'librechat-data-provider'; +import type { IConfig } from '~/types'; + +const configSchema = new Schema( + { + principalType: { + type: String, + enum: Object.values(PrincipalType), + required: true, + index: true, + }, + principalId: { + type: Schema.Types.Mixed, // ObjectId for users/groups, String for roles + refPath: 'principalModel', + required: true, + index: true, + }, + principalModel: { + type: String, + enum: Object.values(PrincipalModel), + required: true, + }, + priority: { + type: Number, + required: true, + index: true, + }, + overrides: { + type: Schema.Types.Mixed, + default: {}, + }, + isActive: { + type: Boolean, + default: true, + index: true, + }, + configVersion: { + type: Number, + default: 1, + }, + }, + { timestamps: true }, +); + +// Composite index for efficient querying +configSchema.index({ principalType: 1, principalId: 1, isActive: 1 }); +configSchema.index({ priority: 1, isActive: 1 }); + +// Auto-increment version on overrides change +configSchema.pre('save', function (next) { + if (this.isModified('overrides')) { + this.configVersion += 1; + } + next(); +}); + +export default configSchema; diff --git a/packages/data-schemas/src/types/config.ts b/packages/data-schemas/src/types/config.ts new file mode 100644 index 0000000000..b72a2b5177 --- /dev/null +++ b/packages/data-schemas/src/types/config.ts @@ -0,0 +1,32 @@ +import type { Document, Types } from 'mongoose'; +import { PrincipalType, PrincipalModel } from 'librechat-data-provider'; +import type { AppConfig } from './app'; + +/** + * Configuration for a principal (user, group, or role) + */ +export type Config = { + /** The type of principal (user, group, role) */ + principalType: PrincipalType; + /** The ID of the principal (ObjectId for users/groups, string for roles) */ + principalId: Types.ObjectId | string; + /** The model name for the principal */ + principalModel: PrincipalModel; + /** Priority level for determining merge order (higher = more specific) */ + priority: number; + /** Configuration overrides matching AppConfig structure */ + overrides: Partial; + /** Whether this config override is currently active */ + isActive: boolean; + /** Version number for cache invalidation */ + configVersion: number; + /** When this config was created */ + createdAt?: Date; + /** When this config was last updated */ + updatedAt?: Date; +}; + +export type IConfig = Config & + Document & { + _id: Types.ObjectId; + }; diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index 828cdf9c8f..f30d4a2b5c 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -1,7 +1,7 @@ import type { Types } from 'mongoose'; export type ObjectId = Types.ObjectId; -export * from './app'; + export * from './user'; export * from './token'; export * from './convo'; @@ -25,6 +25,9 @@ export * from './prompts'; export * from './accessRole'; export * from './aclEntry'; export * from './group'; +/* Config */ +export * from './app'; +export * from './config'; /* Web */ export * from './web'; /* MCP Servers */