rails_audit_log 0.4.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb82307e35624fe226336c4fb33559b5432a6290c4978076aadec80e6939afa0
4
- data.tar.gz: 47e700f38e3d1fbe2a0953ba687285389721b31e8a6531dcbc5e336c6d9af438
3
+ metadata.gz: 6cc56d9335d26c5f43d52de380aa354a268cbb266696799cf1775f051162c2d8
4
+ data.tar.gz: '027792cdf8815ec2822177492b674d05534a3d96163d463dd603a415d471ace2'
5
5
  SHA512:
6
- metadata.gz: 5dc66b780e8d1875260ef21c143b3ec823832ffa7e90c58f8392e002515d4cbe30c797936f4766bd15791a9b1b37e2f8918973f33fec58e396ca117a3623a563
7
- data.tar.gz: d7fbf0c15e39d95a57270203b296ce5bac2835cc15f69d44cd6a7b000a884bc8d639cee91de5c1dd84aeaec64795fa6325397a1926a67f9a8317cc1128c18808
6
+ metadata.gz: 107247da00cc1f7eec9750f9d59eb2b41fa47f4ec9c3a0b00f587882bf6080250fe5af5152a2d13491fdb6ebf6aa7d3f82a6418f89777b9dca3819c024692805
7
+ data.tar.gz: 3715691ec2538d7fca5538af23e9f087899469876dcb98b660d82f18230ee531f45660950c95e4230f15a6a430b4b85999691472b9c61cf1d4e88af6c90849c2
data/README.md CHANGED
@@ -102,6 +102,55 @@ entry.diff
102
102
  # => { "title" => { from: "Hello", to: "World" } }
103
103
  ```
104
104
 
105
+ ### Association tracking
106
+
107
+ Track `has_many` add and remove events by passing `associations: true` to `audit_log`. Call `audit_log` **before** the `has_many` declarations so the callbacks are wired at class load time:
108
+
109
+ ```ruby
110
+ class Post < ApplicationRecord
111
+ include RailsAuditLog::Auditable
112
+ audit_log associations: true
113
+ has_many :tags
114
+ has_many :comments, dependent: :destroy
115
+ end
116
+ ```
117
+
118
+ Each add or remove creates an `update` entry on the parent with the associated record's identity in `object_changes`:
119
+
120
+ ```ruby
121
+ post = Post.create!(title: "Hello")
122
+ tag = post.tags.create!(name: "Ruby")
123
+
124
+ entry = post.audit_log_entries.updated_events.last
125
+ entry.object_changes
126
+ # => { "tags" => [nil, { "id" => 1, "type" => "Tag" }] }
127
+
128
+ post.tags.delete(tag)
129
+ entry = post.audit_log_entries.updated_events.last
130
+ entry.object_changes
131
+ # => { "tags" => [{ "id" => 1, "type" => "Tag" }, nil] }
132
+ ```
133
+
134
+ Track only a named subset of associations:
135
+
136
+ ```ruby
137
+ audit_log associations: [:tags] # comments changes are not recorded
138
+ ```
139
+
140
+ `has_many :through` and `has_and_belongs_to_many` work the same way — no extra configuration:
141
+
142
+ ```ruby
143
+ class Post < ApplicationRecord
144
+ include RailsAuditLog::Auditable
145
+ audit_log associations: true
146
+ has_many :taggings
147
+ has_many :tags, through: :taggings # tracked automatically
148
+ has_and_belongs_to_many :categories # tracked automatically
149
+ end
150
+ ```
151
+
152
+ `belongs_to` foreign-key changes are already tracked as regular column updates and require no extra configuration.
153
+
105
154
  ### Selective tracking
106
155
 
107
156
  Track only specific attributes, or exclude noisy ones:
@@ -179,6 +228,68 @@ entry.previous # => the entry before this one
179
228
  entry.next # => the entry after this one (nil if last)
180
229
  ```
181
230
 
