Skip to content

Multi-Tenant (CRITICO)

◄ Anterior: Handlers | Indice | Siguiente: Testing ►


CRITICO: El aislamiento multi-tenant es FUNDAMENTAL para la seguridad del sistema. Un error en la configuracion de schema puede causar acceso cruzado a datos de otras sucursales.


Tabla de Contenidos


Tabla Central vs. Schema por Tenant

Arquitectura: Las tablas background_jobs y notifications residen en DB_INI.public (la base de datos de infraestructura). Son tablas centrales, NO por schema.

Por que tabla central: Todos los sistemas, tenants y modos (oficial/prueba) comparten una sola tabla. El aislamiento logico se logra mediante una tupla de identidad almacenada en cada fila, no mediante schemas PostgreSQL separados.

Conexion utilizada: JobRepository y NotificationRepository operan sobre la conexion ini:

php
// JobRepository — SIEMPRE usa conexion 'ini' (DB_INI.public)
$conn = $this->connectionManager->getDbal('ini');

La conexion ini tiene skip_schema_context: true en su configuracion, lo que significa que nunca recibe SET search_path. Siempre opera en el schema public de DB_INI.

Implicacion: El search_path configurado por setSchemaContext() afecta SOLO a la conexion principal/oficial (la base de datos del tenant), no a la conexion ini donde viven background_jobs y notifications.


Tupla de Identidad Multi-Tenant

Cada job almacena 5 campos que conforman su identidad multi-tenant completa:

CampoTipoOrigenProposito
nro_sistemaintJWT payload->sistemaIdentifica el sistema ERP (cuando multiples ERPs comparten infraestructura)
user_idintJWT payload->idUsuario que creo el job
dbstringpayload->db (+ sufijo _p si prueba)Base de datos de la empresa
schemastringpayload->schemaSchema PostgreSQL del tenant (suc0001, suc0001caja001, public)
pruebaboolConnectionMiddleware::isPruebaConnection()Distingue modo oficial vs. modo prueba

Origen de cada campo en JobController::dispatch()

php
$userId     = $this->payload->id;
$schema     = $this->payload->schema;
$nroSistema = (int) $this->payload->sistema;
$prueba     = $this->connectionManager->isPruebaConnection();
$db         = $prueba
    ? $this->payload->db . '_p'
    : $this->payload->db;

Almacenamiento en BackgroundJob

Los 5 campos son propiedades readonly del value object:

php
class BackgroundJob
{
    public function __construct(
        public readonly string $type,
        public readonly int $userId,
        public readonly string $db,       // Base de datos (con _p si prueba)
        public readonly string $schema,   // Schema PostgreSQL
        public readonly int $nroSistema,  // Sistema identifier
        public readonly array $payload,
        public readonly bool $prueba = false,
        // ... demas campos
    ) {}
}

Propagacion al Worker CLI

El JobDispatcher pasa schema y db como argumentos CLI al worker:

php
$command = "php {$workerPath} {$jobIdEscaped} {$schemaEscaped} {$dbEscaped} >> {$logFile} 2>&1 &";

Estos argumentos permiten al worker configurar la conexion correcta antes de ejecutar el handler.


Flujo Completo de Propagacion

1. Frontend Envia Request con Contexto JWT

El frontend envia el request con el JWT token (que contiene sistema, db, schema, etc.). El header X-Schema es manejado por middleware.


2. JobController Extrae Identidad y Construye _context

php
// JobController::dispatch()
$jobPayload['_context'] = [
    'cuit'         => $this->payload->cuit,
    'nro_cliente'  => $this->payload->nro_cliente,
    'id_usuario'   => $this->payload->id_usuario,
    'id'           => $this->payload->id,
    'sistema'      => $this->payload->sistema,
    'schema_level' => $schemaLevel,
    'prueba'       => $prueba,
];

El _context viaja dentro del payload JSON del job. Es utilizado por el worker CLI en Fase 1 para reconstruir un Payload sintetico (ver ADR-006).


3. JobDispatcher Persiste Job con Identidad Completa

php
$job = new BackgroundJob(
    type:       $type,
    userId:     $userId,
    db:         $db,
    schema:     $schema,
    nroSistema: $nroSistema,
    payload:    $payload,
    prueba:     $prueba,
    status:     BackgroundJob::STATUS_PENDING,
);

$jobId = $this->jobRepo->create($job);

El INSERT en background_jobs incluye las 5 columnas de identidad:

sql
INSERT INTO background_jobs (type, status, payload, user_id, nro_sistema, prueba, db, schema, max_retries, created_at)
VALUES (:type, :status, :payload, :user_id, :nro_sistema, :prueba, :db, :schema, :max_retries, NOW())
RETURNING id

4. Worker CLI: Two-Phase Bootstrap

El worker recibe {job_id} {schema} {db} como argumentos CLI.

Fase 1 — Conexion PDO minima a DB_INI para leer _context del payload:

php
$jobId  = (int) $argv[1];
$schema = trim($argv[2]);
$db     = trim($argv[3]);

// Conexion directa a DB_INI (sin DI container)
$pdo = new PDO($dsn, $user, $password);
$stmt = $pdo->prepare('SELECT payload FROM background_jobs WHERE id = :id LIMIT 1');

Los datos de _context se propagan via putenv() para que bootstrap-cli.php los consuma.

Fase 2 — Bootstrap DI completo con Payload sintetico y setSchemaContext($schema):

php
$connectionManager = $container->get(ConnectionManager::class);
$connectionManager->setSchemaContext($schema);

$executor = $container->get(JobExecutor::class);
$executor->execute($jobId);

5. Handler Ejecuta en Schema Correcto

Una vez configurado el search_path via setSchemaContext(), todas las queries del handler ejecutan en el schema del tenant:

  • La conexion principal/oficial apunta a $db (base de datos del tenant)
  • El search_path esta configurado a $schema (ej: suc0001)
  • La conexion ini no se ve afectada (skip_schema_context: true)

Resultado:

  • Todas las queries de negocio usan el search_path configurado
  • CERO queries accidentales a otros schemas
  • Las queries a background_jobs y notifications siguen operando en DB_INI.public

Aislamiento de Schema en el Worker

Concepto: Cada sucursal tiene su propio schema PostgreSQL (ej: suc0001, suc0002). Los handlers DEBEN ejecutarse en el schema correcto para acceder solo a los datos de esa sucursal.

Riesgo: Si el worker NO configura el schema, el handler ejecutara en el schema default, causando:

  • Acceso a datos de otra sucursal (security breach)
  • Modificacion de datos incorrectos
  • Errores de claves foraneas (registros no existen en schema incorrecto)

Solucion implementada: El schema se configura en dos puntos como defensa en profundidad:

  1. background-worker.php (Fase 2): $connectionManager->setSchemaContext($schema);
  2. JobExecutor::execute(): $this->connectionManager->setSchemaContext($job->schema);

Ambos usan setSchemaContext() (no setSearchPath()). La diferencia es que setSchemaContext() almacena el schema como contexto persistente en el ConnectionManager, afectando tanto conexiones existentes como futuras.

Validacion de Argumentos CLI

El worker valida estrictamente el formato de schema y db antes de usarlos, previniendo inyeccion SQL:

php
if (!preg_match('/^(public|suc\d{4}(caja\d{3})?)$/', $schema)) {
    fwrite(STDERR, "Schema invalido: '{$schema}'.\n");
    exit(1);
}

if (!preg_match('/^[a-zA-Z0-9_]+$/', $db)) {
    fwrite(STDERR, "Nombre de base de datos invalido: '{$db}'.\n");
    exit(1);
}

Testing de Aislamiento Multi-Tenant

Test de integracion OBLIGATORIO:

Test: Job Ejecuta en Schema Correcto

Objetivo: Verificar que un job despachado en suc0001 NO accede ni modifica datos en suc0002

php
class BackgroundJobsMultiTenantTest extends BaseIntegrationTestCase
{
    public function testJobExecutesInCorrectSchema(): void
    {
        // Arrange: Crear datos en dos schemas diferentes
        $this->setupSchema('suc0001');
        $cliente1 = $this->createCliente(['nombre' => 'Cliente Suc1']);

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

        // Act: Despachar job en suc0001
        $jobId = $this->dispatcher->dispatch(
            'batch_invoicing',
            ['cliente_ids' => [$cliente1->id, $cliente2->id]],
            $userId = 1,
            $schema = 'suc0001',
            $db = 'empresa_test',
            $nroSistema = 1,
            $prueba = false
        );

        // Ejecutar worker
        $this->executor->execute($jobId);

        // Assert: Solo debe procesar datos accesibles en suc0001
        $this->setupSchema('suc0001');
        $facturasCreadas = $this->getFacturasCount();
        $this->assertEquals(1, $facturasCreadas, 'Debe crear 1 factura en suc0001');

        // Assert: NO debe crear factura en suc0002 (schema diferente)
        $this->setupSchema('suc0002');
        $facturasCreadas = $this->getFacturasCount();
        $this->assertEquals(0, $facturasCreadas, 'NO debe crear facturas en suc0002');
    }
}

