Todo sistema de backup multi-tenant tiene el mismo punto de ruina: una consulta sin
WHERE tenant_id = ?. No es un ataque sofisticado. Es un endpoint nuevo escrito un
viernes, un informe gerencial, un job de limpieza, una query ejecutada a mano
para responder a un ticket de soporte.
El daño es desproporcionado respecto al error. En un sistema de backup, "filtrar un registro" rara vez significa filtrar un registro. Significa exponer el árbol de directorios de otra empresa, los nombres de los archivos, los horarios de trabajo, el tamaño de la base de datos del ERP — y, en el peor caso, darle a un cliente la capacidad de restaurar el contenido de otro.
Por qué validar en la aplicación no basta
La verificación en la capa de aplicación funciona. El problema es que funciona exactamente en los lugares donde alguien se acordó de escribirla.
Los caminos que se escapan son siempre los mismos:
- Endpoints nuevos. El equipo copia un controller existente, cambia la entidad y olvida el filtro. El code review detecta la mayoría. "La mayoría" no es un control de seguridad.
- Jobs asíncronos. Worker de expiración, recálculo de uso, recolección de métricas. Se ejecutan sin usuario autenticado, así que el desarrollador no tiene el contexto de tenant a mano y escribe la query global.
- Informes y exportaciones. Casi siempre SQL crudo, casi siempre fuera del ORM, casi siempre fuera de la capa que aplica la regla.
- Migraciones y scripts de corrección. Alguien necesita arreglar 4.000 filas a las dos de la mañana. El script no pasa por la aplicación.
- Carga por ID.
GET /snapshots/8142carga el snapshot y lo devuelve. Si el ID es global y secuencial, enumerar es trivial. - Soporte. La herramienta interna que lee cualquier tenant es legítima — hasta que su credencial es reutilizada por un servicio que no debería tenerla.
La regla práctica: si el aislamiento depende de que alguien se acuerde, va a fallar en proporción al tamaño del equipo multiplicado por el tiempo. El aislamiento tiene que ser la condición por defecto, no la acción consciente.
La superficie de filtración de un sistema de backup
Vale la pena enumerarla, porque es mayor de lo que parece:
- El índice. Ruta completa, nombre, tamaño, fecha de modificación, hash. Es el mapa de la empresa. Se filtra solo, sin un solo byte de contenido.
- Los bloques de contenido. El dato en sí.
- Las claves. Si existe una clave por tenant, el control de quién puede leerla es tan crítico como el control del dato — tema tratado en quién puede abrir tu backup.
- La restauración. El camino menos ejercitado del sistema, y el que entrega contenido directo en manos de alguien. Hablamos de ello en por qué el restore es la parte que nadie prueba.
- Logs, métricas y telemetría. Una ruta de archivo en un mensaje de error es una filtración con retención larga y copia en tres sistemas de observabilidad.
- La deduplicación global. Esta merece sección propia.
La deduplicación entre tenants se filtra por construcción
La deduplicación por hash de contenido compartida entre clientes ahorra almacenamiento y crea un oráculo de existencia. Quien pueda observar que una subida se resolvió como "ya existe" aprende que otro cliente del mismo sistema tiene ese archivo exacto. Para un archivo público eso es inofensivo. Para un contrato en PDF, una planilla de nómina o un binario interno, no.
La mitigación es aburrida y conocida: ámbito de deduplicación por tenant, o clave de derivación distinta por tenant antes del hash. Pagas en espacio lo que ahorras en riesgo. Es una decisión explícita, no un detalle de implementación.
El aislamiento que sobrevive: restricción en la base de datos
La idea es invertir el patrón. En vez de que la query tenga que pedir el filtro, tiene que pedir permiso para no tenerlo.
1. tenant_id en toda tabla de datos, NOT NULL. Sin excepciones del tipo "porque
esta tabla es de metadatos". El metadato de backup es dato del cliente.
2. Clave primaria compuesta: (tenant_id, id). Y toda clave foránea
lleva el tenant_id consigo:
PRIMARY KEY (tenant_id, id),
FOREIGN KEY (tenant_id, snapshot_id)
REFERENCES snapshots (tenant_id, id)
Esto es lo que impide, a nivel de base de datos, que un registro de un tenant apunte al padre de otro. Es la clase de bug que a la revisión de código le cuesta detectar, porque el código parece correcto.
3. Row Level Security activada y forzada. En PostgreSQL, la política vive en la tabla y vale para cualquier vía de acceso — ORM, SQL crudo, job, psql de guardia. La documentación oficial describe el comportamiento en Row Security Policies.
Cuatro detalles que deciden si la protección es real:
ALTER TABLE ... FORCE ROW LEVEL SECURITY. SinFORCE, el dueño de la tabla no queda sometido a la política — y la aplicación suele conectarse como dueño.- El rol de la aplicación no puede tener
BYPASSRLSni ser superusuario. - La escritura también tiene que estar cubierta, no solo la lectura. En una
política
FOR ALLoFOR UPDATEsinWITH CHECK, PostgreSQL usa la propia expresiónUSINGpara rechazar una fila nueva contenant_idajeno; una políticaFOR INSERTsolo aceptaWITH CHECK. DeclararWITH CHECKexplícitamente no cambia el efecto y deja la intención legible. - El tenant viene de un parámetro de sesión, por ejemplo
current_setting('app.tenant_id'), definido al inicio de cada transacción conSET LOCAL. Con pool en modo transacción, unSETsinLOCALfiltra el contexto a la siguiente transacción, que puede ser de otro cliente. Ese es el error de configuración más peligroso del conjunto, porque el sistema funciona perfectamente en desarrollo, donde no hay pool.
4. IDs opacos hacia el exterior. UUID o identificador aleatorio en la API. No resuelve la autorización — solo quita de la mesa la enumeración trivial.
5. El mismo principio en el almacenamiento de objetos. Prefijo por tenant, derivado del contexto autenticado en el servidor y nunca de un campo que venga del cliente. Donde el proveedor lo permita, credencial temporal restringida al prefijo, para que el ámbito se imponga fuera de tu proceso. URLs prefirmadas generadas por tenant, con validez corta.
Cómo probarlo: escribe el test que intenta filtrar
Un test que confirma "el cliente A ve los datos de A" pasa incluso con la RLS desactivada. El test que importa es el inverso.
- Para cada endpoint, una llamada autenticada como tenant B pidiendo un ID de A. Respuesta esperada: 404, no 403 — un 403 confirma la existencia del recurso.
- Un test de base de datos que abre conexión con el rol de la aplicación, define
app.tenant_idcomo B e intentaSELECT,UPDATEyDELETEsobre filas de A. - Una verificación de esquema en el CI que falle si existe una tabla de datos sin
tenant_ido sin política activa. Eso cubre la tabla que alguien creará dentro de seis meses sin haber leído este documento. - Un test de restauración completo con dos tenants poblados, comprobando el recuento y los hashes del contenido entregado.
Lo que este artículo no demuestra
Este enfoque tiene coste y tiene límites, y omitirlos sería deshonesto.
La RLS afecta al planificador. La condición de la política entra en toda consulta. En
tablas grandes, eso cambia los planes y exige que los índices empiecen por tenant_id.
Medir con tu volumen es obligatorio; aquí no existe un número genérico.
La clave compuesta encarece. Índices más grandes, joins más verbosos, ORMs que protestan. Es trabajo real de refactorización en un sistema que ya está en producción.
No toda base de datos lo ofrece. En bases sin RLS, el equivalente posible es usuario por tenant, schema por tenant o vistas restringidas — cada uno con su propia lista de trampas. El principio se mantiene; la implementación no se traslada.
Una base de datos por tenant aísla mejor. Es la opción más fuerte y la más cara: más conexiones, más migraciones, más monitorización, más trabajo en cada despliegue. Para pocos clientes grandes suele compensar. Para muchos clientes pequeños, rara vez.
El aislamiento en la base de datos no lo cubre todo. No protege contra una credencial de administrador comprometida, un bug en el proveedor de almacenamiento, una clave compartida entre tenants o una caché en memoria indexada sin el tenant en la clave. La caché es un punto ciego frecuente: la política de la base de datos no ve el Redis.
Nada de esto sustituye a la retención y la inmutabilidad. El aislamiento impide que un cliente lea el backup de otro. No impide que el backup sea borrado — ese es otro control, descrito en backup inmutable con Object Lock.
El resumen es desalentador y cierto: la parte eficaz de la solución es burocrática. Constraint, política, test negativo, verificación de esquema en el CI. No hay nada elegante en eso. Lo que hay es un sistema en el que la consulta equivocada falla en vez de devolver los datos de otra empresa.
NubliVault es el producto de backup de Nublify: empaqueta millones de archivos pequeños y los guarda en una nube distinta a la de origen. Cómo funciona · Seguridad