Multi-tenant: el error que no se ve
Aislar los datos de cada empresa en una sola base parece resuelto hasta que no lo está. Por qué fijar el search_path no alcanza y dónde mirar de verdad.
En esta crónica · 3 partes
Cuando un sistema atiende a varias empresas desde una sola base de datos, hay una pregunta que se responde el primer día y no se vuelve a mirar nunca: ¿cómo garantizamos que la empresa A no vea los datos de la empresa B?
En Postgres la respuesta corta es Row Level Security. Se activa RLS en la tabla, se escribe una política que compara la empresa de la fila contra la del usuario logueado, y listo. Es correcto, funciona, y es lo que usamos.
El problema aparece después, cuando el sistema crece y alguien necesita hacer algo que la política no deja hacer.
La función que se saltea las reglas
Supongamos que hay que calcular un total que cruza varias tablas, o correr un proceso nocturno.
Escribir eso respetando RLS es incómodo, así que aparece la salida fácil: una función
SECURITY DEFINER. Esa función corre con los permisos de quien la creó —normalmente el dueño de
la base— y las políticas de RLS no la frenan. Es exactamente para eso que existe.
Hasta acá está bien. El problema es qué se revisa después.
La recomendación que todo el mundo repite sobre SECURITY DEFINER es fijarle el search_path:
CREATE FUNCTION calcular_total(p_empresa uuid)
RETURNS numeric
LANGUAGE sql
SECURITY DEFINER
SET search_path = public -- esto es lo que todos revisan
AS $$ ... $$;
Eso previene un ataque real: que alguien cree una tabla con el mismo nombre en un esquema que
esté antes en el search_path y logre que la función lea la suya. Es una defensa necesaria.
Pero no tiene nada que ver con quién puede llamar a la función.
Dónde está el control de acceso de verdad
Una función SECURITY DEFINER esquiva RLS por diseño. Entonces lo único que decide quién puede
ejecutarla es el GRANT EXECUTE. Eso es el control de acceso, no el search_path.
Y ahí está la trampa: en Postgres, cuando creás una función, el permiso de ejecución se le
otorga a PUBLIC por defecto. No hay que hacer nada mal para que quede abierta — hay que
acordarse de cerrarla.
En un proyecto nuestro encontramos tres funciones SECURITY DEFINER que tenían el
search_path correctamente fijado, pasaban todas las revisiones que hacíamos, y estaban
ejecutables por el rol anónimo. El chequeo automático daba verde porque miraba lo que todos
miran.
La forma de verlo:
SELECT p.proname,
p.prosecdef AS es_security_definer,
pg_get_function_identity_arguments(p.oid) AS args,
array_to_string(p.proacl, ', ') AS permisos
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public' AND p.prosecdef
ORDER BY p.proname;
Si en permisos aparece =X/ sin un rol adelante, o el valor es NULL, esa función la puede
llamar cualquiera. Cerrarla es explícito:
REVOKE EXECUTE ON FUNCTION calcular_total(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION calcular_total(uuid) TO authenticated;
Lo que aprendimos
El invariante que revisábamos estaba incompleto. Verificaba que RLS estuviera activo en todas
las tablas y que las funciones SECURITY DEFINER tuvieran search_path fijo. Le faltaba la
tercera pregunta, que es la que importa: quién puede ejecutar cada una de esas funciones.
Es un caso de manual de por qué un chequeo automático que da verde puede ser peor que no tener ninguno: genera la confianza sin dar la garantía. Ahora el chequeo mira las tres cosas, y corre en cada deploy.
Si tenés un sistema multi-tenant en Postgres, la consulta de arriba tarda dos segundos. Vale la pena correrla hoy.