Uma trilha de auditoria confiável em Laravel com PostgreSQL vem de três peças. A primeira é um trigger no banco que grava toda alteração nas tabelas sensíveis, mesmo as que não passam pelo Eloquent. A segunda é o contexto do usuário passado ao banco com set_config dentro da transação. A terceira é uma tabela append-only, protegida por permissão e por um selo criptográfico diário guardado fora do banco. Eventos do Eloquent sozinhos não bastam, porque um update() em massa ou um DB::table() passa direto por eles.

Em fintech, essa pergunta aparece sempre: o limite dessa conta mudou, quem mudou, quando, de onde e por quê? A resposta precisa existir para o time de risco, para o compliance e para o próprio dev que investiga um incidente às duas da manhã. Aqui está o desenho que considero mais equilibrado, com os trade-offs de cada decisão.

Por que o log da aplicação não serve como trilha de auditoria?

Trilha de auditoria é um registro cronológico e imutável de quem alterou qual dado, com o valor anterior e o novo. Log de aplicação (o que vai para o storage/logs ou para um agregador) não cumpre esse papel por três motivos:

  1. Não é transacional. O log é escrito mesmo quando a transação faz rollback, e pode não ser escrito quando o processo morre depois do commit. A auditoria precisa estar na mesma transação do dado.
  2. Não é completo. Ele só registra o que o dev lembrou de logar. Um comando artisan escrito às pressas para corrigir dados não loga nada.
  3. Não é estruturado para consulta. Responder "todas as alterações da conta 123 nos últimos 90 dias" num agregador de logs é possível, mas frágil e caro.

Então a auditoria mora no banco, na mesma transação, e é gravada por um mecanismo que o código da aplicação não consegue esquecer de chamar.

Eventos do Eloquent ou trigger no PostgreSQL?

Essa é a primeira decisão, e é onde vejo mais gente escolher pelo conforto. Pacotes que auditam via eventos do Eloquent (updated, deleted) são fáceis de instalar e têm o usuário logado à mão. O problema é o que fica de fora.

Critério Eventos do Eloquent Trigger no PostgreSQL
Model::where(...)->update([...]) Não captura Captura
DB::table() e SQL cru Não captura Captura
Correção manual via psql Não captura Captura
Saber o usuário logado Natural Precisa de contexto via set_config
Saber a intenção de negócio Natural Precisa de contexto (reason)
Custo de manutenção Baixo Médio (SQL nas migrations)

Updates em massa são justamente o tipo de operação que mais precisa de auditoria: o job que reprocessa taxas, o script que corrige o status de mil transações. Por isso fico com o trigger como fonte da verdade e resolvo a falta de contexto passando as informações da aplicação para o banco.

Como modelar a tabela de auditoria?

Uma tabela genérica, uma linha por alteração de linha, com o diff em jsonb:

CREATE TABLE audit_logs (
    id          bigint GENERATED ALWAYS AS IDENTITY,
    occurred_at timestamptz NOT NULL DEFAULT clock_timestamp(),
    actor_type  text,
    actor_id    text,
    request_id  text,
    reason      text,
    action      text NOT NULL,
    table_name  text NOT NULL,
    row_id      text NOT NULL,
    old_values  jsonb,
    new_values  jsonb,
    PRIMARY KEY (id)
);

CREATE INDEX audit_logs_row_idx ON audit_logs (table_name, row_id, id);
CREATE INDEX audit_logs_occurred_brin ON audit_logs USING brin (occurred_at);

O índice BRIN é um índice que guarda só o mínimo e o máximo de cada bloco de páginas. Ele é minúsculo e funciona bem aqui porque occurred_at cresce junto com a ordem física de inserção. Usei clock_timestamp() em vez de now() porque now() devolve o início da transação, e uma transação longa gravaria horários enganosos.

Essa tabela não substitui o ledger. Se você modela saldo com ledger de partidas dobradas no Laravel e PostgreSQL, os lançamentos já são imutáveis por natureza. A auditoria cobre o resto: cadastro, limites, taxas, chaves Pix, permissões do backoffice.

