O que vamos fazer
5 padrões de policy RLS que cobrem 90% dos casos de aplicação. Cada padrão vem com SQL pronto pra copiar, explicação de quando usar e onde costuma quebrar.
- Own-only — usuário só vê o que ele criou.
- Team-shared — membros do mesmo team veem tudo do team.
- Public read, owner write — qualquer um lê, só dono altera.
- Role-based — admin bypassa, user respeita.
- Time-window — só mostra dados dos últimos N dias.
Pré-requisitos
- Tabela criada no schema do projeto.
- RLS habilitada na tabela:
alter table proj_X.tasks enable row level security; - Auth funcionando — as policies usam
auth.uid().
Atenção: sem enable row level security, as policies existem mas não bloqueiam nada. A tabela continua aberta. Sempre ligue.
Passo a passo
Padrão 1: Own-only (usuário só vê o próprio)
Caso clássico: tabela tasks com coluna user_id. Cada user vê e edita só as próprias tarefas.
alter table proj_X.tasks enable row level security;
-- 1 policy só, cobre SELECT/INSERT/UPDATE/DELETE
create policy tasks_own
on proj_X.tasks
for all
to authenticated
using (user_id = auth.uid()) -- SELECT/UPDATE/DELETE
with check (user_id = auth.uid()); -- INSERT/UPDATE
-- Coluna user_id deve ter default
alter table proj_X.tasks
alter column user_id set default auth.uid();
Com o default auth.uid(), o client pode fazer insert({ title: 'x' }) sem passar user_id — Postgres preenche sozinho com o user logado.
Padrão 2: Team-shared (membros do team veem)
Tabela tasks tem team_id. Cada usuário pode pertencer a vários teams (tabela team_members). Todo mundo do team vê tudo do team.
create table proj_X.team_members (
team_id uuid not null,
user_id uuid not null default auth.uid(),
role text not null default 'member', -- member | admin
primary key (team_id, user_id)
);
alter table proj_X.tasks enable row level security;
create policy tasks_team_select
on proj_X.tasks for select to authenticated
using (
team_id in (
select team_id from proj_X.team_members
where user_id = auth.uid()
)
);
create policy tasks_team_insert
on proj_X.tasks for insert to authenticated
with check (
team_id in (
select team_id from proj_X.team_members
where user_id = auth.uid()
)
);
Dica de performance: tabelas com muitas linhas e IN (select ...) ficam lentas. Crie índice em team_members(user_id, team_id). Se ainda for lento, extraia pra função STABLE que cacheia por transação (ver Helpers).
Padrão 3: Public read, owner write
Posts de blog: qualquer um (até deslogado) lê, só o autor edita.
alter table proj_X.posts enable row level security;
-- Anyone reads
create policy posts_public_read
on proj_X.posts for select
to anon, authenticated
using (published = true);
-- Owner writes
create policy posts_owner_write
on proj_X.posts for all
to authenticated
using (author_id = auth.uid())
with check (author_id = auth.uid());
Repare no using (published = true) da policy de leitura: rascunhos (published=false) não aparecem pra ninguém via essa policy. Pro autor ver o próprio rascunho, a policy de owner write também concede SELECT (clauses são OR).
Padrão 4: Admin bypassa, user respeita
Tem coluna role no JWT (você setou via custom claim). Admin vê tudo, user comum só o próprio.
alter table proj_X.invoices enable row level security;
create policy invoices_user_or_admin
on proj_X.invoices for select to authenticated
using (
user_id = auth.uid()
or (auth.jwt() ->> 'role') = 'admin'
);
-- Pra escrita, só o dono ou admin
create policy invoices_write
on proj_X.invoices for all to authenticated
using (
user_id = auth.uid()
or (auth.jwt() ->> 'role') = 'admin'
)
with check (
user_id = auth.uid()
or (auth.jwt() ->> 'role') = 'admin'
);
Padrão 5: Janela temporal
Cada user só vê dados dos últimos 30 dias. Útil pra logs, eventos, dados que ficam "frios" rápido.
alter table proj_X.events enable row level security;
create policy events_recent_own
on proj_X.events for select to authenticated
using (
user_id = auth.uid()
and created_at > now() - interval '30 days'
);
Helpers úteis
Funções built-in que você usa nas policies:
auth.uid() -- uuid do user logado, ou null se anon
auth.role() -- 'authenticated' | 'anon' | 'service_role'
auth.email() -- email do user (atalho pra auth.jwt() ->> 'email')
auth.jwt() -- jsonb com claims inteiras do JWT
Pra reaproveitar lógica complexa (ex: "é admin do team X?"), extraia pra função STABLE:
create or replace function proj_X.is_team_admin(p_team_id uuid)
returns boolean
language sql
stable -- cacheia por transação — RLS amigável
security invoker
as $$
select exists (
select 1 from proj_X.team_members
where team_id = p_team_id
and user_id = auth.uid()
and role = 'admin'
)
$$;
-- Usar na policy:
create policy tasks_admin_delete on proj_X.tasks
for delete to authenticated
using (proj_X.is_team_admin(team_id));
Resultado
Com esses 5 padrões + helpers, você cobre:
- Apps individuais (todo list, notas, finance pessoal) — own-only.
- Apps colaborativos (Linear, Notion, Slack) — team-shared.
- Content sites (blog, marketplace) — public-read + owner-write.
- Admin dashboards (back-office) — role-based.
- Logs, eventos, analytics — time-window.
Variações
Combinar com funções Postgres
Lógica de policy ficando grande? Coloque numa função STABLE security invoker e chame na policy. O planejador cacheia o resultado por transação — performance OK.
FORCE ROW LEVEL SECURITY
Por padrão, o owner da tabela (geralmente postgres) bypassa RLS. Pra forçar até pro owner:
alter table proj_X.audit_log force row level security;
Útil pra audit_log ou outras tabelas onde nem o admin do banco deve dar bypass por engano.
Template multi-tabela
Tem 10 tabelas com o mesmo padrão own-only? Em vez de copiar 10 vezes, gere via DO $$ ... $$ que itera por information_schema.tables.
Erros comuns
Esquecer WITH CHECK: só USING permite SELECT/UPDATE/DELETE mas não restringe o INSERT. Resultado: user A consegue inserir linha com user_id = B. Sempre WITH CHECK em INSERT e UPDATE.
USING (true): deixa a tabela aberta. Vejo isso em produção mais do que devia. Se quis dizer "todos podem ler", use FOR SELECT USING (true), não FOR ALL USING (true).
RLS não dispara pra service_role: intencional — service_role bypassa tudo. Em scripts admin/cron, isso é desejável. Em código de aplicação, nunca use service_role no client.
Tabela criada sem ENABLE: a policy existe mas é ignorada. Resultado: tabela aberta. Cheque com select relname, relrowsecurity from pg_class where relname = 'tasks'.
Performance de policy com subquery: using (team_id in (select ...)) em tabela com milhões de linhas vira sequential scan. Solução: índice em team_members(user_id) e extrair pra função STABLE.