Todos los posts

PostgreSQL 16 RLS multi-tenant para SaaS colombiano: aislamiento real, no by-convention

2026-05-109 min· Juan José Trujillo Cardozo

En un SaaS multi-tenant, el peor bug que puedes tener no es un crash — es un leak de datos entre tenants. El tenant A ve datos del tenant B. En Colombia, eso es una violación de la Ley 1581/2012 con consecuencias ante la SIC, y una pérdida inmediata del cliente. Row Level Security (RLS) de PostgreSQL es el mecanismo correcto para evitarlo, porque el aislamiento lo garantiza la base de datos, no tu código.

El problema con el aislamiento by-convention

El aislamiento by-convention funciona así: en cada query del ORM añades .filter(tenant_id=current_tenant_id). Funciona hasta que: un desarrollador olvida el filtro en una query nueva, un bug en el middleware no setea el tenant correcto, o una query de agregación hace joins múltiples y el filtro se pierde. Con RLS, el aislamiento ocurre en el motor de PostgreSQL antes de que la query se ejecute. No importa si tu código olvidó el filtro.

Configuración base en PostgreSQL

Usamos un rol de base de datos por aplicación sin privilegios de superusuario (trujoapp_role). Este rol tiene permisos SELECT/INSERT/UPDATE/DELETE sobre todas las tablas, pero no puede alterar el schema ni ejecutar funciones con SECURITY DEFINER. Al obtener una conexión del pool en SQLAlchemy, seteamos la variable de sesión con SET LOCAL app.current_tenant_id = :tid. La clave es SET LOCAL: el valor solo existe dentro de la transacción actual, se limpia automáticamente al terminar.

Habilitando RLS en las tablas

Para cada tabla tenant-scoped ejecutamos ALTER TABLE orders ENABLE ROW LEVEL SECURITY y ALTER TABLE orders FORCE ROW LEVEL SECURITY. El FORCE es importante: sin él, el owner de la tabla puede ver todos los datos ignorando RLS. Con FORCE, incluso el owner necesita tener una policy que lo autorice.

Las políticas RLS

Creamos cuatro políticas por tabla: SELECT, INSERT, INSERT WITH CHECK, UPDATE (USING + WITH CHECK), y DELETE. Todas usan la misma condición: tenant_id = current_setting('app.current_tenant_id', true) con el cast UUID correspondiente. El segundo parámetro true en current_setting hace que retorne NULL si la variable no está seteada, en lugar de lanzar error. NULL no iguala a ningún UUID, así que si por algún bug el tenant no está en contexto, todas las rows quedan invisibles — comportamiento safe-by-default.

Política para datos de referencia compartida

No todo es tenant-scoped. El catálogo de municipios DIAN (1.122 municipios) es compartido entre todos los tenants. Para esas tablas creamos una policy de SELECT con USING (true) — todos pueden leer. Sin policy de INSERT/UPDATE/DELETE en esa tabla para el rol de aplicación, la escritura está bloqueada por defecto.

El bug de los 186 productos huérfanos

Durante la auditoría de seguridad de abril 2026 encontramos 186 filas en catalog_products con tenant_id NULL. Eran productos de prueba creados por migraciones tempranas antes de implementar RLS. Con RLS activo, esas filas eran invisibles para todos los tenants porque NULL != ningún UUID. Un bug silencioso: los productos existían en la DB pero no aparecían en ningún catálogo.

Solución: UPDATE catalog_products SET tenant_id = UUID-del-tenant-demo WHERE tenant_id IS NULL; seguido de ALTER COLUMN tenant_id SET NOT NULL para que no vuelva a pasar.

Testing RLS

El test más importante verifica que un tenant no puede ver datos de otro, incluso conociendo el UUID exacto del registro. El endpoint debe retornar 404 (no 403) para no exponer que el recurso existe. También verificamos que el listado de órdenes de un tenant no incluya ningún UUID de otro tenant. Los tests corren en una base de datos de prueba con dos tenants de fixture y data cruzada entre ellos.

Rendimiento

El overhead de RLS en PostgreSQL 16 es marginal para queries con índice en tenant_id. En producción, las queries de listado de órdenes están en p95 bajo 12ms. El plan de ejecución muestra que el filtro RLS se aplica como un Index Scan Condition sobre el índice compuesto (tenant_id, created_at), no como un filtro posterior sobre los resultados. Verificado con EXPLAIN ANALYZE en producción.

29 políticas, auditoría limpia

Después de la auditoría de seguridad de abril 2026, las 29 tablas tenant-scoped tienen políticas RLS activas. La auditoría no encontró ningún leak entre tenants en los tests automatizados ni en revisión manual. El constraint NOT NULL en tenant_id previene la clase de bug silencioso de los 186 productos.

RLS no reemplaza la validación en código — sigue siendo necesario validar input y autorización a nivel de aplicación. RLS es la última línea de defensa: garantiza que incluso si tu código tiene un bug, los datos de un tenant no cruzan hacia otro.