03 mar 2025·8 min de lectura

Patrones de seguridad a nivel de fila (RLS) en PostgreSQL para apps multitenant

Aprende RLS de PostgreSQL con patrones prácticos para aislar tenants y reglas de roles, de modo que el acceso se aplique en la base de datos y no solo en la app.

Patrones de seguridad a nivel de fila (RLS) en PostgreSQL para apps multitenant

Por qué importa que la base de datos haga cumplir el acceso en las apps de negocio

Las apps de negocio suelen tener reglas como “los usuarios solo pueden ver los registros de su empresa” y “solo los gestores pueden aprobar reembolsos”. Muchos equipos implementan esas reglas en la interfaz o en la API y dan por hecho que eso basta. El problema es que cada vía adicional hacia la base de datos es una nueva oportunidad de filtrar datos: una herramienta administrativa interna, un job en background, una consulta analítica, un endpoint olvidado o un bug que omite una comprobación.

El aislamiento por tenant significa que un cliente (tenant) nunca puede leer o modificar los datos de otro cliente, ni siquiera por accidente. El acceso basado en roles significa que personas dentro del mismo tenant aún tienen diferentes permisos, como agentes, gestores o finanzas. Estas reglas son fáciles de describir, pero difíciles de mantener consistentes cuando viven en muchos lugares.

La seguridad a nivel de fila de PostgreSQL (Row-Level Security, RLS) es una característica de la base de datos que permite que la propia base decida qué filas puede ver o modificar una petición. En lugar de confiar en que cada consulta en tu app recuerde la cláusula WHERE correcta, la base de datos aplica políticas automáticamente.

RLS no es una panacea. No diseñará tu esquema, no sustituye la autenticación y no te protege de alguien que ya tenga un rol de base de datos poderoso (como un superuser). Tampoco evita errores lógicos como “alguien puede actualizar una fila que no puede seleccionar” a menos que escribas políticas para lectura y escritura.

Lo que sí obtienes es una red de seguridad potente:

  • Un conjunto de reglas para cada camino que toca la base de datos
  • Menos momentos de “ups” cuando se lanza una nueva función
  • Auditorías más claras, porque las reglas de acceso son visibles en SQL
  • Mejor defensa si se cuela un bug en la API

Hay un pequeño coste de configuración. Necesitas una forma consistente de pasar “quién es este usuario” y “a qué tenant pertenece” a la base de datos, y mantener las políticas a medida que la app crece. La recompensa es grande, especialmente para SaaS y herramientas internas donde hay datos sensibles de clientes en juego.

Fundamentos de Row-Level Security sin jerga

Row-Level Security (RLS) filtra automáticamente qué filas puede ver o modificar una consulta. En vez de depender de que cada pantalla, endpoint o informe "recuerde" las reglas, la base de datos las aplica por ti.

Con PostgreSQL RLS, escribes políticas que se verifican en cada SELECT, INSERT, UPDATE y DELETE. Si la política dice “este usuario solo puede ver filas del tenant A”, entonces una página administrativa olvidada, una nueva consulta o un hotfix apresurado siguen teniendo las mismas barreras.

RLS es distinto de GRANT/REVOKE. GRANT decide si un rol puede tocar una tabla en absoluto (o columnas concretas). RLS decide qué filas dentro de esa tabla están permitidas. En la práctica, suele usarse ambos: GRANT para limitar quién puede acceder a la tabla, y RLS para limitar qué pueden acceder.

También resiste en el mundo real y desordenado. Las vistas generalmente obedecen RLS porque el acceso a la tabla subyacente aún dispara la política. Los joins y subconsultas también se filtran, así que un usuario no puede “entrar por un join” a los datos de otro. Y la política se aplica sin importar qué cliente ejecute la consulta: código de la app, consola SQL, job en background o herramienta de reporting.