Como escrever o trigger de auditoria genérico?

Uma única função serve para todas as tabelas. As colunas que não devem ser auditadas são passadas como argumento do trigger:

CREATE OR REPLACE FUNCTION audit_row_change() RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
    v_old jsonb;
    v_new jsonb;
BEGIN
    IF TG_OP <> 'INSERT' THEN v_old := to_jsonb(OLD) - TG_ARGV; END IF;
    IF TG_OP <> 'DELETE' THEN v_new := to_jsonb(NEW) - TG_ARGV; END IF;

    -- update que só mexeu em colunas ignoradas não gera ruído
    IF TG_OP = 'UPDATE' AND v_old = v_new THEN
        RETURN NEW;
    END IF;

    INSERT INTO audit_logs
        (actor_type, actor_id, request_id, reason, action, table_name, row_id, old_values, new_values)
    VALUES (
        nullif(current_setting('app.actor_type', true), ''),
        nullif(current_setting('app.actor_id', true), ''),
        nullif(current_setting('app.request_id', true), ''),
        nullif(current_setting('app.reason', true), ''),
        TG_OP,
        TG_TABLE_NAME,
        coalesce(v_new ->> 'id', v_old ->> 'id'),
        v_old,
        v_new
    );

    RETURN coalesce(NEW, OLD);
END $$;

CREATE TRIGGER accounts_audit
AFTER INSERT OR UPDATE OR DELETE ON accounts
FOR EACH ROW EXECUTE FUNCTION audit_row_change('updated_at', 'remember_token');

Alguns detalhes que importam:

  • current_setting('app.actor_id', true): o segundo argumento faz devolver nulo em vez de erro quando a variável não foi definida.
  • SECURITY DEFINER faz a função rodar com as permissões do dono dela, não do usuário da aplicação. Assim o role da aplicação nem precisa de INSERT direto em audit_logs e não consegue forjar linhas. O SET search_path fixo é obrigatório em funções SECURITY DEFINER para evitar sequestro de objetos por outro schema.
  • O trigger é AFTER, então só registra o que de fato foi gravado. Se a transação fizer rollback, a linha de auditoria vai junto.

No Laravel, isso vai numa migration com DB::unprepared(), uma por tabela auditada. Mantenho a função em uma migration própria e cada CREATE TRIGGER na migration da tabela, para ficar óbvio no code review quando uma tabela nova sensível entra sem auditoria.

Como passar o usuário logado e o request_id para dentro do trigger?

O trigger não sabe quem está logado no Laravel. A ponte é set_config(nome, valor, true): o terceiro argumento true faz a configuração valer só até o fim da transação atual, como um SET LOCAL.

Esse "só até o fim da transação" é a escolha certa. Uma configuração de sessão (false) vaza entre requests quando a conexão é reaproveitada, seja com Laravel Octane, seja com PgBouncer em modo transaction, em que cada transação pode cair numa conexão física diferente. Com escopo de transação, não existe vazamento possível.

Primeiro, um middleware coloca o request_id no Context do Laravel (disponível a partir do Laravel 11), que tem a vantagem de ser propagado automaticamente para os jobs enfileirados:

final class AssignRequestId
{
    public function handle(Request $request, Closure $next): Response
    {
        Context::add('request_id', $request->header('X-Request-Id') ?? (string) Str::uuid());

        return $next($request);
    }
}

Depois, um helper que abre a transação e injeta o contexto:

final class Audited
{
    public static function transaction(callable $callback, ?string $reason = null): mixed
    {
        return DB::transaction(function () use ($callback, $reason) {
            $user = auth()->user();

            DB::select(
                'select set_config(?, ?, true), set_config(?, ?, true), set_config(?, ?, true), set_config(?, ?, true)',
                [
                    'app.actor_type', $user ? 'user' : (app()->runningInConsole() ? 'system' : 'anonymous'),
                    'app.actor_id', (string) ($user?->getAuthIdentifier() ?? ''),
                    'app.request_id', (string) Context::get('request_id', ''),
                    'app.reason', (string) ($reason ?? ''),
                ]
            );

            return $callback();
        });
    }
}

