NubliVaultby Nublify
← Blog

· 8 min de lectura

Multi-tenant: cómo impedir que el backup de un cliente se filtre en el de otro

La validación en la capa de aplicación falla por el camino que nadie revisó. El aislamiento que sobrevive a un equipo distraído está en la base de datos, no en el código.

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/8142 carga 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:

  1. 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.
  2. Los bloques de contenido. El dato en sí.
  3. 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.
  4. 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.
  5. 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.
  6. 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. Sin FORCE, 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 BYPASSRLS ni ser superusuario.
  • La escritura también tiene que estar cubierta, no solo la lectura. En una política FOR ALL o FOR UPDATE sin WITH CHECK, PostgreSQL usa la propia expresión USING para rechazar una fila nueva con tenant_id ajeno; una política FOR INSERT solo acepta WITH CHECK. Declarar WITH CHECK explí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 con SET LOCAL. Con pool en modo transacción, un SET sin LOCAL filtra 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_id como B e intenta SELECT, UPDATE y DELETE sobre filas de A.
  • Una verificación de esquema en el CI que falle si existe una tabla de datos sin tenant_id o 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