Blog

Multi-tenancy em SaaS: modelos de isolamento e Row Level Security no PostgreSQL

Como comparar banco, schema e tabela compartilhada por tenant, e como mover o isolamento para o banco com policies de RLS, variável de transação via SET LOCAL e um papel de aplicação sem BYPASSRLS.

Por Alexandre Martins · · 8 min de leitura

Multi-tenancy em SaaS: isolamento e Row Level Security. Modelos de isolamento e RLS no PostgreSQL.

Em um SaaS, o defeito mais caro não é o que derruba o sistema. É a consulta que devolve a um cliente os dados de outro. Quando todos os tenants compartilham as mesmas tabelas, a defesa habitual é um filtro por tenant_id repetido em cada consulta e em cada relatório. A proteção fica distribuída por centenas de pontos do código, e basta um deles faltar.

A alternativa de dar a cada cliente um banco próprio resolve o vazamento e cria outro problema: mil clientes são mil bancos para migrar, monitorar e fazer backup. Entre os dois extremos há modelos intermediários e um mecanismo do próprio PostgreSQL que coloca o filtro onde ele não pode ser esquecido.

Como garantir que nenhuma consulta devolva dados de outro tenant, sem depender de cada desenvolvedor lembrar de um WHERE e sem pagar um banco por cliente?

Três modelos de isolamento

No modelo de banco por tenant, cada cliente tem um database próprio. O isolamento é o mais forte: não existe consulta capaz de cruzar tenants, o backup é por cliente, e um tenant pesado não compete por recursos com os outros. O custo cresce com o número de clientes: cada migração roda N vezes, e a conexão precisa ser roteada para o banco certo.

No modelo de schema por tenant, um único banco tem um schema por cliente, com tabelas de mesmo nome, e o search_path da sessão escolhe o schema. O custo operacional cai, mas as migrações continuam multiplicadas, e o catálogo cresce com dezenas de milhares de tabelas, o que degrada o planejamento e o autovacuum.

No modelo de tabelas compartilhadas, todas as linhas de todos os clientes ficam nas mesmas tabelas, distinguidas por uma coluna tenant_id. Uma migração, um backup, um conjunto de métricas. O isolamento passa a depender do código, e o ruído de vizinho (noisy neighbor) é real: um relatório pesado de um tenant consome o buffer pool dos demais. O guia de arquitetura multi-tenant da Microsoft trata esses modelos como um espectro. Ele descreve o modelo misto, em que a maioria compartilha e alguns clientes, por conformidade ou escala, recebem recursos dedicados.

A escolha depende de quantos tenants existem, de quão diferentes eles são e do que o contrato exige em residência de dados. Para produtos com centenas ou milhares de clientes pequenos, o terceiro modelo é o ponto de partida, e a extração dos grandes para bancos dedicados é a válvula de escape.

Row Level Security no PostgreSQL

Row Level Security (RLS) move o filtro por tenant do código para o banco. Depois de ALTER TABLE ... ENABLE ROW LEVEL SECURITY, a tabela passa a negar tudo por padrão: sem uma policy que autorize, nenhuma linha é visível nem modificável. Cada policy tem uma expressão USING, que decide quais linhas existentes um comando enxerga, e uma expressão WITH CHECK, que decide quais linhas novas ou alteradas são aceitas. Se WITH CHECK for omitida, USING vale para as duas situações.

Nem todo papel passa pelas policies. Superusuários e papéis com o atributo BYPASSRLS as ignoram sempre. O dono da tabela as ignora por padrão, e só passa a obedecê-las com ALTER TABLE ... FORCE ROW LEVEL SECURITY. Daí a regra: a aplicação conecta com um papel que não é dono das tabelas e não tem BYPASSRLS, e as migrações rodam com outro.

A policy precisa saber qual é o tenant da requisição. O mecanismo é uma variável de configuração customizada, com nome em duas partes como app.tenant_id, que o PostgreSQL aceita sem extensão. A aplicação a define com SET LOCAL dentro da transação, e o efeito dura só até o fim dela, com commit ou rollback. A policy a lê com current_setting('app.tenant_id', true). O segundo argumento, missing_ok, faz a função devolver NULL em vez de erro quando a variável não existe, e NULL em USING equivale a falso: uma transação sem tenant não enxerga linha alguma.

Índices e o plano de consulta

