Engenharia

O RLS estava ligado. O banco de dados ainda entregou os dados do tenant errado.

Row-level security não sabe quem é o cliente até seu app dizer a ele, a brecha vive na ponte de identidade, não na policy.

ASR

Apollo Space Research

Apollo Space

· 12 min de leitura

Uma query roda contra uma tabela com row-level security ligada, toda policy no lugar, e ela retorna uma linha que pertence a um cliente diferente. Nada está mal configurado. A policy fez exatamente o que foi dito. A tabela estava protegida, a regra estava correta, e os dados do tenant errado voltaram mesmo assim.

Essa frase soa impossível se você acha que a segurança vive na policy. Ela é rotineira uma vez que você vê onde ela de fato vive.

Row-level security não sabe quem é o cliente até seu app dizer a ele, a brecha vive na ponte de identidade, não na policy.

Este post é sobre essa ponte: a camada fina e fácil de esquecer entre “o banco de dados tem uma regra” e “o banco de dados sabe qual tenant está perguntando.” É a camada para a qual quase ninguém escreve um teste, e é a única camada onde um vazamento multi-tenant pode de fato acontecer.

A regra não é a mesma coisa que a pergunta

Comece com o que row-level security é, dito sem rodeios, porque o nome vende demais.

Uma row-level policy é um filtro que o banco de dados anexa a uma tabela. Você escreve algo como um usuário só pode ver linhas onde o tenant da linha combina com o tenant atual. A partir daí, toda query contra aquela tabela silenciosamente carrega o filtro. Você pode escrever select * from invoices sem nenhuma cláusula where de jeito nenhum, e o banco de dados silenciosamente acrescenta a regra. Parece um muro em volta dos dados, e as pessoas o descrevem assim: “o RLS está ligado, então os tenants estão isolados.”

Aqui está a lacuna que essa frase esconde. A policy é uma condição. A condição lê uma variável, o tenant atual. E o banco de dados não sabe magicamente o tenant atual. Ele sabe o que quer que a última coisa a tocar a connection tenha dito a ele.

A policy é metade da frase. Onde tenant é igual ao tenant atual, esse é o verbo e o objeto. O sujeito, quem o tenant atual é, vem de outro lugar inteiramente. A policy impõe uma comparação; ela não estabelece nenhum dos dois lados dela. Se o “tenant atual” no qual o banco de dados acredita está errado, a policy impõe a comparação errada perfeitamente e devolve as linhas erradas com a consciência limpa.

Então a pergunta de verdade nunca foi “a policy está correta.” É “onde o banco de dados aprende quem está perguntando, e essa resposta pode alguma vez estar velha, em branco, ou de outra pessoa?”

A versão ingênua: sete o tenant, rode a query, confie no pool

O jeito óbvio de conectar isso é o jeito que ele se lê num tutorial. Quando uma request chega, você descobre a qual tenant ela pertence, você diz ao banco de dados “o tenant atual é este,” e então você roda suas queries. A policy faz o resto. Limpo.

Funciona impecavelmente no demo, com uma request por vez, no seu laptop.

Então ele encontra um connection pool.

Aplicações reais não abrem uma connection fresca por request, isso é lento, então elas mantêm um pool de connections quentes e as distribuem. A Request A pega uma connection, seta o tenant atual para Acme, faz o trabalho dela, e devolve a connection ao pool. O setting “tenant atual = Acme” é uma propriedade daquela connection, e ninguém o limpou. A Request B, pertencente a um cliente diferente, pega a mesma connection quente um momento depois. Se B roda até uma query antes de setar o próprio tenant, um health check, um lookup, uma leitura um tantinho cedo demais, a policy fielmente filtra por Acme. A regra disparou. O muro segurou. Os dados ainda cruzaram.

A policy nunca esteve errada. A connection lembrou o último tenant, e ninguém pediu a ela para esquecer.

Esse é o failure mode inteiro num fôlego: row-level security é imposta por query, mas a identidade que ela filtra é setada por connection, e connections são reusadas. No instante em que esses dois lifecycles divergem, você tem um vazamento que nenhuma revisão de policy vai jamais pegar, porque a policy é impecável. O bug está na costura entre setar a identidade e rodar a query, e essa costura é invisível em code review precisamente porque a versão perigosa e a versão segura parecem quase idênticas.

