🏢 feat: Multi-Tenant Data Isolation Infrastructure (#12091)

* chore: imports

* chore: optional chaining in `spendTokens.spec.ts`

* feat: Add tenantId field to all MongoDB schemas for multi-tenant isolation

  - Add AsyncLocalStorage-based tenant context (`tenantContext.ts`) for
    request-scoped tenantId propagation without modifying method signatures
  - Add Mongoose `applyTenantIsolation` plugin that injects `{ tenantId }`
    into all query filters when tenant context is present, with
    `TENANT_ISOLATION_STRICT` env var for fail-closed production mode
  - Add optional `tenantId` field to all 28 collection schemas
  - Update all compound unique indexes to include tenantId (email, OAuth IDs,
    role names, serverName, conversationId+user, messageId+user, etc.)
  - Apply tenant isolation plugin in all 28 model factories
  - Add `tenantId?: string` to all TypeScript document interfaces

  Behaviorally inert — transitional mode (default) passes through all queries
  unchanged. No migration required for existing deployments.

* refactor: Update tenant context and enhance tenant isolation plugin

- Changed `tenantId` in `TenantContext` to be optional, allowing for more flexible usage.
- Refactored `runAsSystem` function to accept synchronous functions, improving usability.
- Introduced comprehensive tests for the `applyTenantIsolation` plugin, ensuring correct tenant filtering in various query scenarios.
- Enhanced the plugin to handle aggregate queries and save operations with tenant context, improving data isolation capabilities.

* docs: tenant context documentation and improve tenant isolation tests

- Added detailed documentation for the `tenantStorage` AsyncLocalStorage instance in `tenantContext.ts`, clarifying its usage for async tenant context propagation.
- Updated tests in `tenantIsolation.spec.ts` to improve clarity and coverage, including new tests for strict mode behavior and tenant context propagation through await boundaries.
- Refactored existing test cases for better readability and consistency, ensuring robust validation of tenant isolation functionality.

* feat: Enhance tenant isolation by preventing tenantId mutations in update operations

- Added a new function to assert that tenantId cannot be modified through update operators in Mongoose queries.
- Implemented middleware to enforce this restriction during findOneAndUpdate, updateOne, and updateMany operations.
- Updated documentation to reflect the new behavior regarding tenantId modifications, ensuring clarity on tenant isolation rules.

* feat: Enhance tenant isolation tests and enforce tenantId restrictions

- Updated existing tests to clarify behavior regarding tenantId preservation during save and insertMany operations.
- Introduced new tests to validate that tenantId cannot be modified through update operations, ensuring strict adherence to tenant isolation rules.
- Added checks for mismatched tenantId scenarios, reinforcing the integrity of tenant context propagation.
- Enhanced test coverage for async context propagation and mutation guards, improving overall robustness of tenant isolation functionality.

* fix: Remove duplicate re-exports in utils/index.ts

Merge artifact caused `string` and `tempChatRetention` to be exported
twice, which produces TypeScript compile errors for duplicate bindings.

* fix: Resolve admin capability gap in multi-tenant mode (TODO #12091)

- hasCapabilityForPrincipals now queries both tenant-scoped AND
  platform-level grants when tenantId is set, so seeded ADMIN grants
  remain effective in tenant mode.
- Add applyTenantIsolation to SystemGrant model factory.

* fix: Harden tenant isolation plugin

- Add replaceGuard for replaceOne/findOneAndReplace to prevent
  cross-tenant document reassignment via replacement documents.
- Cache isStrict() result to avoid process.env reads on every query.
  Export _resetStrictCache() for test teardown.
- Replace console.warn with project logger (winston).
- Add 5 new tests for replace guard behavior (46 total).

* style: Fix import ordering in convo.ts and message.ts

Move type imports after value imports per project style guide.

* fix: Remove tenant isolation from SystemGrant, stamp tenantId in replaceGuard

- SystemGrant is a cross-tenant control plane whose methods handle
  tenantId conditions explicitly. Applying the isolation plugin
  injects a hard equality filter that overrides the $and/$or logic
  in hasCapabilityForPrincipals, making platform-level ADMIN grants
  invisible in tenant mode.
- replaceGuard now stamps tenantId into replacement documents when
  absent, preventing replaceOne from silently stripping tenant
  context. Replacements with a matching tenantId are allowed;
  mismatched tenantId still throws.

* test: Add multi-tenant unique constraint and replace stamping tests

- Verify same name/email can exist in different tenants (compound
  unique index allows it).
- Verify duplicate within same tenant is rejected (E11000).
- Verify tenant-scoped query returns only the correct document.
- Update replaceOne test to assert tenantId is stamped into
  replacement document.
- Add test for replacement with matching tenantId.

* style: Reorder imports in message.ts to align with project style guide

* feat: Add migration to drop superseded unique indexes for multi-tenancy

Existing deployments have single-field unique indexes (e.g. { email: 1 })
that block multi-tenant operation — same email in different tenants
triggers E11000. Mongoose autoIndex creates the new compound indexes
but never drops the old ones.

dropSupersededTenantIndexes() drops all 19 superseded indexes across 11
collections. It is idempotent, skips missing indexes/collections, and
is a no-op on fresh databases.

Must be called before enabling multi-tenant middleware on an existing
deployment. Single-tenant deployments are unaffected (old indexes
coexist harmlessly until migration runs).

Includes 11 tests covering:
- Full upgrade simulation (create old indexes, drop them, verify gone)
- Multi-tenant writes work after migration (same email, different tenant)
- Intra-tenant uniqueness preserved (duplicate within tenant rejected)
- Fresh database (no-op, no errors)
- Partial migration (some collections exist, some don't)
- SUPERSEDED_INDEXES coverage validation

* fix: Update systemGrant test — platform grants now satisfy tenant queries

The TODO #12091 fix intentionally changed hasCapabilityForPrincipals to
match both tenant-scoped AND platform-level grants. The test expected
the old behavior (platform grant invisible to tenant query). Updated
test name and expectation to match the new semantics.

* fix: Align getCapabilitiesForPrincipal with hasCapabilityForPrincipals tenant query

getCapabilitiesForPrincipal used a hard tenantId equality filter while
hasCapabilityForPrincipals uses $and/$or to match both tenant-scoped
and platform-level grants. This caused the two functions to disagree
on what grants a principal holds in tenant mode.

Apply the same $or pattern: when tenantId is provided, match both
{ tenantId } and { tenantId: { $exists: false } }.

Adds test verifying platform-level ADMIN grants appear in
getCapabilitiesForPrincipal when called with a tenantId.

* fix: Remove categories from tenant index migration

categoriesSchema is exported but never used to create a Mongoose model.
No Category model factory exists, no code constructs a model from it,
and no categories collection exists in production databases. Including
it in the migration would attempt to drop indexes from a non-existent
collection (harmlessly skipped) but implies the collection is managed.

* fix: Restrict runAsSystem to async callbacks only

Sync callbacks returning Mongoose thenables silently lose ALS context —
the system bypass does nothing and strict mode throws with no indication
runAsSystem was involved. Narrowing to () => Promise<T> makes the wrong
pattern a compile error. All existing call sites already use async.

* fix: Use next(err) consistently in insertMany pre-hook

The hook accepted a next callback but used throw for errors. Standardize
on next(err) for all error paths so the hook speaks one language —
callback-style throughout.

* fix: Replace optional chaining with explicit null assertions in spendTokens tests

Optional chaining on test assertions masks failures with unintelligible
error messages. Add expect(result).not.toBeNull() before accessing
properties, so a null result produces a clear diagnosis instead of
"received value must be a number".
This commit is contained in:
Danny Avila 2026-03-07 16:37:10 -05:00
parent 530b401e7b
commit 428ef2eb15
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
89 changed files with 1539 additions and 133 deletions

View file

@ -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<TenantContext>();
/** 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<T>(fn: () => Promise<T>): Promise<T> {
return tenantStorage.run({ tenantId: SYSTEM_TENANT_ID }, fn);
}

View file

@ -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';

View file

@ -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 () => {

View file

@ -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({

View file

@ -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 };
}

View file

@ -0,0 +1 @@
export { dropSupersededTenantIndexes } from './tenantIndexes';

View file

@ -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<typeof MongoMemoryServer>;
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<typeof MongoMemoryServer>;
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<typeof MongoMemoryServer>;
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');
});
});
});

View file

@ -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<string, string[]> = {
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<MigrationResult> {
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 };

View file

@ -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<t.IAccessRole>('AccessRole', accessRoleSchema)
);

View file

@ -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<t.IAclEntry>('AclEntry', aclEntrySchema);
}

View file

@ -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<IAction>('Action', actionSchema);
}

View file

@ -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<IAgent>('Agent', agentSchema);
}

View file

@ -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<IAgentApiKey>('AgentApiKey', agentApiKeySchema)
);

View file

@ -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<t.IAgentCategory>('AgentCategory', agentCategorySchema)

View file

@ -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<IAssistant>('Assistant', assistantSchema);
}

View file

@ -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<t.IBalance>('Balance', balanceSchema);
}

View file

@ -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<IBanner>('Banner', bannerSchema);
}

View file

@ -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<IConversationTag>('ConversationTag', conversationTagSchema)

View file

@ -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,

View file

@ -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<IMongoFile>('File', fileSchema);
}

View file

@ -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<t.IGroup>('Group', groupSchema);
}

View file

@ -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<IKey>('Key', keySchema);
}

View file

@ -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<MCPServerDocument>('MCPServer', mcpServerSchema)
);

View file

@ -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<IMemoryEntry>('MemoryEntry', memorySchema);
}

View file

@ -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,

View file

@ -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<IPluginAuth>('PluginAuth', pluginAuthSchema);
}

View file

@ -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<typeof MongoMemoryServer>;
interface ITestDoc {
name: string;
tenantId?: string;
}
function createTestModel(suffix: string) {
const schema = new Schema<ITestDoc>({
name: { type: String, required: true },
tenantId: { type: String, index: true },
});
applyTenantIsolation(schema);
const modelName = `TestTenant_${suffix}_${Date.now()}`;
return mongoose.model<ITestDoc>(modelName, schema);
}
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
describe('applyTenantIsolation', () => {
describe('idempotency', () => {
it('does not add duplicate hooks when called twice on the same schema', async () => {
const schema = new Schema<ITestDoc>({
name: { type: String, required: true },
tenantId: { type: String, index: true },
});
applyTenantIsolation(schema);
applyTenantIsolation(schema);
const modelName = `TestIdempotent_${Date.now()}`;
const Model = mongoose.model<ITestDoc>(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<ITestDoc>;
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<ITestDoc>;
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<ITestDoc>;
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<ITestDoc>;
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<ITestDoc>;
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<ITestDoc>;
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<ITestDoc>;
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<ITestDoc>;
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<ITestDoc>;
beforeAll(async () => {
const schema = new Schema<ITestDoc>({
name: { type: String, required: true },
tenantId: { type: String, index: true },
});
schema.index({ name: 1, tenantId: 1 }, { unique: true });
applyTenantIsolation(schema);
UniqueModel = mongoose.model<ITestDoc>(`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');
});
});
});

View file

@ -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<unknown> | null): void {
if (!update) {
return;
}
for (const op of MUTATION_OPERATORS) {
const payload = update[op] as Record<string, unknown> | 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<unknown, unknown>) {
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<unknown, unknown>) {
const tenantId = getTenantId();
if (tenantId === SYSTEM_TENANT_ID) {
return;
}
assertNoTenantIdMutation(this.getUpdate() as UpdateQuery<unknown> | null);
};
const replaceGuard = function (this: Query<unknown, unknown>) {
const tenantId = getTenantId();
if (tenantId === SYSTEM_TENANT_ID) {
return;
}
const replacement = this.getUpdate() as Record<string, unknown> | 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<unknown>) {
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();
});
}

View file

@ -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<IPreset>('Preset', presetSchema);
}

View file

@ -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<IPrompt>('Prompt', promptSchema);
}

View file

@ -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<IPromptGroupDocument>('PromptGroup', promptGroupSchema)

View file

@ -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<IRole>('Role', roleSchema);
}

View file

@ -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<t.ISession>('Session', sessionSchema);
}

View file

@ -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<ISharedLink>('SharedLink', shareSchema);
}

View file

@ -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 (

View file

@ -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<t.IToken>('Token', tokenSchema);
}

View file

@ -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<IToolCallData>('ToolCall', toolCallSchema);
}

View file

@ -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<ITransaction>('Transaction', transactionSchema)
);

View file

@ -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<t.IUser>('User', userSchema);
}

View file

@ -7,7 +7,6 @@ const accessRoleSchema = new Schema<IAccessRole>(
type: String,
required: true,
index: true,
unique: true,
},
name: {
type: String,
@ -24,8 +23,14 @@ const accessRoleSchema = new Schema<IAccessRole>(
type: Number,
required: true,
},
tenantId: {
type: String,
index: true,
},
},
{ timestamps: true },
);
accessRoleSchema.index({ accessRoleId: 1, tenantId: 1 }, { unique: true });
export default accessRoleSchema;

View file

@ -55,12 +55,22 @@ const aclEntrySchema = new Schema<IAclEntry>(
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;

View file

@ -47,6 +47,10 @@ const Action = new Schema<IAction>({
oauth_client_id: String,
oauth_client_secret: String,
},
tenantId: {
type: String,
index: true,
},
});
export default Action;

View file

@ -114,6 +114,10 @@ const agentSchema = new Schema<IAgent>(
type: Schema.Types.Mixed,
default: undefined,
},
tenantId: {
type: String,
index: true,
},
},
{
timestamps: true,

View file

@ -9,6 +9,7 @@ export interface IAgentApiKey extends Document {
expiresAt?: Date;
createdAt: Date;
updatedAt: Date;
tenantId?: string;
}
const agentApiKeySchema: Schema<IAgentApiKey> = new Schema(
@ -42,11 +43,15 @@ const agentApiKeySchema: Schema<IAgentApiKey> = 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.

View file

@ -6,7 +6,6 @@ const agentCategorySchema = new Schema<IAgentCategory>(
value: {
type: String,
required: true,
unique: true,
trim: true,
lowercase: true,
index: true,
@ -35,12 +34,17 @@ const agentCategorySchema = new Schema<IAgentCategory>(
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 });

View file

@ -30,6 +30,10 @@ const assistantSchema = new Schema<IAssistant>(
type: Boolean,
default: false,
},
tenantId: {
type: String,
index: true,
},
},
{
timestamps: true,

View file

@ -36,6 +36,10 @@ const balanceSchema = new Schema<t.IBalance>({
type: Number,
default: 0,
},
tenantId: {
type: String,
index: true,
},
});
export default balanceSchema;

View file

@ -8,6 +8,7 @@ export interface IBanner extends Document {
type: 'banner' | 'popup';
isPublic: boolean;
persistable: boolean;
tenantId?: string;
}
const bannerSchema = new Schema<IBanner>(
@ -41,6 +42,10 @@ const bannerSchema = new Schema<IBanner>(
type: Boolean,
default: false,
},
tenantId: {
type: String,
index: true,
},
},
{ timestamps: true },
);

View file

@ -3,19 +3,25 @@ import { Schema, Document } from 'mongoose';
export interface ICategory extends Document {
label: string;
value: string;
tenantId?: string;
}
const categoriesSchema = new Schema<ICategory>({
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;

View file

@ -6,6 +6,7 @@ export interface IConversationTag extends Document {
description?: string;
count?: number;
position?: number;
tenantId?: string;
}
const conversationTag = new Schema<IConversationTag>(
@ -31,11 +32,15 @@ const conversationTag = new Schema<IConversationTag>(
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;

View file

@ -37,13 +37,17 @@ const convoSchema: Schema<IConversation> = 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 });

View file

@ -78,6 +78,10 @@ const file: Schema<IMongoFile> = new Schema(
type: Date,
expires: 3600, // 1 hour in seconds
},
tenantId: {
type: String,
index: true,
},
},
{
timestamps: true,
@ -86,7 +90,7 @@ const file: Schema<IMongoFile> = 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 } },
);

View file

@ -41,12 +41,16 @@ const groupSchema = new Schema<IGroup>(
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 } },

View file

@ -5,6 +5,7 @@ export interface IKey extends Document {
name: string;
value: string;
expiresAt?: Date;
tenantId?: string;
}
const keySchema: Schema<IKey> = new Schema({
@ -24,6 +25,10 @@ const keySchema: Schema<IKey> = new Schema({
expiresAt: {
type: Date,
},
tenantId: {
type: String,
index: true,
},
});
keySchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });

View file

@ -6,7 +6,6 @@ const mcpServerSchema = new Schema<MCPServerDocument>(
serverName: {
type: String,
index: true,
unique: true,
required: true,
},
config: {
@ -20,12 +19,17 @@ const mcpServerSchema = new Schema<MCPServerDocument>(
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;

View file

@ -28,6 +28,10 @@ const MemoryEntrySchema: Schema<IMemoryEntry> = new Schema({
type: Date,
default: Date.now,
},
tenantId: {
type: String,
index: true,
},
});
export default MemoryEntrySchema;

View file

@ -144,13 +144,17 @@ const messageSchema: Schema<IMessage> = 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 });

View file

@ -18,6 +18,10 @@ const pluginAuthSchema: Schema<IPluginAuth> = new Schema(
pluginKey: {
type: String,
},
tenantId: {
type: String,
index: true,
},
},
{ timestamps: true },
);

View file

@ -53,6 +53,7 @@ export interface IPreset extends Document {
web_search?: boolean;
disableStreaming?: boolean;
fileTokenLimit?: number;
tenantId?: string;
}
const presetSchema: Schema<IPreset> = new Schema(
@ -79,6 +80,10 @@ const presetSchema: Schema<IPreset> = new Schema(
type: Number,
},
...conversationPreset,
tenantId: {
type: String,
index: true,
},
},
{ timestamps: true },
);

View file

@ -23,6 +23,10 @@ const promptSchema: Schema<IPrompt> = new Schema(
enum: ['text', 'chat'],
required: true,
},
tenantId: {
type: String,
index: true,
},
},
{
timestamps: true,

View file

@ -53,6 +53,10 @@ const promptGroupSchema = new Schema<IPromptGroupDocument>(
`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,

View file

@ -72,10 +72,16 @@ const rolePermissionsSchema = new Schema(
);
const roleSchema: Schema<IRole> = 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;

View file

@ -16,6 +16,10 @@ const sessionSchema: Schema<ISession> = new Schema({
ref: 'User',
required: true,
},
tenantId: {
type: String,
index: true,
},
});
export default sessionSchema;

View file

@ -10,6 +10,7 @@ export interface ISharedLink extends Document {
isPublic: boolean;
createdAt?: Date;
updatedAt?: Date;
tenantId?: string;
}
const shareSchema: Schema<ISharedLink> = new Schema(
@ -40,10 +41,14 @@ const shareSchema: Schema<ISharedLink> = 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;

View file

@ -33,6 +33,10 @@ const tokenSchema: Schema<IToken> = new Schema({
type: Map,
of: Schema.Types.Mixed,
},
tenantId: {
type: String,
index: true,
},
});
tokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });

View file

@ -12,6 +12,7 @@ export interface IToolCallData extends Document {
partIndex?: number;
createdAt?: Date;
updatedAt?: Date;
tenantId?: string;
}
const toolCallSchema: Schema<IToolCallData> = new Schema(
@ -45,11 +46,15 @@ const toolCallSchema: Schema<IToolCallData> = 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;

View file

@ -17,6 +17,7 @@ export interface ITransaction extends Document {
messageId?: string;
createdAt?: Date;
updatedAt?: Date;
tenantId?: string;
}
const transactionSchema: Schema<ITransaction> = new Schema(
@ -54,6 +55,10 @@ const transactionSchema: Schema<ITransaction> = new Schema(
writeTokens: { type: Number },
readTokens: { type: Number },
messageId: { type: String },
tenantId: {
type: String,
index: true,
},
},
{
timestamps: true,

View file

@ -37,7 +37,6 @@ const userSchema = new Schema<IUser>(
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<IUser>(
},
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<IUser>(
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;

View file

@ -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 &

View file

@ -22,6 +22,7 @@ export type AclEntry = {
grantedBy?: Types.ObjectId;
/** When this permission was granted */
grantedAt?: Date;
tenantId?: string;
};
export type IAclEntry = AclEntry &

View file

@ -25,4 +25,5 @@ export interface IAction extends Document {
oauth_client_id?: string;
oauth_client_secret?: string;
};
tenantId?: string;
}

View file

@ -41,4 +41,5 @@ export interface IAgent extends Omit<Document, 'model'> {
mcpServerNames?: string[];
/** Per-tool configuration (defer_loading, allowed_callers) */
tool_options?: AgentToolOptions;
tenantId?: string;
}

View file

@ -9,6 +9,7 @@ export interface IAgentApiKey extends Document {
expiresAt?: Date;
createdAt: Date;
updatedAt: Date;
tenantId?: string;
}
export interface AgentApiKeyCreateData {

View file

@ -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 &

View file

@ -12,4 +12,5 @@ export interface IAssistant extends Document {
file_ids?: string[];
actions?: string[];
append_current_datetime?: boolean;
tenantId?: string;
}

View file

@ -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) */

View file

@ -8,4 +8,5 @@ export interface IBanner extends Document {
type: 'banner' | 'popup';
isPublic: boolean;
persistable: boolean;
tenantId?: string;
}

View file

@ -56,4 +56,5 @@ export interface IConversation extends Document {
expiredAt?: Date;
createdAt?: Date;
updatedAt?: Date;
tenantId?: string;
}

View file

@ -25,4 +25,5 @@ export interface IMongoFile extends Omit<Document, 'model'> {
expiresAt?: Date;
createdAt?: Date;
updatedAt?: Date;
tenantId?: string;
}

View file

@ -14,6 +14,7 @@ export interface IGroup extends Document {
idOnTheSource?: string;
createdAt?: Date;
updatedAt?: Date;
tenantId?: string;
}
export interface CreateGroupRequest {

View file

@ -9,4 +9,5 @@ export interface MCPServerDocument
extends Omit<MCPServerDB, 'author' | '_id'>,
Document<Types.ObjectId> {
author: Types.ObjectId; // ObjectId reference in DB (vs string in API)
tenantId?: string;
}

View file

@ -7,6 +7,7 @@ export interface IMemoryEntry extends Document {
value: string;
tokenCount?: number;
updated_at?: Date;
tenantId?: string;
}
export interface IMemoryEntryLean {

View file

@ -43,4 +43,5 @@ export interface IMessage extends Document {
expiredAt?: Date | null;
createdAt?: Date;
updatedAt?: Date;
tenantId?: string;
}

View file

@ -7,6 +7,7 @@ export interface IPluginAuth extends Document {
pluginKey?: string;
createdAt?: Date;
updatedAt?: Date;
tenantId?: string;
}
export interface PluginAuthQuery {

View file

@ -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 {}

View file

@ -66,6 +66,7 @@ export interface IRole extends Document {
[Permissions.SHARE_PUBLIC]?: boolean;
};
};
tenantId?: string;
}
export type RolePermissions = IRole['permissions'];

View file

@ -4,6 +4,7 @@ export interface ISession extends Document {
refreshTokenHash: string;
expiration: Date;
user: Types.ObjectId;
tenantId?: string;
}
export interface CreateSessionOptions {

View file

@ -9,6 +9,7 @@ export interface IToken extends Document {
createdAt: Date;
expiresAt: Date;
metadata?: Map<string, unknown>;
tenantId?: string;
}
export interface TokenCreateData {

View file

@ -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 {