Skip to content

Guia de Design de RLS — Segurança by Design

Princípio fundamental: menor privilégio como default, não como exceção.
Toda tabela nasce fechada. Acesso é concedido explicitamente, por operação e por papel.


1. Checklist Obrigatório para Nova Tabela

Toda migration que cria tabela DEVE incluir, na mesma migration:

  • [ ] ALTER TABLE ... ENABLE ROW LEVEL SECURITY;antes de qualquer INSERT
  • [ ] Policies separadas por operação: SELECT, INSERT, UPDATE, DELETE — nunca FOR ALL
  • [ ] Policies direcionadas a TO authenticatednunca TO public
  • [ ] USING(...) em SELECT/UPDATE/DELETE com filtro restritivo
  • [ ] WITH CHECK(...) em INSERT/UPDATE para validar dados de entrada
  • [ ] Filtro escola_id para tabelas school-scoped
  • [ ] Filtro sub (user ID) para tabelas de self-access
  • [ ] Restrição por principal_role quando aplicável
sql
-- EXEMPLO MÍNIMO — tabela school-scoped
ALTER TABLE public.nova_tabela ENABLE ROW LEVEL SECURITY;

CREATE POLICY "select_escola" ON public.nova_tabela
  FOR SELECT TO authenticated
  USING (escola_id::text = (auth.jwt()->>'escola_id'));

CREATE POLICY "insert_escola" ON public.nova_tabela
  FOR INSERT TO authenticated
  WITH CHECK (escola_id::text = (auth.jwt()->>'escola_id'));

CREATE POLICY "update_escola" ON public.nova_tabela
  FOR UPDATE TO authenticated
  USING (escola_id::text = (auth.jwt()->>'escola_id'))
  WITH CHECK (escola_id::text = (auth.jwt()->>'escola_id'));

CREATE POLICY "delete_coordenador" ON public.nova_tabela
  FOR DELETE TO authenticated
  USING (
    escola_id::text = (auth.jwt()->>'escola_id')
    AND (auth.jwt()->>'principal_role') IN ('admin', 'coordenador')
  );

2. USING vs WITH CHECK

CláusulaQuando usarO que faz
USING(expr)SELECT, UPDATE, DELETEFiltra quais linhas o usuário consegue ver/modificar
WITH CHECK(expr)INSERT, UPDATEValida se os dados novos são permitidos

Regra crítica: UPDATE precisa de ambos:

  • USING → "quais linhas posso editar?"
  • WITH CHECK → "os novos valores são válidos?"

Sem WITH CHECK em INSERT/UPDATE, um usuário pode inserir dados com escola_id de outra escola.


3. Anti-Padrões Proibidos

USING (true) para operações de escrita

sql
-- PROIBIDO — qualquer autenticado lê E escreve tudo
CREATE POLICY "acesso_total" ON public.tabela
  FOR ALL TO authenticated
  USING (true);

Risco: privilege escalation. Caso real: sub_permissoes permitia qualquer usuário autenticado modificar permissões de qualquer escola.

Correto: policies separadas com filtros restritivos.

TO public (role anon)

sql
-- PROIBIDO — expõe dados para requisições sem autenticação
CREATE POLICY "leitura_publica" ON public.tabela
  FOR SELECT TO public
  USING (true);

Risco: qualquer pessoa com a URL do Supabase acessa os dados, sem token.

Correto: TO authenticated sempre. Se precisa de acesso anônimo (raro), documentar justificativa no ADR.

FOR ALL (operação combinada)

sql
-- PROIBIDO — esconde a intenção e dificulta auditoria
CREATE POLICY "tudo" ON public.tabela
  FOR ALL TO authenticated
  USING (escola_id::text = (auth.jwt()->>'escola_id'));

Risco: DELETE e UPDATE herdam o mesmo filtro de SELECT, que pode ser insuficiente para escrita.

Correto: uma policy por operação. Diferentes operações frequentemente precisam de diferentes níveis de permissão.

❌ INSERT/UPDATE sem WITH CHECK

sql
-- PROIBIDO — permite inserir dados de outra escola
CREATE POLICY "insert_sem_check" ON public.tabela
  FOR INSERT TO authenticated
  USING (true); -- USING em INSERT é ignorado!

Correto: sempre usar WITH CHECK em INSERT e UPDATE.

❌ Recursão infinita em SECURITY DEFINER

Quando uma policy consulta outra tabela que também tem RLS, ocorre loop infinito.

Correto: usar funções SECURITY DEFINER que bypassam RLS internamente.

→ Ref: RLS_POLICIES.md — seção sobre funções auxiliares
→ Ref: Inventário de helpers RLSaluno_pertence_escola(), get_alunos_responsavel(), etc.


4. Padrões Aprovados (Copy-Paste)

4.1 Tabela School-Scoped

Tabelas com escola_id — a grande maioria do sistema (alunos, turmas, inscrições, resultados, etc.)

sql
ALTER TABLE public.TABELA ENABLE ROW LEVEL SECURITY;

-- SELECT: usuários da mesma escola
CREATE POLICY "select_escola" ON public.TABELA
  FOR SELECT TO authenticated
  USING (escola_id::text = (auth.jwt()->>'escola_id'));

-- INSERT: usuários da mesma escola, com validação
CREATE POLICY "insert_escola" ON public.TABELA
  FOR INSERT TO authenticated
  WITH CHECK (escola_id::text = (auth.jwt()->>'escola_id'));