Com RLS, toda consulta à tabela recebe o predicado da policy, mesmo quando o SQL da aplicação não menciona tenant_id. O planejador o trata como qualquer outro filtro, e a tabela precisa de índices que o atendam. Um índice composto que começa por tenant_id e segue pelas colunas de ordenação mais comuns serve à policy e à consulta ao mesmo tempo. A documentação do Supabase recomenda indexar toda coluna usada em policies, e o artigo sobre planejamento de consultas no PostgreSQL discute a ordem das colunas.

Expressões na policy são avaliadas por linha. Envolver a chamada em uma subconsulta, como (select current_setting('app.tenant_id', true)), faz o PostgreSQL avaliá-la uma vez por comando.

Exemplo: policies e middleware em TypeScript

O SQL a seguir cria a tabela de pedidos, o índice, o papel da aplicação e a policy. NULLIF protege contra a string vazia que a variável pode assumir após o fim de uma transação, porque ''::uuid falharia com erro em vez de devolver NULL.

Caminho de uma requisição até a policy. Fluxograma: a requisição chega com o tenant no token, o middleware abre a transação e executa SET LOCAL app.tenant_id, a consulta roda sem WHERE, a policy aplica USING e WITH CHECK, e a conexão volta ao pool sem tenant.
Caminho de uma requisição até a policy. Fluxograma: a requisição chega com o tenant no token, o middleware abre a transação e executa SET LOCAL app.tenant_id, a consulta roda sem WHERE, a policy aplica USING e WITH CHECK, e a conexão volta ao pool sem tenant.
Ver o fluxograma em texto (Mermaid)
flowchart TD
  req["Requisição com JWT"] --> claim["Middleware lê a claim tenant_id"]
  claim --> valido{"UUID válido?"}
  valido -- "não" --> r400["Rejeita a requisição"]
  valido -- "sim" --> begin["BEGIN na conexão app_user"]
  begin --> setlocal["SET LOCAL app.tenant_id"]
  setlocal --> consulta["Consulta de domínio sem WHERE tenant_id"]
  consulta --> policy["Policy aplica USING e WITH CHECK"]
  policy --> cruza{"Linha de outro tenant?"}
  cruza -- "leitura" --> oculta["Linha fica invisível"]
  cruza -- "escrita" --> erro["Comando abortado"]
  cruza -- "não" --> ok["Linhas do tenant"]
  ok --> commit["COMMIT: variável expira"]
  commit --> pool["Conexão volta ao pool sem tenant"]
CREATE TABLE pedido (
  id         UUID          PRIMARY KEY,
  tenant_id  UUID          NOT NULL
             DEFAULT NULLIF(current_setting('app.tenant_id', true), '')::uuid,
  cliente_id UUID          NOT NULL,
  total      NUMERIC(12,2) NOT NULL,
  criado_em  TIMESTAMPTZ   NOT NULL DEFAULT now()
);

-- Toda consulta começa pelo tenant: índice composto iniciado por tenant_id
CREATE INDEX pedido_tenant_criado_idx ON pedido (tenant_id, criado_em DESC);

-- Papel da aplicação: não é dono da tabela e não ignora as policies
CREATE ROLE app_user LOGIN NOBYPASSRLS;
GRANT SELECT, INSERT, UPDATE, DELETE ON pedido TO app_user;

ALTER TABLE pedido ENABLE ROW LEVEL SECURITY;
ALTER TABLE pedido FORCE ROW LEVEL SECURITY;   -- o dono também obedece

CREATE POLICY pedido_isolamento ON pedido
  AS PERMISSIVE
  FOR ALL
  TO app_user
  USING (tenant_id = (SELECT NULLIF(current_setting('app.tenant_id', true), '')::uuid))
  WITH CHECK (tenant_id = (SELECT NULLIF(current_setting('app.tenant_id', true), '')::uuid));

O DEFAULT da coluna tenant_id preenche o tenant da transação em cada INSERT, e WITH CHECK rejeita qualquer tentativa de gravar com outro valor. A função abaixo abre a transação, define o tenant e entrega a conexão ao código de domínio. O comando SET não aceita parâmetros vinculados, então o valor é validado como UUID e escapado como literal.

import { Pool, PoolClient } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });   // conecta como app_user
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

export async function comTenant<T>(tenantId: string, fn: (db: PoolClient) => Promise<T>): Promise<T> {
  if (!UUID.test(tenantId)) throw new Error("tenant inválido");
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    // SET LOCAL vale só até o fim da transação: a conexão volta ao pool sem tenant
    await client.query(`SET LOCAL app.tenant_id = ${client.escapeLiteral(tenantId)}`);
    const resultado = await fn(client);
    await client.query("COMMIT");
    return resultado;
  } catch (erro) {
    await client.query("ROLLBACK");
    throw erro;
  } finally {
    client.release();
  }
}

