SaaS multiinquilino con RLS de Postgres

Appluex·12 de junio de 2026·12 min de lectura
Desarrollo de softwareWeb

Si construyes software B2B, construyes un sistema multiinquilino aunque nunca lo llames así. Muchas empresas usan la misma aplicación, y los datos de cada una tienen que ser completamente invisibles para las demás. Ese aislamiento no es una función que se agrega después. Una sola fuga entre inquilinos es el tipo de incidente que termina contratos, obliga a notificar una brecha y persigue a la empresa en cada cuestionario de seguridad que llene a partir de entonces. Este artículo es el patrón en el que más confiamos para mantener separados a los inquilinos, la seguridad a nivel de fila de Postgres (Row-Level Security, RLS), escrito para los ingenieros que van a implementarlo y para los fundadores que necesitan saber qué están pagando. Es el mismo modelo detrás de nuestros propios productos multiinquilino, desde Vexolis, una plataforma de logística donde cada transportista ve solo su propia flota, hasta DibsHub, donde cada proveedor de servicios es dueño de un calendario privado.

Por qué “agregar WHERE tenant_id” termina fallando

El diseño obvio pone una columna tenant_id en cada tabla y añade WHERE tenant_id = ? a cada consulta. Funciona, hasta el día en que alguien lo olvida. Un endpoint de reportes nuevo, un job en segundo plano escrito a medianoche, una relación del ORM cargada por una ruta que nadie revisó, un string de SQL crudo en una herramienta de administración. Cada uno es una consulta que tiene que ser perfecta para siempre, y la falla es silenciosa: nada se rompe, simplemente la Empresa A ve las facturas de la Empresa B.

El problema de fondo es dónde vive la garantía. Cuando el aislamiento depende del código de la aplicación, la garantía está repartida entre miles de líneas, decenas de colaboradores y cada refactorización futura. Las propiedades de seguridad repartidas se degradan. La solución es mover la garantía al único lugar por el que toda consulta tiene que pasar: el propio motor de base de datos.

Qué hace realmente la seguridad a nivel de fila

RLS te permite adjuntar una política a una tabla. Una vez que la tabla tiene RLS activado, cada sentencia contra ella (selects, updates, deletes, inserts) se filtra por esa política antes de devolver o tocar una sola fila. La aplicación ya no tiene que recordar el filtro; el motor lo aplica dentro del propio plan de consulta. Un WHERE olvidado deja de ser un agujero de seguridad y se convierte en una consulta apenas menos eficiente.

La política necesita saber qué inquilino está preguntando. El enfoque convencional es una variable de sesión que tu aplicación fija al inicio de cada petición y que la política lee mediante current_setting(). Esta es la forma completa para una tabla:

-- 1. Cada tabla propiedad de un inquilino lleva su id, no nulo.
ALTER TABLE invoices
  ADD COLUMN tenant_id uuid NOT NULL REFERENCES tenants(id);

-- 2. Activar RLS y FORZARLO, para que incluso el dueño de la tabla quede filtrado.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;

-- 3. Una política que cubre lecturas y escrituras del inquilino actual.
--    USING filtra filas que ya existen; WITH CHECK valida las filas que se
--    insertan o actualizan, para que un inquilino no escriba dentro de otro.
CREATE POLICY tenant_isolation ON invoices
  FOR ALL
  USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);

-- 4. Opcional pero útil: valor por defecto para que un insert no pueda olvidarlo.
ALTER TABLE invoices
  ALTER COLUMN tenant_id
  SET DEFAULT current_setting('app.tenant_id', true)::uuid;

Tres detalles de ese fragmento importan más de lo que parecen. FORCE ROW LEVEL SECURITY cierra la brecha por la que el rol dueño de la tabla se salta las políticas de forma predeterminada; si tu aplicación se conecta con el mismo rol que corrió las migraciones, necesitas esa línea o RLS no hace nada en silencio. El segundo argumento de current_setting, el true, le indica a Postgres que devuelva nulo en vez de lanzar un error cuando la variable no está definida. Suena a comodidad, y lo es, pero también significa que un inquilino sin definir produce tenant_id = NULL, que no coincide con nada, así que una petición que olvidó fijar su inquilino ve una tabla vacía en lugar de los datos de todos. Y WITH CHECK es la mitad que la mayoría de los tutoriales omite: sin ella, un inquilino solo puede leer sus filas, pero todavía puede insertar una fila marcada con el id de otro.

