@objectql/plugin-multitenancy
v4.2.2
Published
Multi-tenancy plugin for ObjectQL - Automatic tenant isolation with query filtering and schema separation
Maintainers
Readme
@objectql/plugin-multitenancy
Multi-tenancy plugin for ObjectQL - Automatic tenant isolation with query filtering and schema separation.
Features
- Automatic Query Filtering: Auto-inject
tenant_idfilters on all queries - Auto-set Tenant ID: Automatically set
tenant_idon new records - Cross-tenant Protection: Prevent unauthorized access to other tenants' data
- Flexible Configuration: Support multiple tenant isolation strategies
- Audit Logging: Track all tenant-related operations
- Schema Isolation: Optional table-prefix or separate-schema modes
- Security by Default: Strict mode enabled by default
Installation
pnpm add @objectql/plugin-multitenancyQuick Start
import { MultiTenancyPlugin } from '@objectql/plugin-multitenancy';
import { ObjectStackKernel } from '@objectstack/core';
const kernel = new ObjectStackKernel([
new MultiTenancyPlugin({
tenantField: 'tenant_id',
strictMode: true,
exemptObjects: ['users', 'tenants'],
}),
]);
await kernel.start();Configuration
interface MultiTenancyPluginConfig {
/** Enable/disable the plugin. Default: true */
enabled?: boolean;
/** Field name for tenant identification. Default: 'tenant_id' */
tenantField?: string;
/** Strict mode prevents cross-tenant queries. Default: true */
strictMode?: boolean;
/** Tenant resolver function to get current tenant from context */
tenantResolver?: (context: any) => string | Promise<string>;
/** Schema isolation mode: 'none', 'table-prefix', 'separate-schema'. Default: 'none' */
schemaIsolation?: 'none' | 'table-prefix' | 'separate-schema';
/** Objects exempt from tenant isolation. Default: [] */
exemptObjects?: string[];
/** Auto-create tenant_id field on objects. Default: true */
autoAddTenantField?: boolean;
/** Enable tenant context validation. Default: true */
validateTenantContext?: boolean;
/** Throw error when tenant context is missing. Default: true */
throwOnMissingTenant?: boolean;
/** Enable audit logging for cross-tenant access attempts. Default: true */
enableAudit?: boolean;
}How It Works
1. Query Filtering (beforeFind)
The plugin automatically injects tenant filters into all queries:
// User query
const accounts = await objectql.find('accounts', { status: 'active' });
// Transformed query (tenant_id auto-injected)
// SELECT * FROM accounts WHERE status = 'active' AND tenant_id = 'tenant-123'2. Auto-set Tenant ID (beforeCreate)
New records automatically get the current tenant's ID:
// User code
await objectql.create('accounts', { name: 'Acme Corp' });
// Stored data
// { name: 'Acme Corp', tenant_id: 'tenant-123' }3. Cross-tenant Protection (beforeUpdate/beforeDelete)
Updates and deletes are verified to match the current tenant:
// Attempting to update another tenant's record throws error
await objectql.update('accounts', recordId, { name: 'New Name' });
// TenantIsolationError: Cross-tenant update deniedTenant Context Resolution
The plugin resolves the tenant ID from the request context in this order:
context.tenantId(explicit)context.user.tenantId(from user object)context.user.tenant_id(alternative naming)
Custom Tenant Resolver
new MultiTenancyPlugin({
tenantResolver: async (context) => {
// Custom logic to extract tenant ID
const token = context.headers.authorization;
const decoded = await verifyToken(token);
return decoded.organizationId;
},
});Exempt Objects
Some objects may need to be accessible across tenants (e.g., users, tenants):
new MultiTenancyPlugin({
exemptObjects: ['users', 'tenants', 'global_settings'],
});Schema Isolation Modes
None (Default)
All tenants share the same table with tenant_id column:
CREATE TABLE accounts (
id SERIAL PRIMARY KEY,
tenant_id VARCHAR(255) NOT NULL,
name VARCHAR(255),
INDEX idx_tenant (tenant_id)
);Table Prefix
Each tenant gets separate tables with a prefix:
CREATE TABLE accounts_tenant_1 (...);
CREATE TABLE accounts_tenant_2 (...);Separate Schema
Each tenant gets a separate database schema:
CREATE SCHEMA tenant_1;
CREATE TABLE tenant_1.accounts (...);
CREATE SCHEMA tenant_2;
CREATE TABLE tenant_2.accounts (...);Audit Logging
Access the audit logs to track tenant operations:
const plugin = new MultiTenancyPlugin({ enableAudit: true });
// After operations
const logs = plugin.getAuditLogs(100); // Get last 100 entries
logs.forEach(log => {
console.log(`${log.operation} on ${log.objectName} by tenant ${log.tenantId}`);
});Integration with Plugin-Security
Multi-tenancy works alongside the security plugin for tenant-scoped RBAC:
const kernel = new ObjectStackKernel([
new MultiTenancyPlugin({
tenantField: 'tenant_id',
}),
new SecurityPlugin({
enableRowLevelSecurity: true,
}),
]);Error Handling
import { TenantIsolationError } from '@objectql/plugin-multitenancy';
try {
await objectql.update('accounts', recordId, data);
} catch (error) {
if (error instanceof TenantIsolationError) {
console.error('Tenant isolation violation:', error.details);
// {
// tenantId: 'tenant-123',
// objectName: 'accounts',
// operation: 'update',
// reason: 'CROSS_TENANT_UPDATE'
// }
}
}Best Practices
- Always set tenant context: Ensure every request has tenant information
- Use exempt objects sparingly: Only exempt truly global objects
- Enable strict mode in production: Catch cross-tenant bugs early
- Monitor audit logs: Track potential security issues
- Test tenant isolation: Write tests to verify data separation
Architecture
The plugin operates at the Hook layer and does NOT affect SQL generation:
┌─────────────────────────────┐
│ plugin-multitenancy │ ← beforeFind/Create/Update/Delete hooks
│ (Tenant Filter Injection) │
├─────────────────────────────┤
│ plugin-security │ ← RBAC enforcement
├─────────────────────────────┤
│ QueryService → QueryAST │ ← Core: abstract query building
├─────────────────────────────┤
│ Driver → Knex → SQL │ ← Driver: SQL generation (UNTOUCHED)
└─────────────────────────────┘License
MIT
Contributing
See the ObjectQL Contributing Guide.
