Ir para o conteúdo principalIr para projetos
<jgbriel.dev/>

Recursão de RLS entre tabelas cruzadas: como resolvi com SECURITY DEFINER

2026-06-22

Quando duas policies de Row Level Security consultam a tabela uma da outra, o Postgres para com infinite recursion detected in policy: avaliar a policy de A dispara a de B, que dispara a de A de novo. A correção é mover cada checagem cruzada pra uma função SECURITY DEFINER, que lê a outra tabela sem reexecutar a RLS dela e quebra o ciclo.

Esbarrei nisso na plataforma HER enquanto fechava dois apontamentos de segurança do advisor do Supabase — e a correção acabou trazendo uma segunda lição, sobre onde essas funções moram.

De onde veio a recursão?

Da troca de views que bypassavam RLS. As páginas públicas da HER (o diretório de parceiros, acessível sem login) liam de duas views, public_convenios e public_associadas. As duas eram SECURITY DEFINER: rodavam como o dono e pulavam a RLS inteira. O advisor do Supabase marca isso como ERROR, mesmo quando o bypass é proposital.

E o bypass sustentava tudo: o role anon não tinha policy de leitura nas tabelas base. Então a correção foi dar ao anon uma policy de SELECT em cada tabela base, replicando o WHERE da própria view, e depois virar as views pra security_invoker = true, pra respeitarem a RLS.

Por que policies de RLS com referência cruzada entram em recursão?

Porque a subconsulta de uma policy também está sujeita a RLS. A policy de anon em associadas_details precisava saber se a empresa vinculada em empresas_details estava visível no site, e vice-versa. Escrito como EXISTS direto entre as duas tabelas, a subconsulta de cada policy disparava a policy da outra tabela, que consultava de volta — e o Postgres abortou com o erro de recursão.

A falha foi barulhenta, durante a própria migration, antes de qualquer coisa chegar em produção. Essa é a versão boa desse bug.

Como uma função SECURITY DEFINER quebra o ciclo?

Ela roda com os privilégios do dono, não de quem chama, então a consulta dela à outra tabela não re-dispara a RLS dessa tabela. Cada direção da checagem cruzada virou uma função própria, que devolve só um booleano:

create function internal.empresa_is_publicly_visible(p_profile_id uuid)
returns boolean
language sql
security definer
set search_path to 'public'
stable
as $$
  select exists (
    select 1 from empresas_details
    where profile_id = p_profile_id and visivel_no_site = true
  );
$$;

create policy "associados_details_anon_select" on public.associadas_details
  for select to anon
  using (
    visivel_no_site = true
    or internal.empresa_is_publicly_visible(profile_id)
  );

Uma função espelho, associada_is_publicly_visible, atende a policy de empresas_details. O set search_path fixa o schema, pra função não ser sequestrada por um objeto de mesmo nome em outro lugar.

Isso não é só outro bypass de RLS?

Não, e a diferença é o escopo. As views antigas bypassavam a RLS e devolviam linhas inteiras pra qualquer um. Essas funções bypassam só pra responder uma pergunta de sim ou não sobre visibilidade, e não devolvem mais nada. Elas também moram num schema internal que a API do Supabase (PostgREST) não expõe, então não dá pra chamá-las como RPC pública — o EXECUTE foi revogado de public e liberado só pra anon e authenticated, que as policies precisam.

Colocar as funções em public teria trocado o ERROR do advisor por um WARN sobre usuário anônimo conseguir executar função SECURITY DEFINER.

Quando usar SECURITY DEFINER em RLS?

Sempre que uma policy precisar ler outra tabela cuja policy lê de volta. Teste as policies com o role real (set role anon; numa sessão SQL, depois a consulta de verdade) — postgres e service_role bypassam RLS, então testar com eles esconde a recursão. E mantenha cada função auxiliar estreita: um booleano, search_path fixo, um schema que a API não expõe.