Appearance
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
- Tupla de Identidad Multi-Tenant
- Flujo Completo de Propagacion
- Aislamiento de Schema en el Worker
- Testing de Aislamiento Multi-Tenant
- Configuracion de ConnectionManager en Worker
- Troubleshooting Multi-Tenant
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:
| Campo | Tipo | Origen | Proposito |
|---|---|---|---|
nro_sistema | int | JWT payload->sistema | Identifica el sistema ERP (cuando multiples ERPs comparten infraestructura) |
user_id | int | JWT payload->id | Usuario que creo el job |
db | string | payload->db (+ sufijo _p si prueba) | Base de datos de la empresa |
schema | string | payload->schema | Schema PostgreSQL del tenant (suc0001, suc0001caja001, public) |
prueba | bool | ConnectionMiddleware::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 id4. 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/oficialapunta a$db(base de datos del tenant) - El
search_pathesta configurado a$schema(ej:suc0001) - La conexion
inino 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_jobsynotificationssiguen operando enDB_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:
background-worker.php(Fase 2):$connectionManager->setSchemaContext($schema);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
oficialapunta a la base de datos del tenant ($cliJobDb) - La conexion
iniapunta aDB_INIy tieneskip_schema_context: true - El alias
principal→oficialpermite 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:
Tupla de identidad incompleta en el job
- Verificar que
JobControllerextrae los 5 campos correctamente - Verificar que
JobDispatcherlos pasa al constructor deBackgroundJob
- Verificar que
ConnectionManager NO configurado en worker
- Verificar que
bootstrap-cli.phpcrea las conexionesoficialeini - Verificar que
setSchemaContext()se llama antes deexecute()
- Verificar que
Handler usa conexion directa (sin ConnectionManager)
- Los handlers DEBEN inyectar services que usen ConnectionManager
- NUNCA crear
new PDO(...)dentro de un handler
Conexion
inirecibe search_path- Verificar que
skip_schema_context: trueesta configurado para la conexionini - Si no esta configurado, las queries a
background_jobsbuscarian la tabla en el schema del tenant
- Verificar que
Prevencion
Checklist de Code Review:
- [ ] JobController extrae los 5 campos de identidad (nro_sistema, user_id, db, schema, prueba)
- [ ] JobController construye
_contexten el payload con datos del JWT - [ ] JobDispatcher recibe
$schema,$db,$nroSistema,$pruebacomo parametros - [ ] BackgroundJob tiene campos
nro_sistema,db,schema,pruebaNOT NULL - [ ] JobRepository usa conexion
ini(noprincipal) para operar sobrebackground_jobs - [ ] Worker CLI valida formato de schema y db con regex estricto
- [ ] Worker CLI llama
setSchemaContext()ANTES de ejecutar handler - [ ] Conexion
initieneskip_schema_context: true - [ ] Tests de integracion verifican aislamiento multi-tenant
- [ ] Handlers NO crean conexiones directas (usan services inyectados)