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 authenticated— nuncaTO public - [ ]
USING(...)em SELECT/UPDATE/DELETE com filtro restritivo - [ ]
WITH CHECK(...)em INSERT/UPDATE para validar dados de entrada - [ ] Filtro
escola_idpara tabelas school-scoped - [ ] Filtro
sub(user ID) para tabelas de self-access - [ ] Restrição por
principal_rolequando aplicável
-- 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áusula | Quando usar | O que faz |
|---|---|---|
USING(expr) | SELECT, UPDATE, DELETE | Filtra quais linhas o usuário consegue ver/modificar |
WITH CHECK(expr) | INSERT, UPDATE | Valida 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
-- 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)
-- 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)
-- 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
-- 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 RLS — aluno_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.)
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).
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.
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.
-- 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.mdObrigatório: registrar justificativa no ADR: service_role vs RLS.
5. Validação Pós-Criação
Query para encontrar tabelas sem policy (executar periodicamente):
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):
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:
-- ✅ 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_selectemescola_mural_configfoi escrita semlower(). 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 comlower().
→ Ref: Memory rls-syntax-standards
7. Referências Cruzadas
| Documento | Conteúdo |
|---|---|
| RLS_POLICIES.md | Inventário completo de todas as policies existentes |
| ADR: service_role vs RLS | Justificativa para uso de service_role em cada caso |
| SILENT_DATA_LOSS_AUDIT.md | Auditoria de falhas silenciosas por RLS mal configurado |
| DATABASE_SCHEMA.md | Schema completo com tabelas e relações |
Resumo: Regras de Ouro
- RLS ON na mesma migration que cria a tabela
- Nunca
FOR ALL,TO public, ouUSING (true)para escrita - Sempre
WITH CHECKem INSERT e UPDATE - Sempre cast
::textem colunas UUID - Sempre filtro
escola_idem tabelas school-scoped - Sem policy = justificativa obrigatória no ADR
- Validar com queries de auditoria após cada entrega