Row-Level Security en Postgres para aplicaciones multi-tenant
Cómo forzar el aislamiento de tenants a nivel de base de datos con Postgres RLS en lugar de confiar en que el código de la aplicación recuerde una cláusula WHERE.

En todos los SaaS multi-tenant en los que he trabajado, tarde o temprano ocurre el mismo incidente: un developer olvida una cláusula WHERE tenant_id = $1 en una query nueva, y de repente el tenant A puede ver las facturas del tenant B. El code review atrapa la mayoría de estos casos, pero no todos — y "la mayoría" no es suficiente cuando los datos son PII de clientes o registros financieros.
La solución no es más disciplina. Es sacar el aislamiento de tenants del código de la aplicación y llevarlo a la propia base de datos. Row-Level Security (RLS) de Postgres te permite definir políticas que filtran filas automáticamente, de modo que incluso una query con un WHERE faltante no puede filtrar datos entre tenants. No es una bala de plata, pero es lo más cercano a eso para este problema en particular.
Por qué el aislamiento en la capa de aplicación falla tarde o temprano
El patrón típico se ve seguro en el papel: cada método del repositorio recibe un tenantId y lo agrega a la query.
// ❌ El aislamiento depende de que cada developer recuerde este patrón, siempre
async function getInvoices(tenantId: string) {
return db.query(
"SELECT * FROM invoices WHERE tenant_id = $1",
[tenantId]
);
}
// Un join olvidado, una raw query para un "reporte rápido",
// un eager-load del ORM sin scope — y listo, se filtró.
async function getInvoicesWithLineItems(tenantId: string) {
return db.query(
`SELECT i.*, li.* FROM invoices i
JOIN line_items li ON li.invoice_id = i.id
WHERE i.tenant_id = $1` // fácil olvidar también en li.tenant_id
);
}Esto funciona hasta que deja de funcionar. Los scripts de administración, jobs en background, migraciones de datos e integraciones de terceros terminan por saltarse tu capa de repositorio cuidadosamente delimitada. La base de datos es el único lugar que ve cada query sin importar su origen, lo que la convierte en el límite correcto para aplicar esta restricción.
Configurando políticas de RLS
RLS está deshabilitado por defecto. Se habilita por tabla, y luego defines políticas que determinan qué filas son visibles para una sesión dada.
-- Habilitar RLS en la tabla
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- Forzar RLS incluso para el dueño de la tabla (crítico — ver más abajo)
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
-- Política: solo son visibles las filas que coinciden con el contexto de tenant actual
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Misma política para escrituras
CREATE POLICY tenant_isolation_insert ON invoices
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);current_setting('app.current_tenant_id') lee una variable local de sesión que estableces al inicio de cada request. Esta es la pieza que conecta el contexto de autenticación de tu aplicación con el filtrado de filas de Postgres.
Sin FORCE ROW LEVEL SECURITY, los dueños de las tablas se saltan RLS por completo de forma predeterminada. Si tu aplicación se conecta con un rol que es dueño de las tablas (algo común con ORMs que también ejecutan migraciones), tus políticas no hacen nada hasta que las fuerces.
Conectando el contexto de tenant a tu connection pool
La parte complicada con el connection pooling es que SET tiene alcance de sesión, y las conexiones pooled se reutilizan entre requests. Necesitas establecer la variable de tenant al inicio de cada transacción, no una sola vez por conexión.
// ✅ Establecer el contexto de tenant por transacción, no por conexión
async function withTenantContext<T>(
pool: Pool,
tenantId: string,
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
await client.query("BEGIN");
// set_config con local=true limita el setting a esta transacción
await client.query(
"SELECT set_config('app.current_tenant_id', $1, true)",
[tenantId]
);
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
// Uso en un request handler
app.get("/invoices", async (req, res) => {
const invoices = await withTenantContext(pool, req.tenantId, (client) =>
client.query("SELECT * FROM invoices") // no hace falta WHERE — RLS se encarga
);
res.json(invoices.rows);
});El tercer argumento true de set_config hace que el setting sea local a la transacción — se resetea automáticamente al hacer commit o rollback, así que no hay riesgo de que el contexto de tenant se filtre hacia el siguiente request que tome esta conexión pooled.
Manejando roles que legítimamente necesitan acceso cross-tenant
No toda query debería estar delimitada por tenant. Los jobs en background que agregan métricas de todos los tenants, o los dashboards de administración para tu equipo de soporte, necesitan un camino distinto.
-- Un rol dedicado que se salta las políticas de tenant para trabajo cross-tenant legítimo
CREATE ROLE reporting_service BYPASSRLS;
GRANT SELECT ON invoices TO reporting_service;
-- O, mantener RLS activo pero agregar una política explícita para acceso de administración
CREATE POLICY admin_full_access ON invoices
USING (current_setting('app.is_admin', true) = 'true');Prefiere la política explícita sobre BYPASSRLS siempre que sea posible — es auditable en pg_policies y no requiere gestionar un rol separado casi-superusuario. Reserva BYPASSRLS para jobs a nivel de infraestructura que nunca tocan lógica de aplicación delimitada por tenant.
Probar las políticas como pruebas tu lógica de negocio
Las políticas de RLS son código. El código sin probar tiene bugs. Escribe tests que verifiquen que el aislamiento realmente se cumple, no solo que las queries devuelven resultados.
-- Test: el tenant B nunca debe ver las filas del tenant A
BEGIN;
SELECT set_config('app.current_tenant_id', 'tenant-a-uuid', true);
INSERT INTO invoices (tenant_id, amount) VALUES ('tenant-a-uuid', 100);
SELECT set_config('app.current_tenant_id', 'tenant-b-uuid', true);
-- Esto debería devolver cero filas, no un error ni los datos del tenant A
SELECT count(*) FROM invoices WHERE amount = 100;
ROLLBACK;// Test de integración contra la base de datos real, no un mock
describe("tenant isolation", () => {
it("prevents cross-tenant reads even without a WHERE clause", async () => {
await withTenantContext(pool, TENANT_A, (client) =>
client.query("INSERT INTO invoices (tenant_id, amount) VALUES ($1, $2)", [
TENANT_A,
500,
])
);
const result = await withTenantContext(pool, TENANT_B, (client) =>
client.query("SELECT * FROM invoices")
);
expect(result.rows).toHaveLength(0);
});
});Ejecuta esta suite en CI contra una instancia real de Postgres, no contra SQLite ni un shim en memoria — el comportamiento de RLS es específico de Postgres y no existe en la mayoría de las bases de datos de prueba livianas.
Consideraciones de rendimiento
Las políticas de RLS son, en la práctica, cláusulas WHERE que el planner agrega a cada query, así que se benefician de las mismas reglas de indexado. Asegúrate de que tenant_id esté indexado, idealmente como columna principal en índices compuestos para tablas delimitadas por tenant.
-- ✅ tenant_id primero permite que el planner filtre antes de escanear
CREATE INDEX idx_invoices_tenant_created ON invoices (tenant_id, created_at DESC);También revisa los planes de ejecución después de habilitar RLS — en casos raros, políticas complejas con subqueries pueden impedir el uso de índices. Ejecuta EXPLAIN ANALYZE en tus queries más frecuentes después de la migración y compara contra el baseline previo a RLS.
Puntos clave
- El filtrado de tenants en la capa de aplicación falla tarde o temprano — jobs en background, herramientas de administración y cláusulas
WHEREolvidadas se lo saltan. RLS aplica el aislamiento en la única capa por la que pasa cada query. - Siempre combina
ENABLE ROW LEVEL SECURITYconFORCE ROW LEVEL SECURITY, o los dueños de las tablas se saltarán tus políticas silenciosamente. - Establece el contexto de tenant con
set_config(..., true)dentro de una transacción, no con unSETcrudo en una conexión pooled — de lo contrario se filtra entre requests. - Usa políticas explícitas y auditables para acceso cross-tenant (roles de administración, reportes) en lugar de recurrir a
BYPASSRLSpor defecto. - Prueba las políticas de RLS contra una instancia real de Postgres en CI — los bugs de aislamiento son exactamente el tipo de cosa que, de otro modo, solo aparece en producción.
- Indexa
tenant_idcomo columna principal y verifica los planes de ejecución después del despliegue; las políticas de RLS son predicados reales que afectan al planner.


