Guardar as permissions efetivas na sessão é uma otimização razoável. Recalcular roles, grants e partições a cada request custa caro, e quase sempre o resultado é o mesmo. O problema é o “quase”. Uma sessão materializada é um cache, e cache sem estratégia de invalidação é privilégio pegajoso esperando para acontecer.
O payload costuma carregar algo assim:
user_id
roles
permissions
org_units
permission_version
authz_catalog_version
permissions_valid_untilOs três últimos campos existem porque a sessão pode ficar obsoleta por três motivos diferentes, e cada um precisa de um mecanismo próprio.
1. O usuário mudou
Ganhou ou perdeu uma role, mudou de unidade, recebeu ou perdeu um grant direto. Um contador por usuário resolve: toda mutação de autorização incrementa permission_version na mesma transação da mudança. A sessão guarda a versão com que foi resolvida, e a diferença entre as duas denuncia o cache velho.
2. O catálogo mudou
Esse é o caso que costuma passar batido. Alguém adiciona uma permission a uma role, ou remove, ou muda a semântica de uma role curinga. Nenhum usuário sofreu mutação direta, então nenhum permission_version mudou, e todas as sessões abertas continuam com o conjunto antigo. Um authz_catalog_version global, incrementado a cada mudança de catálogo, cobre exatamente esse buraco.
3. O relógio passou
Um grant temporário vence às 22:00. O relógio passar de 21:59 para 22:00 não modifica linha nenhuma no banco. Se a sessão só for invalidada quando alguma versão mudar, o privilégio vencido continua ativo até alguém mexer no usuário.
A tentação é resolver com um job que roda a cada cinco minutos e revoga o que venceu. Funciona até o job atrasar, falhar ou ser desligado numa manutenção, e aí a janela de privilégio vencido vira o intervalo entre execuções, ou infinita. A sessão precisa carregar o próximo instante em que o conjunto materializado deixa de ser confiável:
permissions_valid_until = min(expires_at dos grants diretos ativos)A expiração tem que valer mesmo se nenhum job rodar.
O predicado de re-resolução
Com os três campos, a decisão de recalcular é uma comparação barata, feita a cada request:
needs_reresolve(session, db) :=
session.permission_version != db.user_permission_version
OR session.authz_catalog_version != db.catalog_version
OR now() >= session.permissions_valid_untilQuando é verdadeiro, o servidor recalcula roles, permissions, partições e o próximo vencimento, atualiza a sessão e segue com a request. Ninguém precisa ser deslogado só porque a autorização mudou. As duas versões cabem num lookup indexado, ou num cache invalidado por evento, e o relógio é de graça.
Invalidar autorização não é matar sessão
São problemas parecidos com soluções diferentes. Desativar um usuário, resetar uma credencial ou responder a um incidente exige derrubar as sessões ativas, não só recalcular o que elas podem fazer. Por isso um índice de sessões por usuário, em Redis ou equivalente, continua útil. Versionamento responde “o que essa sessão pode fazer agora”; session kill responde “essa sessão não deveria existir”.
O bootstrap do bypass
Uma permission que atravessa o RLS cria um problema de ovo e galinha. Para saber se a conexão pode receber bypass de tenant, é preciso ler as permissions do usuário antes de abrir o escopo de tenant. Então o mínimo necessário para resolver essa autoridade é lido num escopo de sistema estreito e controlado, e só depois a aplicação decide se a conexão recebe contexto comum ou bypass explícito.
Contexto de conexão e pool
Quando o RLS lê o tenant de uma variável de sessão do Postgres, o ciclo de vida dessa variável é parte da segurança. Com connection pooling, um SET comum sobrevive à devolução da conexão, e a próxima request, de outro tenant, herda o contexto anterior. Use configuração local à transação:
SET LOCAL app.tenant_id = '...';
SET LOCAL app.is_platform_bypass = 'false';
-- ou, de dentro de uma função:
SELECT set_config('app.tenant_id', '...', true);E teste do jeito que quebra: um teste de integração que reaproveita de propósito a mesma conexão entre dois tenants e prova que tenant e bypass foram resetados. É o tipo de bug que nenhum teste unitário pega e que aparece em produção só sob carga.