|

Row Level Security en PostgreSQL: la guía completa

En pocas palabras: Row Level Security (RLS) resuelve el multi-tenancy en PostgreSQL moviendo el aislamiento de datos a nivel del motor de base de datos: con CREATE POLICY y ENABLE ROW LEVEL SECURITY, cada SELECT, INSERT, UPDATE y DELETE filtra automáticamente por tenant, aunque el desarrollador olvide el WHERE en la aplicación.

Un desarrollador escribe SELECT * FROM invoices WHERE id = :invoice_id sin el filtro de tenant, y de repente Acme Corp ve las facturas de Globex. Row Level Security en PostgreSQL resuelve esto moviendo el aislamiento de datos del código de la aplicación al motor de base de datos, donde ningún desarrollador se lo puede olvidar.

Row Level Security (RLS) es un mecanismo nativo de PostgreSQL que define políticas de acceso a nivel de fila directamente en el motor de base de datos. Con RLS habilitado, el query planner inyecta automáticamente el filtro de aislamiento en cada SELECT, INSERT, UPDATE y DELETE, sin depender de que la aplicación agregue la cláusula correcta.

En 30 segundos

  • Row Level Security filtra filas a nivel del motor de PostgreSQL, no en el código de la aplicación.
  • La política se crea con CREATE POLICY ... USING (...) WITH CHECK (...) sobre una tabla con ENABLE ROW LEVEL SECURITY.
  • Se integra con el backend seteando una variable de sesión (current_setting) por request, sin necesidad de WHERE tenant_id manual.
  • Los superusuarios y el dueño de la tabla bypasean RLS por defecto, salvo que uses FORCE ROW LEVEL SECURITY.
  • La documentación oficial de PostgreSQL confirma que sin ninguna política definida, el comportamiento default es denegar todo.

¿Por qué el filtro WHERE tenant_id en el código es un riesgo de seguridad?

Porque depende de que cada desarrollador, en cada query, en cada endpoint nuevo, se acuerde de escribirlo. Un solo olvido filtra datos entre clientes.

En el artículo técnico de Devanshu Patil el ejemplo es tan simple como aterrador: una query bien intencionada como SELECT * FROM invoices WHERE id = :invoice_id no tiene nada de raro a primera vista, compila, corre, devuelve resultados. El problema es que si ese id pertenece a otro tenant, PostgreSQL te lo va a entregar sin chistar porque, a nivel de motor, no hay ninguna diferencia entre una factura propia y una ajena.

Este es el escenario clásico del modelo “pool” de multi-tenancy, donde todos los clientes comparten la misma base y las mismas tablas, separados únicamente por una columna tenant_id. Según el blog técnico de AWS, este modelo agrupado ahorra costos operativos al máximo pero “comúnmente se implementa esperando que se utilice la cláusula WHERE correcta en cada instrucción de SQL”. Esperando. Esa palabra ahí es la trampa.

¿Y qué pasa cuando alguien agrega un endpoint nuevo seis meses después, sin revisar el código viejo? Exacto: la misma vulnerabilidad aparece de nuevo, solo que esta vez en un lugar distinto del código, lejos de donde alguien la va a estar buscando.

¿RLS tiene sentido para cualquier arquitectura multi-tenant?

row level security postgresql diagrama explicativo

No necesariamente, y vale la pena aclararlo antes de meterse con el código. El mismo blog de AWS describe tres modelos de partición de datos en sistemas multi-usuario, y cada uno tiene un trade-off distinto entre aislamiento y costo:

  • Silo: una base de datos por tenant. Es el aislamiento más fuerte posible, pero multiplica la infraestructura y la complejidad de mantenimiento por cada cliente nuevo.
  • Bridge: una base compartida, un esquema por tenant. Ahorra algo de infraestructura frente al silo, pero las migraciones y el versionado de esquema se vuelven un problema a medida que crece la cantidad de clientes.
  • Pool: una base y unas tablas compartidas, separadas por una columna como tenant_id. Es el modelo más económico, pero también el que más depende de que el aislamiento se aplique de forma correcta en cada query.

