229 lines
5.4 KiB
Markdown
229 lines
5.4 KiB
Markdown
# Directiva: Multi-Tenant
|
|
|
|
## Propósito
|
|
|
|
Define las reglas obligatorias para implementar multi-tenancy en ERP Core y todas las verticales que lo extienden.
|
|
|
|
## Alcance
|
|
|
|
- Backend: Todas las consultas y operaciones
|
|
- Database: RLS Policies, schemas, índices
|
|
- Frontend: Contexto de tenant
|
|
- APIs: Headers y validación
|
|
|
|
---
|
|
|
|
## Principios
|
|
|
|
### 1. Aislamiento Total
|
|
```
|
|
OBLIGATORIO: Ningún tenant puede ver o modificar datos de otro tenant.
|
|
```
|
|
|
|
### 2. Tenant ID Obligatorio
|
|
```
|
|
OBLIGATORIO: Toda tabla debe tener columna `tenant_id UUID NOT NULL`.
|
|
```
|
|
|
|
### 3. Filtrado Automático
|
|
```
|
|
OBLIGATORIO: Toda consulta debe filtrar por tenant_id a nivel de aplicación Y base de datos (RLS).
|
|
```
|
|
|
|
---
|
|
|
|
## Implementación en Base de Datos
|
|
|
|
### Columna tenant_id
|
|
```sql
|
|
-- OBLIGATORIO en TODA tabla
|
|
CREATE TABLE {schema}.{tabla} (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id),
|
|
-- ... otras columnas ...
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Índice obligatorio
|
|
CREATE INDEX idx_{tabla}_tenant_id ON {schema}.{tabla}(tenant_id);
|
|
```
|
|
|
|
### RLS Policy Estándar
|
|
```sql
|
|
-- Habilitar RLS
|
|
ALTER TABLE {schema}.{tabla} ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Policy de lectura
|
|
CREATE POLICY "tenant_isolation_select" ON {schema}.{tabla}
|
|
FOR SELECT
|
|
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
|
|
|
|
-- Policy de inserción
|
|
CREATE POLICY "tenant_isolation_insert" ON {schema}.{tabla}
|
|
FOR INSERT
|
|
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);
|
|
|
|
-- Policy de actualización
|
|
CREATE POLICY "tenant_isolation_update" ON {schema}.{tabla}
|
|
FOR UPDATE
|
|
USING (tenant_id = current_setting('app.current_tenant_id')::uuid)
|
|
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);
|
|
|
|
-- Policy de eliminación
|
|
CREATE POLICY "tenant_isolation_delete" ON {schema}.{tabla}
|
|
FOR DELETE
|
|
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
|
|
```
|
|
|
|
### Contexto de Sesión
|
|
```sql
|
|
-- Establecer contexto al inicio de cada request
|
|
SET app.current_tenant_id = '{tenant_uuid}';
|
|
|
|
-- En transacciones
|
|
BEGIN;
|
|
SET LOCAL app.current_tenant_id = '{tenant_uuid}';
|
|
-- ... operaciones ...
|
|
COMMIT;
|
|
```
|
|
|
|
---
|
|
|
|
## Implementación en Backend
|
|
|
|
### Middleware de Tenant
|
|
```typescript
|
|
// middleware/tenant.middleware.ts
|
|
export const tenantMiddleware = async (req, res, next) => {
|
|
const tenantId = req.headers['x-tenant-id'] || req.user?.tenantId;
|
|
|
|
if (!tenantId) {
|
|
return res.status(400).json({ error: 'Tenant ID required' });
|
|
}
|
|
|
|
// Validar que tenant existe y está activo
|
|
const tenant = await tenantService.findById(tenantId);
|
|
if (!tenant || !tenant.isActive) {
|
|
return res.status(403).json({ error: 'Invalid or inactive tenant' });
|
|
}
|
|
|
|
req.tenantId = tenantId;
|
|
|
|
// Establecer contexto en BD
|
|
await dbConnection.query(`SET app.current_tenant_id = '${tenantId}'`);
|
|
|
|
next();
|
|
};
|
|
```
|
|
|
|
### En Servicios
|
|
```typescript
|
|
// OBLIGATORIO: Siempre filtrar por tenantId
|
|
async findAll(tenantId: string): Promise<Entity[]> {
|
|
return this.repository.find({
|
|
where: { tenantId }
|
|
});
|
|
}
|
|
|
|
// OBLIGATORIO: Validar tenantId en creación
|
|
async create(dto: CreateDto, tenantId: string): Promise<Entity> {
|
|
const entity = this.repository.create({
|
|
...dto,
|
|
tenantId
|
|
});
|
|
return this.repository.save(entity);
|
|
}
|
|
```
|
|
|
|
### En Controllers
|
|
```typescript
|
|
@Get()
|
|
async findAll(@Req() req: Request) {
|
|
// tenantId viene del middleware
|
|
return this.service.findAll(req.tenantId);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Implementación en Frontend
|
|
|
|
### Contexto de Tenant
|
|
```typescript
|
|
// stores/tenant.store.ts
|
|
interface TenantState {
|
|
currentTenantId: string | null;
|
|
tenants: Tenant[];
|
|
}
|
|
|
|
export const useTenantStore = create<TenantState>((set) => ({
|
|
currentTenantId: null,
|
|
tenants: [],
|
|
setCurrentTenant: (id) => set({ currentTenantId: id }),
|
|
}));
|
|
```
|
|
|
|
### En API Calls
|
|
```typescript
|
|
// services/api.service.ts
|
|
const api = axios.create({
|
|
baseURL: API_URL,
|
|
});
|
|
|
|
api.interceptors.request.use((config) => {
|
|
const tenantId = useTenantStore.getState().currentTenantId;
|
|
if (tenantId) {
|
|
config.headers['X-Tenant-Id'] = tenantId;
|
|
}
|
|
return config;
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Validaciones Obligatorias
|
|
|
|
### Checklist Pre-Deploy
|
|
- [ ] Toda tabla tiene columna `tenant_id`
|
|
- [ ] Toda tabla tiene RLS habilitado
|
|
- [ ] Toda tabla tiene policies de tenant
|
|
- [ ] Todo endpoint usa middleware de tenant
|
|
- [ ] Todo servicio filtra por tenant
|
|
- [ ] Frontend envía header X-Tenant-Id
|
|
|
|
### Tests Obligatorios
|
|
```typescript
|
|
describe('Tenant Isolation', () => {
|
|
it('should not return data from other tenants', async () => {
|
|
// Crear datos en tenant A
|
|
await service.create(data, tenantA);
|
|
|
|
// Consultar desde tenant B
|
|
const results = await service.findAll(tenantB);
|
|
|
|
// No debe encontrar datos de tenant A
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Excepciones
|
|
|
|
Las siguientes tablas NO requieren tenant_id:
|
|
- `core_tenants.tenants` (la tabla maestra de tenants)
|
|
- `core_auth.system_settings` (configuración global del sistema)
|
|
- `core_catalogs.countries`, `core_catalogs.currencies` (catálogos globales)
|
|
|
|
---
|
|
|
|
## Referencias
|
|
|
|
- RLS PostgreSQL: https://www.postgresql.org/docs/current/ddl-rowsecurity.html
|
|
- Directiva Diseño BD: `DIRECTIVA-DISENO-BASE-DATOS.md`
|
|
|
|
---
|
|
*Directiva específica de ERP-Core*
|