// app/api/pedidos/route.ts
import type { NextRequest } from "next/server";

export async function GET(req: NextRequest) {
  const tenantId = tenantDoToken(req);   // claim do JWT verificado, nunca um cabeçalho livre
  const pedidos = await comTenant(tenantId, async (db) => {
    const { rows } = await db.query(
      "SELECT id, total, criado_em FROM pedido ORDER BY criado_em DESC LIMIT 50",
    );
    return rows;   // sem WHERE tenant_id: a policy filtra
  });
  return Response.json(pedidos);
}

A consulta de domínio não menciona o tenant, e o resultado continua correto se alguém esquecer o filtro. Um UPDATE que tente mover um pedido para outro tenant é abortado por WITH CHECK.

Extração de um tenant para banco dedicado

O modelo compartilhado com tenant_id em toda tabela mantém aberta a saída para o modelo misto. Quando um cliente cresce além do que o banco suporta, ou quando um contrato exige dados em região própria, as linhas dele são copiadas para um banco dedicado com o mesmo esquema. A partir do PostgreSQL 15, uma publicação de replicação lógica aceita a cláusula WHERE por tabela, o que permite replicar só as linhas de um tenant até a troca. A aplicação passa a resolver, por tenant, qual connection string usar, e o código de domínio e as policies não mudam.

Aplicação no Supabase

O Supabase expõe o PostgreSQL ao navegador por uma API REST, e o RLS é a única barreira entre o cliente e as tabelas. As requisições chegam com os papéis anon ou authenticated, e auth.uid() devolve o usuário do JWT. Para multi-tenancy, o tenant vai em uma claim de app_metadata, que o usuário não pode alterar, e a policy a lê com (select auth.jwt() -> 'app_metadata' ->> 'tenant_id'). O papel service_role tem BYPASSRLS e só pode existir no servidor.

Desafios

Pool de conexões em modo de transação, como o PgBouncer, é compatível com SET LOCAL e incompatível com SET de sessão. Uma variável definida sem LOCAL permanece na conexão e vaza para a próxima requisição.

Jobs em segundo plano e relatórios consolidados precisam atravessar tenants. Esses casos exigem um papel separado, com acesso explícito e auditoria. O mesmo vale para os testes: uma suíte que roda como dono da tabela não exercita as policies.

O ruído de vizinho persiste no modelo compartilhado. Um tenant com dez vezes o volume dos outros domina o buffer pool e o autovacuum. Limites de taxa por tenant, como no artigo sobre rate limiting distribuído, e a extração para banco dedicado são as respostas.

A migração que adiciona tenant_id a um sistema existente é a parte mais trabalhosa da adoção. Toda tabela precisa da coluna, de backfill e de índice, e as chaves estrangeiras passam a incluir o tenant.

Boas práticas

  1. Comece pelo modelo compartilhado com tenant_id em toda tabela e reserve o banco dedicado para tenants que o justifiquem.
  2. Ative ENABLE e FORCE ROW LEVEL SECURITY em toda tabela de tenant, e conecte a aplicação com um papel sem BYPASSRLS que não seja dono das tabelas.
  3. Defina o tenant com SET LOCAL dentro da transação, a partir de uma claim verificada, nunca de um cabeçalho livre.
  4. Use current_setting com missing_ok igual a true e NULLIF, para que a ausência de tenant resulte em zero linhas e não em erro.
  5. Crie índices compostos iniciados por tenant_id para as consultas mais frequentes.
  6. Mantenha um papel separado e auditado para operações entre tenants, e rode os testes como o papel da aplicação.

Conclusão

Multi-tenancy é uma decisão sobre onde fica a fronteira entre clientes: na infraestrutura, no esquema ou na linha. Quanto mais baixo o nível, menor o custo por tenant e maior a dependência de um mecanismo que impeça o cruzamento. Row Level Security é esse mecanismo quando a fronteira está na linha, aplicado pelo banco a toda consulta, inclusive às que ninguém revisou.

O preço é disciplina operacional: um papel de aplicação sem privilégios de bypass, o tenant definido por transação e índices que começam pelo tenant. Com essas condições, o WHERE que faltava deixa de ser um risco por consulta e vira uma propriedade da tabela.

Referências

Assuntos: PostgreSQL, SaaS, Multi-Tenancy, Security, Software Architecture

Continue lendo

Quer aplicar isto no seu sistema? Fale com a MFactor: contato ou WhatsApp +55 (19) 99778-9831.