Skip to content

Segurança: 5 tabelas saem de baixo da chave pública + search_path em 39 funções - #54

Merged
BarryBits merged 5 commits into
mainfrom
fix/rls-tabelas-orfas
Aug 12, 2026
Merged

Segurança: 5 tabelas saem de baixo da chave pública + search_path em 39 funções#54
BarryBits merged 5 commits into
mainfrom
fix/rls-tabelas-orfas

Conversation

@BarryBits

@BarryBits BarryBits commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Item 3 da lista aberta pelo marco zero (#52). Primeira migration a passar pela rede nova — o Supabase Preview replayou baseline + esta migration do zero e passou em 18s.

O buraco

db/ESTRUTURA.md achou 5 das 112 tabelas com RLS desligada e GRANT ALL TO anon. A chave anônima é pública, vai no bundle do frontend.

Hoje não vaza nada: as 5 estão com zero linhas. Mas employees guarda salario_bruto, benefícios e cpf_mascara. No dia que a feature rodar, folha de pagamento real fica legível — e gravável — por qualquer um com a chave pública.

É por isso que entra agora: com as tabelas vazias e sem tráfego, não há o que quebrar.

Quem realmente lê estas tabelas (verificado no código vivo)

Mapeado antes de escrever a policy. Achado que decidiu tudo: nenhum leitor usa service_role — todos vão por @/lib/supabase/client ou /server, os dois com chave anon + sessão. RLS sem policy quebraria caminho vivo.

Caminho Vivo? Prova
CHH (/financeiro/chh) ✅ vivo Aba Pessoas → DrillLink "Custo hora-homem" no PainelCasaTab:275; flag FINANCEIRO_CASA_GRUPO global ON. Também command-center:473, AnalysisRecommendationsPanel:47 e import dinâmico em treasury-week-service:96
Folha (/financeiro/folha) ✅ vivo PainelCasaTab:274 + SubirApoioModal:175. Escreve employees no upload
Auto-categorização morto auto-categorizer-enhanced.ts — nenhum dos 3 exports (autoCategorize, autoCategorizeTransacoes, getCategorizationStats) é importado em lugar nenhum. A categorização viva é categorization-rules.ts, que não toca categorization_patterns

Sem policy, o efeito nos dois vivos seria assimétrico e traiçoeiro: upload de folha daria erro (INSERT barrado), e CHH mostraria "sem colaboradores" para sempre — SELECT barrado por RLS volta vazio, não volta erro.

O que cada tabela ganhou

Tabela Regra Por quê
employees admin · staff ATR · cliente com acesso ao projeto Padrão de ingestion_documents (20260617) — o caso análogo, cliente escreve no próprio projeto
company_ai_dossiers idem Escrito server-side, mas com chave anon + sessão
consultant_memories idem O código já acreditava que havia RLS: learning-memory.ts:30 traz "a RLS de consultant_memories barraria o insert sem sessão"
categorization_patterns logado , só a equipe escreve Catálogo global, sem project_id. Não é dado de tenant
meetings RLS sem policy Zero linhas, zero referências em src/. Policy para tabela que ninguém lê é adivinhação

A armadilha latente do contador

increment_pattern_hit é plpgsql INVOKER e dá UPDATE em categorization_patterns. Com RLS ligada e sem policy de UPDATE pro usuário comum, o contador morreria em silêncioUPDATE que casa zero linhas não levanta erro.

Hoje ela não morde, porque o único chamador é o módulo morto acima. Desarmar antes de uma eventual revivência custa zero: em vez de abrir UPDATE pra todo mundo (qualquer um reescrevendo o catálogo), a função vira SECURITY DEFINER — caminho de escrita controlado, que só sabe somar 1 no hit_count de um id. CREATE OR REPLACE preserva os GRANTs e o retorno void não muda.

search_path em 39 funções (não 29)

As 29 sem search_path nenhum, mais 10 que tinham só 'public'. Essas 10 pareciam protegidas: sem pg_temp explícito, o Postgres procura o schema temporário primeiro. O gerador só testa presença, por isso passavam como ✅.

  • ✅ nenhuma das 39 chama função de extensão sem qualificar schema — fixar search_path não quebra nenhuma
  • ✅ nenhuma tem overload
  • ✅ as 39 assinaturas diffadas contra o dump, zero divergência
  • ALTER FUNCTION não toca corpo, assinatura nem GRANT

Aplicação

Migration idempotente (DROP POLICY IF EXISTS antes de cada CREATE), fecha com NOTIFY pgrst. Ainda não aplicada — segue o fluxo da casa: aplicar no SQL Editor, depois re-dump e npx tsx scripts/db/gerar-estrutura.ts, e o ESTRUTURA.md passa a mostrar 112/112 com RLS.

🤖 Generated with Claude Code

O cruzamento banco × código do db/ESTRUTURA.md achou 5 das 112 tabelas com RLS
DESLIGADA e GRANT ALL TO anon. A chave anônima vai no bundle do frontend: sem
RLS, qualquer um com ela lê e escreve nessas tabelas.

Hoje não vaza nada — as 5 estão com zero linhas. Mas employees guarda
salario_bruto, benefícios e cpf_mascara, e tem 17 usos em src/ (Custo
Hora-Homem). No dia que a feature rodar, folha de pagamento real fica legível e
gravável por qualquer um. Por isso entra agora: com as tabelas vazias e sem
tráfego, não há o que quebrar.

employees, company_ai_dossiers e consultant_memories ganham o padrão de
ingestion_documents (o caso análogo, cliente escreve no próprio projeto): admin,
staff ATR, ou cliente com acesso AO projeto. Detalhe: consultant_memories já
tinha comentário no código dizendo "a RLS barraria o insert sem sessão" — o
código acreditava na RLS que o banco não tinha.

categorization_patterns é catálogo global, sem project_id: logado lê, só a
equipe escreve. Aqui morava uma armadilha — increment_pattern_hit é plpgsql
INVOKER e dá UPDATE nessa tabela; com RLS ligada o contador morreria EM SILÊNCIO
(UPDATE que casa zero linhas não dá erro). Em vez de abrir UPDATE pra todo mundo,
a função vira SECURITY DEFINER: é o caminho de escrita controlado, e só sabe
somar 1 num id. CREATE OR REPLACE preserva os GRANTs e o retorno void não muda.

meetings fica com RLS sem policy — zero linhas, zero referências em src/. Pra
tabela que ninguém lê, policy seria adivinhação; quando a feature existir, a
policy nasce com ela e o primeiro acesso falha alto, não calado.

Junto, search_path fixo em 39 funções SECURITY DEFINER: as 29 que não tinham
nenhum, mais 10 que tinham só 'public'. Essas 10 pareciam protegidas e não
estavam — sem pg_temp explícito, o Postgres procura o schema temporário PRIMEIRO.
Conferido antes de escrever: nenhuma das 39 chama função de extensão sem
qualificar schema, nenhuma tem overload, e as 39 assinaturas foram diffadas
contra o dump. ALTER FUNCTION não toca corpo, assinatura nem GRANT.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
atr-os Ready Ready Preview Aug 12, 2026 4:15am

@supabase

supabase Bot commented Aug 12, 2026

Copy link
Copy Markdown

Updates to Preview Branch (fix/rls-tabelas-orfas) ↗︎

Deployments Status Updated
Database Wed, 12 Aug 2026 04:13:42 UTC
Services Wed, 12 Aug 2026 04:13:42 UTC
APIs Wed, 12 Aug 2026 04:13:42 UTC

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

Tasks Status Updated
Configurations Wed, 12 Aug 2026 04:13:44 UTC
Migrations Wed, 12 Aug 2026 04:13:45 UTC
Seeding Wed, 12 Aug 2026 04:13:45 UTC
Edge Functions Wed, 12 Aug 2026 04:13:45 UTC

View logs for this Workflow Run ↗︎.
Learn more about Supabase for Git ↗︎.

Verificação no código vivo, pedida antes de aplicar: dos três caminhos que eu
disse que quebrariam sem policy, só dois estão vivos.

CHH e Folha estão de pé — rota /financeiro/chh e /financeiro/folha, alcançáveis
pela aba Pessoas (flag FINANCEIRO_CASA_GRUPO global ON) via DrillLink no
PainelCasaTab, mais command-center, AnalysisRecommendationsPanel e um import
dinâmico em treasury-week-service.

Auto-categorização NÃO. `auto-categorizer-enhanced.ts` é código morto: nenhum dos
3 exports é importado em lugar nenhum. A categorização viva é
`categorization-rules.ts`, e ela não toca categorization_patterns.

Nada muda no SQL — as policies e o SECURITY DEFINER seguem certos, e desarmar a
armadilha antes de o módulo reviver custa zero. Muda o comentário, que dizia
"armadilha tratada" onde o honesto é "armadilha latente".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Efeito colateral da 20260812000000, achado no re-dump — que é exatamente o que o
re-dump serve pra achar.

employees NÃO era tabela sem policy: já tinha SEIS, dormentes, porque policy
existe mesmo com RLS desligada e só não é aplicada. Meu F0 perguntou "a RLS está
ligada?" e não perguntou "que policies já existem?". O DROP POLICY IF EXISTS
substituiu 4 delas às cegas.

Três substituições não ampliaram nada. select/insert/update antigas dependiam de
project_members — tabela VAZIA, e client_project_access é view sobre ela filtrando
role='cliente'. E as policies employees_cliente_insert/_cliente_update, que não
foram tocadas, já traziam a mesma cláusula de staff que escrevi. Sem essa cláusula
no SELECT, o CHH pararia de enxergar pro consultor no instante em que a RLS ligou.

A quarta ampliou de verdade: employees_delete era is_admin() puro — role IN
('owner','admin') — e passou a deixar CONSULTOR apagar linha de folha de
pagamento. Não foi decisão, foi descuido; a restrição original era deliberada e
nenhum caminho vivo precisa dela (upload de folha faz INSERT/UPDATE). Volta ao
que era.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… a ser autoritativo

schema.sql 14.899 → 15.030. Conferido objeto a objeto: tabelas 112, views 12,
funções 73 e triggers 92 inalterados; policies 348 → 360; tabelas com RLS 107 →
112 de 112; SECURITY DEFINER sem search_path 29 → 0. roles.sql com diff vazio.
seed.sql 25.164 → 25.175, nenhuma tabela perdeu linha.

Foi este re-dump que achou o efeito colateral corrigido pela 20260812010000 —
esperávamos +16 policies e vieram +12, e a diferença revelou as 6 policies
dormentes de employees. Migration não mostra o que ela mesma fez; dump mostra.

Corrige também um número que foi afirmado antes de aplicar. Eu disse que as 5
tabelas estavam com ZERO linhas; o método de contagem procurava blocos COPY e
este dump usa INSERT, então voltava zero pra tudo. Contagem certa: employees 0,
meetings 0, consultant_memories ~4, company_ai_dossiers ~5,
categorization_patterns ~33.

Conferido no dado que ninguém perdeu acesso: profiles tem 1 owner + 2 admin
(cobertos por is_admin) e 3 cliente, e project_members tem exatamente 3 linhas
role='cliente' pra eles — que é o que a view client_project_access enxerga. O 4º
membro é consultor no projeto mas owner em profiles, coberto pelas duas pontas.

O log completo dessa validação está no SNAPSHOT.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Traz o gate de db/ESTRUTURA.md do #53 para que ele valide justamente este PR,
que mexe no dump e no mapa gerado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@BarryBits
BarryBits merged commit 95c05cc into main Aug 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant