|
| 1 | +# @objectql/plugin-multitenancy |
| 2 | + |
| 3 | +Multi-tenancy plugin for ObjectQL - Automatic tenant isolation with query filtering and schema separation. |
| 4 | + |
| 5 | +## Features |
| 6 | + |
| 7 | +- **Automatic Query Filtering**: Auto-inject `tenant_id` filters on all queries |
| 8 | +- **Auto-set Tenant ID**: Automatically set `tenant_id` on new records |
| 9 | +- **Cross-tenant Protection**: Prevent unauthorized access to other tenants' data |
| 10 | +- **Flexible Configuration**: Support multiple tenant isolation strategies |
| 11 | +- **Audit Logging**: Track all tenant-related operations |
| 12 | +- **Schema Isolation**: Optional table-prefix or separate-schema modes |
| 13 | +- **Security by Default**: Strict mode enabled by default |
| 14 | + |
| 15 | +## Installation |
| 16 | + |
| 17 | +```bash |
| 18 | +pnpm add @objectql/plugin-multitenancy |
| 19 | +``` |
| 20 | + |
| 21 | +## Quick Start |
| 22 | + |
| 23 | +```typescript |
| 24 | +import { MultiTenancyPlugin } from '@objectql/plugin-multitenancy'; |
| 25 | +import { ObjectStackKernel } from '@objectstack/core'; |
| 26 | + |
| 27 | +const kernel = new ObjectStackKernel([ |
| 28 | + new MultiTenancyPlugin({ |
| 29 | + tenantField: 'tenant_id', |
| 30 | + strictMode: true, |
| 31 | + exemptObjects: ['users', 'tenants'], |
| 32 | + }), |
| 33 | +]); |
| 34 | + |
| 35 | +await kernel.start(); |
| 36 | +``` |
| 37 | + |
| 38 | +## Configuration |
| 39 | + |
| 40 | +```typescript |
| 41 | +interface MultiTenancyPluginConfig { |
| 42 | + /** Enable/disable the plugin. Default: true */ |
| 43 | + enabled?: boolean; |
| 44 | + |
| 45 | + /** Field name for tenant identification. Default: 'tenant_id' */ |
| 46 | + tenantField?: string; |
| 47 | + |
| 48 | + /** Strict mode prevents cross-tenant queries. Default: true */ |
| 49 | + strictMode?: boolean; |
| 50 | + |
| 51 | + /** Tenant resolver function to get current tenant from context */ |
| 52 | + tenantResolver?: (context: any) => string | Promise<string>; |
| 53 | + |
| 54 | + /** Schema isolation mode: 'none', 'table-prefix', 'separate-schema'. Default: 'none' */ |
| 55 | + schemaIsolation?: 'none' | 'table-prefix' | 'separate-schema'; |
| 56 | + |
| 57 | + /** Objects exempt from tenant isolation. Default: [] */ |
| 58 | + exemptObjects?: string[]; |
| 59 | + |
| 60 | + /** Auto-create tenant_id field on objects. Default: true */ |
| 61 | + autoAddTenantField?: boolean; |
| 62 | + |
| 63 | + /** Enable tenant context validation. Default: true */ |
| 64 | + validateTenantContext?: boolean; |
| 65 | + |
| 66 | + /** Throw error when tenant context is missing. Default: true */ |
| 67 | + throwOnMissingTenant?: boolean; |
| 68 | + |
| 69 | + /** Enable audit logging for cross-tenant access attempts. Default: true */ |
| 70 | + enableAudit?: boolean; |
| 71 | +} |
| 72 | +``` |
| 73 | + |
| 74 | +## How It Works |
| 75 | + |
| 76 | +### 1. Query Filtering (beforeFind) |
| 77 | + |
| 78 | +The plugin automatically injects tenant filters into all queries: |
| 79 | + |
| 80 | +```typescript |
| 81 | +// User query |
| 82 | +const accounts = await objectql.find('accounts', { status: 'active' }); |
| 83 | + |
| 84 | +// Transformed query (tenant_id auto-injected) |
| 85 | +// SELECT * FROM accounts WHERE status = 'active' AND tenant_id = 'tenant-123' |
| 86 | +``` |
| 87 | + |
| 88 | +### 2. Auto-set Tenant ID (beforeCreate) |
| 89 | + |
| 90 | +New records automatically get the current tenant's ID: |
| 91 | + |
| 92 | +```typescript |
| 93 | +// User code |
| 94 | +await objectql.create('accounts', { name: 'Acme Corp' }); |
| 95 | + |
| 96 | +// Stored data |
| 97 | +// { name: 'Acme Corp', tenant_id: 'tenant-123' } |
| 98 | +``` |
| 99 | + |
| 100 | +### 3. Cross-tenant Protection (beforeUpdate/beforeDelete) |
| 101 | + |
| 102 | +Updates and deletes are verified to match the current tenant: |
| 103 | + |
| 104 | +```typescript |
| 105 | +// Attempting to update another tenant's record throws error |
| 106 | +await objectql.update('accounts', recordId, { name: 'New Name' }); |
| 107 | +// TenantIsolationError: Cross-tenant update denied |
| 108 | +``` |
| 109 | + |
| 110 | +## Tenant Context Resolution |
| 111 | + |
| 112 | +The plugin resolves the tenant ID from the request context in this order: |
| 113 | + |
| 114 | +1. `context.tenantId` (explicit) |
| 115 | +2. `context.user.tenantId` (from user object) |
| 116 | +3. `context.user.tenant_id` (alternative naming) |
| 117 | + |
| 118 | +### Custom Tenant Resolver |
| 119 | + |
| 120 | +```typescript |
| 121 | +new MultiTenancyPlugin({ |
| 122 | + tenantResolver: async (context) => { |
| 123 | + // Custom logic to extract tenant ID |
| 124 | + const token = context.headers.authorization; |
| 125 | + const decoded = await verifyToken(token); |
| 126 | + return decoded.organizationId; |
| 127 | + }, |
| 128 | +}); |
| 129 | +``` |
| 130 | + |
| 131 | +## Exempt Objects |
| 132 | + |
| 133 | +Some objects may need to be accessible across tenants (e.g., users, tenants): |
| 134 | + |
| 135 | +```typescript |
| 136 | +new MultiTenancyPlugin({ |
| 137 | + exemptObjects: ['users', 'tenants', 'global_settings'], |
| 138 | +}); |
| 139 | +``` |
| 140 | + |
| 141 | +## Schema Isolation Modes |
| 142 | + |
| 143 | +### None (Default) |
| 144 | + |
| 145 | +All tenants share the same table with `tenant_id` column: |
| 146 | + |
| 147 | +```sql |
| 148 | +CREATE TABLE accounts ( |
| 149 | + id SERIAL PRIMARY KEY, |
| 150 | + tenant_id VARCHAR(255) NOT NULL, |
| 151 | + name VARCHAR(255), |
| 152 | + INDEX idx_tenant (tenant_id) |
| 153 | +); |
| 154 | +``` |
| 155 | + |
| 156 | +### Table Prefix |
| 157 | + |
| 158 | +Each tenant gets separate tables with a prefix: |
| 159 | + |
| 160 | +```sql |
| 161 | +CREATE TABLE accounts_tenant_1 (...); |
| 162 | +CREATE TABLE accounts_tenant_2 (...); |
| 163 | +``` |
| 164 | + |
| 165 | +### Separate Schema |
| 166 | + |
| 167 | +Each tenant gets a separate database schema: |
| 168 | + |
| 169 | +```sql |
| 170 | +CREATE SCHEMA tenant_1; |
| 171 | +CREATE TABLE tenant_1.accounts (...); |
| 172 | + |
| 173 | +CREATE SCHEMA tenant_2; |
| 174 | +CREATE TABLE tenant_2.accounts (...); |
| 175 | +``` |
| 176 | + |
| 177 | +## Audit Logging |
| 178 | + |
| 179 | +Access the audit logs to track tenant operations: |
| 180 | + |
| 181 | +```typescript |
| 182 | +const plugin = new MultiTenancyPlugin({ enableAudit: true }); |
| 183 | + |
| 184 | +// After operations |
| 185 | +const logs = plugin.getAuditLogs(100); // Get last 100 entries |
| 186 | + |
| 187 | +logs.forEach(log => { |
| 188 | + console.log(`${log.operation} on ${log.objectName} by tenant ${log.tenantId}`); |
| 189 | +}); |
| 190 | +``` |
| 191 | + |
| 192 | +## Integration with Plugin-Security |
| 193 | + |
| 194 | +Multi-tenancy works alongside the security plugin for tenant-scoped RBAC: |
| 195 | + |
| 196 | +```typescript |
| 197 | +const kernel = new ObjectStackKernel([ |
| 198 | + new MultiTenancyPlugin({ |
| 199 | + tenantField: 'tenant_id', |
| 200 | + }), |
| 201 | + new SecurityPlugin({ |
| 202 | + enableRowLevelSecurity: true, |
| 203 | + }), |
| 204 | +]); |
| 205 | +``` |
| 206 | + |
| 207 | +## Error Handling |
| 208 | + |
| 209 | +```typescript |
| 210 | +import { TenantIsolationError } from '@objectql/plugin-multitenancy'; |
| 211 | + |
| 212 | +try { |
| 213 | + await objectql.update('accounts', recordId, data); |
| 214 | +} catch (error) { |
| 215 | + if (error instanceof TenantIsolationError) { |
| 216 | + console.error('Tenant isolation violation:', error.details); |
| 217 | + // { |
| 218 | + // tenantId: 'tenant-123', |
| 219 | + // objectName: 'accounts', |
| 220 | + // operation: 'update', |
| 221 | + // reason: 'CROSS_TENANT_UPDATE' |
| 222 | + // } |
| 223 | + } |
| 224 | +} |
| 225 | +``` |
| 226 | + |
| 227 | +## Best Practices |
| 228 | + |
| 229 | +1. **Always set tenant context**: Ensure every request has tenant information |
| 230 | +2. **Use exempt objects sparingly**: Only exempt truly global objects |
| 231 | +3. **Enable strict mode in production**: Catch cross-tenant bugs early |
| 232 | +4. **Monitor audit logs**: Track potential security issues |
| 233 | +5. **Test tenant isolation**: Write tests to verify data separation |
| 234 | + |
| 235 | +## Architecture |
| 236 | + |
| 237 | +The plugin operates at the Hook layer and does NOT affect SQL generation: |
| 238 | + |
| 239 | +``` |
| 240 | +┌─────────────────────────────┐ |
| 241 | +│ plugin-multitenancy │ ← beforeFind/Create/Update/Delete hooks |
| 242 | +│ (Tenant Filter Injection) │ |
| 243 | +├─────────────────────────────┤ |
| 244 | +│ plugin-security │ ← RBAC enforcement |
| 245 | +├─────────────────────────────┤ |
| 246 | +│ QueryService → QueryAST │ ← Core: abstract query building |
| 247 | +├─────────────────────────────┤ |
| 248 | +│ Driver → Knex → SQL │ ← Driver: SQL generation (UNTOUCHED) |
| 249 | +└─────────────────────────────┘ |
| 250 | +``` |
| 251 | + |
| 252 | +## License |
| 253 | + |
| 254 | +MIT |
| 255 | + |
| 256 | +## Contributing |
| 257 | + |
| 258 | +See the [ObjectQL Contributing Guide](../../../CONTRIBUTING.md). |
0 commit comments