No meu esquema de perfis havia uma política assim, escrita com o helper do Drizzle para Neon:
crudPolicy({
role: authenticatedRole,
read: authUid(table.userId),
modify: false,
})
Três linhas. Dizem que um usuário autenticado só vê suas linhas e não pode modificá-las. É difícil discutir com isso. Um ano e meio depois apaguei essas três linhas, e as da tabela de feedback, no mesmo commit. Meses mais tarde repeti a remoção em outro projeto que tinha herdado o mesmo esquema.
Não foi capricho. Foi que a política e o código diziam a mesma coisa, e quando dois lugares dizem a mesma coisa um dos dois acaba mentindo.
A promessa do RLS e por que comprei
A promessa é boa: a permissão vive no banco de dados, então tanto faz quem consulta. Se amanhã eu escrever um script de manutenção, ou abrir um endpoint novo, ou alguém esquecer um where, a linha não sai. É defesa em profundidade de verdade, não um enfeite.
Montei tudo em torno disso. Duas conexões:
export const getRlsDb = async (user: StackUser): Promise<DrizzleDb> => {
const authToken = (await user?.getAuthJson())?.accessToken;
const url = process.env.NEXT_PUBLIC_DATABASE_AUTHENTICATED_URL;
const sql = neon(url, { authToken });
return drizzle(sql);
};
Uma conexão "autenticada" que leva o JWT do usuário e respeita as políticas, e outra de admin com a DATABASE_URL normal que as ignora. Cheguei até a cachear um mapa de conexões RLS por id de usuário, até perceber que estava guardando conexões amarradas a um token que expira e passei a criar uma nova a cada vez.
Onde começou a ranger com Drizzle e server actions
O problema não é o RLS falhar. É que num app com Next e server actions ele quase nunca é a camada que decide.
Cada server action já começa resolvendo a sessão. Você já tem o usuário antes de tocar no banco. E aí você escreve isto:
const db = await getRlsDb(user);
return await db.select().from(feedback).where(eq(feedback.userId, user.id));
Esse where é redundante com a política. Mas você não pode tirar, porque se tirar o código fica mentindo: parece que traz todo o feedback e depende de uma política invisível que está em outro arquivo. Então você acaba com o filtro no código e no banco, e com a dúvida sobre qual dos dois é o que está funcionando.
A segunda coisa é o atrito no dia a dia:
- Duas conexões. Cada função nova começa com a pergunta de se vai por RLS ou por admin. E tudo o que for administração, webhooks de pagamento, crons ou migrações vai por admin. Ou seja: pelo caminho que não verifica nada.
- Os erros não explicam nada. Quando a política te bloqueia ela não diz "está faltando a claim"; te devolve zero linhas. Depurar um
selectque devolve um array vazio quando a linha existe custa mais do que parece. - O token viaja para onde você não gostaria. A URL autenticada acaba numa variável com prefixo
NEXT_PUBLIC_, e isso já obriga a pensar duas vezes no que você expõe. - As políticas vivem no snapshot. Mudar uma política é gerar migração e aplicar. Mudar uma verificação em código é mudar uma linha.
Aliás, em certo momento pulei o Drizzle inteiro e passei o feedback e os perfis a falar por PostgREST, justamente para me apoiar de verdade no RLS. Ali o preço ficou claro: perdi a tipagem, tive que escrever os nomes de coluna na mão em snake_case (user_id, created_at) e cada chamada terminava no mesmo ritual.
const { data, error } = await pg.from('feedback').select('*').eq('user_id', user.id);
if (error) throw new Error(error.message);
return data as Feedback[];
Um as Feedback[] no final. Ou seja, confiar que o banco devolve o que eu digo. Isso voltou para o Drizzle duas semanas depois.
O mesmo desfecho no segundo projeto
Quando comecei o projeto seguinte partindo do mesmo boilerplate, as políticas vinham dentro. Duraram o tempo que levei para mexer na tabela de feedback para valer. O commit em que as tirei fazia outra coisa ao mesmo tempo: renomear feedback.description para feedback.content. É um detalhe bobo mas conta a história inteira: eu estava dando forma ao modelo de dados, e as políticas eram o que atrapalhava para mexer nele. Nesse commit sumiram o import de drizzle-orm/neon e as quatro chamadas a crudPolicy. As tabelas ficaram com a única coisa que de fato se usa todos os dias:
(table) => [index("idx_feedback_user_id").on(table.userId)]
Um índice por user_id. Isso sim o Postgres consulta a cada requisição.
Onde vive hoje minha lógica de permissões
No middleware e na camada de server actions. Quando larguei a auth gerenciada e escrevi meu próprio middleware de sessão, o lugar onde se decide quem vê o quê ficou num único ponto: resolve-se a sessão, verifica-se o papel, e a partir daí todas as consultas são escritas com o userId já na mão. Uma única conexão, a de admin, e nenhum caminho privilegiado paralelo.
É menos seguro no papel. Uma consulta que esqueça o where devolve linhas de outra pessoa. Compenso com o fato de essas consultas viverem todas num diretório de actions, não espalhadas pelo app, e de a assinatura da função obrigar você a passar o usuário.
Quando eu voltaria a usar RLS
Quando o banco de dados tem mais de um cliente. Se além da minha aplicação há um PostgREST público, um cliente que se conecta direto, ou analistas com acesso somente leitura, a política é o único lugar onde a regra vale para todos. Ali o custo de manter duas verdades se paga sozinho.
Também em multi-tenant duro, onde a falha não é "você vê um feedback alheio" e sim "você vê os dados de outra empresa". Ali eu quero a rede embaixo mesmo que o código já filtre.
Para uma aplicação de um cliente só, com server actions e o usuário resolvido antes da primeira consulta, RLS é uma segunda cópia de uma regra que você já tem escrita. E uma regra escrita duas vezes não é duas vezes mais segura: é duas vezes mais fácil de ficar desatualizada.
