Appearance
ADR-002: Tablas por Schema (Multi-Tenant)
Fecha: 2026-02-05 Estado: Aprobado Deciders: Architecture Team, Security Team
Contexto y Problema
Sistema Bautista usa PostgreSQL schema-based multi-tenancy:
- Cada sucursal tiene su schema:
suc0001,suc0002, etc. - Cada caja tiene su schema:
suc0001caja001,suc0001caja002, etc. - Aislamiento completo de datos por schema
Necesidad: Tabla background_jobs debe respetar aislamiento multi-tenant porque:
- Jobs contienen datos sensibles (ej: IDs de clientes, montos de facturas)
- Sucursal A NO debe ver jobs de sucursal B (seguridad)
- Violación de aislamiento = vulnerabilidad de seguridad crítica
Pregunta: ¿En qué nivel crear la tabla background_jobs?
Opciones Consideradas
Opción A: LEVEL_SUCURSAL (SELECCIONADA)
Descripción:
- Tabla
background_jobsse crea en CADA schema de sucursal suc0001.background_jobs,suc0002.background_jobs, etc.- Aislamiento automático por search_path de PostgreSQL
Pros:
- ✅ Aislamiento completo (sucursal A no ve jobs de B)
- ✅ Consistente con arquitectura existente
- ✅ CERO queries cross-schema accidentales
- ✅ Seguridad por diseño (imposible acceder a otro schema sin cambiar search_path)
- ✅ Permisos de DB enforzados automáticamente
Contras:
- ❌ NO hay vista consolidada de todos los jobs (admin global)
- ❌ Cada schema tiene su propia tabla (más rows totales en cluster)
- ❌ Queries analíticas cross-tenant requieren UNION de múltiples schemas
Opción B: LEVEL_EMPRESA con columna sucursal_id
Descripción:
- Tabla
public.background_jobsúnica para toda la empresa - Columna
sucursal_ididentifica a qué sucursal pertenece el job - Filtro
WHERE sucursal_id = :current_sucursalen TODAS las queries
Pros:
- ✅ Vista consolidada fácil (1 query para todos los jobs)
- ✅ Menos tablas totales
- ✅ Queries analíticas simples
Contras:
- ❌ VIOLA aislamiento multi-tenant (todas las sucursales en misma tabla)
- ❌ Filtro
WHERE sucursal_iddebe estar en TODAS las queries (propenso a errores) - ❌ Olvido de filtro = data leakage CRÍTICO
- ❌ Permisos de DB NO enfuerzan aislamiento (depende de código)
- ❌ Testing complejo (simular multi-tenant con mocks)
Veredicto: ❌ Descartado (viola principio de aislamiento)
Opción C: Row Level Security (RLS)
Descripción:
- Tabla
public.background_jobsúnica - PostgreSQL RLS policy:
CREATE POLICY ... USING (schema = current_setting('app.current_schema')) - DB enforza aislamiento automáticamente
Pros:
- ✅ Vista consolidada fácil
- ✅ Aislamiento enforzado por DB (no depende de código)
- ✅ Una sola tabla
Contras:
- ❌ Performance overhead (RLS evalúa policy en cada query)
- ❌ Complejidad adicional (configurar session var
app.current_schema) - ❌ Debugging difícil (policies ocultas en queries)
- ❌ NO consistente con arquitectura existente (resto del sistema NO usa RLS)
Veredicto: ❌ Descartado (complejidad sin suficiente beneficio)
Decisión
Seleccionamos Opción A: LEVEL_SUCURSAL
Justificación:
- Consistente con arquitectura multi-tenant existente (TODAS las tablas transaccionales son LEVEL_SUCURSAL)
- Aislamiento garantizado por diseño (imposible acceder a otro schema sin cambiar search_path explícitamente)
- Menos propenso a errores (NO depende de filtros WHERE en código)
- Security-first approach (mejor aislamiento que facilidad de queries analíticas)
Consecuencias
Positivas
- ✅ Aislamiento completo de datos entre sucursales
- ✅ Consistente con patrón existente (facturacion, movimientos, etc.)
- ✅ Seguridad por diseño (no depende de código)
- ✅ Tests de multi-tenancy simples (cambiar schema, verificar aislamiento)
Negativas
- ❌ Dashboard global de jobs requiere UNION de múltiples schemas
- ❌ Cada sucursal tiene su tabla (más overhead de storage)
Mitigaciones
Mitigaciones de negativos:
Dashboard global: Implementar vista consolidada si es necesario (Fase 3)
sqlSELECT * FROM suc0001.background_jobs UNION ALL SELECT * FROM suc0002.background_jobs -- etc.Storage overhead: Insignificante comparado con tablas transaccionales (jobs se archivan después de 30 días)
Implementación
Migration:
php
class CreateBackgroundJobsTable extends ConfigurableMigration
{
protected function getDefaultLevels(): array
{
return [self::LEVEL_SUCURSAL]; // CRÍTICO
}
public function change(): void
{
$table = $this->table('background_jobs');
$table->addColumn('type', 'string')
->addColumn('status', 'string')
->addColumn('payload', 'json')
->addColumn('schema', 'string') // Para ejecutar en schema correcto
// ...
->create();
}
}Campo schema CRÍTICO:
- Guarda el schema PostgreSQL donde debe ejecutarse el job
- Worker configura
search_pathANTES de ejecutar handler - Ver ADR-005: Schema Isolation en Background para detalles completos