El objetivo es que una fuga entre inquilinos sea imposible por construcción, no apenas improbable mientras cada desarrollador se mantenga cuidadoso durante toda la vida del producto.

Políticas separadas cuando lecturas y escrituras difieren

Una sola política FOR ALL sirve para la mayoría de las tablas, pero los productos reales suelen necesitar reglas asimétricas. Los datos de referencia compartidos por todos los inquilinos deberían poder leerse por todos y escribirse por nadie. Las bitácoras de auditoría deberían ser solo de anexado. El personal de soporte puede necesitar lectura entre inquilinos, con un rol distinto y con cada acceso registrado. Postgres permite apilar políticas por comando:

-- Catálogo compartido: todos leen, nadie escribe a través del rol de la app.
ALTER TABLE plan_catalog ENABLE ROW LEVEL SECURITY;
CREATE POLICY read_all ON plan_catalog FOR SELECT USING (true);

-- Bitácora de auditoría solo de anexado, acotada al inquilino.
ALTER TABLE audit_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE audit_events FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_read   ON audit_events FOR SELECT
  USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
CREATE POLICY tenant_append ON audit_events FOR INSERT
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- No existe política de UPDATE ni DELETE, así que esas sentencias afectan cero filas.

-- Rol de soporte: solo lecturas entre inquilinos, y solo con un id de ticket fijado,
-- que tu aplicación registra junto a cada consulta que ejecuta.
CREATE POLICY support_read ON invoices FOR SELECT TO support_role
  USING (current_setting('app.support_ticket', true) IS NOT NULL);

Las políticas permisivas sobre la misma tabla se combinan con OR, así que la lectura entre inquilinos del rol de soporte no debilita la política de inquilino para las conexiones normales de la aplicación; solo aplica a las sesiones que corren como support_role. Mantén pocos roles y reglas legibles. Una política que nadie del equipo puede explicar en una frase es una política que se desactivará “temporalmente” durante un incidente y nunca se volverá a activar.

Propagar el id del inquilino desde la petición hasta la política

RLS es exactamente tan confiable como el valor que fijes en app.tenant_id. Ese valor tiene que salir de la sesión verificada en el servidor, nunca de un encabezado, un parámetro de consulta o un campo del cuerpo que el cliente controle. El flujo es: autenticar la petición, resolver el inquilino del usuario a partir de tus propios registros, abrir una transacción, fijar la variable dentro de esa transacción, ejecutar las consultas de la petición, confirmar.

La palabra clave es SET LOCAL (o set_config(nombre, valor, true), que es lo mismo en forma de función). Un SET simple persiste durante toda la vida de la conexión; SET LOCAL dura solo hasta que termina la transacción. Con un pool de conexiones esa diferencia lo es todo, porque la siguiente petición que tome prestada la misma conexión no debe heredar el inquilino anterior. Esta es la forma con Prisma, que es lo que usan la mayoría de nuestros productos en Next.js; la misma idea se traslada directo a Drizzle, Kysely o un cliente pg crudo:

// lib/tenant-db.js
export async function withTenant(tenantId, fn) {
  return prisma.$transaction(async (tx) => {
    // set_config(..., true) acota el valor a esta transacción solamente.
    await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
    return fn(tx);
  });
}

// pages/api/invoices.js
export default async function handler(req, res) {
  const session = await requireSession(req);          // verificada, del lado del servidor
  const rows = await withTenant(session.tenantId, (tx) =>
    tx.invoice.findMany({ orderBy: { issuedAt: "desc" } })  // sin WHERE tenant_id
  );
  res.json(rows);
}