RLS no es una alternativa a estos tres modelos: es lo que hace viable al modelo pool sin asumir el riesgo de depender pura y exclusivamente del código de la aplicación. Si tu negocio maneja pocos clientes con requisitos de aislamiento extremos (por ejemplo, compliance financiero o regulatorio que exige segregación física de datos), el modelo silo sigue siendo la opción más conservadora, aunque más cara. Si en cambio tenés muchos tenants medianos o chicos y la prioridad es el costo operativo, RLS sobre un modelo pool es el punto donde conviene parar de preguntarse “¿y si alguien se olvida del WHERE?”.

¿Qué es Row Level Security en PostgreSQL y cómo funciona?

Row Level Security en PostgreSQL es una funcionalidad del motor que restringe, fila por fila, cuáles registros puede ver o modificar un usuario según políticas definidas en la propia base de datos. En vez de confiar en que la aplicación filtre bien, PostgreSQL inspecciona la identidad de la sesión activa y aplica el filtro antes de que la query termine de ejecutarse.

La documentación oficial de PostgreSQL 18 lo explica con precisión: “cuando la seguridad de fila está habilitada en una tabla, todo acceso normal para seleccionar o modificar filas debe estar permitido por una política de seguridad de fila. Si no existe ninguna política, se usa una política de denegación por defecto”. Es decir: RLS no es opt-in liviano, es todo o nada una vez que lo activás.

Lo interesante es que el filtro se evalúa antes que cualquier condición del WHERE de tu query, salvo funciones marcadas como leakproof. Así que aunque el desarrollador haga SELECT * FROM invoices; a secas (sin ninguna condición), PostgreSQL solo va a devolver las filas del tenant activo. No es un parche de la aplicación, es una capa de seguridad del motor.

¿Cómo se habilita RLS en una tabla de PostgreSQL?

Se habilita con una sola instrucción DDL después de crear la tabla: ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;. Ojo: este paso activa el mecanismo pero no define ninguna regla todavía.

Tomando el ejemplo de la fuente de dev.to, la tabla de facturas se define así:

  • La tabla necesita una columna de tenant: tenant_id VARCHAR(64) NOT NULL es el campo que va a usar la política para decidir qué filas mostrar.
  • El ENABLE activa el enforcement, no las reglas: una vez ejecutado ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;, si no creaste ninguna política, por defecto nadie ve nada (default-deny según la documentación oficial).
  • El dueño de la tabla queda exento por defecto: acordate de esto para la sección del superusuario más abajo, porque es la trampa más común al implementar esto en producción.

¿Cómo se crea una política de aislamiento por tenant con CREATE POLICY?

La política se crea con CREATE POLICY, especificando una condición USING para lecturas y una WITH CHECK para escrituras. La fuente de dev.to propone esta política restrictiva para la tabla de facturas:

CREATE POLICY tenant_isolation_policy ON invoices AS RESTRICTIVE USING (tenant_id = current_setting('app.current_tenant_id', true)) WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true));

La cláusula USING filtra qué filas son visibles en un SELECT, UPDATE o DELETE. La cláusula WITH CHECK valida que las filas nuevas o modificadas (en un INSERT o UPDATE) cumplan la misma condición, así un tenant no puede insertar una fila con el tenant_id de otro aunque quiera forzarlo manualmente en el payload.

El modificador AS RESTRICTIVE no es decorativo. Según la documentación de PostgreSQL, las políticas permisivas (el default) se combinan con OR, mientras que las restrictivas se combinan con AND. Si tenés una sola política de aislamiento por tenant, no cambia mucho, pero si más adelante agregás una segunda política (por ejemplo, para un rol de soporte que puede ver todo), la restrictiva actúa como un candado adicional que ninguna política permisiva puede saltear.

El AWS SaaS Factory, en su blog técnico sobre aislamiento multi-tenant, propone una variante que usa tenant_id::TEXT = current_user en vez de una variable de sesión, pero advierte que ese enfoque “no es fácil de mantener y no es escalable” porque obliga a crear un rol de PostgreSQL por cada tenant. El método con current_setting que usa la fuente de dev.to evita ese problema: un solo rol de aplicación, una variable de sesión que cambia por request.

