Hace unos meses, durante una auditoría de seguridad en una plataforma financiera, nos encontramos con el peor escenario posible para cualquier arquitecto de software: un endpoint de reportes estaba devolviendo transacciones mezcladas de diferentes clientes. La causa no fue un hackeo sofisticado, sino un simple error humano. Un desarrollador junior había olvidado agregar la cláusula WHERE tenant_id = ? en una consulta SQL nativa compleja que puenteaba el ORM. Este incidente nos obligó a replantear nuestra estrategia de SaaS Multi-tenancy, moviéndonos de una seguridad lógica en la capa de aplicación a un aislamiento forzado en el motor de base de datos.
Análisis del Problema: La Fragilidad del "WHERE" Manual
En arquitecturas SaaS tradicionales de "Shared Database, Shared Schema" (Base de datos compartida, Esquema compartido), la estrategia estándar para el aislamiento de datos es confiar ciegamente en que cada consulta incluya el filtro del inquilino (tenant). En un entorno controlado con un ORM como Hibernate o TypeORM, esto se puede automatizar parcialmente mediante "Scopes" o "Filters". Sin embargo, la realidad de producción es mucho más caótica.
Nuestra aplicación corría sobre Node.js conectando a un clúster de PostgreSQL 14. Teníamos más de 50 tablas críticas. A medida que el sistema crecía, la necesidad de realizar consultas analíticas complejas nos empujó a escribir SQL crudo (Raw SQL). Aquí es donde la seguridad basada en la aplicación se desmorona. Basta con que un solo desarrollador olvide el filtro en un JOIN para exponer datos confidenciales. El riesgo no es solo técnico, es legal y reputacional.
SELECT * FROM users u JOIN orders o ON u.id = o.user_id;
Sin un filtro explícito de
tenant_id, esta consulta devuelve todas las órdenes de todos los clientes del sistema.
Para mitigar esto, inicialmente intentamos crear Vistas (Views) para cada cliente, pero con más de 5,000 inquilinos, la gestión del esquema se volvió insostenible y las migraciones tardaban horas. Necesitábamos algo nativo, robusto y transparente para el desarrollador.
Por qué falló el enfoque de Esquemas Separados
Antes de llegar a la solución definitiva, intentamos implementar una separación física utilizando un esquema de base de datos por cliente (Schema-per-tenant). Si bien esto garantiza un aislamiento casi perfecto, el costo operativo se disparó. El pool de conexiones se saturaba al tener que mantener conexiones abiertas para cientos de esquemas diferentes, y el uso de memoria en el servidor de base de datos aumentó drásticamente. Además, realizar consultas agregadas para métricas globales (ej. "Ingresos totales de la plataforma") requería una complejidad de ingeniería absurda. Fue entonces cuando decidimos profundizar en la Seguridad en bases de datos nativa.
La Solución: Implementando Row Level Security (RLS)
La característica PostgreSQL RLS (Row Level Security) permite definir políticas de seguridad directamente en las tablas de la base de datos. Una vez activada, PostgreSQL aplica automáticamente estas políticas a cada consulta ejecutada, actuando como un firewall interno. Incluso si el desarrollador escribe SELECT * FROM orders, la base de datos reescribe silenciosamente la consulta para incluir WHERE tenant_id = 'mi-tenant-actual'.
A continuación, presento la configuración exacta que utilizamos para blindar nuestras tablas sin cambiar drásticamente el código de la aplicación.
-- 1. Habilitar RLS en la tabla objetivo
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- 2. Crear un usuario de aplicación con permisos restringidos
-- IMPORTANTE: Nunca usar el usuario 'postgres' (superuser) para la app
CREATE ROLE app_user WITH LOGIN PASSWORD 'secure_password';
GRANT ALL ON invoices TO app_user;
-- 3. Definir la Política de Aislamiento
-- Esta política usa una variable de configuración de sesión 'app.current_tenant'
CREATE POLICY tenant_isolation_policy ON invoices
FOR ALL -- Aplica para SELECT, INSERT, UPDATE, DELETE
TO app_user
USING (tenant_id = current_setting('app.current_tenant')::uuid)
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);
-- NOTA: La cláusula 'USING' filtra filas existentes.
-- La cláusula 'WITH CHECK' impide insertar datos para otros tenants.
El núcleo de esta solución radica en la función current_setting('app.current_tenant'). En lugar de pasar el ID del inquilino en cada consulta SQL, lo configuramos una sola vez al inicio de la transacción o sesión. Esto simplifica enormemente el código del backend y centraliza la lógica de seguridad.
Integración en el Backend (Ejemplo Conceptual)
Para que esto funcione, su aplicación debe establecer la variable de sesión antes de ejecutar cualquier consulta de negocio. Aquí es donde muchos fallan al usar pools de conexiones.
// Ejemplo en Node.js con pg-pool
const client = await pool.connect();
try {
await client.query('BEGIN');
// ESTE ES EL PASO CRÍTICO
// Inyectamos el contexto del tenant en la sesión de Postgres
await client.query(
"SELECT set_config('app.current_tenant', $1, true)",
[req.user.tenantId]
);
// Ahora, esta consulta es segura automáticamente
const result = await client.query('SELECT * FROM invoices');
await client.query('COMMIT');
return result.rows;
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
Observen el tercer parámetro true en set_config. Esto indica que la configuración es local para la transacción. Si olvidan esto o no usan transacciones, la variable podría persistir en la conexión y el siguiente usuario que tome esa conexión del pool heredaría el acceso del usuario anterior. Este es un detalle de implementación vital.
| Métrica | Filtrado por App (WHERE) | PostgreSQL RLS |
|---|---|---|
| Seguridad | Baja (Depende del Dev) | Alta (Forzada por DB) |
| Latencia (10k filas) | 12ms | 14ms |
| Complejidad de Código | Alta (Repetitiva) | Baja (Centralizada) |
| Riesgo de Fuga | Alto | Casi Nulo |
Como pueden ver en la tabla de rendimiento, el impacto de RLS en la latencia es despreciable (apenas 2ms adicionales en nuestras pruebas de carga) comparado con la inmensa ganancia en seguridad. PostgreSQL optimiza estas políticas al mismo nivel que un WHERE clause normal, utilizando los índices disponibles.
Casos Borde y Advertencias Críticas
Aunque Row Level Security es poderoso, no es una "bala de plata". Existen escenarios donde su comportamiento puede causar problemas si no se tiene experiencia previa.
Primero, el usuario Superuser (postgres) ignora RLS por defecto. Si su aplicación se conecta como superusuario (mala práctica común en desarrollo), las políticas no tendrán efecto y verá todos los datos. Siempre cree un rol específico con permisos limitados (NOBYPASSRLS) para la aplicación.
Segundo, hay que tener cuidado con las herramientas de backup como pg_dump. Si se ejecuta con un usuario sin privilegios adecuados, es posible que el backup solo contenga los datos visibles para ese usuario, resultando en una pérdida de datos silenciosa. Asegúrese de que sus scripts de mantenimiento utilicen un rol con el atributo BYPASSRLS.
set_config es seguro siempre que use el flag is_local=true. Sin embargo, en modo "Statement", no puede garantizar que el contexto de seguridad persista entre dos comandos SQL. Para arquitecturas RLS, el modo "Transaction" o "Session" es obligatorio.
Conclusión
Implementar PostgreSQL RLS transformó nuestra arquitectura de seguridad. Pasamos de auditar cada línea de código SQL en busca de cláusulas WHERE faltantes a confiar en que nuestra base de datos es el guardián final de la integridad. Para cualquier aplicación SaaS Multi-tenancy moderna, delegar el aislamiento de datos al motor de base de datos no es solo una optimización; es una necesidad para dormir tranquilo por las noches.
Post a Comment