Blog

Audit trail que aguenta uma auditoria de verdade.

A maioria dos sistemas tem uma tabela chamada audit_log. Ela guarda o nome da tabela alterada, o ID do registro, um JSON com o antes e o depois, e o usuário.

Parece suficiente até o dia em que alguém de fora faz perguntas. Aí você descobre que a tabela guarda mudanças de linha e a pergunta era sobre decisões de negócio, e as duas coisas não se traduzem uma na outra.

As perguntas que a auditoria realmente faz

Não é “o campo status mudou de 2 para 3 em 14 de março”.

É “quem aprovou esse pagamento acima do limite, com base em qual informação, e essa pessoa tinha autoridade pra isso na data?”.

A diferença entre as duas frases é a distância entre um log técnico e um audit trail. Um registra o efeito no banco, o outro registra o ato.

O que precisa estar em cada registro

Seis campos. Faltando qualquer um, existe uma pergunta que você não consegue responder.

O ato, em linguagem de negócio. pagamento_aprovado, não UPDATE payments. Se o nome do evento não fizer sentido pro contador, ele não serve.

Quem. O usuário, e também quem estava agindo em nome de quem. Suporte agindo por um cliente é um caso que sempre aparece e quase nunca está modelado. Registre os dois.

Quando aconteceu e quando foi registrado. Duas colunas. Divergem sempre que existe fila ou aplicativo offline, e a diferença entre elas é uma das primeiras coisas que um auditor competente pergunta.

Sob qual autoridade. A permissão ou o papel usado no momento. Papel muda com o tempo. Registrar só o ID do usuário significa que a resposta de amanhã sobre um ato de ontem usa o cargo de hoje, que está errado.

Com base em quê. Os dados que a pessoa tinha na tela quando decidiu. Essa é a que quase ninguém guarda, e é a que decide disputa. “Aprovei porque o sistema mostrava saldo suficiente” só é verificável se você tiver registrado o que o sistema mostrava.

Correlação. Um identificador que amarra esse ato à requisição, à sessão e aos outros eventos do mesmo fluxo.

create table audit_event (
  id             bigserial primary key,
  event_type     text not null,
  occurred_at    timestamptz not null,
  recorded_at    timestamptz not null default now(),

  actor_id       uuid not null,
  on_behalf_of   uuid,
  actor_role     text not null,
  actor_permissions text[] not null,

  subject_type   text not null,
  subject_id     uuid not null,

  context        jsonb not null,   -- o que estava visível na decisão
  changes        jsonb,            -- antes e depois, quando aplicável

  correlation_id uuid not null,
  source         text not null,    -- 'web', 'api', 'batch', 'support'
  ip             inet,

  prev_hash      char(64),
  hash           char(64) not null
);

Imutabilidade que não depende de disciplina

Um audit trail que aceita UPDATE não é um audit trail. É uma tabela.

Bloqueie no banco, não no code review:

create rule audit_no_update as on update to audit_event do instead nothing;
create rule audit_no_delete as on delete to audit_event do instead nothing;

revoke update, delete on audit_event from app_user;

O prev_hash encadeia os registros: cada evento carrega o hash do anterior, e o hash do atual inclui esse encadeamento. Remover um evento do meio quebra a cadeia a partir dali.

Isso não impede que alguém com acesso total ao banco reescreva tudo. Eleva o custo de “editei uma linha” pra “reescrevi todo o histórico posterior sem ninguém notar”. Escrevi o desenho completo em system design de rastreabilidade com hash único.

Se o requisito for provar integridade pra terceiro que não confia em você, publique periodicamente a raiz da cadeia em algum lugar fora do seu controle. Uma vez por dia já muda bastante a conversa.

Grave a decisão, não o diff

O erro estrutural mais comum é derivar o audit trail de trigger de banco.

Trigger é atraente porque pega tudo automaticamente e ninguém precisa lembrar de nada. E ele só enxerga o que o banco enxerga: linha, coluna, valor.

Uma aprovação que virou três UPDATE em duas tabelas aparece como três eventos técnicos desconexos. Ninguém reconstrói a decisão a partir disso. Pior: uma migração de dado gera dez mil eventos de auditoria idênticos que poluem o histórico pra sempre.

O audit trail pertence à camada de aplicação, onde a intenção existe. Trigger serve como rede de segurança para detectar alteração fora do caminho normal, o que é outro objetivo, igualmente válido.

O teste de fogo

Antes de considerar pronto, tente responder isto com uma consulta:

Todas as ações desse usuário nos últimos noventa dias, em ordem.

Tudo que aconteceu com esse pedido, incluindo o que não mudou nenhum campo (consulta, exportação, tentativa negada).

Quem tinha permissão de aprovar acima de dez mil em março.

Todos os atos originados de suporte agindo em nome de cliente.

Se alguma delas exigir cruzar log de aplicação com memória de alguém, o audit trail está incompleto.

O que quase ninguém pensa antes

Auditoria e direito ao esquecimento se contradizem. O usuário pede exclusão dos dados dele e o audit trail é imutável.

A saída usual é guardar referência em vez de conteúdo pessoal: o audit registra actor_id, e o nome vive na tabela de usuário, que é mutável e pode ser anonimizada. O histórico continua íntegro e o dado pessoal some.

Isso precisa estar decidido no desenho. Depois, com milhões de registros contendo CPF dentro de um jsonb imutável, o problema não tem solução barata.

Se o seu sistema tem dinheiro no meio, esse tema encosta em core ledger, que resolve a parte contábil da mesma necessidade.

← Voltar pro blog