Pular para o conteúdo
COLMEIA.digital
SaaS & multi-tenant5 min de leitura

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_id em 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);
  • USING aplica em SELECT, UPDATE, DELETE (linhas visíveis).
  • WITH CHECK aplica em INSERT e UPDATE (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:

  1. Adicionar policies em modo permissivo. USING (true) enquanto migra o código para usar withTenant.
  2. Adicionar policy real em paralelo. Comente a antiga, ative a nova, rode integração.
  3. 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

  1. PostgreSQL — Row Security Policies · acessado em 2026-05-18
  2. PostgreSQL — CREATE POLICY · acessado em 2026-05-18
  3. PostgreSQL — set_config / current_setting · acessado em 2026-05-18

Leia também

  1. Arquitetura & escalabilidade

    PostgreSQL 18: I/O assíncrono, UUIDv7 e o que muda em SaaS

  2. Engenharia de software

    Drizzle vs Prisma em 2026: o trade-off real para SaaS TypeScript

  3. SaaS & multi-tenant

    Stripe Sessions 2026: 288 lançamentos, streaming payments e Agentic Commerce