🧵 feat: ALS Context Middleware, Tenant Threading, and Config Cache Invalidation (#12407)

* 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.
This commit is contained in:
Danny Avila 2026-03-26 17:35:00 -04:00 committed by GitHub
parent 083042e56c
commit 9f6d8c6e93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 768 additions and 63 deletions

View file

@ -77,6 +77,8 @@ export interface AdminConfigDeps {
userId?: string;
tenantId?: string;
}) => Promise<AppConfig>;
/** Invalidate all config-related caches after a mutation. */
invalidateConfigCaches?: (tenantId?: string) => Promise<void>;
}
// ── Validation helpers ───────────────────────────────────────────────
@ -133,6 +135,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps) {
toggleConfigActive,
hasConfigCapability,
getAppConfig,
invalidateConfigCaches,
} = deps;
/**
@ -176,7 +179,9 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps) {
return res.status(501).json({ error: 'Base config endpoint not configured' });
}
const appConfig = await getAppConfig();
const appConfig = await getAppConfig({
tenantId: user.tenantId,
});
return res.status(200).json({ config: appConfig });
} catch (error) {
logger.error('[adminConfig] getBaseConfig error:', error);
@ -278,6 +283,9 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps) {
priority ?? DEFAULT_PRIORITY,
);
invalidateConfigCaches?.(user.tenantId)?.catch((err) =>
logger.error('[adminConfig] Cache invalidation failed after upsert:', err),
);
return res.status(config?.configVersion === 1 ? 201 : 200).json({ config });
} catch (error) {
logger.error('[adminConfig] upsertConfigOverrides error:', error);
@ -367,6 +375,9 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps) {
priority ?? existing?.priority ?? DEFAULT_PRIORITY,
);
invalidateConfigCaches?.(user.tenantId)?.catch((err) =>
logger.error('[adminConfig] Cache invalidation failed after patch:', err),
);
return res.status(200).json({ config });
} catch (error) {
logger.error('[adminConfig] patchConfigField error:', error);
@ -414,6 +425,9 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps) {
return res.status(404).json({ error: 'Config not found' });
}
invalidateConfigCaches?.(user.tenantId)?.catch((err) =>
logger.error('[adminConfig] Cache invalidation failed after field delete:', err),
);
return res.status(200).json({ config });
} catch (error) {
logger.error('[adminConfig] deleteConfigField error:', error);
@ -449,6 +463,9 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps) {
return res.status(404).json({ error: 'Config not found' });
}
invalidateConfigCaches?.(user.tenantId)?.catch((err) =>
logger.error('[adminConfig] Cache invalidation failed after config delete:', err),
);
return res.status(200).json({ success: true });
} catch (error) {
logger.error('[adminConfig] deleteConfigOverrides error:', error);
@ -489,6 +506,9 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps) {
return res.status(404).json({ error: 'Config not found' });
}
invalidateConfigCaches?.(user.tenantId)?.catch((err) =>
logger.error('[adminConfig] Cache invalidation failed after toggle:', err),
);
return res.status(200).json({ config });
} catch (error) {
logger.error('[adminConfig] toggleConfig error:', error);

View file

@ -1,17 +1,24 @@
import { createAppConfigService } from './service';
function createMockCache() {
/**
* Creates a mock cache that simulates Keyv's namespace behavior.
* Keyv stores keys internally as `namespace:key` but its API (get/set/delete)
* accepts un-namespaced keys and auto-prepends the namespace.
*/
function createMockCache(namespace = 'app_config') {
const store = new Map();
return {
get: jest.fn((key) => Promise.resolve(store.get(key))),
get: jest.fn((key) => Promise.resolve(store.get(`${namespace}:${key}`))),
set: jest.fn((key, value) => {
store.set(key, value);
store.set(`${namespace}:${key}`, value);
return Promise.resolve(undefined);
}),
delete: jest.fn((key) => {
store.delete(key);
store.delete(`${namespace}:${key}`);
return Promise.resolve(true);
}),
/** Mimic Keyv's opts.store structure for key enumeration in clearOverrideCache */
opts: { store: { keys: () => store.keys() } },
_store: store,
};
}
@ -58,6 +65,23 @@ describe('createAppConfigService', () => {
expect(deps.loadBaseConfig).toHaveBeenCalledTimes(1);
});
it('baseOnly returns YAML config without DB queries', async () => {
const deps = createDeps({
getApplicableConfigs: jest
.fn()
.mockResolvedValue([
{ priority: 10, overrides: { interface: { endpointsMenu: false } }, isActive: true },
]),
});
const { getAppConfig } = createAppConfigService(deps);
const config = await getAppConfig({ baseOnly: true });
expect(deps.loadBaseConfig).toHaveBeenCalledTimes(1);
expect(deps.getApplicableConfigs).not.toHaveBeenCalled();
expect(config).toEqual(deps._baseConfig);
});
it('reloads base config when refresh is true', async () => {
const deps = createDeps();
const { getAppConfig } = createAppConfigService(deps);
@ -144,8 +168,8 @@ describe('createAppConfigService', () => {
await getAppConfig({ userId: 'uid1' });
const cachedKeys = [...deps._cache._store.keys()];
const overrideKey = cachedKeys.find((k) => k.startsWith('_OVERRIDE_:'));
expect(overrideKey).toBe('_OVERRIDE_:__default__:uid1');
const overrideKey = cachedKeys.find((k) => k.includes('_OVERRIDE_:'));
expect(overrideKey).toBe('app_config:_OVERRIDE_:__default__:uid1');
});
it('tenantId is included in cache key to prevent cross-tenant contamination', async () => {
@ -241,4 +265,70 @@ describe('createAppConfigService', () => {
expect(deps.loadBaseConfig).toHaveBeenCalledTimes(2);
});
});
describe('clearOverrideCache', () => {
it('clears all override caches when no tenantId is provided', async () => {
const deps = createDeps({
getApplicableConfigs: jest
.fn()
.mockResolvedValue([{ priority: 10, overrides: { x: 1 }, isActive: true }]),
});
const { getAppConfig, clearOverrideCache } = createAppConfigService(deps);
await getAppConfig({ role: 'ADMIN', tenantId: 'tenant-a' });
await getAppConfig({ role: 'ADMIN', tenantId: 'tenant-b' });
expect(deps.getApplicableConfigs).toHaveBeenCalledTimes(2);
await clearOverrideCache();
// After clearing, both tenants should re-query DB
await getAppConfig({ role: 'ADMIN', tenantId: 'tenant-a' });
await getAppConfig({ role: 'ADMIN', tenantId: 'tenant-b' });
expect(deps.getApplicableConfigs).toHaveBeenCalledTimes(4);
});
it('clears only specified tenant override caches', async () => {
const deps = createDeps({
getApplicableConfigs: jest
.fn()
.mockResolvedValue([{ priority: 10, overrides: { x: 1 }, isActive: true }]),
});
const { getAppConfig, clearOverrideCache } = createAppConfigService(deps);
await getAppConfig({ role: 'ADMIN', tenantId: 'tenant-a' });
await getAppConfig({ role: 'ADMIN', tenantId: 'tenant-b' });
expect(deps.getApplicableConfigs).toHaveBeenCalledTimes(2);
await clearOverrideCache('tenant-a');
// tenant-a should re-query, tenant-b should be cached
await getAppConfig({ role: 'ADMIN', tenantId: 'tenant-a' });
await getAppConfig({ role: 'ADMIN', tenantId: 'tenant-b' });
expect(deps.getApplicableConfigs).toHaveBeenCalledTimes(3);
});
it('does not clear base config', async () => {
const deps = createDeps();
const { getAppConfig, clearOverrideCache } = createAppConfigService(deps);
await getAppConfig();
expect(deps.loadBaseConfig).toHaveBeenCalledTimes(1);
await clearOverrideCache();
await getAppConfig();
// Base config should still be cached
expect(deps.loadBaseConfig).toHaveBeenCalledTimes(1);
});
it('does not throw when store.keys is unavailable (Redis fallback to TTL expiry)', async () => {
const deps = createDeps();
// Remove store.keys to simulate Redis-backed cache
deps._cache.opts = {};
const { clearOverrideCache } = createAppConfigService(deps);
// Should not throw — logs warning and relies on TTL expiry
await expect(clearOverrideCache()).resolves.toBeUndefined();
});
});
});