RLS encaja bien cuando tienes necesidades fuertes de aislamiento por tenant, múltiples formas de consultar los mismos datos o muchos roles que comparten tablas (común en SaaS y herramientas internas). Puede ser demasiado para apps muy pequeñas con un backend de confianza único, o para datos que no son sensibles y nunca salen de un único servicio controlado. En el momento en que tienes más de un punto de entrada (herramientas admin, exportaciones, BI, scripts), RLS suele amortizarse.

Empieza mapeando tenants, roles y propiedad de datos

Antes de escribir una sola política, aclara quién posee qué. RLS funciona mejor cuando tu modelo de datos ya refleja tenants, roles y propiedad.

Comienza por los tenants. En la mayoría de las apps SaaS, la regla más simple es: cada tabla compartida que contiene datos de clientes tiene un tenant_id. Eso incluye tablas “obvias” como facturas, pero también cosas que la gente olvida, como adjuntos, comentarios, logs de auditoría y jobs en background.

A continuación, nombra los roles que la gente realmente usa. Mantén el conjunto pequeño y humano: owner, manager, agent, read-only. Estos son roles de negocio que luego mapearás a comprobaciones en las políticas (no son lo mismo que los roles de la base de datos).

Luego decide cómo se poseen los registros. Algunas tablas están en propiedad de un único usuario (por ejemplo, una nota privada). Otras están en propiedad de un equipo (por ejemplo, una bandeja compartida). Mezclar ambos sin un plan lleva a políticas difíciles de leer y fáciles de eludir.

Una forma sencilla de documentar tus reglas es responder las mismas preguntas para cada tabla:

  • ¿Cuál es el límite de tenant (qué columna lo impone)?
  • ¿Quién puede leer filas (por rol y por propiedad)?
  • ¿Quién puede crear y actualizar filas (y bajo qué condiciones)?
  • ¿Quién puede borrar filas (usualmente la regla más estricta)?
  • ¿Qué excepciones están permitidas (personal de soporte, automatizaciones, exportaciones)?

Ejemplo: “Facturas” podría permitir que los managers vean todas las facturas del tenant, que los agentes vean facturas de clientes asignados y que los read-only solo puedan ver pero nunca editar. Decide de antemano qué reglas deben ser estrictas (aislamiento por tenant, borrados) y cuáles pueden ser flexibles (visibilidad adicional para managers). Si construyes en una herramienta no-code como AppMaster, este mapeo también ayuda a mantener alineadas las expectativas de la UI y las reglas de la base de datos.

Patrones de diseño para tablas multitenant

RLS multitenant funciona mejor cuando tus tablas tienen una forma predecible. Si cada tabla guarda el tenant de una manera diferente, tus políticas se convierten en un rompecabezas. Una forma consistente facilita leer, probar y mantener las políticas de PostgreSQL con el tiempo.

Empieza por elegir un identificador de tenant y úsalo en todas partes. Los UUID son comunes porque son difíciles de adivinar y fáciles de generar. Los enteros también están bien, especialmente para apps internas. Los slugs (como "acme") son amigables para humanos, pero pueden cambiar, así que trátalos como campo de presentación, no como clave central.

Para datos acotados por tenant, añade una columna tenant_id a cada tabla que pertenezca a un tenant, y hazla NOT NULL siempre que sea posible. Si una fila puede existir sin tenant, suele ser una señal de olor: muchas veces estás mezclando datos globales y por tenant en una sola tabla, lo que hace las políticas RLS más difíciles y frágiles.

El índice es simple pero importante. La mayoría de las consultas en una app SaaS filtran por tenant primero, y luego por un campo de negocio como estado o fecha. Un buen punto de partida es un índice sobre tenant_id, y para tablas de alto tráfico un índice compuesto como (tenant_id, created_at) o (tenant_id, status) según tus filtros comunes.

Decide pronto qué tablas son globales y cuáles están acotadas por tenant. Tablas globales comunes incluyen países, códigos de moneda o definiciones de plan. Tablas acotadas por tenant incluyen clientes, facturas, tickets y cualquier cosa que el tenant posea.