O uso fica explícito no ponto em que a intenção de negócio existe:

Audited::transaction(
    fn () => $account->update(['daily_pix_limit' => $newLimit]),
    reason: "Backoffice: aumento de limite, ticket {$ticket->id}",
);

E se alguém gravar fora do Audited::transaction? O trigger registra do mesmo jeito, só que com actor_type nulo. Isso acaba virando um recurso. Rodo uma consulta periódica por linhas sem ator nas tabelas sensíveis, e cada resultado aponta um caminho de código que esqueceu de passar o contexto. A auditoria nunca fica incompleta, só fica menos rica, e a gente descobre onde.

Se o helper for chamado dentro de uma transação já aberta, o DB::transaction aninhado vira um savepoint e o set_config vale para a transação externa inteira. Na prática isso costuma ser o desejado, mas fique atento se dois contextos diferentes forem aninhados.

Como tornar a tabela de auditoria realmente append-only?

Append-only significa que só se insere, nunca se altera nem se apaga. Uso duas camadas.

A primeira é a permissão. O usuário que a aplicação usa em produção não pode ser o mesmo que roda as migrations nem o dono das tabelas:

REVOKE ALL ON audit_logs FROM app_user;
GRANT SELECT ON audit_logs TO app_user;

A segunda é um trigger que barra alteração mesmo para quem tem permissão, o que protege contra o dono da tabela fazendo besteira com pressa:

CREATE OR REPLACE FUNCTION audit_logs_immutable() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
    RAISE EXCEPTION 'audit_logs é append-only (operação %)', TG_OP;
END $$;

CREATE TRIGGER audit_logs_no_change
BEFORE UPDATE OR DELETE ON audit_logs
FOR EACH ROW EXECUTE FUNCTION audit_logs_immutable();

CREATE TRIGGER audit_logs_no_truncate
BEFORE TRUNCATE ON audit_logs
FOR EACH STATEMENT EXECUTE FUNCTION audit_logs_immutable();

O TRUNCATE precisa de um trigger por statement à parte, porque ele não dispara triggers de linha.

Isso protege contra erro e contra a aplicação comprometida. Não protege contra um superusuário, que pode desabilitar triggers. Para esse caso, a única defesa é detectar a adulteração depois, com algo guardado fora do alcance dele.

Como detectar adulteração na auditoria com um selo diário?

A ideia é calcular, todo dia, um hash SHA-256 de todas as linhas do dia anterior em ordem de id. Esse hash é encadeado com o selo do dia anterior e gravado num lugar que nem o DBA consegue apagar, como um bucket S3 com Object Lock em modo compliance. Se alguém alterar ou remover uma linha antiga, o hash recalculado deixa de bater.

final class SealAuditDay implements ShouldQueue
{
    public function __construct(public string $day) {}

    public function handle(): void
    {
        $start = CarbonImmutable::parse($this->day, 'America/Sao_Paulo')->startOfDay();
        $end = $start->addDay();

        $row = DB::selectOne(<<<'SQL'
            select count(*) as total,
                   encode(sha256(convert_to(coalesce(string_agg(
                       concat_ws('|', id, occurred_at, actor_id, action, table_name, row_id,
                                 old_values::text, new_values::text),
                       chr(10) order by id), ''), 'UTF8')), 'hex') as digest
            from audit_logs
            where occurred_at >= ? and occurred_at < ?
        SQL, [$start, $end]);

        $previous = AuditSeal::orderByDesc('day')->value('chained_hash') ?? '';
        $chained = hash('sha256', $previous . $row->digest);

        AuditSeal::create([
            'day' => $this->day,
            'rows' => $row->total,
            'digest' => $row->digest,
            'chained_hash' => $chained,
        ]);

        Storage::disk('audit-anchor')->put("seals/{$this->day}.json", json_encode([
            'day' => $this->day,
            'rows' => $row->total,
            'chained_hash' => $chained,
        ]));
    }
}

