RLS protege linhas. Sua coluna sensível continua exposta.
Uma tabela de perfil tem user_id, display_name, plan, credit_limit e
approved_by. A aplicação permite que cada pessoa altere o próprio nome.
A policy parece suficiente:
create policy "a pessoa altera o próprio perfil"
on public.profile
for update
to app_user
using (owner_id = current_user)
with check (owner_id = current_user);Ela responde uma pergunta importante: quais linhas aquele papel pode atualizar?
Não responde outra: quais colunas ele pode colocar no UPDATE.
Se app_user ainda possui UPDATE sobre a tabela inteira, uma atualização da
própria linha pode tentar mudar plan, credit_limit ou approved_by junto com
display_name. RLS não é um filtro de campos. A fronteira precisa separar o
registro que a pessoa pode alcançar do atributo que ela tem permissão de alterar.
São duas decisões, não uma policy maior
O PostgreSQL trata essas decisões em camadas diferentes:
privilégios SQL: este papel pode executar UPDATE nesta tabela ou coluna?
RLS: entre as linhas permitidas, quais podem ser lidas ou modificadas?
contrato de escrita: quais campos a aplicação expõe em cada fluxo?A documentação do PostgreSQL define RLS como uma forma de restringir, por usuário,
quais linhas são retornadas ou modificadas. A mesma documentação também mostra
que UPDATE pode ser concedido para colunas específicas. São controles que se
completam, não alternativas.
A ordem prática é simples: retire o privilégio amplo e devolva apenas os campos que o fluxo precisa escrever.
revoke update on table public.profile from app_user;
grant update (display_name) on table public.profile to app_user;
alter table public.profile enable row level security;
create policy "a pessoa altera apenas o próprio perfil"
on public.profile
for update
to app_user
using (owner_id = current_user)
with check (owner_id = current_user);O exemplo usa current_user apenas para deixar a diferença visível. No sistema
real, a expressão de identidade deve ser a que o runtime usa de fato. Em um app
com JWT, por exemplo, a policy precisa se apoiar na identidade validada pelo
servidor, não em um user_id aceito cegamente no body da requisição.
Com esse contrato, app_user pode tentar atualizar display_name em uma linha
permitida pela policy. Não recebe autorização de UPDATE para plan só porque
ambos os campos moram na mesma linha.
Um campo de autoridade não é preferência do usuário
A separação fica mais importante quando uma coluna muda o poder de uma identidade ou o valor de uma operação:
display_name: preferência do titular
avatar_url: preferência do titular
marketing_opt_in: escolha do titular
plan: decisão comercial
credit_limit: decisão financeira
approved_by: trilha de autoridade
role: autorizaçãoNão é a tabela que define o nível de risco. É a consequência da alteração.
Permitir que a pessoa atualize seu próprio registro é correto para os três primeiros
campos. Aplicar a mesma regra a role, credit_limit ou approved_by transforma
uma conveniência de perfil em uma escalada de privilégio ou alteração de estado
que deveria pertencer ao servidor.
Uma DTO ou schema de validação no backend ajuda a reduzir o payload aceito. Mas ela não deve ser a última barreira. Outro endpoint, uma integração futura ou uma mudança de ORM pode ignorar esse schema. O banco continua sendo o lugar onde o privilégio de escrever uma coluna sensível precisa ser negado.
Quando usar função ou endpoint estreito
Há transições que não cabem em um GRANT UPDATE (coluna): aprovar cadastro,
conceder crédito, trocar papel, fechar fatura. Elas têm regra de negócio, contexto
de auditoria e, muitas vezes, uma segunda identidade autorizadora.
Nesses casos, prefira uma operação estreita no servidor, com entrada pequena e resultado explícito:
type ApproveAccountInput = {
accountId: string;
decision: "approved" | "rejected";
};
async function approveAccount(
actor: AuthenticatedOperator,
input: ApproveAccountInput,
) {
requirePermission(actor, "accounts.approve");
return db.transaction(async (tx) => {
const account = await tx.account.findUniqueOrThrow({
where: { id: input.accountId },
});
if (account.status !== "pending") {
throw new Error("invalid account transition");
}
return tx.account.update({
where: { id: account.id },
data: {
status: input.decision,
approvedBy: actor.id,
},
});
});
}Isso não é uma receita universal de ORM. É um limite de arquitetura: o cliente não recebe uma escrita genérica sobre campos de autoridade. Ele pede uma transição nomeada; o servidor valida permissão, estado anterior e auditoria antes de tocar nos campos protegidos.
A pegadinha de RLS em APIs expostas
Em uma API baseada em Postgres, uma policy só faz seu trabalho depois que o papel tem o privilégio SQL necessário. A documentação do Supabase deixa a separação explícita: grants decidem se o papel pode executar uma operação na tabela; policies decidem a quais linhas a operação se aplica. Uma permissão ausente falha antes da policy; uma policy não remove um grant amplo já existente.
Isso explica dois bugs opostos:
- a policy está correta, mas a atualização falha porque o papel não recebeu o privilégio necessário;
- a policy está correta, a atualização da própria linha passa e um campo de
autoridade mudou porque o papel manteve
UPDATEamplo.
A solução não é tornar a expressão de RLS cada vez mais complicada. É revisar as três camadas: privilégio, linha e contrato de mutação.
Teste o que o papel não consegue fazer
O teste que importa não é só “uma pessoa consegue mudar o nome”. Ele precisa provar também os limites:
- o papel atualiza
display_namena própria linha; - o mesmo papel não atualiza
display_namena linha de outra pessoa; - o mesmo papel não atualiza
role,plan,credit_limitou outro campo de autoridade na própria linha; - a operação interna que muda um campo protegido exige o papel certo, registra o ator e rejeita uma transição de estado inválida.
Esses quatro cenários transformam a intenção de segurança em comportamento observável. Sem eles, uma migration, um novo endpoint ou uma mudança de SDK pode reabrir um campo sensível sem que a policy de linha pareça quebrada.
Fontes
- PostgreSQL: Row Security Policies: RLS restringe, por usuário, quais linhas podem ser consultadas ou modificadas; o exemplo oficial combina policy de linha com
GRANT UPDATEpor coluna. - PostgreSQL: Privileges:
UPDATEpode ser concedido para qualquer coluna ou para colunas específicas. - PostgreSQL:
GRANT: sintaxe de privilégios de coluna paraSELECT,INSERT,UPDATEeREFERENCES. - Supabase: Row Level Security: grants controlam a operação de tabela; policies delimitam as linhas alcançadas.
Próximo passo
Se a sua API permite que usuários ou agentes escrevam no banco, mas perfil, pagamento, limite ou autorização compartilham a mesma rota de atualização,
traga esse fluxo para um diagnóstico técnico de 30 minutos
.
Posts relacionados
robots.txt não tranca um painel interno
Excluir uma rota do rastreamento não decide quem pode receber seus dados. Painéis, relatórios e APIs internas precisam responder com autenticação e autorização no servidor antes de qualquer consulta.
A fila que guarda o payload cru — e por que ela salvou a operação
Passamos de escrita direta no banco para RabbitMQ, e de RabbitMQ para uma fila no próprio Postgres. A troca que importou não foi de tecnologia: foi decidir guardar o payload cru antes de processar.
Zero não é dado ausente
Quando uma API não informa uma métrica, gravar zero transforma uma lacuna de observação em uma afirmação. Preserve o desconhecido na ingestão para não fabricar resultado no dashboard.