Si quieres un conjunto de reglas mantenible, mantenlo estrecho:

  • Tablas acotadas por tenant: tenant_id NOT NULL, RLS habilitado, políticas que siempre comprueban tenant_id.
  • Tablas de referencia globales: sin tenant_id, sin políticas por tenant, solo lectura para la mayoría de roles.
  • Tablas compartidas pero controladas: tablas separadas por concepto (evita mezclar filas globales y de tenant).

Si construyes con una herramienta como AppMaster, esta consistencia se nota también en el modelo de datos. Una vez tenant_id es un campo estándar, puedes reutilizar los mismos patrones sin sorpresas.

Paso a paso: crea tu primera política por tenant

Lanza un backend más seguro antes
Genera una API lista para producción respaldada por código fuente limpio cuando tus requisitos cambien.
Construir Backend

Un buen primer paso con RLS es una tabla que solo pueda leerse desde el tenant actual. El objetivo es simple: aunque alguien olvide un WHERE en la API, la base de datos se niega a devolver filas de otros tenants.

Empieza con una tabla que incluya una columna tenant_id:

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

Una vez RLS está habilitado, el comportamiento por defecto suele sorprender: si un rol está sujeto a RLS y no existe una política coincidente, SELECT devuelve cero filas (y los writes fallan). Eso es lo que quieres al principio.

Ahora añade una política de lectura mínima. Este ejemplo asume que tu app establece una variable de sesión como app.tenant_id después del login:

CREATE POLICY invoices_tenant_read
ON invoices
FOR SELECT
USING (tenant_id = current_setting('app.tenant_id')::uuid);

A continuación, agrega reglas de escritura. En RLS, USING controla qué filas existentes puedes tocar, y WITH CHECK controla qué nuevos valores puedes escribir.

CREATE POLICY invoices_tenant_insert
ON invoices
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);

CREATE POLICY invoices_tenant_update
ON invoices
FOR UPDATE
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);

CREATE POLICY invoices_tenant_delete
ON invoices
FOR DELETE
USING (tenant_id = current_setting('app.tenant_id')::uuid);

Las políticas son PERMISSIVE por defecto, lo que significa que cualquiera de ellas puede permitir el acceso. Elige RESTRICTIVE cuando quieras reglas que deban pasar todas (útil para añadir una segunda barrera como “solo cuentas activas”).

Mantén las políticas pequeñas y enfocadas por rol. En vez de una regla gigante con muchos OR, crea políticas separadas por audiencia (por ejemplo, invoices_tenant_read_app_user y invoices_tenant_read_support_agent). Es más fácil de probar, revisar y seguro de cambiar después.

Pasar el contexto de tenant y usuario de forma segura

Para que RLS funcione, la base necesita saber “quién llama” y “a qué tenant pertenece”. Las políticas RLS solo pueden comparar filas con valores que la base puede leer en tiempo de consulta, así que debes pasar ese contexto a la sesión.

Un patrón común es establecer variables de sesión tras la autenticación, y dejar que las políticas las lean con current_setting(). La app prueba identidad (por ejemplo, validando un JWT), y luego copia los IDs de tenant y usuario en la conexión de la base de datos.

-- Run once per request (or per transaction)
SELECT set_config('app.tenant_id', '3f2a0c3e-9c7b-4d3f-9c5c-3c5e9c5d1a11', true);
SELECT set_config('app.user_id',   '8d9c6b1a-6b6d-4e32-9c0d-2bfe6f6c1111', true);
SELECT set_config('app.role',      'support_agent', true);

-- In a policy
-- tenant_id column is a UUID
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);

Usar el tercer argumento true lo hace “local” a la transacción actual. Eso importa si usas connection pooling: una conexión en el pool puede ser reutilizada por otra petición, así que no quieres que el contexto del tenant de ayer persista.

Poblar contexto desde claims de JWT

Si tu API usa JWTs, trata los claims como entrada, no como verdad absoluta. Verifica la firma y la expiración del token primero, y luego copia solo los campos que necesites (tenant_id, user_id, role) a las settings de sesión. Evita dejar que los clientes envíen esos valores directamente en headers o query params.