¿Cómo se integra RLS con la aplicación usando Python y SQLAlchemy?

Se integra seteando la variable de sesión app.current_tenant_id al principio de cada transacción, con un SELECT set_config(...) ejecutado antes de cualquier query de negocio. El valor sale del JWT del usuario autenticado, no de un parámetro que el cliente pueda manipular en la URL.

El código de ejemplo de la fuente queda así:

db.execute(text("SELECT set_config('app.current_tenant_id', :tenant, true)"), {"tenant": tenant_id})

Después de esa línea, cualquier query corriente ya queda filtrada. Subís el endpoint, extraés el tenant del token, seteás la variable de sesión, ejecutás SELECT id, amount, description FROM invoices sin ningún WHERE adicional, y PostgreSQL te devuelve exclusivamente las filas del tenant activo porque la política RLS ya está haciendo ese trabajo por vos.

El tercer parámetro true en set_config es clave: indica que el valor es local a la transacción actual, no a toda la sesión de la conexión. Esto importa en entornos con connection pooling (pgbouncer, por ejemplo), donde una misma conexión física puede atender requests de tenants distintos en momentos distintos.

Ejemplo hipotético (a modo de ilustración, no un caso real reportado): imaginemos una API detrás de pgbouncer en modo transacción, atendiendo requests de varios tenants con un pool reducido de conexiones físicas. La conexión número 7 procesa primero una request de Acme Corp y ejecuta set_config('app.current_tenant_id', 'acme_corp', true). Si ese tercer parámetro fuera false (o se omitiera), la variable quedaría atada a la conexión y no a la transacción. Un instante después, pgbouncer reutiliza esa misma conexión física para una request de Globex; si el middleware de esa request no vuelve a settear la variable por alguna razón —un endpoint de solo lectura que asume que ya viene seteada desde una capa anterior, por ejemplo—, la query de Globex correría con el tenant_id de Acme Corp todavía activo. El resultado sería exactamente el bug que RLS debería eliminar, reaparecido por un detalle de alcance transaccional en lugar de un WHERE olvidado.

¿Por qué el superusuario de PostgreSQL puede ver datos de todos los tenants?

Porque los superusuarios y los roles con el atributo BYPASSRLS ignoran las políticas de seguridad de fila por diseño. Lo mismo pasa, por defecto, con el dueño de la tabla: la documentación oficial lo deja claro cuando dice que “el propietario de la tabla típicamente no está sujeto a políticas de seguridad de fila”.

Esto no es un bug, es una decisión de diseño de PostgreSQL. El tema es que si tu aplicación se conecta con el mismo rol que creó las tablas (algo bastante común cuando arrancás un proyecto y usás el usuario postgres para todo), todas las políticas RLS que definiste son letra muerta para esa conexión.

La fuente de dev.to propone la solución obvia pero que mucha gente no aplica: crear un rol de aplicación separado del dueño de las tablas.

  • Creá un rol dedicado para la aplicación: CREATE ROLE app_user WITH LOGIN PASSWORD 'strong_password'; en vez de reusar el usuario administrador.
  • Otorgale privilegios acotados: GRANT ALL PRIVILEGES ON invoices TO app_user; le da acceso a la tabla sin convertirlo en superusuario ni en dueño.
  • Forzá la política incluso para el dueño: ALTER TABLE invoices FORCE ROW LEVEL SECURITY; es la línea que cierra el agujero si en algún punto la app termina conectándose con el rol propietario.

El blog de AWS agrega una advertencia adicional: si tu código de aplicación se conecta con el mismo rol que ejecutó los CREATE TABLE, “sus políticas de seguridad no están vigentes por defecto”. Conclusión práctica: separar el rol de administración del rol de aplicación no es una buena práctica opcional, es un requisito para que RLS funcione. El mismo blog aclara que Amazon RDS admite RLS con los motores Aurora para PostgreSQL y RDS para PostgreSQL, así que esto no es exclusivo de instalaciones on-premise.

