PostgreSQL Row-Level Security: aislamiento seguro para SaaS multiempresa
En una aplicación SaaS multiempresa, una consulta que olvida el filtro tenant_id puede mostrar facturas, usuarios o proyectos de otra organización. Revisar cada consulta ayuda, pero no constituye una barrera suficiente. PostgreSQL Row-Level Security (RLS) permite colocar una segunda línea de aislamiento dentro de la base de datos.
Esta guía propone un diseño pequeño y comprobable para usar RLS en un modelo pooled: varias empresas comparten las tablas, cada fila lleva su tenant_id y la aplicación fija el contexto del tenant antes de consultar. El objetivo no es esconder errores detrás de una política, sino combinar autorización de la aplicación, privilegios SQL y pruebas de aislamiento.
- Qué problema resuelve RLS
- Modelo de datos mínimo
- El contexto del tenant en una conexión
- Crear una política de aislamiento
- Usar una función para centralizar el contexto
- El request correcto con un pool de conexiones
- Evitar el bypass accidental
- Pruebas que detectan fugas entre empresas
- Tablas relacionadas: no basta con proteger la tabla raíz
- Fallos frecuentes en producción
- Checklist para publicar una arquitectura pooled
- Conclusión
Qué problema resuelve RLS
PostgreSQL define políticas que deciden qué filas puede ver o modificar un rol. Al activar RLS en una tabla, las operaciones normales deben estar permitidas por una política. Si no existe ninguna, se aplica una denegación por defecto. La documentación oficial de PostgreSQL también advierte que el propietario de la tabla, los superusuarios y los roles con BYPASSRLS pueden saltarse estas reglas en condiciones normales.
RLS no sustituye a GRANT. Los privilegios SQL responden a “¿este rol puede usar la tabla?”. La política responde a “¿qué filas puede usar dentro de ella?”. Necesitas ambas capas.
Modelo de datos mínimo
Empieza haciendo que la pertenencia sea explícita. No dependas de una relación indirecta difícil de inspeccionar:
CREATE TABLE tenant (
tenant_id uuid PRIMARY KEY,
name text NOT NULL UNIQUE
);
CREATE TABLE project (
project_id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenant(tenant_id),
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX project_tenant_id_idx ON project(tenant_id);
El índice no crea seguridad, pero evita que el filtro por empresa se convierta en un escaneo costoso. En tablas grandes, cada clave de pertenencia usada por las políticas merece una revisión del plan de ejecución.
El contexto del tenant en una conexión
Una aplicación multiempresa no necesita crear un usuario PostgreSQL por cliente. AWS recomienda, para el modelo pooled, fijar un contexto de tenant en una variable de sesión y usarlo en las políticas. Esto permite mantener un conjunto de roles manejable.
BEGIN;
SELECT set_config(
'app.current_tenant',
'7f2e6aa6-1d60-4c1c-a2fc-6c3e8f8081c1',
true
);
SELECT project_id, name
FROM project
ORDER BY created_at DESC;
COMMIT;
El tercer argumento true hace que el valor dure hasta el final de la transacción. Es una elección deliberada cuando existe un pool de conexiones: evita dejar un tenant pegado a la conexión para el siguiente request.
Crear una política de aislamiento
ALTER TABLE project ENABLE ROW LEVEL SECURITY;
CREATE POLICY project_tenant_isolation
ON project
USING (
tenant_id = current_setting('app.current_tenant', true)::uuid
)
WITH CHECK (
tenant_id = current_setting('app.current_tenant', true)::uuid
);
USING controla las filas visibles y las filas objetivo de operaciones como UPDATE o DELETE. WITH CHECK controla las filas que se pueden insertar o dejar después de una actualización. Escribir ambas expresiones hace que la intención sea legible y evita depender de una inferencia implícita.
El segundo argumento de current_setting permite devolver NULL si el contexto no existe. El casteo a UUID provocará un error para valores con formato inválido. Decide si tu aplicación prefiere ese error explícito o una función de contexto que emita un mensaje más claro.
Usar una función para centralizar el contexto
Para no repetir conversiones y nombres de variables por todo el esquema, puedes encapsular la lectura:
CREATE FUNCTION app_current_tenant()
RETURNS uuid
LANGUAGE sql
STABLE
AS $$
SELECT nullif(current_setting('app.current_tenant', true), '')::uuid
$$;
DROP POLICY project_tenant_isolation ON project;
CREATE POLICY project_tenant_isolation
ON project
USING (tenant_id = app_current_tenant())
WITH CHECK (tenant_id = app_current_tenant());
La función no debe permitir que el cliente elija un tenant sin autorización. El backend debe obtener la pertenencia desde la sesión autenticada, comprobar que el usuario pertenece a esa empresa y solo entonces fijar el valor en la transacción.
El request correcto con un pool de conexiones
El orden importa. Una forma segura de estructurar cada operación es:
- Validar la sesión del usuario en la aplicación.
- Resolver el tenant permitido por esa sesión.
- Obtener una conexión del pool.
- Abrir una transacción.
- Fijar
app.current_tenantcon alcance local a la transacción. - Ejecutar las consultas de negocio.
- Confirmar o revertir la transacción.
- Devolver la conexión al pool.
Si una consulta corre antes de fijar el contexto, puede fallar por ausencia de políticas o usar una función de contexto incorrecta. Si se fija con alcance de sesión, el siguiente request podría heredar el valor. Prueba este caso con el mismo pool y dos tenants alternados.
Evitar el bypass accidental
Una prueba RLS puede parecer correcta mientras la aplicación se conecta como propietario de las tablas. PostgreSQL señala que los propietarios suelen omitir RLS, salvo que se use FORCE ROW LEVEL SECURITY. Para el rol de la aplicación:
CREATE ROLE app_runtime LOGIN PASSWORD 'cambiar-en-el-secreto-del-entorno';
GRANT USAGE ON SCHEMA public TO app_runtime;
GRANT SELECT, INSERT, UPDATE, DELETE ON project TO app_runtime;
ALTER TABLE project FORCE ROW LEVEL SECURITY;
No pongas contraseñas reales en migraciones ni en el repositorio. En producción usa el gestor de secretos de tu infraestructura y separa el rol de migraciones del rol de runtime. El rol de migraciones puede necesitar privilegios que el proceso web nunca debería tener.
Pruebas que detectan fugas entre empresas
Una prueba útil no solo consulta filas visibles. También intenta leer, insertar, actualizar y borrar usando dos contextos:
BEGIN;
SELECT set_config('app.current_tenant', '7f2e6aa6-1d60-4c1c-a2fc-6c3e8f8081c1', true);
-- Debe devolver solo proyectos del tenant A
SELECT * FROM project;
-- Debe fallar o no modificar filas del tenant B
UPDATE project
SET name = 'intento de cruce'
WHERE tenant_id = '4b31c2a0-0ae1-4f43-9f4a-95d0d21b2f22';
-- Debe fallar por WITH CHECK si el proyecto queda en otro tenant
INSERT INTO project(project_id, tenant_id, name)
VALUES (gen_random_uuid(), '4b31c2a0-0ae1-4f43-9f4a-95d0d21b2f22', 'proyecto cruzado');
ROLLBACK;
Ejecuta las mismas pruebas conectando con el rol real de la aplicación, no con el usuario administrador. Añade una prueba que omita intencionadamente set_config: el resultado esperado debe ser un conjunto vacío o un error controlado, nunca la devolución de todas las filas.
Tablas relacionadas: no basta con proteger la tabla raíz
Si task pertenece a project y solo project tiene RLS, una consulta directa sobre task puede exponer datos si el rol posee privilegios sobre esa tabla. Cada tabla que contiene datos de tenant debe tener una política propia o una estrategia de acceso que no deje una ruta alternativa.
CREATE TABLE task (
task_id uuid PRIMARY KEY,
project_id uuid NOT NULL REFERENCES project(project_id),
title text NOT NULL
);
ALTER TABLE task ENABLE ROW LEVEL SECURITY;
CREATE POLICY task_tenant_isolation
ON task
USING (
EXISTS (
SELECT 1
FROM project
WHERE project.project_id = task.project_id
AND project.tenant_id = app_current_tenant()
)
)
WITH CHECK (
EXISTS (
SELECT 1
FROM project
WHERE project.project_id = task.project_id
AND project.tenant_id = app_current_tenant()
)
);
En esquemas con muchas tablas, documenta la cadena de pertenencia y automatiza una revisión que marque tablas con columnas de tenant sin RLS. La complejidad del esquema no debe convertirse en una excusa para dejar datos sin protección.
Fallos frecuentes en producción
- Usar un rol propietario para el servidor web y creer que RLS está activo cuando ese rol la omite.
- Fijar el tenant con alcance de sesión dentro de un pool compartido.
- Proteger
SELECTpero olvidarWITH CHECKen inserciones y actualizaciones. - Permitir que el frontend envíe cualquier
tenant_idsin comprobar membresía. - Crear políticas distintas e incompatibles y no revisar si son permisivas u restrictivas.
- Probar solo lecturas y no intentar cruces mediante
UPDATE,DELETEo relaciones. - Confundir RLS con cifrado: la política controla autorización, pero no cifra datos en reposo ni en tránsito.
Checklist para publicar una arquitectura pooled
- Cada fila sensible tiene una pertenencia de tenant explícita.
- La aplicación usa un rol de runtime distinto del propietario.
- RLS está habilitado y, cuando corresponde, forzado.
- Las políticas cubren lectura y escritura con
USINGyWITH CHECK. - El contexto se fija dentro de una transacción al usar pooling.
- Las tablas relacionadas tienen su propia ruta de aislamiento.
- Existen pruebas automatizadas con dos tenants y el rol real de runtime.
- Los logs permiten investigar el tenant sin registrar secretos ni datos innecesarios.
Conclusión
Row-Level Security no corrige una autorización mal diseñada, pero reduce el impacto de una consulta que olvidó filtrar por empresa. El patrón funciona cuando el backend decide el tenant, la conexión recibe un contexto transaccional y PostgreSQL verifica cada fila con políticas explícitas. Si además pruebas el comportamiento con el rol de runtime y con pool de conexiones, la separación entre clientes deja de depender de una sola línea de código.
Fuentes técnicas: PostgreSQL Row Security Policies y AWS Row-level security recommendations.
Si quieres conocer otros artículos parecidos a PostgreSQL Row-Level Security: aislamiento seguro para SaaS multiempresa puedes visitar la categoría Blog.
Deja una respuesta

También puede gustarte: