Arquitectura Multi-Tenant para SaaS Financiero: Aislamiento de Datos y Escalabilidad en PHP 8.4

Lectura estimada: 7 min

Arquitectura Multi-Tenant para SaaS Financiero: Aislamiento de Datos y Escalabilidad en PHP 8.4

Construir un SaaS fintech B2B en LATAM exige resolver una tensión fundamental: compartir infraestructura para reducir costos operativos, pero garantizar aislamiento absoluto de datos entre clientes para cumplir regulaciones financieras (SBS, CNBV, CMF). La arquitectura multi-tenant mal implementada es la causa #1 de fugas de datos cruzados y rechazos en auditorías PCI-DSS.

En esta guía implementaremos un patrón multi-tenant con base de datos compartida y schema isolation usando PHP 8.4 + MariaDB 10.6+, evitando los extremos costosos (DB por cliente) e inseguros (solo columna tenant_id). El enfoque balancea densidad de tenants, seguridad regulatoria y rendimiento bajo alto tráfico transaccional.

⚠️ Nota Legal: Este material es educativo. Arquitecturas multi-tenant en fintech requieren validación formal por QSA y regulador local antes de producción.

Los Tres Modelos de Multi-Tenancy en Fintech

No existe modelo universal. La elección depende del volumen de datos por tenant, requisitos regulatorios y presupuesto de infraestructura:

ModeloAislamientoCosto InfraComplejidad OpsCumplimiento RegulatorioRecomendado Para
DB por Tenant✅ Máximo❌ Muy Alto❌ Alta (N DBs)✅ TotalBancos / Grandes corporativos
Schema por Tenant✅ Fuerte⚠️ Medio⚠️ Media✅ Aceptable con controlesSaaS Fintech B2B LATAM
Tabla Compartida + tenant_id❌ Débil✅ Bajo✅ Baja❌ Riesgo alto fuga cruzadaMVPs no financieros únicamente

Para startups fintech LATAM con 10-500 tenants, el modelo Schema por Tenant ofrece el mejor balance: aislamiento lógico fuerte (cada tenant tiene su propio namespace de tablas), costo compartido de conexión/pooling, y compatibilidad con backups granulares por tenant sin sobrecarga de gestionar cientos de instancias MySQL separadas.

Implementación del Patrón Schema Isolation en PHP 8.4

Paso 1: Middleware de Resolución de Tenant

El tenant se identifica siempre desde el token JWT autenticado, nunca desde parámetros URL o headers manipulables. Esto previene ataques de tenant enumeration y acceso cruzado.

<?php
declare(strict_types=1);

namespace App\Middleware;

use Exception;

final class TenantResolver
{
    private const SCHEMA_PREFIX = 'tenant_';
    
    /**
     * Resuelve y valida el schema del tenant actual desde claims JWT.
     * @throws Exception si el tenant no existe o está inactivo.
     */
    public static function resolve(array $jwtClaims): string
    {
        $tenantId = $jwtClaims['tenant_id'] ?? null;
        
        if (!$tenantId || !preg_match('/^[a-z0-9_]{3,32}$/', $tenantId)) {
            throw new Exception('TENANT_INVALID', 403);
        }
        
        $schemaName = self::SCHEMA_PREFIX . $tenantId;
        
        // Verificar que el schema existe y está activo (caché Redis recomendado)
        $redis = new \Redis();
        $redis->connect('127.0.0.1');
        $isActive = $redis->get("tenant_active:{$tenantId}");
        
        if ($isActive === false) {
            // Fallback a DB solo si caché miss
            global $pdo;
            $stmt = $pdo->prepare("SELECT active FROM tenant_registry WHERE tenant_id = ?");
            $stmt->execute([$tenantId]);
            $row = $stmt->fetch(\PDO::FETCH_ASSOC);
            
            if (!$row || !$row['active']) {
                throw new Exception('TENANT_INACTIVE', 403);
            }
            
            // Poblar caché para próximas requests
            $redis->setex("tenant_active:{$tenantId}", 3600, '1');
        }
        
        return $schemaName;
    }
    
    /**
     * Cambia el schema activo en la conexión PDO actual.
     * CRÍTICO: Usar USE statement, no concatenar schema en queries.
     */
    public static function activateSchema(\PDO $pdo, string $schemaName): void
    {
        // Validar nombre de schema contra inyección (ya filtrado por regex arriba)
        $pdo->exec("USE `{$schemaName}`");
    }
}

Paso 2: Factory de Conexión con Pooling

Crear una nueva conexión PDO por request es inviable bajo alto tráfico. Usamos un pool estático que reutiliza conexiones y cambia de schema según el tenant activo:

<?php
declare(strict_types=1);

namespace App\Database;

use PDO;
use App\Middleware\TenantResolver;

final class TenantConnection
{
    private static ?PDO $instance = null;
    private static ?string $currentSchema = null;
    
