La seguridad a nivel de fila (RLS) de PostgreSQL ayuda a aplicar el aislamiento por tenant en la base de datos. Aprende cuándo usarla, cómo escribir políticas y qué evitar.

En una app SaaS, el bug de seguridad más peligroso es el que aparece cuando escalas. Empiezas con una regla simple como “los usuarios solo pueden ver los datos de su tenant”, luego publicas rápido un nuevo endpoint, añades una consulta para reporting o introduces un join que silenciosamente omite la comprobación.
La autorización solo en la app falla bajo presión porque las reglas acaban dispersas. Un controlador comprueba tenant_id, otro la membresía, un job en background se olvida y una ruta de “export admin” se queda “temporal” durante meses. Incluso equipos cuidadosos se olvidan de un punto.
La seguridad a nivel de fila (RLS) de PostgreSQL resuelve un problema específico: hace que la base de datos aplique qué filas son visibles para una petición dada. El modelo mental es simple: cada SELECT, UPDATE y DELETE se filtra automáticamente por las políticas, como cada petición se filtra por el middleware de autenticación.
La parte de “filas” importa. RLS no protege todo:
Un ejemplo concreto: añades un endpoint que lista proyectos con un join a facturas para un dashboard. Con autorización solo en la app, es fácil filtrar projects por tenant y olvidar filtrar invoices, o unir por una clave que cruza tenants. Con RLS, ambas tablas pueden hacer cumplir el aislamiento por tenant, así la consulta falla de forma segura en vez de filtrar datos.
La compensación es real. Escribes menos código de autorización repetido y reduces los lugares donde puede haber fugas. Pero también asumes trabajo nuevo: debes diseñar políticas con cuidado, probarlas pronto y aceptar que una política puede bloquear una consulta que esperabas que funcionara.
RLS puede parecer trabajo extra hasta que tu app supera unas pocas rutas. Si tienes límites de tenant estrictos y muchas vías de consulta (pantallas de listado, búsqueda, exports, herramientas admin), poner la regla en la base de datos significa que no tienes que recordar añadir el mismo filtro en todas partes.
RLS encaja bien cuando la regla es aburrida y universal: “un usuario solo puede ver filas de su tenant” o “un usuario solo puede ver proyectos de los que es miembro”. En esos casos, las políticas reducen errores porque cada SELECT, UPDATE y DELETE pasa por la misma puerta, incluso cuando se añade una consulta después.
También ayuda en apps con carga de lectura donde la lógica de filtrado se mantiene consistente. Si tu API tiene 15 maneras diferentes de cargar facturas (por estado, por fecha, por cliente, por búsqueda), RLS te permite dejar de reimplementar el filtrado por tenant en cada consulta y centrarte en la funcionalidad.
RLS añade dolor cuando las reglas no son por fila. Reglas por campo como “puedes ver el salario pero no el bonus” o “enmascara esta columna a menos que seas RR.HH.” a menudo se convierten en SQL incómodo y excepciones difíciles de mantener.
Tampoco encaja bien para reporting pesado que realmente necesita acceso amplio. Los equipos suelen crear roles de bypass para “solo este job”, y ahí es donde se acumulan los errores.
Antes de comprometerte, decide si quieres que la base de datos sea la puerta final. Si la respuesta es sí, planifica disciplina: prueba el comportamiento de la base de datos (no solo las respuestas de la API), trata las migraciones como cambios de seguridad, evita bypasses rápidos, decide cómo se autentican los jobs en background y mantén las políticas pequeñas y repetibles.
Si usas herramientas que generan backends, pueden acelerar la entrega, pero no quitan la necesidad de roles claros, tests y un modelo de tenant simple. (Por ejemplo, Koder.ai usa Go y PostgreSQL para backends generados, y aún quieres diseñar RLS deliberadamente en vez de “esparcirlo después”.)
RLS es más sencillo cuando tu esquema ya dice claramente quién posee qué. Si empiezas con un modelo difuso y tratas de “arreglarlo con políticas”, normalmente obtienes queries lentas y bugs confusos.
Elige una clave de tenant (como org_id) y úsala de forma consistente. La mayoría de las tablas propiedad del tenant deberían tenerla, incluso si además referencian otra tabla que la tenga. Esto evita joins dentro de las políticas y mantiene simples las comprobaciones USING.
Una regla práctica: si una fila debería desaparecer cuando un cliente cancela, probablemente necesita org_id.
Las políticas RLS suelen responder a una pregunta: “¿Es este usuario miembro de esta org, y qué puede hacer?” Eso es difícil de inferir desde columnas ad hoc.
Mantén las tablas centrales pequeñas y aburridas:
users (una fila por persona)orgs (una fila por tenant)org_memberships (user_id, org_id, role, status)project_memberships para acceso por proyectoCon eso, tus políticas pueden comprobar la membresía con una sola búsqueda indexada.
No todo necesita org_id. Tablas de referencia como países, categorías de producto o tipos de plan suelen ser compartidas entre tenants. Hazlas de solo lectura para la mayoría de roles y no las ates a una org.
Los datos propiedad del tenant (proyectos, facturas, tickets) deberían evitar traer detalles de tenant a través de tablas compartidas. Mantén las tablas compartidas mínimas y estables.
Las foreign keys siguen funcionando con RLS, pero los deletes pueden sorprender si el rol que borra no puede “ver” las filas dependientes. Planifica cascadas con cuidado y prueba flujos reales de borrado.
Indexa las columnas que filtran tus políticas, especialmente org_id y las claves de membresía. Una política que se lee como WHERE org_id = ... no debería convertirse en un escaneo de tabla completa cuando la tabla llega a millones de filas.
RLS es un interruptor por tabla. Una vez habilitado, PostgreSQL deja de confiar en que tu código recuerde el filtro de tenant. Cada SELECT, UPDATE y DELETE se filtra por políticas, y cada INSERT y UPDATE se valida por políticas.
El mayor cambio mental: con RLS activado, consultas que antes devolvían datos pueden empezar a devolver cero filas sin errores. Eso es PostgreSQL aplicando control de acceso.
Las políticas son reglas pequeñas adjuntas a una tabla. Usan dos comprobaciones:
USING es el filtro de lectura. Si una fila no cumple USING, es invisible para SELECT y no puede ser objetivo de UPDATE o DELETE.WITH CHECK es la puerta de escritura. Decide qué filas nuevas o modificadas están permitidas para INSERT o UPDATE.Un patrón común en SaaS: USING asegura que solo ves filas de tu tenant, y WITH CHECK asegura que no puedes insertar una fila en el tenant de otro adivinando un tenant_id.
Cuando añades más políticas después, esto importa:
PERMISSIVE (por defecto): una fila se permite si cualquier política la permite.RESTRICTIVE: una fila se permite solo si todas las políticas restrictivas lo permiten (además del comportamiento permisivo).Si planeas apilar reglas como coincidencia de tenant más comprobaciones de rol más membresía de proyecto, las políticas restrictivas pueden dejar la intención más clara, pero también facilitan que te quedes fuera si olvidas una condición.
RLS necesita un valor fiable de “quién llama”. Opciones comunes:
app.user_id y app.tenant_id).SET ROLE ... por petición), que puede funcionar pero añade overhead operacional.Elige un enfoque y aplícalo en todas partes. Mezclar fuentes de identidad entre servicios es un camino rápido a bugs confusos.
Usa una convención predecible para que los dumps de esquema y logs sigan siendo legibles. Por ejemplo: {tabla}__{acción}__{regla}, como projects__select__tenant_match.
Si eres nuevo en RLS, empieza con una tabla y una pequeña prueba. El objetivo no es cobertura perfecta. El objetivo es que la base de datos se niegue a permitir acceso entre tenants incluso si hay un bug en la app.
Asume una tabla simple projects. Primero, añade tenant_id de una forma que no rompa escrituras.
ALTER TABLE projects ADD COLUMN tenant_id uuid;
-- Backfill existing rows (example: everyone belongs to a default tenant)
UPDATE projects SET tenant_id = '11111111-1111-1111-1111-111111111111'::uuid
WHERE tenant_id IS NULL;
ALTER TABLE projects ALTER COLUMN tenant_id SET NOT NULL;
A continuación, separa la propiedad del acceso. Un patrón común es: un rol posee las tablas (app_owner), otro rol lo usa la API (app_user). El rol de la API no debe ser el owner de la tabla, o puede eludir las políticas.
ALTER TABLE projects OWNER TO app_owner;
REVOKE ALL ON projects FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE, DELETE ON projects TO app_user;
Ahora decide cómo la petición le dice a Postgres qué tenant está sirviendo. Un enfoque simple es un setting de alcance de petición. Tu app lo establece justo después de abrir una transacción.
-- inside the same transaction as the request
SELECT set_config('app.current_tenant', '22222222-2222-2222-2222-222222222222', true);
Habilita RLS y comienza con acceso de lectura.
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY projects_tenant_select
ON projects
FOR SELECT
TO app_user
USING (tenant_id = current_setting('app.current_tenant')::uuid);
Verifícalo probando dos tenants distintos y comprobando que el conteo de filas cambia.
Las políticas de lectura no protegen escrituras. Añade WITH CHECK para que inserts y updates no puedan colar filas en el tenant equivocado.
CREATE POLICY projects_tenant_write
ON projects
FOR INSERT, UPDATE
TO app_user
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);
Una forma rápida de verificar comportamiento (incluidos fallos) es mantener un pequeño script SQL que puedas volver a ejecutar después de cada migración:
BEGIN; SET LOCAL ROLE app_user;SELECT set_config('app.current_tenant', '\u003ctenant A\u003e', true); SELECT count(*) FROM projects;INSERT INTO projects(id, tenant_id, name) VALUES (gen_random_uuid(), '\u003ctenant B\u003e', 'bad'); (debe fallar)UPDATE projects SET tenant_id = '\u003ctenant B\u003e' WHERE ...; (debe fallar)ROLLBACK;Si puedes ejecutar ese script y obtener los mismos resultados cada vez, tienes una línea base fiable antes de expandir RLS a otras tablas.
La mayoría de equipos adoptan RLS cuando se cansan de repetir los mismos chequeos de autorización en cada consulta. La buena noticia es que las formas de política que necesitas suelen ser consistentes.
Algunas tablas son naturalmente propiedad de un usuario (notas, tokens API). Otras pertenecen a un tenant donde el acceso depende de la membresía. Trata estos como patrones distintos.
Para datos solo del propietario, las políticas suelen comprobar created_by = app_user_id(). Para datos de tenant, las políticas suelen comprobar si el usuario tiene una fila de membresía para la org.
Una forma práctica de mantener legibles las políticas es centralizar la identidad en pequeños helpers SQL y reutilizarlos:
-- Example helpers
create function app_user_id() returns uuid
language sql stable as $$
select current_setting('app.user_id', true)::uuid
$$;
create function app_is_admin() returns boolean
language sql stable as $$
select current_setting('app.is_admin', true) = 'true'
$$;
Las lecturas suelen ser más amplias que las escrituras. Por ejemplo, cualquier miembro de la org puede SELECT proyectos, pero solo los editores pueden UPDATE, y solo los propietarios pueden DELETE.
Manténlo explícito: una política para SELECT (membresía), una para INSERT/UPDATE con WITH CHECK (rol) y una para DELETE (a menudo más estricta que update).
Evita “apagar RLS para admins”. En su lugar, añade una salida de emergencia dentro de las políticas, como app_is_admin(), para que no concedas accidentalmente acceso completo a un rol de servicio compartido.
Si usas deleted_at o status, inclúyelos en la política de SELECT (deleted_at is null). De lo contrario, alguien puede “resucitar” filas cambiando flags que la app asumía definitivos.
WITH CHECK compatibleINSERT ... ON CONFLICT DO UPDATE debe cumplir WITH CHECK para la fila después de la escritura. Si tu política requiere created_by = app_user_id(), asegúrate de que tu upsert establezca created_by en el insert y no lo sobreescriba en el update.
Si generas código backend, estos patrones valen la pena convertirlos en plantillas internas para que nuevas tablas empiecen con valores seguros en lugar de una hoja en blanco.
RLS es genial hasta que un pequeño detalle hace que parezca que PostgreSQL está “ocultando o mostrando datos al azar”. Los errores siguientes hacen perder más tiempo.
La primera trampa es olvidar WITH CHECK en insert y update. USING controla lo que puedes ver, no lo que puedes crear. Sin WITH CHECK, un bug en la app puede escribir una fila en el tenant equivocado y quizá no lo notes porque ese mismo usuario no puede leerla.
Otra fuga común es el “join filtrado mal”. Filtras correctamente projects, luego haces join a invoices, notes o files que no están protegidos igual. La solución es estricta pero directa: cada tabla que pueda revelar datos de tenant necesita su propia política, y las vistas no deberían depender de que solo una tabla sea segura.
Patrones de fallo comunes que aparecen temprano:
WITH CHECK para escritura.Políticas que referencian la misma tabla (directamente o a través de una vista) pueden crear sorpresas de recursión. Una política puede comprobar membresía consultando una vista que lee la tabla protegida otra vez, lo que lleva a errores, consultas lentas o una política que nunca coincide.
La configuración de roles es otra fuente de confusión. Owners de tablas y roles elevados pueden evitar RLS, así que tus tests pasan mientras los usuarios reales fallan (o al revés). Prueba siempre con el mismo rol de bajo privilegio que usa tu app.
Ten cuidado con funciones SECURITY DEFINER. Corren con los privilegios del owner de la función, así que un helper como current_tenant_id() puede estar bien, pero una función “de conveniencia” que lea datos puede leer accidentalmente entre tenants a menos que la diseñes para respetar RLS.
También establece un search_path seguro dentro de funciones security definer. Si no lo haces, la función puede usar un objeto con el mismo nombre y tu lógica de política puede apuntar silenciosamente a lo incorrecto según el estado de la sesión.
Los bugs de RLS suelen ser falta de contexto, no “SQL malo”. Una política puede ser correcta en papel y aún fallar porque el rol de sesión es diferente de lo que piensas, o porque la petición nunca fijó los valores de tenant y usuario que la política espera.
Una forma fiable de reproducir un informe de producción es replicar la misma configuración de sesión localmente y ejecutar la consulta exacta. Eso usualmente significa:
SET ROLE app_user; (o el rol real de la API)SELECT set_config('app.tenant_id', 't_123', true); y SELECT set_config('app.user_id', 'u_456', true);SELECT current_user, current_setting('app.tenant_id', true), current_setting('app.user_id', true);Cuando no estés seguro de qué política se aplica, consulta el catálogo en lugar de adivinar. pg_policies muestra cada política, el comando y las expresiones USING y WITH CHECK. Combínalo con pg_class para confirmar que RLS está habilitado en la tabla y no se está evitando.
Los problemas de rendimiento pueden parecer problemas de autorización. Una política que hace join a una tabla de membresía o llama a una función puede ser correcta pero lenta cuando la tabla crece. Usa EXPLAIN (ANALYZE, BUFFERS) en la consulta reproducida y busca escaneos secuenciales, nested loops inesperados o filtros aplicados tarde. La falta de índices en (tenant_id, user_id) y tablas de membresía son causas comunes.
También ayuda registrar tres valores por petición en la capa app: el tenant ID, el user ID y el rol de base de datos usado para la petición. Cuando esos no coinciden con lo que piensas que estableciste, RLS se comportará “mal” porque las entradas son incorrectas.
Para tests, conserva algunos tenants seed y haz las fallas explícitas. Una pequeña suite suele incluir: “Tenant A no puede leer Tenant B”, “usuario sin membresía no puede ver el proyecto”, “owner puede actualizar, viewer no”, “insert bloqueado a menos que tenant_id coincida con el contexto” y “override admin solo aplica donde debe”.
Trata RLS como un cinturón de seguridad, no como un toggle de feature. Pequeños fallos se convierten en “todo el mundo ve los datos de todos” o “todo devuelve cero filas”.
Asegúrate de que el diseño de tablas y las reglas de política encajen con tu modelo de tenant.
tenant_id). Si no la tiene, escribe por qué (por ejemplo, tablas de referencia globales).FORCE ROW LEVEL SECURITY en esas tablas.USING. Las escrituras deben incluir WITH CHECK para que inserts/updates no puedan mover una fila a otro tenant.tenant_id o hacen joins a tablas de membresía, añade los índices correspondientes.Un escenario de sanity simple: un usuario del tenant A puede leer sus propias facturas, puede insertar una factura solo para el tenant A y no puede actualizar una factura para cambiar tenant_id.
RLS solo es tan fuerte como los roles que usa tu app.
bypassrls.Imagina una app B2B donde compañías (orgs) tienen proyectos, y los proyectos tienen tareas. Los usuarios pueden pertenecer a múltiples orgs y pueden ser miembros de algunos proyectos y no de otros. Esto encaja bien con RLS porque la base de datos puede hacer cumplir el aislamiento por tenant incluso si un endpoint olvida un filtro.
Un modelo simple es: orgs, users, org_memberships (org_id, user_id, role), projects (id, org_id), project_memberships (project_id, user_id), tasks (id, project_id, org_id, ...). Ese org_id en tasks es intencional. Mantiene las políticas simples y reduce sorpresas en joins.
Una fuga clásica ocurre cuando tasks solo tiene project_id y tu política comprueba acceso mediante un join a projects. Un error (una política permisiva en projects, un join que elimina una condición o una vista que cambia contexto) puede exponer tareas de otra org.
Un camino de migración más seguro evita romper tráfico en producción:
org_id a tasks, añade tablas de membresía).tasks.org_id desde projects.org_id, luego añade NOT NULL.El acceso de soporte suele manejarse mejor con un rol narrow break-glass, no desactivando RLS. Sepáralo de las cuentas normales de soporte y haz explícito cuándo se usa.
Documenta las reglas para que las políticas no se desvíen: qué variables de sesión deben setearse (user_id, org_id), qué tablas deben llevar org_id, qué significa “miembro” y unos pocos ejemplos SQL que deberían devolver 0 filas si se ejecutan con la org equivocada.
RLS es más fácil de mantener cuando lo tratas como un cambio de producto. Desplázalo en trozos pequeños, prueba el comportamiento con tests y guarda un registro claro del porqué de cada política.
Un plan de despliegue que suele funcionar:
projects) y ciérrala.Tras la primera tabla estable, haz que los cambios de política sean deliberados. Añade un paso de revisión de políticas a las migraciones e incluye una nota corta sobre la intención (quién debe acceder a qué y por qué) más el test correspondiente. Esto evita “añadir otro OR” que poco a poco se convierta en un agujero.
Si te mueves rápido, herramientas como Koder.ai (koder.ai) pueden ayudarte a generar un punto de partida Go + PostgreSQL por chat, y luego puedes añadir políticas RLS y tests con la misma disciplina que tendría un backend hecho a mano.
Finalmente, mantén redes de seguridad durante el despliegue. Toma snapshots antes de migraciones de políticas, practica rollbacks hasta que sea aburrido y conserva un pequeño camino break-glass para soporte que no desactive RLS en todo el sistema.
RLS hace que PostgreSQL aplique qué filas son visibles o escribibles para una petición, de modo que el aislamiento por tenant no dependa de que cada endpoint recuerde el WHERE tenant_id = .... La ventaja principal es reducir los errores de “se olvidó un chequeo” cuando la aplicación crece y las consultas se multiplican.
Vale la pena cuando las reglas de acceso son consistentes y basadas en filas, como el aislamiento por tenant o acceso por membresía, y cuando hay muchas vías para consultar datos (búsquedas, exports, pantallas de administración, jobs). Normalmente no compensa si las reglas son por campo, altamente excepcionales o están dominadas por grandes informes que necesitan lecturas entre tenants.
Usa RLS para visibilidad de filas y control básico de escritura; para lo demás emplea otras herramientas. La privacidad de columnas típicamente necesita vistas y privilegios por columna, y las reglas de negocio complejas (por ejemplo, propiedad de facturación o flujos de aprobación) siguen perteneciendo a la lógica de la aplicación o a constraints de base de datos bien diseñados.
Crea un rol de bajo privilegio para la API (no el owner de las tablas), habilita RLS y añade una política SELECT y una política INSERT/UPDATE con WITH CHECK. Usa un valor de sesión por petición (por ejemplo app.current_tenant) y verifica que cambiarlo altera las filas que puedes ver y escribir.
Una opción común es una variable de sesión por petición, establecida al inicio de la transacción, como app.tenant_id y app.user_id. La clave es consistencia: todos los caminos de código (requests web, jobs, scripts) deben fijar los mismos valores que esperan las políticas, o verás comportamientos confusos de “cero filas”.
USING controla el filtro de lectura: qué filas existentes son visibles y pueden ser objetivo de SELECT, UPDATE y DELETE. WITH CHECK controla qué filas nuevas o modificadas están permitidas en INSERT y , de modo que evita “escribir en otro tenant” aunque la app pase un incorrecto.
Porque si solo añades USING, un endpoint con bug aún puede insertar o actualizar filas en otro tenant y quizá no lo notes porque ese mismo usuario no puede leer esa fila equivocada. Empareja siempre las reglas de lectura por tenant con una regla WITH CHECK para escrituras, así no se crean datos malos desde el principio.
Evita joins dentro de las políticas poniendo la clave de tenant (por ejemplo org_id) directamente en las tablas propiedad del tenant, incluso si también referencian otra tabla que la tenga. Añade tablas de membresía explícitas (org_memberships, opcionalmente project_memberships) para que las políticas hagan una búsqueda indexada en lugar de inferencias complejas.
Reproduce primero el mismo contexto de sesión que usa tu app fijando el mismo rol y settings de sesión, y luego ejecuta la consulta SQL exacta. Después, confirma que RLS está habilitado e inspecciona pg_policies para ver qué expresiones USING y WITH CHECK se aplican: RLS suele fallar por falta de contexto de identidad más que por “SQL malo”.
Sí, pero trata el código generado como punto de partida, no como un sistema de seguridad terminado. Si usas Koder.ai para generar un backend Go + PostgreSQL, aún necesitas definir tu modelo de tenants, fijar identidad en sesión de forma consistente y añadir políticas y tests deliberadamente para que las nuevas tablas no salgan sin las protecciones correctas.
UPDATEtenant_id