231
+ ### Attaching a reason
232
+
233
+ Record a free-text rationale alongside any write using a thread-local block — safe to nest, cleared automatically:
234
+
235
+ ```ruby
236
+ RailsAuditLog.audit_log_reason("Approved by legal") do
237
+ contract.update!(status: "approved")
238
+ end
239
+
240
+ entry.reason # => "Approved by legal"
241
+ ```
242
+
243
+ ### Arbitrary metadata
244
+
245
+ The `metadata` JSON column accepts any key/value hash. Populate it automatically with `audit_log meta:`:
246
+
247
+ ```ruby
248
+ class Article < ApplicationRecord
249
+ include RailsAuditLog::Auditable
250
+
251
+ # Zero-arg lambda — evaluated at write time (good for thread-locals)
252
+ # One-arg lambda — receives the record instance
253
+ audit_log meta: {
254
+ tenant_id: -> { Current.tenant.id },
255
+ title_length: ->(record) { record.title.length }
256
+ }
257
+ end
258
+
259
+ entry.metadata # => { "tenant_id" => "acme", "title_length" => 12 }
260
+ ```
261
+
262
+ ### Request metadata capture
263
+
264
+ Enable opt-in capture of `remote_ip` and `user_agent` from the current request in an initializer:
265
+
266
+ ```ruby
267
+ # config/initializers/rails_audit_log.rb
268
+ RailsAuditLog.capture_request_metadata = true
269
+ ```
270
+
271
+ When enabled, the `Controller` concern automatically stores these into each entry's `metadata` column:
272
+
273
+ ```ruby
274
+ entry.metadata # => { "remote_ip" => "203.0.113.1", "user_agent" => "Mozilla/5.0 ..." }
275
+ ```
276
+
277
+ Request metadata and `audit_log meta:` values are merged together automatically.
278
+
279
+ ### Actor display name snapshot
280
+
281
+ `whodunnit_snapshot` stores the actor's display name at write time so entries remain meaningful even after the actor record is deleted:
282
+
283
+ ```ruby
284
+ entry.whodunnit_snapshot # => "Alice" (even if the User record is later deleted)
285
+ ```
286
+
287
+ The default display proc uses `actor.name` if available, otherwise `actor.to_s`. Override globally in an initializer:
288
+
289
+ ```ruby
290
+ RailsAuditLog.whodunnit_display = ->(actor) { actor.email }
291
+ ```
292
+
182
293
  ### Object snapshot storage
183
294
 
184
295
  By default every entry stores a full `object` snapshot of the pre-change state alongside `object_changes`. This makes `reify` and `version_at` reliable without any database lookups:
@@ -3,8 +3,10 @@ module RailsAuditLog
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  included do
6
- class_attribute :_audit_log_only, default: nil
7
- class_attribute :_audit_log_ignore, default: nil
6
+ class_attribute :_audit_log_only, default: nil
7
+ class_attribute :_audit_log_ignore, default: nil
8
+ class_attribute :_audit_log_meta, default: nil
9
+ class_attribute :_audit_log_associations, default: nil
8
10
 
9
11
  has_many :audit_log_entries,
10
12
  class_name: "RailsAuditLog::AuditLogEntry",
@@ -14,12 +16,45 @@ module RailsAuditLog
14
16
  after_create :record_audit_create
15
17
  after_update :record_audit_update
16
18
  after_destroy :record_audit_destroy
19
+
20
+ # Intercept has_many (including :through) and has_and_belongs_to_many to
21
+ # inject after_add/after_remove callbacks when association tracking is
22
+ # enabled. Must be defined after has_many :audit_log_entries so that the
23
+ # internal association is not affected.
24
+ def self.has_many(name, scope = nil, **options, &extension)
25
+ if _audit_log_associations && name.to_s != "audit_log_entries"
26
+ options = _build_audit_association_options(name.to_s, options)
27
+ end
28
+ scope ? super(name, scope, **options, &extension) : super(name, **options, &extension)
29
+ end
30
+
31
+ def self.has_and_belongs_to_many(name, scope = nil, **options, &extension)
32
+ if _audit_log_associations
33
+ options = _build_audit_association_options(name.to_s, options)
34
+ end
35
+ scope ? super(name, scope, **options, &extension) : super(name, **options, &extension)
36
+ end
37
+
38
+ def self._build_audit_association_options(assoc_name, options)
39
+ tracked = _audit_log_associations == true ||
40
+ Array(_audit_log_associations).map(&:to_s).include?(assoc_name)
41
+ return options unless tracked
42
+
43
+ add_cb = ->(owner, rec) { owner.send(:record_audit_association_change, assoc_name, nil, { "id" => rec.id, "type" => rec.class.name }) }
44
+ remove_cb = ->(owner, rec) { owner.send(:record_audit_association_change, assoc_name, { "id" => rec.id, "type" => rec.class.name }, nil) }
45
+ options.merge(
46
+ after_add: [*options[:after_add]] + [add_cb],
47
+ after_remove: [*options[:after_remove]] + [remove_cb]
48
+ )
49
+ end
17
50
  end
18
51
 
19
52
  class_methods do
