A segurança ao nível de linha (RLS) do PostgreSQL para SaaS ajuda a reforçar o isolamento de clientes/organizações no banco de dados. Saiba quando usar, como escrever políticas e o que evitar.

Em um app SaaS, o bug de segurança mais perigoso é o que aparece depois que você escala. Você começa com uma regra simples do tipo “usuários só podem ver dados do seu tenant”, então lança um novo endpoint rápido, adiciona uma query de relatório ou introduz um join que silenciosamente ignora a checagem.
A autorização só na aplicação falha sob pressão porque as regras acabam espalhadas. Um controller verifica tenant_id, outro verifica membership, um job em background esquece, e um caminho de “admin export” fica “temporário” por meses. Mesmo equipes cuidadosas perdem um ponto.
O row-level security (RLS) do PostgreSQL resolve um problema específico: faz com que o banco force quais linhas são visíveis para uma requisição. O modelo mental é simples: todo SELECT, UPDATE e DELETE é automaticamente filtrado por políticas, do mesmo modo que todo pedido é filtrado pelo middleware de autenticação.
O “rows” importa. RLS não protege tudo:
Um exemplo concreto: você adiciona um endpoint que lista projetos com um join em invoices para um dashboard. Só na aplicação é fácil filtrar projects por tenant e esquecer de filtrar invoices, ou fazer um join numa chave que cruza tenants. Com RLS, ambas as tabelas podem reforçar o isolamento por tenant, de modo que a query falhe de forma segura em vez de vazar dados.
O trade-off é real. Você escreve menos código de autorização repetido e reduz os pontos que podem vazar. Mas também assume trabalho novo: precisa desenhar políticas com cuidado, testá-las cedo e aceitar que uma política pode bloquear uma query que você esperava que funcionasse.
RLS pode parecer trabalho extra até seu app passar de um punhado de endpoints. Se você tem limites rígidos entre tenants e muitos caminhos de consulta (telas de listagem, busca, exports, ferramentas admin), colocar a regra no banco significa que você não precisa lembrar de adicionar o mesmo filtro em todo lugar.
RLS é uma boa escolha quando a regra é chata e universal: “um usuário só vê linhas do seu tenant” ou “um usuário só vê projetos dos quais é membro”. Nesses cenários, políticas reduzem erros porque todo SELECT, UPDATE e DELETE passa pelo mesmo portão, mesmo quando uma query é adicionada depois.
Também ajuda em apps com leitura intensiva onde a lógica de filtragem é consistente. Se sua API tem 15 maneiras diferentes de carregar invoices (por status, data, cliente, busca), RLS permite parar de reimplementar o filtro por tenant em cada consulta e focar na funcionalidade.
RLS complica quando as regras não são baseadas em linhas. Regras por campo como “você pode ver salário mas não bônus” ou “mascarar esta coluna a menos que você seja RH” viram SQL esquisito e exceções difíceis de manter.
Também é um ajuste ruim para relatórios pesados que genuinamente precisam de acesso amplo. Equipes costumam criar roles de bypass para “só este job”, e é aí que erros se acumulam.
Antes de se comprometer, decida se você quer que o banco seja o portão final. Se sim, planeje a disciplina: teste o comportamento do banco (não só respostas da API), trate migrations como mudanças de segurança, evite bypasses rápidos, decida como jobs em background se autenticam e mantenha políticas pequenas e repetíveis.
Se você usa ferramentas que geram backends, isso pode acelerar a entrega, mas não elimina a necessidade de roles claras, testes e um modelo de tenant simples. (Por exemplo, Koder.ai usa Go e PostgreSQL para backends gerados, e você ainda quer desenhar RLS deliberadamente em vez de “salpicar depois”.)
RLS é mais fácil quando seu esquema já diz claramente quem possui o quê. Se você começa com um modelo nebuloso e tenta “consertar com políticas”, normalmente ganha queries lentas e bugs confusos.
Escolha uma chave de tenant (como org_id) e use-a consistentemente. A maioria das tabelas owned por tenant deve tê-la, mesmo que também referenciem outra tabela que a tenha. Isso evita joins dentro de políticas e mantém checagens USING simples.
Uma regra prática: se uma linha deve desaparecer quando um cliente cancela, provavelmente precisa de org_id.
Políticas RLS geralmente respondem a uma pergunta: “Este usuário é membro desta org, e o que ele pode fazer?” Isso é difícil de inferir a partir de colunas ad hoc.
Mantenha as tabelas centrais pequenas e simples:
users (uma linha por pessoa)orgs (uma linha por tenant)org_memberships (user_id, org_id, role, status)project_memberships para acesso por projetoCom isso, suas políticas podem checar membership com uma única busca indexada.
Nem tudo precisa de org_id. Tabelas de referência como countries, categorias de produto ou tipos de plano frequentemente são compartilhadas entre tenants. Torne-as somente-leitura para a maior parte das roles e não as vincule a uma org.
Dados owned por tenant (projetos, invoices, tickets) devem evitar puxar detalhes específicos de tenant através de tabelas compartilhadas. Mantenha tabelas compartilhadas mínimas e estáveis.
Foreign keys continuam funcionando com RLS, mas deletes podem surpreender se a role que deleta não consegue “ver” linhas dependentes. Planeje cascades com cuidado e teste fluxos reais de delete.
Indexe as colunas que suas políticas filtram, especialmente org_id e chaves de membership. Uma política que parece WHERE org_id = ... não deve virar um table scan quando a tabela atingir milhões de linhas.
RLS é um interruptor por tabela. Uma vez habilitado, o PostgreSQL para de confiar que seu código aplicacional lembra do filtro de tenant. Todo SELECT, UPDATE e DELETE é filtrado por políticas, e todo INSERT e UPDATE é validado por políticas.
A maior mudança mental: com RLS ligado, queries que antes retornavam dados podem começar a retornar zero linhas sem erro. Isso é o PostgreSQL fazendo controle de acesso.
Políticas são regras pequenas atreladas a uma tabela. Elas usam dois checks:
USING é o filtro de leitura. Se uma linha não bater com o USING, ela fica invisível para SELECT e não pode ser alvo de UPDATE ou DELETE.WITH CHECK é o portão de escrita. Decide que novas ou alteradas linhas são permitidas para INSERT ou UPDATE.Um padrão comum em SaaS: USING garante que você só veja linhas do seu tenant, e WITH CHECK garante que você não consiga inserir uma linha no tenant de outra pessoa chutando um tenant_id.
Quando você adiciona mais políticas depois, isso importa:
PERMISSIVE (padrão): uma linha é permitida se qualquer política permitir.RESTRICTIVE: uma linha é permitida somente se todas as políticas restritivas permitirem (além do comportamento permissive).Se você planeja empilhar regras como conferência de tenant + checagem de role + membership de projeto, políticas restrictive podem clarificar a intenção, mas também tornam mais fácil se trancar fora se você esquecer uma condição.
RLS precisa de um valor confiável “quem está chamando”. Opções comuns:
app.user_id e app.tenant_id).SET ROLE ...) por requisição, que pode funcionar mas adiciona overhead operacional.Escolha uma abordagem e aplique-a em todos os lugares. Misturar fontes de identidade entre serviços é caminho rápido para bugs confusos.
Use uma convenção previsível para que dumps de schema e logs permaneçam legíveis. Por exemplo: {table}__{action}__{rule}, como projects__select__tenant_match.
Se você é novo em RLS, comece com uma tabela e uma prova pequena. O objetivo não é cobertura perfeita. O objetivo é fazer o banco recusar acesso cross-tenant mesmo quando houver um bug na app.
Assuma uma tabela simples projects. Primeiro, adicione tenant_id de forma que não quebre gravações.
ALTER TABLE projects ADD COLUMN tenant_id uuid;
-- Backfill existing rows (example: everyone belongs to a default tenant)
UPDATE projects SET tenant_id = '11111111-1111-1111-1111-111111111111'::uuid
WHERE tenant_id IS NULL;
ALTER TABLE projects ALTER COLUMN tenant_id SET NOT NULL;
Em seguida, separe propriedade de acesso. Um padrão comum é: uma role que é dona das tabelas (app_owner) e outra usada pela API (app_user). A role da API não deve ser dona das tabelas, ou ela pode burlar políticas.
ALTER TABLE projects OWNER TO app_owner;
REVOKE ALL ON projects FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE, DELETE ON projects TO app_user;
Agora decida como a requisição informa ao Postgres qual tenant está servindo. Uma abordagem simples é um setting com escopo de requisição. Sua app o define logo após abrir uma transação.
-- inside the same transaction as the request
SELECT set_config('app.current_tenant', '22222222-2222-2222-2222-222222222222', true);
Habilite RLS e comece pelo acesso de leitura.
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY projects_tenant_select
ON projects
FOR SELECT
TO app_user
USING (tenant_id = current_setting('app.current_tenant')::uuid);
Prove que funciona tentando dois tenants diferentes e verificando que a contagem de linhas muda.
Políticas de leitura não protegem escritas. Adicione WITH CHECK para que inserts e updates não contrabandeiem linhas para o tenant errado.
CREATE POLICY projects_tenant_write
ON projects
FOR INSERT, UPDATE
TO app_user
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);
Uma forma rápida de verificar o comportamento (incluindo falhas) é manter um pequeno script SQL que você possa repetir após cada migration:
BEGIN; SET LOCAL ROLE app_user;SELECT set_config('app.current_tenant', '\u003ctenant A\u003e', true); SELECT count(*) FROM projects;INSERT INTO projects(id, tenant_id, name) VALUES (gen_random_uuid(), '\u003ctenant B\u003e', 'bad'); (deve falhar)UPDATE projects SET tenant_id = '\u003ctenant B\u003e' WHERE ...; (deve falhar)ROLLBACK;Se você consegue rodar esse script e obter resultados consistentes sempre, tem uma baseline confiável antes de expandir RLS para outras tabelas.
A maioria das equipes adota RLS depois de cansar de repetir as mesmas checagens de autorização em cada query. A boa notícia é que os formatos de política que você precisa costumam ser consistentes.
Algumas tabelas são naturalmente owned por um usuário (notes, API tokens). Outras pertencem a um tenant onde o acesso depende de membership. Trate esses padrões de forma diferente.
Para dados de owner, políticas frequentemente checam created_by = app_user_id(). Para dados de tenant, políticas checam se o usuário tem uma linha de membership para a org.
Uma forma prática de manter políticas legíveis é centralizar identidade em helpers SQL pequenos e reusá-los:
-- Example helpers
create function app_user_id() returns uuid
language sql stable as $$
select current_setting('app.user_id', true)::uuid
$$;
create function app_is_admin() returns boolean
language sql stable as $$
select current_setting('app.is_admin', true) = 'true'
$$;
Leituras costumam ser mais amplas que escritas. Por exemplo, qualquer membro da org pode SELECT projetos, mas só editores podem UPDATE, e apenas proprietários podem DELETE.
Mantenha explícito: uma política para SELECT (membership), uma para INSERT/UPDATE com WITH CHECK (role) e uma para DELETE (frequentemente mais rígida que update).
Evite “desligar o RLS para admins”. Em vez disso, adicione uma saída dentro das políticas, como app_is_admin(), para não conceder acidentalmente acesso total a uma role de serviço compartilhada.
Se você usa deleted_at ou status, inclua isso na política de SELECT (deleted_at is null). Caso contrário, alguém pode “ressuscitar” linhas alterando flags que a app assumia como finais.
WITH CHECK amigávelINSERT ... ON CONFLICT DO UPDATE deve satisfazer WITH CHECK para a linha após a escrita. Se sua política exige created_by = app_user_id(), garanta que o upsert defina created_by no insert e não o sobrescreva no update.
Se você gera código backend, esses padrões valem a pena virar templates internos para que novas tabelas iniciem com defaults seguros em vez de um papel em branco.
RLS é ótimo até um pequeno detalhe fazer parecer que o PostgreSQL está “aleatoriamente” escondendo ou mostrando dados. Os erros abaixo são os que mais consomem tempo.
A primeira armadilha é esquecer WITH CHECK em insert e update. USING controla o que você pode ver, não o que pode criar. Sem WITH CHECK, um bug pode escrever uma linha no tenant errado, e você pode não perceber porque esse mesmo usuário não consegue ler a linha.
Outro vazamento comum é o “join vazante”. Você filtra projects corretamente, depois faz join em invoices, notes ou files que não estão protegidos da mesma forma. A correção é estrita mas direta: toda tabela que possa revelar dados por tenant precisa de sua própria política, e views não devem depender de apenas uma tabela estar segura.
Padrões de falha comuns aparecem cedo:
WITH CHECK para escrita.Políticas que referenciam a mesma tabela (diretamente ou via view) podem criar surpresas de recursão. Uma política pode checar membership consultando uma view que lê a tabela protegida de novo, levando a erros, queries lentas ou uma política que nunca casa.
A configuração de roles é outra fonte de confusão. Donos de tabela e roles elevadas podem burlar RLS, então seus testes passam enquanto usuários reais falham (ou o contrário). Sempre teste com a role de baixo privilégio que sua app usa.
Tenha cautela com funções SECURITY DEFINER. Elas rodam com privilégios do dono da função, então um helper como current_tenant_id() pode ser ok, mas uma função “conveniente” que lê dados pode acidentalmente ler entre tenants, a menos que você a desenhe para respeitar RLS.
Também defina um search_path seguro dentro de funções security definer. Caso contrário, a função pode encontrar um objeto com o mesmo nome em outro schema, e sua lógica de política pode apontar para a coisa errada dependendo do estado da sessão.
Bugs de RLS geralmente são falta de contexto, não “SQL ruim”. Uma política pode estar correta no papel e ainda falhar porque a role de sessão é diferente do que você pensa, ou porque a requisição nunca definiu os valores de tenant e usuário que a política espera.
Uma maneira confiável de reproduzir um relatório de produção é espelhar a mesma configuração de sessão localmente e rodar a query exata. Isso normalmente significa:
SET ROLE app_user; (ou a role real da API)SELECT set_config('app.tenant_id', 't_123', true); e SELECT set_config('app.user_id', 'u_456', true);SELECT current_user, current_setting('app.tenant_id', true), current_setting('app.user_id', true);Quando você não tem certeza de qual política está sendo aplicada, cheque o catálogo em vez de adivinhar. pg_policies mostra cada política, o comando e as expressões USING e WITH CHECK. Combine isso com pg_class para confirmar que o RLS está habilitado na tabela e não está sendo contornado.
Problemas de performance podem parecer problemas de autenticação. Uma política que faz join em membership ou chama uma função pode estar correta mas lenta quando a tabela cresce. Use EXPLAIN (ANALYZE, BUFFERS) na query reproduzida e procure por scans sequenciais, nested loops inesperados ou filtros aplicados tarde. Índices faltantes em (tenant_id, user_id) e nas tabelas de membership são causas comuns.
Também ajuda logar três valores por requisição na camada da app: o tenant ID, o user ID e a role do banco usada. Quando esses não batem com o que você acha que definiu, o RLS se comportará “errado” porque as entradas estão erradas.
Para testes, mantenha alguns tenants seedados e deixe falhas explícitas. Uma pequena suíte costuma incluir: “Tenant A não lê Tenant B”, “usuário sem membership não vê o projeto”, “owner pode atualizar, viewer não”, “insert é bloqueado a menos que tenant_id bata com o contexto”, e “override admin só aplica onde planejado”.
Trate RLS como cinto de segurança, não como um toggle de recurso. Pequenos deslizes viram “todo mundo vê tudo” ou “tudo retorna zero linhas”.
Garanta que o design das tabelas e as regras de política casem com seu modelo de tenant.
tenant_id). Se não tiver, registre o porquê (por exemplo, tabelas de referência globais).FORCE ROW LEVEL SECURITY nessas tabelas.USING. Escritas precisam de WITH CHECK para que inserts e updates não movam uma linha para outro tenant.tenant_id ou fazem joins por membership, adicione os índices correspondentes.Um cenário simples de sanidade: um usuário do tenant A pode ler suas próprias invoices, pode inserir uma invoice apenas para o tenant A e não pode atualizar uma invoice para mudar tenant_id.
RLS é tão forte quanto as roles que sua app usa.
bypassrls.Imagine um app B2B onde empresas (orgs) têm projetos, e projetos têm tasks. Usuários podem pertencer a múltiplas orgs, e um usuário pode ser membro de alguns projetos e não de outros. Esse é um bom caso para RLS porque o banco pode reforçar isolamento por tenant mesmo se um endpoint da API esquecer um filtro.
Um modelo simples é: orgs, users, org_memberships (org_id, user_id, role), projects (id, org_id), project_memberships (project_id, user_id), tasks (id, project_id, org_id, ...). Esse org_id em tasks é intencional. Mantém as políticas simples e reduz surpresas durante joins.
Um vazamento clássico acontece quando tasks só têm project_id e sua política checa acesso via join em projects. Um erro (uma política permissiva em projects, um join que remove uma condição ou uma view que muda contexto) pode expor tasks de outra org.
Um caminho de migração mais seguro evita quebrar tráfego em produção:
org_id a tasks, adicione tabelas de membership).tasks.org_id a partir de projects.org_id, depois adicione NOT NULL.Suporte é geralmente melhor tratado com uma role estreita de break-glass, não desabilitando RLS. Mantenha-a separada de contas normais de suporte e registre explicitamente quando for usada.
Documente as regras para que políticas não divirjam: quais variáveis de sessão devem ser definidas (user_id, org_id), quais tabelas devem carregar org_id, o que “membro” significa e alguns exemplos SQL que devem retornar 0 linhas quando executados como a org errada.
RLS é mais fácil de conviver quando você o trata como uma mudança de produto. Faça rollout em pequenos pedaços, prove comportamento com testes e mantenha um registro claro do porquê de cada política existir.
Um plano de rollout que costuma funcionar:
projects) e bloqueie-a.Depois que a primeira tabela estiver estável, trate mudanças de política como deliberadas. Adicione uma etapa de revisão de políticas nas migrations e inclua uma nota curta sobre a intenção (quem deve acessar o quê e por quê) junto com a atualização dos testes. Isso evita “só adiciona outro OR” que vira um buraco com o tempo.
Se você está se movendo rápido, ferramentas como Koder.ai (koder.ai) podem ajudar a gerar um ponto de partida Go + PostgreSQL via chat, e então você vai acrescentar políticas RLS e testes com a mesma disciplina de um backend feito à mão.
Por fim, mantenha redes de segurança durante o rollout. Faça snapshots antes de migrações de política, pratique rollback até ficar trivial e mantenha um pequeno caminho de break-glass para suporte que não desabilite RLS em todo o sistema.
RLS faz o PostgreSQL impor quais linhas são visíveis ou editáveis por uma requisição, de modo que o isolamento por tenant não dependa de cada endpoint lembrar o WHERE tenant_id = .... O principal benefício é reduzir bugs do tipo “uma verificação esquecida” quando a aplicação cresce e as consultas se multiplicam.
Compensa quando as regras de acesso são consistentes e baseadas em linhas — por exemplo, isolamento por tenant ou acesso baseado em membership — e quando você tem muitos caminhos de consulta (buscas, exports, telas administrativas, jobs). Normalmente não vale a pena se a maior parte das regras for por campo, cheia de exceções, ou dominada por relatórios amplos que precisam ler entre tenants.
Use RLS para visibilidade de linhas e controle básico de escrita; o resto continua com outras ferramentas. Privacidade de colunas costuma exigir views e privilégios de coluna, e regras de negócio complexas (por exemplo, propriedade de cobrança ou fluxos de aprovação) ainda pertencem à lógica da aplicação ou a constraints cuidadosamente desenhadas no banco.
Crie uma role de baixo privilégio para a API (não a dona das tabelas), habilite RLS, depois adicione uma política de SELECT e uma política de INSERT/UPDATE com WITH CHECK. Use um valor de sessão por requisição (por exemplo, app.current_tenant) e verifique que alterná-lo muda quais linhas você vê e pode gravar.
Um padrão comum é uma variável de sessão por requisição, definida no começo da transação, como app.tenant_id e app.user_id. O importante é consistência: todo caminho de código (requisições web, jobs, scripts) deve definir os mesmos valores que as políticas esperam, caso contrário você terá comportamentos “zero rows” confusos.
USING controla o filtro de leitura. Se uma linha não bater com o USING, ela fica invisível para e não pode ser alvo de ou . é o portão de escrita: decide que novas ou alteradas linhas são permitidas em ou . Em resumo: filtra o que você vê; valida o que pode ser escrito.
Porque, se você só adicionar USING, um endpoint com bug ainda pode inserir ou atualizar linhas para outro tenant, e você talvez não note porque o mesmo usuário não conseguirá ler a linha errada. Sempre combine regras de leitura por tenant com um WITH CHECK correspondente para escritas, assim dados incorretos não podem ser criados.
Evite joins dentro de políticas colocando a chave de tenant (por exemplo, org_id) diretamente nas tabelas owned pelo tenant, mesmo que elas referenciem outra tabela que também tenha esse campo. Use tabelas explícitas de membership (org_memberships, e opcionalmente project_memberships) para que a política faça uma única busca indexada em vez de inferências complicadas.
Reproduza o mesmo contexto de sessão que sua aplicação usa: defina a mesma role e as mesmas configurações de sessão, e rode a consulta exata. Depois, confirme que o RLS está habilitado e inspecione pg_policies para ver quais expressões USING e WITH CHECK existem — RLS costuma falhar por falta de contexto de identidade, não por “SQL ruim”.
Sim — trate o código gerado como ponto de partida, não como sistema de segurança pronto. Mesmo se você usar Koder.ai para gerar um backend Go + PostgreSQL, você precisa definir seu modelo de tenant, garantir identidade de sessão consistente e adicionar políticas e testes deliberadamente para que novas tabelas não sejam liberadas sem as proteções certas.
SELECTUPDATEDELETEWITH CHECKINSERTUPDATEUSINGWITH CHECK