Multi-tenant single-table com RLS
O modelo é o de tabela única: uma coluna tenant_id em cada tabela de negócio, um
banco só, um schema só. A alternativa — schema ou banco por tenant — não é o padrão da
Basis e não está coberta aqui.
A ideia central, e a que mais se erra: filtro por tenant escrito na aplicação é
verificação, não isolamento. Ele depende de o desenvolvedor lembrar, em todo findById,
todo UPDATE, toda consulta derivada nova. A RLS do Postgres é o isolamento de verdade,
porque o banco recusa a linha independentemente do que o código pediu — mas ela só funciona
sob três condições que quase nunca estão presentes por default, e falha em silêncio quando
não estão. Silêncio é o problema: RLS mal configurada não dá erro, ela simplesmente não faz
nada, e o teste que "passa" é o mesmo que passaria sem policy nenhuma.
Quando usar
Alguém diz: "corrigir isolamento multi-tenant (RLS bypass e leak cross-tenant)", "esse endpoint aceita tenantId no path", "a consulta voltou vazia e não devia", "o Spring Data JDBC não suporta chave composta". Ou vai criar uma tabela nova e pergunta qual migration copiar.
Os quatro hábitos que resolvem
1. Desconfie de RLS que ninguém provou. ENABLE ROW LEVEL SECURITY mais CREATE POLICY
é o que se vê no diff, e não basta. A pergunta a fazer não é "tem policy?", é "com que role
a aplicação conecta, e essa role é dona da tabela?".
2. Prove o isolamento conectando, não lendo o schema. Duas sessões psql com
app.current_tenant diferente, um SELECT e um INSERT cruzados. Trinta segundos, e é a
única evidência que vale.
3. Trate tenant_id vindo do cliente como entrada hostil. Path param, query param e
campo de DTO são todos controlados por quem chama. O tenant vem do token, sempre.
4. Prefira eliminar a classe de bug a lembrar de evitá-la. É o que a chave composta faz — ver §6.
1. A role da aplicação não pode ser dona das tabelas
O Postgres não aplica policy de RLS ao dono da tabela. Não é configuração, é o
comportamento padrão. Uma aplicação que conecta com a role dona tem isolamento nenhum, por
mais policy que exista — e todas elas continuam aparecendo bonitas no \d.
Nenhum erro é emitido. A consulta simplesmente devolve as linhas de todos os tenants.
Duas roles, com papéis distintos:
| Role | Quem usa | Dona? | Superuser? |
|---|---|---|---|
<app> |
Flyway, migrations, manutenção | Sim | Irrelevante, mas costuma ser |
<app>_app |
A aplicação em runtime | Não | Não (rolsuper = f) |
rolsuper = f é condição necessária: superuser também escapa da RLS.
A role vai no docker/postgres/init.sql, que o entrypoint do Postgres roda antes do Flyway.
Os GRANT sobre as tabelas ficam na migration de baseline, que é quem sabe quais tabelas
existem — com um ALTER DEFAULT PRIVILEGES cobrindo as futuras, para que migration de
tabela nova não precise repetir GRANT nenhum.
FORCE ROW LEVEL SECURITY é defesa em profundidade, não a correção. Ele faz o próprio
dono ser filtrado, o que protege contra alguém apontar a aplicação ou um script de
manutenção para a role dona por engano. Mas a correção é a role não-dona; FORCE sem ela
apenas move o problema.
2. A policy precisa da forma missing_ok
CREATE POLICY tenant_isolation_<tabela> ON <tabela>
USING (tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::bigint);
O segundo argumento (missing_ok = true) não é detalhe de estilo. Sem ele,
current_setting lança erro quando a variável não foi definida — e isso acontece de
verdade, no fluxo de login: antes de autenticar não existe tenant no contexto, então os
métodos marcados para pular o filtro deixam a variável indefinida. Com a forma sem
missing_ok, o login quebra com erro de SQL no dia em que a RLS passa a valer.
O NULLIF(..., '') cobre o caso da variável definida como string vazia. Com os dois, o
resultado é NULL, a comparação é falsa e a linha não aparece — que é o comportamento
desejado: zero linhas, não exceção.
Contar policies não diz nada
Uma policy pode existir e não isolar:
CREATE POLICY usuario_perfil_all ON usuario_perfil USING (true);
\d mostra a policy. Qualquer verificação que conte policies devolve 1. E ela permite
tudo. Inspecione a expressão, não a quantidade — o predicado precisa mencionar
tenant_id.
Vale entender por que essa policy nasce, porque a causa costuma ser estrutural: uma tabela de
ligação que guarda só usuario_id não tem coluna de tenant para o predicado usar. Quem
precisa habilitar RLS nela escreve USING (true) como paliativo, e fica. A correção não é na
policy, é no schema — a chave estrangeira precisa carregar o tenant junto:
CONSTRAINT fk_usuario_perfil_usuario FOREIGN KEY (usuario_tenant_id, usuario_id)
REFERENCES usuario(tenant_id, id) ON DELETE CASCADE
Com a coluna, o predicado existe. É um dos ganhos da chave composta (§6): ela propaga o tenant pelas chaves estrangeiras, e é isso que torna tabela de ligação isolável.
A policy pré-autenticação é um buraco deliberado
O fluxo de login precisa ler usuario antes de existir tenant. A saída usual é uma segunda
policy:
CREATE POLICY pre_auth_usuario ON usuario
USING (NULLIF(current_setting('app.current_tenant', true), '') IS NULL);
Policies permissivas se somam por OR. Isso significa que, com o tenant indefinido, a tabela inteira fica visível — e não só para o login: para todo caminho de código que simplesmente esqueceu de abrir transação ou de definir o contexto. O modo de falha de §3 deixa de devolver zero linhas e passa a devolver tudo, em silêncio.
Duas providências, e a segunda é a que importa:
- Restrinja a policy ao mínimo:
FOR SELECTapenas, e nas tabelas do caminho de autenticação apenas (usuario,passkey,email_verification_code). - Considere
AS RESTRICTIVEpara o predicado de tenant. Policies restritivas se somam por AND, então a permissiva de pré-autenticação deixa de poder abrir sozinha a tabela.
Toda policy pré-autenticação é dívida de segurança consciente. Ela precisa estar listada no
AGENTS.md, com a justificativa, para não ser copiada para uma tabela onde não faz sentido.
3. O contexto de tenant é transaction-local
O aspecto que define o tenant emite:
jdbcTemplate.queryForObject("SELECT set_config('app.current_tenant', ?, true)", String.class, tenantId);
O terceiro argumento true significa is_local: o valor vale até o fim da transação
corrente. Sem uma transação aberta, cada statement é sua própria transação — o
set_config vale para si mesmo e evapora antes da consulta seguinte rodar.
O sintoma é cruel porque parece bug de dado: a consulta volta vazia, o registro existe, o
tenant está certo. No ponto foi exatamente isso — dois métodos de leitura sem
@Transactional faziam um endpoint pré-autenticação responder 400 com erro de SQL, e o
endpoint estava quebrado no develop sem ninguém ter notado.
Regra: todo método de serviço que toca repositório em app multi-tenant é
@Transactional, inclusive os de leitura, inclusive os de um findBy só. Não é rigor
cerimonial: sem a transação, o isolamento não existe.
O jeito de garantir isso sem depender de memória está na basis-spring-app §4:
@Transactional(readOnly = true) na classe e override explícito nos métodos de escrita.
Em app multi-tenant esse padrão deixa de ser boa prática e vira requisito de segurança —
é a diferença entre "o desenvolvedor lembrou" e "a classe já vem transacional".
E aqui a aposta é assimétrica: esquecer o @Transactional num método de leitura custa uma
consulta vazia, ou, nas tabelas com policy pré-autenticação (§2), todas as linhas de todos
os tenants. Esquecer o override num método de escrita custa uma exceção no primeiro teste.
Errar para o lado do readOnly é sempre o erro barato.
4. Copie a migration certa, não a mais recente
O reflexo de quem vai criar uma tabela é abrir a migration de CREATE TABLE mais nova e
copiar. Isso reintroduz vazamento sempre que a mais nova for justamente a que esqueceu a
RLS — e é o que aconteceu no ponto: a V3 criou configuracao_tenant sem policy
nenhuma, meses depois de a V1 ter feito certo.
Duas providências, porque a primeira sozinha não segura:
- Um modelo declarado — no
AGENTS.mddo projeto, dizendo qual arquivo copiar pelo nome. "A mais recente" não é instrução, é sorte. - Um teste que varre o schema, afirmando que toda tabela com coluna
tenant_idtemrelrowsecurityerelforcerowsecurityverdadeiros. É o único mecanismo que pega a tabela nova sem policy antes de ela chegar em produção. Revisão humana não pega — a ausência de quatro linhas de SQL num diff de duzentas não salta.
Quando as migrations antigas já divergiram a ponto de não se conseguir deduzir o schema final lendo em ordem — uma desfazendo a outra, uma esquecendo RLS —, consolide em baseline enquanto o sistema só existe em dev. Depois de estar em produção essa porta fecha.
As tabelas de infraestrutura que carregam tenant_id
A varredura precisa cobrir toda tabela com tenant_id, não as que o time lembra como
"de negócio". O caso concreto é a event_publication do Spring Modulith.
Ela é criada pelo Flyway — o schema-initialization do Modulith está desligado, como manda
a basis-spring-app §4 — e ficou deliberadamente de fora da RLS, com a justificativa escrita
na própria migration:
-- Sem RLS: é infraestrutura, e o tenant_id aqui é apenas informacional.
A decisão é defensável e a justificativa está incompleta, e é essa a lição. Ela responde
pela coluna e não responde pelo conteúdo: serialized_event guarda o payload do evento de
domínio, em texto, de todos os tenants, legível por qualquer conexão da aplicação. Se o
evento carrega CPF, salário ou e-mail, o dado atravessa a fronteira que todas as outras
tabelas respeitam — e atravessa numa tabela que ninguém pensa em auditar.
A regra que sai daqui: decisão de deixar tabela fora da RLS se justifica pelo conteúdo que ela guarda, nunca pelo papel que ela cumpre. "É infraestrutura" descreve a função, não o dado. Ou a tabela entra na RLS, ou a migration explica por que o payload pode ser lido por todos os tenants — e essa frase é bem mais difícil de escrever, que é justamente o ponto.
Para event_publication especificamente, a saída barata costuma ser não deixar dado sensível
no payload: evento carrega identificador, o consumidor busca o resto já sob RLS.
Varra por coluna, não por lista. Foi assim que este caso apareceu, num banco onde o isolamento tinha acabado de ser declarado provado ponta a ponta — a verificação da sessão percorreu as tabelas de negócio que o time enumerou, e essa não estava entre elas.
O inverso também merece olhada: tabela isolada sem ter tenant_id, por policy que passa
por join. Ela não aparece na varredura por coluna, e por isso o script imprime também a
lista das tabelas sem tenant_id — para leitura humana, item a item.
5. FORCE depois do seed, e o que isso faz com DML futuro
Com FORCE ativo o dono passa a ser filtrado. Os INSERT de seed do Flyway rodam como a
role dona e sem app.current_tenant definido — seriam rejeitados pelas próprias policies.
A ordem que resolve, sem gambiarra:
V<ts>0__baseline_schema.sql DDL, GRANT, ENABLE RLS + policies
V<ts>1__seed_dados_iniciais.sql DML, ainda sem FORCE
V<ts>2__force_row_level_security.sql FORCE em todas as tabelas de tenant
Consequência permanente, e a parte que se esquece: toda migration futura que faça DML em
tabela com tenant_id roda depois do FORCE e será filtrada. Ela precisa definir o tenant
antes:
SELECT set_config('app.current_tenant', '1', false);
UPDATE funcionario SET ...;
Note o false — aqui se quer valor de sessão, não transaction-local. DDL (CREATE/ALTER TABLE) não é afetado por RLS e não precisa disso.
6. Chave primária composta — o que elimina a classe de bug
Com PRIMARY KEY (tenant_id, id) e o id mapeado como composto, "esqueci de filtrar por
tenant" deixa de ser possível. Não é mitigação: é impossível pedir um registro sem dizer
de qual tenant, porque a assinatura do método exige os dois.
Todo o padrão defensivo que se escreve sem isso — o carregar(tenantId, id) privado que
refaz findById(id).filter(e -> e.getTenantId().equals(tenantId)) em todo serviço — existe
apenas porque findById aceita um Long sem tenant.
O Spring Data JDBC 4 suporta isso nativamente, via @Id @Embedded.Nullable num record:
public record TenantAwareId(
@Column("tenant_id") Long tenantId,
@Column("id") Long id) {
}
repository.findById(new TenantAwareId(tenantId, id)) emite WHERE tenant_id = ? AND id = ?.
Conhecimento negativo, e ele custou tempo: existe a crença de que Spring Data JDBC não
lida com chave composta, e ela aparece até em comentário de migration — "índice separado em
(id), porque o Spring Data JDBC emite WHERE id = ? e a PK composta não atende esse
acesso". A afirmação está errada no enquadramento. O framework suporta; o que força o
WHERE id = ? é o projeto ter mapeado o id como Long simples. Apresentado como limitação
da ferramenta, o problema fica sem solução; enunciado como escolha de mapeamento, tem.
O índice extra em (id) é o sintoma dessa escolha. Com o id composto adotado, ele some — a
própria PK atende o acesso.
O custo, medido e não estimado
Adotar não é gratuito, e a conta precisa ser feita antes, porque a parte cara não é a
entidade, é o contrato do Persistable:
O custo depende quase inteiramente de uma decisão de desenho, e é ela que separa "refatoração de uma tarde" de "projeto próprio".
Persistable<TenantAwareId> obriga getId() a devolver o composto — o nome é do contrato da
interface, não dá para escolher. O reflexo é propagar o tipo novo para todo chamador. A saída
é delegar: manter na superclasse getTenantId(), setId(Long) e setTenantId(Long) como
acessores sobre a chave, e dar nome novo só ao que mudou de tipo (getIdValue() para o número
cru). Assim só getId() muda de assinatura.
| Abordagem | Pontos de código a alterar |
|---|---|
Propagar TenantAwareId para todos os acessores |
~117 |
| Delegar, renomeando só o getter do id cru | ~22 |
Medido no ponto, depois de feito. O resto da conta:
| Frente | Tamanho |
|---|---|
Entidades, repositórios, findById fora deles |
6 · 8 · 8 |
| Mappers MapStruct | 4, com @Mapping explícito nos dois sentidos |
| Consultas derivadas | findByTenantId(Long) deixa de resolver — tenantId vira pk.tenantId |
| Hierarquia de entidade | Id simples e id composto não convivem na mesma superclasse; entidades globais ficam na antiga |
| Geração de id | isNew por id == null para de valer; exige BeforeConvertCallback e marcação explícita |
| Tabelas de ligação | Ganham a coluna de tenant do lado dono — e com ela a possibilidade de ter policy |
Para estimar antes: conte getId() filtrando por entidade multi-tenant. A conta por
findById() subestima; a conta por getId() bruto superestima. Se o número mudar no meio,
devolva a decisão a quem aprovou — aprovação sobre número errado é decisão não tomada.
As seis armadilhas de execução — validação no record, construtor de persistência, coluna nas
tabelas de ligação, asserção que compila e falha, compilação incremental que mente, @Query
que fica incompleta em silêncio — estão em
references/chave-composta.md.
Quando decidir
Antes de o padrão se espalhar. Se novas entidades forem escritas com @Id Long e o
projeto migrar depois, elas são reescritas inteiras — entidade, repositório, serviço e
testes. Em projeto com gente entrando, o código existente é o que vai ser copiado.
O pré-requisito que quase sempre falta
Migração de id composto quebra na camada de mapeamento objeto-relacional. Suíte só de teste unitário com repositório mockado não exercita essa camada — 322 testes verdes não dizem nada sobre isso. Antes de migrar: teste de integração com Testcontainers que, para cada entidade, salva, busca por id, lista por tenant e confirma o isolamento. Sem essa rede, a única verificação é subir a aplicação e clicar em tela.
7. O tenant vem do token, nunca do cliente
Duas formas do mesmo bug, ambas encontradas no mesmo projeto:
- Endpoint que aceita
tenantIdcomo path param e usa esse valor para consultar. - Resource que grava com
dto.tenantId(), do corpo da requisição.
Com RLS de verdade nas duas pontas, pedir o tenant alheio devolve [] e escrever no tenant
alheio dá new row violates row-level security policy. Sem RLS, os dois são leitura e
escrita cross-tenant com o carimbo da aplicação.
Corrigir os dois no código continua sendo necessário — a RLS é a rede, não a licença para
escrever errado. Endpoint que recebe tenantId do cliente deve devolver 403, não []: o
silêncio esconde a tentativa.
Não hardcode current_setting na consulta do repositório. Aparece como
tenant_id = (SELECT current_setting('app.current_tenant')::bigint) escrito à mão, e é
duplicação do que a policy já faz — com a forma sem missing_ok, ainda por cima, que
explode no fluxo pré-autenticação. Filtro por tenant é responsabilidade da policy.
8. Provar o isolamento
scripts/verificar-isolamento.sh <container> <db> <role-dona> <role-app>
Somente leitura. Reconcilia numa tela só as quatro coisas que precisam ser verdadeiras ao
mesmo tempo: role da app não é dona, não é superuser, toda tabela com tenant_id tem rls
e forced, e existe policy. É a checagem que a leitura de schema não dá, porque cada peça
isolada parece certa.
A prova de comportamento, depois, é manual e vale por si:
| Cenário | Esperado |
|---|---|
App role sem app.current_tenant |
0 linhas, sem erro |
SET app.current_tenant='1' |
só as linhas do tenant 1 |
| Tenant 1 pedindo registro do tenant 2 | 0 linhas |
INSERT com tenant_id alheio |
new row violates row-level security policy |
Erro em vez de zero linhas na primeira: policy sem missing_ok. Linhas de todos os tenants:
a app está conectando como dona, ou como superuser.
Separar "a policy filtrou" de "a consulta pediu certo"
Quando um registro não aparece, as duas causas dão o mesmo 404: a RLS barrou, ou a consulta
pediu o par (tenant, id) e ele não existe. São situações opostas — uma é a proteção
funcionando, a outra é a aplicação buscando o que não é dela.
Ligue o log de SQL e leia o predicado emitido:
ALTER SYSTEM SET log_statement = 'all';
SELECT pg_reload_conf();
WHERE "funcionario"."id" = $1 AND "funcionario"."tenant_id" = $2
Com os dois predicados, o tenant entrou na consulta, e não só na policy — que é o alvo do §6. Só o primeiro significa que a aplicação continua pedindo por id solto e a segurança depende inteiramente da RLS estar correta naquele instante.
Desligue depois: log_statement = 'all' registra valor de parâmetro.
Assinatura — sintoma e causa
| Observação | Causa |
|---|---|
Policies existem, \d mostra tudo certo, e vazam todos os tenants |
App conecta como dona da tabela, ou como superuser |
| Consulta volta vazia com dado existente e tenant certo | set_config(..., true) sem transação aberta — método sem @Transactional |
| Erro de SQL em endpoint pré-autenticação, 400 | current_setting sem missing_ok com a variável indefinida |
INSERT do Flyway rejeitado por policy |
Migration de DML rodando depois do FORCE sem set_config |
| Tabela nova vaza, as antigas não | Migration copiada da mais recente, que era a sem RLS |
| Tudo passa em teste e vaza em runtime | Suíte só unitária com repositório mockado — não exercita a camada onde a RLS age |
| A tabela tem policy e mesmo assim vaza | Policy USING (true) — existe, conta, e não filtra |
| Vaza tudo em vez de nada quando o contexto se perde | Policy pré-autenticação permissiva somando por OR |
Tabela de infraestrutura com tenant_id sem policy |
Dispensada da RLS pelo papel ("é infraestrutura") em vez de pelo conteúdo |
O que engana
O \d da tabela. Mostra as policies e não mostra quem conecta. A policy pode estar
perfeita e valer para ninguém.
O teste verde. Repositório mockado não tem RLS. A suíte inteira passa com o isolamento desligado, o que faz dela evidência de nada nesse assunto.
A migration mais recente. É o modelo que todo mundo copia e o que ninguém verificou.
A contagem de policies. USING (true) conta como policy. Verificação que soma linha de
pg_policies dá verde numa tabela aberta.
A lista de tabelas que o time enumera. Sai sempre com as de negócio e sem as de
infraestrutura. Varra o catálogo por coluna tenant_id.
"É só informacional". Dito de uma coluna tenant_id, costuma ser verdade — e não
responde pelo resto da linha. A pergunta é o que mais a tabela guarda.
Um SELECT que volta vazio. Parece dado ausente e costuma ser contexto de tenant
perdido. Antes de investigar o dado, rode SELECT current_setting('app.current_tenant', true)
na mesma transação.
spring-boot-docker-compose no classpath. Ele publica um JdbcConnectionDetails que
tem precedência sobre spring.datasource.*, então a aplicação conecta onde o starter
mandou e não onde o yaml diz — o que impede apontar para a role não-dona e mascara erro de
configuração do datasource. Em app multi-tenant, remova.
Checklist para tabela nova com tenant_id
-
tenant_id BIGINT NOT NULLcom FK paratenant -
ENABLE ROW LEVEL SECURITYeFORCE ROW LEVEL SECURITY - Policy na forma
NULLIF(current_setting('app.current_tenant', true), '')::bigint - O predicado da policy menciona
tenant_id—USING (true)não vale - Sem policy pré-autenticação, a menos que a tabela esteja no caminho de login; se
estiver,
FOR SELECTe registrada noAGENTS.md - Unicidade de negócio composta com o tenant (
UNIQUE (tenant_id, nome)), nunca só a coluna - Sem
GRANTrepetido, se oALTER DEFAULT PRIVILEGESda baseline cobre - Se a migration tiver DML e rodar depois do
FORCE, temset_configantes - Classe de serviço com
@Transactional(readOnly = true)e override explícito nas escritas - Nenhum
tenantIdlido de path, query ou corpo
Scripts
| Script | Muta? | Uso |
|---|---|---|
scripts/verificar-isolamento.sh |
Não | Reconcilia role, ownership, superuser, RLS e policies numa tela |
References
references/rls-postgres.md— mecânica do Postgres: dono versusFORCE, formas decurrent_setting,USINGversusWITH CHECK,GRANTeALTER DEFAULT PRIVILEGES, o que RLS não cobre.references/chave-composta.md— id composto no Spring Data JDBC: mapeamento,Persistable, callback de geração, mappers, consultas derivadas, e o roteiro de migração com o teste de integração que ela exige.