Multi-tenancy Implementation in Intent
Overview
Multi-tenancy is a core architectural feature of Intent, allowing the system to serve multiple isolated customer environments (tenants) from a single deployment. The implementation follows a "shared database, separate schemas" approach with comprehensive tenant isolation at multiple levels: data access, authentication, authorization, and processing.
Core Concepts
Tenant Identification
Every tenant in the system is identified by a unique tenant_id
(UUID), which is:
- Required in all commands and events
- Stored in all database tables
- Included in JWT tokens for authentication
- Used to scope workflow execution
Tenant Isolation Layers
The system implements tenant isolation at multiple layers:
- Data Layer: Database-level isolation through tenant_id columns and row-level security
- Domain Layer: Commands and events are scoped to tenants
- API Layer: Authentication and authorization enforce tenant boundaries
- Processing Layer: Workflows and projections maintain tenant isolation
Implementation Details
Data Layer Isolation
Database Schema Design
The database schema includes tenant_id
as a required field in all multi-tenant tables:
CREATE TABLE "public"."aggregates" ( "id" uuid NOT NULL, "tenant_id" uuid NOT NULL, "type" text NOT NULL, "version" int4 NOT NULL, "snapshot" jsonb, "created_at" timestamptz NOT NULL, "schema_version" int4 NOT NULL DEFAULT 1 ); ALTER TABLE "public"."aggregates" ADD CONSTRAINT "aggregates_pkey" PRIMARY KEY ("id", "tenant_id");
Key aspects:
tenant_id
is defined as NOT NULL to ensure every record belongs to a tenant- Composite primary keys include
tenant_id
to prevent ID collisions across tenants - Indexes on
tenant_id
improve query performance for tenant-specific data
Row-Level Security (RLS)
The system uses PostgreSQL's Row-Level Security to enforce tenant isolation at the database level:
// From src/infra/projections/genRlsSql.ts // Add tenant_id check for multi-tenant tables if not already present if (!hasTenantCheck && hasMultiTenancy) { // We'll add the tenant check, assuming the table has a tenant_id column sqlCondition = `${sqlCondition} AND current_setting('request.jwt.claims', true)::json->>'tenant_id' = tenant_id::text`; }
This ensures that even if application code fails to filter by tenant_id, the database will still enforce tenant isolation.
Session Context
The system sets a tenant context at the database session level:
// From src/infra/pg/pg-command-store.ts private async setTenantContext(client: PoolClient, tenantId: UUID): Promise<void> { await client.query(`SET LOCAL app.tenant_id = '${tenantId}'`); }
This allows database functions and triggers to access the current tenant context.
Domain Layer Isolation
Commands and Events
Both commands and events require a tenant_id
field:
// From src/core/contracts.ts export interface Command<T = any> { id: UUID; tenant_id: UUID; type: string; payload: T; status?: 'pending' | 'consumed' | 'processed' | 'failed'; metadata?: Metadata; } export interface Event<T = any> { id: UUID; tenant_id: UUID; type: string; payload: T; aggregateId: UUID; aggregateType: string; version: number; metadata?: Metadata; }
The command bus enforces tenant consistency between the command and its payload:
// From src/core/command-bus.ts const cmdTenant = cmd.tenant_id; const payloadTenant = (cmd.payload as any)?.tenantId; if (payloadTenant && payloadTenant !== cmdTenant) { throw new Error(`[Command-bus] Mismatch between command.tenant_id and payload.tenantId`); }
Projections
Projections maintain tenant isolation when updating read models:
// From src/core/system/read-models/system-status.projection.ts async on(event) { const { tenant_id, aggregateId, payload, metadata } = event; if (!tenant_id || !aggregateId || !payload) { throw new Error(`[System-Status-Projection] Invalid event ${event.type}. Missing tenant_id, aggregateId, or payload.`); } const upsertData = { id: aggregateId, tenant_id, // ... other fields }; await updater.upsert(tenant_id, aggregateId, upsertData); }
API Layer Isolation
JWT-Based Authentication
The system extracts tenant information from JWT tokens:
// From src/infra/supabase/edge-functions/command.ts // Extract the tenant_id from the JWT claims const tenantId = user.app_metadata?.tenant_id; if (!tenantId) { return new Response(JSON.stringify({ error: 'Missing tenant_id claim' }), { status: 403, headers: { 'Content-Type': 'application/json' } }); }
Authorization Policies
Access control policies enforce tenant boundaries:
// SQL query with tenant isolation AND current_setting('request.jwt.claims', true)::json->>'tenant_id' = tenant_id::text
Processing Layer Isolation
Temporal Workflows
Workflow execution is scoped by tenant:
// From src/infra/temporal/workflow-router.ts const {tenant_id} = cmd; const {aggregateType, aggregateId} = cmd.payload; const workflowId = this.getAggregateWorkflowId(tenant_id, aggregateType, aggregateId); // Execute with tenant tags for observability const result = await this.client.workflow.execute(processCommand, { taskQueue: this.taskQueue, workflowId, searchAttributes: { tenantId: [`${tenant_id}`], }, args: [tenant_id, aggregateType, aggregateId, cmd], });
Event Publication
Events are published to tenant-specific channels:
// From src/infra/supabase/supabase-publisher.ts .channel(`tenant-${event.tenant_id}`)
Testing Multi-tenancy
The system includes integration tests that verify tenant isolation:
// From src/infra/integration-tests/projection.integration.test.ts // Verify tenant isolation expect(result.rows.every((row: { tenant_id: any; }) => row.tenant_id === tenantId)).toBe(true); expect(result.rows.every((row: { tenant_id: any; }) => row.tenant_id !== tenantId2)).toBe(true);
Benefits of the Multi-tenancy Approach
- Resource Efficiency: A single deployment serves multiple customers
- Operational Simplicity: Centralized management and monitoring
- Data Isolation: Strong security boundaries between tenants
- Scalability: Can scale to support many tenants
- Consistent Experience: All tenants benefit from the same features and updates
Challenges and Considerations
- Query Performance: Filtering by tenant_id can impact performance without proper indexing
- Development Complexity: All code must be tenant-aware
- Testing Overhead: Must test for proper tenant isolation
- Security Risks: Bugs in tenant isolation could lead to data leakage
- Resource Contention: Noisy neighbor problems without proper resource allocation
Integration with Other Patterns
Multi-tenancy in Intent integrates with several other patterns:
- Event Sourcing: Events are scoped by tenant_id
- CQRS: Read models maintain tenant isolation
- Domain-Driven Design: Aggregates operate within tenant boundaries
- Temporal Workflows: Workflows respect tenant isolation