20
- def audit_log(only: nil, ignore: nil)
53
+ def audit_log(only: nil, ignore: nil, meta: nil, associations: nil)
21
54
  self._audit_log_only = only.map(&:to_s) if only
22
55
  self._audit_log_ignore = ignore.map(&:to_s) if ignore
56
+ self._audit_log_meta = meta if meta
57
+ self._audit_log_associations = associations unless associations.nil?
23
58
  end
24
59
  end
25
60
 
@@ -44,6 +79,24 @@ module RailsAuditLog
44
79
  record_audit_entry("destroy", changes, snapshot)
45
80
  end
46
81
 
82
+ def record_audit_association_change(assoc_name, before, after)
83
+ return unless RailsAuditLog.enabled?
84
+
85
+ actor = RailsAuditLog.actor
86
+ meta = build_audit_metadata
87
+ RailsAuditLog::AuditLogEntry.create!(
88
+ event: "update",
89
+ item_type: self.class.name,
90
+ item_id: id,
91
+ object_changes: { assoc_name => [before, after] },
92
+ reason: RailsAuditLog.reason,
93
+ metadata: meta.presence,
94
+ whodunnit_snapshot: actor ? RailsAuditLog.whodunnit_display.call(actor) : nil,
95
+ actor_type: actor&.class&.name,
96
+ actor_id: actor.respond_to?(:id) ? actor.id : nil
97
+ )
98
+ end
99
+
47
100
  def record_audit_entry(event, changes, snapshot = nil)
48
101
  return unless RailsAuditLog.enabled?
49
102
 
@@ -51,17 +104,32 @@ module RailsAuditLog
51
104
  return if filtered.empty? && event == "update"
52
105
 
53
106
  actor = RailsAuditLog.actor
107
+ meta = build_audit_metadata
54
108
  RailsAuditLog::AuditLogEntry.create!(
55
- event: event,
56
- item_type: self.class.name,
57
- item_id: id,
58
- object_changes: filtered,
59
- object: snapshot,
60
- actor_type: actor&.class&.name,
61
- actor_id: actor.respond_to?(:id) ? actor.id : nil
109
+ event: event,
110
+ item_type: self.class.name,
111
+ item_id: id,
112
+ object_changes: filtered,
113
+ object: snapshot,
114
+ reason: RailsAuditLog.reason,
115
+ metadata: meta.presence,
116
+ whodunnit_snapshot: actor ? RailsAuditLog.whodunnit_display.call(actor) : nil,
117
+ actor_type: actor&.class&.name,
118
+ actor_id: actor.respond_to?(:id) ? actor.id : nil
62
119
  )
63
120
  end
64
121
 
122
+ def build_audit_metadata
123
+ meta = {}
124
+ if self.class._audit_log_meta
125
+ self.class._audit_log_meta.each do |key, callable|
126
+ meta[key.to_s] = callable.arity == 0 ? callable.call : callable.call(self)
127
+ end
128
+ end
129
+ meta.merge!(RailsAuditLog.request_metadata || {})
130
+ meta
131
+ end
132
+
65
133
  def filter_changes(changes)
66
134
  result = changes.dup
67
135
 
@@ -5,6 +5,8 @@ module RailsAuditLog
5
5
  included do
6
6
  before_action :set_audit_log_actor
7
7
  after_action :clear_audit_log_actor
8
+ before_action :capture_audit_log_request_metadata
9
+ after_action :clear_audit_log_request_metadata
8
10
  end
9
11
 
10
12
  class_methods do
@@ -27,5 +29,20 @@ module RailsAuditLog
27
29
  def clear_audit_log_actor
28
30
  RailsAuditLog.actor = nil
29
31
  end
32
+
33
+ def capture_audit_log_request_metadata
34
+ return unless RailsAuditLog.capture_request_metadata
35
+
36
+ RailsAuditLog.request_metadata = {
37
+ "remote_ip" => request.remote_ip,
38
+ "user_agent" => request.user_agent
39
+ }.compact
40
+ end
41
+
42
+ def clear_audit_log_request_metadata
43
+ return unless RailsAuditLog.capture_request_metadata
44
+
45
+ RailsAuditLog.request_metadata = nil
46
+ end
30
47
  end
31
48
  end
@@ -10,6 +10,7 @@ module RailsAuditLog
10
10
  validates :event, presence: true, inclusion: { in: EVENTS }
11
11
  validates :item_type, presence: true
12
12
  validates :item_id, presence: true
13
+ validate :metadata_must_be_a_hash
13
14
 
