Appearance
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_pathdetermina en qué schema se ejecutan las queries - Request HTTP tiene header
X-Schemaque 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:
- Usuario de suc0001 crea job (facturación masiva)
- Worker se ejecuta sin configurar schema
- Worker ejecuta en schema DEFAULT (ej: public o suc0002)
- Job accede/modifica datos de OTRA sucursal
- VIOLACIÓN CRÍTICA DE MULTI-TENANCY
Opciones Consideradas
Opción A: Schema en Job Payload + setSearchPath (SELECCIONADA)
Descripción:
JobDispatcherguardaschemaen job al crearlo (extrae de X-Schema header)JobExecutorconfigurasearch_pathANTES 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]);
}