🏗️ feat: bulkWrite isolation, pre-auth context, strict-mode fixes (#12445)

* fix: wrap seedDatabase() in runAsSystem() for strict tenant mode

seedDatabase() was called without tenant context at startup, causing
every Mongoose operation inside it to throw when
TENANT_ISOLATION_STRICT=true. Wrapping in runAsSystem() gives it the
SYSTEM_TENANT_ID sentinel so the isolation plugin skips filtering,
matching the pattern already used for performStartupChecks and
updateInterfacePermissions.

* fix: chain tenantContextMiddleware in optionalJwtAuth

optionalJwtAuth populated req.user but never established ALS tenant
context, unlike requireJwtAuth which chains tenantContextMiddleware
after successful auth. Authenticated users hitting routes with
optionalJwtAuth (e.g. /api/banner) had no tenant isolation.

* feat: tenant-safe bulkWrite wrapper and call-site migration

Mongoose's bulkWrite() does not trigger schema-level middleware hooks,
so the applyTenantIsolation plugin cannot intercept it. This adds a
tenantSafeBulkWrite() utility that injects the current ALS tenant
context into every operation's filter/document before delegating to
native bulkWrite.

Migrates all 8 runtime bulkWrite call sites:
- agentCategory (seedCategories, ensureDefaultCategories)
- conversation (bulkSaveConvos)
- message (bulkSaveMessages)
- file (batchUpdateFiles)
- conversationTag (updateTagsForConversation, bulkIncrementTagCounts)
- aclEntry (bulkWriteAclEntries)

systemGrant.seedSystemGrants is intentionally not migrated — it uses
explicit tenantId: { $exists: false } filters and is exempt from the
isolation plugin.

* feat: pre-auth tenant middleware and tenant-scoped config cache

Adds preAuthTenantMiddleware that reads X-Tenant-Id from the request
header and wraps downstream in tenantStorage ALS context. Wired onto
/oauth, /api/auth, /api/config, and /api/share — unauthenticated
routes that need tenant scoping before JWT auth runs.

The /api/config cache key is now tenant-scoped
(STARTUP_CONFIG:${tenantId}) so multi-tenant deployments serve the
correct login page config per tenant.

The middleware is intentionally minimal — no subdomain parsing, no
OIDC claim extraction. The private fork's reverse proxy or auth
gateway sets the header.

* feat: accept optional tenantId in updateInterfacePermissions

When tenantId is provided, the function re-enters inside
tenantStorage.run({ tenantId }) so all downstream Mongoose queries
target that tenant's roles instead of the system context. This lets
the private fork's tenant provisioning flow call
updateInterfacePermissions per-tenant after creating tenant-scoped
ADMIN/USER roles.

* fix: tenant-filter $lookup in getPromptGroup aggregation

The $lookup stage in getPromptGroup() queried the prompts collection
without tenant filtering. While the outer PromptGroup aggregate is
protected by the tenantIsolation plugin's pre('aggregate') hook,
$lookup runs as an internal MongoDB operation that bypasses Mongoose
hooks entirely.

Converts from simple field-based $lookup to pipeline-based $lookup
with an explicit tenantId match when tenant context is active.

* fix: replace field-level unique indexes with tenant-scoped compounds

Field-level unique:true creates a globally-unique single-field index in
MongoDB, which would cause insert failures across tenants sharing the
same ID values.

- agent.id: removed field-level unique, added { id, tenantId } compound
- convo.conversationId: removed field-level unique (compound at line 50
  already exists: { conversationId, user, tenantId })
- message.messageId: removed field-level unique (compound at line 165
  already exists: { messageId, user, tenantId })
- preset.presetId: removed field-level unique, added { presetId, tenantId }
  compound

* fix: scope MODELS_CONFIG, ENDPOINT_CONFIG, PLUGINS, TOOLS caches by tenant

These caches store per-tenant configuration (available models, endpoint
settings, plugin availability, tool definitions) but were using global
cache keys. In multi-tenant mode, one tenant's cached config would be
served to all tenants.

Appends :${tenantId} to cache keys when tenant context is active.
Falls back to the unscoped key when no tenant context exists (backward
compatible for single-tenant OSS deployments).

Covers all read, write, and delete sites:
- ModelController.js: get/set MODELS_CONFIG
- PluginController.js: get/set PLUGINS, get/set TOOLS
- getEndpointsConfig.js: get/set/delete ENDPOINT_CONFIG
- app.js: delete ENDPOINT_CONFIG (clearEndpointConfigCache)
- mcp.js: delete TOOLS (updateMCPTools, mergeAppTools)
- importers.js: get ENDPOINT_CONFIG

* fix: add getTenantId to PluginController spec mock

The data-schemas mock was missing getTenantId, causing all
PluginController tests to throw when the controller calls
getTenantId() for tenant-scoped cache keys.

* fix: address review findings — migration, strict-mode, DRY, types

Addresses all CRITICAL, MAJOR, and MINOR review findings:

F1 (CRITICAL): Add agents, conversations, messages, presets to
SUPERSEDED_INDEXES in tenantIndexes.ts so dropSupersededTenantIndexes()
drops the old single-field unique indexes that block multi-tenant inserts.

F2 (CRITICAL): Unknown bulkWrite op types now throw in strict mode
instead of silently passing through without tenant injection.

F3 (MAJOR): Replace wildcard export with named export for
tenantSafeBulkWrite, hiding _resetBulkWriteStrictCache from the
public package API.

F5 (MAJOR): Restore AnyBulkWriteOperation<IAclEntry>[] typing on
bulkWriteAclEntries — the unparameterized wrapper accepts parameterized
ops as a subtype.

F7 (MAJOR): Fix config.js tenant precedence — JWT-derived
req.user.tenantId now takes priority over the X-Tenant-Id header for
authenticated requests.

F8 (MINOR): Extract scopedCacheKey() helper into tenantContext.ts and
replace all 11 inline occurrences across 7 files.

F9 (MINOR): Use simple localField/foreignField $lookup for the
non-tenant getPromptGroup path (more efficient index seeks).

F12 (NIT): Remove redundant BulkOp type alias.
F13 (NIT): Remove debug log that leaked raw tenantId.

* fix: add new superseded indexes to tenantIndexes test fixture

The test creates old indexes to verify the migration drops them.
Missing fixture entries for agents.id_1, conversations.conversationId_1,
messages.messageId_1, and presets.presetId_1 caused the count assertion
to fail (expected 22, got 18).

* fix: restore logger.warn for unknown bulk op types in non-strict mode

* fix: block SYSTEM_TENANT_ID sentinel from external header input

CRITICAL: preAuthTenantMiddleware accepted any string as X-Tenant-Id,
including '__SYSTEM__'. The tenantIsolation plugin treats SYSTEM_TENANT_ID
as an explicit bypass — skipping ALL query filters. A client sending
X-Tenant-Id: __SYSTEM__ to pre-auth routes (/api/share, /api/config,
/api/auth, /oauth) would execute Mongoose operations without tenant
isolation.

Fixes:
- preAuthTenantMiddleware rejects SYSTEM_TENANT_ID in header
- scopedCacheKey returns the base key (not key:__SYSTEM__) in system
  context, preventing stale cache entries during runAsSystem()
- updateInterfacePermissions guards tenantId against SYSTEM_TENANT_ID
- $lookup pipeline separates $expr join from constant tenantId match
  for better index utilization
- Regression test for sentinel rejection in preAuthTenant.spec.ts
- Remove redundant getTenantId() call in config.js

* test: add missing deleteMany/replaceOne coverage, fix vacuous ALS assertions

bulkWrite spec:
- deleteMany: verifies tenant-scoped deletion leaves other tenants untouched
- replaceOne: verifies tenantId injected into both filter and replacement
- replaceOne overwrite: verifies a conflicting tenantId in the replacement
  document is overwritten by the ALS tenant (defense-in-depth)
- empty ops array: verifies graceful handling

preAuthTenant spec:
- All negative-case tests now use the capturedNext pattern to verify
  getTenantId() inside the middleware's execution context, not the
  test runner's outer frame (which was always undefined regardless)

* feat: tenant-isolate MESSAGES cache, FLOWS cache, and GenerationJobManager

MESSAGES cache (streamAudio.js):
- Cache key now uses scopedCacheKey(messageId) to prefix with tenantId,
  preventing cross-tenant message content reads during TTS streaming.

FLOWS cache (FlowStateManager):
- getFlowKey() now generates ${type}:${tenantId}:${flowId} when tenant
  context is active, isolating OAuth flow state per tenant.

GenerationJobManager:
- tenantId added to SerializableJobData and GenerationJobMetadata
- createJob() captures the current ALS tenant context (excluding
  SYSTEM_TENANT_ID) and stores it in job metadata
- SSE subscription endpoint validates job.metadata.tenantId matches
  req.user.tenantId, blocking cross-tenant stream access
- Both InMemoryJobStore and RedisJobStore updated to accept tenantId

* fix: add getTenantId and SYSTEM_TENANT_ID to MCP OAuth test mocks

FlowStateManager.getFlowKey() now calls getTenantId() for tenant-scoped
flow keys. The 4 MCP OAuth test files mock @librechat/data-schemas
without these exports, causing TypeError at runtime.

* fix: correct import ordering per AGENTS.md conventions

Package imports sorted shortest to longest line length, local imports
sorted longest to shortest — fixes ordering violations introduced by
our new imports across 8 files.

* fix: deserialize tenantId in RedisJobStore — cross-tenant SSE guard was no-op in Redis mode

serializeJob() writes tenantId to the Redis hash via Object.entries,
but deserializeJob() manually enumerates fields and omitted tenantId.
Every getJob() from Redis returned tenantId: undefined, causing the
SSE route's cross-tenant guard to short-circuit (undefined && ... → false).

* test: SSE tenant guard, FlowStateManager key consistency, ALS scope docs

SSE stream tenant tests (streamTenant.spec.js):
- Cross-tenant user accessing another tenant's stream → 403
- Same-tenant user accessing own stream → allowed
- OSS mode (no tenantId on job) → tenant check skipped

FlowStateManager tenant tests (manager.tenant.spec.ts):
- completeFlow finds flow created under same tenant context
- completeFlow does NOT find flow under different tenant context
- Unscoped flows are separate from tenant-scoped flows

Documentation:
- JSDoc on getFlowKey documenting ALS context consistency requirement
- Comment on streamAudio.js scopedCacheKey capture site

* fix: SSE stream tests hang on success path, remove internal fork references

The success-path tests entered the SSE streaming code which never
closes, causing timeout. Mock subscribe() to end the response
immediately. Restructured assertions to verify non-403/non-404.

Removed "private fork" and "OSS" references from code and test
descriptions — replaced with "deployment layer", "multi-tenant
deployments", and "single-tenant mode".

* fix: address review findings — test rigor, tenant ID validation, docs

F1: SSE stream tests now mock subscribe() with correct signature
(streamId, writeEvent, onDone, onError) and assert 200 status,
verifying the tenant guard actually allows through same-tenant users.

F2: completeFlow logs the attempted key and ALS tenantId when flow
is not found, so reverse proxy misconfiguration (missing X-Tenant-Id
on OAuth callback) produces an actionable warning.

F3/F10: preAuthTenantMiddleware validates tenant ID format — rejects
colons, special characters, and values exceeding 128 chars. Trims
whitespace. Prevents cache key collisions via crafted headers.

F4: Documented cache invalidation scope limitation in
clearEndpointConfigCache — only the calling tenant's key is cleared;
other tenants expire via TTL.

F7: getFlowKey JSDoc now lists all 8 methods requiring consistent
ALS context.

F8: Added dedicated scopedCacheKey unit tests — base key without
context, base key in system context, scoped key with tenant, no
ALS leakage across scope boundaries.

* fix: revert flow key tenant scoping, fix SSE test timing

FlowStateManager: Reverts tenant-scoped flow keys. OAuth callbacks
arrive without tenant ALS context (provider redirects don't carry
X-Tenant-Id), so completeFlow/failFlow would never find flows
created under tenant context. Flow IDs are random UUIDs with no
collision risk, and flow data is ephemeral (TTL-bounded).

SSE tests: Use process.nextTick for onDone callback so Express
response headers are flushed before res.write/res.end are called.

* fix: restore getTenantId import for completeFlow diagnostic log

* fix: correct completeFlow warning message, add missing flow test

The warning referenced X-Tenant-Id header consistency which was only
relevant when flow keys were tenant-scoped (since reverted). Updated
to list actual causes: TTL expiry, missing flow, or routing to a
different instance without shared Keyv storage.

Removed the getTenantId() call and import — no longer needed since
flow keys are unscoped.

Added test for the !flowState branch in completeFlow — verifies
return false and logger.warn on nonexistent flow ID.

* fix: add explicit return type to recursive updateInterfacePermissions

The recursive call (tenantId branch calls itself without tenantId)
causes TypeScript to infer circular return type 'any'. Adding
explicit Promise<void> satisfies the rollup typescript plugin.

* fix: update MCPOAuthRaceCondition test to match new completeFlow warning

* fix: clearEndpointConfigCache deletes both scoped and unscoped keys

Unauthenticated /api/endpoints requests populate the unscoped
ENDPOINT_CONFIG key. Admin config mutations clear only the
tenant-scoped key, leaving the unscoped entry stale indefinitely.
Now deletes both when in tenant context.

* fix: tenant guard on abort/status endpoints, warn logs, test coverage

F1: Add tenant guard to /chat/status/:conversationId and /chat/abort
matching the existing guard on /chat/stream/:streamId. The status
endpoint exposes aggregatedContent (AI response text) which requires
tenant-level access control.

F2: preAuthTenantMiddleware now logs warn for rejected __SYSTEM__
sentinel and malformed tenant IDs, providing observability for
bypass probing attempts.

F3: Abort fallback path (getActiveJobIdsForUser) now has tenant
check after resolving the job.

F4: Test for strict mode + SYSTEM_TENANT_ID — verifies runAsSystem
bypasses tenantSafeBulkWrite without throwing in strict mode.

F5: Test for job with tenantId + user without tenantId → 403.

F10: Regex uses idiomatic hyphen-at-start form.

F11: Test descriptions changed from "rejects" to "ignores" since
middleware calls next() (not 4xx).

Also fixes MCPOAuthRaceCondition test assertion to match updated
completeFlow warning message.

* fix: test coverage for logger.warn, status/abort guards, consistency

A: preAuthTenant spec now mocks logger and asserts warn calls for
__SYSTEM__ sentinel, malformed characters, and oversized headers.

B: streamTenant spec expanded with status and abort endpoint tests —
cross-tenant status returns 403, same-tenant returns 200 with body,
cross-tenant abort returns 403.

C: Abort endpoint uses req.user.tenantId (not req.user?.tenantId)
matching stream/status pattern — requireJwtAuth guarantees req.user.

D: Malformed header warning now includes ip in log metadata,
matching the sentinel warning for consistent SOC correlation.

* fix: assert ip field in malformed header warn tests

* fix: parallelize cache deletes, document tenant guard, fix import order

- clearEndpointConfigCache uses Promise.all for independent cache
  deletes instead of sequential awaits
- SSE stream tenant guard has inline comment explaining backward-compat
  behavior for untenanted legacy jobs
- conversation.ts local imports reordered longest-to-shortest per
  AGENTS.md

* fix: tenant-qualify userJobs keys, document tenant guard backward-compat

Job store userJobs keys now include tenantId when available:
- Redis: stream:user:{tenantId:userId}:jobs (falls back to
  stream:user:{userId}:jobs when no tenant)
- InMemory: composite key tenantId:userId in userJobMap

getActiveJobIdsByUser/getActiveJobIdsForUser accept optional tenantId
parameter, threaded through from req.user.tenantId at all call sites
(/chat/active and /chat/abort fallback).

Added inline comments on all three SSE tenant guards explaining the
backward-compat design: untenanted legacy jobs remain accessible
when the userId check passes.

* fix: parallelize cache deletes, document tenant guard, fix import order

Fix InMemoryJobStore.getActiveJobIdsByUser empty-set cleanup to use
the tenant-qualified userKey instead of bare userId — prevents
orphaned empty Sets accumulating in userJobMap for multi-tenant users.

Document cross-tenant staleness in clearEndpointConfigCache JSDoc —
other tenants' scoped keys expire via TTL, not active invalidation.

* fix: cleanup userJobMap leak, startup warning, DRY tenant guard, docs

F1: InMemoryJobStore.cleanup() now removes entries from userJobMap
before calling deleteJob, preventing orphaned empty Sets from
accumulating with tenant-qualified composite keys.

F2: Startup warning when TENANT_ISOLATION_STRICT is active — reminds
operators to configure reverse proxy to control X-Tenant-Id header.

F3: mergeAppTools JSDoc documents that tenant-scoped TOOLS keys are
not actively invalidated (matching clearEndpointConfigCache pattern).

F5: Abort handler getActiveJobIdsForUser call uses req.user.tenantId
(not req.user?.tenantId) — consistent with stream/status handlers.

F6: updateInterfacePermissions JSDoc clarifies SYSTEM_TENANT_ID
behavior — falls through to caller's ALS context.

F7: Extracted hasTenantMismatch() helper, replacing three identical
inline tenant guard blocks across stream/status/abort endpoints.

F9: scopedCacheKey JSDoc documents both passthrough cases (no context
and SYSTEM_TENANT_ID context).

* fix: clean userJobMap in evictOldest — same leak as cleanup()
This commit is contained in:
Danny Avila 2026-03-28 16:43:50 -04:00 committed by GitHub
parent 935288f841
commit 877c2efc85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1224 additions and 83 deletions

View file

@ -0,0 +1,26 @@
import { tenantStorage, runAsSystem, scopedCacheKey } from './tenantContext';
describe('scopedCacheKey', () => {
it('returns base key when no ALS context is set', () => {
expect(scopedCacheKey('MODELS_CONFIG')).toBe('MODELS_CONFIG');
});
it('returns base key in SYSTEM_TENANT_ID context', async () => {
await runAsSystem(async () => {
expect(scopedCacheKey('MODELS_CONFIG')).toBe('MODELS_CONFIG');
});
});
it('appends tenantId when tenant context is active', async () => {
await tenantStorage.run({ tenantId: 'acme' }, async () => {
expect(scopedCacheKey('MODELS_CONFIG')).toBe('MODELS_CONFIG:acme');
});
});
it('does not leak tenant context outside ALS scope', async () => {
await tenantStorage.run({ tenantId: 'acme' }, async () => {
expect(scopedCacheKey('KEY')).toBe('KEY:acme');
});
expect(scopedCacheKey('KEY')).toBe('KEY');
});
});

View file

@ -26,3 +26,16 @@ export function getTenantId(): string | undefined {
export function runAsSystem<T>(fn: () => Promise<T>): Promise<T> {
return tenantStorage.run({ tenantId: SYSTEM_TENANT_ID }, fn);
}
/**
* Appends `:${tenantId}` to a cache key when a non-system tenant context is active.
* Returns the base key unchanged when no ALS context is set or when running
* inside `runAsSystem()` (SYSTEM_TENANT_ID context).
*/
export function scopedCacheKey(baseKey: string): string {
const tenantId = getTenantId();
if (!tenantId || tenantId === SYSTEM_TENANT_ID) {
return baseKey;
}
return `${baseKey}:${tenantId}`;
}

View file

@ -19,6 +19,12 @@ export type * from './types';
export type * from './methods';
export { default as logger } from './config/winston';
export { default as meiliLogger } from './config/meiliLogger';
export { tenantStorage, getTenantId, runAsSystem, SYSTEM_TENANT_ID } from './config/tenantContext';
export {
tenantStorage,
getTenantId,
runAsSystem,
scopedCacheKey,
SYSTEM_TENANT_ID,
} from './config/tenantContext';
export type { TenantContext } from './config/tenantContext';
export { dropSupersededTenantIndexes, dropSupersededPromptGroupIndexes } from './migrations';

View file

@ -8,6 +8,7 @@ import type {
Model,
} from 'mongoose';
import type { IAclEntry } from '~/types';
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
export function createAclEntryMethods(mongoose: typeof import('mongoose')) {
/**
@ -378,7 +379,7 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) {
options?: { session?: ClientSession },
) {
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
return AclEntry.bulkWrite(ops, options || {});
return tenantSafeBulkWrite(AclEntry, ops, options || {});
}
/**
@ -448,7 +449,9 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) {
{ $group: { _id: '$resourceId' } },
]);
const multiOwnerIds = new Set(otherOwners.map((doc: { _id: Types.ObjectId }) => doc._id.toString()));
const multiOwnerIds = new Set(
otherOwners.map((doc: { _id: Types.ObjectId }) => doc._id.toString()),
);
return ownedIds.filter((id) => !multiOwnerIds.has(id.toString()));
}

View file

@ -1,5 +1,6 @@
import type { Model, Types } from 'mongoose';
import type { IAgentCategory } from '~/types';
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
export function createAgentCategoryMethods(mongoose: typeof import('mongoose')) {
/**
@ -74,7 +75,7 @@ export function createAgentCategoryMethods(mongoose: typeof import('mongoose'))
},
}));
return await AgentCategory.bulkWrite(operations);
return await tenantSafeBulkWrite(AgentCategory, operations);
}
/**
@ -241,7 +242,7 @@ export function createAgentCategoryMethods(mongoose: typeof import('mongoose'))
},
}));
await AgentCategory.bulkWrite(bulkOps, { ordered: false });
await tenantSafeBulkWrite(AgentCategory, bulkOps, { ordered: false });
}
return updates.length > 0 || created > 0;

View file

@ -1,6 +1,7 @@
import type { FilterQuery, Model, SortOrder } from 'mongoose';
import logger from '~/config/winston';
import { createTempChatExpirationDate } from '~/utils/tempChatRetention';
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
import logger from '~/config/winston';
import type { AppConfig, IConversation } from '~/types';
import type { MessageMethods } from './message';
import type { DeleteResult } from 'mongoose';
@ -228,7 +229,7 @@ export function createConversationMethods(
},
}));
const result = await Conversation.bulkWrite(bulkOps);
const result = await tenantSafeBulkWrite(Conversation, bulkOps);
return result;
} catch (error) {
logger.error('[bulkSaveConvos] Error saving conversations in bulk', error);

View file

@ -1,4 +1,5 @@
import type { Model } from 'mongoose';
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
import logger from '~/config/winston';
interface IConversationTag {
@ -233,7 +234,7 @@ export function createConversationTagMethods(mongoose: typeof import('mongoose')
}
if (bulkOps.length > 0) {
await ConversationTag.bulkWrite(bulkOps);
await tenantSafeBulkWrite(ConversationTag, bulkOps);
}
const updatedConversation = (
@ -273,7 +274,7 @@ export function createConversationTagMethods(mongoose: typeof import('mongoose')
},
}));
const result = await ConversationTag.bulkWrite(bulkOps);
const result = await tenantSafeBulkWrite(ConversationTag, bulkOps);
if (result && result.modifiedCount > 0) {
logger.debug(
`user: ${user} | Incremented tag counts - modified ${result.modifiedCount} tags`,

View file

@ -2,6 +2,7 @@ import logger from '../config/winston';
import { EToolResources, FileContext } from 'librechat-data-provider';
import type { FilterQuery, SortOrder, Model } from 'mongoose';
import type { IMongoFile } from '~/types/file';
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
/** Factory function that takes mongoose instance and returns the file methods */
export function createFileMethods(mongoose: typeof import('mongoose')) {
@ -322,7 +323,7 @@ export function createFileMethods(mongoose: typeof import('mongoose')) {
},
}));
const result = await File.bulkWrite(bulkOperations);
const result = await tenantSafeBulkWrite(File, bulkOperations);
logger.info(`Updated ${result.modifiedCount} files with new S3 URLs`);
}