Dois jeitos da mesma tabela protegida responder uma query. Na faixa ingênua, uma connection pooled ainda carrega a identidade do tenant anterior, então a policy correta filtra no tenant errado e vaza; na faixa segura, a identidade é vinculada à própria query, scoped e limpa toda vez, então a policy filtra no chamador real.

Por que as correções usuais só movem o bug

Uma vez que um time sente essa falha, as primeiras correções são razoáveis e incompletas. Cada uma estreita a janela sem fechá-la.

O primeiro instinto é disciplina: sempre sete o tenant primeiro, antes de qualquer query. Isso é verdadeiro e inútil do jeito que “sempre libere sua memória” é verdadeiro e inútil. Depende de todo code path, presente e futuro, lembrar uma obrigação invisível. O único path que lê antes de setar, adicionado seis meses depois por alguém que nunca sentiu o bug original, reabre o buraco, e nada falha alto para avisá-lo.

O segundo instinto é limpar a identidade quando a connection volta ao pool. Melhor. Agora uma connection não pode carregar o tenant de ontem para a request de amanhã. Mas “limpar na release” depende de a release sempre rodar, e a release nem sempre roda, um crash, um timeout, uma exception num path incomum, e a connection volta suja ou a limpeza é pulada. Você encolheu a janela perigosa, não a removeu. O bug agora precisa de uma coincidência para disparar, o que significa que ele dispara raramente, o que significa que ele dispara em produção e não nos seus testes.

O terceiro instinto é o perigoso confortável: rodamos um teste, dois tenants, nenhum vazamento, coloca em produção. O problema é que o vazamento precisa de uma race, um interleaving específico de qual request pegou qual connection em qual ordem antes de qual set. Um teste que passa prova que o vazamento não aconteceu daquela vez. Ele não pode provar que não pode acontecer. Bugs de concorrência não estão ausentes porque um teste ficou verde; eles estão escondidos até a carga e o timing se alinharem.

Cada um desses movimentos move o bug. Nenhum deles muda o formato do sistema que o produz. O formato é o problema: identidade e query têm tempos de vida separados, e enquanto tiverem, alguém tem que sincronizá-los perfeitamente à mão, para sempre, em todo path.

Nosso jeito: vincule a identidade à query, não à connection

A correção não é uma regra melhor ou um hábito mais rigoroso. É parar de deixar a identidade sobreviver à query que precisa dela. A brecha vive na ponte de identidade, não na policy, então reconstruímos a ponte em vez de polir a regra.

A ideia-chave é simples: a identidade do tenant deveria nascer com a query e morrer com ela, para que não haja janela onde uma connection segura uma identidade que nenhuma query atual pediu. Em vez de “sete o tenant na connection, depois rode uma query, e torça para nada ter reusado aquela connection,” você scopa a identidade a uma única unidade de trabalho que o banco de dados derruba quando o trabalho termina, automaticamente, mesmo nos failure paths, porque é o mesmo mecanismo que já faz rollback de uma transação que falhou.

Concretamente, toda peça de trabalho que toca dados de tenant abre uma transação, seta o tenant atual dentro daquela transação com um setting scoped a ela, roda suas queries, e dá commit. Quando a transação termina, sucesso ou exception, saída limpa ou crash, o setting scoped se vai com ela. Não há passo de “lembre de limpar” para esquecer, porque esquecer não é uma opção que o mecanismo oferece. A connection volta ao pool carregando nada. A próxima request que a pega encontra uma lousa em branco e tem que declarar o próprio tenant antes de ler uma única linha, dentro da própria transação, scoped do mesmo jeito.

Isso inverte o default. Na versão ingênua, o estado inseguro, uma connection segurando uma identidade velha, é o que acontece quando você não faz nada, e a segurança exige vigilância constante. No nosso, o estado seguro é o que acontece quando você não faz nada, e não há path que leia dados de tenant sem primeiro, dentro do mesmo scope, declarar quem ele é. A race condition não tem onde viver porque os dois lifecycles que costumavam divergir agora são o mesmo lifecycle.

Uma query por dados de tenant flui através de um scope: abra uma transação, declare o tenant do chamador dentro dela, deixe a policy filtrar nisso, então derrube o scope para que a identidade morra com a query e a connection volte em branco.