    public static function get(string $schemaName): PDO
    {
        // Crear conexión solo una vez por proceso PHP-FPM worker
        if (self::$instance === null) {
            self::$instance = new PDO(
                'mysql:host=127.0.0.1;dbname=information_schema;charset=utf8mb4',
                $_SERVER['CD_DB_USER'],
                $_SERVER['CD_DB_PASS'],
                [
                    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                    PDO::ATTR_EMULATE_PREPARES => false, // Prevenir inyección en prepared statements
                    PDO::MYSQL_ATTR_INIT_COMMAND => "SET SESSION sql_mode='STRICT_TRANS_TABLES,NO_ZERO_DATE'"
                ]
            );
        }
        
        // Cambiar schema solo si cambió el tenant (evita USE redundante)
        if (self::$currentSchema !== $schemaName) {
            TenantResolver::activateSchema(self::$instance, $schemaName);
            self::$currentSchema = $schemaName;
        }
        
        return self::$instance;
    }
    
    /**
     * Resetear estado al final del request (previene fuga de schema entre requests en mismo worker)
     */
    public static function reset(): void
    {
        self::$currentSchema = null;
        // Opcional: volver a information_schema como default seguro
        if (self::$instance !== null) {
            self::$instance->exec("USE `information_schema`");
        }
    }
}

Paso 3: Provisioning Automatizado de Nuevos Tenants

Cuando se onboarded un nuevo cliente fintech, se crea su schema y se aplican migraciones automáticamente. Nunca ejecutar DDL manual en producción:

<?php
// Ejecutar solo desde CLI o endpoint admin autenticado
function provisionNewTenant(string $tenantId): void
{
    $schemaName = 'tenant_' . $tenantId;
    
    // 1. Crear schema
    global $adminPdo; // Conexión con privilegios CREATE SCHEMA
    $adminPdo->exec("CREATE DATABASE IF NOT EXISTS `{$schemaName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
    
    // 2. Aplicar migraciones base (transacciones, ledger, usuarios)
    $migrations = glob(__DIR__ . '/migrations/tenant_base/*.sql');
    sort($migrations); // Orden alfabético = orden de ejecución
    
    $adminPdo->exec("USE `{$schemaName}`");
    foreach ($migrations as $file) {
        $sql = file_get_contents($file);
        $adminPdo->exec($sql);
    }
    
    // 3. Registrar en tabla central de tenants
    $stmt = $adminPdo->prepare("INSERT INTO tenant_registry (tenant_id, schema_name, created_at, active) VALUES (?, ?, NOW(), 1)");
    $stmt->execute([$tenantId, $schemaName]);
    
    // 4. Invalidar caché negativa
    $redis = new \Redis();
    $redis->connect('127.0.0.1');
    $redis->del("tenant_active:{$tenantId}");
    
    error_log("[CD-Provision] Tenant {$tenantId} provisionado exitosamente.");
}

Seguridad Crítica: Prevención de Fuga Cruzada

  • Nunca confiar en input del cliente para schema: Siempre derivar desde JWT verificado. Parámetros URL como ?tenant=abc son vectores de ataque directos.
  • Resetear schema al final de cada request: Workers PHP-FPM reutilizan procesos. Si no reseteas, el siguiente request en el mismo worker hereda el schema del tenant anterior → fuga de datos silenciosa.
  • Privilegios mínimos por schema: El usuario de aplicación debe tener SELECT/INSERT/UPDATE/DELETE solo en schemas tenant_*, nunca CREATE/DROP/GRANT. Esos privilegios solo para usuario admin de provisioning.
  • Auditoría de acceso cruzado: Loguear cada cambio de schema con tenant_id, user_id, timestamp y endpoint. Alertar si un mismo worker sirve >5 tenants distintos en <1 segundo (posible ataque de enumeración).

Escalabilidad y Rendimiento

El modelo schema-per-tenant escala horizontalmente sin cambios de código:

  • Hasta ~200 tenants por instancia MariaDB: Límite práctico dictado por overhead de metadata y file descriptors. Monitorear innodb_open_files y table_open_cache.
  • Sharding por rangos de tenant_id: Cuando superas 200 tenants, distribuye schemas en múltiples instancias MariaDB usando hash consistente. El middleware de resolución consulta registry central para saber en qué shard está cada tenant.
  • Backups granulares: Usa mariabackup --databases="tenant_abc" para backup/restauración individual sin afectar otros tenants. RPO/RTO por tenant independiente.
  • Pool de conexiones persistente: Configura pm.max_children en PHP-FPM igual al número máximo de tenants concurrentes esperados. Cada worker mantiene una conexión abierta que cambia de schema en memoria, sin overhead TCP/TLS por request.

Conclusión

La arquitectura multi-tenant con schema isolation en PHP 8.4 + MariaDB proporciona el equilibrio óptimo para SaaS fintech LATAM: aislamiento regulatoriamente aceptable, costo operativo compartido y escalabilidad predecible. La clave no está en la complejidad técnica, sino en la disciplina operativa: nunca confiar en input del cliente para routing de schema, siempre resetear estado entre requests, y auditar cada acceso cruzado.

Recuerda: en fintech B2B, un solo incidente de fuga cruzada destruye la confianza de todos tus clientes simultáneamente. Invierte en aislamiento correcto desde el día uno; refactorizar después es 10x más caro y riesgoso.