mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-12 11:02:37 +01:00
🏢 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:
parent
530b401e7b
commit
428ef2eb15
89 changed files with 1539 additions and 133 deletions
28
packages/data-schemas/src/config/tenantContext.ts
Normal file
28
packages/data-schemas/src/config/tenantContext.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
1
packages/data-schemas/src/migrations/index.ts
Normal file
1
packages/data-schemas/src/migrations/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { dropSupersededTenantIndexes } from './tenantIndexes';
|
||||
286
packages/data-schemas/src/migrations/tenantIndexes.spec.ts
Normal file
286
packages/data-schemas/src/migrations/tenantIndexes.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
102
packages/data-schemas/src/migrations/tenantIndexes.ts
Normal file
102
packages/data-schemas/src/migrations/tenantIndexes.ts
Normal 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 };
|
||||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
660
packages/data-schemas/src/models/plugins/tenantIsolation.spec.ts
Normal file
660
packages/data-schemas/src/models/plugins/tenantIsolation.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
177
packages/data-schemas/src/models/plugins/tenantIsolation.ts
Normal file
177
packages/data-schemas/src/models/plugins/tenantIsolation.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -114,6 +114,10 @@ const agentSchema = new Schema<IAgent>(
|
|||
type: Schema.Types.Mixed,
|
||||
default: undefined,
|
||||
},
|
||||
tenantId: {
|
||||
type: String,
|
||||
index: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ const assistantSchema = new Schema<IAssistant>(
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
tenantId: {
|
||||
type: String,
|
||||
index: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ const balanceSchema = new Schema<t.IBalance>({
|
|||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
tenantId: {
|
||||
type: String,
|
||||
index: true,
|
||||
},
|
||||
});
|
||||
|
||||
export default balanceSchema;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ const MemoryEntrySchema: Schema<IMemoryEntry> = new Schema({
|
|||
type: Date,
|
||||
default: Date.now,
|
||||
},
|
||||
tenantId: {
|
||||
type: String,
|
||||
index: true,
|
||||
},
|
||||
});
|
||||
|
||||
export default MemoryEntrySchema;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ const pluginAuthSchema: Schema<IPluginAuth> = new Schema(
|
|||
pluginKey: {
|
||||
type: String,
|
||||
},
|
||||
tenantId: {
|
||||
type: String,
|
||||
index: true,
|
||||
},
|
||||
},
|
||||
{ timestamps: true },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ const promptSchema: Schema<IPrompt> = new Schema(
|
|||
enum: ['text', 'chat'],
|
||||
required: true,
|
||||
},
|
||||
tenantId: {
|
||||
type: String,
|
||||
index: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ const sessionSchema: Schema<ISession> = new Schema({
|
|||
ref: 'User',
|
||||
required: true,
|
||||
},
|
||||
tenantId: {
|
||||
type: String,
|
||||
index: true,
|
||||
},
|
||||
});
|
||||
|
||||
export default sessionSchema;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 &
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export type AclEntry = {
|
|||
grantedBy?: Types.ObjectId;
|
||||
/** When this permission was granted */
|
||||
grantedAt?: Date;
|
||||
tenantId?: string;
|
||||
};
|
||||
|
||||
export type IAclEntry = AclEntry &
|
||||
|
|
|
|||
|
|
@ -25,4 +25,5 @@ export interface IAction extends Document {
|
|||
oauth_client_id?: string;
|
||||
oauth_client_secret?: string;
|
||||
};
|
||||
tenantId?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface IAgentApiKey extends Document {
|
|||
expiresAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface AgentApiKeyCreateData {
|
||||
|
|
|
|||
|
|
@ -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 &
|
||||
|
|
|
|||
|
|
@ -12,4 +12,5 @@ export interface IAssistant extends Document {
|
|||
file_ids?: string[];
|
||||
actions?: string[];
|
||||
append_current_datetime?: boolean;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -8,4 +8,5 @@ export interface IBanner extends Document {
|
|||
type: 'banner' | 'popup';
|
||||
isPublic: boolean;
|
||||
persistable: boolean;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,4 +56,5 @@ export interface IConversation extends Document {
|
|||
expiredAt?: Date;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,4 +25,5 @@ export interface IMongoFile extends Omit<Document, 'model'> {
|
|||
expiresAt?: Date;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export interface IGroup extends Document {
|
|||
idOnTheSource?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface CreateGroupRequest {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface IMemoryEntry extends Document {
|
|||
value: string;
|
||||
tokenCount?: number;
|
||||
updated_at?: Date;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface IMemoryEntryLean {
|
||||
|
|
|
|||
|
|
@ -43,4 +43,5 @@ export interface IMessage extends Document {
|
|||
expiredAt?: Date | null;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface IPluginAuth extends Document {
|
|||
pluginKey?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface PluginAuthQuery {
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export interface IRole extends Document {
|
|||
[Permissions.SHARE_PUBLIC]?: boolean;
|
||||
};
|
||||
};
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export type RolePermissions = IRole['permissions'];
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export interface ISession extends Document {
|
|||
refreshTokenHash: string;
|
||||
expiration: Date;
|
||||
user: Types.ObjectId;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface CreateSessionOptions {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface IToken extends Document {
|
|||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
metadata?: Map<string, unknown>;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface TokenCreateData {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue