Skip to content

ADR-005: Schema Isolation en Background (CRÍTICO)

Fecha: 2026-02-05 Estado: Aprobado Deciders: Architecture Team, Security Team Severity: 🔴 CRÍTICO (Seguridad)

Contexto y Problema

Sistema Bautista es multi-tenant con PostgreSQL schema-based isolation:

  • Cada sucursal tiene su schema: suc0001, suc0002, etc.
  • PostgreSQL search_path determina en qué schema se ejecutan las queries
  • Request HTTP tiene header X-Schema que configura search_path

Problema en background jobs:

  • Worker CLI NO tiene request HTTP (no hay X-Schema header)
  • Si NO se configura search_path, worker ejecuta en schema DEFAULT
  • Job podría acceder a datos de OTRA sucursal (VIOLACIÓN DE SEGURIDAD CRÍTICA)

Escenario de riesgo:

  1. Usuario de suc0001 crea job (facturación masiva)
  2. Worker se ejecuta sin configurar schema
  3. Worker ejecuta en schema DEFAULT (ej: public o suc0002)
  4. Job accede/modifica datos de OTRA sucursal
  5. VIOLACIÓN CRÍTICA DE MULTI-TENANCY

Opciones Consideradas

Opción A: Schema en Job Payload + setSearchPath (SELECCIONADA)

Descripción:

  • JobDispatcher guarda schema en job al crearlo (extrae de X-Schema header)
  • JobExecutor configura search_path ANTES de ejecutar handler
  • Handler ejecuta en schema correcto automáticamente
  • Tests de integración OBLIGATORIOS para verificar aislamiento

Código:

php
// JobDispatcher::dispatch()
$job = new BackgroundJob(
    type: $type,
    payload: $payload,
    schema: $request->getHeaderLine('X-Schema'), // CRÍTICO
    user_id: $userId,
    // ...
);

// JobExecutor::execute()
public function execute(int $jobId): void
{
    $job = $this->repo->findById($jobId);

    // CRÍTICO: Configurar schema ANTES de ejecutar
    $this->connectionManager->setSearchPath($job->schema);

    $handler = $this->handlers[$job->type];
    $result = $handler->handle($job->payload);
}

Pros:

  • ✅ Aislamiento garantizado (schema configurado ANTES de ejecutar)
  • ✅ Handler NO necesita código especial de multi-tenancy
  • ✅ Tests detectan violación (query en schema incorrecto falla)
  • ✅ Consistente con arquitectura existente

Contras:

  • ❌ Si se olvida configurar schema, error CRÍTICO (mitigado con exception)

Opción B: Schema en Cada Query

Descripción:

  • Handler incluye schema en cada query: SELECT * FROM suc0001.background_jobs
  • NO usa search_path

Código:

php
public function handle(array $payload): array
{
    $schema = $payload['_schema']; // Metadata
    $sql = "SELECT * FROM {$schema}.clientes WHERE id = :id";
    // ...
}

Pros:

  • ✅ Explícito en cada query

Contras:

  • ❌ Propenso a errores (fácil olvidar schema en 1 query)
  • ❌ Todos los handlers deben recordar incluir schema
  • ❌ NO funciona con ORM (Doctrine, Eloquent)
  • ❌ Código verbose y repetitivo

Veredicto: ❌ Descartado (propenso a errores)


Opción C: Conexión por Schema

Descripción:

  • Crear conexión DB específica para cada schema
  • Pasar conexión específica al handler

Código:

php
$connection = $connectionManager->getForSchema($job->schema);
$handler->handle($job->payload, $connection);

Pros:

  • ✅ Conexión está "bound" a schema (no puede cambiar)

Contras:

  • ❌ Overhead de conexiones (1 conexión por schema)
  • ❌ Pool de conexiones complejo
  • ❌ Handlers deben recibir conexión (cambio de interface)

Veredicto: ❌ Descartado (overhead, complejidad)


Decisión

Seleccionamos Opción A: Schema en Job Payload + setSearchPath

