← LUCAS KARSTEN NOTA TÉCNICA · MIBI

Isolamento por schema sob um pooler

No mibi cada empresa cliente fica num schema Postgres separado. É a forma mais simples de garantir que a query de uma não alcance o dado da outra: o isolamento passa a ser responsabilidade do banco, e não de um WHERE tenant_id = ? que alguém vai esquecer numa sexta à tarde. A conta vem depois. Três das armadilhas só apareceram em produção.

O desenho

O subdomínio identifica o tenant. Uma consulta em public.tenant devolve o schema_name e toda query daquela requisição roda com o search_path apontado pra lá. Fazer essa consulta toda vez custa um round-trip, então tem um cache em memória com TTL de cinco minutos.

Esse cache não pode crescer sem limite. O domínio é público e aceita qualquer subdomínio, então basta alguém varrer a.mibi.app.br, b.mibi.app.br e seguir o alfabeto pra encher a memória do processo. Virou LRU com teto de mil entradas. O Map do JavaScript mantém ordem de inserção, então delete seguido de set promove a entrada usada e a chave mais antiga sai pelo começo.

Armadilha 1: o search_path não sobrevive ao pooler

A versão que parecia óbvia:

await client.query("SELECT set_config('search_path', $1, false)", [safe + ", public"]);
return await callback(client);

O terceiro argumento false quer dizer escopo de sessão. Funciona contra um Postgres direto. Contra o endpoint -pooler do Neon, que é PgBouncer em modo transação, funciona em desenvolvimento e falha em produção.

Em modo transação o pooler só amarra a conexão do cliente a um backend físico enquanto durar uma transação. Fora dela, cada statement pode cair num backend diferente. O set_config de sessão roda num statement em autocommit, o statement seguinte vai pra outro backend, e lá o search_path ainda é public. Resultado:

error: relation "candidate" does not exist  (42P01)

Intermitente, e só sob carga. Com pouco tráfego o pooler tende a devolver o mesmo backend, então o bug some justamente quando você liga o log pra investigar. Reproduzia umas duas vezes a cada quarenta requisições.

A correção é abrir transação explícita e usar escopo local, que o pooler preserva porque a transação inteira fica no mesmo backend:

await client.query("BEGIN");
await client.query("SELECT set_config('search_path', $1, true)", [safe + ", public"]);
await client.query("SELECT set_config('app.current_tenant', $1, true)", [safe]);
const result = await callback(client);
await client.query("COMMIT");

De brinde, no COMMIT a configuração some junto com a transação, então o client volta limpo pro pool sem depender de reset manual.

Isso cria uma regra que precisa ficar escrita em algum lugar: essa variante não serve pra caminho com I/O longo. Uma chamada à API da Anthropic dentro da transação seguraria um backend do pooler durante toda a latência do modelo, que pode passar de dez segundos. Leitura e escrita curtas usam a versão transacional. Qualquer coisa que espere rede fica de fora.

Armadilha 2: o client sujo voltando pro pool

Na variante sem transação o search_path precisa ser revertido no finally, antes de devolver o client. A pergunta que quase ninguém faz é: e se esse reset falhar?

A conexão volta pro pool ainda apontando pro schema do cliente anterior. A próxima requisição, de outra empresa, pega esse client. Se o set_config dela também escorregar, ela lê a tabela errada. Vazamento entre tenants, sem exceção nenhuma no log.

try {
  await client.query("SELECT set_config('search_path', 'public', false), ...");
  client.release();
} catch {
  client.release(true); // destrói em vez de devolver ao pool
}

O true no release manda o pool descartar a conexão. Custa um handshake novo. É um preço que eu pago sem pensar duas vezes.

Armadilha 3: a policy que bloqueia sem avisar

O search_path sozinho não é fronteira de segurança. Ele é conveniência de resolução de nome, e quem escrever o nome completo do schema passa por cima. A fronteira de verdade é uma policy RLS RESTRICTIVE que compara a linha com app.current_tenant.

O detalhe que queima uma tarde: policy RESTRICTIVE compõe com AND. Se app.current_tenant não for definido junto com o search_path, ela não devolve erro de permissão. Devolve zero linhas. A aplicação se comporta como se o banco estivesse vazio e você vai procurar o bug no lugar errado. Por isso as duas configurações ficam sempre no mesmo lugar e no mesmo escopo. Separar as duas transforma uma falha de segurança numa lista vazia, que é bem mais difícil de diagnosticar.

O nome do schema

Identificador não é parametrizável em SQL. O $1 resolve valor, não nome de objeto, então em algum momento o nome do schema entra por concatenação. Concatenação é onde mora a injeção.

const safe = schemaName.replace(/[^a-z0-9_]/g, "");
if (!safe || safe !== schemaName || safe.length > 63) {
  throw new Error(`Invalid schema name: ${schemaName}`);
}

Tem três decisões aí. A primeira é usar allow-list, que descreve o que pode passar, em vez de tentar listar tudo que é perigoso. A segunda é comparar safe !== schemaName e recusar a entrada, em vez de aceitar a versão higienizada: sanitizar calado esconde a tentativa, recusar deixa rastro no log. A terceira é o limite de 63 caracteres, que é o tamanho máximo de identificador no Postgres. Acima disso ele trunca sem avisar, e um nome truncado pode colidir com outro schema.

Um detalhe fácil de passar batido: a regex não tem a flag i. Se tivesse, Cliente_A passaria na validação, mas o Postgres dobra identificador sem aspas pra minúscula e o nome deixaria de bater com o schema real.

O que eu faria diferente

Um schema por cliente escala bem até uns poucos milhares de tenants e depois começa a doer. Cada migration roda N vezes e o catálogo do Postgres cresce junto. Pra volume maior, tabela única com RLS por coluna é o desenho mais defensável.

Pro mibi, com clientes B2B na casa das dezenas ou centenas, a troca valeu: isolamento que não depende de todo mundo lembrar de um WHERE. O erro caro não foi escolher schema. Foi assumir que estado de sessão sobrevive a um pooler, e descobrir isso em produção em vez de ler a documentação do PgBouncer antes.

← voltar