Configuracion de ConnectionManager en Worker

bootstrap-cli.php

El bootstrap CLI configura ConnectionManager con dos conexiones:

php
// Conexion 'oficial' → base de datos del tenant
$connectionManager = new ConnectionManager();
$connectionManager->setConfig('oficial', [
    'driver'   => 'pdo_pgsql',
    'host'     => $_ENV['DB_HOST'] ?? HOST,
    'database' => $cliJobDb,  // ← Base de datos del job (con _p si prueba)
    'username' => $_ENV['DB_USER'] ?? USER,
    'password' => $_ENV['DB_PASS'] ?? PASSWORD,
    'port'     => (int) ($_ENV['DB_PORT'] ?? PORT),
]);

// Conexion 'ini' → DB_INI (tabla central de jobs/notifications)
$connectionManager->setConfig('ini', [
    'driver'              => 'pdo_pgsql',
    'host'                => $_ENV['DB_HOST'] ?? HOST,
    'database'            => DB_INI,
    'username'            => $_ENV['DB_USER'] ?? USER,
    'password'            => $_ENV['DB_PASS'] ?? PASSWORD,
    'port'                => (int) ($_ENV['DB_PORT'] ?? PORT),
    'skip_schema_context' => true,  // ← CRITICO: No aplicar SET search_path
]);

// Alias para compatibilidad con servicios que usan 'principal'
$connectionManager->setAlias('principal', 'oficial');

Puntos criticos:

  • La conexion oficial apunta a la base de datos del tenant ($cliJobDb)
  • La conexion ini apunta a DB_INI y tiene skip_schema_context: true
  • El alias principaloficial permite que los servicios existentes funcionen sin cambios

Troubleshooting Multi-Tenant

Sintoma: Job Ejecuta en Schema Incorrecto

Diagnostico:

bash
# 1. Verificar identidad multi-tenant del job
psql -d DB_INI -c "SELECT id, nro_sistema, user_id, db, schema, prueba FROM background_jobs WHERE id = 123;"

# 2. Verificar logs del worker
tail -f /logs/background-jobs.log | grep "search_path\|schema"

# 3. Verificar argumentos CLI recibidos por el worker
# (aparecen en el log como 'Background worker starting')

Causas posibles:

  1. Tupla de identidad incompleta en el job

    • Verificar que JobController extrae los 5 campos correctamente
    • Verificar que JobDispatcher los pasa al constructor de BackgroundJob
  2. ConnectionManager NO configurado en worker

    • Verificar que bootstrap-cli.php crea las conexiones oficial e ini
    • Verificar que setSchemaContext() se llama antes de execute()
  3. Handler usa conexion directa (sin ConnectionManager)

    • Los handlers DEBEN inyectar services que usen ConnectionManager
    • NUNCA crear new PDO(...) dentro de un handler
  4. Conexion ini recibe search_path

    • Verificar que skip_schema_context: true esta configurado para la conexion ini
    • Si no esta configurado, las queries a background_jobs buscarian la tabla en el schema del tenant

Prevencion

Checklist de Code Review:

  • [ ] JobController extrae los 5 campos de identidad (nro_sistema, user_id, db, schema, prueba)
  • [ ] JobController construye _context en el payload con datos del JWT
  • [ ] JobDispatcher recibe $schema, $db, $nroSistema, $prueba como parametros
  • [ ] BackgroundJob tiene campos nro_sistema, db, schema, prueba NOT NULL
  • [ ] JobRepository usa conexion ini (no principal) para operar sobre background_jobs
  • [ ] Worker CLI valida formato de schema y db con regex estricto
  • [ ] Worker CLI llama setSchemaContext() ANTES de ejecutar handler
  • [ ] Conexion ini tiene skip_schema_context: true
  • [ ] Tests de integracion verifican aislamiento multi-tenant
  • [ ] Handlers NO crean conexiones directas (usan services inyectados)

◄ Anterior: Handlers | Indice | Siguiente: Testing ►