Justificación:

  • Patrón estándar en arquitectura existente (request HTTP hace lo mismo)
  • Handler NO necesita código especial (transparente)
  • Tests verifican aislamiento (fallan si schema incorrecto)
  • Bajo overhead (1 comando SQL: SET search_path)

Consecuencias

Positivas

  • ✅ Aislamiento garantizado por diseño
  • ✅ Handler transparente (no necesita código multi-tenant)
  • ✅ Tests verifican aislamiento (CRITICAL for security)

Negativas

  • ❌ Si se olvida configurar schema, ERROR CRÍTICO

Mitigaciones

Mitigaciones OBLIGATORIAS:

1. Exception si schema faltante

php
public function execute(int $jobId): void
{
    $job = $this->repo->findById($jobId);

    if (empty($job->schema)) {
        throw new MissingSchemaException("Job {$jobId} NO tiene schema (CRÍTICO)");
    }

    $this->connectionManager->setSearchPath($job->schema);
}

2. Tests de integración multi-tenant OBLIGATORIOS

php
public function testJobExecutesInCorrectSchema(): void
{
    // Arrange: Datos en dos schemas
    $this->setupSchema('suc0001');
    $cliente1 = $this->createCliente(['nombre' => 'Cliente 1']);

    $this->setupSchema('suc0002');
    $cliente2 = $this->createCliente(['nombre' => 'Cliente 2']);

    // Act: Despachar job en suc0001
    $jobId = $this->dispatcher->dispatch(
        'batch_invoicing',
        ['cliente_ids' => [$cliente1->id, $cliente2->id]],
        1,
        'suc0001' // Schema
    );

    $this->executor->execute($jobId);

    // Assert: Solo cliente1 procesado (mismo schema)
    $this->setupSchema('suc0001');
    $this->assertFacturasCreadas(1);

    // Assert: NO procesó cliente2 (schema diferente)
    $this->setupSchema('suc0002');
    $this->assertFacturasCreadas(0);
}

3. Code review checklist

  • [ ] JobDispatcher guarda schema del request?
  • [ ] JobExecutor configura search_path ANTES de ejecutar?
  • [ ] Tests de multi-tenant incluidos?

Implementación

JobDispatcher

php
public function dispatch(string $type, array $payload, int $userId, string $schema): int
{
    // Validar schema
    if (empty($schema) || !preg_match('/^suc[0-9]{4}/', $schema)) {
        throw new InvalidSchemaException("Schema inválido: {$schema}");
    }

    $job = new BackgroundJob(
        type: $type,
        payload: $payload,
        user_id: $userId,
        schema: $schema, // CRÍTICO
        status: 'pending',
        created_at: date('Y-m-d H:i:s')
    );

    return $this->repo->create($job);
}

JobExecutor

php
public function execute(int $jobId): void
{
    $job = $this->repo->findById($jobId);

    // Validar schema (CRÍTICO)
    if (empty($job->schema)) {
        throw new MissingSchemaException(
            "Job {$jobId} NO tiene schema. " .
            "CRÍTICO: Job podría ejecutar en schema incorrecto."
        );
    }

    // Configurar schema (CRÍTICO)
    $this->connectionManager->setSearchPath($job->schema);

    // Log para auditoría
    $this->logger->info("Job executing in schema", [
        'job_id' => $jobId,
        'schema' => $job->schema,
    ]);

    // Ejecutar handler (ahora en schema correcto)
    $handler = $this->handlers[$job->type];
    $result = $handler->handle($job->payload);
}

ConnectionManager::setSearchPath()

php
public function setSearchPath(string $schema): void
{
    // Validar formato
    if (!preg_match('/^suc[0-9]{4}(caja[0-9]{3})?$/', $schema)) {
        throw new InvalidSchemaException("Schema inválido: {$schema}");
    }

    $pdo = $this->get('oficial')->getConnection();
    $stmt = $pdo->prepare("SET search_path = :schema, public");
    $stmt->execute(['schema' => $schema]);

    $this->logger->debug("Search path configured", ['schema' => $schema]);
}

Referencias