BG-01 · ONLINE
[ DESCARGAR CV ]
REGISTRO
REG-008
SECCIÓN
S-ING · INGENIERÍA
ARCHIVADO
2026.10.17
CONSULTA
9 MIN
POSTGRESQLMULTI-TENANTSEGURIDAD

Multi-tenant con Row-Level Security: que la base no confíe en mi código

Cuadra es un SaaS de gestión para pymes colombianas (punto de venta, inventario y caja) en Go y PostgreSQL. Todos los comercios viven en la misma base y cada tabla de negocio lleva una columna tenant_id. La forma barata de aislarlos es añadir WHERE tenant_id = $1 a cada consulta. El problema es que basta con olvidarlo una vez para que una ferretería vea las ventas de otra. Quería que esa garantía la diera la base de datos, no mi disciplina.

La política

Cada tabla de negocio tiene Row-Level Security activado y forzado, con una política que compara contra el tenant de la transacción en curso:

SEGMENTO DE CÓDIGO · SQL
CREATE FUNCTION current_tenant_id() RETURNS uuid LANGUAGE sql STABLE AS
$$ SELECT NULLIF(current_setting('app.tenant_id', true), '')::uuid $$;

ALTER TABLE products ENABLE ROW LEVEL SECURITY;
ALTER TABLE products FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON products
    USING (tenant_id = current_tenant_id())
    WITH CHECK (tenant_id = current_tenant_id());

Falla cerrada: si no hay tenant, la consulta no ve ni escribe ninguna fila. No lanza error, pero tampoco filtra nada. WITH CHECK impide insertar filas a nombre de otro tenant o moverlas con un UPDATE.

El tenant viaja con la transacción

El código de negocio solo llega a la base a través de un helper que abre una transacción y fija el tenant:

SEGMENTO DE CÓDIGO · GO
return d.WithTx(ctx, func(tx pgx.Tx) error {
    _, err := tx.Exec(ctx, "SELECT set_config('app.tenant_id', $1, true)", tenantID.String())
    if err != nil {
        return fmt.Errorf("db: set tenant: %w", err)
    }
    return fn(tx)
})

El true del final es lo importante: hace que el ajuste sea local a la transacción. Con un pool de conexiones, un SET de sesión se quedaría pegado a la conexión y lo heredaría la siguiente petición, de otro comercio.

Trampa 1: el rol que se salta todo

RLS no aplica a los superusuarios, a los roles con BYPASSRLS ni al dueño de la tabla. Un error de configuración, como conectar la API con el usuario que corre las migraciones, haría desaparecer el aislamiento sin que nada fallara.

Por eso la API comprueba su propio rol al arrancar y se niega a iniciar si podría saltarse RLS. Las migraciones corren con otro rol, dueño del esquema, y el rol de la aplicación solo tiene SELECT, INSERT, UPDATE y DELETE. Tampoco tiene TRUNCATE, que ignora las políticas.

Trampa 2: las claves foráneas no ven RLS

PostgreSQL comprueba las claves foráneas sin aplicar las políticas. Con una FK simple, un producto del tenant A podría apuntar a una categoría del tenant B y la base lo aceptaría. La solución es que la FK incluya el tenant:

SEGMENTO DE CÓDIGO · SQL
ALTER TABLE products
    ADD FOREIGN KEY (tenant_id, category_id) REFERENCES categories (tenant_id, id);

Trampa 3: el índice que nunca se usó

La búsqueda de productos tenía un índice trigram para acelerar los LIKE. Al medir, resultó que nunca se usaba. Con RLS forzado, PostgreSQL no evalúa antes de la política un operador que no esté marcado como LEAKPROOF, porque podría filtrar datos de filas que el usuario no debe ver. LIKE no lo es.

Con 20.000 productos, la búsqueda tardaba 2,1 ms sin RLS y 7,2 ms con RLS, recorriendo los productos del tenant por el índice de tenant_id. Marcar el operador como LEAKPROOF exige ser superusuario, que en Azure no está disponible, y una función SECURITY DEFINER abriría justo el tipo de puerta entre tenants que RLS cierra. Quité el índice y acepté el costo: crece con el catálogo de un solo comercio, no con la tabla entera. Lo vuelvo a medir con pruebas de carga antes de crecer.

Lecciones

  • Si el aislamiento depende de no olvidar un WHERE, tarde o temprano se rompe. Que lo imponga la base.
  • set_config(..., true): con un pool, el tenant tiene que vivir en la transacción, no en la conexión.
  • El código debe comprobar que su rol no puede saltarse RLS; una revisión manual no basta.
  • Las FK no pasan por RLS: inclúyeles el tenant_id.
  • RLS tiene costo en el planificador. Hay que medirlo con datos reales, no suponerlo.