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.