Blog · Arquitectura
Row-level security en PostgreSQL para telemetría multi-tenant
Una configuración funcional de RLS multi-tenant en PostgreSQL: FORCE ROW LEVEL SECURITY, roles separados de migración y aplicación, contexto de tenant local a la transacción con set_config, trampas habituales y el test de aislamiento.
En un servicio de monitorización multi-tenant, el peor bug posible no es una caída. Es que un cliente vea las cuentas de otro. Los filtros a nivel de aplicación (WHERE tenant_id = ? en cada consulta) lo evitan hasta el día en que alguien olvida uno. El row-level security de PostgreSQL traslada la regla a la base de datos, donde un filtro olvidado devuelve nada en lugar de devolverlo todo. Así lo aplicamos a la telemetría de MT5, con el SQL.
El modelo en un párrafo
Toda tabla con datos de tenant lleva un tenant_id. El row-level security se activa y se fuerza en esas tablas. Una política permite una fila solo cuando su tenant_id coincide con un ajuste de sesión. La aplicación se conecta con un rol que no tiene forma de saltarse la política, y fija ese valor al inicio de cada transacción a partir de la credencial autenticada. Las migraciones se ejecutan con un rol distinto. Si el ajuste falta, la comparación es contra NULL y la consulta devuelve cero filas: fail-closed por construcción.
Esquema y política
CREATE TABLE ea_telemetry_latest (
tenant_id uuid NOT NULL,
account_key text NOT NULL, -- 'servidor::login'
magic bigint NOT NULL,
symbol text NOT NULL,
final_lot numeric NOT NULL,
allow_trading boolean NOT NULL,
last_seen_utc timestamptz NOT NULL,
PRIMARY KEY (tenant_id, account_key, magic)
);
ALTER TABLE ea_telemetry_latest ENABLE ROW LEVEL SECURITY;
ALTER TABLE ea_telemetry_latest FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ea_telemetry_latest
USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
Tres detalles sostienen todo lo demás. FORCE hace que la política se aplique también al propietario de la tabla; sin él, un rol propietario se salta el RLS en silencio. El segundo argumento true de current_setting devuelve NULL en lugar de lanzar un error cuando el ajuste no existe. Y WITH CHECK cubre las escrituras: sin él, un tenant podría insertar filas etiquetadas como de otro.
Dos roles, nunca uno
CREATE ROLE telemetry_migrator LOGIN; -- dueño del esquema, ejecuta migraciones
CREATE ROLE telemetry_app LOGIN NOBYPASSRLS; -- el que usa la API en ejecución
GRANT SELECT, INSERT, UPDATE ON ea_telemetry_latest TO telemetry_app;
El rol de ejecución no es dueño de nada, no puede alterar políticas y no es superusuario (los superusuarios y los roles con BYPASSRLS ignoran las políticas por completo). Las credenciales del migrador solo existen en el pipeline de despliegue. Compartir una misma cadena de conexión para ambos trabajos elimina toda la protección sin hacer ruido.
Fijar el tenant en cada transacción
BEGIN;
SELECT set_config('app.tenant_id', $1, true); -- true = local a esta transacción
SELECT account_key, magic, last_seen_utc FROM ea_telemetry_latest;
COMMIT;
Usa la forma local a la transacción. Con un pool de conexiones, un SET a nivel de sesión sobrevive al final de la petición, y la siguiente petición en esa conexión hereda el tenant anterior. La forma local desaparece al hacer commit o rollback. En un ORM, hazlo en un único punto: un interceptor de conexión o un middleware que abra la transacción, fije el valor a partir del principal autenticado y se niegue a continuar si no lo hay.
Lo que el RLS no cubre
- Políticas en tablas nuevas. El RLS es por tabla. Una tabla añadida en el próximo sprint está desprotegida hasta que reciba su política. Añade un test que liste las tablas con columna
tenant_idy falle sirelrowsecurityorelforcerowsecurityes falso. - Vistas y funciones. Las vistas se ejecutan con los permisos de su propietario salvo que se creen con
security_invoker(PostgreSQL 15+); las funcionesSECURITY DEFINERse ejecutan como su propietario. Ambas pueden convertirse en una vía para rodear la política. - Fugas por unicidad. Un índice único que no empiece por
tenant_idpermite a un tenant deducir, por el error de la restricción, que un valor existe en otro sitio. Prefija contenant_idlas claves únicas de ámbito de tenant. - Rendimiento. La política es un predicado extra en cada consulta. Con
tenant_idcomo primera columna del índice el coste es despreciable; sin ella, cada consulta recorre la tabla.
El test que importa
Crea dos tenants con datos de prueba. Con el rol de ejecución: con el tenant A fijado, comprueba que las filas de B son invisibles y que insertar una fila etiquetada como B falla; sin ningún tenant fijado, comprueba que todas las tablas devuelven cero filas. Ejecútalo en CI contra un PostgreSQL real, no contra un mock: el comportamiento que se verifica vive en la base de datos. Es un test pequeño, y es el que te permite decirle a un cliente, con verdad, que el aislamiento no depende de que cada desarrollador recuerde un WHERE.
Preguntas frecuentes
¿Por qué usar FORCE ROW LEVEL SECURITY?
Sin FORCE, el propietario de la tabla se salta las políticas. Al forzarlo, la política se aplica también al propietario, de modo que un rol mal configurado no puede leer en silencio todos los tenants.
¿Es más seguro SET o set_config con un pool de conexiones?
Usa set_config con el indicador local dentro de una transacción. Un SET a nivel de sesión persiste en la conexión del pool y puede filtrar el tenant anterior a la siguiente petición.
¿El row-level security ralentiza las consultas?
Añade un predicado por consulta. Con tenant_id como primera columna de los índices, la sobrecarga es despreciable.
¿Qué ocurre si falta el ajuste de tenant?
current_setting con el indicador missing_ok devuelve NULL, la comparación de la política nunca es cierta y la consulta devuelve cero filas. El modo de fallo es un resultado vacío, no una fuga.