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 +4 -4
- data/README.md +111 -0
- data/app/concerns/rails_audit_log/auditable.rb +78 -10
- data/app/concerns/rails_audit_log/controller.rb +17 -0
- data/app/models/rails_audit_log/audit_log_entry.rb +16 -2
- data/lib/generators/rails_audit_log/install/templates/create_audit_log_entries.rb +3 -0
- data/lib/rails_audit_log/version.rb +1 -1
- data/lib/rails_audit_log.rb +32 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6cc56d9335d26c5f43d52de380aa354a268cbb266696799cf1775f051162c2d8
|
|
4
|
+
data.tar.gz: '027792cdf8815ec2822177492b674d05534a3d96163d463dd603a415d471ace2'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
7
|
-
class_attribute :_audit_log_ignore,
|
|
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:
|
|
56
|
-
item_type:
|
|
57
|
-
item_id:
|
|
58
|
-
object_changes:
|
|
59
|
-
object:
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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" }
|
data/lib/rails_audit_log.rb
CHANGED
|
@@ -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
|
-
|
|
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
|