Contexto faltante o inválido: denegar por defecto

Diseña políticas de modo que valores faltantes resulten en cero filas.

Usa current_setting('app.tenant_id', true) para que valores ausentes devuelvan NULL. Castea al tipo correcto (por ejemplo ::uuid) para que formatos inválidos fallen rápido. Y falla la petición si no se puede establecer el contexto de tenant/usuario, en lugar de asumir un valor por defecto.

Esto mantiene el control de acceso consistente incluso cuando una consulta evita la UI o se añade un endpoint nuevo.

Patrones prácticos de roles que resultan mantenibles

Estandariza tu modelo de datos por tenant
Modela tenant_id una vez en Data Designer y reutilízalo en todas las tablas y módulos.
Comenzar a crear

La forma más sencilla de mantener políticas RLS legibles es separar identidad de permisos. Una base sólida es una tabla users más una tabla memberships que conecta un usuario con un tenant y un rol (o varios). Entonces tus políticas pueden responder a una sola pregunta: “¿Tiene el usuario actual la membresía correcta para esta fila?”

Mantén los nombres de rol ligados a acciones reales, no a títulos de trabajo. “invoice_viewer” y “invoice_approver” suelen envejecer mejor que “manager”, porque la política se puede escribir en términos claros.

Aquí algunos patrones de roles que se mantienen simples a medida que la app crece:

  • Solo propietario: la fila tiene created_by_user_id (o owner_user_id) y el acceso coincide exactamente con ese ID.
  • Solo equipo: la fila tiene team_id, y la política comprueba que el usuario es miembro de ese equipo dentro del mismo tenant.
  • Solo aprobados: las lecturas están permitidas solo cuando status = 'approved', y las escrituras están restringidas a quienes aprueban.
  • Reglas mixtas: empieza estricto y añade excepciones pequeñas (por ejemplo, “soporte puede leer, pero solo dentro del tenant”).

Los admins cross-tenant son donde muchos equipos se complican. Trátalos explícitamente, no como un atajo “superuser” oculto. Crea un concepto separado como platform_admin (global) y requiere una comprobación deliberada en la política. Mejor aún, mantén el acceso cross-tenant en solo lectura por defecto y exige un nivel superior para writes.

La documentación importa más de lo que parece. Pon un comentario corto encima de cada política que explique la intención, no solo el SQL. “Los aprobadores pueden cambiar el estado. Los visualizadores solo pueden leer facturas aprobadas.” Seis meses después, esa nota es lo que mantiene seguras las ediciones de la política.

Si trabajas con una herramienta no-code como AppMaster, estos patrones siguen aplicando. La UI y la API pueden moverse rápido, pero las reglas de la base de datos permanecen estables porque se apoyan en membresías y significado claro de roles.

Escenario de ejemplo: un SaaS simple con facturas y soporte

Move from prototype to production
Despliega en AppMaster Cloud o en tu propia nube cuando estés listo para salir.
Desplegar

Imagina un SaaS pequeño que atiende a varias compañías. Cada compañía es un tenant. La app tiene facturas (dinero) y tickets de soporte (ayuda diaria). Los usuarios pueden ser agentes, managers o personal de soporte.

Modelo de datos (simplificado): cada fila de invoice y ticket tiene un tenant_id. Los tickets también tienen assignee_user_id. La app establece el tenant y el usuario actuales en la sesión de la base de datos justo después del login.

Así es como RLS cambia el riesgo diario.

Un usuario del Tenant A abre la pantalla de facturas e intenta adivinar un ID de factura del Tenant B (o la UI lo envía por error). La consulta aún se ejecuta, pero la base de datos devuelve cero filas porque la política exige invoice.tenant_id = current_tenant_id. No hay fuga de “acceso denegado”, solo un resultado vacío.

Dentro de un mismo tenant, los roles reducen el acceso aún más. Un manager puede ver todas las facturas y tickets de su tenant. Un agente solo puede ver tickets asignados a él, y quizá sus borradores. Aquí es donde los equipos suelen fallar en la API, especialmente cuando los filtros son opcionales.

Soporte es un caso especial. Puede necesitar ver facturas para ayudar clientes, pero no debería poder cambiar campos sensibles como amount, bank_account o tax_id. Un patrón práctico es:

  • Permitir SELECT en facturas para el rol de soporte (siempre con ámbito tenant).
  • Permitir UPDATE solo a través de un camino “seguro” (por ejemplo, una vista que exponga columnas editables, o una política de update estricta que rechace cambios a campos protegidos).

Ahora el escenario de “bug accidental en la API”: un endpoint olvida aplicar el filtro por tenant durante un refactor. Sin RLS, puede filtrar facturas entre tenants. Con RLS, la base de datos se niega a devolver filas fuera del tenant de sesión, por lo que el bug produce una pantalla rota, no una brecha de datos.

Si construyes este tipo de SaaS en AppMaster, aún quieres estas reglas en la base de datos. Los controles en la UI ayudan, pero las reglas en la base son las que sostienen cuando algo falla.

Errores comunes y cómo evitarlos

RLS es potente, pero pequeños descuidos pueden convertir “seguro” en “sorprendente”. La mayoría de problemas aparecen cuando se añade una nueva tabla, cambia un rol o alguien prueba con un usuario de base de datos equivocado.

Un fallo común es olvidarse de habilitar RLS en una tabla nueva. Puedes escribir políticas cuidadosas para tablas centrales y luego añadir una tabla de “notas” o “adjuntos” más tarde y lanzarla con acceso total. Hazlo costumbre: tabla nueva significa RLS habilitado, más al menos una política.

Otra trampa frecuente es políticas desajustadas entre acciones. Una política que permite INSERT pero bloquea SELECT puede hacer que “los datos desaparezcan” justo después de crearse. Lo contrario también duele: los usuarios pueden leer filas que no pueden crear, así que buscan soluciones en la UI. Piensa en flujos: “crear y luego ver”, “actualizar y luego reabrir”, “borrar y luego listar”.

Ten cuidado con funciones SECURITY DEFINER. Se ejecutan con los privilegios del propietario de la función, lo que puede eludir RLS si no eres estricto. Si las usas, mantenlas pequeñas, valida entradas y evita SQL dinámico a menos que realmente lo necesites.

También evita confiar solo en el filtrado del lado de la app dejando acceso amplio en la base. Incluso APIs bien construidas generan nuevos endpoints, jobs en background y scripts admin. Si el rol de la base puede leer todo, tarde o temprano algo lo explotará.

Para detectar problemas pronto, mantén las comprobaciones prácticas:

  • Prueba usando el mismo rol DB que tu app en producción, no tu usuario admin personal.
  • Añade una prueba negativa por tabla: un usuario de otro tenant debe ver cero filas.
  • Confirma que cada tabla soporta las acciones esperadas: SELECT, INSERT, UPDATE, DELETE.
  • Revisa el uso de SECURITY DEFINER y documenta por qué es necesario.
  • Incluye “¿RLS habilitado?” en checklists de code review y migraciones.

Ejemplo: si un agente de soporte crea una nota de factura pero no puede leerla luego, suele ser una política de INSERT sin una política SELECT coincidente (o el contexto de tenant no se está estableciendo para esa sesión).

Checklist rápido para validar tu RLS

Construye herramientas internas seguras
Crea facturas, tickets y pantallas de administración con salvaguardas que coincidan con tus reglas de base de datos.
Pruébalo ahora

RLS puede parecer correcto en revisión y aun así fallar en uso real. La validación no trata tanto de leer políticas como de intentar romperlas con cuentas y consultas realistas. Pruébalo como tu app lo usará, no como esperas que funcione.

Crea un pequeño conjunto de identidades de prueba primero. Usa al menos dos tenants (Tenant A y Tenant B). Para cada tenant, añade un usuario normal y un rol admin o manager. Si soportas roles como “soporte” o “solo lectura”, añade uno también.

