Art. 37 LGPD: controlador e operador devem manter registro das operações. Em fiscalização, é o documento mais pedido. Em incidente, é a única prova de cadeia de eventos. Audit log é append-only, autenticado, imutável e auditável — três garantias técnicas, não três intenções.
O que registrar — schema canônico
CREATE TABLE audit_log (
event_id UUID PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
actor JSONB NOT NULL,
action TEXT NOT NULL,
resource JSONB NOT NULL,
changes TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
context JSONB NOT NULL,
result TEXT NOT NULL,
prev_hash BYTEA NOT NULL,
self_hash BYTEA NOT NULL,
signature BYTEA
);
-- Imutabilidade no DB: revoga DML; INSERT só via função SECURITY DEFINER
REVOKE INSERT, UPDATE, DELETE ON audit_log FROM PUBLIC;
-- Trigger antifraude — nega UPDATE/DELETE mesmo de roles privilegiadas
CREATE OR REPLACE FUNCTION audit_log_block_mutation() RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'audit_log is append-only (event_id=%)', OLD.event_id;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER audit_log_no_update BEFORE UPDATE ON audit_log
FOR EACH ROW EXECUTE FUNCTION audit_log_block_mutation();
CREATE TRIGGER audit_log_no_delete BEFORE DELETE ON audit_log
FOR EACH ROW EXECUTE FUNCTION audit_log_block_mutation();
CREATE INDEX idx_audit_actor ON audit_log USING GIN (actor jsonb_path_ops);
CREATE INDEX idx_audit_resource ON audit_log USING GIN (resource jsonb_path_ops);
CREATE INDEX idx_audit_ts ON audit_log (ts);Hash chain — detecção de tampering em O(n)
import { createHash } from 'crypto';
import { canonicalize } from 'json-canonicalize'; // RFC 8785 JCS
export type AuditEntry = {
event_id: string;
ts: string;
actor: object; action: string; resource: object;
changes: string[]; context: object; result: string;
};
export function hashEntry(prevHash: Buffer, entry: AuditEntry): Buffer {
const payload = canonicalize(entry);
return createHash('sha256').update(prevHash).update(payload).digest();
}
export async function appendAudit(db: Pool, entry: AuditEntry) {
// SELECT FOR UPDATE serializa appends (1 writer); para alta vazão, particione por dia
const { rows } = await db.query<{ self_hash: Buffer }>(
'SELECT self_hash FROM audit_log ORDER BY ts DESC LIMIT 1 FOR UPDATE'
);
const prev = rows[0]?.self_hash ?? Buffer.alloc(32);
const selfHash = hashEntry(prev, entry);
await db.query(
`INSERT INTO audit_log (event_id, ts, actor, action, resource, changes, context, result, prev_hash, self_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[entry.event_id, entry.ts, entry.actor, entry.action, entry.resource,
entry.changes, entry.context, entry.result, prev, selfHash]
);
}
export async function verifyChain(db: Pool, fromTs?: string): Promise<{ ok: boolean; brokenAt?: string }> {
const cur = db.query(
`DECLARE c CURSOR FOR SELECT * FROM audit_log WHERE ts >= COALESCE($1, '-infinity') ORDER BY ts`,
[fromTs]
);
let prev = Buffer.alloc(32);
for await (const row of cur as unknown as AsyncIterable<{ event_id: string; self_hash: Buffer; [k: string]: unknown }>) {
const expected = hashEntry(prev, row as unknown as AuditEntry);
if (!expected.equals(row.self_hash)) return { ok: false, brokenAt: row.event_id };
prev = row.self_hash;
}
return { ok: true };
}WORM externo — S3 Object Lock
Hash chain detecta tampering, mas não impede deleção física. Replique o log para storage WORM (Write Once Read Many) em outra conta/região. S3 Object Lock no modo Compliance impede deleção até a retention expirar — nem o root account remove.
# Bucket dedicado para audit, Object Lock habilitado na criação (irreversível)
aws s3api create-bucket --bucket acme-audit-prod-sa-east-1 \
--create-bucket-configuration LocationConstraint=sa-east-1 \
--object-lock-enabled-for-bucket
aws s3api put-object-lock-configuration --bucket acme-audit-prod-sa-east-1 \
--object-lock-configuration '{
"ObjectLockEnabled": "Enabled",
"Rule": {
"DefaultRetention": { "Mode": "COMPLIANCE", "Years": 6 }
}
}'
# Bucket Policy: nega DeleteObjectVersion para todos exceto role específico
aws s3api put-public-access-block --bucket acme-audit-prod-sa-east-1 \
--public-access-block-configuration "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
aws s3api put-bucket-versioning --bucket acme-audit-prod-sa-east-1 \
--versioning-configuration Status=Enabled,MFADelete=Enabled \
--mfa "arn:aws:iam::ACCOUNT:mfa/audit-admin 123456"Alternativas: ledger databases
| Solução | Modelo | Trade-off | Quando faz sentido |
|---|---|---|---|
| Postgres + hash chain + S3 Lock | DIY | Controle total, código simples, dependência baixa | Maioria dos casos LGPD |
| AWS QLDB | Managed ledger (Merkle) | Verificação criptográfica nativa, query SQL-like (PartiQL) | Audit compliance heavy (finanças) |
| AWS CloudTrail Lake | Managed query layer | Apenas eventos AWS; agora aceita custom events | Ambiente AWS-centric |
| ImmuDB | Open source Merkle ledger | API K/V e SQL, prova criptográfica, self-host | Você precisa rodar, mas quer ledger pronto |
| Azure Confidential Ledger | Managed, com SGX | Hardware attestation | Compliance regulado pesado |
| Hyperledger Fabric | Blockchain consortium | Pesado, multi-org | Apenas se múltiplas entidades co-auditam |
O que NÃO logar — minimização também aqui
Audit log é alvo de alta atratividade — atacante quer apagar trace. Minimize o blast radius caso comprometido.
AWS CloudTrail + Lake — para infra
Para eventos de infraestrutura (RDS snapshot, IAM AssumeRole, S3 GetObject de PII), use CloudTrail Lake. É audit log gerenciado pela AWS, com retenção até 10 anos, SQL queryable e integrity validation built-in.
Decisão: tabela Postgres vs QLDB vs CloudTrail Lake
📋 Startup BR, foco LGPD, stack Postgres + AWS, ainda sem requisitos de auditoria externa pesada
Custo baixo, controle total, código simples e auditável por você mesmo. Hash chain dá tamper detection, S3 Object Lock dá WORM. ANPD aceita. Se virar banco/fintech sob BCB, migra para QLDB ou amplia. Não comece com complexidade que pode não precisar.
Alt: AWS QLDB —
Alt: CloudTrail Lake —
Alt: Apenas logs em CloudWatch —
Verificação periódica — sem isso, audit log é teatro
#!/usr/bin/env bash
# Roda diariamente via cron. Falha = alerta P1 (potential tampering)
set -euo pipefail
RESULT=$(psql "$DB_URL" -At -c "SELECT * FROM verify_audit_chain();")
if [ "$RESULT" != "ok" ]; then
echo "AUDIT CHAIN BROKEN: $RESULT" >&2
# Notifica PagerDuty
curl -X POST -H "Authorization: Token token=$PD_TOKEN" \
-d "{\"event_action\":\"trigger\",\"payload\":{\"summary\":\"Audit hash chain broken: $RESULT\"}}" \
https://events.pagerduty.com/v2/enqueue
exit 1
fi
# Verifica sink S3 — checksum recalculado
aws s3 sync s3://acme-audit-prod-sa-east-1/dt=$(date -d yesterday +%F)/ ./tmp/audit/
python verify_s3_chain.py ./tmp/audit/