Skip to content

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_jobs se 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_id identifica a qué sucursal pertenece el job
  • Filtro WHERE sucursal_id = :current_sucursal en 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_id debe 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:

  1. Dashboard global: Implementar vista consolidada si es necesario (Fase 3)

    sql
    SELECT * FROM suc0001.background_jobs
    UNION ALL
    SELECT * FROM suc0002.background_jobs
    -- etc.
  2. 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:

Referencias