Luego somete a presión RLS con un conjunto pequeño y repetible de comprobaciones:

  • Ejecuta las operaciones básicas para cada rol: listar filas, obtener una fila por id, insertar, actualizar y borrar. Para cada operación, intenta casos “permitidos” y “deben bloquearse”.
  • Prueba los límites de tenant: como Tenant A, intenta leer o modificar datos de Tenant B usando ids que sabes que existen. Debes obtener cero filas o un error de permiso, nunca “algunas filas”.
  • Prueba joins por fugas: une tablas protegidas a otras tablas (incluidas tablas lookup). Confirma que un join no puede traer filas relacionadas de otro tenant mediante una foreign key o una vista.
  • Comprueba que contexto faltante o erróneo deniega acceso: limpia el contexto de tenant/usuario y reintenta. “Sin contexto” debe fallar cerrado. También prueba un tenant id inválido.
  • Confirma el rendimiento básico: observa planes de consulta y asegúrate de que los índices soporten tu patrón de filtro por tenant (comúnmente tenant_id más lo que ordenas o buscas).

Si alguna prueba te sorprende, arregla la política o el establecimiento de contexto primero. No lo parchees en la UI o la API esperando que las reglas de la base “se mantendrán”.

Próximos pasos: desplegar con seguridad y mantener consistencia

Trata RLS como un sistema de seguridad: introdúcelo con cuidado, verifica con frecuencia y mantén las reglas lo bastante simples para que tu equipo las siga.

Empieza pequeño. Elige las tablas donde una fuga sería más dañina (pagos, facturas, datos personales, mensajes de clientes) y habilita RLS allí primero. Los éxitos tempranos valen más que un despliegue masivo que nadie comprende por completo.

Un orden práctico de despliegue suele ser:

  • Tablas “propias” centrales primero (filas que claramente pertenecen a un tenant)
  • Tablas con datos personales (PII)
  • Tablas compartidas pero filtradas por tenant (informes, analytics)
  • Tablas de join y casos límite (relaciones many-to-many)
  • Todo lo demás una vez que lo básico esté estable

Haz que las pruebas sean obligatorio. Las pruebas automatizadas deben ejecutar las mismas consultas como distintos tenants y roles y confirmar los cambios. Incluye comprobaciones “debe permitir” y “debe denegar”, porque los bugs más caros son los de sobre-permisión silenciosa.

Mantén un lugar claro en el flujo de la petición donde se establezca el contexto de sesión antes de ejecutar cualquier consulta. Tenant id, user id y role deben aplicarse una vez, temprano, y nunca asumirse más tarde. Si estableces contexto en medio de una transacción, acabarás ejecutando consultas con valores faltantes o antiguos.

Cuando construyas con AppMaster, planifica la consistencia entre tus APIs generadas y tus políticas PostgreSQL. Estandariza cómo se pasa el contexto de tenant y rol a la base (por ejemplo, las mismas session variables para cada endpoint) para que las políticas se comporten igual en todas partes. Si usas AppMaster en appmaster.io, RLS sigue valiendo como autoridad final para el aislamiento por tenant, incluso si también limitas acceso en la UI.

Por último, observa qué falla. Los fallos de autorización son señales útiles, sobre todo justo después del despliegue. Registra las denegaciones repetidas e investiga si apuntan a un ataque real, un flujo cliente roto o una política demasiado estricta.

Una lista de hábitos corta que ayuda a mantener RLS saludable:

  • Mentalidad de denegar por defecto, con excepciones añadidas intencionalmente
  • Nombres de política claros (tabla + acción + audiencia)
  • Los cambios en políticas se revisan como cambios de código
  • Denegaciones registradas y revisadas durante el despliegue inicial
  • Un pequeño conjunto de pruebas añadido por cada tabla nueva con RLS
Fácil de empezar
Crea algo sorprendente

Experimente con AppMaster con plan gratuito.
Cuando esté listo, puede elegir la suscripción adecuada.

Empieza