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_id y falle si relrowsecurity o relforcerowsecurity es falso.
  • Vistas y funciones. Las vistas se ejecutan con los permisos de su propietario salvo que se creen con security_invoker (PostgreSQL 15+); las funciones SECURITY DEFINER se 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_id permite a un tenant deducir, por el error de la restricción, que un valor existe en otro sitio. Prefija con tenant_id las claves únicas de ámbito de tenant.
  • Rendimiento. La política es un predicado extra en cada consulta. Con tenant_id como 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.