14
15
  # Event scopes
15
16
  scope :created_events, -> { where(event: "create") }
@@ -50,8 +51,13 @@ module RailsAuditLog
50
51
  return instance
51
52
  end
52
53
 
53
- # Fallback: diff-only mode or entries recorded before snapshot support
54
- from_attrs = (object_changes || {}).transform_values { |from_to| from_to[0] }
54
+ # Fallback: diff-only mode or entries recorded before snapshot support.
55
+ # Filter to column names so association-change entries (e.g. tags, comments)
56
+ # don't get assigned to the record as if they were scalar attributes.
57
+ column_names = klass.column_names.map(&:to_s)
58
+ from_attrs = (object_changes || {})
59
+ .select { |k, _| column_names.include?(k) }
60
+ .transform_values { |from_to| from_to[0] }
55
61
 
56
62
  if event == "update"
57
63
  record = klass.find_by(id: item_id)
@@ -82,6 +88,14 @@ module RailsAuditLog
82
88
  object_changes.transform_values { |from_to| { from: from_to[0], to: from_to[1] } }
83
89
  end
84
90
 
91
+ private
92
+
93
+ def metadata_must_be_a_hash
94
+ errors.add(:metadata, "must be a Hash") if metadata.present? && !metadata.is_a?(Hash)
95
+ end
96
+
97
+ public
98
+
85
99
  # Attribute scope — uses json_extract (SQLite/MySQL) or ->> (PostgreSQL)
86
100
  scope :touching, ->(attribute) {
87
101
  if connection.adapter_name =~ /PostgreSQL/i
@@ -6,6 +6,9 @@ class CreateAuditLogEntries < ActiveRecord::Migration[<%= ActiveRecord::Migratio
6
6
  t.bigint :item_id, null: false
7
7
  t.json :object_changes
8
8
  t.json :object
9
+ t.json :metadata
10
+ t.string :reason
11
+ t.string :whodunnit_snapshot
9
12
  t.string :actor_type
10
13
  t.bigint :actor_id
11
14
  t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
@@ -1,3 +1,3 @@
1
1
  module RailsAuditLog
2
- VERSION = "0.4.0"
2
+ VERSION = "0.6.0"
3
3
  end
@@ -6,6 +6,18 @@ module RailsAuditLog
6
6
  # Override in an initializer: RailsAuditLog.ignored_attributes = %w[updated_at cached_at]
7
7
  mattr_accessor :ignored_attributes, default: %w[updated_at]
8
8
  mattr_accessor :store_snapshot, default: true
9
+ mattr_accessor :capture_request_metadata, default: false
10
+ mattr_accessor :whodunnit_display, default: ->(actor) {
11
+ actor.respond_to?(:name) ? actor.name.to_s : actor.to_s
12
+ }
13
+
14
+ def self.request_metadata
15
+ Thread.current[:rails_audit_log_request_metadata]
16
+ end
17
+
18
+ def self.request_metadata=(value)
19
+ Thread.current[:rails_audit_log_request_metadata] = value
20
+ end
9
21
 
10
22
  def self.actor
11
23
  Thread.current[:rails_audit_log_actor]
@@ -35,6 +47,22 @@ module RailsAuditLog
35
47
  Thread.current[:rails_audit_log_disabled] = previous
36
48
  end
37
49
 
50
+ def self.reason
51
+ Thread.current[:rails_audit_log_reason]
52
+ end
53
+
54
+ def self.reason=(value)
55
+ Thread.current[:rails_audit_log_reason] = value
56
+ end
57
+
58
+ def self.audit_log_reason(value)
59
+ previous = self.reason
60
+ self.reason = value
61
+ yield
62
+ ensure
63
+ self.reason = previous
64
+ end
65
+
38
66
  def self.version_at(record, time)
39
67
  entry = AuditLogEntry
40
68
  .where(item_type: record.class.name, item_id: record.id)
@@ -45,7 +73,10 @@ module RailsAuditLog
45
73
  return nil if entry.nil? || entry.event == "destroy"
46
74
 
47
75
  klass = record.class
48
- to_attrs = (entry.object_changes || {}).transform_values { |v| v[1] }
76
+ column_names = klass.column_names.map(&:to_s)
77
+ to_attrs = (entry.object_changes || {})
78
+ .select { |k, _| column_names.include?(k) }
79
+ .transform_values { |v| v[1] }
49
80
  attrs = entry.object.present? ? entry.object.merge(to_attrs) : to_attrs
50
81
 
51
82
  instance = klass.new
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_audit_log
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith