ProCat Solutions
Un WHERE olvidado: así separamos a los inquilinos de un SaaS
Modelos de aislamiento de inquilinos, row-level security, configuración por inquilino en base de datos, migraciones, vecino ruidoso, facturación y backups.
Basta un WHERE tenant_id = ... olvidado para que un cliente vea los datos de otro. Ese es el riesgo de fondo de un SaaS multi-tenant, el tipo de sistema en el que una única base de código y una única infraestructura dan servicio a muchos clientes independientes entre sí. Los problemas de carga de la entrada anterior suman aquí una capa más. El sistema tiene que ser rápido y seguro, y además garantizar que ningún inquilino vea ni ralentice a los demás. Las decisiones que siguen salen de los proyectos SaaS del último año.

Tres modelos
Hay tres formas básicas de separar los datos de los inquilinos, y cada una tiene su lugar.
Esquema compartido, con columna tenant_id. Todas las tablas llevan un identificador de inquilino y todas las consultas filtran por él. Es lo más barato de operar, las migraciones son simples y el backup abarca una sola base de datos. El riesgo: un único WHERE tenant_id = ... olvidado y ya hay fuga de datos.
Un esquema por inquilino. Dentro de una misma base de datos PostgreSQL, cada inquilino recibe su propio esquema. El aislamiento es más fuerte y, con el search_path configurado, las consultas no cambian. A cambio, la migración se ejecuta inquilino por inquilino y, a partir de unos pocos cientos de esquemas, las operaciones sobre el catálogo se ralentizan de forma apreciable.
Una base de datos por inquilino. Aislamiento total, backup y restauración por inquilino, incluso en servidores separados. Es la opción más cara y solo se justifica por una exigencia contractual o regulatoria.
La mayoría de nuestros proyectos opta por el esquema compartido. El riesgo del filtro olvidado no se confía a la disciplina del equipo; de eso se encarga una herramienta a nivel de base de datos.
Que filtre la base de datos
Con el row-level security (RLS) de PostgreSQL, el filtrado lo impone la propia base de datos, aunque la aplicación se equivoque. Nuestro patrón:
- en todas las tablas asociadas a un inquilino,
ENABLE ROW LEVEL SECURITYy una policy que filtra por el valor decurrent_setting('app.tenant_id'), - la aplicación fija el inquilino al principio de la petición, dentro de la transacción, con
SET LOCAL app.tenant_id = ..., - el usuario de base de datos de la aplicación no es propietario de la tabla, de modo que la policy también le aplica.
Si falta una condición WHERE, el resultado es un conjunto vacío. Eso se detecta mucho antes que una fuga de datos y duele mucho menos. El precio es una complicación mínima de los planes de consulta y el hecho de que, con PgBouncer en modo transacción, SET LOCAL es la única forma segura.

Los ajustes, en una tabla
Es una lección que hemos pagado varias veces. Los ajustes propios de cada inquilino (límites, funcionalidades activadas, claves de integración, elementos de marca) van a la base de datos, a una tabla versionada del estilo de tenant_settings. Fuera quedan las variables de entorno y los ficheros de configuración. Los motivos:
- dar de alta un inquilino nuevo no requiere un despliegue,
- los cambios en los ajustes son auditables y reversibles,
- la interfaz de administración ve exactamente los mismos datos que la aplicación.
Los secretos (claves de API, contraseñas) se guardan cifrados y la clave se lee del entorno de la aplicación. Los ajustes se cachean en Redis con un TTL corto, porque los necesita cada petición.
Migraciones y el vecino ruidoso
La gran ventaja del esquema compartido es que la migración se ejecuta una sola vez. Pero modificar una tabla grande afecta a todos los inquilinos a la vez, así que mantenemos algunas reglas: solo cambios aditivos en horario de producción, borrado de columnas en dos pasos (primero el código, después el esquema) y CREATE INDEX CONCURRENTLY para todos los índices.
Contra el “vecino ruidoso”, cuando la carga de un inquilino ralentiza a los demás, nos defendemos en varios niveles:
- rate limit por inquilino en Redis, además del global,
- las operaciones pesadas (exportación, importación masiva, informes) van a una cola en segundo plano, con un límite de concurrencia por inquilino,
- las consultas llevan un
statement_timeout, para que una sola consulta mala no ocupe una conexión durante minutos, - el identificador de inquilino figura como etiqueta en todas las métricas, de modo que el inquilino problemático salta a la vista.
Facturación y backups
En un SaaS, la facturación es una cuestión de arquitectura y difícil de añadir a posteriori. Hay dos cosas que incorporamos desde el primer día:
Eventos de uso. Cada operación facturable (llamada a la API, mensaje enviado, registro almacenado) escribe un evento en una tabla append-only, con identificador de inquilino y marca de tiempo. De ahí sale el resumen al cierre del periodo de facturación y, en caso de disputa, ahí se reconstruye lo ocurrido.
Hooks de transición de estado. El ciclo de vida del inquilino (prueba, activo, impago, suspendido, eliminado) es una máquina de estados explícita. Cada transición ejecuta sus hooks: notificación, limitación de funcionalidades, arranque del temporizador de retención de datos. La pregunta “qué pasa si no paga” tiene así una respuesta documentada.
El punto débil del esquema compartido es el backup: restaurar un único inquilino no es trivial. Por eso, además del backup completo diario, generamos un export lógico por inquilino (pg_dump con datos filtrados o un export a nivel de aplicación) y ensayamos la restauración con regularidad, en la práctica y no solo sobre el papel.
La arquitectura multi-tenant se reduce a una docena de decisiones menores que dependen unas de otras. El esquema compartido con RLS, la configuración de inquilinos en base de datos y los límites por inquilino ofrecen, en la mayoría de los casos, un buen equilibrio entre simplicidad operativa y seguridad. Si un inquilino exige aislamiento total por motivos contractuales, recibe una base de datos separada. Para nosotros, eso sigue siendo la excepción.
También construimos y operamos esta arquitectura para clientes (desarrollo y operación SaaS). Nuestro propio producto, Abigel.ai, muestra cómo funciona en producción en un caso de éxito.