View file

@ -1,6 +1,7 @@
import type { DeleteResult, FilterQuery, Model } from 'mongoose';
import logger from '~/config/winston';
import { createTempChatExpirationDate } from '~/utils/tempChatRetention';
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
import type { AppConfig, IMessage } from '~/types';
/** Simple UUID v4 regex to replace zod validation */
@ -165,7 +166,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
upsert: true,
},
}));
const result = await Message.bulkWrite(bulkOps);
const result = await tenantSafeBulkWrite(Message, bulkOps);
return result;
} catch (err) {
logger.error('Error saving messages in bulk:', err);

View file

@ -1,8 +1,9 @@
import { ResourceType, SystemCategories } from 'librechat-data-provider';
import type { Model, Types } from 'mongoose';
import type { IAclEntry, IPrompt, IPromptGroup, IPromptGroupDocument } from '~/types';
import { escapeRegExp } from '~/utils/string';
import { getTenantId, SYSTEM_TENANT_ID } from '~/config/tenantContext';
import { isValidObjectIdString } from '~/utils/objectId';
import { escapeRegExp } from '~/utils/string';
import logger from '~/config/winston';
export interface PromptDeps {
@ -508,16 +509,37 @@ export function createPromptMethods(mongoose: typeof import('mongoose'), deps: P
if (typeof matchFilter._id === 'string') {
matchFilter._id = new ObjectId(matchFilter._id);
}
const tenantId = getTenantId();
const useTenantFilter = tenantId && tenantId !== SYSTEM_TENANT_ID;
const lookupStage = useTenantFilter
? {
$lookup: {
from: 'prompts',
let: { prodId: '$productionId' },
pipeline: [
{
$match: {
$expr: { $eq: ['$_id', '$$prodId'] },
tenantId,
},
},
],
as: 'productionPrompt',
},
}
: {
$lookup: {
from: 'prompts',
localField: 'productionId',
foreignField: '_id',
as: 'productionPrompt',
},
};
const result = await PromptGroup.aggregate([
{ $match: matchFilter },
{
$lookup: {
from: 'prompts',
localField: 'productionId',
foreignField: '_id',
as: 'productionPrompt',
},
},
lookupStage,
{ $unwind: { path: '$productionPrompt', preserveNullAndEmptyArrays: true } },
]);
const group = result[0] || null;

View file

@ -47,7 +47,13 @@ describe('dropSupersededTenantIndexes', () => {
await db.createCollection('roles');
await db.collection('roles').createIndex({ name: 1 }, { unique: true, name: 'name_1' });
await db.createCollection('agents');
await db.collection('agents').createIndex({ id: 1 }, { unique: true, name: 'id_1' });
await db.createCollection('conversations');
await db
.collection('conversations')
.createIndex({ conversationId: 1 }, { unique: true, name: 'conversationId_1' });
await db
.collection('conversations')
.createIndex(
@ -56,10 +62,18 @@ describe('dropSupersededTenantIndexes', () => {
);
await db.createCollection('messages');
await db
.collection('messages')
.createIndex({ messageId: 1 }, { unique: true, name: 'messageId_1' });
await db
.collection('messages')
.createIndex({ messageId: 1, user: 1 }, { unique: true, name: 'messageId_1_user_1' });
await db.createCollection('presets');
await db
.collection('presets')
.createIndex({ presetId: 1 }, { unique: true, name: 'presetId_1' });
await db.createCollection('agentcategories');
await db
.collection('agentcategories')

View file

@ -24,8 +24,10 @@ const SUPERSEDED_INDEXES: Record<string, string[]> = {
'appleId_1',
],
roles: ['name_1'],
conversations: ['conversationId_1_user_1'],
messages: ['messageId_1_user_1'],
agents: ['id_1'],
conversations: ['conversationId_1', 'conversationId_1_user_1'],
messages: ['messageId_1', 'messageId_1_user_1'],
presets: ['presetId_1'],
agentcategories: ['value_1'],
accessroles: ['accessRoleId_1'],
conversationtags: ['tag_1_user_1'],

View file

@ -5,8 +5,6 @@ const agentSchema = new Schema<IAgent>(
{
id: {
type: String,
index: true,
unique: true,
required: true,
},
name: {
@ -124,6 +122,7 @@ const agentSchema = new Schema<IAgent>(
},
);
agentSchema.index({ id: 1, tenantId: 1 }, { unique: true });
agentSchema.index({ updatedAt: -1, _id: 1 });
agentSchema.index({ 'edges.to': 1 });

View file

@ -6,7 +6,6 @@ const convoSchema: Schema<IConversation> = new Schema(
{
conversationId: {
type: String,
unique: true,
required: true,
index: true,
meiliIndex: true,

View file

@ -5,7 +5,6 @@ const messageSchema: Schema<IMessage> = new Schema(
{
messageId: {
type: String,
unique: true,
required: true,
index: true,
meiliIndex: true,

View file

@ -60,7 +60,6 @@ const presetSchema: Schema<IPreset> = new Schema(
{
presetId: {
type: String,
unique: true,
required: true,
index: true,
},
@ -88,4 +87,6 @@ const presetSchema: Schema<IPreset> = new Schema(
{ timestamps: true },
);
presetSchema.index({ presetId: 1, tenantId: 1 }, { unique: true });
export default presetSchema;

View file

@ -1,5 +1,6 @@
export * from './principal';
export * from './string';
export * from './tempChatRetention';
export { tenantSafeBulkWrite } from './tenantBulkWrite';
export * from './transactions';
export * from './objectId';

View file

@ -0,0 +1,376 @@
import mongoose, { Schema } from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { tenantStorage, runAsSystem, SYSTEM_TENANT_ID } from '~/config/tenantContext';
import { applyTenantIsolation, _resetStrictCache } from '~/models/plugins/tenantIsolation';
import { tenantSafeBulkWrite, _resetBulkWriteStrictCache } from './tenantBulkWrite';
let mongoServer: InstanceType<typeof MongoMemoryServer>;
interface ITestDoc {
name: string;
value?: number;
tenantId?: string;
}
function createTestModel(suffix: string) {
const schema = new Schema<ITestDoc>({
name: { type: String, required: true },
value: { type: Number, default: 0 },
tenantId: { type: String, index: true },
});
applyTenantIsolation(schema);
const modelName = `TestBulkWrite_${suffix}_${Date.now()}`;
return mongoose.model<ITestDoc>(modelName, schema);
}
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
afterEach(() => {
delete process.env.TENANT_ISOLATION_STRICT;
_resetStrictCache();
_resetBulkWriteStrictCache();
});
describe('tenantSafeBulkWrite', () => {
describe('with tenant context', () => {
it('injects tenantId into updateOne filters', async () => {
const Model = createTestModel('updateOne');
// Seed data for two tenants
await runAsSystem(async () => {
await Model.create([
{ name: 'doc1', value: 1, tenantId: 'tenant-a' },
{ name: 'doc1', value: 1, tenantId: 'tenant-b' },
]);
});
// Update only tenant-a's doc
await tenantStorage.run({ tenantId: 'tenant-a' }, async () => {
await tenantSafeBulkWrite(Model, [
{
updateOne: {
filter: { name: 'doc1' },
update: { $set: { value: 99 } },
},
},
]);
});
// Verify tenant-a was updated, tenant-b was not
const docs = await runAsSystem(async () => Model.find({}).lean());
const docA = docs.find((d) => d.tenantId === 'tenant-a');
const docB = docs.find((d) => d.tenantId === 'tenant-b');
expect(docA?.value).toBe(99);
expect(docB?.value).toBe(1);
});
it('injects tenantId into insertOne documents', async () => {
const Model = createTestModel('insertOne');
await tenantStorage.run({ tenantId: 'tenant-x' }, async () => {
await tenantSafeBulkWrite(Model, [
{
insertOne: {
document: { name: 'new-doc', value: 42 } as ITestDoc,
},
},
]);
});
const docs = await runAsSystem(async () => Model.find({}).lean());
expect(docs).toHaveLength(1);
expect(docs[0].tenantId).toBe('tenant-x');
expect(docs[0].name).toBe('new-doc');
});
it('injects tenantId into deleteOne filters', async () => {
const Model = createTestModel('deleteOne');
await runAsSystem(async () => {
await Model.create([
{ name: 'to-delete', tenantId: 'tenant-a' },
{ name: 'to-delete', tenantId: 'tenant-b' },
]);
});
await tenantStorage.run({ tenantId: 'tenant-a' }, async () => {
await tenantSafeBulkWrite(Model, [
{
deleteOne: {
filter: { name: 'to-delete' },
},
},
]);
});
const docs = await runAsSystem(async () => Model.find({}).lean());
expect(docs).toHaveLength(1);
expect(docs[0].tenantId).toBe('tenant-b');
});
it('injects tenantId into updateMany filters', async () => {
const Model = createTestModel('updateMany');
await runAsSystem(async () => {
await Model.create([
{ name: 'batch', value: 0, tenantId: 'tenant-a' },
{ name: 'batch', value: 0, tenantId: 'tenant-a' },
{ name: 'batch', value: 0, tenantId: 'tenant-b' },
]);
});
await tenantStorage.run({ tenantId: 'tenant-a' }, async () => {
await tenantSafeBulkWrite(Model, [
{
updateMany: {
filter: { name: 'batch' },
update: { $set: { value: 5 } },
},
},
]);
});
const docs = await runAsSystem(async () => Model.find({}).lean());
const tenantADocs = docs.filter((d) => d.tenantId === 'tenant-a');
const tenantBDocs = docs.filter((d) => d.tenantId === 'tenant-b');
expect(tenantADocs.every((d) => d.value === 5)).toBe(true);
expect(tenantBDocs[0].value).toBe(0);
});
});
describe('with SYSTEM_TENANT_ID', () => {
it('skips tenantId injection (cross-tenant operation)', async () => {
const Model = createTestModel('system');
await runAsSystem(async () => {
await Model.create([
{ name: 'sys-doc', value: 0, tenantId: 'tenant-a' },
{ name: 'sys-doc', value: 0, tenantId: 'tenant-b' },
]);
});
// System context should update ALL docs regardless of tenant
await runAsSystem(async () => {
await tenantSafeBulkWrite(Model, [
{
updateMany: {
filter: { name: 'sys-doc' },
update: { $set: { value: 100 } },
},
},
]);
});
const docs = await runAsSystem(async () => Model.find({}).lean());
expect(docs.every((d) => d.value === 100)).toBe(true);
});
});
describe('with SYSTEM_TENANT_ID in strict mode', () => {
it('does not throw when runAsSystem is used in strict mode', async () => {
process.env.TENANT_ISOLATION_STRICT = 'true';
_resetBulkWriteStrictCache();
const Model = createTestModel('systemStrict');
await runAsSystem(async () => {
await Model.create({ name: 'strict-sys', value: 0 });
});
await expect(
runAsSystem(async () =>
tenantSafeBulkWrite(Model, [
{
updateOne: {
filter: { name: 'strict-sys' },
update: { $set: { value: 42 } },
},
},
]),
),
).resolves.toBeDefined();
});
});
describe('deleteMany and replaceOne', () => {
it('injects tenantId into deleteMany filters', async () => {
const Model = createTestModel('deleteMany');
await runAsSystem(async () => {
await Model.create([
{ name: 'batch-del', value: 0, tenantId: 'tenant-a' },
{ name: 'batch-del', value: 0, tenantId: 'tenant-a' },
{ name: 'batch-del', value: 0, tenantId: 'tenant-b' },
]);
});
await tenantStorage.run({ tenantId: 'tenant-a' }, async () => {
await tenantSafeBulkWrite(Model, [{ deleteMany: { filter: { name: 'batch-del' } } }]);
});
const docs = await runAsSystem(async () => Model.find({}).lean());
expect(docs).toHaveLength(1);
expect(docs[0].tenantId).toBe('tenant-b');
});
it('injects tenantId into replaceOne filter and replacement', async () => {
const Model = createTestModel('replaceOne');
await runAsSystem(async () => {
await Model.create([
{ name: 'to-replace', value: 1, tenantId: 'tenant-a' },
{ name: 'to-replace', value: 1, tenantId: 'tenant-b' },
]);
});
await tenantStorage.run({ tenantId: 'tenant-a' }, async () => {
await tenantSafeBulkWrite(Model, [
{
replaceOne: {
filter: { name: 'to-replace' },
replacement: { name: 'replaced', value: 99 },
},
},
]);
});
const docs = await runAsSystem(async () => Model.find({}).sort({ name: 1 }).lean());
const replaced = docs.find((d) => d.name === 'replaced');
const untouched = docs.find((d) => d.tenantId === 'tenant-b');
expect(replaced?.value).toBe(99);
expect(replaced?.tenantId).toBe('tenant-a');
expect(untouched?.value).toBe(1);
});
it('replaceOne overwrites a conflicting tenantId in the replacement document', async () => {
const Model = createTestModel('replaceOverwrite');
await runAsSystem(async () => {
await Model.create({ name: 'conflict', value: 1, tenantId: 'tenant-a' });
});
await tenantStorage.run({ tenantId: 'tenant-a' }, async () => {
await tenantSafeBulkWrite(Model, [
{
replaceOne: {
filter: { name: 'conflict' },
replacement: { name: 'conflict', value: 2, tenantId: 'tenant-evil' } as ITestDoc,
},
},
]);
});
const docs = await runAsSystem(async () => Model.find({}).lean());
expect(docs).toHaveLength(1);
expect(docs[0].tenantId).toBe('tenant-a');
expect(docs[0].value).toBe(2);
});
});
describe('edge cases', () => {
it('handles empty ops array', async () => {
const Model = createTestModel('emptyOps');
const result = await tenantStorage.run({ tenantId: 'tenant-x' }, async () =>
tenantSafeBulkWrite(Model, []),
);
expect(result.insertedCount).toBe(0);
expect(result.modifiedCount).toBe(0);
});
});
describe('without tenant context', () => {
it('passes through in non-strict mode', async () => {
const Model = createTestModel('noCtx');
await runAsSystem(async () => {
await Model.create({ name: 'no-ctx', value: 0 });
});
// No ALS context — non-strict should pass through
const result = await tenantSafeBulkWrite(Model, [
{
updateOne: {
filter: { name: 'no-ctx' },
update: { $set: { value: 10 } },
},
},
]);
expect(result.modifiedCount).toBe(1);
});
it('throws in strict mode', async () => {
process.env.TENANT_ISOLATION_STRICT = 'true';
_resetBulkWriteStrictCache();
const Model = createTestModel('strict');
await expect(
tenantSafeBulkWrite(Model, [
{
updateOne: {
filter: { name: 'any' },
update: { $set: { value: 1 } },
},
},
]),
).rejects.toThrow('bulkWrite on TestBulkWrite_strict');
});
});
describe('mixed operations', () => {
it('handles a batch of mixed insert, update, delete operations', async () => {
const Model = createTestModel('mixed');
await runAsSystem(async () => {
await Model.create([
{ name: 'existing1', value: 1, tenantId: 'tenant-m' },
{ name: 'to-remove', value: 2, tenantId: 'tenant-m' },
{ name: 'existing1', value: 1, tenantId: 'tenant-other' },
]);
});
await tenantStorage.run({ tenantId: 'tenant-m' }, async () => {
await tenantSafeBulkWrite(Model, [
{
insertOne: {
document: { name: 'new-item', value: 10 } as ITestDoc,
},
},
{
updateOne: {
filter: { name: 'existing1' },
update: { $set: { value: 50 } },
},
},
{
deleteOne: {
filter: { name: 'to-remove' },
},
},
]);
});
const docs = await runAsSystem(async () => Model.find({}).sort({ name: 1 }).lean());
// tenant-other's doc should be untouched
const otherDoc = docs.find((d) => d.tenantId === 'tenant-other' && d.name === 'existing1');
expect(otherDoc?.value).toBe(1);
// tenant-m: existing1 updated, to-remove deleted, new-item inserted
const tenantMDocs = docs.filter((d) => d.tenantId === 'tenant-m');
expect(tenantMDocs).toHaveLength(2);
expect(tenantMDocs.find((d) => d.name === 'existing1')?.value).toBe(50);
expect(tenantMDocs.find((d) => d.name === 'new-item')?.value).toBe(10);
expect(tenantMDocs.find((d) => d.name === 'to-remove')).toBeUndefined();
});
});
});

View file

@ -0,0 +1,109 @@
import type { AnyBulkWriteOperation, Model, MongooseBulkWriteOptions } from 'mongoose';
import type { BulkWriteResult } from 'mongodb';
import { getTenantId, SYSTEM_TENANT_ID } from '~/config/tenantContext';
import logger from '~/config/winston';
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 _resetBulkWriteStrictCache(): void {
_strictMode = undefined;
}
/**
* Tenant-safe wrapper around Mongoose `Model.bulkWrite()`.
*
* Mongoose's `bulkWrite` does not trigger schema-level middleware hooks, so the
* `applyTenantIsolation` plugin cannot intercept it. This wrapper injects the
* current ALS tenant context into every operation's filter and/or document
* before delegating to the native `bulkWrite`.
*
* Behavior:
* - **tenantId present** (normal request): injects `{ tenantId }` into every
* operation filter (updateOne, deleteOne, replaceOne) and document (insertOne).
* - **SYSTEM_TENANT_ID**: skips injection (cross-tenant system operation).
* - **No tenantId + strict mode**: throws (fail-closed, same as the plugin).
* - **No tenantId + non-strict**: passes through without injection (backward compat).
*/
export async function tenantSafeBulkWrite<T>(
model: Model<T>,
ops: AnyBulkWriteOperation[],
options?: MongooseBulkWriteOptions,
): Promise<BulkWriteResult> {
const tenantId = getTenantId();
if (!tenantId) {
if (isStrict()) {
throw new Error(
`[TenantIsolation] bulkWrite on ${model.modelName} attempted without tenant context in strict mode`,
);
}
return model.bulkWrite(ops, options);
}
if (tenantId === SYSTEM_TENANT_ID) {
return model.bulkWrite(ops, options);
}
const injected = ops.map((op) => injectTenantId(op, tenantId));
return model.bulkWrite(injected, options);
}
/**
* Injects `tenantId` into a single bulk-write operation.
* Returns a new operation object does not mutate the original.
*/
function injectTenantId(op: AnyBulkWriteOperation, tenantId: string): AnyBulkWriteOperation {
if ('insertOne' in op) {
return {
insertOne: {
document: { ...op.insertOne.document, tenantId },
},
};
}
if ('updateOne' in op) {
const { filter, ...rest } = op.updateOne;
return { updateOne: { ...rest, filter: { ...filter, tenantId } } };
}
if ('updateMany' in op) {
const { filter, ...rest } = op.updateMany;
return { updateMany: { ...rest, filter: { ...filter, tenantId } } };
}
if ('deleteOne' in op) {
const { filter, ...rest } = op.deleteOne;
return { deleteOne: { ...rest, filter: { ...filter, tenantId } } };
}
if ('deleteMany' in op) {
const { filter, ...rest } = op.deleteMany;
return { deleteMany: { ...rest, filter: { ...filter, tenantId } } };
}
if ('replaceOne' in op) {
const { filter, replacement, ...rest } = op.replaceOne;
return {
replaceOne: {
...rest,
filter: { ...filter, tenantId },
replacement: { ...replacement, tenantId },
},
};
}
if (isStrict()) {
throw new Error(
'[TenantIsolation] Unknown bulkWrite operation type in strict mode — refusing to pass through without tenant injection',
);
}
logger.warn(
'[tenantSafeBulkWrite] Unknown bulk op type, passing through without tenant injection',
);
return op;
}