Dos reglas mantienen esto honesto. Primera, todo acceso a la base de datos pasa por withTenant, incluidos los jobs en segundo plano, los webhooks y las tareas de cron; un job que consulta sin inquilino fijado obtiene resultados vacíos, lo bastante ruidoso para notarlo en pruebas y lo bastante seguro para no filtrar nunca en producción. Segunda, el trabajo a nivel de sistema usa un rol distinto, deliberadamente no sujeto a la política de inquilino, y las credenciales de ese rol nunca aparecen en el código que atiende peticiones. Las migraciones, la conciliación de cobros entre todos los inquilinos y las herramientas de soporte corren ahí, con su propio rastro de auditoría.

Pool de conexiones: las advertencias que muerden en producción

La mayoría de los despliegues en producción ponen un pooler entre la aplicación y Postgres, sea PgBouncer o un equivalente gestionado. Los poolers funcionan en uno de tres modos, y solo uno es seguro con el patrón de variable de sesión si no tienes cuidado.

Modo del poolQué recibe la appImplicación para RLS
SesiónUna conexión de servidor dedicada hasta que el cliente se desconectaTanto SET como SET LOCAL funcionan, pero el pool es mucho menos eficiente; restablece las variables al liberar de todos modos
TransacciónUna conexión de servidor solo mientras dura cada transacciónSolo SET LOCAL dentro de una transacción explícita es seguro; un SET suelto puede caer en una conexión distinta de la consulta que sigue
SentenciaUna conexión de servidor por sentenciaNo admite transacciones de varias sentencias, así que el patrón no funciona en absoluto

El modo transacción es la opción común para apps de alta concurrencia y está bien, siempre que la configuración del inquilino y las consultas que dependen de ella vivan dentro de la misma transacción explícita. Los ORM que abren transacciones implícitas por consulta pueden romper esto de formas sutiles: el SET corre en una transacción implícita, termina, y la siguiente consulta corre en la conexión que el pooler entregue. El envoltorio withTenant de arriba evita la trampa precisamente porque usa una sola transacción interactiva para toda la unidad de trabajo. Algunos proveedores de Postgres serverless añaden sus propias capas de pooling; lee su documentación sobre cómo manejan el estado de sesión y verifícalo con la prueba descrita más abajo, en lugar de confiar en la página de marketing.

Rendimiento: índices y forma de la política

RLS añade un predicado a cada consulta, lo que significa que el planificador lo trata como cualquier otra cláusula WHERE. Las consecuencias prácticas son ordinarias y fáciles de resolver. Pon tenant_id como primera columna en los índices compuestos de las tablas por inquilino: (tenant_id, issued_at), (tenant_id, status), etcétera, para que el predicado reduzca el escaneo antes de que corra cualquier otra cosa. Mantén la expresión de la política barata y determinista; current_setting() cuesta poco, pero una política que llame a una subconsulta o a una función volátil por cada fila aparecerá en tu registro de consultas lentas. Si envuelves la lectura del ajuste en una función auxiliar por legibilidad, decláralala STABLE para que el planificador pueda evaluarla una vez por consulta y no una vez por fila.

En tablas muy grandes, la columna de inquilino también se convierte en una clave natural de particionado. Particionar por hash de inquilino es una optimización a la que recurres a escala real, no un punto de partida, pero tranquiliza saber que el diseño con RLS y la eventual estrategia de particionado apuntan en la misma dirección.

Probar el aislamiento, no solo las funciones

Un error de aislamiento entre inquilinos es invisible en las pruebas normales porque las pruebas normales usan un solo inquilino. Necesitas pruebas cuyo único propósito sea intentar cruzar la línea. La suite que corremos en cada producto multiinquilino tiene cuatro partes, y es barata de escribir una vez que existe el envoltorio withTenant.

  • Fixture de dos inquilinos. Siembra al inquilino A y al inquilino B con filas distinguibles en cada tabla por inquilino. Cada prueba de aislamiento corre contra ambos.
  • Frontera de lectura. Como inquilino A, lista cada tabla y comprueba que nunca ves una fila de B. Hazlo de forma genérica, iterando sobre el esquema, para que una tabla añadida el próximo trimestre quede cubierta automáticamente.
  • Frontera de escritura. Como inquilino A, intenta insertar una fila con el id de B y actualizar o borrar una de las filas de B por clave primaria. Espera que el insert sea rechazado por WITH CHECK y que el update o delete afecte cero filas.
  • Frontera sin inquilino. Ejecuta las mismas lecturas sin fijar inquilino y comprueba que todo resultado esté vacío. Esta prueba es la que atrapa el envoltorio olvidado en un endpoint nuevo.

