O row level security (RLS) é uma funcionalidade do PostgreSQL que decide, linha a linha, quais as linhas que um utilizador pode ler ou alterar, e a regra corre dentro da própria base de dados. Numa app Supabase, o RLS é a única coisa que separa as suas tabelas de qualquer visitante que copie a chave pública do seu navegador. Se uma tabela não tem RLS, qualquer pessoa pode lê-la ou editá-la através da API pública.
Este guia é para quem cria apps web com um AI builder e quer perceber o que fazem as políticas geradas, para as poder verificar. Cobre os quatro tipos de política, os padrões habituais, o armazenamento e um teste com duas contas que demora dez minutos.
Porque é que o RLS é obrigatório quando a chave está no navegador
Uma app web Supabase fala com a base de dados diretamente a partir do navegador, por isso o URL do projeto e a chave pública (anon ou publishable) seguem dentro do JavaScript que todos os visitantes descarregam. É assim por conceção: a chave identifica o seu projeto, não o protege. A proteção vem das políticas de cada tabela.
A documentação do Supabase descreve uma política como uma cláusula WHERE adicional em cada consulta, e refere que, depois de o RLS ser ativado numa tabela, nenhum dado fica acessível através da API com a chave pública até criar políticas. O perigo é o inverso: uma tabela no esquema public sem RLS está aberta. Qualquer pessoa pode abrir a consola do navegador, chamar a sua API com a sua própria chave pública e executar select, insert, update ou delete nela.
A chave secreta (service role) é outra coisa. Contorna o RLS, por isso nunca deve aparecer no código do cliente. Os segredos pertencem ao código de servidor, como as edge functions. O controlo de acesso quebrado, em que um utilizador chega a dados que não são seus, é a primeira categoria do OWASP Top 10.
O que acontece sem RLS
Sem RLS, a app continua a funcionar nos seus próprios testes, e é por isso que o problema passa despercebido. Os ecrãs só pedem as suas linhas. A API não quer saber o que os ecrãs pedem.
- Um visitante lista todas as linhas da tabela de encomendas, clientes ou mensagens.
- Um utilizador com sessão iniciada altera o preço, o papel ou o dono de uma linha que pertence a outra pessoa.
- Um script apaga uma tabela inteira, sem sequer ter conta.
Esconder um botão na interface não protege nada, já que a API aceita qualquer pedido que a chave permita. Se está a comparar backends, o nosso guia sobre Supabase vs Firebase explica como cada um trata as regras de acesso.
Os quatro tipos de política: select, insert, update, delete
Uma política aplica-se a uma operação, e cada operação usa uma ou duas condições. USING filtra as linhas que já existem. WITH CHECK valida as linhas que estão a ser escritas. A documentação do Supabase recomenda uma política por operação.
| Operação | Condição usada | O que controla |
|---|---|---|
| SELECT | USING | Que linhas existentes o utilizador pode ler |
| INSERT | WITH CHECK | Se a nova linha é permitida |
| UPDATE | USING e WITH CHECK | Que linhas podem ser editadas e no que se podem tornar. Precisa também de uma política SELECT |
| DELETE | USING | Que linhas podem ser removidas |
Eis um exemplo completo para uma tabela todos em que cada linha tem uma coluna user_id. Primeiro, ative o RLS:
alter table public.todos enable row level security;Depois, uma política por operação, cada uma limitada com a cláusula TO aos utilizadores com sessão iniciada:
create policy "Read own todos"
on public.todos for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "Create own todos"
on public.todos for insert
to authenticated
with check ( (select auth.uid()) = user_id );
create policy "Update own todos"
on public.todos for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
create policy "Delete own todos"
on public.todos for delete
to authenticated
using ( (select auth.uid()) = user_id );Dois detalhes contam. A cláusula TO indica o papel: sem ela, a política é avaliada também para pedidos anónimos (o papel anon). E auth.uid() devolve o ID do utilizador com sessão iniciada, ou null se ninguém estiver autenticado, caso em que a comparação falha em silêncio e nenhuma linha corresponde. Envolver a chamada como (select auth.uid()) permite ao Postgres calculá-la uma vez por instrução em vez de uma vez por linha, o que o Supabase recomenda por desempenho.
Padrão 1: linhas que pertencem a um só utilizador
O padrão por utilizador é uma coluna user_id preenchida com o ID do utilizador autenticado, mais as quatro políticas acima. Serve para notas, tarefas, perfis, definições e qualquer dado privado. Um bom valor por defeito para a coluna é auth.uid(), para que o navegador não tenha de o enviar:
user_id uuid not null default auth.uid()
references auth.users (id) on delete cascadeCrie um índice na coluna que as suas políticas filtram, já que a política corre em cada consulta:
create index todos_user_id_idx on public.todos (user_id);Padrão 2: equipas e organizações
Para dados partilhados, guarde a pertença numa tabela própria e deixe a política perguntar se o utilizador pertence à equipa da linha. Uma tabela de membros com team_id e user_id chega:
create policy "Members read team projects"
on public.projects for select
to authenticated
using (
team_id in (
select team_id from public.team_members
where user_id = (select auth.uid())
)
);As políticas de escrita são mais rigorosas do que as de leitura na maioria das apps: os membros podem ler, só alguns podem inserir ou apagar. Ponha uma coluna role em team_members e verifique-a nas políticas de insert, update e delete. Crie um índice em team_members por user_id, porque uma chave primária composta que começa por team_id não indexa user_id.
Padrão 3: papéis de administrador
Uma política de admin deve ler o papel num sítio que o utilizador não possa editar. A documentação do Supabase avisa que raw_user_meta_data pode ser alterado pelo utilizador, pelo que não deve ser usado para autorização, enquanto raw_app_meta_data (app_metadata no JWT) é de confiança. O caminho mais simples e mais comum é uma tabela de administradores em que só o dono da base de dados escreve:
create policy "Admins read all invoices"
on public.invoices for select
to authenticated
using (
exists (
select 1 from public.admins
where user_id = (select auth.uid())
)
);As políticas para a mesma operação combinam-se com OR, por isso esta política acrescenta o acesso de admin por cima da política por utilizador. Mantenha a tabela admins bloqueada, com o seu próprio RLS e sem política de insert, caso contrário um utilizador podia tornar-se admin a si mesmo.
Políticas de armazenamento para ficheiros carregados
Os ficheiros no Supabase Storage são protegidos por políticas RLS na tabela storage.objects, e um bucket não aceita nenhum upload até acrescentar uma política. Uma organização comum guarda os ficheiros de cada utilizador numa pasta com o nome do seu ID, e a política verifica a primeira pasta do caminho:
create policy "Upload to own folder"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
create policy "Read own files"
on storage.objects for select
to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text
);Sobrescrever um ficheiro (upsert) exige políticas de insert, select e update em conjunto. Os objetos num bucket público são legíveis por qualquer pessoa com o link, por isso guarde faturas, contratos e documentos de identificação em buckets privados. Veja também a página do Supabase sobre controlo de acesso do Storage.
Como testar as suas políticas com duas contas
O único teste que prova que o RLS funciona é iniciar sessão como um utilizador e tentar aceder aos dados de outro. O editor SQL não conta: corre com privilégios elevados e ignora as suas políticas. Use a própria app, ou a API com uma sessão de utilizador real.
- Crie duas contas, A e B, e adicione algumas linhas com cada uma.
- Com a sessão de A, confirme que a lista mostra apenas as linhas de A.
- Ainda como A, chame a API diretamente para as linhas de B (um select com o user_id de B, um update num ID de linha de B, um delete). Cada pedido deve devolver nada ou falhar.
- Tente inserir uma linha com o user_id de B. A política de insert tem de a recusar.
- Abra a app numa janela privada, sem sessão, e peça as mesmas tabelas. As tabelas privadas não devem devolver nada.
- Repita os passos para os ficheiros: A não deve conseguir descarregar o ficheiro de B pelo seu caminho.
Erros comuns
| Erro | Resultado | Correção |
|---|---|---|
| RLS nunca ativado numa tabela | A tabela fica aberta a todos os que tenham a chave pública | Ative o RLS em todas as tabelas do esquema public |
| Política sem cláusula TO | Avaliada também para pedidos anónimos | Acrescente to authenticated |
| using (true) | Todas as linhas são visíveis para o papel | Reserve-o para dados destinados a ser públicos |
| Política de update sem política de select | Os updates não alteram nada, em silêncio | Acrescente uma política de select correspondente |
| Papel lido dos metadados do utilizador | Os utilizadores podem atribuir-se o papel | Use app_metadata ou uma tabela de admins |
| View sobre uma tabela protegida | As views contornam o RLS por defeito | No Postgres 15 e posteriores, crie-a com security_invoker = true |
| Chave secreta no código do cliente | Contorna todas as políticas | Mantenha-a apenas no código de servidor |
Como o Cadrant trata disto
O Cadrant, o nosso criador, gera apps web em React sobre um projeto Supabase na sua própria conta Supabase. Quando a IA cria uma tabela, escreve a migração com políticas RLS para as tabelas por utilizador, políticas de armazenamento incluídas, e pode inspecionar o esquema e as políticas atuais antes de os alterar. As chaves secretas ficam fora do código do cliente: integrações como o Stripe passam por edge functions, com os segredos guardados como variáveis de ambiente. A configuração está descrita na documentação da ligação ao Supabase e no guia dos AI builders com Supabase.
A IA escreve as políticas a partir do que lhe diz, por isso diga-o: «um cliente vê apenas as suas próprias faturas, o gestor vê todas». Os prompts dos nossos exemplos de prompts para aplicação web mostram como. As políticas geradas são um bom primeiro rascunho, não uma garantia. Faça sempre o teste com duas contas antes de partilhar a app com utilizadores reais. O Cadrant, como qualquer AI builder, pode errar uma regra, e o teste é a forma de o descobrir.