O job roda de madrugada para o dia anterior, com folga suficiente para que nenhuma transação ainda aberta grave linhas com horário de ontem. Um segundo job, semanal, recalcula os digests de dias aleatórios e compara com o que está no bucket. Divergência vira alerta de severidade máxima.

Por que selo diário e não um hash encadeado linha a linha? O encadeamento por linha exige ler o hash anterior a cada insert. Isso serializa todas as escritas auditadas num único ponto, ou obriga a usar um lock por entidade com pg_advisory_xact_lock. Num sistema com muitas escritas concorrentes, isso vira gargalo. O selo diário não toca o caminho quente e detecta a mesma adulteração. O preço é que ele só cobre dias já fechados: uma alteração feita hoje, antes do selo, passa despercebida até lá. Para a maioria dos cenários de compliance, esse trade-off é aceitável.

E os dados pessoais dentro da auditoria?

Aqui mora uma armadilha de LGPD. Se accounts tem CPF, telefone e endereço, o trigger copia tudo isso para old_values e new_values, e agora você tem dado pessoal numa tabela que, por definição, não pode ser apagada. Um pedido de eliminação de dados vira um problema.

O que faço:

  1. Coluna criptografada continua criptografada. Se o CPF já é guardado cifrado, como descrevo em criptografar CPF no Laravel com encrypted cast e blind index, o trigger copia o texto cifrado. Ao destruir a chave daquele titular (crypto-shredding), o histórico fica ilegível sem precisar apagar linhas.
  2. Colunas que não agregam valor de auditoria saem pelos argumentos do trigger: tokens, hashes de senha, updated_at.
  3. O blind index entra no diff no lugar do valor. Ele mostra que o CPF mudou sem revelar qual era.

Como evitar que a tabela de auditoria vire o gargalo do banco?

Cada escrita auditada vira duas escritas, e a tabela só cresce. Três cuidados resolvem:

  1. Particione por mês. Com particionamento declarativo, consultas por período só tocam as partições relevantes, e partições antigas podem ir para um storage mais barato ou ser exportadas. O mesmo raciocínio do particionamento no PostgreSQL com Laravel para a tabela de transações vale aqui. Lembre que o trigger que barra UPDATE e DELETE precisa existir na tabela particionada, e que remover uma partição inteira (DETACH PARTITION) não dispara trigger de linha. Esse passo deve ser feito por um role administrativo, num procedimento documentado de retenção.
  2. Audite só o que é sensível. Tabelas de sessão, cache e filas não entram. A regra prática é auditar o que um analista de risco ou um auditor externo perguntaria.
  3. Mantenha os índices enxutos. O índice por (table_name, row_id, id) e o BRIN de data atendem quase todas as consultas. Índice GIN no jsonb só se houver busca recorrente por conteúdo do diff, porque ele encarece cada insert.

O que eu implantaria amanhã

Se eu fosse colocar isso numa base Laravel existente, seguiria esta ordem:

  1. Separar o role de migrations do role da aplicação. Sem isso, nenhuma garantia de imutabilidade se sustenta.
  2. Criar audit_logs, a função audit_row_change e os triggers de imutabilidade, tudo em migrations.
  3. Ligar o trigger primeiro nas tabelas de maior risco: contas, limites, taxas, chaves Pix e usuários do backoffice.
  4. Adicionar o middleware de request_id e o helper Audited::transaction, começando pelas ações do backoffice, que são as que mais precisam de reason.
  5. Criar o alerta para linhas sem ator e ir fechando os buracos que ele revelar.
  6. Só então montar o selo diário com bucket em Object Lock e o job de verificação.

Os passos de 1 a 3 já entregam uma auditoria completa, que não depende de ninguém lembrar de chamar nada. O resto enriquece o contexto e transforma "confia em mim" em algo que dá para provar.