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 {