Appearance
Multi-Tenant (CRÍTICO)
◄ Anterior: Handlers | Índice | Siguiente: Testing ►
⚠️ CRÍTICO: El aislamiento multi-tenant es FUNDAMENTAL para la seguridad del sistema. Un error en la configuración de schema puede causar acceso cruzado a datos de otras sucursales.
Tabla de Contenidos
- Aislamiento por Schema
- Flujo Completo de Propagación
- Testing de Aislamiento Multi-Tenant
- Configuración de ConnectionManager en Worker
- Troubleshooting Multi-Tenant
Aislamiento por Schema
Concepto: Cada sucursal tiene su propio schema PostgreSQL (ej: suc0001, suc0002). Los jobs DEBEN ejecutarse en el schema correcto para acceder solo a los datos de esa sucursal.
Riesgo: Si el worker NO configura el schema, el job ejecutará en el schema default (probablemente public o el último usado), causando:
- ❌ Acceso a datos de otra sucursal (security breach)
- ❌ Modificación de datos incorrectos
- ❌ Errores de claves foráneas (registros no existen en schema incorrecto)
Solución: Propagar schema desde frontend hasta worker CLI mediante campo schema en tabla background_jobs.
Flujo Completo de Propagación
1. Frontend Envía X-Schema Header
Automático via interceptor Axios:
javascript
axios.post('/api/jobs/batch_invoicing', {
payload: { cliente_ids: [1, 2, 3] }
}, {
headers: {
'X-Schema': 'suc0001caja001' // Inyectado por interceptor
}
});2. JobController Extrae Schema
En Controller:
php
public function dispatch(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$schema = $request->getHeaderLine('X-Schema');
$userId = $this->auth->getUserId();
$payload = $request->getParsedBody()['payload'];
// Delegar a JobDispatcher con schema
$jobId = $this->jobDispatcher->dispatch(
$args['type'],
$payload,
$userId,
$schema // ← CRÍTICO: Pasar schema
);
return $this->jsonResponse($response, ['job_id' => $jobId], 202);
}3. JobDispatcher Persiste Schema en Job
En Service:
php
public function dispatch(string $type, array $payload, int $userId, string $schema): int
{
$job = new BackgroundJob(
type: $type,
status: 'pending',
payload: $payload,
user_id: $userId,
schema: $schema, // ← CRÍTICO: Guardar para usar en worker
created_at: date('Y-m-d H:i:s')
);
$jobId = $this->repo->create($job);
exec("php cli/background-worker.php {$jobId} > /dev/null 2>&1 &");
return $jobId;
}4. CLI Worker Configura Schema ANTES de Ejecutar
En JobExecutor::execute():
php
public function execute(int $jobId): void
{
// 1. Cargar job desde BD
$job = $this->repo->findById($jobId);
// 2. Actualizar a running
$job->status = 'running';
$job->started_at = date('Y-m-d H:i:s');
$this->repo->update($job);
// 3. CRÍTICO: Configurar search_path ANTES de llamar al handler
$this->connectionManager->setSearchPath($job->schema);
// 4. Ahora handler ejecuta en schema correcto
try {
$handler = $this->handlers[$job->type];
$result = $handler->handle($job->payload);
$job->status = 'completed';
$job->result = $result;
} catch (Exception $e) {
$job->status = 'failed';
$job->error = $e->getMessage();
}
// 5. Actualizar job final
$job->completed_at = date('Y-m-d H:i:s');
$this->repo->update($job);
// 6. Crear notificación
$this->notificationService->createFromJobResult($job);
}5. Handler Ejecuta en Schema Correcto
Automático: Una vez configurado el search_path, todas las queries ejecutan en el schema correcto:
php
// Handler: BatchInvoicingJobHandler
public function handle(array $payload): array
{
foreach ($payload['cliente_ids'] as $clienteId) {
// Esta query ejecuta en el schema configurado (suc0001)
$cliente = $this->clienteModel->findById($clienteId);
// Esta query también ejecuta en el schema correcto
$this->facturaService->insert($facturaDTO);
}
}Resultado:
- ✅ Todas las queries usan el search_path configurado
- ✅ CERO queries accidentales a otros schemas
- ✅ Aislamiento completo garantizado
Testing de Aislamiento Multi-Tenant
Test de integración OBLIGATORIO:
Test: Job Ejecuta en Schema Correcto
Objetivo: Verificar que un job despachado en suc0001 NO accede ni modifica datos en suc0002
Implementación:
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 con AMBOS clientes
$jobId = $this->dispatcher->dispatch(
'batch_invoicing',
['cliente_ids' => [$cliente1->id, $cliente2->id]],
$userId = 1,
$schema = 'suc0001' // ← Job ejecutará en suc0001
);
// Ejecutar worker
$this->executor->execute($jobId);
// Assert: Solo debe crear factura para cliente1 (mismo schema)
$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');
}
public function testJobCannotAccessDataFromOtherSchema(): void
{
// Arrange
$this->setupSchema('suc0001');
$cliente1 = $this->createCliente(['nombre' => 'Cliente Suc1', 'id' => 999]);
$this->setupSchema('suc0002');
// NO crear cliente con ID 999 en suc0002
// Act: Despachar job en suc0002 intentando acceder cliente 999
$jobId = $this->dispatcher->dispatch(
'batch_invoicing',
['cliente_ids' => [999]], // Cliente existe solo en suc0001
$userId = 1,
$schema = 'suc0002' // ← Job ejecutará en suc0002
);
$this->executor->execute($jobId);
// Assert: Job debe fallar (cliente no existe en suc0002)
$job = $this->repo->findById($jobId);
$this->assertEquals('failed', $job->status);
$this->assertStringContainsString('no encontrado', $job->error);
}
}Configuración de ConnectionManager en Worker
bootstrap-cli.php
Debe configurar ConnectionManager con soporte multi-tenant:
php
<?php
// cli/bootstrap-cli.php
require_once __DIR__ . '/../vendor/autoload.php';
// Cargar environment
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
// Configurar ConnectionManager con soporte multi-tenant
$connectionManager = new ConnectionManager([
'oficial' => [
'host' => getenv('DB_HOST'),
'database' => getenv('DB_NAME'),
'user' => getenv('DB_USER'),
'password' => getenv('DB_PASS'),
]
]);
// Crear DI container
$container = new Container();
// Registrar ConnectionManager
$container->set(ConnectionManager::class, $connectionManager);
// Registrar otros servicios
$container->set(JobRepository::class, function ($c) {
return new JobRepository($c->get(ConnectionManager::class));
});
$container->set(JobExecutor::class, function ($c) {
$executor = new JobExecutor(
$c->get(JobRepository::class),
$c->get(NotificationRepository::class),
$c->get(ConnectionManager::class) // ← CRÍTICO: Pasar ConnectionManager
);
// Registrar handlers
$executor->registerHandler($c->get(BatchInvoicingJobHandler::class));
return $executor;
});
return $container;JobExecutor Usa ConnectionManager
Método execute() DEBE configurar schema:
php
class JobExecutor
{
private ConnectionManager $connectionManager;
public function __construct(
private JobRepository $repo,
private NotificationRepository $notificationRepo,
ConnectionManager $connectionManager
) {
$this->connectionManager = $connectionManager;
}
public function execute(int $jobId): void
{
$job = $this->repo->findById($jobId);
// CRÍTICO: Configurar schema ANTES de ejecutar handler
$this->connectionManager->setSearchPath($job->schema);
// Ahora handler ejecuta en schema correcto
try {
$handler = $this->handlers[$job->type];
$result = $handler->handle($job->payload);
$job->status = 'completed';
$job->result = $result;
} catch (Exception $e) {
$job->status = 'failed';
$job->error = $e->getMessage();
}
$job->completed_at = date('Y-m-d H:i:s');
$this->repo->update($job);
$this->notificationService->createFromJobResult($job);
}
}Troubleshooting Multi-Tenant
Síntoma: Job Ejecuta en Schema Incorrecto
Diagnóstico:
bash
# 1. Verificar schema en job
psql -c "SELECT id, schema FROM background_jobs WHERE id = 123;"
# 2. Verificar logs de ConnectionManager
tail -f /logs/background-jobs.log | grep "search_path"
# 3. Test de aislamiento
# (verificar que job en suc0001 NO accede a suc0002)Causas posibles:
Schema NO guardado en job
php// ❌ MAL: No se guarda schema $job = new BackgroundJob( type: $type, payload: $payload // schema: $schema ← FALTA );ConnectionManager NO configurado en worker
php// ❌ MAL: JobExecutor no recibe ConnectionManager $executor = new JobExecutor($repo, $notificationRepo); // ❌ MAL: No se configura search_path // $this->connectionManager->setSearchPath($job->schema); ← FALTAHandler usa conexión directa (sin ConnectionManager)
php// ❌ MAL: Handler crea conexión directa $pdo = new PDO('pgsql:host=localhost;dbname=mydb', 'user', 'pass'); // Esta conexión NO tiene search_path configurado
Solución:
Verificar que
JobDispatcherguarda schema:php$job = new BackgroundJob(schema: $schema); // ✅Verificar que
JobExecutorconfigura schema:php$this->connectionManager->setSearchPath($job->schema); // ✅Verificar que handlers usan ConnectionManager:
php// ✅ BIEN: Handler inyecta service que usa ConnectionManager public function __construct(private FacturaService $service) {}
Prevención
Checklist de Code Review:
- [ ] ✅ JobController extrae
X-Schemaheader - [ ] ✅ JobDispatcher recibe
$schemacomo parámetro - [ ] ✅ BackgroundJob tiene campo
schemaNOT NULL - [ ] ✅ JobExecutor recibe
ConnectionManageren constructor - [ ] ✅ JobExecutor llama
setSearchPath()ANTES de ejecutar handler - [ ] ✅ Tests de integración verifican aislamiento multi-tenant
- [ ] ✅ Handlers NO crean conexiones directas (usan services inyectados)