Skip to content

Commit c02f118

Browse files
Copilothotlong
andcommitted
feat(plugin-multitenancy): implement multi-tenancy plugin with automatic tenant isolation
- Package scaffolding with TypeScript, Zod, and dependencies - Core components: MultiTenancyPlugin, TenantResolver, QueryFilterInjector, MutationGuard - beforeFind/beforeCount hooks: auto-inject tenant_id filter on all queries - beforeCreate hook: auto-set tenant_id on new records - beforeUpdate/beforeDelete hooks: verify tenant_id matches current tenant - Strict mode: prevent cross-tenant data access with errors - Configuration: custom tenant resolver, schema isolation modes, exempt objects - Comprehensive tests: 58 unit and integration tests (all passing) - Documentation: README, CHANGELOG, and content/docs/extending/multitenancy.mdx Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 72366bc commit c02f118

18 files changed

Lines changed: 2770 additions & 0 deletions

content/docs/extending/multitenancy.mdx

Lines changed: 483 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# @objectql/plugin-multitenancy
2+
3+
## 4.2.0 (2026-02-08)
4+
5+
### Features
6+
7+
- **Initial Release**: Multi-tenancy plugin for automatic tenant isolation
8+
- **Query Filtering**: Auto-inject `tenant_id` filters on all queries via `beforeFind` hook
9+
- **Auto-set Tenant ID**: Automatically set `tenant_id` on new records via `beforeCreate` hook
10+
- **Cross-tenant Protection**: Verify `tenant_id` on updates and deletes via `beforeUpdate` and `beforeDelete` hooks
11+
- **Strict Mode**: Prevent cross-tenant data access with configurable error handling
12+
- **Custom Tenant Resolver**: Support custom functions to extract tenant ID from context
13+
- **Schema Isolation**: Support for shared tables, table-prefix, and separate-schema modes
14+
- **Exempt Objects**: Configure objects that should not be tenant-isolated
15+
- **Audit Logging**: Track all tenant-related operations with in-memory audit log
16+
- **Comprehensive Tests**: Unit and integration tests with Memory driver
17+
- **TypeScript Support**: Full type definitions with Zod schema validation
18+
19+
### Architecture
20+
21+
- Plugin-based implementation (not core modification)
22+
- Hook-based filter injection (operates at Hook layer, above SQL generation)
23+
- Zero changes to core query compilation pipeline
24+
- Compatible with `@objectql/plugin-security` for tenant-scoped RBAC
25+
26+
### Components
27+
28+
- `MultiTenancyPlugin`: Main plugin class implementing `RuntimePlugin` interface
29+
- `TenantResolver`: Extracts tenant ID from request context
30+
- `QueryFilterInjector`: Injects tenant filters into queries
31+
- `MutationGuard`: Verifies tenant isolation on mutations
32+
- `TenantIsolationError`: Custom error for tenant violations
33+
34+
### Configuration
35+
36+
All configuration options are validated via Zod schema:
37+
- `enabled`: Enable/disable plugin (default: true)
38+
- `tenantField`: Field name for tenant ID (default: 'tenant_id')
39+
- `strictMode`: Prevent cross-tenant access (default: true)
40+
- `tenantResolver`: Custom tenant extraction function
41+
- `schemaIsolation`: Isolation mode (default: 'none')
42+
- `exemptObjects`: Objects exempt from isolation (default: [])
43+
- `autoAddTenantField`: Auto-create tenant_id field (default: true)
44+
- `validateTenantContext`: Validate tenant presence (default: true)
45+
- `throwOnMissingTenant`: Error on missing tenant (default: true)
46+
- `enableAudit`: Enable audit logging (default: true)
47+
48+
### Documentation
49+
50+
- README with comprehensive usage examples
51+
- JSDoc comments on all public APIs
52+
- Integration examples with plugin-security
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
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

Comments
 (0)