View file

@ -13,6 +13,12 @@ interface CacheStore {
get: (key: string) => Promise<unknown>;
set: (key: string, value: unknown, ttl?: number) => Promise<unknown>;
delete: (key: string) => Promise<boolean>;
/** Keyv options — used for key enumeration when clearing override caches. */
opts?: {
store?: {
keys?: () => IterableIterator<string>;
};
};
}
export interface AppConfigServiceDeps {
@ -39,8 +45,28 @@ export interface AppConfigServiceDeps {
// ── Helpers ──────────────────────────────────────────────────────────
let _strictOverride: boolean | undefined;
function isStrictOverrideMode(): boolean {
return (_strictOverride ??= process.env.TENANT_ISOLATION_STRICT === 'true');
}
/** @internal Resets the cached strict-override flag. Exposed for test teardown only. */
let _warnedNoTenantInStrictMode = false;
export function _resetOverrideStrictCache(): void {
_strictOverride = undefined;
_warnedNoTenantInStrictMode = false;
}
function overrideCacheKey(role?: string, userId?: string, tenantId?: string): string {
const tenant = tenantId || '__default__';
if (!tenantId && isStrictOverrideMode() && !_warnedNoTenantInStrictMode) {
_warnedNoTenantInStrictMode = true;
logger.warn(
'[overrideCacheKey] No tenantId in strict mode — falling back to __default__. ' +
'This likely indicates a code path that bypasses the tenant context middleware.',
);
}
if (userId && role) {
return `_OVERRIDE_:${tenant}:${role}:${userId}`;
}
@ -83,20 +109,13 @@ export function createAppConfigService(deps: AppConfigServiceDeps) {
}
/**
* Get the app configuration, optionally merged with DB overrides for the given principal.
*
* The base config (from YAML + AppService) is cached indefinitely. Per-principal merged
* configs are cached with a short TTL (`overrideCacheTtl`, default 60s). On cache miss,
* `getApplicableConfigs` queries the DB for matching overrides and merges them by priority.
* Ensure the YAML-derived base config is loaded and cached.
* Returns the `_BASE_` config (YAML + AppService). No DB queries.
*/
async function getAppConfig(
options: { role?: string; userId?: string; tenantId?: string; refresh?: boolean } = {},
): Promise<AppConfig> {
const { role, userId, tenantId, refresh } = options;
async function ensureBaseConfig(refresh?: boolean): Promise<AppConfig> {
let baseConfig = (await cache.get(BASE_CONFIG_KEY)) as AppConfig | undefined;
if (!baseConfig || refresh) {
logger.info('[getAppConfig] Loading base configuration...');
logger.info('[ensureBaseConfig] Loading base configuration...');
baseConfig = await loadBaseConfig();
if (!baseConfig) {
@ -109,6 +128,37 @@ export function createAppConfigService(deps: AppConfigServiceDeps) {
await cache.set(BASE_CONFIG_KEY, baseConfig);
}
return baseConfig;
}
/**
* Get the app configuration, optionally merged with DB overrides for the given principal.
*
* The base config (from YAML + AppService) is cached indefinitely. Per-principal merged
* configs are cached with a short TTL (`overrideCacheTtl`, default 60s). On cache miss,
* `getApplicableConfigs` queries the DB for matching overrides and merges them by priority.
*
* When `baseOnly` is true, returns the YAML-derived config without any DB queries.
* `role`, `userId`, and `tenantId` are ignored in this mode.
* Use this for startup, auth strategies, and other pre-tenant code paths.
*/
async function getAppConfig(
options: {
role?: string;
userId?: string;
tenantId?: string;
refresh?: boolean;
/** When true, return only the YAML-derived base config — no DB override queries. */
baseOnly?: boolean;
} = {},
): Promise<AppConfig> {
const { role, userId, tenantId, refresh, baseOnly } = options;
const baseConfig = await ensureBaseConfig(refresh);
if (baseOnly) {
return baseConfig;
}
const cacheKey = overrideCacheKey(role, userId, tenantId);
if (!refresh) {
@ -146,9 +196,55 @@ export function createAppConfigService(deps: AppConfigServiceDeps) {
await cache.delete(BASE_CONFIG_KEY);
}
/**
* Clear per-principal override caches. When `tenantId` is provided, only caches
* matching `_OVERRIDE_:${tenantId}:*` are deleted. When omitted, ALL override
* caches are cleared.
*/
async function clearOverrideCache(tenantId?: string): Promise<void> {
const namespace = cacheKeys.APP_CONFIG;
const overrideSegment = tenantId ? `_OVERRIDE_:${tenantId}:` : '_OVERRIDE_:';
// In-memory store — enumerate keys directly.
// APP_CONFIG defaults to FORCED_IN_MEMORY_CACHE_NAMESPACES, so this is the
// standard path. Redis SCAN is intentionally avoided here — it can cause 60s+
// stalls under concurrent load (see #12410). When APP_CONFIG is Redis-backed
// and store.keys() is unavailable, overrides expire naturally via TTL.
const store = (cache as CacheStore).opts?.store;
if (store && typeof store.keys === 'function') {
// Keyv stores keys with a namespace prefix (e.g. "APP_CONFIG:_OVERRIDE_:...").
// We match on the namespaced key but delete using the un-namespaced key
// because Keyv.delete() auto-prepends the namespace.
const namespacedPrefix = `${namespace}:${overrideSegment}`;
const toDelete: string[] = [];
for (const key of store.keys()) {
if (key.startsWith(namespacedPrefix)) {
toDelete.push(key.slice(namespace.length + 1));
}
}
if (toDelete.length > 0) {
await Promise.all(toDelete.map((key) => cache.delete(key)));
logger.info(
`[clearOverrideCache] Cleared ${toDelete.length} override cache entries` +
(tenantId ? ` for tenant ${tenantId}` : ''),
);
}
return;
}
logger.warn(
'[clearOverrideCache] Cache store does not support key enumeration. ' +
'Override caches will expire naturally via TTL (%dms). ' +
'This is expected when APP_CONFIG is Redis-backed — Redis SCAN is avoided ' +
'for performance reasons (see #12410).',
overrideCacheTtl,
);
}
return {
getAppConfig,
clearAppConfigCache,
clearOverrideCache,
};
}

View file

@ -0,0 +1,101 @@
import { getTenantId } from '@librechat/data-schemas';
import type { Response, NextFunction } from 'express';
import type { ServerRequest } from '~/types/http';
// Import directly from source file — _resetTenantMiddlewareStrictCache is intentionally
// excluded from the public barrel export (index.ts).
import { tenantContextMiddleware, _resetTenantMiddlewareStrictCache } from '../tenant';
function mockReq(user?: Record<string, unknown>): ServerRequest {
return { user } as unknown as ServerRequest;
}
function mockRes(): Response {
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
return res as unknown as Response;
}
/** Runs the middleware and returns a Promise that resolves when next() is called. */
function runMiddleware(req: ServerRequest, res: Response): Promise<string | undefined> {
return new Promise((resolve) => {
const next: NextFunction = () => {
resolve(getTenantId());
};
tenantContextMiddleware(req, res, next);
});
}
describe('tenantContextMiddleware', () => {
afterEach(() => {
_resetTenantMiddlewareStrictCache();
delete process.env.TENANT_ISOLATION_STRICT;
});
it('sets ALS tenant context for authenticated requests with tenantId', async () => {
const req = mockReq({ tenantId: 'tenant-x', role: 'user' });
const res = mockRes();
const tenantId = await runMiddleware(req, res);
expect(tenantId).toBe('tenant-x');
});
it('is a no-op for unauthenticated requests (no user)', async () => {
const req = mockReq();
const res = mockRes();
const tenantId = await runMiddleware(req, res);
expect(tenantId).toBeUndefined();
});
it('passes through without ALS when user has no tenantId in non-strict mode', async () => {
const req = mockReq({ role: 'user' });
const res = mockRes();
const tenantId = await runMiddleware(req, res);
expect(tenantId).toBeUndefined();
});
it('returns 403 when user has no tenantId in strict mode', () => {
process.env.TENANT_ISOLATION_STRICT = 'true';
_resetTenantMiddlewareStrictCache();
const req = mockReq({ role: 'user' });
const res = mockRes();
const next: NextFunction = jest.fn();
tenantContextMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('Tenant context required') }),
);
expect(next).not.toHaveBeenCalled();
});
it('allows authenticated requests with tenantId in strict mode', async () => {
process.env.TENANT_ISOLATION_STRICT = 'true';
_resetTenantMiddlewareStrictCache();
const req = mockReq({ tenantId: 'tenant-y', role: 'admin' });
const res = mockRes();
const tenantId = await runMiddleware(req, res);
expect(tenantId).toBe('tenant-y');
});
it('different requests get independent tenant contexts', async () => {
const runRequest = (tid: string) => {
const req = mockReq({ tenantId: tid, role: 'user' });
const res = mockRes();
return runMiddleware(req, res);
};
const results = await Promise.all([runRequest('tenant-1'), runRequest('tenant-2')]);
expect(results).toHaveLength(2);
expect(results).toContain('tenant-1');
expect(results).toContain('tenant-2');
});
});

View file

@ -12,7 +12,11 @@ import type { BalanceUpdateFields } from '~/types';
import { getBalanceConfig } from '~/app/config';
export interface BalanceMiddlewareOptions {
getAppConfig: (options?: { role?: string; refresh?: boolean }) => Promise<AppConfig>;
getAppConfig: (options?: {
role?: string;
tenantId?: string;
refresh?: boolean;
}) => Promise<AppConfig>;
findBalanceByUser: (userId: string) => Promise<IBalance | null>;
upsertBalanceFields: (userId: string, fields: IBalanceUpdate) => Promise<IBalance | null>;
}
@ -92,7 +96,10 @@ export function createSetBalanceConfig({
return async (req: ServerRequest, res: ServerResponse, next: NextFunction): Promise<void> => {
try {
const user = req.user as IUser & { _id: string | ObjectId };
const appConfig = await getAppConfig({ role: user?.role });
const appConfig = await getAppConfig({
role: user?.role,
tenantId: user?.tenantId,
});
const balanceConfig = getBalanceConfig(appConfig);
if (!balanceConfig?.enabled) {
return next();

View file

@ -5,5 +5,6 @@ export * from './notFound';
export * from './balance';
export * from './json';
export * from './capabilities';
export { tenantContextMiddleware } from './tenant';
export * from './concurrency';
export * from './checkBalance';

View file

@ -0,0 +1,70 @@
import { isMainThread } from 'worker_threads';
import { tenantStorage, logger } from '@librechat/data-schemas';
import type { Response, NextFunction } from 'express';
import type { ServerRequest } from '~/types/http';
let _checkedThread = false;
let _strictMode: boolean | undefined;
function isStrict(): boolean {
return (_strictMode ??= process.env.TENANT_ISOLATION_STRICT === 'true');
}
/** Resets the cached strict-mode flag. Exposed for test teardown only. */
export function _resetTenantMiddlewareStrictCache(): void {
_strictMode = undefined;
}
/**
* Express middleware that propagates the authenticated user's `tenantId` into
* the AsyncLocalStorage context used by the Mongoose tenant-isolation plugin.
*
* **Placement**: Chained automatically by `requireJwtAuth` after successful
* passport authentication (req.user is populated). Must NOT be registered at
* global `app.use()` scope `req.user` is undefined at that stage.
*
* Behaviour:
* - Authenticated request with `tenantId` wraps downstream in `tenantStorage.run({ tenantId })`
* - Authenticated request **without** `tenantId`:
* - Strict mode (`TENANT_ISOLATION_STRICT=true`) responds 403
* - Non-strict (default) passes through without ALS context (backward compat)
* - Unauthenticated request no-op (calls `next()` directly)
*/
export function tenantContextMiddleware(
req: ServerRequest,
res: Response,
next: NextFunction,
): void {
if (!_checkedThread) {
_checkedThread = true;
if (!isMainThread) {
logger.error(
'[tenantContextMiddleware] Running in a worker thread — ' +
'ALS context will not propagate. This middleware must only run in the main Express process.',
);
}
}
const user = req.user as { tenantId?: string } | undefined;
if (!user) {
next();
return;
}
const tenantId = user.tenantId;
if (!tenantId) {
if (isStrict()) {
res.status(403).json({ error: 'Tenant context required in strict isolation mode' });
return;
}
next();
return;
}
return void tenantStorage.run({ tenantId }, async () => {
next();
});
}