Añade una comprobación más a nivel de esquema: una prueba que consulte pg_tables y pg_policies y falle si cualquier tabla con columna tenant_id carece de RLS o de FORCE. Esa única aserción nos ha atrapado más errores reales que cualquier cantidad de revisión de código, porque falla en el momento en que alguien añade una tabla y olvida la ceremonia. Corre estas pruebas contra un Postgres real en CI, no contra un sustituto en memoria; el comportamiento de RLS es exactamente el tipo de cosa que un doble de pruebas ligero no emula.

Dónde termina RLS y empiezan otras capas

RLS es el piso, no el edificio completo. Algunas fronteras que no cubre merecen su propio diseño. Almacenamiento de objetos: los archivos en S3 o similares necesitan claves con prefijo de inquilino y URLs firmadas de corta vida generadas solo después de la misma verificación de inquilino en el servidor. Índices de búsqueda y cachés: un índice de Elasticsearch o una caché en Redis no conocen tus políticas, así que el id de inquilino tiene que formar parte de cada clave y de cada filtro ahí, y vale la pena aplicar la misma disciplina de “envoltorio o nada”. Analítica en segundo plano: un almacén de datos que recibe tablas replicadas las recibe sin RLS, lo cual está bien mientras solo sea alcanzable por roles que deben verlo todo. Nombrar estas fronteras explícitamente durante el diseño mantiene honesta la afirmación de que “RLS nos protege”.

Tablas compartidas, esquema por inquilino o base de datos por inquilino

ModeloAislamientoCosto operativoRecúrrelo cuando
Tablas compartidas + RLSLógico, aplicado por el motorEl más bajo: un esquema, una migración, un respaldoPor defecto para casi cualquier SaaS
Esquema por inquilinoSeparación lógica más fuerte, misma instanciaLas migraciones se multiplican por el número de inquilinos; el enrutado de conexiones se complicaPocos inquilinos grandes con necesidades de esquema propias
Base de datos por inquilinoSeparación físicaEl más alto: aprovisionamiento, monitoreo, respaldos y actualizaciones por inquilinoUn contrato o una regulación lo exige, y el inquilino lo paga

Los modelos no son excluyentes. Una forma madura habitual es tablas compartidas con RLS para la larga cola de clientes y una base de datos dedicada para la única cuenta corporativa cuyo equipo de compras escribió el aislamiento físico en el contrato. Diseña la aplicación para que el paso de resolución del inquilino pueda devolver un destino de conexión además de un id, y ese híbrido se convierte en un cambio de configuración en lugar de una reescritura.

Lista de verificación para el despliegue

  • Añade tenant_id NOT NULL con clave foránea a cada tabla por inquilino; en un producto existente, rellena antes de añadir la restricción.
  • Activa y fuerza RLS, escribe USING y WITH CHECK, y da a la columna un valor por defecto desde el ajuste.
  • Crea un rol de aplicación dedicado que no sea dueño de nada y se conecte con el mínimo privilegio; mantén separado el rol de migraciones.
  • Envuelve todo acceso a datos por inquilino en un solo ayudante acotado a transacción; busca en las revisiones de código llamadas a la base de datos fuera de él.
  • Confirma el modo de tu pooler y que el ayudante use una única transacción explícita.
  • Encabeza cada índice compuesto con tenant_id.
  • Incluye las cuatro pruebas de aislamiento más la aserción de esquema, corriendo contra un Postgres real en CI.
  • Documenta las fronteras fuera de RLS (almacenamiento, búsqueda, caché, almacén de datos) y sus reglas de inquilino.

La conclusión

El aislamiento entre inquilinos pertenece a la base de datos, aplicado por el motor, expresado una vez por tabla y probado intentando romperlo. Postgres RLS te da eso con una cantidad modesta de SQL y un envoltorio disciplinado en la aplicación. Constrúyelo desde la primera migración; incorporar el aislamiento a un producto ya en producción es mucho más difícil, porque tienes que encontrar cada consulta escrita bajo el supuesto de que la aplicación filtraría. Si estás diseñando una plataforma B2B y quieres hacerlo bien desde el primer día, mira cómo abordamos el desarrollo de SaaS, lee la pieza complementaria sobre construir SaaS sobre Next.js moderno, o cuéntanos sobre el producto.

