Auditor de políticas Row Level Security para PostgreSQL e Supabase.
Você passa o schema (arquivo .sql ou conexão com o banco) e ele aponta onde um usuário
consegue ler ou escrever dado de outro usuário, com o SQL de correção sugerido.
Num app Supabase o front conversa direto com o banco usando a chave anon, que fica no
bundle do cliente. Quem decide o que cada usuário enxerga é o RLS. Uma política com
USING (true) no lugar errado e qualquer pessoa com o DevTools aberto lê a tabela de usuários
inteira.
Configurei RLS em todas as tabelas da Prumo, e o que mais deu trabalho foi revisar as
políticas. auth.role() = 'authenticated' parece controle de acesso e libera tudo para
qualquer usuário logado. Uma view criada pelo SQL Editor ignora o RLS das tabelas sem avisar.
Transformei esses erros em regras que rodam no CI, antes da migration ir para produção.
git clone https://github.com/burnoutscript/rls-guard.git
cd rls-guard
pip install -e .Python 3.10+. O modo padrão (ler arquivo SQL) não tem dependência nenhuma. O modo de conexão
com o banco usa psycopg e é opcional:
pip install -e ".[live]"# saída do pg_dump
pg_dump --schema-only "$DATABASE_URL" > schema.sql
rls-guard schema.sql
# migrations do Supabase, aplicadas na ordem
rls-guard supabase/migrations/*.sql
# com o SQL de correção em cada achado
rls-guard schema.sql --fix
# Markdown para colar no pull request
rls-guard supabase/migrations/*.sql --format markdown --fix -o relatorio.md
# JSON
rls-guard schema.sql --format json | jq '.findings[] | select(.severity=="critical")'
# banco ao vivo (só leitura, consulta apenas o catálogo)
rls-guard --dsn "postgresql://auditor@localhost:5432/app"
# outros esquemas, ignorando uma regra
rls-guard schema.sql --schema public --schema api --ignore RG003Trecho da saída com --fix:
rls-guard — auditoria de Row Level Security
6 tabelas · 5 com RLS · 8 políticas · 10 achado(s)
──────────────────────────────────────────────────────────────────
CRÍTICO Leitura liberada para anônimos RG002
public.usuarios · política "perfis visiveis" samples/schema_inseguro.sql:23
A política libera SELECT com USING (true) para anon, authenticated. Qualquer pessoa, sem
login, lê a tabela inteira.
→ Restringir a política ao role authenticated e ao dono da linha. Se o dado for público
de propósito, expor só as colunas necessárias por uma view com security_invoker.
DROP POLICY "perfis visiveis" ON public.usuarios;
CREATE POLICY "perfis visiveis" ON public.usuarios FOR SELECT TO authenticated
USING ((select auth.uid()) = id);
CRÍTICO Escrita liberada por condição permissiva RG004
public.redacoes · política "aluno atualiza redacao" samples/schema_inseguro.sql:56
A condição de UPDATE (auth.role() = 'authenticated') não amarra a linha a ninguém:
qualquer usuário que a política alcança escreve em qualquer linha.
Os dois schemas de exemplo imitam uma plataforma de estudos (usuarios, redacoes,
assinaturas, questoes):
rls-guard samples/schema_inseguro.sql --fix # 10 achados, pelo menos um por regra
rls-guard samples/schema_seguro.sql # o mesmo schema corrigido, nenhum achado| ID | Regra | Severidade | O que procura |
|---|---|---|---|
| RG001 | Tabela sem RLS | Crítica / Alta | Tabela sem ENABLE ROW LEVEL SECURITY. Crítica se tem GRANT para anon ou authenticated |
| RG002 | Leitura pública | Crítica | Política de SELECT com USING (true) que alcança anon (ou PUBLIC, que inclui anon) |
| RG003 | RLS sem política | Baixa | RLS ligado e nenhuma política: tudo fica negado, quase sempre por esquecimento |
| RG004 | Escrita permissiva | Crítica | INSERT/UPDATE/DELETE com condição que não amarra a linha a ninguém |
| RG005 | Sem WITH CHECK |
Alta / Baixa | Política de escrita que não valida a linha nova (ver abaixo) |
| RG006 | Dado sensível sem RLS | Crítica | Coluna como cpf, email, senha, token, api_key, cartao em tabela sem RLS |
| RG007 | View contorna RLS | Alta | View sem security_invoker = on, ou materializada, sobre tabela com RLS |
| RG008 | SECURITY DEFINER sem search_path |
Alta | Função SECURITY DEFINER sem SET search_path |
| RG009 | Escrita para anon |
Crítica | GRANT ALL ou privilégio de escrita para anon ou PUBLIC |
A RG004 não procura só true. Antes de testar, ela simplifica a condição:
| Condição | Resultado |
|---|---|
true, 1 = 1, x OR true |
permissiva (sempre verdadeira) |
auth.role() = 'authenticated' |
permissiva (qualquer usuário logado passa, em qualquer linha) |
auth.uid() IS NOT NULL |
permissiva (mesma coisa com outra cara) |
(select auth.uid()) = usuario_id |
restrita |
auth.uid() IS NOT NULL AND auth.uid() = usuario_id |
restrita (a segunda parte restringe) |
As linhas 2 e 3 são o erro que mais vejo em projeto Supabase. A política exige login, passa no teste manual, e cada aluno consegue editar a redação de todos os outros.
O peso de uma política de escrita sem WITH CHECK depende do comando, por causa do que o
Postgres faz quando a cláusula falta:
FOR INSERTsemWITH CHECK: INSERT não temUSING, então nada valida a linha nova. O aluno consegue inserir uma redação com ousuario_idde outra pessoa. Alta.FOR UPDATEouFOR ALLsemWITH CHECK: o Postgres usa o próprioUSINGpara validar a linha nova. Se oUSINGéauth.uid() = usuario_id, a linha atualizada também precisa satisfazer isso, e o aluno não consegue passar a redação para o nome de outra pessoa. A proteção existe, só que implícita. Baixa, com a sugestão de deixar explícito.
Se as duas fossem Alta, um schema correto sairia com achado grave, e em pouco tempo ninguém
mais leria o relatório. Quando o USING é ele mesmo permissivo, quem aponta o problema é a
RG004 (crítica), e a RG005 não repete o achado.
A RG003 segue a mesma lógica. RLS ligado sem política nega todo acesso, então o erro deixa a tabela inacessível em vez de exposta, e fica como Baixa.
schema.sql / migrations banco ao vivo (--dsn)
│ │
▼ ▼
┌───────────┐ ┌──────────┐
│ parser │ divide em │ live │ consulta pg_class,
│ │ comandos e │ │ pg_policies, pg_proc
└─────┬─────┘ aplica em ordem └────┬─────┘ (somente leitura)
│ │
└──────────────┬───────────────┘
│ Schema (tabelas, políticas, grants, views, funções)
▼
┌──────────┐ 9 regras independentes
│ rules │ recebem o modelo, devolvem achados
└────┬─────┘
│ Finding (severidade, objeto, linha, SQL de correção)
▼
┌──────────┐ ┌─────────┐
│ report │◀───────│ fixes │ SQL de remediação (--fix)
└────┬─────┘ └─────────┘
│
terminal · JSON · Markdown
O parser aplica os comandos na ordem em que aparecem, do jeito que o banco faria. Um DISABLE
depois do ENABLE ROW LEVEL SECURITY desliga o RLS de novo, um
GRANT ... ON ALL TABLES IN SCHEMA só pega as tabelas que já existiam naquele ponto, e um
REVOKE depois de GRANT ALL tira os privilégios.
As regras não leem SQL, só o modelo Schema. Por isso o modo ao vivo, que lê o catálogo do
Postgres em vez de arquivo, usa as mesmas nove regras sem código extra.
O --fix só imprime o SQL; nada é executado no banco. A política sugerida amarra a linha ao auth.uid() usando
a coluna de dono que encontrar (usuario_id, user_id, owner_id...). Isso serve para dado
do próprio usuário. Numa tabela compartilhada entre membros de uma turma a política teria que
ser outra, por isso o SQL precisa de revisão antes de rodar. Ele usa (select auth.uid()) em vez de auth.uid()
porque, com o select, o Postgres avalia a função uma vez por consulta e não uma vez por
linha.
| Código | Significado |
|---|---|
0 |
Nenhum achado |
1 |
Achados encontrados |
2 |
Erro (arquivo não existe, nenhuma tabela, regra desconhecida, banco inacessível) |
No CI:
- run: |
pip install git+https://github.com/burnoutscript/rls-guard.git
rls-guard supabase/migrations/*.sql --min-severity highpip install -e ".[dev]"
pytest -v # 83 testes
ruff check src testsCada regra tem teste nos dois sentidos: o caso que deve disparar e um parecido que não deve
(política restritiva com USING (true), SELECT liberado só para authenticated, view sobre
tabela sem RLS, coluna tokens_usados).
- O parser não cobre todo o PostgreSQL, só os comandos que as regras usam. O resto do arquivo é
ignorado, e SQL dinâmico dentro de
DO $$ ... $$não é interpretado. - A análise de expressão é sintática.
USING (eh_admin())não é avaliado (não tem como saber o que a função devolve) e conta como restrita. Tautologias mais elaboradas quex OR truepassam. - Dado sensível é detectado pelo nome da coluna.
documentocom CPF dentro passa. O nome é quebrado por_, entãotokens_usadosnão dispara, masemail_marketing_opt_indispara mesmo sem guardar e-mail. - O Supabase concede
ALLparaanonpor padrão nas tabelas dopublic(viaALTER DEFAULT PRIVILEGES). A RG009 aponta isso de propósito, porque aí o RLS vira a única barreira. Se foi uma escolha consciente, use--ignore RG009. - A RG002 não cruza com os
GRANTs: uma políticaUSING (true)paraanonaparece mesmo queanonnão tenhaSELECTna tabela. Ela vira problema no primeiroGRANTque alguém fizer. - O modo ao vivo foi escrito para o catálogo do Postgres 15+ e é testado com linhas simuladas. O modo por arquivo é o principal e é o que o CI cobre.
MIT. Veja LICENSE.