Row-Level Security no Postgres: receita para SaaS multi-tenant
RLS resolve isolamento de tenant na fonte. Mas só vira robusto quando o time entende como GUCs, policies e índices interagem. Receita prática, com armadilhas que aparecem em produção.
Resposta atômica: RLS é o mecanismo do Postgres que filtra linhas automaticamente em cada query com base em uma policy. Em SaaS multi-tenant, ele transforma o filtro por
tenant_idem uma propriedade do banco — não da aplicação. Resultado: o vazamento por query mal escrita deixa de existir, e a superfície de revisão de código encolhe.
O problema que RLS resolve
Em SaaS multi-tenant compartilhado (modelo "row-per-tenant"), o filtro WHERE tenant_id = $1 aparece em toda query. Em códigos grandes, esquecer esse filtro uma vez é suficiente para vazar dados de um cliente para outro. ORMs ajudam, mas escondem o problema atrás de scopes implícitos — que outro desenvolvedor pode pular.
RLS muda quem é responsável: o banco passa a impor o filtro. A aplicação fornece o tenant_id via setting; o Postgres aplica a policy.
Receita mínima
1. Tenant em todas as tabelas
CREATE TABLE invoices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
amount numeric(12,2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
Convenção dura: tenant_id é NOT NULL, uuid e a primeira coluna de todo índice composto que você cria.
2. Habilitar RLS
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
FORCE é o que faz RLS valer inclusive para o dono da tabela. Sem ele, um usuário com OWNER ignora a policy.
3. Policy de tenant
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
USINGaplica emSELECT,UPDATE,DELETE(linhas visíveis).WITH CHECKaplica emINSERTeUPDATE(linhas que podem ser gravadas).current_setting('app.tenant_id')lê uma GUC configurada por sessão ou transação.
4. Setar tenant no início da transação
async function withTenant<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {
return db.transaction(async (tx) => {
await tx.execute(sql`SELECT set_config('app.tenant_id', ${tenantId}, true)`);
return fn();
});
}
O terceiro argumento de set_config (true) faz o setting valer apenas para a transação corrente — fundamental em pools, onde a conexão é reutilizada.
5. Usar em toda query autenticada
const invoices = await withTenant(user.tenantId, () =>
db.invoices.findMany({ where: { status: 'open' } }),
);
Note: você não escreve WHERE tenant_id = .... O banco já filtra. Se algum dia alguém escrever uma query crua que omita o filtro, RLS impede o vazamento.
Performance — o ponto que mais quebra
A policy roda em toda query. Três técnicas para manter a performance:
Índice composto começando por tenant_id
CREATE INDEX invoices_tenant_status_idx ON invoices (tenant_id, status, created_at DESC);
Sem isso, queries de leitura precisam varrer toda a tabela e filtrar. Com isso, o Postgres busca direto na partição lógica do tenant.
Expressão de policy simples
A policy do exemplo é igualdade direta — o planner consegue empurrar o filtro para o índice. Subqueries em policies pagam preço por linha. Evite.
tenant_id vem da GUC, não de um JOIN
current_setting('app.tenant_id') é uma chamada O(1). Comparar com tabela de usuários a cada query custa caro.
BYPASSRLS para rotinas administrativas
Migrations, jobs de billing, exports e relatórios cross-tenant precisam ver todas as linhas. Crie um role separado:
CREATE ROLE app_admin LOGIN BYPASSRLS;
Use-o apenas em código com proteção explícita (jobs em workers separados, nunca no caminho de request).
Testar isolamento — antes de produção
test('tenant A não enxerga dados de tenant B', async () => {
await withTenant(TENANT_A, async () => {
await db.invoices.create({ data: { amount: 100, status: 'open' } });
});
const fromB = await withTenant(TENANT_B, () => db.invoices.findMany());
expect(fromB).toEqual([]);
});
test('insert em A com tenant_id de B é bloqueado', async () => {
await expect(
withTenant(TENANT_A, () =>
db.execute(sql`INSERT INTO invoices (tenant_id, amount) VALUES (${TENANT_B}, 1)`),
),
).rejects.toThrow();
});
Armadilhas que aparecem em produção
1. Connection pool sem set_config por transação. Conexões persistem GUCs entre requests. Sem SET LOCAL ou set_config(name, value, true), a primeira request seta o tenant e as próximas herdam por engano.
2. Migrations rodando com role normal. Travam em policies. Crie role app_admin com BYPASSRLS para a pipeline de migration.
3. Sequences compartilhadas. id BIGSERIAL cria uma sequence global. Não tente reiniciar por tenant. Use UUID v7 e siga em frente.
4. Queries em batch que esquecem o withTenant. Workers, listeners de webhook, jobs assíncronos — todos precisam restabelecer o tenant. Crie um helper único e proíba acesso ao DB sem ele.
5. JOIN entre tabela tenant-scoped e tabela compartilhada. Às vezes o planner ignora o índice. Repita o filtro explicitamente quando precisar.
Quando RLS não é suficiente
Se o requisito é isolamento completo de infraestrutura, RLS não é o modelo certo. Use schema por tenant ou cluster por tenant. RLS resolve isolamento lógico sob suposição de Postgres confiável.
Migrar de filtro manual para RLS
Roteiro em três fases:
- Adicionar policies em modo permissivo.
USING (true)enquanto migra o código para usarwithTenant. - Adicionar policy real em paralelo. Comente a antiga, ative a nova, rode integração.
- Endurecer.
ALTER TABLE ... FORCE ROW LEVEL SECURITY. Remova filtros manuais redundantes.
O ganho real
Depois que RLS está consolidado, três coisas mudam no time:
- Code review encolhe.
- Segurança vira regressível.
- Novos engenheiros não vazam dados por engano.
Próximo passo
Pegue uma tabela do seu sistema atual que tem tenant_id e siga a receita: enable RLS, force, policy, índice, helper de transação, testes de integração. Em uma tarde você descobre quantos lugares do seu app ainda dependem de filtro manual.
Fontes citadas
- PostgreSQL — Row Security Policies · acessado em 2026-05-18
- PostgreSQL — CREATE POLICY · acessado em 2026-05-18
- PostgreSQL — set_config / current_setting · acessado em 2026-05-18
Leia também