# Multi-Tenancy Guide ## Overview ERP Suite implementa **multi-tenancy a nivel de schema** usando PostgreSQL Row-Level Security (RLS) para garantizar aislamiento completo de datos entre tenants. **Modelo:** Shared database, shared schema, **isolated by tenant_id** ## Architecture ### Tenant Isolation Strategy ``` ┌─────────────────────────────────────────┐ │ Single PostgreSQL Database │ │ │ │ ┌────────────────────────────────────┐ │ │ │ Table: products.products │ │ │ │ ┌──────────────────────────────┐ │ │ │ │ │ Tenant A rows (tenant_id=A) │ │ │ │ │ ├──────────────────────────────┤ │ │ │ │ │ Tenant B rows (tenant_id=B) │ │ │ │ │ ├──────────────────────────────┤ │ │ │ │ │ Tenant C rows (tenant_id=C) │ │ │ │ │ └──────────────────────────────┘ │ │ │ │ │ │ │ │ RLS Policy: WHERE tenant_id = │ │ │ │ current_setting('app.current_ │ │ │ │ tenant_id')::uuid │ │ │ └────────────────────────────────────┘ │ └─────────────────────────────────────────┘ ``` ### Why RLS (Row-Level Security)? **Advantages:** - **Security at database level** - Even if application has bugs, data is isolated - **Transparent to application** - No manual filtering in every query - **Works with BI tools** - Reports automatically scoped to tenant - **Audit trail** - PostgreSQL logs enforce tenant context **Disadvantages:** - PostgreSQL specific (not portable to MySQL/MongoDB) - Slight performance overhead (minimal) - Requires SET LOCAL on each connection ## Implementation ### 1. Database Schema Design #### Tenant Table ```sql -- Schema: auth CREATE TABLE auth.tenants ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL, slug VARCHAR(100) NOT NULL UNIQUE, status VARCHAR(20) NOT NULL DEFAULT 'active', plan VARCHAR(50) NOT NULL DEFAULT 'free', settings JSONB DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ ); -- Index CREATE INDEX idx_tenants_slug ON auth.tenants(slug) WHERE deleted_at IS NULL; CREATE INDEX idx_tenants_status ON auth.tenants(status); ``` #### User-Tenant Relationship ```sql CREATE TABLE auth.users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id), email VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL, -- ... CONSTRAINT uq_user_email_tenant UNIQUE (email, tenant_id) ); CREATE INDEX idx_users_tenant_id ON auth.users(tenant_id); ``` #### Standard Table Structure **Every table** (except auth.tenants) must have: ```sql CREATE TABLE {schema}.{table} ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id), -- Business columns -- ... -- Audit columns created_by UUID REFERENCES auth.users(id), updated_by UUID REFERENCES auth.users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ -- Soft delete ); -- Standard indexes CREATE INDEX idx_{table}_tenant_id ON {schema}.{table}(tenant_id); CREATE INDEX idx_{table}_deleted_at ON {schema}.{table}(deleted_at); ``` ### 2. Row-Level Security Policies #### Enable RLS ```sql ALTER TABLE {schema}.{table} ENABLE ROW LEVEL SECURITY; ``` #### Standard Policy ```sql -- Policy: tenant_isolation -- Applies to: SELECT, INSERT, UPDATE, DELETE CREATE POLICY tenant_isolation ON {schema}.{table} USING (tenant_id = current_setting('app.current_tenant_id')::uuid); ``` **What this does:** - `USING` clause filters SELECT queries - Also applies to UPDATE and DELETE - INSERT requires tenant_id to match #### Super Admin Bypass (Optional) ```sql -- Allow super admins to see all data CREATE POLICY admin_bypass ON {schema}.{table} USING ( current_setting('app.current_tenant_id')::uuid = tenant_id OR current_setting('app.is_super_admin', true)::boolean = true ); ``` #### Example: Full RLS Setup ```sql -- Create table CREATE TABLE products.products ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id), name VARCHAR(255) NOT NULL, sku VARCHAR(100), price NUMERIC(12, 2), created_by UUID REFERENCES auth.users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ ); -- Indexes CREATE INDEX idx_products_tenant_id ON products.products(tenant_id); CREATE INDEX idx_products_sku ON products.products(tenant_id, sku) WHERE deleted_at IS NULL; -- Enable RLS ALTER TABLE products.products ENABLE ROW LEVEL SECURITY; -- Create policy CREATE POLICY tenant_isolation ON products.products USING (tenant_id = current_setting('app.current_tenant_id')::uuid); -- Grant permissions GRANT SELECT, INSERT, UPDATE, DELETE ON products.products TO erp_app_user; ``` ### 3. Backend Implementation #### Middleware: Set Tenant Context ```typescript // middleware/tenant-context.middleware.ts import { Request, Response, NextFunction } from 'express'; import pool from '../config/database'; export const setTenantContext = async ( req: Request, res: Response, next: NextFunction ) => { const tenantId = req.user?.tenantId; // From JWT if (!tenantId) { return res.status(401).json({ error: 'Tenant not found' }); } try { // Set tenant context for this connection await pool.query( `SET LOCAL app.current_tenant_id = $1`, [tenantId] ); // Optional: Set super admin flag if (req.user?.role === 'super_admin') { await pool.query(`SET LOCAL app.is_super_admin = true`); } next(); } catch (error) { console.error('Error setting tenant context:', error); res.status(500).json({ error: 'Internal server error' }); } }; ``` **Apply to all routes:** ```typescript // app.ts import express from 'express'; import { authenticateJWT } from './middleware/auth.middleware'; import { setTenantContext } from './middleware/tenant-context.middleware'; const app = express(); // Apply to all routes after authentication app.use(authenticateJWT); app.use(setTenantContext); // Now all routes are tenant-scoped app.use('/api/products', productRoutes); ``` #### BaseService with Tenant Enforcement ```typescript // shared/services/base.service.ts export abstract class BaseService { constructor( protected tableName: string, protected schema: string ) {} async findAll( tenantId: string, filters?: Filters, pagination?: Pagination ): Promise> { const { page = 1, limit = 20 } = pagination || {}; const offset = (page - 1) * limit; // RLS automatically filters by tenant_id // No need to add WHERE tenant_id = $1 const query = ` SELECT * FROM ${this.schema}.${this.tableName} WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2 `; const result = await pool.query(query, [limit, offset]); return { data: result.rows, total: result.rowCount, page, limit }; } async create( data: CreateDto, tenantId: string, userId: string ): Promise { const columns = Object.keys(data); const values = Object.values(data); // Explicitly add tenant_id columns.push('tenant_id', 'created_by'); values.push(tenantId, userId); const placeholders = values.map((_, i) => `$${i + 1}`).join(', '); const query = ` INSERT INTO ${this.schema}.${this.tableName} (${columns.join(', ')}) VALUES (${placeholders}) RETURNING * `; const result = await pool.query(query, values); return result.rows[0]; } // Other CRUD methods... } ``` ### 4. Frontend Implementation #### Store Tenant Info in Auth State ```typescript // stores/auth.store.ts import create from 'zustand'; interface User { id: string; email: string; tenantId: string; tenantName: string; role: string; } interface AuthState { user: User | null; token: string | null; login: (email: string, password: string) => Promise; logout: () => void; } export const useAuthStore = create((set) => ({ user: null, token: null, login: async (email, password) => { const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) }); const { user, token } = await response.json(); set({ user, token }); localStorage.setItem('token', token); }, logout: () => { set({ user: null, token: null }); localStorage.removeItem('token'); } })); ``` #### Display Tenant Context ```tsx // components/TenantIndicator.tsx import { useAuthStore } from '../stores/auth.store'; export const TenantIndicator = () => { const user = useAuthStore((state) => state.user); if (!user) return null; return (
Tenant: {user.tenantName}
); }; ``` ## Multi-Tenant SaaS Features ### Onboarding New Tenant ```typescript // services/tenant.service.ts export class TenantService { async createTenant(data: CreateTenantDto): Promise { const client = await pool.connect(); try { await client.query('BEGIN'); // 1. Create tenant const tenantQuery = ` INSERT INTO auth.tenants (name, slug, plan) VALUES ($1, $2, $3) RETURNING * `; const tenantResult = await client.query(tenantQuery, [ data.name, data.slug, data.plan || 'free' ]); const tenant = tenantResult.rows[0]; // 2. Create admin user for tenant const passwordHash = await bcrypt.hash(data.adminPassword, 10); const userQuery = ` INSERT INTO auth.users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING * `; await client.query(userQuery, [ tenant.id, data.adminEmail, passwordHash, 'admin' ]); // 3. Initialize default data (catalogs, etc.) await this.initializeTenantData(client, tenant.id); await client.query('COMMIT'); return tenant; } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } private async initializeTenantData(client: PoolClient, tenantId: string) { // Create default categories await client.query(` INSERT INTO products.categories (tenant_id, name) VALUES ($1, 'General'), ($1, 'Services') `, [tenantId]); // Create default warehouse await client.query(` INSERT INTO inventory.warehouses (tenant_id, name, code) VALUES ($1, 'Main Warehouse', 'WH01') `, [tenantId]); // More defaults... } } ``` ### Tenant Switching (for super admins) ```typescript // middleware/switch-tenant.middleware.ts export const switchTenant = async ( req: Request, res: Response, next: NextFunction ) => { const targetTenantId = req.headers['x-tenant-id'] as string; if (req.user?.role !== 'super_admin') { return res.status(403).json({ error: 'Only super admins can switch tenants' }); } if (!targetTenantId) { return res.status(400).json({ error: 'Target tenant ID required' }); } // Override tenant context await pool.query(`SET LOCAL app.current_tenant_id = $1`, [targetTenantId]); next(); }; ``` ## Data Isolation Testing ### Test Suite ```typescript // __tests__/multi-tenancy.test.ts import { pool } from '../config/database'; import { TenantService } from '../services/tenant.service'; import { ProductService } from '../modules/products/product.service'; describe('Multi-Tenancy Data Isolation', () => { let tenantA: Tenant; let tenantB: Tenant; beforeAll(async () => { tenantA = await TenantService.createTenant({ name: 'Tenant A', ... }); tenantB = await TenantService.createTenant({ name: 'Tenant B', ... }); }); it('should isolate data between tenants', async () => { // Set context to Tenant A await pool.query(`SET LOCAL app.current_tenant_id = $1`, [tenantA.id]); // Create product for Tenant A const productA = await ProductService.create({ name: 'Product A', price: 100 }, tenantA.id, 'user-a'); // Switch context to Tenant B await pool.query(`SET LOCAL app.current_tenant_id = $1`, [tenantB.id]); // Try to fetch products (should only see Tenant B's data) const products = await ProductService.findAll(tenantB.id); expect(products.data).toHaveLength(0); // Tenant B has no products expect(products.data).not.toContainEqual(productA); }); it('should prevent cross-tenant access', async () => { // Set context to Tenant A await pool.query(`SET LOCAL app.current_tenant_id = $1`, [tenantA.id]); // Create product for Tenant A const productA = await ProductService.create({ name: 'Product A' }, tenantA.id, 'user-a'); // Switch to Tenant B await pool.query(`SET LOCAL app.current_tenant_id = $1`, [tenantB.id]); // Try to access Tenant A's product const result = await ProductService.findById(productA.id, tenantB.id); expect(result).toBeNull(); // RLS blocks access }); }); ``` ## Performance Considerations ### Indexing Strategy **Always index tenant_id:** ```sql CREATE INDEX idx_{table}_tenant_id ON {schema}.{table}(tenant_id); ``` **Composite indexes for common queries:** ```sql -- Query: Find product by SKU for tenant CREATE INDEX idx_products_tenant_sku ON products.products(tenant_id, sku) WHERE deleted_at IS NULL; -- Query: List recent orders for tenant CREATE INDEX idx_orders_tenant_created ON sales.orders(tenant_id, created_at DESC); ``` ### Query Performance **With RLS:** ```sql EXPLAIN ANALYZE SELECT * FROM products.products WHERE sku = 'ABC123'; -- Plan: -- Index Scan using idx_products_tenant_sku -- Filter: (tenant_id = '...'::uuid) ← Automatic ``` **RLS overhead:** ~5-10% (minimal with proper indexing) ### Connection Pooling ```typescript // config/database.ts import { Pool } from 'pg'; export const pool = new Pool({ host: process.env.DB_HOST, port: parseInt(process.env.DB_PORT || '5432'), database: process.env.DB_NAME, user: process.env.DB_USER, password: process.env.DB_PASSWORD, max: 20, // Max connections idleTimeoutMillis: 30000, connectionTimeoutMillis: 2000, }); // Set tenant context on each query pool.on('connect', async (client) => { // This is set per-request, not per-connection // Use middleware instead }); ``` ## Security Best Practices ### 1. Always Validate Tenant Access ```typescript // Even with RLS, validate user belongs to tenant if (req.user.tenantId !== req.params.tenantId) { return res.status(403).json({ error: 'Forbidden' }); } ``` ### 2. Never Disable RLS ```sql -- ❌ NEVER DO THIS ALTER TABLE products.products NO FORCE ROW LEVEL SECURITY; ``` ### 3. Audit Tenant Changes ```sql CREATE TABLE audit.tenant_changes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, user_id UUID NOT NULL, action VARCHAR(50) NOT NULL, table_name VARCHAR(100) NOT NULL, record_id UUID, old_data JSONB, new_data JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` ### 4. Monitor Cross-Tenant Attempts ```typescript // Log suspicious activity if (attemptedCrossTenantAccess) { logger.warn('Cross-tenant access attempt', { userId: req.user.id, userTenant: req.user.tenantId, targetTenant: req.params.tenantId, ip: req.ip }); } ``` ## Troubleshooting ### RLS Not Working **Check if RLS is enabled:** ```sql SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname = 'products'; ``` **Check policies:** ```sql SELECT * FROM pg_policies WHERE tablename = 'products'; ``` **Verify tenant context is set:** ```sql SHOW app.current_tenant_id; ``` ### Performance Issues **Check query plan:** ```sql EXPLAIN ANALYZE SELECT * FROM products.products WHERE name LIKE '%widget%'; ``` **Add missing indexes:** ```sql CREATE INDEX idx_products_name ON products.products(tenant_id, name); ``` ## References - [PostgreSQL RLS Documentation](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) - [Architecture Documentation](./ARCHITECTURE.md) - [Database Schema](../apps/erp-core/database/ddl/)