DOMAIN:DATABASE — MULTI-TENANT ARCHITECTURE¶
OWNER: boris, yoanna
UPDATED: 2026-03-24
SCOPE: tenant isolation strategy for all GE client projects
MULTITENANCY:OVERVIEW¶
GE builds SaaS applications for SME clients. Every application serves multiple end-customers (tenants).
Tenant isolation is non-negotiable — one tenant must NEVER see another tenant's data.
Failure here is a security incident, compliance violation, and business-ending event.
Three strategies exist. GE uses a decision tree to pick the right one per project.
MULTITENANCY:DECISION_TREE¶
EVALUATE IN ORDER¶
QUESTION_1: Does the client require physical data isolation (regulated industry, contractual requirement)?
YES → schema-per-tenant or database-per-tenant
NO → continue
QUESTION_2: Will the application exceed 100 tenants?
YES → application-level isolation or RLS (schema-per-tenant becomes unmanageable)
NO → continue
QUESTION_3: Does the client need per-tenant schema customization (custom fields, custom tables)?
YES → schema-per-tenant
NO → continue
QUESTION_4: Is the development team experienced with RLS?
YES → RLS (strongest isolation with shared tables)
NO → application-level isolation (simpler but higher risk)
GE DEFAULT RECOMMENDATION¶
DEFAULT: Row-Level Security (RLS) on shared tables
WHY: best balance of isolation strength, operational simplicity, and performance
ESCALATE_TO_SCHEMA_PER_TENANT: regulated industries, contractual physical isolation requirement
NEVER: database-per-tenant (operational nightmare at scale — connection pools, migrations, backups multiply)
MULTITENANCY:APPLICATION_LEVEL_ISOLATION¶
HOW IT WORKS¶
Every table has a tenant_id column.
Every query includes WHERE tenant_id = :current_tenant_id.
Application middleware sets current tenant from authentication context.
No database-level enforcement — isolation is purely in application code.
IMPLEMENTATION¶
// Drizzle schema
export const orders = pgTable('orders', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull().references(() => tenants.id),
// ... other columns
});
// Query — developer MUST include tenant filter
const results = await db
.select()
.from(orders)
.where(eq(orders.tenantId, ctx.tenantId));
ADVANTAGES¶
SIMPLE: no PostgreSQL-specific features needed
PORTABLE: works on any database
EASY_TO_UNDERSTAND: every developer can read and debug
DISADVANTAGES¶
FRAGILE: one forgotten WHERE clause = data leak between tenants
UNAUDITABLE: no database-level proof of isolation for compliance
BUG_PRONE: LLM-generated code especially likely to forget tenant filter
NO_ENFORCEMENT: database will happily serve cross-tenant data if asked
MITIGATION IF CHOSEN¶
MUST: create a Drizzle helper that auto-injects tenant filter:
function tenantQuery<T>(table: T, tenantId: string) {
return db.select().from(table).where(eq(table.tenantId, tenantId));
}
MUST: add Koen (linting agent) rules to flag queries missing tenant_id filter
NOTE: even with mitigations, this is the weakest isolation strategy
MULTITENANCY:SCHEMA_PER_TENANT¶
HOW IT WORKS¶
Each tenant gets a PostgreSQL schema (namespace): tenant_abc123.orders vs tenant_def456.orders.
Application sets search_path to the tenant's schema on each connection/transaction.
Shared data (lookup tables, system config) lives in public schema.
IMPLEMENTATION¶
-- Create tenant schema
CREATE SCHEMA tenant_abc123;
-- Create tables in tenant schema
CREATE TABLE tenant_abc123.orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- no tenant_id needed — schema IS the isolation
);
// Set search_path per request
await db.execute(sql`SET search_path TO ${sql.identifier(tenantSchema)}, public`);
ADVANTAGES¶
STRONG_ISOLATION: database-level boundary — even raw SQL queries are scoped
CUSTOMIZABLE: tenants can have custom tables/columns without affecting others
COMPLIANCE_FRIENDLY: can demonstrate physical separation for auditors
BACKUP_GRANULARITY: can dump/restore individual tenant schemas
DISADVANTAGES¶
MIGRATION_COMPLEXITY: every migration runs N times (once per tenant schema)
CONNECTION_POOL: search_path changes affect connection reuse — must reset per query
OPERATIONAL_OVERHEAD: >100 schemas becomes hard to manage
MONITORING: must aggregate metrics across schemas
SCALING_LIMIT: PostgreSQL catalog bloat with thousands of schemas — real limit around 500-1000
MIGRATION STRATEGY FOR SCHEMA-PER-TENANT¶
# Script to run migration across all tenant schemas
for schema in $(psql -t -c "SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%'"); do
psql -c "SET search_path TO $schema; \i migration.sql"
done
NOTE: this must be atomic — if migration fails for one tenant, investigate before continuing
NOTE: consider running migrations in parallel for many tenants (with connection limit awareness)
MULTITENANCY:ROW_LEVEL_SECURITY¶
HOW IT WORKS¶
Shared tables with tenant_id column — like application-level, but enforced BY THE DATABASE.
PostgreSQL automatically filters rows based on policies — no way to bypass from application code.
Even a SQL injection cannot access another tenant's data (if policies are correctly written).
SETUP¶
-- 1. Enable RLS on the table
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- 2. Force RLS for table owner too (important for superuser safety)
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
-- 3. Create policy
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);
APPLICATION INTEGRATION¶
// Middleware — runs before every database query
async function setTenantContext(tenantId: string) {
await db.execute(sql`SET LOCAL app.current_tenant_id = ${tenantId}`);
}
// In request handler
await db.transaction(async (tx) => {
await tx.execute(sql`SET LOCAL app.current_tenant_id = ${ctx.tenantId}`);
// All subsequent queries in this transaction are automatically filtered
const orders = await tx.select().from(ordersTable); // only returns this tenant's orders
});
NOTE: SET LOCAL scopes the setting to the current transaction — essential for connection pooling
NOTE: without a transaction, use SET (session-level) but reset after query
ALERT: if app.current_tenant_id is not set, the policy blocks ALL rows — fail-safe behavior
POLICY PATTERNS¶
SELECT ONLY (read isolation)¶
CREATE POLICY tenant_read ON orders
FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
INSERT ONLY (write isolation)¶
CREATE POLICY tenant_insert ON orders
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);
ALL OPERATIONS¶
CREATE POLICY tenant_all ON orders
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);
ADMIN BYPASS (for system operations)¶
-- Create a role for admin operations
CREATE ROLE app_admin;
-- Policy: admin sees all, regular users see own tenant
CREATE POLICY tenant_or_admin ON orders
USING (
tenant_id = current_setting('app.current_tenant_id', true)::uuid
OR current_user = 'app_admin'
);
NOTE: current_setting('...', true) returns NULL if not set (instead of error) — needed for admin path
ALERT: admin bypass must be carefully restricted — audit all uses
SHARED DATA (lookup tables)¶
-- Public rows (tenant_id IS NULL) visible to everyone
-- Tenant-specific rows visible only to that tenant
CREATE POLICY shared_and_private ON categories
USING (
tenant_id IS NULL
OR tenant_id = current_setting('app.current_tenant_id')::uuid
);
MULTITENANCY:RLS_PERFORMANCE¶
INDEX REQUIREMENT¶
CRITICAL: tenant_id MUST be the first column in composite indexes for RLS tables
WHY: every query has an implicit WHERE tenant_id = X — without index, full table scan
-- GOOD: tenant_id first
CREATE INDEX idx_orders_tenant_created ON orders (tenant_id, created_at);
-- BAD: tenant_id not in index — RLS filter cannot use this index
CREATE INDEX idx_orders_created ON orders (created_at);
QUERY PLAN VERIFICATION¶
ALWAYS run EXPLAIN ANALYZE on key queries after enabling RLS:
SET app.current_tenant_id = 'test-tenant-uuid';
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at DESC LIMIT 20;
CHECK: the Filter or Index Cond should show tenant_id = ...
CHECK: no Seq Scan on large tables — should use index with tenant_id
PERFORMANCE IMPACT¶
OVERHEAD: 1-5% for well-indexed tables — negligible
OVERHEAD: 10-50% if tenant_id is missing from indexes — fix indexes, not RLS
OVERHEAD: significant if RLS policy uses subqueries or function calls — keep policies simple
RULE: RLS policy should be a simple column comparison — no subqueries, no function calls
NOTE: PostgreSQL query planner inlines RLS policies — they are optimized with the query
MULTITENANCY:RLS_TESTING¶
UNIT TESTS¶
describe('RLS: orders tenant isolation', () => {
const tenantA = 'tenant-a-uuid';
const tenantB = 'tenant-b-uuid';
beforeAll(async () => {
// Create test data for both tenants
await adminDb.insert(orders).values([
{ tenantId: tenantA, amount: 100 },
{ tenantId: tenantB, amount: 200 },
]);
});
it('tenant A cannot see tenant B orders', async () => {
await db.transaction(async (tx) => {
await tx.execute(sql`SET LOCAL app.current_tenant_id = ${tenantA}`);
const results = await tx.select().from(orders);
expect(results.every(r => r.tenantId === tenantA)).toBe(true);
expect(results.some(r => r.tenantId === tenantB)).toBe(false);
});
});
it('tenant A cannot insert into tenant B', async () => {
await db.transaction(async (tx) => {
await tx.execute(sql`SET LOCAL app.current_tenant_id = ${tenantA}`);
await expect(
tx.insert(orders).values({ tenantId: tenantB, amount: 300 })
).rejects.toThrow(); // WITH CHECK violation
});
});
it('no tenant context returns zero rows', async () => {
// app.current_tenant_id not set — policy should block all rows
const results = await db.select().from(orders);
expect(results.length).toBe(0);
});
});
PENETRATION TESTING CHECKLIST¶
- [ ] Direct query without tenant context returns zero rows
- [ ] Setting tenant context to non-existent tenant returns zero rows
- [ ] Cannot UPDATE rows belonging to another tenant
- [ ] Cannot DELETE rows belonging to another tenant
- [ ] Cannot INSERT with another tenant's tenant_id
- [ ] Aggregate queries (COUNT, SUM) only reflect current tenant
- [ ] JOIN queries respect RLS on all joined tables
- [ ] Subqueries respect RLS
- [ ] CTE (WITH) queries respect RLS
- [ ] COPY command respects RLS (it does since PG 16)
MULTITENANCY:RLS_PITFALLS¶
PITFALL: PGBOUNCER TRANSACTION MODE¶
PROBLEM: SET LOCAL only works in transactions. PgBouncer transaction mode releases connection at transaction end.
SOLUTION: always wrap database calls in explicit transactions when using RLS + PgBouncer
ALERT: a bare SELECT without explicit transaction may execute without tenant context
PITFALL: DRIZZLE MIGRATION QUERIES¶
PROBLEM: Drizzle migration runner may not set tenant context — migration queries bypass RLS if run as table owner
SOLUTION: migrations should run as a superuser or the table owner — RLS does not apply to table owners UNLESS FORCE ROW LEVEL SECURITY is set
CHECK: verify ALTER TABLE ... FORCE ROW LEVEL SECURITY is set on all tenant tables
PITFALL: BACKUP AND RESTORE¶
PROBLEM: pg_dump runs as superuser — dumps all tenants' data
SOLUTION: this is correct for backups. For tenant-specific export, use COPY with WHERE clause
NOTE: restoring a backup restores all tenants — cannot selectively restore one tenant with RLS alone
PITFALL: POSTGRES SUPERUSER BYPASSES RLS¶
PROBLEM: superuser and table owner bypass RLS by default
SOLUTION: FORCE ROW LEVEL SECURITY makes policies apply to table owner too
SOLUTION: application should connect as a non-superuser role
ALERT: NEVER use the superuser role for application connections
PITFALL: FOREIGN KEY REFERENCES ACROSS TENANTS¶
PROBLEM: FK constraint does not enforce tenant isolation — tenant A's order could reference tenant B's user
SOLUTION: include tenant_id in FK: FOREIGN KEY (tenant_id, user_id) REFERENCES users (tenant_id, id)
REQUIRES: composite unique constraint on referenced table: UNIQUE (tenant_id, id)
PITFALL: MISSING RLS ON NEW TABLES¶
PROBLEM: new table added without RLS — data accessible to all tenants
SOLUTION: DBA review checklist includes RLS verification for every new table with tenant_id
AUTOMATION: CI check that every table with a tenant_id column has RLS enabled
MULTITENANCY:GE_IMPLEMENTATION_GUIDE¶
STANDARD SETUP FOR NEW CLIENT PROJECT¶
-- 1. Create application role (non-superuser)
CREATE ROLE app_user LOGIN PASSWORD '...';
GRANT USAGE ON SCHEMA public TO app_user;
-- 2. For each table with tenant_id:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO app_user;
-- 3. Create admin role for migrations and system operations
CREATE ROLE app_admin LOGIN PASSWORD '...';
GRANT ALL ON ALL TABLES IN SCHEMA public TO app_admin;
DRIZZLE MIDDLEWARE PATTERN¶
// src/db/middleware/tenant.ts
import { sql } from 'drizzle-orm';
export function withTenant<T>(
db: DrizzleClient,
tenantId: string,
callback: (tx: Transaction) => Promise<T>
): Promise<T> {
return db.transaction(async (tx) => {
await tx.execute(sql`SET LOCAL app.current_tenant_id = ${tenantId}`);
return callback(tx);
});
}
// Usage
const orders = await withTenant(db, ctx.tenantId, async (tx) => {
return tx.select().from(ordersTable).where(eq(ordersTable.status, 'pending'));
});
NOTE: this pattern ensures tenant context is ALWAYS set before any query
NOTE: if a developer forgets to use withTenant, RLS blocks all rows — fail-safe