mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-03 06:17:21 +02:00
* feat: add tenant context middleware for ALS-based isolation Introduces tenantContextMiddleware that propagates req.user.tenantId into AsyncLocalStorage, activating the Mongoose applyTenantIsolation plugin for all downstream DB queries within a request. - Strict mode (TENANT_ISOLATION_STRICT=true) returns 403 if no tenantId - Non-strict mode passes through for backward compatibility - No-op for unauthenticated requests - Includes 6 unit tests covering all paths * feat: register tenant middleware and wrap startup/auth in runAsSystem() - Register tenantContextMiddleware in Express app after capability middleware - Wrap server startup initialization in runAsSystem() for strict mode compat - Wrap auth strategy getAppConfig() calls in runAsSystem() since they run before user context is established (LDAP, SAML, OpenID, social login, AuthService) * feat: thread tenantId through all getAppConfig callers Pass tenantId from req.user to getAppConfig() across all callers that have request context, ensuring correct per-tenant cache key resolution. Also fixes getBaseConfig admin endpoint to scope to requesting admin's tenant instead of returning the unscoped base config. Files updated: - Controllers: UserController, PluginController - Middleware: checkDomainAllowed, balance - Routes: config - Services: loadConfigModels, loadDefaultModels, getEndpointsConfig, MCP - Audio services: TTSService, STTService, getVoices, getCustomConfigSpeech - Admin: getBaseConfig endpoint * feat: add config cache invalidation on admin mutations - Add clearOverrideCache(tenantId?) to flush per-principal override caches by enumerating Keyv store keys matching _OVERRIDE_: prefix - Add invalidateConfigCaches() helper that clears base config, override caches, tool caches, and endpoint config cache in one call - Wire invalidation into all 5 admin config mutation handlers (upsert, patch, delete field, delete overrides, toggle active) - Add strict mode warning when __default__ tenant fallback is used - Add 3 new tests for clearOverrideCache (all/scoped/base-preserving) * chore: update getUserPrincipals comment to reflect ALS-based tenant filtering The TODO(#12091) about missing tenantId filtering is resolved by the tenant context middleware + applyTenantIsolation Mongoose plugin. Group queries are now automatically scoped by tenantId via ALS. * fix: replace runAsSystem with baseOnly for pre-tenant code paths App configs are tenant-owned — runAsSystem() would bypass tenant isolation and return cross-tenant DB overrides. Instead, add baseOnly option to getAppConfig() that returns YAML-derived config only, with zero DB queries. All startup code, auth strategies, and MCP initialization now use getAppConfig({ baseOnly: true }) to get the YAML config without touching the Config collection. * fix: address PR review findings — middleware ordering, types, cache safety - Chain tenantContextMiddleware inside requireJwtAuth after passport auth instead of global app.use() where req.user is always undefined (Finding 1) - Remove global tenantContextMiddleware registration from index.js - Update BalanceMiddlewareOptions to include tenantId, remove redundant cast (Finding 4) - Add warning log when clearOverrideCache cannot enumerate keys on Redis (Finding 3) - Use startsWith instead of includes for cache key filtering (Finding 12) - Use generator loop instead of Array.from for key enumeration (Finding 3) - Selective barrel export — exclude _resetTenantMiddlewareStrictCache (Finding 5) - Move isMainThread check to module level, remove per-request check (Finding 9) - Move mid-file require to top of app.js (Finding 8) - Parallelize invalidateConfigCaches with Promise.all (Finding 10) - Remove clearOverrideCache from public app.js exports (internal only) - Strengthen getUserPrincipals comment re: ALS dependency (Finding 2) * fix: restore runAsSystem for startup DB ops, consolidate require, clarify baseOnly - Restore runAsSystem() around performStartupChecks, updateInterfacePermissions, initializeMCPs, and initializeOAuthReconnectManager — these make Mongoose queries that need system context in strict tenant mode (NEW-3) - Consolidate duplicate require('@librechat/api') in requireJwtAuth.js (NEW-1) - Document that baseOnly ignores role/userId/tenantId in JSDoc (NEW-2) * test: add requireJwtAuth tenant chaining + invalidateConfigCaches tests - requireJwtAuth: 5 tests verifying ALS tenant context is set after passport auth, isolated between concurrent requests, and not set when user has no tenantId (Finding 6) - invalidateConfigCaches: 4 tests verifying all four caches are cleared, tenantId is threaded through, partial failure is handled gracefully, and operations run in parallel via Promise.all (Finding 11) * fix: address Copilot review — passport errors, namespaced cache keys, /base scoping - Forward passport errors in requireJwtAuth before entering tenant middleware — prevents silent auth failures from reaching handlers (P1) - Account for Keyv namespace prefix in clearOverrideCache — stored keys are namespaced as "APP_CONFIG:_OVERRIDE_:..." not "_OVERRIDE_:...", so override caches were never actually matched/cleared (P2) - Remove role from getBaseConfig — /base should return tenant-scoped base config, not role-merged config that drifts per admin role (P2) - Return tenantStorage.run() for cleaner async semantics - Update mock cache in service.spec.ts to simulate Keyv namespacing * fix: address second review — cache safety, code quality, test reliability - Decouple cache invalidation from mutation response: fire-and-forget with logging so DB mutation success is not masked by cache failures - Extract clearEndpointConfigCache helper from inline IIFE - Move isMainThread check to lazy once-per-process guard (no import side effect) - Memoize process.env read in overrideCacheKey to avoid per-request env lookups and log flooding in strict mode - Remove flaky timer-based parallelism assertion, use structural check - Merge orphaned double JSDoc block on getUserPrincipals - Fix stale [getAppConfig] log prefix → [ensureBaseConfig] - Fix import order in tenant.spec.ts (package types before local values) - Replace "Finding 1" reference with self-contained description - Use real tenantStorage primitives in requireJwtAuth spec mock * fix: move JSDoc to correct function after clearEndpointConfigCache extraction * refactor: remove Redis SCAN from clearOverrideCache, rely on TTL expiry Redis SCAN causes 60s+ stalls under concurrent load (see #12410). APP_CONFIG defaults to FORCED_IN_MEMORY_CACHE_NAMESPACES, so the in-memory store.keys() path handles the standard case. When APP_CONFIG is Redis-backed, overrides expire naturally via overrideCacheTtl (60s default) — an acceptable window for admin config mutations. * fix: remove return from tenantStorage.run to satisfy void middleware signature * fix: address second review — cache safety, code quality, test reliability - Switch invalidateConfigCaches from Promise.all to Promise.allSettled so partial failures are logged individually instead of producing one undifferentiated error (Finding 3) - Gate overrideCacheKey strict-mode warning behind a once-per-process flag to prevent log flooding under load (Finding 4) - Add test for passport error forwarding in requireJwtAuth — the if (err) { return next(err) } branch now has coverage (Finding 5) - Add test for real partial failure in invalidateConfigCaches where clearAppConfigCache rejects (not just the swallowed endpoint error) * chore: reorder imports in index.js and app.js for consistency - Moved logger and runAsSystem imports to maintain a consistent import order across files. - Improved code readability by ensuring related imports are grouped together.
167 lines
4.9 KiB
JavaScript
167 lines
4.9 KiB
JavaScript
const fs = require('fs');
|
|
const LdapStrategy = require('passport-ldapauth');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const { SystemRoles, ErrorTypes } = require('librechat-data-provider');
|
|
const { isEnabled, getBalanceConfig, isEmailDomainAllowed } = require('@librechat/api');
|
|
const { createUser, findUser, updateUser, countUsers } = require('~/models');
|
|
const { getAppConfig } = require('~/server/services/Config');
|
|
|
|
const {
|
|
LDAP_URL,
|
|
LDAP_BIND_DN,
|
|
LDAP_BIND_CREDENTIALS,
|
|
LDAP_USER_SEARCH_BASE,
|
|
LDAP_SEARCH_FILTER,
|
|
LDAP_CA_CERT_PATH,
|
|
LDAP_FULL_NAME,
|
|
LDAP_ID,
|
|
LDAP_USERNAME,
|
|
LDAP_EMAIL,
|
|
LDAP_TLS_REJECT_UNAUTHORIZED,
|
|
LDAP_STARTTLS,
|
|
} = process.env;
|
|
|
|
// Check required environment variables
|
|
if (!LDAP_URL || !LDAP_USER_SEARCH_BASE) {
|
|
module.exports = null;
|
|
}
|
|
|
|
const searchAttributes = [
|
|
'displayName',
|
|
'mail',
|
|
'uid',
|
|
'cn',
|
|
'name',
|
|
'commonname',
|
|
'givenName',
|
|
'sn',
|
|
'sAMAccountName',
|
|
];
|
|
|
|
if (LDAP_FULL_NAME) {
|
|
searchAttributes.push(...LDAP_FULL_NAME.split(','));
|
|
}
|
|
if (LDAP_ID) {
|
|
searchAttributes.push(LDAP_ID);
|
|
}
|
|
if (LDAP_USERNAME) {
|
|
searchAttributes.push(LDAP_USERNAME);
|
|
}
|
|
if (LDAP_EMAIL) {
|
|
searchAttributes.push(LDAP_EMAIL);
|
|
}
|
|
const rejectUnauthorized = isEnabled(LDAP_TLS_REJECT_UNAUTHORIZED);
|
|
const startTLS = isEnabled(LDAP_STARTTLS);
|
|
|
|
const ldapOptions = {
|
|
server: {
|
|
url: LDAP_URL,
|
|
bindDN: LDAP_BIND_DN,
|
|
bindCredentials: LDAP_BIND_CREDENTIALS,
|
|
searchBase: LDAP_USER_SEARCH_BASE,
|
|
searchFilter: LDAP_SEARCH_FILTER || 'mail={{username}}',
|
|
searchAttributes: [...new Set(searchAttributes)],
|
|
...(LDAP_CA_CERT_PATH && {
|
|
tlsOptions: {
|
|
rejectUnauthorized,
|
|
ca: (() => {
|
|
try {
|
|
return [fs.readFileSync(LDAP_CA_CERT_PATH)];
|
|
} catch (err) {
|
|
logger.error('[ldapStrategy]', 'Failed to read CA certificate', err);
|
|
throw err;
|
|
}
|
|
})(),
|
|
},
|
|
}),
|
|
...(startTLS && { starttls: true }),
|
|
},
|
|
usernameField: 'email',
|
|
passwordField: 'password',
|
|
};
|
|
|
|
const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
|
|
if (!userinfo) {
|
|
return done(null, false, { message: 'Invalid credentials' });
|
|
}
|
|
|
|
try {
|
|
const ldapId =
|
|
(LDAP_ID && userinfo[LDAP_ID]) || userinfo.uid || userinfo.sAMAccountName || userinfo.mail;
|
|
|
|
let user = await findUser({ ldapId });
|
|
if (user && user.provider !== 'ldap') {
|
|
logger.info(
|
|
`[ldapStrategy] User ${user.email} already exists with provider ${user.provider}`,
|
|
);
|
|
return done(null, false, {
|
|
message: ErrorTypes.AUTH_FAILED,
|
|
});
|
|
}
|
|
|
|
const fullNameAttributes = LDAP_FULL_NAME && LDAP_FULL_NAME.split(',');
|
|
const fullName =
|
|
fullNameAttributes && fullNameAttributes.length > 0
|
|
? fullNameAttributes.map((attr) => userinfo[attr]).join(' ')
|
|
: userinfo.cn || userinfo.name || userinfo.commonname || userinfo.displayName;
|
|
|
|
const username =
|
|
(LDAP_USERNAME && userinfo[LDAP_USERNAME]) || userinfo.givenName || userinfo.mail;
|
|
|
|
let mail = (LDAP_EMAIL && userinfo[LDAP_EMAIL]) || userinfo.mail || username + '@ldap.local';
|
|
mail = Array.isArray(mail) ? mail[0] : mail;
|
|
|
|
if (!userinfo.mail && !(LDAP_EMAIL && userinfo[LDAP_EMAIL])) {
|
|
logger.warn(
|
|
'[ldapStrategy]',
|
|
`No valid email attribute found in LDAP userinfo. Using fallback email: ${username}@ldap.local`,
|
|
`LDAP_EMAIL env var: ${LDAP_EMAIL || 'not set'}`,
|
|
`Available userinfo attributes: ${Object.keys(userinfo).join(', ')}`,
|
|
'Full userinfo:',
|
|
JSON.stringify(userinfo, null, 2),
|
|
);
|
|
}
|
|
|
|
const appConfig = await getAppConfig({ baseOnly: true });
|
|
if (!isEmailDomainAllowed(mail, appConfig?.registration?.allowedDomains)) {
|
|
logger.error(
|
|
`[LDAP Strategy] Authentication blocked - email domain not allowed [Email: ${mail}]`,
|
|
);
|
|
return done(null, false, { message: 'Email domain not allowed' });
|
|
}
|
|
|
|
if (!user) {
|
|
const isFirstRegisteredUser = (await countUsers()) === 0;
|
|
const role = isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER;
|
|
|
|
user = {
|
|
provider: 'ldap',
|
|
ldapId,
|
|
username,
|
|
email: mail,
|
|
emailVerified: true, // The ldap server administrator should verify the email
|
|
name: fullName,
|
|
role,
|
|
};
|
|
const balanceConfig = getBalanceConfig(appConfig);
|
|
const userId = await createUser(user, balanceConfig);
|
|
user._id = userId;
|
|
} else {
|
|
// Users registered in LDAP are assumed to have their user information managed in LDAP,
|
|
// so update the user information with the values registered in LDAP
|
|
user.provider = 'ldap';
|
|
user.ldapId = ldapId;
|
|
user.email = mail;
|
|
user.username = username;
|
|
user.name = fullName;
|
|
}
|
|
|
|
user = await updateUser(user._id, user);
|
|
done(null, user);
|
|
} catch (err) {
|
|
logger.error('[ldapStrategy]', err);
|
|
done(err);
|
|
}
|
|
});
|
|
|
|
module.exports = ldapLogin;
|