audit_log_rails
Auditoria de models ActiveRecord para Rails: registra quem criou, alterou ou excluiu cada registro, quando e o que mudou (valor antigo e valor novo), numa tabela própria, com uma versão já traduzida para exibir na tela.
class Contrato < ApplicationRecord
auditable owner: :pessoa
end
contrato.update!(cargo: assessor)
contrato.audit_logs.last.audited_changes_humanize
# => { "type" => "update",
# "fields" => { "cargo_id" => { "label" => "Cargo", "old_value" => 3, "new_value" => 7,
# "old_value_label" => "PROFESSOR", "new_value_label" => "ASSESSOR" } } }Sumário
- Recursos
- Compatibilidade
- Instalação
- Configuração
- Quem fez a alteração (contexto do ator)
- Auditando uma model
- Consultando os logs
- Campos sensíveis
- Histórico de um agregado (dono)
- Rótulo de associações
- Eventos manuais
- Falhas não derrubam a requisição
- Formato dos dados
- Tradução dos campos (I18n)
- Atualizando da 0.1.x
- Limitações e cuidados
- Desenvolvimento
Recursos
-
auditablena model: registracreate,updateedestroydepois do commit. Se houver rollback, nada é gravado. -
Só o que mudou: em
update, guarda apenas os campos alterados, comold_valueenew_value. -
Dois formatos: o bruto (
audited_changes) e o humanizado (audited_changes_humanize), com o nome traduzido de cada campo. - Campos sensíveis mascarados: a troca de senha ou token fica registrada, sem os valores.
-
Rótulo de associações: em
belongs_to, o humanizado traz o nome do registro ("PROFESSOR → ASSESSOR"), e não só o id. - Dono do registro: junta num só histórico a pessoa, os contratos e os dependentes dela.
-
Eventos manuais: para o que não passa por callback, como login, pedido de reset de senha e
update_all. - Falha segura: se gravar o log falhar, a requisição do usuário não quebra.
- Contexto do ator: usuário, tipo, metadados, IP e um uuid que agrupa as alterações da mesma requisição.
Compatibilidade
| Versão | |
|---|---|
| Ruby | >= 3.1 |
| Rails / ActiveRecord |
>= 7.0 e < 9.0
|
| Banco | PostgreSQL (a migration usa jsonb). Em outros bancos, troque as colunas JSON por text: a gem serializa sozinha. |
A gem se chama audit_log_rails, mas o namespace Ruby é AuditLogger.
Instalação
-
Adicione a gem ao
Gemfilee instale:gem "audit_log_rails"
bundle install
-
Gere a migration e o initializer:
bin/rails generate audit_logger:install
O comando cria:
-
db/migrate/<timestamp>_create_audit_logs.rb: a tabelaaudit_logs; -
config/initializers/audit_logger.rb: a configuração, com os exemplos comentados.
-
-
Rode a migration:
bin/rails db:migrate
-
Ajuste o initializer para o seu app: quem é o usuário atual, o IP e assim por diante. Veja Configuração.
-
Coloque
auditablenas models que quiser auditar.
A tabela audit_logs
| Coluna | Conteúdo |
|---|---|
model_class_name |
Classe auditada, ex.: Contrato
|
id_object |
Id do registro auditado (texto) |
action |
create, update, destroy ou o nome do evento manual |
uuid |
Agrupa os logs da mesma requisição ou fluxo |
changed_by_id / changed_by_type
|
Quem fez a alteração |
changed_by_other |
JSON livre com metadados do ator (nome, e-mail, request id...) |
audited_changes |
Payload bruto |
audited_changes_humanize |
Payload humanizado |
ip_remote |
IP de origem |
owner_type / owner_id
|
Registro "dono" do log (desde a 0.2.0) |
created_at, updated_at, deleted_at
|
Datas |
A migration também cria os índices de consulta: por model e id, uuid, ação, ator, dono e data.
Configuração
Tudo fica em config/initializers/audit_logger.rb:
AuditLogger.configure do |config|
# Quem fez a alteração.
config.changed_by_id_resolver = -> { Current.user&.id }
config.changed_by_type_resolver = -> { Current.user&.class&.name }
config.changed_by_other_resolver = -> { { nome: Current.user&.nome, request_id: Current.request_id }.compact }
# Agrupamento e origem.
config.uuid_resolver = -> { Current.request_id }
config.ip_resolver = -> { Current.ip }
# O que entra no log.
config.ignored_attributes = %i[created_at updated_at lock_version]
config.masked_attributes += %i[token_api]
config.mask_value = "[FILTRADO]"
# Humanização.
config.humanize_by_default = true
config.i18n_scopes = ["activerecord.attributes", "attributes"]
config.resolve_associations = true
config.humanizer = nil
# Falhas.
config.raise_errors = Rails.env.test?
config.error_handler = ->(error, record, action) { Rails.error.report(error, context: { action: action, record: record&.class&.name }) }
end| Opção | Padrão | O que faz |
|---|---|---|
changed_by_id_resolver |
nil |
Valor gravado em changed_by_id
|
changed_by_type_resolver |
nil |
Valor gravado em changed_by_type (classe, perfil, papel...) |
changed_by_other_resolver |
-> { {} } |
Hash gravado em changed_by_other
|
uuid_resolver |
nil |
Uuid de correlação; sem valor, a gem gera um por log |
ip_resolver |
nil |
Valor gravado em ip_remote
|
ignored_attributes |
created_at, updated_at, lock_version
|
Campos que nunca entram no log |
masked_attributes |
password, password_digest, encrypted_password, reset_password_token, confirmation_token, unlock_token
|
Campos registrados sem o valor (Campos sensíveis) |
mask_value |
"[FILTRADO]" |
Texto que substitui o valor mascarado |
humanize_by_default |
true |
Gera o audited_changes_humanize
|
i18n_scopes |
["activerecord.attributes", "attributes"] |
Onde procurar a tradução dos campos |
resolve_associations |
true |
Rótulo do registro em belongs_to (Rótulo de associações) |
humanizer |
nil |
Humanização personalizada (Tradução dos campos) |
raise_errors |
false |
Deixa o erro de gravação do log subir (útil em teste) |
error_handler |
nil |
->(error, record, action) chamado quando gravar o log falha |
Todo resolver é um lambda. Se um resolver lançar erro, a gem registra um aviso e grava o campo vazio; o log não se perde por isso. Uma configuração inválida, como um valor que não é lambda, gera AuditLogger::ConfigurationError na subida do app.
Quem fez a alteração (contexto do ator)
A gem não sabe sozinha quem está logado. O jeito mais comum é usar ActiveSupport::CurrentAttributes:
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :user, :request_id, :ip
end# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base # ou ActionController::API
before_action do
Current.user = current_user # na API com JWT, o usuário que o token identificou
Current.request_id = request.request_id
Current.ip = request.remote_ip
end
endCom uuid_resolver = -> { Current.request_id }, todos os logs de uma mesma requisição ficam com o mesmo uuid. Por exemplo, salvar a pessoa e os contratos dela num formulário só.
Fora de requisição (jobs, rake, console), não há Current.user. O log é gravado com o ator vazio. Se quiser identificar a origem, preencha o Current no job.
Auditando uma model
class Pessoa < ApplicationRecord
auditable
endOpções, todas opcionais e válidas só para a model:
| Opção | Exemplo | O que faz |
|---|---|---|
ignored_attributes |
[:sincronizado_em] |
Soma aos ignorados globais |
masked_attributes |
[:token_generate] |
Soma aos mascarados globais |
owner |
:pessoa ou ->(r) { r.contrato&.pessoa }
|
Registro dono do log (Histórico de um agregado) |
humanize |
false |
Liga ou desliga a humanização nesta model |
i18n_scopes |
["meu_app.campos", "activerecord.attributes"] |
Escopos de tradução próprios |
humanizer |
->(klass, campo, antigo, novo) { ... } |
Humanização própria da model |
class User < ApplicationRecord
auditable masked_attributes: [:token_generate], ignored_attributes: [:last_sign_in_at]
endConsultando os logs
pessoa.audit_logs # logs do próprio registro
pessoa.audit_logs.where(action: "update").order(created_at: :desc)
AuditLogger::AuditLog.of_owner(pessoa) # pessoa + registros que têm ela como dona
AuditLogger::AuditLog.where(model_class_name: "Contrato", action: "destroy")
AuditLogger::AuditLog.where(changed_by_id: user.id)Para telas administrativas, filtros com Ransack ou scopes próprios, crie uma model no app que herda da model da gem:
class AuditLog < AuditLogger::AuditLog
scope :recentes, -> { order(created_at: :desc) }
def self.ransackable_attributes(_auth = nil) = %w[model_class_name action changed_by_id created_at]
endCampos sensíveis
Pôr a senha em ignored_attributes faz a troca de senha sumir do histórico. Em masked_attributes, a alteração aparece, mas sem os valores:
{ "type": "update", "fields": { "encrypted_password": { "old_value": "[FILTRADO]", "new_value": "[FILTRADO]" } } }- O valor nulo continua nulo, para mostrar que o campo estava vazio.
- No
create, um campo mascarado preenchido aparece como"[FILTRADO]". - Se um campo estiver nos ignorados e nos mascarados ao mesmo tempo, vale o ignorado.
Histórico de um agregado (dono)
Cada log guarda um registro "dono" (owner_type/owner_id). Sem configuração, o dono é o próprio registro. Para juntar o histórico de várias models num lugar só:
class Pessoa < ApplicationRecord
auditable # dona de si mesma
end
class Contrato < ApplicationRecord
belongs_to :pessoa
auditable owner: :pessoa # associação
end
class Dependente < ApplicationRecord
belongs_to :contrato
auditable owner: ->(dependente) { dependente.contrato&.pessoa } # caminho mais longo
end
AuditLogger::AuditLog.of_owner(pessoa) # tudo da pessoa, do mais recente ao mais antigoSe a associação do dono estiver vazia, o dono passa a ser o próprio registro. Com STI, o owner_type é a classe base.
Rótulo de associações
Com resolve_associations = true, a troca de um belongs_to ganha no formato humanizado o nome do registro antigo e do novo:
{ "label": "Cargo", "old_value": 3, "new_value": 7, "old_value_label": "PROFESSOR", "new_value_label": "ASSESSOR" }No create e no destroy, o rótulo vai em value_label. Ele vem do to_s definido na model associada; se ela não tiver um, de display_name, descricao, nome, name ou title. A busca inclui registros excluídos por soft delete (with_deleted), porque o histórico precisa do nome antigo.
Cada rótulo custa uma consulta ao banco. Em tabelas com muitas associações e muita escrita, avalie desligar a opção na model (humanize: false) ou no global.
Eventos manuais
O que não passa pelos callbacks da model é registrado com AuditLogger.record_event. É o caso do update_all, do login e do pedido de reset de senha.
# Com um registro: usa a humanização e o dono da model dele.
AuditLogger.record_event(
action: "password_reset_requested",
record: user,
fields: { encrypted_password: { old_value: "[FILTRADO]", new_value: "[FILTRADO]" } },
metadata: { origem: "esqueci_minha_senha" }
)
# Sem registro (ex.: login de CPF que não existe).
AuditLogger.record_event(action: "login_failed", model_class_name: "User", id_object: cpf, metadata: { motivo: "senha" })
# Alteração em massa, com dono explícito.
ids = perfil.users.where(id: user_ids).pluck(:id)
perfil.users.where(id: ids).update_all(g_perfil_id: nil)
AuditLogger.record_event(action: "usuarios_desvinculados", record: perfil, metadata: { user_ids: ids })| Parâmetro | Obrigatório | Conteúdo |
|---|---|---|
action |
sim | Nome do evento |
record |
não | Registro relacionado; define model, id e dono |
model_class_name, id_object
|
não | Usados quando não há record
|
owner |
não | Dono explícito (sobrepõe o da model) |
fields |
não | Mesmo formato do log automático |
metadata |
não | Hash livre, gravado em audited_changes["metadata"]
|
Falhas não derrubam a requisição
O log é gravado depois do commit, quando o dado do usuário já foi salvo. Se a gravação do log falhar (banco fora, JSON inválido, erro no humanizer), a gem:
- registra o erro com
Rails.logger.error; - chama o
error_handler, se houver; - segue sem lançar exceção. A requisição não vira erro 500.
Em teste, use config.raise_errors = true para o erro aparecer.
Formato dos dados
audited_changes (bruto) e audited_changes_humanize (humanizado) têm a mesma estrutura:
O humanizado acrescenta em cada campo:
-
label: o nome traduzido; -
old_value_label,new_value_labelouvalue_label: o rótulo, nos campos debelongs_to.
Tradução dos campos (I18n)
O label é procurado nesta ordem, para cada escopo de i18n_scopes:
-
<escopo>.<model>.<campo>, ex.:activerecord.attributes.pessoa.nome; -
<escopo>.<campo>, ex.:attributes.nome.
Se não achar nada, usa campo.humanize.
pt-BR:
activerecord:
attributes:
pessoa:
nome: "Nome"
cargo_id: "Cargo"Para controlar a apresentação de um campo, use um humanizer, global ou na model. Ele pode devolver:
-
nil: usa a tradução padrão; - um
Hash: as chaves são mescladas ao campo (ex.:{ label:, old_value:, new_value: }); - um valor simples: vira o
valuedo campo.
auditable humanizer: ->(_klass, campo, antigo, novo) do
next unless campo == "situacao"
{ label: "Situação", old_value: SITUACOES[antigo], new_value: SITUACOES[novo] }
endUma falha no humanizer é registrada como aviso, e o campo cai na tradução padrão.
Atualizando da 0.1.x
A 0.2.0 traz as colunas owner_type e owner_id. Para criá-las:
bin/rails generate audit_logger:upgrade
bin/rails db:migrateSem as colunas a gem continua funcionando; só não grava o dono. As demais novidades estão no CHANGELOG:
- campos mascarados;
- falha segura;
- rótulos de associação;
- eventos manuais.
Limitações e cuidados
-
Operações em massa não disparam callbacks.
update_all,delete_all,insert_all,upsert_alleupdate_column(s)não geram log; registre comAuditLogger.record_event. -
Soft delete:
- com
paranoia, odestroydispara os callbacks de destroy, então confira no seu app se oafter_destroy_commité chamado; - com
discard, a exclusão é umupdatee aparece como tal, comdiscarded_atmudando.
- com
-
PostgreSQL: a migration gerada usa
jsonb. Nos testes da gem, as colunas sãotextem SQLite. -
urleuser_agentnão têm coluna própria: guarde emchanged_by_other.
Desenvolvimento
bin/setup
bundle exec rake test # Minitest com SQLite em memória
bundle exec rubocop
bundle-audit check # dependências com CVEPara publicar uma versão:
- Atualize
lib/audit_logger/version.rbe oCHANGELOG.md. - Rode
bundle exec rake test. - Gere o pacote com
gem build audit_logger.gemspec. - Publique com
gem push audit_log_rails-<versão>.gem(pede login e o código de verificação do RubyGems). - Crie a tag
v<versão>no Git e envie:git tag v<versão> && git push origin main --tags.
Licença
Código aberto sob a licença MIT.