From 428ef2eb1503227f69962b286a1e6719184aba09 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 7 Mar 2026 16:37:10 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=A2=20feat:=20Multi-Tenant=20Data=20Is?= =?UTF-8?q?olation=20Infrastructure=20(#12091)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: imports * chore: optional chaining in `spendTokens.spec.ts` * feat: Add tenantId field to all MongoDB schemas for multi-tenant isolation - Add AsyncLocalStorage-based tenant context (`tenantContext.ts`) for request-scoped tenantId propagation without modifying method signatures - Add Mongoose `applyTenantIsolation` plugin that injects `{ tenantId }` into all query filters when tenant context is present, with `TENANT_ISOLATION_STRICT` env var for fail-closed production mode - Add optional `tenantId` field to all 28 collection schemas - Update all compound unique indexes to include tenantId (email, OAuth IDs, role names, serverName, conversationId+user, messageId+user, etc.) - Apply tenant isolation plugin in all 28 model factories - Add `tenantId?: string` to all TypeScript document interfaces Behaviorally inert — transitional mode (default) passes through all queries unchanged. No migration required for existing deployments. * refactor: Update tenant context and enhance tenant isolation plugin - Changed `tenantId` in `TenantContext` to be optional, allowing for more flexible usage. - Refactored `runAsSystem` function to accept synchronous functions, improving usability. - Introduced comprehensive tests for the `applyTenantIsolation` plugin, ensuring correct tenant filtering in various query scenarios. - Enhanced the plugin to handle aggregate queries and save operations with tenant context, improving data isolation capabilities. * docs: tenant context documentation and improve tenant isolation tests - Added detailed documentation for the `tenantStorage` AsyncLocalStorage instance in `tenantContext.ts`, clarifying its usage for async tenant context propagation. - Updated tests in `tenantIsolation.spec.ts` to improve clarity and coverage, including new tests for strict mode behavior and tenant context propagation through await boundaries. - Refactored existing test cases for better readability and consistency, ensuring robust validation of tenant isolation functionality. * feat: Enhance tenant isolation by preventing tenantId mutations in update operations - Added a new function to assert that tenantId cannot be modified through update operators in Mongoose queries. - Implemented middleware to enforce this restriction during findOneAndUpdate, updateOne, and updateMany operations. - Updated documentation to reflect the new behavior regarding tenantId modifications, ensuring clarity on tenant isolation rules. * feat: Enhance tenant isolation tests and enforce tenantId restrictions - Updated existing tests to clarify behavior regarding tenantId preservation during save and insertMany operations. - Introduced new tests to validate that tenantId cannot be modified through update operations, ensuring strict adherence to tenant isolation rules. - Added checks for mismatched tenantId scenarios, reinforcing the integrity of tenant context propagation. - Enhanced test coverage for async context propagation and mutation guards, improving overall robustness of tenant isolation functionality. * fix: Remove duplicate re-exports in utils/index.ts Merge artifact caused `string` and `tempChatRetention` to be exported twice, which produces TypeScript compile errors for duplicate bindings. * fix: Resolve admin capability gap in multi-tenant mode (TODO #12091) - hasCapabilityForPrincipals now queries both tenant-scoped AND platform-level grants when tenantId is set, so seeded ADMIN grants remain effective in tenant mode. - Add applyTenantIsolation to SystemGrant model factory. * fix: Harden tenant isolation plugin - Add replaceGuard for replaceOne/findOneAndReplace to prevent cross-tenant document reassignment via replacement documents. - Cache isStrict() result to avoid process.env reads on every query. Export _resetStrictCache() for test teardown. - Replace console.warn with project logger (winston). - Add 5 new tests for replace guard behavior (46 total). * style: Fix import ordering in convo.ts and message.ts Move type imports after value imports per project style guide. * fix: Remove tenant isolation from SystemGrant, stamp tenantId in replaceGuard - SystemGrant is a cross-tenant control plane whose methods handle tenantId conditions explicitly. Applying the isolation plugin injects a hard equality filter that overrides the $and/$or logic in hasCapabilityForPrincipals, making platform-level ADMIN grants invisible in tenant mode. - replaceGuard now stamps tenantId into replacement documents when absent, preventing replaceOne from silently stripping tenant context. Replacements with a matching tenantId are allowed; mismatched tenantId still throws. * test: Add multi-tenant unique constraint and replace stamping tests - Verify same name/email can exist in different tenants (compound unique index allows it). - Verify duplicate within same tenant is rejected (E11000). - Verify tenant-scoped query returns only the correct document. - Update replaceOne test to assert tenantId is stamped into replacement document. - Add test for replacement with matching tenantId. * style: Reorder imports in message.ts to align with project style guide * feat: Add migration to drop superseded unique indexes for multi-tenancy Existing deployments have single-field unique indexes (e.g. { email: 1 }) that block multi-tenant operation — same email in different tenants triggers E11000. Mongoose autoIndex creates the new compound indexes but never drops the old ones. dropSupersededTenantIndexes() drops all 19 superseded indexes across 11 collections. It is idempotent, skips missing indexes/collections, and is a no-op on fresh databases. Must be called before enabling multi-tenant middleware on an existing deployment. Single-tenant deployments are unaffected (old indexes coexist harmlessly until migration runs). Includes 11 tests covering: - Full upgrade simulation (create old indexes, drop them, verify gone) - Multi-tenant writes work after migration (same email, different tenant) - Intra-tenant uniqueness preserved (duplicate within tenant rejected) - Fresh database (no-op, no errors) - Partial migration (some collections exist, some don't) - SUPERSEDED_INDEXES coverage validation * fix: Update systemGrant test — platform grants now satisfy tenant queries The TODO #12091 fix intentionally changed hasCapabilityForPrincipals to match both tenant-scoped AND platform-level grants. The test expected the old behavior (platform grant invisible to tenant query). Updated test name and expectation to match the new semantics. * fix: Align getCapabilitiesForPrincipal with hasCapabilityForPrincipals tenant query getCapabilitiesForPrincipal used a hard tenantId equality filter while hasCapabilityForPrincipals uses $and/$or to match both tenant-scoped and platform-level grants. This caused the two functions to disagree on what grants a principal holds in tenant mode. Apply the same $or pattern: when tenantId is provided, match both { tenantId } and { tenantId: { $exists: false } }. Adds test verifying platform-level ADMIN grants appear in getCapabilitiesForPrincipal when called with a tenantId. * fix: Remove categories from tenant index migration categoriesSchema is exported but never used to create a Mongoose model. No Category model factory exists, no code constructs a model from it, and no categories collection exists in production databases. Including it in the migration would attempt to drop indexes from a non-existent collection (harmlessly skipped) but implies the collection is managed. * fix: Restrict runAsSystem to async callbacks only Sync callbacks returning Mongoose thenables silently lose ALS context — the system bypass does nothing and strict mode throws with no indication runAsSystem was involved. Narrowing to () => Promise makes the wrong pattern a compile error. All existing call sites already use async. * fix: Use next(err) consistently in insertMany pre-hook The hook accepted a next callback but used throw for errors. Standardize on next(err) for all error paths so the hook speaks one language — callback-style throughout. * fix: Replace optional chaining with explicit null assertions in spendTokens tests Optional chaining on test assertions masks failures with unintelligible error messages. Add expect(result).not.toBeNull() before accessing properties, so a null result produces a clear diagnosis instead of "received value must be a number". --- .../data-schemas/src/config/tenantContext.ts | 28 + packages/data-schemas/src/index.ts | 3 + .../src/methods/spendTokens.spec.ts | 15 +- .../src/methods/systemGrant.spec.ts | 17 +- .../data-schemas/src/methods/systemGrant.ts | 12 +- packages/data-schemas/src/migrations/index.ts | 1 + .../src/migrations/tenantIndexes.spec.ts | 286 ++++++++ .../src/migrations/tenantIndexes.ts | 102 +++ .../data-schemas/src/models/accessRole.ts | 5 +- packages/data-schemas/src/models/aclEntry.ts | 5 +- packages/data-schemas/src/models/action.ts | 5 +- packages/data-schemas/src/models/agent.ts | 5 +- .../data-schemas/src/models/agentApiKey.ts | 2 + .../data-schemas/src/models/agentCategory.ts | 5 +- packages/data-schemas/src/models/assistant.ts | 5 +- packages/data-schemas/src/models/balance.ts | 5 +- packages/data-schemas/src/models/banner.ts | 5 +- .../src/models/conversationTag.ts | 5 +- packages/data-schemas/src/models/convo.ts | 5 +- packages/data-schemas/src/models/file.ts | 5 +- packages/data-schemas/src/models/group.ts | 5 +- packages/data-schemas/src/models/key.ts | 5 +- packages/data-schemas/src/models/mcpServer.ts | 5 +- packages/data-schemas/src/models/memory.ts | 2 + packages/data-schemas/src/models/message.ts | 5 +- .../data-schemas/src/models/pluginAuth.ts | 5 +- .../models/plugins/tenantIsolation.spec.ts | 660 ++++++++++++++++++ .../src/models/plugins/tenantIsolation.ts | 177 +++++ packages/data-schemas/src/models/preset.ts | 5 +- packages/data-schemas/src/models/prompt.ts | 5 +- .../data-schemas/src/models/promptGroup.ts | 5 +- packages/data-schemas/src/models/role.ts | 5 +- packages/data-schemas/src/models/session.ts | 5 +- .../data-schemas/src/models/sharedLink.ts | 5 +- .../data-schemas/src/models/systemGrant.ts | 7 +- packages/data-schemas/src/models/token.ts | 5 +- packages/data-schemas/src/models/toolCall.ts | 5 +- .../data-schemas/src/models/transaction.ts | 5 +- packages/data-schemas/src/models/user.ts | 5 +- .../data-schemas/src/schema/accessRole.ts | 7 +- packages/data-schemas/src/schema/aclEntry.ts | 16 +- packages/data-schemas/src/schema/action.ts | 4 + packages/data-schemas/src/schema/agent.ts | 4 + .../data-schemas/src/schema/agentApiKey.ts | 7 +- .../data-schemas/src/schema/agentCategory.ts | 6 +- packages/data-schemas/src/schema/assistant.ts | 4 + packages/data-schemas/src/schema/balance.ts | 4 + packages/data-schemas/src/schema/banner.ts | 5 + .../data-schemas/src/schema/categories.ts | 10 +- .../src/schema/conversationTag.ts | 7 +- packages/data-schemas/src/schema/convo.ts | 6 +- packages/data-schemas/src/schema/file.ts | 6 +- packages/data-schemas/src/schema/group.ts | 6 +- packages/data-schemas/src/schema/key.ts | 5 + packages/data-schemas/src/schema/mcpServer.ts | 6 +- packages/data-schemas/src/schema/memory.ts | 4 + packages/data-schemas/src/schema/message.ts | 6 +- .../data-schemas/src/schema/pluginAuth.ts | 4 + packages/data-schemas/src/schema/preset.ts | 5 + packages/data-schemas/src/schema/prompt.ts | 4 + .../data-schemas/src/schema/promptGroup.ts | 4 + packages/data-schemas/src/schema/role.ts | 8 +- packages/data-schemas/src/schema/session.ts | 4 + packages/data-schemas/src/schema/share.ts | 7 +- packages/data-schemas/src/schema/token.ts | 4 + packages/data-schemas/src/schema/toolCall.ts | 9 +- .../data-schemas/src/schema/transaction.ts | 5 + packages/data-schemas/src/schema/user.ts | 41 +- packages/data-schemas/src/types/accessRole.ts | 1 + packages/data-schemas/src/types/aclEntry.ts | 1 + packages/data-schemas/src/types/action.ts | 1 + packages/data-schemas/src/types/agent.ts | 1 + .../data-schemas/src/types/agentApiKey.ts | 1 + .../data-schemas/src/types/agentCategory.ts | 1 + packages/data-schemas/src/types/assistant.ts | 1 + packages/data-schemas/src/types/balance.ts | 1 + packages/data-schemas/src/types/banner.ts | 1 + packages/data-schemas/src/types/convo.ts | 1 + packages/data-schemas/src/types/file.ts | 1 + packages/data-schemas/src/types/group.ts | 1 + packages/data-schemas/src/types/mcp.ts | 1 + packages/data-schemas/src/types/memory.ts | 1 + packages/data-schemas/src/types/message.ts | 1 + packages/data-schemas/src/types/pluginAuth.ts | 1 + packages/data-schemas/src/types/prompts.ts | 2 + packages/data-schemas/src/types/role.ts | 1 + packages/data-schemas/src/types/session.ts | 1 + packages/data-schemas/src/types/token.ts | 1 + packages/data-schemas/src/types/user.ts | 1 + 89 files changed, 1539 insertions(+), 133 deletions(-) create mode 100644 packages/data-schemas/src/config/tenantContext.ts create mode 100644 packages/data-schemas/src/migrations/index.ts create mode 100644 packages/data-schemas/src/migrations/tenantIndexes.spec.ts create mode 100644 packages/data-schemas/src/migrations/tenantIndexes.ts create mode 100644 packages/data-schemas/src/models/plugins/tenantIsolation.spec.ts create mode 100644 packages/data-schemas/src/models/plugins/tenantIsolation.ts diff --git a/packages/data-schemas/src/config/tenantContext.ts b/packages/data-schemas/src/config/tenantContext.ts new file mode 100644 index 0000000000..e5e4376a90 --- /dev/null +++ b/packages/data-schemas/src/config/tenantContext.ts @@ -0,0 +1,28 @@ +import { AsyncLocalStorage } from 'async_hooks'; + +export interface TenantContext { + tenantId?: string; +} + +/** Sentinel value for deliberate cross-tenant system operations */ +export const SYSTEM_TENANT_ID = '__SYSTEM__'; + +/** + * AsyncLocalStorage instance for propagating tenant context. + * Callbacks passed to `tenantStorage.run()` must be `async` for the context to propagate + * through Mongoose query execution. Sync callbacks returning a Mongoose thenable will lose context. + */ +export const tenantStorage = new AsyncLocalStorage(); + +/** Returns the current tenant ID from async context, or undefined if none is set */ +export function getTenantId(): string | undefined { + return tenantStorage.getStore()?.tenantId; +} + +/** + * Runs a function in an explicit cross-tenant system context (bypasses tenant filtering). + * The callback MUST be async — sync callbacks returning Mongoose thenables will lose context. + */ +export function runAsSystem(fn: () => Promise): Promise { + return tenantStorage.run({ tenantId: SYSTEM_TENANT_ID }, fn); +} diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts index 3a34b574ae..485599c6f7 100644 --- a/packages/data-schemas/src/index.ts +++ b/packages/data-schemas/src/index.ts @@ -18,3 +18,6 @@ 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 type { TenantContext } from './config/tenantContext'; +export { dropSupersededTenantIndexes } from './migrations'; diff --git a/packages/data-schemas/src/methods/spendTokens.spec.ts b/packages/data-schemas/src/methods/spendTokens.spec.ts index 58e5f4a0ab..5730bc7bdd 100644 --- a/packages/data-schemas/src/methods/spendTokens.spec.ts +++ b/packages/data-schemas/src/methods/spendTokens.spec.ts @@ -863,8 +863,9 @@ describe('spendTokens', () => { tokenUsage.promptTokens.read * (readRate ?? 0); const expectedCompletionCost = tokenUsage.completionTokens * premiumCompletionRate; - expect(result?.prompt?.prompt).toBeCloseTo(-expectedPromptCost, 0); - expect(result?.completion?.completion).toBeCloseTo(-expectedCompletionCost, 0); + expect(result).not.toBeNull(); + expect(result!.prompt.prompt).toBeCloseTo(-expectedPromptCost, 0); + expect(result!.completion.completion).toBeCloseTo(-expectedCompletionCost, 0); }); it('should charge standard rates for structured tokens when below threshold', async () => { @@ -905,8 +906,9 @@ describe('spendTokens', () => { tokenUsage.promptTokens.read * (readRate ?? 0); const expectedCompletionCost = tokenUsage.completionTokens * standardCompletionRate; - expect(result?.prompt?.prompt).toBeCloseTo(-expectedPromptCost, 0); - expect(result?.completion?.completion).toBeCloseTo(-expectedCompletionCost, 0); + expect(result).not.toBeNull(); + expect(result!.prompt.prompt).toBeCloseTo(-expectedPromptCost, 0); + expect(result!.completion.completion).toBeCloseTo(-expectedCompletionCost, 0); }); it('should charge standard rates for gemini-3.1-pro-preview when prompt tokens are below threshold', async () => { @@ -1034,8 +1036,9 @@ describe('spendTokens', () => { tokenUsage.promptTokens.read * readRate; const expectedCompletionCost = tokenUsage.completionTokens * premiumCompletionRate; - expect(result.prompt.prompt).toBeCloseTo(-expectedPromptCost, 0); - expect(result.completion.completion).toBeCloseTo(-expectedCompletionCost, 0); + expect(result).not.toBeNull(); + expect(result!.prompt.prompt).toBeCloseTo(-expectedPromptCost, 0); + expect(result!.completion.completion).toBeCloseTo(-expectedCompletionCost, 0); }); it('should not apply premium pricing to non-premium models regardless of prompt size', async () => { diff --git a/packages/data-schemas/src/methods/systemGrant.spec.ts b/packages/data-schemas/src/methods/systemGrant.spec.ts index fb886c74d3..188d31b544 100644 --- a/packages/data-schemas/src/methods/systemGrant.spec.ts +++ b/packages/data-schemas/src/methods/systemGrant.spec.ts @@ -560,7 +560,7 @@ describe('systemGrant methods', () => { expect(result).toBe(false); }); - it('platform-level grant does not match tenant-scoped query', async () => { + it('platform-level grant satisfies tenant-scoped query', async () => { const userId = new Types.ObjectId(); await methods.grantCapability({ @@ -574,7 +574,7 @@ describe('systemGrant methods', () => { capability: SystemCapabilities.READ_CONFIGS, tenantId: 'tenant-1', }); - expect(result).toBe(false); + expect(result).toBe(true); }); it('tenant-scoped grant matches same-tenant query', async () => { @@ -679,6 +679,19 @@ describe('systemGrant methods', () => { expect(grants[0].capability).toBe(SystemCapabilities.READ_CONFIGS); }); + it('includes platform-level grants when called with a tenantId', async () => { + await methods.seedSystemGrants(); + + const grants = await methods.getCapabilitiesForPrincipal({ + principalType: PrincipalType.ROLE, + principalId: SystemRoles.ADMIN, + tenantId: 'acme', + }); + + expect(grants.some((g) => g.capability === SystemCapabilities.ACCESS_ADMIN)).toBe(true); + expect(grants).toHaveLength(Object.values(SystemCapabilities).length); + }); + it('throws TypeError for invalid ObjectId string on USER principal', async () => { await expect( methods.getCapabilitiesForPrincipal({ diff --git a/packages/data-schemas/src/methods/systemGrant.ts b/packages/data-schemas/src/methods/systemGrant.ts index f45d9fde9d..f0f389d762 100644 --- a/packages/data-schemas/src/methods/systemGrant.ts +++ b/packages/data-schemas/src/methods/systemGrant.ts @@ -54,16 +54,8 @@ export function createSystemGrantMethods(mongoose: typeof import('mongoose')) { capability: capabilityQuery, }; - /* - * TODO(#12091): In multi-tenant mode, platform-level grants (tenantId absent) - * should also satisfy tenant-scoped checks so that seeded ADMIN grants remain - * effective. When tenantId is set, query both tenant-scoped AND platform-level: - * query.$or = [{ tenantId }, { tenantId: { $exists: false } }] - * Also: getUserPrincipals currently has no tenantId param, so group memberships - * are returned across all tenants. Filter by tenant there too. - */ if (tenantId != null) { - query.tenantId = tenantId; + query.$and = [{ $or: [{ tenantId }, { tenantId: { $exists: false } }] }]; } else { query.tenantId = { $exists: false }; } @@ -194,7 +186,7 @@ export function createSystemGrantMethods(mongoose: typeof import('mongoose')) { }; if (tenantId != null) { - filter.tenantId = tenantId; + filter.$or = [{ tenantId }, { tenantId: { $exists: false } }]; } else { filter.tenantId = { $exists: false }; } diff --git a/packages/data-schemas/src/migrations/index.ts b/packages/data-schemas/src/migrations/index.ts new file mode 100644 index 0000000000..165b34dbf8 --- /dev/null +++ b/packages/data-schemas/src/migrations/index.ts @@ -0,0 +1 @@ +export { dropSupersededTenantIndexes } from './tenantIndexes'; diff --git a/packages/data-schemas/src/migrations/tenantIndexes.spec.ts b/packages/data-schemas/src/migrations/tenantIndexes.spec.ts new file mode 100644 index 0000000000..4637e7d0ad --- /dev/null +++ b/packages/data-schemas/src/migrations/tenantIndexes.spec.ts @@ -0,0 +1,286 @@ +import mongoose, { Schema } from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { dropSupersededTenantIndexes, SUPERSEDED_INDEXES } from './tenantIndexes'; + +jest.mock('~/config/winston', () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +})); + +let mongoServer: InstanceType; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +describe('dropSupersededTenantIndexes', () => { + describe('with pre-existing single-field unique indexes (simulates upgrade)', () => { + beforeAll(async () => { + const db = mongoose.connection.db; + + await db.createCollection('users'); + const users = db.collection('users'); + await users.createIndex({ email: 1 }, { unique: true, name: 'email_1' }); + await users.createIndex({ googleId: 1 }, { unique: true, sparse: true, name: 'googleId_1' }); + await users.createIndex( + { facebookId: 1 }, + { unique: true, sparse: true, name: 'facebookId_1' }, + ); + await users.createIndex({ openidId: 1 }, { unique: true, sparse: true, name: 'openidId_1' }); + await users.createIndex({ samlId: 1 }, { unique: true, sparse: true, name: 'samlId_1' }); + await users.createIndex({ ldapId: 1 }, { unique: true, sparse: true, name: 'ldapId_1' }); + await users.createIndex({ githubId: 1 }, { unique: true, sparse: true, name: 'githubId_1' }); + await users.createIndex( + { discordId: 1 }, + { unique: true, sparse: true, name: 'discordId_1' }, + ); + await users.createIndex({ appleId: 1 }, { unique: true, sparse: true, name: 'appleId_1' }); + + await db.createCollection('roles'); + await db.collection('roles').createIndex({ name: 1 }, { unique: true, name: 'name_1' }); + + await db.createCollection('conversations'); + await db + .collection('conversations') + .createIndex( + { conversationId: 1, user: 1 }, + { unique: true, name: 'conversationId_1_user_1' }, + ); + + await db.createCollection('messages'); + await db + .collection('messages') + .createIndex({ messageId: 1, user: 1 }, { unique: true, name: 'messageId_1_user_1' }); + + await db.createCollection('agentcategories'); + await db + .collection('agentcategories') + .createIndex({ value: 1 }, { unique: true, name: 'value_1' }); + + await db.createCollection('accessroles'); + await db + .collection('accessroles') + .createIndex({ accessRoleId: 1 }, { unique: true, name: 'accessRoleId_1' }); + + await db.createCollection('conversationtags'); + await db + .collection('conversationtags') + .createIndex({ tag: 1, user: 1 }, { unique: true, name: 'tag_1_user_1' }); + + await db.createCollection('mcpservers'); + await db + .collection('mcpservers') + .createIndex({ serverName: 1 }, { unique: true, name: 'serverName_1' }); + + await db.createCollection('files'); + await db + .collection('files') + .createIndex( + { filename: 1, conversationId: 1, context: 1 }, + { unique: true, name: 'filename_1_conversationId_1_context_1' }, + ); + + await db.createCollection('groups'); + await db + .collection('groups') + .createIndex( + { idOnTheSource: 1, source: 1 }, + { unique: true, name: 'idOnTheSource_1_source_1' }, + ); + }); + + it('drops all superseded indexes', async () => { + const result = await dropSupersededTenantIndexes(mongoose.connection); + + expect(result.errors).toHaveLength(0); + expect(result.dropped.length).toBeGreaterThan(0); + + const totalExpected = Object.values(SUPERSEDED_INDEXES).reduce( + (sum, arr) => sum + arr.length, + 0, + ); + expect(result.dropped).toHaveLength(totalExpected); + }); + + it('reports no superseded indexes on second run (idempotent)', async () => { + const result = await dropSupersededTenantIndexes(mongoose.connection); + + expect(result.errors).toHaveLength(0); + expect(result.dropped).toHaveLength(0); + expect(result.skipped.length).toBeGreaterThan(0); + }); + + it('old unique indexes are actually gone from users collection', async () => { + const indexes = await mongoose.connection.db.collection('users').indexes(); + const indexNames = indexes.map((idx) => idx.name); + + expect(indexNames).not.toContain('email_1'); + expect(indexNames).not.toContain('googleId_1'); + expect(indexNames).not.toContain('openidId_1'); + expect(indexNames).toContain('_id_'); + }); + + it('old unique indexes are actually gone from roles collection', async () => { + const indexes = await mongoose.connection.db.collection('roles').indexes(); + const indexNames = indexes.map((idx) => idx.name); + + expect(indexNames).not.toContain('name_1'); + }); + + it('old compound unique indexes are gone from conversations collection', async () => { + const indexes = await mongoose.connection.db.collection('conversations').indexes(); + const indexNames = indexes.map((idx) => idx.name); + + expect(indexNames).not.toContain('conversationId_1_user_1'); + }); + }); + + describe('multi-tenant writes after migration', () => { + beforeAll(async () => { + const db = mongoose.connection.db; + + const users = db.collection('users'); + await users.createIndex( + { email: 1, tenantId: 1 }, + { unique: true, name: 'email_1_tenantId_1' }, + ); + }); + + it('allows same email in different tenants after old index is dropped', async () => { + const users = mongoose.connection.db.collection('users'); + + await users.insertOne({ + email: 'shared@example.com', + tenantId: 'tenant-a', + name: 'User A', + }); + await users.insertOne({ + email: 'shared@example.com', + tenantId: 'tenant-b', + name: 'User B', + }); + + const countA = await users.countDocuments({ + email: 'shared@example.com', + tenantId: 'tenant-a', + }); + const countB = await users.countDocuments({ + email: 'shared@example.com', + tenantId: 'tenant-b', + }); + + expect(countA).toBe(1); + expect(countB).toBe(1); + }); + + it('still rejects duplicate email within same tenant', async () => { + const users = mongoose.connection.db.collection('users'); + + await users.insertOne({ + email: 'unique-within@example.com', + tenantId: 'tenant-dup', + name: 'First', + }); + + await expect( + users.insertOne({ + email: 'unique-within@example.com', + tenantId: 'tenant-dup', + name: 'Second', + }), + ).rejects.toThrow(/E11000|duplicate key/); + }); + }); + + describe('on a fresh database (no pre-existing collections)', () => { + let freshServer: InstanceType; + let freshConnection: mongoose.Connection; + + beforeAll(async () => { + freshServer = await MongoMemoryServer.create(); + freshConnection = mongoose.createConnection(freshServer.getUri()); + await freshConnection.asPromise(); + }); + + afterAll(async () => { + await freshConnection.close(); + await freshServer.stop(); + }); + + it('skips all indexes gracefully (no errors)', async () => { + const result = await dropSupersededTenantIndexes(freshConnection); + + expect(result.errors).toHaveLength(0); + expect(result.dropped).toHaveLength(0); + expect(result.skipped.length).toBeGreaterThan(0); + }); + }); + + describe('partial migration (some indexes exist, some do not)', () => { + let partialServer: InstanceType; + let partialConnection: mongoose.Connection; + + beforeAll(async () => { + partialServer = await MongoMemoryServer.create(); + partialConnection = mongoose.createConnection(partialServer.getUri()); + await partialConnection.asPromise(); + + const db = partialConnection.db; + await db.createCollection('users'); + await db.collection('users').createIndex({ email: 1 }, { unique: true, name: 'email_1' }); + }); + + afterAll(async () => { + await partialConnection.close(); + await partialServer.stop(); + }); + + it('drops existing indexes and skips missing ones', async () => { + const result = await dropSupersededTenantIndexes(partialConnection); + + expect(result.errors).toHaveLength(0); + expect(result.dropped).toContain('users.email_1'); + expect(result.skipped.length).toBeGreaterThan(0); + + const skippedCollections = result.skipped.filter((s) => s.includes('does not exist')); + expect(skippedCollections.length).toBeGreaterThan(0); + }); + }); + + describe('SUPERSEDED_INDEXES coverage', () => { + it('covers all collections with unique index changes', () => { + const expectedCollections = [ + 'users', + 'roles', + 'conversations', + 'messages', + 'agentcategories', + 'accessroles', + 'conversationtags', + 'mcpservers', + 'files', + 'groups', + ]; + + for (const col of expectedCollections) { + expect(SUPERSEDED_INDEXES).toHaveProperty(col); + expect(SUPERSEDED_INDEXES[col].length).toBeGreaterThan(0); + } + }); + + it('users collection lists all 9 OAuth ID indexes plus email', () => { + expect(SUPERSEDED_INDEXES.users).toHaveLength(9); + expect(SUPERSEDED_INDEXES.users).toContain('email_1'); + expect(SUPERSEDED_INDEXES.users).toContain('googleId_1'); + expect(SUPERSEDED_INDEXES.users).toContain('openidId_1'); + }); + }); +}); diff --git a/packages/data-schemas/src/migrations/tenantIndexes.ts b/packages/data-schemas/src/migrations/tenantIndexes.ts new file mode 100644 index 0000000000..c68df4db2b --- /dev/null +++ b/packages/data-schemas/src/migrations/tenantIndexes.ts @@ -0,0 +1,102 @@ +import type { Connection } from 'mongoose'; +import logger from '~/config/winston'; + +/** + * Indexes that were superseded by compound tenant-scoped indexes. + * Each entry maps a collection name to the old index names that must be dropped + * before multi-tenancy can function (old unique indexes enforce global uniqueness, + * blocking same-value-different-tenant writes). + * + * These are only the indexes whose uniqueness constraints conflict with multi-tenancy. + * Non-unique indexes that were extended with tenantId are harmless (queries still work, + * just with slightly less optimal plans) and are not included here. + */ +const SUPERSEDED_INDEXES: Record = { + users: [ + 'email_1', + 'googleId_1', + 'facebookId_1', + 'openidId_1', + 'samlId_1', + 'ldapId_1', + 'githubId_1', + 'discordId_1', + 'appleId_1', + ], + roles: ['name_1'], + conversations: ['conversationId_1_user_1'], + messages: ['messageId_1_user_1'], + agentcategories: ['value_1'], + accessroles: ['accessRoleId_1'], + conversationtags: ['tag_1_user_1'], + mcpservers: ['serverName_1'], + files: ['filename_1_conversationId_1_context_1'], + groups: ['idOnTheSource_1_source_1'], +}; + +interface MigrationResult { + dropped: string[]; + skipped: string[]; + errors: string[]; +} + +/** + * Drops superseded unique indexes that block multi-tenant operation. + * Idempotent — skips indexes that don't exist. Safe to run on fresh databases. + * + * Call this before enabling multi-tenant middleware on an existing deployment. + * On a fresh database (no pre-existing data), this is a no-op. + */ +export async function dropSupersededTenantIndexes( + connection: Connection, +): Promise { + const result: MigrationResult = { dropped: [], skipped: [], errors: [] }; + + for (const [collectionName, indexNames] of Object.entries(SUPERSEDED_INDEXES)) { + const collection = connection.db.collection(collectionName); + + let existingIndexes: Array<{ name?: string }>; + try { + existingIndexes = await collection.indexes(); + } catch { + result.skipped.push( + ...indexNames.map((idx) => `${collectionName}.${idx} (collection does not exist)`), + ); + continue; + } + + const existingNames = new Set(existingIndexes.map((idx) => idx.name)); + + for (const indexName of indexNames) { + if (!existingNames.has(indexName)) { + result.skipped.push(`${collectionName}.${indexName}`); + continue; + } + + try { + await collection.dropIndex(indexName); + result.dropped.push(`${collectionName}.${indexName}`); + logger.info(`[TenantMigration] Dropped superseded index: ${collectionName}.${indexName}`); + } catch (err) { + const msg = `${collectionName}.${indexName}: ${(err as Error).message}`; + result.errors.push(msg); + logger.error(`[TenantMigration] Failed to drop index: ${msg}`); + } + } + } + + if (result.dropped.length > 0) { + logger.info( + `[TenantMigration] Migration complete. Dropped ${result.dropped.length} superseded indexes.`, + ); + } else { + logger.info( + '[TenantMigration] No superseded indexes found — database is already migrated or fresh.', + ); + } + + return result; +} + +/** Exported for testing — the raw index map */ +export { SUPERSEDED_INDEXES }; diff --git a/packages/data-schemas/src/models/accessRole.ts b/packages/data-schemas/src/models/accessRole.ts index 5da1e41dda..cd2c8b691c 100644 --- a/packages/data-schemas/src/models/accessRole.ts +++ b/packages/data-schemas/src/models/accessRole.ts @@ -1,10 +1,9 @@ import accessRoleSchema from '~/schema/accessRole'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import type * as t from '~/types'; -/** - * Creates or returns the AccessRole model using the provided mongoose instance and schema - */ export function createAccessRoleModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(accessRoleSchema); return ( mongoose.models.AccessRole || mongoose.model('AccessRole', accessRoleSchema) ); diff --git a/packages/data-schemas/src/models/aclEntry.ts b/packages/data-schemas/src/models/aclEntry.ts index 62028d2a78..195b328438 100644 --- a/packages/data-schemas/src/models/aclEntry.ts +++ b/packages/data-schemas/src/models/aclEntry.ts @@ -1,9 +1,8 @@ import aclEntrySchema from '~/schema/aclEntry'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import type * as t from '~/types'; -/** - * Creates or returns the AclEntry model using the provided mongoose instance and schema - */ export function createAclEntryModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(aclEntrySchema); return mongoose.models.AclEntry || mongoose.model('AclEntry', aclEntrySchema); } diff --git a/packages/data-schemas/src/models/action.ts b/packages/data-schemas/src/models/action.ts index 4778222460..421610ab73 100644 --- a/packages/data-schemas/src/models/action.ts +++ b/packages/data-schemas/src/models/action.ts @@ -1,9 +1,8 @@ import actionSchema from '~/schema/action'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import type { IAction } from '~/types'; -/** - * Creates or returns the Action model using the provided mongoose instance and schema - */ export function createActionModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(actionSchema); return mongoose.models.Action || mongoose.model('Action', actionSchema); } diff --git a/packages/data-schemas/src/models/agent.ts b/packages/data-schemas/src/models/agent.ts index bff6bae60d..595d890f06 100644 --- a/packages/data-schemas/src/models/agent.ts +++ b/packages/data-schemas/src/models/agent.ts @@ -1,9 +1,8 @@ import agentSchema from '~/schema/agent'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import type { IAgent } from '~/types'; -/** - * Creates or returns the Agent model using the provided mongoose instance and schema - */ export function createAgentModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(agentSchema); return mongoose.models.Agent || mongoose.model('Agent', agentSchema); } diff --git a/packages/data-schemas/src/models/agentApiKey.ts b/packages/data-schemas/src/models/agentApiKey.ts index 70387a2cef..8251b3d7cd 100644 --- a/packages/data-schemas/src/models/agentApiKey.ts +++ b/packages/data-schemas/src/models/agentApiKey.ts @@ -1,6 +1,8 @@ import agentApiKeySchema, { IAgentApiKey } from '~/schema/agentApiKey'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; export function createAgentApiKeyModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(agentApiKeySchema); return ( mongoose.models.AgentApiKey || mongoose.model('AgentApiKey', agentApiKeySchema) ); diff --git a/packages/data-schemas/src/models/agentCategory.ts b/packages/data-schemas/src/models/agentCategory.ts index 387e0b9e43..f0f1f79d2e 100644 --- a/packages/data-schemas/src/models/agentCategory.ts +++ b/packages/data-schemas/src/models/agentCategory.ts @@ -1,10 +1,9 @@ import agentCategorySchema from '~/schema/agentCategory'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import type * as t from '~/types'; -/** - * Creates or returns the AgentCategory model using the provided mongoose instance and schema - */ export function createAgentCategoryModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(agentCategorySchema); return ( mongoose.models.AgentCategory || mongoose.model('AgentCategory', agentCategorySchema) diff --git a/packages/data-schemas/src/models/assistant.ts b/packages/data-schemas/src/models/assistant.ts index bf8a9f5ac7..16c6dc2bc6 100644 --- a/packages/data-schemas/src/models/assistant.ts +++ b/packages/data-schemas/src/models/assistant.ts @@ -1,9 +1,8 @@ import assistantSchema from '~/schema/assistant'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import type { IAssistant } from '~/types'; -/** - * Creates or returns the Assistant model using the provided mongoose instance and schema - */ export function createAssistantModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(assistantSchema); return mongoose.models.Assistant || mongoose.model('Assistant', assistantSchema); } diff --git a/packages/data-schemas/src/models/balance.ts b/packages/data-schemas/src/models/balance.ts index e7ace38937..7c1fb34478 100644 --- a/packages/data-schemas/src/models/balance.ts +++ b/packages/data-schemas/src/models/balance.ts @@ -1,9 +1,8 @@ import balanceSchema from '~/schema/balance'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import type * as t from '~/types'; -/** - * Creates or returns the Balance model using the provided mongoose instance and schema - */ export function createBalanceModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(balanceSchema); return mongoose.models.Balance || mongoose.model('Balance', balanceSchema); } diff --git a/packages/data-schemas/src/models/banner.ts b/packages/data-schemas/src/models/banner.ts index 7be6e2e07b..ac18fa72b4 100644 --- a/packages/data-schemas/src/models/banner.ts +++ b/packages/data-schemas/src/models/banner.ts @@ -1,9 +1,8 @@ import bannerSchema from '~/schema/banner'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import type { IBanner } from '~/types'; -/** - * Creates or returns the Banner model using the provided mongoose instance and schema - */ export function createBannerModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(bannerSchema); return mongoose.models.Banner || mongoose.model('Banner', bannerSchema); } diff --git a/packages/data-schemas/src/models/conversationTag.ts b/packages/data-schemas/src/models/conversationTag.ts index 902915a6cc..a2df6459cc 100644 --- a/packages/data-schemas/src/models/conversationTag.ts +++ b/packages/data-schemas/src/models/conversationTag.ts @@ -1,9 +1,8 @@ import conversationTagSchema, { IConversationTag } from '~/schema/conversationTag'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; -/** - * Creates or returns the ConversationTag model using the provided mongoose instance and schema - */ export function createConversationTagModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(conversationTagSchema); return ( mongoose.models.ConversationTag || mongoose.model('ConversationTag', conversationTagSchema) diff --git a/packages/data-schemas/src/models/convo.ts b/packages/data-schemas/src/models/convo.ts index da0a8c68cf..7cf504bf48 100644 --- a/packages/data-schemas/src/models/convo.ts +++ b/packages/data-schemas/src/models/convo.ts @@ -1,11 +1,10 @@ import type * as t from '~/types'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import mongoMeili from '~/models/plugins/mongoMeili'; import convoSchema from '~/schema/convo'; -/** - * Creates or returns the Conversation model using the provided mongoose instance and schema - */ export function createConversationModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(convoSchema); if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) { convoSchema.plugin(mongoMeili, { mongoose, diff --git a/packages/data-schemas/src/models/file.ts b/packages/data-schemas/src/models/file.ts index c12dbec140..da291a13e3 100644 --- a/packages/data-schemas/src/models/file.ts +++ b/packages/data-schemas/src/models/file.ts @@ -1,9 +1,8 @@ import fileSchema from '~/schema/file'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import type { IMongoFile } from '~/types'; -/** - * Creates or returns the File model using the provided mongoose instance and schema - */ export function createFileModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(fileSchema); return mongoose.models.File || mongoose.model('File', fileSchema); } diff --git a/packages/data-schemas/src/models/group.ts b/packages/data-schemas/src/models/group.ts index c6ee8f9516..0396de83f1 100644 --- a/packages/data-schemas/src/models/group.ts +++ b/packages/data-schemas/src/models/group.ts @@ -1,9 +1,8 @@ import groupSchema from '~/schema/group'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import type * as t from '~/types'; -/** - * Creates or returns the Group model using the provided mongoose instance and schema - */ export function createGroupModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(groupSchema); return mongoose.models.Group || mongoose.model('Group', groupSchema); } diff --git a/packages/data-schemas/src/models/key.ts b/packages/data-schemas/src/models/key.ts index 6e2ff70c92..d6534e870b 100644 --- a/packages/data-schemas/src/models/key.ts +++ b/packages/data-schemas/src/models/key.ts @@ -1,8 +1,7 @@ import keySchema, { IKey } from '~/schema/key'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; -/** - * Creates or returns the Key model using the provided mongoose instance and schema - */ export function createKeyModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(keySchema); return mongoose.models.Key || mongoose.model('Key', keySchema); } diff --git a/packages/data-schemas/src/models/mcpServer.ts b/packages/data-schemas/src/models/mcpServer.ts index e2ad054068..6b93754d1c 100644 --- a/packages/data-schemas/src/models/mcpServer.ts +++ b/packages/data-schemas/src/models/mcpServer.ts @@ -1,10 +1,9 @@ import mcpServerSchema from '~/schema/mcpServer'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import type { MCPServerDocument } from '~/types'; -/** - * Creates or returns the MCPServer model using the provided mongoose instance and schema - */ export function createMCPServerModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(mcpServerSchema); return ( mongoose.models.MCPServer || mongoose.model('MCPServer', mcpServerSchema) ); diff --git a/packages/data-schemas/src/models/memory.ts b/packages/data-schemas/src/models/memory.ts index fb970c04ce..ad2c8cf8dc 100644 --- a/packages/data-schemas/src/models/memory.ts +++ b/packages/data-schemas/src/models/memory.ts @@ -1,6 +1,8 @@ import memorySchema from '~/schema/memory'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import type { IMemoryEntry } from '~/types/memory'; export function createMemoryModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(memorySchema); return mongoose.models.MemoryEntry || mongoose.model('MemoryEntry', memorySchema); } diff --git a/packages/data-schemas/src/models/message.ts b/packages/data-schemas/src/models/message.ts index 3a81211e68..b8b26b3e06 100644 --- a/packages/data-schemas/src/models/message.ts +++ b/packages/data-schemas/src/models/message.ts @@ -1,11 +1,10 @@ import type * as t from '~/types'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import mongoMeili from '~/models/plugins/mongoMeili'; import messageSchema from '~/schema/message'; -/** - * Creates or returns the Message model using the provided mongoose instance and schema - */ export function createMessageModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(messageSchema); if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) { messageSchema.plugin(mongoMeili, { mongoose, diff --git a/packages/data-schemas/src/models/pluginAuth.ts b/packages/data-schemas/src/models/pluginAuth.ts index 5075fe6f43..22b46d05a8 100644 --- a/packages/data-schemas/src/models/pluginAuth.ts +++ b/packages/data-schemas/src/models/pluginAuth.ts @@ -1,9 +1,8 @@ import pluginAuthSchema from '~/schema/pluginAuth'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import type { IPluginAuth } from '~/types/pluginAuth'; -/** - * Creates or returns the PluginAuth model using the provided mongoose instance and schema - */ export function createPluginAuthModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(pluginAuthSchema); return mongoose.models.PluginAuth || mongoose.model('PluginAuth', pluginAuthSchema); } diff --git a/packages/data-schemas/src/models/plugins/tenantIsolation.spec.ts b/packages/data-schemas/src/models/plugins/tenantIsolation.spec.ts new file mode 100644 index 0000000000..52c40c54bc --- /dev/null +++ b/packages/data-schemas/src/models/plugins/tenantIsolation.spec.ts @@ -0,0 +1,660 @@ +import mongoose, { Schema } from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { tenantStorage, runAsSystem, SYSTEM_TENANT_ID } from '~/config/tenantContext'; +import { applyTenantIsolation, _resetStrictCache } from './tenantIsolation'; + +let mongoServer: InstanceType; + +interface ITestDoc { + name: string; + tenantId?: string; +} + +function createTestModel(suffix: string) { + const schema = new Schema({ + name: { type: String, required: true }, + tenantId: { type: String, index: true }, + }); + applyTenantIsolation(schema); + const modelName = `TestTenant_${suffix}_${Date.now()}`; + return mongoose.model(modelName, schema); +} + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +describe('applyTenantIsolation', () => { + describe('idempotency', () => { + it('does not add duplicate hooks when called twice on the same schema', async () => { + const schema = new Schema({ + name: { type: String, required: true }, + tenantId: { type: String, index: true }, + }); + + applyTenantIsolation(schema); + applyTenantIsolation(schema); + + const modelName = `TestIdempotent_${Date.now()}`; + const Model = mongoose.model(modelName, schema); + + await Model.create([ + { name: 'a', tenantId: 'tenant-a' }, + { name: 'b', tenantId: 'tenant-b' }, + ]); + + const docs = await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + Model.find().lean(), + ); + + expect(docs).toHaveLength(1); + expect(docs[0].name).toBe('a'); + }); + }); + + describe('query filtering', () => { + let TestModel: mongoose.Model; + + beforeAll(() => { + TestModel = createTestModel('query'); + }); + + beforeEach(async () => { + await TestModel.deleteMany({}); + await TestModel.create([ + { name: 'tenant-a-doc', tenantId: 'tenant-a' }, + { name: 'tenant-b-doc', tenantId: 'tenant-b' }, + { name: 'no-tenant-doc' }, + ]); + }); + + it('injects tenantId filter into find when context is set', async () => { + const docs = await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + TestModel.find().lean(), + ); + + expect(docs).toHaveLength(1); + expect(docs[0].name).toBe('tenant-a-doc'); + }); + + it('injects tenantId filter into findOne', async () => { + const doc = await tenantStorage.run({ tenantId: 'tenant-b' }, async () => + TestModel.findOne({ name: 'tenant-a-doc' }).lean(), + ); + + expect(doc).toBeNull(); + }); + + it('does not inject filter when context is absent (non-strict)', async () => { + const docs = await TestModel.find().lean(); + expect(docs).toHaveLength(3); + }); + + it('bypasses filter for SYSTEM_TENANT_ID', async () => { + const docs = await tenantStorage.run({ tenantId: SYSTEM_TENANT_ID }, async () => + TestModel.find().lean(), + ); + + expect(docs).toHaveLength(3); + }); + + it('injects tenantId filter into countDocuments', async () => { + const count = await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + TestModel.countDocuments(), + ); + + expect(count).toBe(1); + }); + + it('injects tenantId filter into findOneAndUpdate', async () => { + const doc = await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + TestModel.findOneAndUpdate( + { name: 'tenant-b-doc' }, + { $set: { name: 'updated' } }, + { new: true }, + ).lean(), + ); + + expect(doc).toBeNull(); + const original = await TestModel.findOne({ name: 'tenant-b-doc' }).lean(); + expect(original).not.toBeNull(); + }); + + it('injects tenantId filter into deleteOne', async () => { + await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + TestModel.deleteOne({ name: 'tenant-b-doc' }), + ); + + const doc = await TestModel.findOne({ name: 'tenant-b-doc' }).lean(); + expect(doc).not.toBeNull(); + }); + + it('injects tenantId filter into deleteMany', async () => { + await tenantStorage.run({ tenantId: 'tenant-a' }, async () => TestModel.deleteMany({})); + + const remaining = await TestModel.find().lean(); + expect(remaining).toHaveLength(2); + expect(remaining.every((d) => d.tenantId !== 'tenant-a')).toBe(true); + }); + + it('injects tenantId filter into updateMany', async () => { + await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + TestModel.updateMany({}, { $set: { name: 'updated' } }), + ); + + const tenantBDoc = await TestModel.findOne({ tenantId: 'tenant-b' }).lean(); + expect(tenantBDoc!.name).toBe('tenant-b-doc'); + + const tenantADoc = await TestModel.findOne({ tenantId: 'tenant-a' }).lean(); + expect(tenantADoc!.name).toBe('updated'); + }); + }); + + describe('aggregate filtering', () => { + let TestModel: mongoose.Model; + + beforeAll(() => { + TestModel = createTestModel('aggregate'); + }); + + beforeEach(async () => { + await TestModel.deleteMany({}); + await TestModel.create([ + { name: 'agg-a', tenantId: 'tenant-a' }, + { name: 'agg-b', tenantId: 'tenant-b' }, + { name: 'agg-none' }, + ]); + }); + + it('prepends $match stage with tenantId to aggregate pipeline', async () => { + const results = await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + TestModel.aggregate([{ $project: { name: 1 } }]), + ); + + expect(results).toHaveLength(1); + expect(results[0].name).toBe('agg-a'); + }); + + it('does not filter aggregate when no context is set (non-strict)', async () => { + const results = await TestModel.aggregate([{ $project: { name: 1 } }]); + expect(results).toHaveLength(3); + }); + + it('bypasses aggregate filter for SYSTEM_TENANT_ID', async () => { + const results = await tenantStorage.run({ tenantId: SYSTEM_TENANT_ID }, async () => + TestModel.aggregate([{ $project: { name: 1 } }]), + ); + + expect(results).toHaveLength(3); + }); + }); + + describe('save hook', () => { + let TestModel: mongoose.Model; + + beforeAll(() => { + TestModel = createTestModel('save'); + }); + + beforeEach(async () => { + await TestModel.deleteMany({}); + }); + + it('stamps tenantId on save for new documents', async () => { + const doc = await tenantStorage.run({ tenantId: 'tenant-x' }, async () => { + const d = new TestModel({ name: 'new-doc' }); + await d.save(); + return d; + }); + + expect(doc.tenantId).toBe('tenant-x'); + }); + + it('does not overwrite existing tenantId on save when it matches context', async () => { + const doc = await tenantStorage.run({ tenantId: 'tenant-x' }, async () => { + const d = new TestModel({ name: 'existing', tenantId: 'tenant-x' }); + await d.save(); + return d; + }); + + expect(doc.tenantId).toBe('tenant-x'); + }); + + it('allows mismatched tenantId on save in non-strict mode', async () => { + const doc = await tenantStorage.run({ tenantId: 'tenant-x' }, async () => { + const d = new TestModel({ name: 'mismatch', tenantId: 'tenant-other' }); + await d.save(); + return d; + }); + + expect(doc.tenantId).toBe('tenant-other'); + }); + + it('does not set tenantId for SYSTEM_TENANT_ID', async () => { + const doc = await tenantStorage.run({ tenantId: SYSTEM_TENANT_ID }, async () => { + const d = new TestModel({ name: 'system-doc' }); + await d.save(); + return d; + }); + + expect(doc.tenantId).toBeUndefined(); + }); + + it('saves without tenantId when no context is set (non-strict)', async () => { + const doc = new TestModel({ name: 'no-context' }); + await doc.save(); + + expect(doc.tenantId).toBeUndefined(); + }); + }); + + describe('insertMany hook', () => { + let TestModel: mongoose.Model; + + beforeAll(() => { + TestModel = createTestModel('insertMany'); + }); + + beforeEach(async () => { + await TestModel.deleteMany({}); + }); + + it('stamps tenantId on all insertMany docs', async () => { + const docs = await tenantStorage.run({ tenantId: 'tenant-bulk' }, async () => + TestModel.insertMany([{ name: 'bulk-1' }, { name: 'bulk-2' }]), + ); + + expect(docs).toHaveLength(2); + for (const doc of docs) { + expect(doc.tenantId).toBe('tenant-bulk'); + } + }); + + it('does not overwrite existing tenantId in insertMany when it matches', async () => { + const docs = await tenantStorage.run({ tenantId: 'tenant-bulk' }, async () => + TestModel.insertMany([{ name: 'pre-set', tenantId: 'tenant-bulk' }]), + ); + + expect(docs[0].tenantId).toBe('tenant-bulk'); + }); + + it('allows mismatched tenantId in insertMany in non-strict mode', async () => { + const docs = await tenantStorage.run({ tenantId: 'tenant-bulk' }, async () => + TestModel.insertMany([{ name: 'mismatch', tenantId: 'tenant-other' }]), + ); + + expect(docs[0].tenantId).toBe('tenant-other'); + }); + + it('does not hang when no tenant context is set (non-strict)', async () => { + const docs = await TestModel.insertMany([{ name: 'no-context-bulk' }]); + + expect(docs).toHaveLength(1); + expect(docs[0].tenantId).toBeUndefined(); + }); + + it('does not stamp tenantId for SYSTEM_TENANT_ID', async () => { + const docs = await tenantStorage.run({ tenantId: SYSTEM_TENANT_ID }, async () => + TestModel.insertMany([{ name: 'system-bulk' }]), + ); + + expect(docs[0].tenantId).toBeUndefined(); + }); + }); + + describe('update mutation guard', () => { + let TestModel: mongoose.Model; + + beforeAll(() => { + TestModel = createTestModel('mutation'); + }); + + beforeEach(async () => { + await TestModel.deleteMany({}); + await TestModel.create({ name: 'guarded', tenantId: 'tenant-a' }); + }); + + it('blocks $set of tenantId via findOneAndUpdate', async () => { + await expect( + tenantStorage.run({ tenantId: 'tenant-a' }, async () => + TestModel.findOneAndUpdate({ name: 'guarded' }, { $set: { tenantId: 'tenant-b' } }), + ), + ).rejects.toThrow('[TenantIsolation] Modifying tenantId via update operators is not allowed'); + }); + + it('blocks $unset of tenantId via updateOne', async () => { + await expect( + tenantStorage.run({ tenantId: 'tenant-a' }, async () => + TestModel.updateOne({ name: 'guarded' }, { $unset: { tenantId: 1 } }), + ), + ).rejects.toThrow('[TenantIsolation] Modifying tenantId via update operators is not allowed'); + }); + + it('blocks top-level tenantId in update payload', async () => { + await expect( + tenantStorage.run({ tenantId: 'tenant-a' }, async () => + TestModel.updateOne({ name: 'guarded' }, { tenantId: 'tenant-b' } as Record< + string, + string + >), + ), + ).rejects.toThrow('[TenantIsolation] Modifying tenantId via update operators is not allowed'); + }); + + it('blocks $setOnInsert of tenantId via updateMany', async () => { + await expect( + tenantStorage.run({ tenantId: 'tenant-a' }, async () => + TestModel.updateMany({}, { $setOnInsert: { tenantId: 'tenant-b' } }), + ), + ).rejects.toThrow('[TenantIsolation] Modifying tenantId via update operators is not allowed'); + }); + + it('allows updates that do not touch tenantId', async () => { + const result = await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + TestModel.findOneAndUpdate( + { name: 'guarded' }, + { $set: { name: 'updated' } }, + { new: true }, + ).lean(), + ); + + expect(result).not.toBeNull(); + expect(result!.name).toBe('updated'); + expect(result!.tenantId).toBe('tenant-a'); + }); + + it('allows SYSTEM_TENANT_ID to modify tenantId', async () => { + const result = await tenantStorage.run({ tenantId: SYSTEM_TENANT_ID }, async () => + TestModel.findOneAndUpdate( + { name: 'guarded' }, + { $set: { tenantId: 'tenant-b' } }, + { new: true }, + ).lean(), + ); + + expect(result).not.toBeNull(); + expect(result!.tenantId).toBe('tenant-b'); + }); + + it('blocks tenantId mutation even without tenant context', async () => { + await expect( + TestModel.updateOne({ name: 'guarded' }, { $set: { tenantId: 'tenant-b' } }), + ).rejects.toThrow('[TenantIsolation] Modifying tenantId via update operators is not allowed'); + }); + + it('blocks tenantId in replaceOne replacement document', async () => { + await expect( + tenantStorage.run({ tenantId: 'tenant-a' }, async () => + TestModel.replaceOne({ name: 'guarded' }, { name: 'replaced', tenantId: 'tenant-b' }), + ), + ).rejects.toThrow('[TenantIsolation] Modifying tenantId via replacement is not allowed'); + }); + + it('blocks tenantId in findOneAndReplace replacement document', async () => { + await expect( + tenantStorage.run({ tenantId: 'tenant-a' }, async () => + TestModel.findOneAndReplace( + { name: 'guarded' }, + { name: 'replaced', tenantId: 'tenant-b' }, + ), + ), + ).rejects.toThrow('[TenantIsolation] Modifying tenantId via replacement is not allowed'); + }); + + it('stamps tenantId into replacement when absent from replacement document', async () => { + await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + TestModel.replaceOne({ name: 'guarded' }, { name: 'replaced-ok' }), + ); + + const doc = await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + TestModel.findOne({ name: 'replaced-ok' }).lean(), + ); + expect(doc).not.toBeNull(); + expect(doc!.tenantId).toBe('tenant-a'); + }); + + it('allows replacement with matching tenantId', async () => { + await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + TestModel.replaceOne({ name: 'guarded' }, { name: 'replaced-match', tenantId: 'tenant-a' }), + ); + + const doc = await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + TestModel.findOne({ name: 'replaced-match' }).lean(), + ); + expect(doc).not.toBeNull(); + expect(doc!.tenantId).toBe('tenant-a'); + }); + + it('allows SYSTEM_TENANT_ID to replace with tenantId', async () => { + await tenantStorage.run({ tenantId: SYSTEM_TENANT_ID }, async () => + TestModel.replaceOne({ name: 'guarded' }, { name: 'sys-replaced', tenantId: 'tenant-b' }), + ); + + const doc = await TestModel.findOne({ name: 'sys-replaced' }).lean(); + expect(doc).not.toBeNull(); + expect(doc!.tenantId).toBe('tenant-b'); + }); + }); + + describe('runAsSystem', () => { + let TestModel: mongoose.Model; + + beforeAll(() => { + TestModel = createTestModel('runAsSystem'); + }); + + beforeEach(async () => { + await TestModel.deleteMany({}); + await TestModel.create([ + { name: 'sys-a', tenantId: 'tenant-a' }, + { name: 'sys-b', tenantId: 'tenant-b' }, + ]); + }); + + it('bypasses tenant filter inside runAsSystem', async () => { + const docs = await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + runAsSystem(async () => TestModel.find().lean()), + ); + + expect(docs).toHaveLength(2); + }); + }); + + describe('async context propagation', () => { + let TestModel: mongoose.Model; + + beforeAll(() => { + TestModel = createTestModel('asyncCtx'); + }); + + beforeEach(async () => { + await TestModel.deleteMany({}); + await TestModel.create([ + { name: 'ctx-a', tenantId: 'tenant-a' }, + { name: 'ctx-b', tenantId: 'tenant-b' }, + ]); + }); + + it('propagates tenant context through await boundaries', async () => { + const docs = await tenantStorage.run({ tenantId: 'tenant-a' }, async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return TestModel.find().lean(); + }); + + expect(docs).toHaveLength(1); + expect(docs[0].name).toBe('ctx-a'); + }); + }); + + describe('strict mode', () => { + let TestModel: mongoose.Model; + const originalEnv = process.env.TENANT_ISOLATION_STRICT; + + beforeAll(() => { + TestModel = createTestModel('strict'); + }); + + beforeEach(async () => { + await runAsSystem(async () => { + await TestModel.deleteMany({}); + await TestModel.create({ name: 'strict-doc', tenantId: 'tenant-a' }); + }); + process.env.TENANT_ISOLATION_STRICT = 'true'; + _resetStrictCache(); + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.TENANT_ISOLATION_STRICT; + } else { + process.env.TENANT_ISOLATION_STRICT = originalEnv; + } + _resetStrictCache(); + }); + + it('throws on find without tenant context', async () => { + await expect(TestModel.find().lean()).rejects.toThrow( + '[TenantIsolation] Query attempted without tenant context in strict mode', + ); + }); + + it('throws on findOne without tenant context', async () => { + await expect(TestModel.findOne().lean()).rejects.toThrow('[TenantIsolation]'); + }); + + it('throws on aggregate without tenant context', async () => { + await expect(TestModel.aggregate([{ $project: { name: 1 } }])).rejects.toThrow( + '[TenantIsolation] Aggregate attempted without tenant context in strict mode', + ); + }); + + it('throws on save without tenant context', async () => { + const doc = new TestModel({ name: 'strict-new' }); + await expect(doc.save()).rejects.toThrow( + '[TenantIsolation] Save attempted without tenant context in strict mode', + ); + }); + + it('throws on insertMany without tenant context', async () => { + await expect(TestModel.insertMany([{ name: 'strict-bulk' }])).rejects.toThrow( + '[TenantIsolation] insertMany attempted without tenant context in strict mode', + ); + }); + + it('throws on save with mismatched tenantId', async () => { + await expect( + tenantStorage.run({ tenantId: 'tenant-a' }, async () => { + const d = new TestModel({ name: 'mismatch', tenantId: 'tenant-b' }); + await d.save(); + }), + ).rejects.toThrow( + '[TenantIsolation] Document tenantId does not match current tenant context', + ); + }); + + it('throws on insertMany with mismatched tenantId', async () => { + await expect( + tenantStorage.run({ tenantId: 'tenant-a' }, async () => + TestModel.insertMany([{ name: 'mismatch', tenantId: 'tenant-b' }]), + ), + ).rejects.toThrow( + '[TenantIsolation] Document tenantId does not match current tenant context', + ); + }); + + it('allows queries with tenant context in strict mode', async () => { + const docs = await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + TestModel.find().lean(), + ); + + expect(docs).toHaveLength(1); + }); + + it('allows SYSTEM_TENANT_ID to bypass strict mode', async () => { + const docs = await tenantStorage.run({ tenantId: SYSTEM_TENANT_ID }, async () => + TestModel.find().lean(), + ); + + expect(docs).toHaveLength(1); + }); + + it('allows runAsSystem to bypass strict mode', async () => { + const docs = await runAsSystem(async () => TestModel.find().lean()); + expect(docs).toHaveLength(1); + }); + }); + + describe('multi-tenant unique constraints', () => { + let UniqueModel: mongoose.Model; + + beforeAll(async () => { + const schema = new Schema({ + name: { type: String, required: true }, + tenantId: { type: String, index: true }, + }); + schema.index({ name: 1, tenantId: 1 }, { unique: true }); + applyTenantIsolation(schema); + UniqueModel = mongoose.model(`TestUnique_${Date.now()}`, schema); + await UniqueModel.ensureIndexes(); + }); + + afterAll(async () => { + await UniqueModel.deleteMany({}); + }); + + it('allows same name in different tenants', async () => { + await tenantStorage.run({ tenantId: 'tenant-a' }, async () => { + await UniqueModel.create({ name: 'shared-name' }); + }); + await tenantStorage.run({ tenantId: 'tenant-b' }, async () => { + await UniqueModel.create({ name: 'shared-name' }); + }); + + const docA = await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + UniqueModel.findOne({ name: 'shared-name' }).lean(), + ); + const docB = await tenantStorage.run({ tenantId: 'tenant-b' }, async () => + UniqueModel.findOne({ name: 'shared-name' }).lean(), + ); + + expect(docA).not.toBeNull(); + expect(docB).not.toBeNull(); + expect(docA!.tenantId).toBe('tenant-a'); + expect(docB!.tenantId).toBe('tenant-b'); + }); + + it('rejects duplicate name within the same tenant', async () => { + await tenantStorage.run({ tenantId: 'tenant-dup' }, async () => { + await UniqueModel.create({ name: 'unique-within-tenant' }); + }); + + await expect( + tenantStorage.run({ tenantId: 'tenant-dup' }, async () => + UniqueModel.create({ name: 'unique-within-tenant' }), + ), + ).rejects.toThrow(/E11000|duplicate key/); + }); + + it('tenant-scoped query returns only the correct document', async () => { + await tenantStorage.run({ tenantId: 'tenant-x' }, async () => { + await UniqueModel.create({ name: 'scoped-doc' }); + }); + await tenantStorage.run({ tenantId: 'tenant-y' }, async () => { + await UniqueModel.create({ name: 'scoped-doc' }); + }); + + const results = await tenantStorage.run({ tenantId: 'tenant-x' }, async () => + UniqueModel.find({ name: 'scoped-doc' }).lean(), + ); + + expect(results).toHaveLength(1); + expect(results[0].tenantId).toBe('tenant-x'); + }); + }); +}); diff --git a/packages/data-schemas/src/models/plugins/tenantIsolation.ts b/packages/data-schemas/src/models/plugins/tenantIsolation.ts new file mode 100644 index 0000000000..ddb98f9aa9 --- /dev/null +++ b/packages/data-schemas/src/models/plugins/tenantIsolation.ts @@ -0,0 +1,177 @@ +import type { Schema, Query, Aggregate, UpdateQuery } from 'mongoose'; +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 _resetStrictCache(): void { + _strictMode = undefined; +} + +if ( + process.env.TENANT_ISOLATION_STRICT && + process.env.TENANT_ISOLATION_STRICT !== 'true' && + process.env.TENANT_ISOLATION_STRICT !== 'false' +) { + logger.warn( + `[TenantIsolation] TENANT_ISOLATION_STRICT="${process.env.TENANT_ISOLATION_STRICT}" ` + + 'is not "true" or "false"; defaulting to non-strict mode.', + ); +} + +const TENANT_ISOLATION_APPLIED = Symbol.for('librechat:tenantIsolation'); + +const MUTATION_OPERATORS = ['$set', '$unset', '$setOnInsert', '$rename'] as const; + +function assertNoTenantIdMutation(update: UpdateQuery | null): void { + if (!update) { + return; + } + for (const op of MUTATION_OPERATORS) { + const payload = update[op] as Record | undefined; + if (payload && 'tenantId' in payload) { + throw new Error('[TenantIsolation] Modifying tenantId via update operators is not allowed'); + } + } + if ('tenantId' in update) { + throw new Error('[TenantIsolation] Modifying tenantId via update operators is not allowed'); + } +} + +/** + * Mongoose schema plugin that enforces tenant-level data isolation. + * + * - `tenantId` present in async context -> injected into every query filter. + * - `tenantId` is `SYSTEM_TENANT_ID` -> skips injection (explicit cross-tenant op). + * - `tenantId` absent + `TENANT_ISOLATION_STRICT=true` -> throws (fail-closed). + * - `tenantId` absent + strict mode off -> passes through (transitional/pre-tenancy). + * - Update and replace operations that modify `tenantId` are blocked unless running as system. + */ +export function applyTenantIsolation(schema: Schema): void { + const s = schema as Schema & { [key: symbol]: boolean }; + if (s[TENANT_ISOLATION_APPLIED]) { + return; + } + s[TENANT_ISOLATION_APPLIED] = true; + + const queryMiddleware = function (this: Query) { + const tenantId = getTenantId(); + + if (!tenantId && isStrict()) { + throw new Error('[TenantIsolation] Query attempted without tenant context in strict mode'); + } + + if (!tenantId || tenantId === SYSTEM_TENANT_ID) { + return; + } + + this.where({ tenantId }); + }; + + const updateGuard = function (this: Query) { + const tenantId = getTenantId(); + if (tenantId === SYSTEM_TENANT_ID) { + return; + } + assertNoTenantIdMutation(this.getUpdate() as UpdateQuery | null); + }; + + const replaceGuard = function (this: Query) { + const tenantId = getTenantId(); + if (tenantId === SYSTEM_TENANT_ID) { + return; + } + const replacement = this.getUpdate() as Record | null; + if (!replacement) { + return; + } + if ('tenantId' in replacement && replacement.tenantId !== tenantId) { + throw new Error('[TenantIsolation] Modifying tenantId via replacement is not allowed'); + } + if (tenantId && !('tenantId' in replacement)) { + replacement.tenantId = tenantId; + } + }; + + schema.pre('find', queryMiddleware); + schema.pre('findOne', queryMiddleware); + schema.pre('findOneAndUpdate', queryMiddleware); + schema.pre('findOneAndDelete', queryMiddleware); + schema.pre('findOneAndReplace', queryMiddleware); + schema.pre('updateOne', queryMiddleware); + schema.pre('updateMany', queryMiddleware); + schema.pre('deleteOne', queryMiddleware); + schema.pre('deleteMany', queryMiddleware); + schema.pre('countDocuments', queryMiddleware); + schema.pre('replaceOne', queryMiddleware); + + schema.pre('findOneAndUpdate', updateGuard); + schema.pre('updateOne', updateGuard); + schema.pre('updateMany', updateGuard); + + schema.pre('replaceOne', replaceGuard); + schema.pre('findOneAndReplace', replaceGuard); + + schema.pre('aggregate', function (this: Aggregate) { + const tenantId = getTenantId(); + + if (!tenantId && isStrict()) { + throw new Error( + '[TenantIsolation] Aggregate attempted without tenant context in strict mode', + ); + } + + if (!tenantId || tenantId === SYSTEM_TENANT_ID) { + return; + } + + this.pipeline().unshift({ $match: { tenantId } }); + }); + + schema.pre('save', function () { + const tenantId = getTenantId(); + + if (!tenantId && isStrict()) { + throw new Error('[TenantIsolation] Save attempted without tenant context in strict mode'); + } + + if (tenantId && tenantId !== SYSTEM_TENANT_ID) { + if (!this.tenantId) { + this.tenantId = tenantId; + } else if (isStrict() && this.tenantId !== tenantId) { + throw new Error( + '[TenantIsolation] Document tenantId does not match current tenant context', + ); + } + } + }); + + schema.pre('insertMany', function (next, docs) { + const tenantId = getTenantId(); + + if (!tenantId && isStrict()) { + return next( + new Error('[TenantIsolation] insertMany attempted without tenant context in strict mode'), + ); + } + + if (tenantId && tenantId !== SYSTEM_TENANT_ID && Array.isArray(docs)) { + for (const doc of docs) { + if (!doc.tenantId) { + doc.tenantId = tenantId; + } else if (isStrict() && doc.tenantId !== tenantId) { + return next( + new Error('[TenantIsolation] Document tenantId does not match current tenant context'), + ); + } + } + } + + next(); + }); +} diff --git a/packages/data-schemas/src/models/preset.ts b/packages/data-schemas/src/models/preset.ts index c5b156e555..dc61c6d251 100644 --- a/packages/data-schemas/src/models/preset.ts +++ b/packages/data-schemas/src/models/preset.ts @@ -1,8 +1,7 @@ import presetSchema, { IPreset } from '~/schema/preset'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; -/** - * Creates or returns the Preset model using the provided mongoose instance and schema - */ export function createPresetModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(presetSchema); return mongoose.models.Preset || mongoose.model('Preset', presetSchema); } diff --git a/packages/data-schemas/src/models/prompt.ts b/packages/data-schemas/src/models/prompt.ts index 87edfa1ef8..25ff23e81a 100644 --- a/packages/data-schemas/src/models/prompt.ts +++ b/packages/data-schemas/src/models/prompt.ts @@ -1,9 +1,8 @@ import promptSchema from '~/schema/prompt'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import type { IPrompt } from '~/types/prompts'; -/** - * Creates or returns the Prompt model using the provided mongoose instance and schema - */ export function createPromptModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(promptSchema); return mongoose.models.Prompt || mongoose.model('Prompt', promptSchema); } diff --git a/packages/data-schemas/src/models/promptGroup.ts b/packages/data-schemas/src/models/promptGroup.ts index 8de3dc9e16..2d1d226988 100644 --- a/packages/data-schemas/src/models/promptGroup.ts +++ b/packages/data-schemas/src/models/promptGroup.ts @@ -1,10 +1,9 @@ import promptGroupSchema from '~/schema/promptGroup'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import type { IPromptGroupDocument } from '~/types/prompts'; -/** - * Creates or returns the PromptGroup model using the provided mongoose instance and schema - */ export function createPromptGroupModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(promptGroupSchema); return ( mongoose.models.PromptGroup || mongoose.model('PromptGroup', promptGroupSchema) diff --git a/packages/data-schemas/src/models/role.ts b/packages/data-schemas/src/models/role.ts index ccc56af1d6..2860007044 100644 --- a/packages/data-schemas/src/models/role.ts +++ b/packages/data-schemas/src/models/role.ts @@ -1,9 +1,8 @@ import roleSchema from '~/schema/role'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import type { IRole } from '~/types'; -/** - * Creates or returns the Role model using the provided mongoose instance and schema - */ export function createRoleModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(roleSchema); return mongoose.models.Role || mongoose.model('Role', roleSchema); } diff --git a/packages/data-schemas/src/models/session.ts b/packages/data-schemas/src/models/session.ts index 3d4eba2761..746f9a74dd 100644 --- a/packages/data-schemas/src/models/session.ts +++ b/packages/data-schemas/src/models/session.ts @@ -1,9 +1,8 @@ import sessionSchema from '~/schema/session'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import type * as t from '~/types'; -/** - * Creates or returns the Session model using the provided mongoose instance and schema - */ export function createSessionModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(sessionSchema); return mongoose.models.Session || mongoose.model('Session', sessionSchema); } diff --git a/packages/data-schemas/src/models/sharedLink.ts b/packages/data-schemas/src/models/sharedLink.ts index 662f9aafc4..f379c4605c 100644 --- a/packages/data-schemas/src/models/sharedLink.ts +++ b/packages/data-schemas/src/models/sharedLink.ts @@ -1,8 +1,7 @@ import shareSchema, { ISharedLink } from '~/schema/share'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; -/** - * Creates or returns the SharedLink model using the provided mongoose instance and schema - */ export function createSharedLinkModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(shareSchema); return mongoose.models.SharedLink || mongoose.model('SharedLink', shareSchema); } diff --git a/packages/data-schemas/src/models/systemGrant.ts b/packages/data-schemas/src/models/systemGrant.ts index e439d2af81..e30b444c65 100644 --- a/packages/data-schemas/src/models/systemGrant.ts +++ b/packages/data-schemas/src/models/systemGrant.ts @@ -1,8 +1,11 @@ -import systemGrantSchema from '~/schema/systemGrant'; import type * as t from '~/types'; +import systemGrantSchema from '~/schema/systemGrant'; /** - * Creates or returns the SystemGrant model using the provided mongoose instance and schema + * SystemGrant is a cross-tenant control plane — its query logic in systemGrant methods + * explicitly handles tenantId conditions (platform-level vs tenant-scoped grants). + * Do NOT apply tenant isolation plugin here; it would inject a hard tenantId equality + * filter that conflicts with the $and/$or logic in hasCapabilityForPrincipals. */ export function createSystemGrantModel(mongoose: typeof import('mongoose')) { return ( diff --git a/packages/data-schemas/src/models/token.ts b/packages/data-schemas/src/models/token.ts index 870233f615..0cdefab0d9 100644 --- a/packages/data-schemas/src/models/token.ts +++ b/packages/data-schemas/src/models/token.ts @@ -1,9 +1,8 @@ import tokenSchema from '~/schema/token'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import type * as t from '~/types'; -/** - * Creates or returns the Token model using the provided mongoose instance and schema - */ export function createTokenModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(tokenSchema); return mongoose.models.Token || mongoose.model('Token', tokenSchema); } diff --git a/packages/data-schemas/src/models/toolCall.ts b/packages/data-schemas/src/models/toolCall.ts index 18292fd8e8..262aade342 100644 --- a/packages/data-schemas/src/models/toolCall.ts +++ b/packages/data-schemas/src/models/toolCall.ts @@ -1,8 +1,7 @@ import toolCallSchema, { IToolCallData } from '~/schema/toolCall'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; -/** - * Creates or returns the ToolCall model using the provided mongoose instance and schema - */ export function createToolCallModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(toolCallSchema); return mongoose.models.ToolCall || mongoose.model('ToolCall', toolCallSchema); } diff --git a/packages/data-schemas/src/models/transaction.ts b/packages/data-schemas/src/models/transaction.ts index 52a33b86a7..358ead23a7 100644 --- a/packages/data-schemas/src/models/transaction.ts +++ b/packages/data-schemas/src/models/transaction.ts @@ -1,9 +1,8 @@ import transactionSchema, { ITransaction } from '~/schema/transaction'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; -/** - * Creates or returns the Transaction model using the provided mongoose instance and schema - */ export function createTransactionModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(transactionSchema); return ( mongoose.models.Transaction || mongoose.model('Transaction', transactionSchema) ); diff --git a/packages/data-schemas/src/models/user.ts b/packages/data-schemas/src/models/user.ts index 1aef66f6d1..18a1da2b0b 100644 --- a/packages/data-schemas/src/models/user.ts +++ b/packages/data-schemas/src/models/user.ts @@ -1,9 +1,8 @@ import userSchema from '~/schema/user'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; import type * as t from '~/types'; -/** - * Creates or returns the User model using the provided mongoose instance and schema - */ export function createUserModel(mongoose: typeof import('mongoose')) { + applyTenantIsolation(userSchema); return mongoose.models.User || mongoose.model('User', userSchema); } diff --git a/packages/data-schemas/src/schema/accessRole.ts b/packages/data-schemas/src/schema/accessRole.ts index 52f9e796c0..dbcaf83ddb 100644 --- a/packages/data-schemas/src/schema/accessRole.ts +++ b/packages/data-schemas/src/schema/accessRole.ts @@ -7,7 +7,6 @@ const accessRoleSchema = new Schema( type: String, required: true, index: true, - unique: true, }, name: { type: String, @@ -24,8 +23,14 @@ const accessRoleSchema = new Schema( type: Number, required: true, }, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true }, ); +accessRoleSchema.index({ accessRoleId: 1, tenantId: 1 }, { unique: true }); + export default accessRoleSchema; diff --git a/packages/data-schemas/src/schema/aclEntry.ts b/packages/data-schemas/src/schema/aclEntry.ts index dbaf73b466..e58cb1a424 100644 --- a/packages/data-schemas/src/schema/aclEntry.ts +++ b/packages/data-schemas/src/schema/aclEntry.ts @@ -55,12 +55,22 @@ const aclEntrySchema = new Schema( type: Date, default: Date.now, }, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true }, ); -aclEntrySchema.index({ principalId: 1, principalType: 1, resourceType: 1, resourceId: 1 }); -aclEntrySchema.index({ resourceId: 1, principalType: 1, principalId: 1 }); -aclEntrySchema.index({ principalId: 1, permBits: 1, resourceType: 1 }); +aclEntrySchema.index({ + principalId: 1, + principalType: 1, + resourceType: 1, + resourceId: 1, + tenantId: 1, +}); +aclEntrySchema.index({ resourceId: 1, principalType: 1, principalId: 1, tenantId: 1 }); +aclEntrySchema.index({ principalId: 1, permBits: 1, resourceType: 1, tenantId: 1 }); export default aclEntrySchema; diff --git a/packages/data-schemas/src/schema/action.ts b/packages/data-schemas/src/schema/action.ts index 4d5f64a0e1..5cde2ad6fc 100644 --- a/packages/data-schemas/src/schema/action.ts +++ b/packages/data-schemas/src/schema/action.ts @@ -47,6 +47,10 @@ const Action = new Schema({ oauth_client_id: String, oauth_client_secret: String, }, + tenantId: { + type: String, + index: true, + }, }); export default Action; diff --git a/packages/data-schemas/src/schema/agent.ts b/packages/data-schemas/src/schema/agent.ts index eff4b8e675..42a7ca5418 100644 --- a/packages/data-schemas/src/schema/agent.ts +++ b/packages/data-schemas/src/schema/agent.ts @@ -114,6 +114,10 @@ const agentSchema = new Schema( type: Schema.Types.Mixed, default: undefined, }, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true, diff --git a/packages/data-schemas/src/schema/agentApiKey.ts b/packages/data-schemas/src/schema/agentApiKey.ts index d7037f857f..50334f5f5c 100644 --- a/packages/data-schemas/src/schema/agentApiKey.ts +++ b/packages/data-schemas/src/schema/agentApiKey.ts @@ -9,6 +9,7 @@ export interface IAgentApiKey extends Document { expiresAt?: Date; createdAt: Date; updatedAt: Date; + tenantId?: string; } const agentApiKeySchema: Schema = new Schema( @@ -42,11 +43,15 @@ const agentApiKeySchema: Schema = new Schema( expiresAt: { type: Date, }, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true }, ); -agentApiKeySchema.index({ userId: 1, name: 1 }); +agentApiKeySchema.index({ userId: 1, name: 1, tenantId: 1 }); /** * TTL index for automatic cleanup of expired keys. diff --git a/packages/data-schemas/src/schema/agentCategory.ts b/packages/data-schemas/src/schema/agentCategory.ts index d0d42f46c9..2922042129 100644 --- a/packages/data-schemas/src/schema/agentCategory.ts +++ b/packages/data-schemas/src/schema/agentCategory.ts @@ -6,7 +6,6 @@ const agentCategorySchema = new Schema( value: { type: String, required: true, - unique: true, trim: true, lowercase: true, index: true, @@ -35,12 +34,17 @@ const agentCategorySchema = new Schema( type: Boolean, default: false, }, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true, }, ); +agentCategorySchema.index({ value: 1, tenantId: 1 }, { unique: true }); agentCategorySchema.index({ isActive: 1, order: 1 }); agentCategorySchema.index({ order: 1, label: 1 }); diff --git a/packages/data-schemas/src/schema/assistant.ts b/packages/data-schemas/src/schema/assistant.ts index 4f0226d38a..3fc052d458 100644 --- a/packages/data-schemas/src/schema/assistant.ts +++ b/packages/data-schemas/src/schema/assistant.ts @@ -30,6 +30,10 @@ const assistantSchema = new Schema( type: Boolean, default: false, }, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true, diff --git a/packages/data-schemas/src/schema/balance.ts b/packages/data-schemas/src/schema/balance.ts index 8e786ae388..b9a65b8f4f 100644 --- a/packages/data-schemas/src/schema/balance.ts +++ b/packages/data-schemas/src/schema/balance.ts @@ -36,6 +36,10 @@ const balanceSchema = new Schema({ type: Number, default: 0, }, + tenantId: { + type: String, + index: true, + }, }); export default balanceSchema; diff --git a/packages/data-schemas/src/schema/banner.ts b/packages/data-schemas/src/schema/banner.ts index 7cd07a93af..73baa92b2f 100644 --- a/packages/data-schemas/src/schema/banner.ts +++ b/packages/data-schemas/src/schema/banner.ts @@ -8,6 +8,7 @@ export interface IBanner extends Document { type: 'banner' | 'popup'; isPublic: boolean; persistable: boolean; + tenantId?: string; } const bannerSchema = new Schema( @@ -41,6 +42,10 @@ const bannerSchema = new Schema( type: Boolean, default: false, }, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true }, ); diff --git a/packages/data-schemas/src/schema/categories.ts b/packages/data-schemas/src/schema/categories.ts index 5ebd2afb83..94832b54de 100644 --- a/packages/data-schemas/src/schema/categories.ts +++ b/packages/data-schemas/src/schema/categories.ts @@ -3,19 +3,25 @@ import { Schema, Document } from 'mongoose'; export interface ICategory extends Document { label: string; value: string; + tenantId?: string; } const categoriesSchema = new Schema({ label: { type: String, required: true, - unique: true, }, value: { type: String, required: true, - unique: true, + }, + tenantId: { + type: String, + index: true, }, }); +categoriesSchema.index({ label: 1, tenantId: 1 }, { unique: true }); +categoriesSchema.index({ value: 1, tenantId: 1 }, { unique: true }); + export default categoriesSchema; diff --git a/packages/data-schemas/src/schema/conversationTag.ts b/packages/data-schemas/src/schema/conversationTag.ts index e22231fdc2..6b37257121 100644 --- a/packages/data-schemas/src/schema/conversationTag.ts +++ b/packages/data-schemas/src/schema/conversationTag.ts @@ -6,6 +6,7 @@ export interface IConversationTag extends Document { description?: string; count?: number; position?: number; + tenantId?: string; } const conversationTag = new Schema( @@ -31,11 +32,15 @@ const conversationTag = new Schema( default: 0, index: true, }, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true }, ); // Create a compound index on tag and user with unique constraint. -conversationTag.index({ tag: 1, user: 1 }, { unique: true }); +conversationTag.index({ tag: 1, user: 1, tenantId: 1 }, { unique: true }); export default conversationTag; diff --git a/packages/data-schemas/src/schema/convo.ts b/packages/data-schemas/src/schema/convo.ts index e6a9ede6be..9ed8949e9c 100644 --- a/packages/data-schemas/src/schema/convo.ts +++ b/packages/data-schemas/src/schema/convo.ts @@ -37,13 +37,17 @@ const convoSchema: Schema = new Schema( expiredAt: { type: Date, }, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true }, ); convoSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 }); convoSchema.index({ createdAt: 1, updatedAt: 1 }); -convoSchema.index({ conversationId: 1, user: 1 }, { unique: true }); +convoSchema.index({ conversationId: 1, user: 1, tenantId: 1 }, { unique: true }); // index for MeiliSearch sync operations convoSchema.index({ _meiliIndex: 1, expiredAt: 1 }); diff --git a/packages/data-schemas/src/schema/file.ts b/packages/data-schemas/src/schema/file.ts index c5e3b3c4e3..e483541bdb 100644 --- a/packages/data-schemas/src/schema/file.ts +++ b/packages/data-schemas/src/schema/file.ts @@ -78,6 +78,10 @@ const file: Schema = new Schema( type: Date, expires: 3600, // 1 hour in seconds }, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true, @@ -86,7 +90,7 @@ const file: Schema = new Schema( file.index({ createdAt: 1, updatedAt: 1 }); file.index( - { filename: 1, conversationId: 1, context: 1 }, + { filename: 1, conversationId: 1, context: 1, tenantId: 1 }, { unique: true, partialFilterExpression: { context: FileContext.execute_code } }, ); diff --git a/packages/data-schemas/src/schema/group.ts b/packages/data-schemas/src/schema/group.ts index 55cb54e8b5..3cdbd31330 100644 --- a/packages/data-schemas/src/schema/group.ts +++ b/packages/data-schemas/src/schema/group.ts @@ -41,12 +41,16 @@ const groupSchema = new Schema( return this.source !== 'local'; }, }, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true }, ); groupSchema.index( - { idOnTheSource: 1, source: 1 }, + { idOnTheSource: 1, source: 1, tenantId: 1 }, { unique: true, partialFilterExpression: { idOnTheSource: { $exists: true } }, diff --git a/packages/data-schemas/src/schema/key.ts b/packages/data-schemas/src/schema/key.ts index 54857db753..330eb23471 100644 --- a/packages/data-schemas/src/schema/key.ts +++ b/packages/data-schemas/src/schema/key.ts @@ -5,6 +5,7 @@ export interface IKey extends Document { name: string; value: string; expiresAt?: Date; + tenantId?: string; } const keySchema: Schema = new Schema({ @@ -24,6 +25,10 @@ const keySchema: Schema = new Schema({ expiresAt: { type: Date, }, + tenantId: { + type: String, + index: true, + }, }); keySchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); diff --git a/packages/data-schemas/src/schema/mcpServer.ts b/packages/data-schemas/src/schema/mcpServer.ts index 8210c258d6..ac881932da 100644 --- a/packages/data-schemas/src/schema/mcpServer.ts +++ b/packages/data-schemas/src/schema/mcpServer.ts @@ -6,7 +6,6 @@ const mcpServerSchema = new Schema( serverName: { type: String, index: true, - unique: true, required: true, }, config: { @@ -20,12 +19,17 @@ const mcpServerSchema = new Schema( required: true, index: true, }, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true, }, ); +mcpServerSchema.index({ serverName: 1, tenantId: 1 }, { unique: true }); mcpServerSchema.index({ updatedAt: -1, _id: 1 }); export default mcpServerSchema; diff --git a/packages/data-schemas/src/schema/memory.ts b/packages/data-schemas/src/schema/memory.ts index b6eadf04a7..773fa87115 100644 --- a/packages/data-schemas/src/schema/memory.ts +++ b/packages/data-schemas/src/schema/memory.ts @@ -28,6 +28,10 @@ const MemoryEntrySchema: Schema = new Schema({ type: Date, default: Date.now, }, + tenantId: { + type: String, + index: true, + }, }); export default MemoryEntrySchema; diff --git a/packages/data-schemas/src/schema/message.ts b/packages/data-schemas/src/schema/message.ts index f960194541..610251443d 100644 --- a/packages/data-schemas/src/schema/message.ts +++ b/packages/data-schemas/src/schema/message.ts @@ -144,13 +144,17 @@ const messageSchema: Schema = new Schema( type: Boolean, default: undefined, }, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true }, ); messageSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 }); messageSchema.index({ createdAt: 1 }); -messageSchema.index({ messageId: 1, user: 1 }, { unique: true }); +messageSchema.index({ messageId: 1, user: 1, tenantId: 1 }, { unique: true }); // index for MeiliSearch sync operations messageSchema.index({ _meiliIndex: 1, expiredAt: 1 }); diff --git a/packages/data-schemas/src/schema/pluginAuth.ts b/packages/data-schemas/src/schema/pluginAuth.ts index 534c49d127..e278e63d45 100644 --- a/packages/data-schemas/src/schema/pluginAuth.ts +++ b/packages/data-schemas/src/schema/pluginAuth.ts @@ -18,6 +18,10 @@ const pluginAuthSchema: Schema = new Schema( pluginKey: { type: String, }, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true }, ); diff --git a/packages/data-schemas/src/schema/preset.ts b/packages/data-schemas/src/schema/preset.ts index fc23d86c0b..33c217ea23 100644 --- a/packages/data-schemas/src/schema/preset.ts +++ b/packages/data-schemas/src/schema/preset.ts @@ -53,6 +53,7 @@ export interface IPreset extends Document { web_search?: boolean; disableStreaming?: boolean; fileTokenLimit?: number; + tenantId?: string; } const presetSchema: Schema = new Schema( @@ -79,6 +80,10 @@ const presetSchema: Schema = new Schema( type: Number, }, ...conversationPreset, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true }, ); diff --git a/packages/data-schemas/src/schema/prompt.ts b/packages/data-schemas/src/schema/prompt.ts index 017eeea5e3..dd32789727 100644 --- a/packages/data-schemas/src/schema/prompt.ts +++ b/packages/data-schemas/src/schema/prompt.ts @@ -23,6 +23,10 @@ const promptSchema: Schema = new Schema( enum: ['text', 'chat'], required: true, }, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true, diff --git a/packages/data-schemas/src/schema/promptGroup.ts b/packages/data-schemas/src/schema/promptGroup.ts index d751c67557..bd4db546e3 100644 --- a/packages/data-schemas/src/schema/promptGroup.ts +++ b/packages/data-schemas/src/schema/promptGroup.ts @@ -53,6 +53,10 @@ const promptGroupSchema = new Schema( `Command cannot be longer than ${Constants.COMMANDS_MAX_LENGTH} characters`, ], }, // Casting here bypasses the type error for the command field. + tenantId: { + type: String, + index: true, + }, }, { timestamps: true, diff --git a/packages/data-schemas/src/schema/role.ts b/packages/data-schemas/src/schema/role.ts index e4821ba405..1c27478ef6 100644 --- a/packages/data-schemas/src/schema/role.ts +++ b/packages/data-schemas/src/schema/role.ts @@ -72,10 +72,16 @@ const rolePermissionsSchema = new Schema( ); const roleSchema: Schema = new Schema({ - name: { type: String, required: true, unique: true, index: true }, + name: { type: String, required: true, index: true }, permissions: { type: rolePermissionsSchema, }, + tenantId: { + type: String, + index: true, + }, }); +roleSchema.index({ name: 1, tenantId: 1 }, { unique: true }); + export default roleSchema; diff --git a/packages/data-schemas/src/schema/session.ts b/packages/data-schemas/src/schema/session.ts index 9dc2d733a5..4a66a37535 100644 --- a/packages/data-schemas/src/schema/session.ts +++ b/packages/data-schemas/src/schema/session.ts @@ -16,6 +16,10 @@ const sessionSchema: Schema = new Schema({ ref: 'User', required: true, }, + tenantId: { + type: String, + index: true, + }, }); export default sessionSchema; diff --git a/packages/data-schemas/src/schema/share.ts b/packages/data-schemas/src/schema/share.ts index 987dd10fc2..3238084889 100644 --- a/packages/data-schemas/src/schema/share.ts +++ b/packages/data-schemas/src/schema/share.ts @@ -10,6 +10,7 @@ export interface ISharedLink extends Document { isPublic: boolean; createdAt?: Date; updatedAt?: Date; + tenantId?: string; } const shareSchema: Schema = new Schema( @@ -40,10 +41,14 @@ const shareSchema: Schema = new Schema( type: Boolean, default: true, }, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true }, ); -shareSchema.index({ conversationId: 1, user: 1, targetMessageId: 1 }); +shareSchema.index({ conversationId: 1, user: 1, targetMessageId: 1, tenantId: 1 }); export default shareSchema; diff --git a/packages/data-schemas/src/schema/token.ts b/packages/data-schemas/src/schema/token.ts index 8cb17eec5d..dae2118e64 100644 --- a/packages/data-schemas/src/schema/token.ts +++ b/packages/data-schemas/src/schema/token.ts @@ -33,6 +33,10 @@ const tokenSchema: Schema = new Schema({ type: Map, of: Schema.Types.Mixed, }, + tenantId: { + type: String, + index: true, + }, }); tokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); diff --git a/packages/data-schemas/src/schema/toolCall.ts b/packages/data-schemas/src/schema/toolCall.ts index 4bc35e5799..d36d6b758a 100644 --- a/packages/data-schemas/src/schema/toolCall.ts +++ b/packages/data-schemas/src/schema/toolCall.ts @@ -12,6 +12,7 @@ export interface IToolCallData extends Document { partIndex?: number; createdAt?: Date; updatedAt?: Date; + tenantId?: string; } const toolCallSchema: Schema = new Schema( @@ -45,11 +46,15 @@ const toolCallSchema: Schema = new Schema( partIndex: { type: Number, }, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true }, ); -toolCallSchema.index({ messageId: 1, user: 1 }); -toolCallSchema.index({ conversationId: 1, user: 1 }); +toolCallSchema.index({ messageId: 1, user: 1, tenantId: 1 }); +toolCallSchema.index({ conversationId: 1, user: 1, tenantId: 1 }); export default toolCallSchema; diff --git a/packages/data-schemas/src/schema/transaction.ts b/packages/data-schemas/src/schema/transaction.ts index 6faf684b12..bb41494696 100644 --- a/packages/data-schemas/src/schema/transaction.ts +++ b/packages/data-schemas/src/schema/transaction.ts @@ -17,6 +17,7 @@ export interface ITransaction extends Document { messageId?: string; createdAt?: Date; updatedAt?: Date; + tenantId?: string; } const transactionSchema: Schema = new Schema( @@ -54,6 +55,10 @@ const transactionSchema: Schema = new Schema( writeTokens: { type: Number }, readTokens: { type: Number }, messageId: { type: String }, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true, diff --git a/packages/data-schemas/src/schema/user.ts b/packages/data-schemas/src/schema/user.ts index c2bdc6fd34..75613c9889 100644 --- a/packages/data-schemas/src/schema/user.ts +++ b/packages/data-schemas/src/schema/user.ts @@ -37,7 +37,6 @@ const userSchema = new Schema( type: String, required: [true, "can't be blank"], lowercase: true, - unique: true, match: [/\S+@\S+\.\S+/, 'is invalid'], index: true, }, @@ -68,43 +67,27 @@ const userSchema = new Schema( }, googleId: { type: String, - unique: true, - sparse: true, }, facebookId: { type: String, - unique: true, - sparse: true, }, openidId: { type: String, - unique: true, - sparse: true, }, samlId: { type: String, - unique: true, - sparse: true, }, ldapId: { type: String, - unique: true, - sparse: true, }, githubId: { type: String, - unique: true, - sparse: true, }, discordId: { type: String, - unique: true, - sparse: true, }, appleId: { type: String, - unique: true, - sparse: true, }, plugins: { type: Array, @@ -157,8 +140,32 @@ const userSchema = new Schema( type: String, sparse: true, }, + tenantId: { + type: String, + index: true, + }, }, { timestamps: true }, ); +userSchema.index({ email: 1, tenantId: 1 }, { unique: true }); + +const oAuthIdFields = [ + 'googleId', + 'facebookId', + 'openidId', + 'samlId', + 'ldapId', + 'githubId', + 'discordId', + 'appleId', +] as const; + +for (const field of oAuthIdFields) { + userSchema.index( + { [field]: 1, tenantId: 1 }, + { unique: true, partialFilterExpression: { [field]: { $exists: true } } }, + ); +} + export default userSchema; diff --git a/packages/data-schemas/src/types/accessRole.ts b/packages/data-schemas/src/types/accessRole.ts index 54f6aeb077..e873f9bdce 100644 --- a/packages/data-schemas/src/types/accessRole.ts +++ b/packages/data-schemas/src/types/accessRole.ts @@ -10,6 +10,7 @@ export type AccessRole = { resourceType: string; /** e.g., 1 for read, 3 for read+write */ permBits: number; + tenantId?: string; }; export type IAccessRole = AccessRole & diff --git a/packages/data-schemas/src/types/aclEntry.ts b/packages/data-schemas/src/types/aclEntry.ts index 026b852aa8..ae5860a599 100644 --- a/packages/data-schemas/src/types/aclEntry.ts +++ b/packages/data-schemas/src/types/aclEntry.ts @@ -22,6 +22,7 @@ export type AclEntry = { grantedBy?: Types.ObjectId; /** When this permission was granted */ grantedAt?: Date; + tenantId?: string; }; export type IAclEntry = AclEntry & diff --git a/packages/data-schemas/src/types/action.ts b/packages/data-schemas/src/types/action.ts index 6a269856dd..841d6c95e5 100644 --- a/packages/data-schemas/src/types/action.ts +++ b/packages/data-schemas/src/types/action.ts @@ -25,4 +25,5 @@ export interface IAction extends Document { oauth_client_id?: string; oauth_client_secret?: string; }; + tenantId?: string; } diff --git a/packages/data-schemas/src/types/agent.ts b/packages/data-schemas/src/types/agent.ts index 1171028c5d..2af6c22439 100644 --- a/packages/data-schemas/src/types/agent.ts +++ b/packages/data-schemas/src/types/agent.ts @@ -41,4 +41,5 @@ export interface IAgent extends Omit { mcpServerNames?: string[]; /** Per-tool configuration (defer_loading, allowed_callers) */ tool_options?: AgentToolOptions; + tenantId?: string; } diff --git a/packages/data-schemas/src/types/agentApiKey.ts b/packages/data-schemas/src/types/agentApiKey.ts index 968937a717..c5e7836edd 100644 --- a/packages/data-schemas/src/types/agentApiKey.ts +++ b/packages/data-schemas/src/types/agentApiKey.ts @@ -9,6 +9,7 @@ export interface IAgentApiKey extends Document { expiresAt?: Date; createdAt: Date; updatedAt: Date; + tenantId?: string; } export interface AgentApiKeyCreateData { diff --git a/packages/data-schemas/src/types/agentCategory.ts b/packages/data-schemas/src/types/agentCategory.ts index 1a814d289f..1c1648bac1 100644 --- a/packages/data-schemas/src/types/agentCategory.ts +++ b/packages/data-schemas/src/types/agentCategory.ts @@ -13,6 +13,7 @@ export type AgentCategory = { isActive: boolean; /** Whether this is a custom user-created category */ custom?: boolean; + tenantId?: string; }; export type IAgentCategory = AgentCategory & diff --git a/packages/data-schemas/src/types/assistant.ts b/packages/data-schemas/src/types/assistant.ts index d2e180c668..33381f7399 100644 --- a/packages/data-schemas/src/types/assistant.ts +++ b/packages/data-schemas/src/types/assistant.ts @@ -12,4 +12,5 @@ export interface IAssistant extends Document { file_ids?: string[]; actions?: string[]; append_current_datetime?: boolean; + tenantId?: string; } diff --git a/packages/data-schemas/src/types/balance.ts b/packages/data-schemas/src/types/balance.ts index e5eb4c4f15..54ceb0a1e9 100644 --- a/packages/data-schemas/src/types/balance.ts +++ b/packages/data-schemas/src/types/balance.ts @@ -9,6 +9,7 @@ export interface IBalance extends Document { refillIntervalUnit: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months'; lastRefill: Date; refillAmount: number; + tenantId?: string; } /** Plain data fields for creating or updating a balance record (no Mongoose Document methods) */ diff --git a/packages/data-schemas/src/types/banner.ts b/packages/data-schemas/src/types/banner.ts index e9c63ac97e..756718e5d1 100644 --- a/packages/data-schemas/src/types/banner.ts +++ b/packages/data-schemas/src/types/banner.ts @@ -8,4 +8,5 @@ export interface IBanner extends Document { type: 'banner' | 'popup'; isPublic: boolean; persistable: boolean; + tenantId?: string; } diff --git a/packages/data-schemas/src/types/convo.ts b/packages/data-schemas/src/types/convo.ts index 43965a5827..c7888efba2 100644 --- a/packages/data-schemas/src/types/convo.ts +++ b/packages/data-schemas/src/types/convo.ts @@ -56,4 +56,5 @@ export interface IConversation extends Document { expiredAt?: Date; createdAt?: Date; updatedAt?: Date; + tenantId?: string; } diff --git a/packages/data-schemas/src/types/file.ts b/packages/data-schemas/src/types/file.ts index 8f17e3b597..bbf9de3d3d 100644 --- a/packages/data-schemas/src/types/file.ts +++ b/packages/data-schemas/src/types/file.ts @@ -25,4 +25,5 @@ export interface IMongoFile extends Omit { expiresAt?: Date; createdAt?: Date; updatedAt?: Date; + tenantId?: string; } diff --git a/packages/data-schemas/src/types/group.ts b/packages/data-schemas/src/types/group.ts index e0e622aca2..15cb6f288e 100644 --- a/packages/data-schemas/src/types/group.ts +++ b/packages/data-schemas/src/types/group.ts @@ -14,6 +14,7 @@ export interface IGroup extends Document { idOnTheSource?: string; createdAt?: Date; updatedAt?: Date; + tenantId?: string; } export interface CreateGroupRequest { diff --git a/packages/data-schemas/src/types/mcp.ts b/packages/data-schemas/src/types/mcp.ts index 9b1c622293..560d535737 100644 --- a/packages/data-schemas/src/types/mcp.ts +++ b/packages/data-schemas/src/types/mcp.ts @@ -9,4 +9,5 @@ export interface MCPServerDocument extends Omit, Document { author: Types.ObjectId; // ObjectId reference in DB (vs string in API) + tenantId?: string; } diff --git a/packages/data-schemas/src/types/memory.ts b/packages/data-schemas/src/types/memory.ts index 6ab6c29345..4d9b4fbefd 100644 --- a/packages/data-schemas/src/types/memory.ts +++ b/packages/data-schemas/src/types/memory.ts @@ -7,6 +7,7 @@ export interface IMemoryEntry extends Document { value: string; tokenCount?: number; updated_at?: Date; + tenantId?: string; } export interface IMemoryEntryLean { diff --git a/packages/data-schemas/src/types/message.ts b/packages/data-schemas/src/types/message.ts index c4e96b34ba..c3f465e711 100644 --- a/packages/data-schemas/src/types/message.ts +++ b/packages/data-schemas/src/types/message.ts @@ -43,4 +43,5 @@ export interface IMessage extends Document { expiredAt?: Date | null; createdAt?: Date; updatedAt?: Date; + tenantId?: string; } diff --git a/packages/data-schemas/src/types/pluginAuth.ts b/packages/data-schemas/src/types/pluginAuth.ts index c38bc790ab..fb5c5fc4e7 100644 --- a/packages/data-schemas/src/types/pluginAuth.ts +++ b/packages/data-schemas/src/types/pluginAuth.ts @@ -7,6 +7,7 @@ export interface IPluginAuth extends Document { pluginKey?: string; createdAt?: Date; updatedAt?: Date; + tenantId?: string; } export interface PluginAuthQuery { diff --git a/packages/data-schemas/src/types/prompts.ts b/packages/data-schemas/src/types/prompts.ts index 53f09dcd49..02db35a1be 100644 --- a/packages/data-schemas/src/types/prompts.ts +++ b/packages/data-schemas/src/types/prompts.ts @@ -7,6 +7,7 @@ export interface IPrompt extends Document { type: 'text' | 'chat'; createdAt?: Date; updatedAt?: Date; + tenantId?: string; } export interface IPromptGroup { @@ -21,6 +22,7 @@ export interface IPromptGroup { createdAt?: Date; updatedAt?: Date; isPublic?: boolean; + tenantId?: string; } export interface IPromptGroupDocument extends IPromptGroup, Document {} diff --git a/packages/data-schemas/src/types/role.ts b/packages/data-schemas/src/types/role.ts index e70e29204a..60a579240c 100644 --- a/packages/data-schemas/src/types/role.ts +++ b/packages/data-schemas/src/types/role.ts @@ -66,6 +66,7 @@ export interface IRole extends Document { [Permissions.SHARE_PUBLIC]?: boolean; }; }; + tenantId?: string; } export type RolePermissions = IRole['permissions']; diff --git a/packages/data-schemas/src/types/session.ts b/packages/data-schemas/src/types/session.ts index a7e9591e12..db01a23162 100644 --- a/packages/data-schemas/src/types/session.ts +++ b/packages/data-schemas/src/types/session.ts @@ -4,6 +4,7 @@ export interface ISession extends Document { refreshTokenHash: string; expiration: Date; user: Types.ObjectId; + tenantId?: string; } export interface CreateSessionOptions { diff --git a/packages/data-schemas/src/types/token.ts b/packages/data-schemas/src/types/token.ts index e71958a1d9..063e18a7c9 100644 --- a/packages/data-schemas/src/types/token.ts +++ b/packages/data-schemas/src/types/token.ts @@ -9,6 +9,7 @@ export interface IToken extends Document { createdAt: Date; expiresAt: Date; metadata?: Map; + tenantId?: string; } export interface TokenCreateData { diff --git a/packages/data-schemas/src/types/user.ts b/packages/data-schemas/src/types/user.ts index a78c4679f2..8e1f9dd771 100644 --- a/packages/data-schemas/src/types/user.ts +++ b/packages/data-schemas/src/types/user.ts @@ -43,6 +43,7 @@ export interface IUser extends Document { updatedAt?: Date; /** Field for external source identification (for consistency with TPrincipal schema) */ idOnTheSource?: string; + tenantId?: string; } export interface BalanceConfig {