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:
| Modelo | Aislamiento | Costo Infra | Complejidad Ops | Cumplimiento Regulatorio | Recomendado Para |
|---|---|---|---|---|---|
| DB por Tenant | ✅ Máximo | ❌ Muy Alto | ❌ Alta (N DBs) | ✅ Total | Bancos / Grandes corporativos |
| Schema por Tenant | ✅ Fuerte | ⚠️ Medio | ⚠️ Media | ✅ Aceptable con controles | SaaS Fintech B2B LATAM |
| Tabla Compartida + tenant_id | ❌ Débil | ✅ Bajo | ✅ Baja | ❌ Riesgo alto fuga cruzada | MVPs 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=abcson 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_filesytable_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_childrenen 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.