Quase todo sistema tem uma tabela audit_log com uma coluna JSONB onde cabe tudo: login, erro, mudança de configuração, concessão de role, abertura de documento. Funciona até alguém fazer uma pergunta de governança de verdade, e aí consultar vira arqueologia.
Eu separo a trilha em três planos, porque eles respondem perguntas diferentes:
| Plano | Pergunta | Exemplos |
|---|---|---|
| Operacional | O que aconteceu no sistema? | login, erro, configuração, processamento |
| Concessão | Quem deu qual poder a quem, e quando? | roles, grants diretos, shares, revogações |
| Acesso | Quem viu ou consultou qual conteúdo? | abertura, download, impressão, consulta a microdados |
Cada plano tem volume, retenção, índices e criticidade próprios. Concessão é pouco volume e muito valor probatório. Acesso é volume alto e pede partição por tempo. Operacional é ruído útil. Misturar tudo faz uma trilha importante depender de um writer pensado para outra finalidade.
Estado não é evento
Uma tabela user_role diz quem tem a role hoje. Ela não diz quem tinha há três meses. Para isso existe uma tabela append-only de eventos. As duas são necessárias, e uma não substitui a outra.
Uma tabela de estado diz quem tem acesso. Só a trilha de eventos diz como ele chegou lá.
Assimetria de falha
Nem toda falha de auditoria deve ter a mesma consequência:
| Natureza | Se a trilha falhar | Por quê |
|---|---|---|
| operacional | best-effort | o fato pode já ter ocorrido |
| concessão | falha fechada | poder invisível é inaceitável |
| acesso sensível | falha fechada | acesso sem evidência quebra accountability |
Isso é uma decisão regulatória e operacional, e tem custo: fail-closed coloca o storage da trilha no caminho crítico. Antes de adotar, meça p50 e p99 do insert, a taxa de erro causada pela trilha, o crescimento diário, o bloat e o comportamento do pool. A política não pode exigir uma disponibilidade que a infraestrutura não foi dimensionada para sustentar.
Concessão e evento na mesma transação
BEGIN;
INSERT INTO grant_event (...);
INSERT INTO user_role (...);
UPDATE app_user
SET permission_version = permission_version + 1
WHERE id = ...;
COMMIT;Se qualquer etapa falhar, rollback de tudo. Um evento que afirma uma concessão que não existe é tão ruim quanto uma concessão sem evento. Sem Unit of Work global, um helper estreito resolve:
security_transaction(operation):
begin
operation(shared_session)
commit_or_rollbackE a regra que dá dente ao helper: qualquer call site de concessão fora dele é bug estrutural, e o CI procura por isso.
Registrar antes de servir
Quando acesso sensível é fail-closed, a ordem é autorizar, registrar o evento e só então serializar e devolver o conteúdo. O erro residual é registrar um acesso que não chegou ao usuário porque algo falhou depois. Esse erro “para mais” é auditável. O inverso, conteúdo entregue sem evento, é exatamente o que a política quer impedir.
Registre a via
Quando alguém abre um recurso, vale gravar por qual caminho a autorização foi satisfeita:
access_via = own_partition | share | all_partitions | platform_bypass | legacySe o share for revogado amanhã, a trilha continua explicando o acesso de hoje sem precisar reconstruir o estado passado. Quando mais de uma via é verdadeira ao mesmo tempo, defina uma precedência e mantenha, ou grave todas. O importante é não deixar ambiguidade histórica.
O que não registrar
Polling de interface não é acesso. Listar metadados e abrir conteúdo são eventos diferentes, e gravar cada refresh automático gera volume enorme com pouco valor probatório. Defina o que conta como acesso antes de ligar o writer.
E lembre que a própria trilha é dado pessoal: identidade, IP, horário, objeto acessado, padrão de uso. Ela precisa de controle de acesso, retenção explícita e, nos ambientes mais exigentes, de um usuário de aplicação sem UPDATE nem DELETE sobre ela.