Skip to content

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

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:

  1. Schema NO guardado en job

    php
    // ❌ MAL: No se guarda schema
    $job = new BackgroundJob(
        type: $type,
        payload: $payload
        // schema: $schema  ← FALTA
    );
  2. 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); ← FALTA
  3. Handler 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:

  1. Verificar que JobDispatcher guarda schema:

    php
    $job = new BackgroundJob(schema: $schema);  // ✅
  2. Verificar que JobExecutor configura schema:

    php
    $this->connectionManager->setSearchPath($job->schema);  // ✅
  3. 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-Schema header
  • [ ] ✅ JobDispatcher recibe $schema como parámetro
  • [ ] ✅ BackgroundJob tiene campo schema NOT NULL
  • [ ] ✅ JobExecutor recibe ConnectionManager en constructor
  • [ ] ✅ JobExecutor llama setSearchPath() ANTES de ejecutar handler
  • [ ] ✅ Tests de integración verifican aislamiento multi-tenant
  • [ ] ✅ Handlers NO crean conexiones directas (usan services inyectados)

◄ Anterior: Handlers | Índice | Siguiente: Testing ►