workspace/projects/erp-suite/docs/MULTI-TENANCY.md
rckrdmrd 513a86ceee
Some checks are pending
CI Pipeline / changes (push) Waiting to run
CI Pipeline / core (push) Blocked by required conditions
CI Pipeline / trading-backend (push) Blocked by required conditions
CI Pipeline / trading-data-service (push) Blocked by required conditions
CI Pipeline / trading-frontend (push) Blocked by required conditions
CI Pipeline / erp-core (push) Blocked by required conditions
CI Pipeline / erp-mecanicas (push) Blocked by required conditions
CI Pipeline / gamilit-backend (push) Blocked by required conditions
CI Pipeline / gamilit-frontend (push) Blocked by required conditions
Major update: orchestration system, catalog references, and multi-project enhancements
Core:
- Add catalog reference implementations (auth, payments, notifications, websocket, etc.)
- New agent profiles: Database Auditor, Integration Validator, LLM Agent, Policy Auditor, Trading Strategist
- Update SIMCO directives and add escalation/git guidelines
- Add deployment inventory and audit execution reports

Projects:
- erp-suite: DevOps configs, Dockerfiles, shared libs, vertical enhancements
- gamilit: Test structure, admin controllers, service refactoring, husky/commitlint
- trading-platform: MT4 gateway, auth controllers, admin frontend, deployment scripts
- platform_marketing_content: Full DevOps setup, tests, Docker configs
- betting-analytics/inmobiliaria-analytics: Initial app structure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 22:53:55 -06:00

675 lines
18 KiB
Markdown

# 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<T, CreateDto, UpdateDto> {
constructor(
protected tableName: string,
protected schema: string
) {}
async findAll(
tenantId: string,
filters?: Filters,
pagination?: Pagination
): Promise<PaginatedResult<T>> {
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<T> {
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<void>;
logout: () => void;
}
export const useAuthStore = create<AuthState>((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 (
<div className="bg-blue-100 px-4 py-2 rounded">
<span className="text-sm text-gray-700">
Tenant: <strong>{user.tenantName}</strong>
</span>
</div>
);
};
```
## Multi-Tenant SaaS Features
### Onboarding New Tenant
```typescript
// services/tenant.service.ts
export class TenantService {
async createTenant(data: CreateTenantDto): Promise<Tenant> {
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/)