Errores comunes al implementar RLS en PostgreSQL

  • Conectar la app con el rol dueño de las tablas: es el error número uno. Si no separás el rol de aplicación del rol que corrió los CREATE TABLE, todas tus políticas quedan sin efecto para esas conexiones.
  • Olvidar el FORCE ROW LEVEL SECURITY: sin esa línea, cualquier conexión que use el rol propietario esquiva la política aunque hayas hecho todo bien hasta ahí.
  • Setear la variable de sesión sin el flag de transacción local: usar set_config sin el tercer parámetro true en entornos con pooling de conexiones puede filtrar el tenant_id de un request al siguiente, como en el ejemplo hipotético de más arriba.
  • Pensar que RLS reemplaza toda la seguridad de la aplicación: RLS protege contra fugas de datos por SQL, pero no valida lógica de negocio ni permisos a nivel de feature. Sigue siendo necesario autenticar y autorizar en la capa de aplicación.
  • No probar con un rol de bajos privilegios: probar RLS siempre con el usuario administrador da una falsa sensación de que todo funciona, porque ese rol bypasea las políticas.

¿Ya elegiste dónde alojar tu PostgreSQL?

RLS resuelve el problema de aislamiento una vez que ya decidiste usar PostgreSQL como motor. Qué proveedor o modelo de hosting usar es otra decisión, separada de esto. Si todavía estás en esa etapa, estas comparaciones con foco en costo y gestión pueden servirte como siguiente paso:

Preguntas Frecuentes

¿Qué es Row-Level Security en PostgreSQL?

Row-Level Security (RLS) es una funcionalidad nativa de PostgreSQL, disponible desde la versión 9.5, que permite definir políticas de acceso a filas directamente en el motor de base de datos. Con RLS habilitado, cada SELECT, INSERT, UPDATE o DELETE queda filtrado automáticamente según la política definida, sin depender de condiciones WHERE escritas a mano en la aplicación.

¿Cómo se implementa RLS para multi-tenancy en PostgreSQL?

Se implementa habilitando RLS sobre la tabla con ALTER TABLE ... ENABLE ROW LEVEL SECURITY y creando una política con CREATE POLICY ... USING (tenant_id = current_setting('app.current_tenant_id', true)). La aplicación setea esa variable de sesión al inicio de cada transacción con el tenant_id extraído del token del usuario autenticado.

¿RLS reemplaza el WHERE tenant_id en las queries?

Sí, una vez que la política está creada y la variable de sesión seteada, no hace falta escribir WHERE tenant_id = ... en ninguna query. El propio motor de PostgreSQL inyecta ese filtro antes de devolver resultados, incluso si el desarrollador ejecuta un SELECT * sin condiciones.

¿Por qué el superusuario de PostgreSQL puede ver datos de todos los tenants?

Porque los superusuarios y los roles con atributo BYPASSRLS ignoran las políticas de seguridad de fila por diseño del motor, y el dueño de la tabla hace lo mismo salvo que se use FORCE ROW LEVEL SECURITY. La solución es conectar la aplicación con un rol dedicado, distinto del que creó las tablas, y forzar la política explícitamente.

¿Cómo se integra RLS con SQLAlchemy o un ORM?

Se integra ejecutando una query cruda de set_config('app.current_tenant_id', tenant_id, true) al inicio de cada transacción, antes de cualquier operación de negocio. El resto de las queries del ORM no necesitan modificación porque PostgreSQL aplica el filtro de forma transparente a nivel de motor.

Conclusión

Row Level Security mueve el aislamiento de tenants de un lugar frágil (el código de cada query) a un lugar robusto (el motor de PostgreSQL). Esto no elimina la necesidad de buenas prácticas de seguridad en la aplicación, pero sí elimina la categoría de bug más común y más costosa en SaaS multi-tenant: el WHERE olvidado.

Si tu stack ya corre sobre PostgreSQL y maneja datos de múltiples clientes en las mismas tablas, vale la pena evaluar esta migración. El trabajo inicial es acotado: habilitar RLS, escribir una política, ajustar la capa de conexión para setear la variable de sesión y separar el rol de aplicación del rol administrador. Lo que no conviene es asumir que “ya lo tenemos cubierto en el código” sin haber probado qué pasa cuando alguien, en algún endpoint, se olvida del filtro.

Fuentes

Te puede interesar...