-- UPDATE: usuários da mesma escola
CREATE POLICY "update_escola" ON public.TABELA
  FOR UPDATE TO authenticated
  USING (escola_id::text = (auth.jwt()->>'escola_id'))
  WITH CHECK (escola_id::text = (auth.jwt()->>'escola_id'));

-- DELETE: apenas coordenador/admin da mesma escola
CREATE POLICY "delete_coordenador" ON public.TABELA
  FOR DELETE TO authenticated
  USING (
    escola_id::text = (auth.jwt()->>'escola_id')
    AND (auth.jwt()->>'principal_role') IN ('admin', 'coordenador')
  );

4.2 Tabela Global Admin-Only

Tabelas de configuração global (feature_flags, planos, configuracoes_plataforma).

sql
ALTER TABLE public.TABELA ENABLE ROW LEVEL SECURITY;

-- SELECT: qualquer autenticado pode ler
CREATE POLICY "select_authenticated" ON public.TABELA
  FOR SELECT TO authenticated
  USING (true);

-- INSERT/UPDATE/DELETE: apenas admin
CREATE POLICY "manage_admin" ON public.TABELA
  FOR INSERT TO authenticated
  WITH CHECK ((auth.jwt()->>'principal_role') = 'admin');

CREATE POLICY "update_admin" ON public.TABELA
  FOR UPDATE TO authenticated
  USING ((auth.jwt()->>'principal_role') = 'admin')
  WITH CHECK ((auth.jwt()->>'principal_role') = 'admin');

CREATE POLICY "delete_admin" ON public.TABELA
  FOR DELETE TO authenticated
  USING ((auth.jwt()->>'principal_role') = 'admin');

4.3 Tabela Self-Access

Tabelas de dados pessoais (perfil, preferências) — usuário só acessa seus próprios dados.

sql
ALTER TABLE public.TABELA ENABLE ROW LEVEL SECURITY;

CREATE POLICY "select_self" ON public.TABELA
  FOR SELECT TO authenticated
  USING (usuario_id::text = (auth.jwt()->>'sub'));

CREATE POLICY "update_self" ON public.TABELA
  FOR UPDATE TO authenticated
  USING (usuario_id::text = (auth.jwt()->>'sub'))
  WITH CHECK (usuario_id::text = (auth.jwt()->>'sub'));

4.4 Tabela de Sistema (sem policies — via service_role)

Tabelas acessadas exclusivamente por Edge Functions com createSupabaseSystem(): logs_transacoes, login_otps, cron_status.

sql
-- RLS habilitado mas SEM policies → acesso APENAS via service_role
ALTER TABLE public.TABELA ENABLE ROW LEVEL SECURITY;
-- Nenhuma policy criada intencionalmente.
-- Justificativa documentada em ADR_SERVICE_ROLE_USAGE.md

Obrigatório: registrar justificativa no ADR: service_role vs RLS.


5. Validação Pós-Criação

Query para encontrar tabelas sem policy (executar periodicamente):

sql
SELECT
  schemaname,
  tablename,
  rowsecurity
FROM pg_tables t
WHERE schemaname = 'public'
  AND NOT EXISTS (
    SELECT 1 FROM pg_policies p
    WHERE p.schemaname = t.schemaname
      AND p.tablename = t.tablename
  )
ORDER BY tablename;

Resultado esperado: apenas tabelas intencionalmente sem policy (logs, OTPs), listadas no ADR.

Query para encontrar policies FOR ALL (devem ser zero):

sql
SELECT schemaname, tablename, policyname, cmd
FROM pg_policies
WHERE schemaname = 'public'
  AND cmd = '*'  -- '*' = FOR ALL
ORDER BY tablename;

6. Cast Obrigatório: UUID → Text + lower() em Claims de Role

Colunas uuid comparadas com valores do JWT (text) exigem cast explícito. Adicionalmente, todo claim de papel (principal_role) deve ser comparado com lower() para evitar falhas silenciosas se o JWT vier com case diferente:

sql
-- ✅ Correto
USING (
  lower((auth.jwt()->>'principal_role')) = ANY (ARRAY['coordenador','diretor'])
  AND escola_id::text = (auth.jwt()->>'escola_id')
)

-- ❌ Incorreto — comparação uuid vs text pode falhar silenciosamente
USING (escola_id = (auth.jwt()->>'escola_id'))

-- ❌ Incorreto — sem lower() o policy quebra se o claim vier com case diferente
USING ((auth.jwt()->>'principal_role') = ANY (ARRAY['coordenador','diretor']))

Caso real (2026-04): A policy portal_config_coordenador_diretor_select em escola_mural_config foi escrita sem lower(). Resultado: o coordenador via "Mural não configurado" mesmo com slug ativo, porque o SELECT retornava 0 rows (PGRST116). Correção: padronizar todas as 5 policies da tabela com lower().

→ Ref: Memory rls-syntax-standards


7. Referências Cruzadas

DocumentoConteúdo
RLS_POLICIES.mdInventário completo de todas as policies existentes
ADR: service_role vs RLSJustificativa para uso de service_role em cada caso
SILENT_DATA_LOSS_AUDIT.mdAuditoria de falhas silenciosas por RLS mal configurado
DATABASE_SCHEMA.mdSchema completo com tabelas e relações

Resumo: Regras de Ouro

  1. RLS ON na mesma migration que cria a tabela
  2. Nunca FOR ALL, TO public, ou USING (true) para escrita
  3. Sempre WITH CHECK em INSERT e UPDATE
  4. Sempre cast ::text em colunas UUID
  5. Sempre filtro escola_id em tabelas school-scoped
  6. Sem policy = justificativa obrigatória no ADR
  7. Validar com queries de auditoria após cada entrega