# Trilha de auditoria imutável no Laravel com PostgreSQL: triggers, contexto do usuário e selo diário

> Como registrar quem mudou o quê em uma fintech com triggers no PostgreSQL, contexto do usuário via set_config e um selo diário que denuncia adulteração.

Por Lucas Roberto · Arquitetura · publicado em 2026-10-24 · 10 min de leitura
Versão web: https://lucasroberto.com/blog/trilha-de-auditoria-imutavel-no-laravel-com-postgresql-triggers-contexto-do-usuario-e-selo-diario

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`:

```sql
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](/blog/ledger-de-partidas-dobradas-no-laravel-e-postgresql-como-modelar-o-saldo-de-uma-conta-digital), 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:

```sql
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:

```php
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:

```php
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:

```php
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:

```sql
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:

```sql
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.

```php
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](/blog/como-criptografar-cpf-no-laravel-sem-perder-a-busca-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](/blog/particionamento-no-postgresql-com-laravel-como-manter-a-tabela-de-transacoes-rapida) 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.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