Preguntas frecuentes

¿Qué es Row-Level Security de Postgres y por qué usarlo en un SaaS multiinquilino?

Row-Level Security permite adjuntar una política a una tabla para que cada select, insert, update y delete sea filtrado por el motor antes de devolver o tocar una fila. Para un producto multiinquilino eso significa que el filtro por inquilino ya no depende de que cada desarrollador recuerde una cláusula WHERE para siempre; se expresa una vez por tabla y lo aplica la base de datos. Un filtro olvidado se convierte en una consulta apenas menos eficiente en lugar de una fuga entre inquilinos.

¿Cómo sabe la base de datos qué inquilino hace la petición?

Mediante un ajuste de sesión, normalmente app.tenant_id, que la política lee con current_setting(). La aplicación lo fija al inicio de cada petición a partir de la sesión verificada en el servidor, nunca de algo que envíe el cliente, usando SET LOCAL o set_config con la bandera local a la transacción para que el valor muera con ella. Envuelve todo acceso a datos por inquilino en un solo ayudante que abra una transacción, fije el inquilino y ejecute las consultas, incluidos los jobs en segundo plano y los webhooks.

¿Por qué necesito USING y WITH CHECK en una política?

USING filtra las filas que ya existen, lo que cubre lecturas, actualizaciones y borrados. WITH CHECK valida las filas que se insertan o actualizan, para que un inquilino no pueda escribir una fila marcada con el id de otro. Una política solo con USING deja que un inquilino lea únicamente sus datos pero todavía inserte en los de otro. Añade también FORCE ROW LEVEL SECURITY, porque el dueño de la tabla se salta las políticas por defecto, y usa current_setting con la bandera de valor ausente para que un inquilino sin definir no coincida con nada en lugar de fallar o exponerlo todo.

¿Row-Level Security funciona con pools de conexiones como PgBouncer?

Sí en modo sesión y en modo transacción, siempre que el ajuste de inquilino y las consultas que dependen de él corran dentro de la misma transacción explícita usando SET LOCAL. Un SET suelto en modo transacción puede caer en una conexión de servidor distinta de la consulta que sigue. El modo sentencia no admite transacciones de varias sentencias, así que ahí el patrón no funciona en absoluto. Los ORM que abren transacciones implícitas por consulta pueden romper esto de forma sutil, y por eso un envoltorio de una sola transacción interactiva es la forma segura.

¿Cómo pruebo que los inquilinos están realmente aislados?

Siembra dos inquilinos con filas distinguibles en cada tabla por inquilino y escribe pruebas cuyo único propósito sea cruzar la línea: lee cada tabla como inquilino A y comprueba que no aparece ninguna fila de B, intenta insertar con el id de B y actualizar o borrar filas de B esperando rechazo o cero filas afectadas, y ejecuta las lecturas sin inquilino fijado esperando resultados vacíos. Añade una prueba a nivel de esquema que falle si cualquier tabla con columna tenant_id carece de RLS o de FORCE. Corre todo contra un Postgres real en CI.

¿Cuándo debería usar un esquema o una base de datos por inquilino en lugar de tablas compartidas?

Las tablas compartidas con RLS son el valor por defecto correcto para casi cualquier SaaS: un esquema, una migración, un respaldo. Un esquema por inquilino conviene para pocos clientes grandes con necesidades de esquema propias, a costa de que las migraciones se multipliquen por el número de inquilinos. Una base de datos por inquilino da separación física y se justifica cuando un contrato o una regulación lo exige y el inquilino lo paga. Los modelos se combinan bien: tablas compartidas para la larga cola y una base de datos dedicada para la única cuenta corporativa que lo requiere.

¿Estás pensando en construir esto?

Appluex diseña y lanza apps móviles y web en producción, incluidas funciones de inteligencia artificial. Hablemos.

Agenda una consulta →← Todos los artículos
WhatsApp