Adicionamos mais uma coisa, e ela importa mais do que parece. A role de banco de dados com que a aplicação conecta não tem permissão de ignorar a policy. Soa óbvio, mas o vazamento silencioso mais comum nessa categoria inteira é uma connection que roda como uma role de alto privilégio para a qual o row-level security está simplesmente desligado, toda policy que você cuidadosamente escreveu é bypassada, não porque falhou, mas porque o chamador estava isento dela. Então a role da aplicação é uma simples, sem privilégios, à qual a policy se aplica sem exceções, e toda query scoped a tenant também carrega o filtro de tenant explicitamente no próprio texto. Belt and braces: a policy impõe o isolamento, e a query declara a mesma restrição em voz alta. Se uma camada algum dia estiver mal configurada, a outra ainda segura.

Nota de campo: a falha que prova a fronteira

Há um teste que todo time rodando dados multi-tenant deveria rodar e a maioria nunca roda, porque ele faz uma pergunta que os testes de happy-path não conseguem.

O happy path pergunta: o tenant A consegue ler os dados do tenant A? Claro que consegue; essa é a feature. A pergunta que de fato testa isolamento é a hostil: enquanto o tenant A está conectado e autenticado, ele consegue alcançar uma única linha do tenant B? Você escreve o probe para tentar, mesmo lifecycle de connection, mesmo pool, tudo, e você assertz que volta vazio. Não “deveria estar vazio.” Vazio, provado, como um teste que falha alto no dia em que alguém adiciona o path que lê antes de setar.

Quando esse probe está faltando, o isolamento é uma suposição. Quando ele está presente e verde sob concorrência real, o isolamento é uma propriedade para a qual você pode apontar. A diferença entre esses dois estados é a diferença inteira entre “acreditamos que os tenants estão isolados” e “temos um teste que quebra no momento em que eles não estão.” Este é o failure mode que visita todo time rodando infraestrutura compartilhada através de clientes, e a disciplina que o mata não é uma policy mais inteligente, é um teste adversarial que assume o vazamento e força o sistema a provar que ele não pode.

A lição generaliza para além de bancos de dados. Em qualquer lugar onde identidade e ação têm tempos de vida separados, um auth token cacheado, um user thread-local, um request context reusado através de uma fronteira async, a mesma costura se abre, e a mesma correção a fecha: vincule a identidade à menor unidade de trabalho e deixe-a morrer ali, então escreva o teste que tenta cruzar a linha de propósito.

A virada: isolamento é uma promessa, e promessas são mantidas por pessoas

Coloque as transações e as policies de lado, e o que sobra é a coisa mais antiga em software que lida com dados de outras pessoas.

Uma fronteira de tenant é uma promessa. Quando uma empresa coloca os registros dos clientes dela no seu sistema, ela está confiando que os dados dela e os dados da próxima empresa nunca vão se tocar, e essa confiança não é estabelecida por uma feature estar ligada. Ela é estabelecida por um engenheiro que se recusou a acreditar no teste verde, que perguntou “mas o que acontece no path que lê antes de setar,” que escreveu o probe hostil que tenta cruzar a linha para que o sistema possa provar, em todo build, que ele não pode. Row-level security é uma ferramenta. A promessa é mantida pela pessoa que trata uma policy limpa como o começo da pergunta, não o fim dela.

Esse instinto, de desconfiar do verde confortável, de ir procurar a costura, é a parte que você não consegue instalar. Um banco de dados vai impor qualquer regra que você der a ele, perfeitamente, incluindo a errada. O que ele não consegue fazer é se importar se a regra está fazendo a pergunta certa. A brecha vive na ponte de identidade, não na policy, e alguém tem que ser paranoico sobre a ponte.


É isso que estamos construindo na Apollo Space: um sistema operacional AI-native onde o trabalho de muitos tenants roda lado a lado e a fronteira entre eles é algo que podemos provar, não algo que esperamos que segure. Se você já encarou uma policy correta que ainda deixou a linha errada passar, você já sabe que o trabalho de verdade nunca foi escrever a regra, foi se recusar a confiar nela até algo tentar quebrá-la e falhar.

A Apollo cuida da operação repetitiva da sua empresa pro seu time não precisar.

Entre na lista de espera: acesso antecipado, preço de usuário fundador e um lugar na primeira fila enquanto a gente constrói.

Entrar na lista de espera