Saltar al contenido
Maestransa
Conversemos

Multi-tenancy: por qué un WHERE empresa_id no basta

Filtrar por inquilino en la capa de aplicación convierte cada consulta en un punto único de fuga. Qué falla, por qué, y cómo se defiende de verdad.

Casi todo sistema que sirve a varias empresas desde una sola instalación empieza igual: una columna empresa_id en cada tabla y un WHERE empresa_id = ? en cada consulta.

Funciona. Es simple, es barato y es lo que hace la mayoría. El problema no es que esté mal: es que convierte cada consulta del sistema en un punto donde se puede filtrar información de un cliente a otro, y basta con que falle una.

La aritmética del asunto

Un sistema mediano tiene fácilmente varios cientos de consultas entre listados, reportes, exportaciones, búsquedas, tareas programadas y endpoints. Si el aislamiento depende de que todas lleven su filtro, el sistema es seguro solo mientras el acierto sea del cien por cien, para siempre, incluido el desarrollador nuevo de dentro de dos años y el reporte urgente de un viernes.

Ningún control que exige perfección sostenida es un control. Es una expectativa.

Y el fallo no avisa: una consulta sin filtro devuelve más filas, no un error. En un listado paginado puede pasar meses inadvertida. Se descubre cuando un cliente ve en pantalla el nombre de otro, y para entonces ya no es un bug — es un incidente que hay que notificar.

Dónde se cae en la práctica

Rara vez en el SELECT obvio del listado principal. Se cae en los bordes:

Las consultas escritas a mano. El ORM aplica el filtro global bien, pero el reporte pesado se escribió en SQL directo para que fuera rápido, y ahí el filtro depende de que alguien se acordara.

Los JOIN. La tabla principal está filtrada, la que se une no. Un pedido filtrado por inquilino que trae líneas de pedido sin filtrar devuelve datos de otro en cuanto los identificadores no son globalmente únicos.

La búsqueda por identificador. SELECT * FROM documento WHERE id = ? sin inquilino. Si los identificadores son secuenciales, cualquiera puede iterarlos desde la barra de direcciones. Es la fuga más fácil de explotar y la más común.

Los procesos en segundo plano. Corren sin usuario en sesión, así que el contexto de inquilino no viene de ningún sitio. Es donde el filtro «automático» del framework normalmente no aplica.

Las agregaciones. Un COUNT o un SUM sin filtro no muestra filas ajenas, pero revela su volumen. Sigue siendo una fuga.

Por qué «tenemos cuidado» no es una respuesta

Cuando se plantea esto, la respuesta habitual es que el equipo revisa, que hay code review, que se usa un scope global del ORM.

El scope global del ORM ayuda de verdad y conviene usarlo. Pero se puede desactivar, no aplica al SQL escrito a mano, y no aplica a lo que corre fuera de una petición con usuario. Es una capa útil, no un límite.

La diferencia entre una capa y un límite es dónde vive: si el aislamiento vive en el mismo lugar donde se escribe la lógica de negocio, quien escribe la lógica puede saltárselo sin darse cuenta. Un límite real está en una capa que el código de aplicación no puede eludir aunque quiera.

Las tres formas de tener un límite real

De menor a mayor separación, y por tanto de menor a mayor costo.

Seguridad a nivel de fila en la base de datos

En PostgreSQL, Row Level Security. Se declara una política por tabla y el motor aplica el filtro él mismo, en cada consulta, venga de donde venga: del ORM, del SQL a mano, del reporte, del proceso nocturno o de alguien conectado con un cliente de base de datos.

El aislamiento deja de depender de que el desarrollador se acuerde y pasa a depender de que la política esté declarada, que es una cosa por tabla en vez de una por consulta. Es un cambio de orden de magnitud en la superficie de error.

Lo que hay que cuidar: la sesión tiene que llevar el inquilino actual, y el usuario de base de datos de la aplicación no puede ser superusuario ni dueño de las tablas, porque esos eluden las políticas. Es fácil de configurar mal de una forma que parece funcionar.

Un esquema por inquilino

Cada cliente en su propio esquema, con las mismas tablas. El aislamiento es estructural: una consulta mal escrita ve las tablas de su esquema, punto.

El costo aparece en las migraciones —hay que aplicarlas a todos los esquemas, de forma consistente, con una estrategia para cuando una falla a mitad— y en las consultas que necesitan cruzar inquilinos, que ahora son un problema en vez de un GROUP BY.

Escala razonablemente hasta cientos de inquilinos. Con miles, la gestión de migraciones se vuelve el trabajo dominante.

Una base de datos por inquilino

La separación máxima. Además del aislamiento, permite respaldos y restauraciones independientes, y responder a «bórrenos todos nuestros datos» sin escribir un DELETE cuidadoso.

Es también la más cara: el pool de conexiones se multiplica, la operación se multiplica, y el costo de infraestructura deja de ser compartido. Se justifica cuando el cliente lo exige por contrato o por regulación, o cuando los inquilinos son pocos y grandes.

Elegir no es una cuestión de gusto

El criterio no es cuál es más elegante, sino qué pasa si se filtra.

Si sus inquilinos son sucursales de la misma empresa, una fuga es un problema interno y el filtro en aplicación reforzado con seguridad a nivel de fila es proporcionado.

Si sus inquilinos son empresas que compiten entre sí, o si hay datos sujetos a regulación —financieros, de salud, personales en volumen—, una fuga no es un bug: es una obligación de notificación, un riesgo contractual y un daño de reputación que sobrevive a la corrección. Ahí el costo del esquema o la base separada deja de parecer caro.

Es una decisión de arquitectura, se toma antes de la primera tabla, y cambiarla después significa migrar datos de todos los clientes a la vez.

La prueba que casi nadie escribe

Independientemente del modelo elegido, el sistema debería tener una prueba automatizada que haga esto: crear dos inquilinos con datos, autenticarse como el primero, e intentar acceder a los identificadores del segundo por todas las vías —listado, detalle por id, búsqueda, exportación, informe, API.

La prueba pasa cuando todos esos intentos devuelven vacío o error de autorización. No cuando devuelven algo razonable.

Es media jornada de trabajo, corre en cada cambio, y es la única forma de saber que el aislamiento sigue existiendo después de seis meses de funcionalidades nuevas escritas por gente que no estuvo en esta conversación.

Construimos portales y extranets con permisos por organización