audit_log_rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +776 -0
- data/Rakefile +12 -0
- data/lib/audit_logger/actor_context_resolver.rb +65 -0
- data/lib/audit_logger/audit_log.rb +47 -0
- data/lib/audit_logger/auditable.rb +47 -0
- data/lib/audit_logger/change_extractor.rb +75 -0
- data/lib/audit_logger/config_validator.rb +76 -0
- data/lib/audit_logger/configuration.rb +48 -0
- data/lib/audit_logger/humanizer.rb +112 -0
- data/lib/audit_logger/model_config.rb +80 -0
- data/lib/audit_logger/railtie.rb +17 -0
- data/lib/audit_logger/record_audit_entry.rb +61 -0
- data/lib/audit_logger/version.rb +5 -0
- data/lib/audit_logger.rb +44 -0
- data/lib/generators/audit_logger/install_generator.rb +33 -0
- data/lib/generators/audit_logger/templates/audit_logger_initializer.rb.tt +48 -0
- data/lib/generators/audit_logger/templates/create_audit_logs.rb.tt +32 -0
- data/sig/audit_logger.rbs +4 -0
- metadata +107 -0
data/Rakefile
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AuditLogger
|
|
4
|
+
class ActorContextResolver
|
|
5
|
+
def self.call(configuration)
|
|
6
|
+
new(configuration).call
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(configuration)
|
|
10
|
+
@configuration = configuration
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Resolve todos os campos de contexto do ator em um unico ponto.
|
|
14
|
+
def call
|
|
15
|
+
{
|
|
16
|
+
changed_by_id: call_resolver(:changed_by_id, configuration.changed_by_id_resolver),
|
|
17
|
+
changed_by_type: call_resolver(:changed_by_type, configuration.changed_by_type_resolver),
|
|
18
|
+
changed_by_other: normalize_json_object(call_resolver(:changed_by_other, configuration.changed_by_other_resolver)),
|
|
19
|
+
uuid: resolve_uuid,
|
|
20
|
+
ip_remote: call_resolver(:ip_remote, configuration.ip_resolver)
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
attr_reader :configuration
|
|
27
|
+
|
|
28
|
+
# Executa o resolver apenas quando ele foi configurado e isola falhas.
|
|
29
|
+
def call_resolver(label, resolver)
|
|
30
|
+
return nil if resolver.nil?
|
|
31
|
+
|
|
32
|
+
resolver.call
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
log_warning(label, e)
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Garante que o metadado adicional sempre seja persistido como objeto JSON.
|
|
39
|
+
def normalize_json_object(value)
|
|
40
|
+
return {} if value.nil?
|
|
41
|
+
return value if value.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
{ value: value }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Mantem um fallback seguro para correlacao quando o app nao informar uuid.
|
|
47
|
+
def resolve_uuid
|
|
48
|
+
value = call_resolver(:uuid, configuration.uuid_resolver)
|
|
49
|
+
return value if value && !value.to_s.empty?
|
|
50
|
+
|
|
51
|
+
SecureRandom.uuid
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Evita que falhas de contexto derrubem a auditoria inteira da requisicao.
|
|
55
|
+
def log_warning(label, error)
|
|
56
|
+
message = "[AuditLogger] Falha ao resolver #{label}: #{error.class} - #{error.message}"
|
|
57
|
+
|
|
58
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
59
|
+
Rails.logger.warn(message)
|
|
60
|
+
else
|
|
61
|
+
warn(message)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AuditLogger
|
|
4
|
+
class AuditLog < ActiveRecord::Base
|
|
5
|
+
self.table_name = "audit_logs"
|
|
6
|
+
|
|
7
|
+
JSON_FIELDS = %w[changed_by_other audited_changes audited_changes_humanize].freeze
|
|
8
|
+
|
|
9
|
+
# Mantem compatibilidade entre bancos nativos JSON/JSONB e colunas texto sem
|
|
10
|
+
# depender de conexao ativa no carregamento da classe.
|
|
11
|
+
JSON_FIELDS.each do |field_name|
|
|
12
|
+
define_method("#{field_name}=") do |value|
|
|
13
|
+
super(normalize_json_assignment(field_name, value))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
define_method(field_name) do
|
|
17
|
+
deserialize_json_value(super())
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# Em bancos sem suporte nativo a JSON, persiste como string JSON.
|
|
24
|
+
def normalize_json_assignment(field_name, value)
|
|
25
|
+
return value if value.nil? || json_like_column?(field_name)
|
|
26
|
+
return value if value.is_a?(String)
|
|
27
|
+
|
|
28
|
+
JSON.generate(value)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Ao ler colunas texto, reconstrói o Hash/Array original para o restante da gem.
|
|
32
|
+
def deserialize_json_value(value)
|
|
33
|
+
return value unless value.is_a?(String)
|
|
34
|
+
|
|
35
|
+
JSON.parse(value)
|
|
36
|
+
rescue JSON::ParserError
|
|
37
|
+
value
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A verificacao do tipo fica em runtime, quando a conexao ja deve estar disponivel.
|
|
41
|
+
def json_like_column?(field_name)
|
|
42
|
+
self.class.type_for_attribute(field_name.to_s).type.in?([ :json, :jsonb ])
|
|
43
|
+
rescue ActiveRecord::ConnectionNotDefined, ActiveRecord::NoDatabaseError
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AuditLogger
|
|
4
|
+
module Auditable
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
# Guarda a configuracao local da model sem poluir instancias.
|
|
9
|
+
class_attribute :audit_logger_model_config, instance_accessor: false, default: ModelConfig.new
|
|
10
|
+
# Evita registrar os mesmos callbacks mais de uma vez.
|
|
11
|
+
class_attribute :audit_logger_callbacks_registered, instance_accessor: false, default: false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class_methods do
|
|
15
|
+
# DSL principal da gem usada pelas models do projeto cliente.
|
|
16
|
+
def auditable(**options)
|
|
17
|
+
# Mantem a configuracao por model isolada do restante da gem.
|
|
18
|
+
self.audit_logger_model_config = ModelConfig.new(**options)
|
|
19
|
+
|
|
20
|
+
# Expoe uma associacao simples para consultar os logs desta model.
|
|
21
|
+
define_audit_logs_association!
|
|
22
|
+
|
|
23
|
+
return if audit_logger_callbacks_registered
|
|
24
|
+
|
|
25
|
+
# Usa callbacks de commit para evitar auditoria falsa em caso de rollback.
|
|
26
|
+
after_create_commit { AuditLogger::RecordAuditEntry.call(self, action: :create) }
|
|
27
|
+
after_update_commit { AuditLogger::RecordAuditEntry.call(self, action: :update) }
|
|
28
|
+
after_destroy_commit { AuditLogger::RecordAuditEntry.call(self, action: :destroy) }
|
|
29
|
+
|
|
30
|
+
self.audit_logger_callbacks_registered = true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Define a associacao apenas uma vez para evitar conflitos com reload da classe.
|
|
36
|
+
def define_audit_logs_association!
|
|
37
|
+
return if reflect_on_association(:audit_logs)
|
|
38
|
+
|
|
39
|
+
has_many :audit_logs,
|
|
40
|
+
->(record) { where(model_class_name: record.class.name) },
|
|
41
|
+
class_name: "AuditLogger::AuditLog",
|
|
42
|
+
foreign_key: :id_object,
|
|
43
|
+
primary_key: :id
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AuditLogger
|
|
4
|
+
class ChangeExtractor
|
|
5
|
+
def initialize(record:, action:, model_config:, configuration:)
|
|
6
|
+
@record = record
|
|
7
|
+
@action = action.to_s
|
|
8
|
+
@model_config = model_config
|
|
9
|
+
@configuration = configuration
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Retorna o payload bruto no formato padronizado da gem.
|
|
13
|
+
def call
|
|
14
|
+
{
|
|
15
|
+
"type" => action,
|
|
16
|
+
"fields" => extract_fields
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader :record, :action, :model_config, :configuration
|
|
23
|
+
|
|
24
|
+
# Direciona a extracao conforme o tipo da acao auditada.
|
|
25
|
+
def extract_fields
|
|
26
|
+
case action
|
|
27
|
+
when "create"
|
|
28
|
+
snapshot_fields
|
|
29
|
+
when "update"
|
|
30
|
+
update_fields
|
|
31
|
+
when "destroy"
|
|
32
|
+
snapshot_fields
|
|
33
|
+
else
|
|
34
|
+
{}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Usa snapshot completo em create e destroy para preservar contexto.
|
|
39
|
+
def snapshot_fields
|
|
40
|
+
filtered_attributes.each_with_object({}) do |(attribute, value), result|
|
|
41
|
+
result[attribute] = { "value" => value }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Em update salva apenas delta com old/new para reduzir ruido.
|
|
46
|
+
def update_fields
|
|
47
|
+
filtered_previous_changes.each_with_object({}) do |(attribute, values), result|
|
|
48
|
+
old_value, new_value = values
|
|
49
|
+
result[attribute] = {
|
|
50
|
+
"old_value" => old_value,
|
|
51
|
+
"new_value" => new_value
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Remove do snapshot os atributos que nao devem ser auditados.
|
|
57
|
+
def filtered_attributes
|
|
58
|
+
record.attributes.reject do |attribute, _value|
|
|
59
|
+
ignored_attributes.include?(attribute)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Filtra as mudancas do ActiveRecord antes de montar o delta final.
|
|
64
|
+
def filtered_previous_changes
|
|
65
|
+
record.previous_changes.reject do |attribute, _value|
|
|
66
|
+
ignored_attributes.include?(attribute)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Memoiza a lista final de ignored attributes para nao recalcular varias vezes.
|
|
71
|
+
def ignored_attributes
|
|
72
|
+
@ignored_attributes ||= model_config.effective_ignored_attributes(configuration)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AuditLogger
|
|
4
|
+
class ConfigValidator
|
|
5
|
+
# Campos que aceitam estrategia configuravel via Proc/Lambda.
|
|
6
|
+
CALLABLE_FIELDS = %i[
|
|
7
|
+
changed_by_id_resolver
|
|
8
|
+
changed_by_type_resolver
|
|
9
|
+
changed_by_other_resolver
|
|
10
|
+
uuid_resolver
|
|
11
|
+
ip_resolver
|
|
12
|
+
humanizer
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
def self.validate!(configuration)
|
|
16
|
+
new(configuration).validate!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(configuration)
|
|
20
|
+
@configuration = configuration
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Centraliza a validacao para falhar cedo no boot da aplicacao.
|
|
24
|
+
def validate!
|
|
25
|
+
validate_callable_fields!
|
|
26
|
+
validate_humanize_by_default!
|
|
27
|
+
validate_i18n_scopes!
|
|
28
|
+
validate_ignored_attributes!
|
|
29
|
+
|
|
30
|
+
configuration
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
attr_reader :configuration
|
|
36
|
+
|
|
37
|
+
# Garante que os pontos extensivos da gem sejam sempre chamaveis.
|
|
38
|
+
def validate_callable_fields!
|
|
39
|
+
CALLABLE_FIELDS.each do |field_name|
|
|
40
|
+
value = configuration.public_send(field_name)
|
|
41
|
+
next if value.nil? || value.is_a?(Proc)
|
|
42
|
+
|
|
43
|
+
raise ConfigurationError,
|
|
44
|
+
"#{field_name} deve ser um Proc/Lambda ou nil"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Evita estados ambiguos de configuracao para humanizacao.
|
|
49
|
+
def validate_humanize_by_default!
|
|
50
|
+
return if [true, false].include?(configuration.humanize_by_default)
|
|
51
|
+
|
|
52
|
+
raise ConfigurationError,
|
|
53
|
+
"humanize_by_default deve ser true ou false"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Obriga escopos consistentes para o fallback de traducao.
|
|
57
|
+
def validate_i18n_scopes!
|
|
58
|
+
value = configuration.i18n_scopes
|
|
59
|
+
|
|
60
|
+
unless value.is_a?(Array) && value.all? { |item| item.is_a?(String) && !item.empty? }
|
|
61
|
+
raise ConfigurationError,
|
|
62
|
+
"i18n_scopes deve ser um Array de strings nao vazias"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Mantem a comparacao de atributos ignorados previsivel.
|
|
67
|
+
def validate_ignored_attributes!
|
|
68
|
+
value = configuration.ignored_attributes
|
|
69
|
+
|
|
70
|
+
unless value.is_a?(Array) && value.all? { |item| item.is_a?(String) || item.is_a?(Symbol) }
|
|
71
|
+
raise ConfigurationError,
|
|
72
|
+
"ignored_attributes deve ser um Array de strings ou symbols"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AuditLogger
|
|
4
|
+
class Configuration
|
|
5
|
+
# Cada accessor representa um ponto configuravel da gem.
|
|
6
|
+
attr_accessor :changed_by_id_resolver,
|
|
7
|
+
:changed_by_type_resolver,
|
|
8
|
+
:changed_by_other_resolver,
|
|
9
|
+
:uuid_resolver,
|
|
10
|
+
:ip_resolver,
|
|
11
|
+
:humanize_by_default,
|
|
12
|
+
:i18n_scopes,
|
|
13
|
+
:ignored_attributes,
|
|
14
|
+
:humanizer
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
reset!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Restaura todos os defaults para um estado conhecido e previsivel.
|
|
21
|
+
def reset!
|
|
22
|
+
# Defaults simples para que a gem funcione sem obrigar configuracao imediata.
|
|
23
|
+
self.changed_by_id_resolver = nil
|
|
24
|
+
self.changed_by_type_resolver = nil
|
|
25
|
+
self.changed_by_other_resolver = -> { {} }
|
|
26
|
+
self.uuid_resolver = nil
|
|
27
|
+
self.ip_resolver = nil
|
|
28
|
+
self.humanize_by_default = true
|
|
29
|
+
self.i18n_scopes = default_i18n_scopes
|
|
30
|
+
self.ignored_attributes = default_ignored_attributes
|
|
31
|
+
self.humanizer = nil
|
|
32
|
+
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Escopos padrao usados para localizar labels de atributos.
|
|
39
|
+
def default_i18n_scopes
|
|
40
|
+
["activerecord.attributes", "attributes"]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Campos normalmente ruidosos que nao agregam valor na auditoria.
|
|
44
|
+
def default_ignored_attributes
|
|
45
|
+
%i[created_at updated_at lock_version]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AuditLogger
|
|
4
|
+
class Humanizer
|
|
5
|
+
def initialize(record:, payload:, model_config:, configuration:)
|
|
6
|
+
@record = record
|
|
7
|
+
@payload = payload
|
|
8
|
+
@model_config = model_config
|
|
9
|
+
@configuration = configuration
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Gera o payload humanizado mantendo a mesma estrutura do payload bruto.
|
|
13
|
+
def call
|
|
14
|
+
return empty_payload unless model_config.humanize_enabled?(configuration)
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
"type" => payload["type"],
|
|
18
|
+
"fields" => humanized_fields
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
attr_reader :record, :payload, :model_config, :configuration
|
|
25
|
+
|
|
26
|
+
# Quando a humanizacao estiver desabilitada, preserva apenas o tipo da acao.
|
|
27
|
+
def empty_payload
|
|
28
|
+
{
|
|
29
|
+
"type" => payload["type"],
|
|
30
|
+
"fields" => {}
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Humaniza campo por campo para permitir fallback individual.
|
|
35
|
+
def humanized_fields
|
|
36
|
+
payload.fetch("fields", {}).each_with_object({}) do |(attribute, values), result|
|
|
37
|
+
result[attribute] = build_field_payload(attribute, values)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Tenta humanizer customizado primeiro e cai no comportamento padrao.
|
|
42
|
+
def build_field_payload(attribute, values)
|
|
43
|
+
custom_result = call_custom_humanizer(attribute, values)
|
|
44
|
+
return normalize_custom_result(attribute, values, custom_result) unless custom_result.nil?
|
|
45
|
+
|
|
46
|
+
default_field_payload(attribute, values)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# O humanizer recebe model, atributo e valores bruto para maximo contexto.
|
|
50
|
+
def call_custom_humanizer(attribute, values)
|
|
51
|
+
humanizer = model_config.effective_humanizer(configuration)
|
|
52
|
+
return nil if humanizer.nil?
|
|
53
|
+
|
|
54
|
+
humanizer.call(record.class, attribute, values["old_value"], values["new_value"] || values["value"])
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
log_warning(attribute, e)
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Aceita retorno em Hash para sobrescrita rica ou valor simples para casos basicos.
|
|
61
|
+
def normalize_custom_result(attribute, values, custom_result)
|
|
62
|
+
return default_field_payload(attribute, values).merge(stringify_keys(custom_result)) if custom_result.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
default_field_payload(attribute, values).merge("value" => custom_result)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Mantem o label traduzido e preserva os dados do payload bruto.
|
|
68
|
+
def default_field_payload(attribute, values)
|
|
69
|
+
result = { "label" => translate_attribute(attribute) }
|
|
70
|
+
|
|
71
|
+
values.each do |key, value|
|
|
72
|
+
result[key] = value
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
result
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Faz o fallback de traducao por escopo ate encontrar um label valido.
|
|
79
|
+
def translate_attribute(attribute)
|
|
80
|
+
scopes = model_config.effective_i18n_scopes(configuration)
|
|
81
|
+
model_key = record.class.model_name.i18n_key
|
|
82
|
+
|
|
83
|
+
scopes.each do |scope|
|
|
84
|
+
translation = I18n.t("#{scope}.#{model_key}.#{attribute}", default: nil)
|
|
85
|
+
return translation if translation
|
|
86
|
+
|
|
87
|
+
fallback = I18n.t("#{scope}.#{attribute}", default: nil)
|
|
88
|
+
return fallback if fallback
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
attribute.to_s.humanize
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Normaliza chaves vindas do humanizer customizado para JSON consistente.
|
|
95
|
+
def stringify_keys(hash)
|
|
96
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
97
|
+
result[key.to_s] = value
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Evita que falhas no humanizer customizado interrompam a gravacao da auditoria.
|
|
102
|
+
def log_warning(attribute, error)
|
|
103
|
+
message = "[AuditLogger] Falha ao humanizar #{record.class.name}##{attribute}: #{error.class} - #{error.message}"
|
|
104
|
+
|
|
105
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
106
|
+
Rails.logger.warn(message)
|
|
107
|
+
else
|
|
108
|
+
warn(message)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AuditLogger
|
|
4
|
+
class ModelConfig
|
|
5
|
+
# Guarda apenas a configuracao local da model, sem misturar com a global.
|
|
6
|
+
attr_reader :humanize, :i18n_scopes, :humanizer, :ignored_attributes
|
|
7
|
+
|
|
8
|
+
def initialize(humanize: nil, i18n_scopes: nil, humanizer: nil, ignored_attributes: [])
|
|
9
|
+
@humanize = humanize
|
|
10
|
+
@i18n_scopes = i18n_scopes
|
|
11
|
+
@humanizer = humanizer
|
|
12
|
+
@ignored_attributes = Array(ignored_attributes)
|
|
13
|
+
|
|
14
|
+
validate!
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Resolve se a humanizacao ficara ativa considerando override local.
|
|
18
|
+
def humanize_enabled?(global_configuration)
|
|
19
|
+
return global_configuration.humanize_by_default if humanize.nil?
|
|
20
|
+
|
|
21
|
+
humanize
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Retorna os escopos efetivos usados para traducao desta model.
|
|
25
|
+
def effective_i18n_scopes(global_configuration)
|
|
26
|
+
return global_configuration.i18n_scopes if i18n_scopes.nil? || i18n_scopes.empty?
|
|
27
|
+
|
|
28
|
+
i18n_scopes
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Prioriza o humanizer da model e cai no global quando necessario.
|
|
32
|
+
def effective_humanizer(global_configuration)
|
|
33
|
+
humanizer || global_configuration.humanizer
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Combina ignored attributes globais e locais em uma lista unica.
|
|
37
|
+
def effective_ignored_attributes(global_configuration)
|
|
38
|
+
(global_configuration.ignored_attributes + ignored_attributes).map(&:to_s).uniq
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Concentra as validacoes do contrato aceito pela DSL `auditable`.
|
|
44
|
+
def validate!
|
|
45
|
+
validate_humanize!
|
|
46
|
+
validate_i18n_scopes!
|
|
47
|
+
validate_humanizer!
|
|
48
|
+
validate_ignored_attributes!
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Limita o override de humanizacao a valores simples e previsiveis.
|
|
52
|
+
def validate_humanize!
|
|
53
|
+
return if humanize.nil? || [true, false].include?(humanize)
|
|
54
|
+
|
|
55
|
+
raise ConfigurationError, "humanize da model deve ser true, false ou nil"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Garante que o fallback de I18n nao receba dados inconsistentes.
|
|
59
|
+
def validate_i18n_scopes!
|
|
60
|
+
return if i18n_scopes.nil?
|
|
61
|
+
return if i18n_scopes.is_a?(Array) && i18n_scopes.all? { |item| item.is_a?(String) && !item.empty? }
|
|
62
|
+
|
|
63
|
+
raise ConfigurationError, "i18n_scopes da model deve ser um Array de strings nao vazias"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Mantem o ponto de extensao da model com a mesma regra do config global.
|
|
67
|
+
def validate_humanizer!
|
|
68
|
+
return if humanizer.nil? || humanizer.is_a?(Proc)
|
|
69
|
+
|
|
70
|
+
raise ConfigurationError, "humanizer da model deve ser um Proc/Lambda ou nil"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Normaliza a entrada para facilitar o filtro de atributos depois.
|
|
74
|
+
def validate_ignored_attributes!
|
|
75
|
+
return if ignored_attributes.all? { |item| item.is_a?(String) || item.is_a?(Symbol) }
|
|
76
|
+
|
|
77
|
+
raise ConfigurationError, "ignored_attributes da model deve ser um Array de strings ou symbols"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module AuditLogger
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
initializer "audit_logger.active_record" do
|
|
8
|
+
ActiveSupport.on_load(:active_record) do
|
|
9
|
+
include AuditLogger::Auditable
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
generators do
|
|
14
|
+
require_relative "../generators/audit_logger/install_generator"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AuditLogger
|
|
4
|
+
class RecordAuditEntry
|
|
5
|
+
def self.call(record, action:)
|
|
6
|
+
new(record, action: action).call
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(record, action:)
|
|
10
|
+
@record = record
|
|
11
|
+
@action = action.to_s
|
|
12
|
+
@configuration = AuditLogger.configuration
|
|
13
|
+
@model_config = record.class.audit_logger_model_config
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Orquestra toda a montagem do log e persiste o resultado final.
|
|
17
|
+
def call
|
|
18
|
+
payload = ChangeExtractor.new(
|
|
19
|
+
record: record,
|
|
20
|
+
action: action,
|
|
21
|
+
model_config: model_config,
|
|
22
|
+
configuration: configuration
|
|
23
|
+
).call
|
|
24
|
+
|
|
25
|
+
return if payload.fetch("fields", {}).empty?
|
|
26
|
+
|
|
27
|
+
# Resolve o contexto do ator e gera a versao amigavel do payload antes de persistir.
|
|
28
|
+
actor_context = ActorContextResolver.call(configuration)
|
|
29
|
+
humanized_payload = Humanizer.new(
|
|
30
|
+
record: record,
|
|
31
|
+
payload: payload,
|
|
32
|
+
model_config: model_config,
|
|
33
|
+
configuration: configuration
|
|
34
|
+
).call
|
|
35
|
+
|
|
36
|
+
AuditLog.create!(
|
|
37
|
+
model_class_name: record.class.name,
|
|
38
|
+
id_object: resolve_record_id,
|
|
39
|
+
action: action,
|
|
40
|
+
uuid: actor_context[:uuid].to_s,
|
|
41
|
+
changed_by_id: actor_context[:changed_by_id],
|
|
42
|
+
changed_by_type: actor_context[:changed_by_type],
|
|
43
|
+
changed_by_other: actor_context[:changed_by_other],
|
|
44
|
+
audited_changes: payload,
|
|
45
|
+
audited_changes_humanize: humanized_payload,
|
|
46
|
+
ip_remote: actor_context[:ip_remote],
|
|
47
|
+
created_at: Time.current,
|
|
48
|
+
updated_at: Time.current
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
attr_reader :record, :action, :configuration, :model_config
|
|
55
|
+
|
|
56
|
+
# Normaliza o id como string para manter consistencia entre tipos de PK.
|
|
57
|
+
def resolve_record_id
|
|
58
|
+
record.id.to_s
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/audit_logger.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "audit_logger/version"
|
|
4
|
+
require "active_support"
|
|
5
|
+
require "active_support/concern"
|
|
6
|
+
require "active_record"
|
|
7
|
+
require "i18n"
|
|
8
|
+
require "json"
|
|
9
|
+
require "securerandom"
|
|
10
|
+
require_relative "audit_logger/configuration"
|
|
11
|
+
require_relative "audit_logger/config_validator"
|
|
12
|
+
require_relative "audit_logger/model_config"
|
|
13
|
+
require_relative "audit_logger/audit_log"
|
|
14
|
+
require_relative "audit_logger/actor_context_resolver"
|
|
15
|
+
require_relative "audit_logger/change_extractor"
|
|
16
|
+
require_relative "audit_logger/humanizer"
|
|
17
|
+
require_relative "audit_logger/record_audit_entry"
|
|
18
|
+
require_relative "audit_logger/auditable"
|
|
19
|
+
|
|
20
|
+
module AuditLogger
|
|
21
|
+
class Error < StandardError; end
|
|
22
|
+
class ConfigurationError < Error; end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Retorna a configuracao global da gem, instanciando-a sob demanda.
|
|
26
|
+
def configuration
|
|
27
|
+
@configuration ||= Configuration.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Permite configurar a gem via bloco e valida o resultado no final.
|
|
31
|
+
def configure
|
|
32
|
+
yield(configuration)
|
|
33
|
+
ConfigValidator.validate!(configuration)
|
|
34
|
+
configuration
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Facilita testes e reconfiguracao do estado global da gem.
|
|
38
|
+
def reset_configuration!
|
|
39
|
+
@configuration = Configuration.new
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
require_relative "audit_logger/railtie" if defined?(Rails::Railtie)
|