Em resumo
Teste o row level security (RLS, segurança ao nível da linha) do Supabase da forma como um membro o encontra: entre com cada função, execute a ação real com uma permissão desligada e depois ligada, e verifique se o resultado muda. Numa aplicação em produção com cinco funções editáveis, isto mostrou que 30 das 87 caixas de permissão que um administrador podia guardar não mudavam nada na base de dados. Um teste política a política não consegue ver isto, porque testa a política que escreveu, não a caixa no ecrã.
Sou o Denys, engenheiro full-stack e de IA. No início deste ano construí em Supabase um sistema de gestão de projetos e de registo de horas para uma empresa de auditoria, onde todas as regras de acesso vivem na base de dados. Abaixo está o método que uso para provar essas regras, o que encontrou e quanto custa executá-lo.
Porque é que os testes de políticas não chegam para testar Supabase RLS?
Um teste de política responde a “esta política faz o que escrevi?”. Os seus utilizadores fazem outra pergunta: “se eu desmarcar esta caixa para esta função, ainda conseguem fazê-lo?”. Numa aplicação pequena, as duas perguntas têm a mesma resposta. Numa aplicação com uma página de definições onde os administradores editam uma matriz de permissões, afastam-se.
A aplicação tinha 41 tabelas com RLS ativo e 402 instruções CREATE POLICY em 255 migrações. O ecrã de administração mostrava uma matriz de áreas (projetos, tarefas, folhas de horas, ficheiros, etc.) contra ações (ver, criar, editar, arquivar), com uma coluna por função. Cada caixa guardada era uma promessa ao administrador. Nada verificava se a base de dados a cumpria.
O Supabase documenta bem as ferramentas habituais: os testes pgTAP e os helpers de teste permitem criar utilizadores, mudar para eles e verificar o que conseguem ler. Use-os. O método abaixo assenta por cima: é guiado pela matriz que o administrador vê, não pela lista de políticas.
Como funciona a prova da matriz?
Cada caixa da matriz torna-se uma sonda. Uma sonda entra como membro da função, executa uma ação real e regista o resultado. Fá-lo duas vezes: uma com a caixa desligada e outra com a caixa ligada, com tudo o resto igual. Se o resultado muda, a caixa é aplicada. Se não muda, a caixa não faz nada, por mais convincente que pareça no ecrã.
- Uma caixa da matrizExemplo: Membro Standard · Tarefas · Criar
- Caixa desligada: agir como membro da funçãoCriar uma tarefa pelo mesmo caminho que a aplicação usa. Registar o resultado.
- Caixa ligada: repetir a mesma açãoMesmo utilizador, mesmos dados. Só a caixa mudou.
- Comparar os dois resultadosO veredicto depende apenas do que a base de dados fez.
- O resultado mudouAplicada. Recusada quando desligada, permitida quando ligada.
- Igual nos dois casosNão faz nada. O ecrã promete um controlo que não existe.
- Ecrã e base de dados discordamUm bug. A página mostra um botão que a base de dados recusa, ou o contrário.
“Resultado” significa aqui o que quer que faça quem consome a permissão: uma política devolve linhas ou recusa uma escrita, uma função da base de dados lança um erro, um trigger bloqueia ou uma edge function responde 403. Uma caixa pode proteger várias destas coisas, por isso as 87 caixas exigiram 279 verificações por função. Cada verificação terminou num destes veredictos:
| Veredicto | Verificações por função | O que significa |
|---|---|---|
| Efeito | 227 | O resultado mudou com a caixa |
| Morta | 30 | O resultado ficou igual com a caixa desligada e ligada |
| Inalcançável | 19 | A ação não está ao alcance desta função, provado à parte |
| Edge function | 1 | Aplicada por uma edge function, verificada por HTTP |
| Só no ecrã | 1 | Muda apenas o que a página mostra, coberta por um teste de front-end |
| Ecrã e base de dados coincidem | 1 | O que a página oferece corresponde ao que a base de dados permite |
| Falha | 0 | Uma verificação cujo resultado não correspondeu ao esperado |
O que encontrou a prova?
A execução de 15 de setembro de 2026 cobriu as cinco funções que um administrador pode editar (Admin, Senior, Standard, Restricted e uma função personalizada clonada). Os resultados foram idênticos em todas as funções.
Nenhuma das 30 deixava entrar ninguém. Em 29 delas, nenhuma política, função ou trigger lia a caixa; a última deixou de importar depois de uma política ser reescrita. Mesmo assim é um problema real: um administrador que desmarca “Apagar” para uma função acredita que essa função não consegue apagar. Escondemos as 30 em vez de as ligar, porque cada uma teria exigido uma decisão de produto, e uma caixa que não faz nada não deve estar no ecrã enquanto essa decisão é tomada.
O mesmo trabalho revelou mais quatro defeitos:
| O que estava errado | Como se manifestou | Correção |
|---|---|---|
| 30 caixas não faziam nada | Veredicto “morta”, resultado idêntico desligada e ligada | Escondidas da matriz |
| Três funções da base de dados deixavam passar uma ação quando uma tarefa não tinha responsável | Uma sonda que devia ser recusada foi permitida | As funções passaram a recusar quando o valor falta |
| Repor uma folha de horas em rascunho dependia da caixa errada | Ecrã e base de dados discordavam para uma função | Passou a depender da permissão de reabrir, a que lhe pertence |
| Um membro só via permissões novas depois de recarregar | A base de dados tinha mudado, a página não | As permissões atualizam-se a cada 60 segundos e ao focar a janela |
| O valor por omissão de uma coluna violava a sua própria check constraint, e os inserts sem essa coluna falhavam | Encontrado pela revisão de segurança da correção, no mesmo trabalho | Valor por omissão alterado para um que a constraint aceita |
O bug do responsável em falta foi o que importou. As verificações estavam escritas assim em PL/pgSQL:
if not (has_permission or assignee_id = auth.uid()) then
raise exception 'forbidden';
end if;
Quando assignee_id é null, assignee_id = auth.uid() é null, false or null é null, not null continua null, e o if trata null como falso. A exceção nunca é lançada. Em produção nenhuma tarefa tinha responsável definido, por isso não era um caso limite: aplicava-se a todas as tarefas abrangidas. A correção envolve a condição em coalesce(…, false) para que um valor em falta seja recusado, e foi publicada primeiro, sozinha, antes do resto do lote.
Como se executa contra uma cópia de produção?
Uma prova contra um esquema diferente do de produção não prova nada sobre produção. O harness corre na stack local do Supabase, numa base de dados separada que é uma cópia do esquema de produção mais as migrações em revisão. Antes de cada execução compara uma impressão digital das duas bases de dados locais: 799 linhas que descrevem tabelas, políticas, funções e grants. Qualquer diferença trava a execução.
- Esquema de produçãoCopiado para uma única máquina de confiança. Nunca vai para um runner de CI alojado.
- Stack local: base de dados da aplicaçãoServida pela API e pela autenticação locais, usada pelas verificações end-to-end.
- Stack local: base de dados isolada da provaMesmo esquema, usada apenas pela prova da matriz.
- Verificação da impressão digital799 linhas por base de dados. Qualquer diferença trava a execução antes da primeira sonda.
- Sondas executadas como cada funçãoComo membros reais com sessão iniciada, pelos mesmos caminhos que a aplicação usa.
- Sem deixar rastoAs contagens de linhas no fim têm de ser iguais às do início.
Dois pormenores tornaram o harness fiável:
- Primeiro, partir de propósito. Antes de confiar numa execução verde, substituí uma política de insert por uma que aceita tudo e voltei a correr a prova. Tinha de ficar vermelha nas caixas que essa política protege. Um harness que continua verde com uma política partida é decoração.
- Verificar também o estado guardado. A primeira passagem começa cada função a partir de uma base limpa. Uma segunda passagem começa a partir da matriz atualmente guardada para cada função, porque uma caixa pode comportar-se de outra forma ao lado das caixas que um administrador realmente marcou.
Como se mantém verde depois do lançamento?
Uma prova executada uma vez fica desatualizada com a migração seguinte. A prova corre agora antes de cada push que mexa nas migrações, no código de autenticação ou na própria matriz.
- git pushA alteração mexe em migrações, autenticação ou na matriz.
- Hook de pre-push: prova da matrizCorre localmente contra a base de dados da prova. Vermelho bloqueia o push.
- O push chega à mainO deploy corre como habitualmente.
- Execução de CI self-hostedA mesma prova outra vez, na máquina que guarda a cópia do esquema de produção.
A prova são 135 testes em 11 ficheiros, ao lado de cerca de 3100 testes unitários normais no mesmo projeto.
O que não funcionou?
- O CI alojado não a conseguia correr. A prova precisa da cópia do esquema de produção, e eu não a queria num runner alojado. Por isso a barreira que bloqueia é um hook de pre-push, e o CI numa máquina self-hosted é uma segunda verificação pela qual o deploy não espera.
- As contagens fixas de linhas partiram-se. A primeira verificação “sem rasto” comparava com números fixos. A migração legítima seguinte acrescentou uma linha de permissão e pô-la vermelha. Agora compara as contagens do fim com as do início.
- Ignorado não é aprovado. As verificações end-to-end precisam do edge runtime local. Quando está em baixo, são ignoradas, e é fácil ler um teste ignorado como verde.
- É lenta num portátil ocupado. As passagens por função precisaram de timeouts maiores numa máquina carregada.
- Prova o que as sondas descrevem. Uma caixa para a qual ninguém escreveu uma sonda é reportada como lacuna de cobertura, não como segura.
Como repetir isto na sua aplicação Supabase
- Liste todas as permissões que o ecrã deixa alguém guardar: função × área × ação. Essa lista é a especificação, não as suas políticas.
- Para cada caixa, anote a ação real que deve controlar e como se manifesta uma recusa (sem linhas, um erro, um 403).
- Monte uma base de dados igual à de produção e tire-lhe a impressão digital, para que uma divergência de esquema trave a execução.
- Execute cada ação como membro com sessão iniciada, caixa desligada e depois ligada, e compare.
- Parta uma política de propósito e confirme que a prova fica vermelha.
- Esconda ou ligue cada caixa que não faz nada e faça da prova uma barreira antes do push.
Se a sua aplicação já tem RLS mas não tem a certeza de que faz o que o ecrã de definições diz, o mesmo método serve como auditoria de um código existente, o primeiro passo de um resgate de projeto. Escrevi sobre o projeto por trás destes números no caso do registo de horas para a empresa de auditoria.
Não tem a certeza de que o seu RLS corresponde ao ecrã de definições?
Troco impressões com gosto sobre como o testa. Sem pitch.
Fale comigo