rails_audit_log 0.9.0 → 1.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 +4 -4
- data/README.md +332 -55
- data/Rakefile +5 -0
- data/app/concerns/rails_audit_log/auditable.rb +78 -9
- data/app/concerns/rails_audit_log/controller.rb +29 -0
- data/app/controllers/rails_audit_log/application_controller.rb +1 -0
- data/app/controllers/rails_audit_log/audit_log_entries_controller.rb +1 -0
- data/app/controllers/rails_audit_log/resources_controller.rb +1 -0
- data/app/helpers/rails_audit_log/application_helper.rb +1 -0
- data/app/jobs/rails_audit_log/application_job.rb +1 -0
- data/app/jobs/rails_audit_log/prune_audit_log_job.rb +58 -0
- data/app/jobs/rails_audit_log/write_audit_log_job.rb +11 -10
- data/app/models/rails_audit_log/application_record.rb +1 -0
- data/app/models/rails_audit_log/audit_log_entry.rb +126 -26
- data/lib/generators/rails_audit_log/initializer/templates/rails_audit_log.rb +5 -0
- data/lib/generators/rails_audit_log/migrate_from_paper_trail/migrate_from_paper_trail_generator.rb +21 -0
- data/lib/generators/rails_audit_log/migrate_from_paper_trail/templates/migrate_from_paper_trail.rb +99 -0
- data/lib/rails_audit_log/engine.rb +1 -0
- data/lib/rails_audit_log/matchers.rb +56 -0
- data/lib/rails_audit_log/minitest_assertions.rb +36 -0
- data/lib/rails_audit_log/paper_trail_compat.rb +76 -0
- data/lib/rails_audit_log/test_helpers.rb +20 -0
- data/lib/rails_audit_log/version.rb +1 -1
- data/lib/rails_audit_log.rb +181 -9
- data/lib/tasks/rails_audit_log_tasks.rake +7 -4
- metadata +10 -5
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
module RailsAuditLog
|
|
2
|
+
# Include in +ApplicationController+ (or any controller) to automatically
|
|
3
|
+
# set and clear the current actor for every request, so that audit entries
|
|
4
|
+
# written during the request are tagged with the signed-in user.
|
|
5
|
+
#
|
|
6
|
+
# == Usage
|
|
7
|
+
#
|
|
8
|
+
# class ApplicationController < ActionController::Base
|
|
9
|
+
# include RailsAuditLog::Controller
|
|
10
|
+
# audit_log_actor { current_user }
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# The block passed to {audit_log_actor} is evaluated in controller instance
|
|
14
|
+
# context on every request via a +before_action+. The actor is cleared in an
|
|
15
|
+
# +after_action+ so it never leaks between requests.
|
|
16
|
+
#
|
|
17
|
+
# Request metadata (+remote_ip+, +user_agent+) is captured automatically when
|
|
18
|
+
# {RailsAuditLog.capture_request_metadata} is +true+.
|
|
2
19
|
module Controller
|
|
3
20
|
extend ActiveSupport::Concern
|
|
4
21
|
|
|
@@ -10,10 +27,22 @@ module RailsAuditLog
|
|
|
10
27
|
end
|
|
11
28
|
|
|
12
29
|
class_methods do
|
|
30
|
+
# Registers a block that resolves the current actor for each request.
|
|
31
|
+
# The block is evaluated in controller instance context, so any helper
|
|
32
|
+
# method available to the controller (e.g. +current_user+) can be used.
|
|
33
|
+
#
|
|
34
|
+
# @yield block evaluated in controller context; should return the actor
|
|
35
|
+
# @return [void]
|
|
36
|
+
# @example
|
|
37
|
+
# audit_log_actor { current_user }
|
|
38
|
+
#
|
|
39
|
+
# # With a one-argument lambda style (actor = the controller instance)
|
|
40
|
+
# audit_log_actor { |c| c.current_user }
|
|
13
41
|
def audit_log_actor(&block)
|
|
14
42
|
@audit_log_actor_block = block
|
|
15
43
|
end
|
|
16
44
|
|
|
45
|
+
# @api private
|
|
17
46
|
def audit_log_actor_block
|
|
18
47
|
@audit_log_actor_block
|
|
19
48
|
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module RailsAuditLog
|
|
2
|
+
# Background job that prunes {AuditLogEntry} records for every audited model
|
|
3
|
+
# that has a configured retention policy.
|
|
4
|
+
#
|
|
5
|
+
# Enqueue on a recurring schedule via your job backend (Solid Queue,
|
|
6
|
+
# Sidekiq, GoodJob, etc.):
|
|
7
|
+
#
|
|
8
|
+
# RailsAuditLog::PruneAuditLogJob.perform_later
|
|
9
|
+
#
|
|
10
|
+
# The job iterates over every +item_type+ present in +audit_log_entries+,
|
|
11
|
+
# resolves the effective +retention_period+ / +version_limit+ for that model,
|
|
12
|
+
# and deletes entries that exceed either constraint. Pruning is always scoped
|
|
13
|
+
# to one +item_type+ at a time so models do not interfere with each other.
|
|
14
|
+
class PruneAuditLogJob < ApplicationJob
|
|
15
|
+
def perform
|
|
16
|
+
AuditLogEntry.distinct.pluck(:item_type).each do |item_type|
|
|
17
|
+
prune_item_type(item_type)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def prune_item_type(item_type)
|
|
24
|
+
klass = item_type.safe_constantize
|
|
25
|
+
period = resolve_period(klass)
|
|
26
|
+
limit = resolve_limit(klass)
|
|
27
|
+
return unless period || limit
|
|
28
|
+
|
|
29
|
+
scope = AuditLogEntry.where(item_type: item_type)
|
|
30
|
+
|
|
31
|
+
scope.where(created_at: ..period.ago).delete_all if period
|
|
32
|
+
|
|
33
|
+
return unless limit
|
|
34
|
+
|
|
35
|
+
scope.select(:item_id).distinct.pluck(:item_id).each do |item_id|
|
|
36
|
+
record_scope = scope.where(item_id: item_id)
|
|
37
|
+
excess = record_scope.count - limit
|
|
38
|
+
record_scope.order(id: :asc).limit(excess).delete_all if excess > 0
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resolve_period(klass)
|
|
43
|
+
if klass.respond_to?(:_audit_log_retain_for)
|
|
44
|
+
klass._audit_log_retain_for || RailsAuditLog.retention_period
|
|
45
|
+
else
|
|
46
|
+
RailsAuditLog.retention_period
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolve_limit(klass)
|
|
51
|
+
if klass.respond_to?(:_audit_log_version_limit)
|
|
52
|
+
klass._audit_log_version_limit || RailsAuditLog.version_limit
|
|
53
|
+
else
|
|
54
|
+
RailsAuditLog.version_limit
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
module RailsAuditLog
|
|
2
2
|
class WriteAuditLogJob < ApplicationJob
|
|
3
|
-
def perform(entry_attrs, version_limit: nil)
|
|
3
|
+
def perform(entry_attrs, version_limit: nil, retention_period: nil)
|
|
4
4
|
AuditLogEntry.create!(entry_attrs)
|
|
5
5
|
|
|
6
|
-
return unless version_limit
|
|
7
|
-
|
|
8
6
|
item_type = entry_attrs["item_type"]
|
|
9
7
|
item_id = entry_attrs["item_id"]
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
scope = AuditLogEntry.where(item_type: item_type, item_id: item_id)
|
|
9
|
+
|
|
10
|
+
if retention_period
|
|
11
|
+
scope.where(created_at: ..retention_period.ago).delete_all
|
|
12
|
+
end
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
if version_limit
|
|
15
|
+
count = scope.count
|
|
16
|
+
excess = count - version_limit
|
|
17
|
+
scope.order(id: :asc).limit(excess).delete_all if excess > 0
|
|
18
|
+
end
|
|
18
19
|
end
|
|
19
20
|
end
|
|
20
21
|
end
|
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
module RailsAuditLog
|
|
2
|
+
# Represents a single audited event (+create+, +update+, or +destroy+) for
|
|
3
|
+
# one ActiveRecord record.
|
|
4
|
+
#
|
|
5
|
+
# == Columns
|
|
6
|
+
#
|
|
7
|
+
# [event] One of <tt>"create"</tt>, <tt>"update"</tt>, <tt>"destroy"</tt>.
|
|
8
|
+
# [item_type] Class name of the audited record (e.g. <tt>"Article"</tt>).
|
|
9
|
+
# [item_id] Primary key of the audited record.
|
|
10
|
+
# [object_changes] JSON hash of attribute changes in <tt>[from, to]</tt> form.
|
|
11
|
+
# All three event types use the same format:
|
|
12
|
+
# +create+ stores <tt>[nil, new]</tt> for every column,
|
|
13
|
+
# +update+ stores <tt>[old, new]</tt> for changed columns only,
|
|
14
|
+
# +destroy+ stores <tt>[final, nil]</tt> for every column.
|
|
15
|
+
# [object] JSON snapshot of the record's full attributes before the
|
|
16
|
+
# change (stored when {RailsAuditLog.store_snapshot} is +true+).
|
|
17
|
+
# [whodunnit_snapshot] Display name of the actor at the time of the change.
|
|
18
|
+
# [actor_type / actor_id] Polymorphic reference to the actor record.
|
|
19
|
+
# [reason] Optional free-text reason string.
|
|
20
|
+
# [metadata] Arbitrary JSON hash (request IP, custom lambdas, etc.).
|
|
2
21
|
class AuditLogEntry < ApplicationRecord
|
|
3
22
|
self.table_name = "audit_log_entries"
|
|
4
23
|
|
|
@@ -6,33 +25,63 @@ module RailsAuditLog
|
|
|
6
25
|
BLOB_COLUMNS = %w[object_changes object metadata].freeze
|
|
7
26
|
PERIODS = { "1h" => 1.hour, "24h" => 24.hours, "7d" => 7.days }.freeze
|
|
8
27
|
|
|
28
|
+
# @api private
|
|
9
29
|
def self.configure_connection!
|
|
10
30
|
return unless (opts = RailsAuditLog.connects_to)
|
|
11
31
|
|
|
12
32
|
connects_to(**opts)
|
|
13
33
|
end
|
|
14
34
|
|
|
15
|
-
belongs_to :item,
|
|
35
|
+
belongs_to :item, polymorphic: true, optional: true
|
|
16
36
|
belongs_to :actor, polymorphic: true, optional: true
|
|
17
37
|
|
|
18
|
-
validates :event,
|
|
38
|
+
validates :event, presence: true, inclusion: { in: EVENTS }
|
|
19
39
|
validates :item_type, presence: true
|
|
20
|
-
validates :item_id,
|
|
21
|
-
validate
|
|
40
|
+
validates :item_id, presence: true
|
|
41
|
+
validate :metadata_must_be_a_hash
|
|
22
42
|
|
|
23
|
-
# Event scopes
|
|
43
|
+
# @!group Event scopes
|
|
44
|
+
|
|
45
|
+
# Entries for +create+ events.
|
|
46
|
+
# @return [ActiveRecord::Relation]
|
|
24
47
|
scope :created_events, -> { where(event: "create") }
|
|
48
|
+
|
|
49
|
+
# Entries for +update+ events.
|
|
50
|
+
# @return [ActiveRecord::Relation]
|
|
25
51
|
scope :updated_events, -> { where(event: "update") }
|
|
26
|
-
scope :destroyed_events, -> { where(event: "destroy") }
|
|
27
52
|
|
|
28
|
-
#
|
|
53
|
+
# Entries for +destroy+ events.
|
|
54
|
+
# @return [ActiveRecord::Relation]
|
|
55
|
+
scope :destroyed_events, -> { where(event: "destroy") }
|
|
29
56
|
|
|
57
|
+
# @deprecated Use {.created_events} instead.
|
|
30
58
|
scope :creates, -> { created_events }
|
|
59
|
+
# @deprecated Use {.updated_events} instead.
|
|
31
60
|
scope :updates, -> { updated_events }
|
|
61
|
+
# @deprecated Use {.destroyed_events} instead.
|
|
32
62
|
scope :destroys, -> { destroyed_events }
|
|
33
63
|
|
|
34
|
-
#
|
|
64
|
+
# @!endgroup
|
|
65
|
+
|
|
66
|
+
# @!group Actor / resource scopes
|
|
67
|
+
|
|
68
|
+
# Entries written by a specific actor.
|
|
69
|
+
#
|
|
70
|
+
# @param actor [ActiveRecord::Base] the actor record to filter by
|
|
71
|
+
# @return [ActiveRecord::Relation]
|
|
72
|
+
# @example
|
|
73
|
+
# AuditLogEntry.by_actor(current_user)
|
|
35
74
|
scope :by_actor, ->(actor) { where(actor_type: actor.class.name, actor_id: actor.id) }
|
|
75
|
+
|
|
76
|
+
# Entries for a specific resource class or instance.
|
|
77
|
+
# Pass a class to get all entries for that type; pass an instance for one record.
|
|
78
|
+
#
|
|
79
|
+
# @param resource [Class, ActiveRecord::Base]
|
|
80
|
+
# @return [ActiveRecord::Relation]
|
|
81
|
+
# @example All entries for Article
|
|
82
|
+
# AuditLogEntry.for_resource(Article)
|
|
83
|
+
# @example All entries for one article
|
|
84
|
+
# AuditLogEntry.for_resource(article)
|
|
36
85
|
scope :for_resource, lambda { |resource|
|
|
37
86
|
if resource.is_a?(Class)
|
|
38
87
|
where(item_type: resource.name)
|
|
@@ -41,16 +90,61 @@ module RailsAuditLog
|
|
|
41
90
|
end
|
|
42
91
|
}
|
|
43
92
|
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
93
|
+
# @!endgroup
|
|
94
|
+
|
|
95
|
+
# @!group Time scopes
|
|
96
|
+
|
|
97
|
+
# Entries created at or after +time+.
|
|
98
|
+
#
|
|
99
|
+
# @param time [Time]
|
|
100
|
+
# @return [ActiveRecord::Relation]
|
|
101
|
+
scope :since, ->(time) { where(created_at: time..) }
|
|
102
|
+
|
|
103
|
+
# Entries created at or before +time+.
|
|
104
|
+
#
|
|
105
|
+
# @param time [Time]
|
|
106
|
+
# @return [ActiveRecord::Relation]
|
|
107
|
+
scope :until, ->(time) { where(created_at: ..time) }
|
|
108
|
+
|
|
109
|
+
# Entries within a named period. Valid keys: <tt>"1h"</tt>, <tt>"24h"</tt>, <tt>"7d"</tt>.
|
|
110
|
+
#
|
|
111
|
+
# @param period [String] one of +PERIODS.keys+
|
|
112
|
+
# @return [ActiveRecord::Relation]
|
|
47
113
|
scope :for_period, ->(period) { where(created_at: PERIODS[period].ago..) }
|
|
48
114
|
|
|
49
|
-
#
|
|
115
|
+
# @!endgroup
|
|
116
|
+
|
|
117
|
+
# Omits the three JSON blob columns (+object_changes+, +object+, +metadata+)
|
|
118
|
+
# from the +SELECT+. Use on index/listing queries where blobs are not
|
|
119
|
+
# displayed to reduce I/O and avoid deserializing large payloads.
|
|
120
|
+
#
|
|
121
|
+
# @return [ActiveRecord::Relation]
|
|
50
122
|
scope :slim, -> { select(column_names - BLOB_COLUMNS) }
|
|
51
123
|
|
|
52
|
-
#
|
|
124
|
+
# Entries where +object_changes+ contains a key matching +attribute+.
|
|
125
|
+
# Uses <tt>json_extract</tt> on SQLite/MySQL and <tt>->></tt> on PostgreSQL.
|
|
126
|
+
#
|
|
127
|
+
# @param attribute [Symbol, String] the attribute name to filter on
|
|
128
|
+
# @return [ActiveRecord::Relation]
|
|
129
|
+
# @example
|
|
130
|
+
# AuditLogEntry.touching(:title)
|
|
131
|
+
# post.audit_log_entries.updated_events.touching(:published_at)
|
|
132
|
+
scope :touching, ->(attribute) {
|
|
133
|
+
if connection.adapter_name =~ /PostgreSQL/i
|
|
134
|
+
# :nocov:
|
|
135
|
+
where("object_changes->>? IS NOT NULL", attribute.to_s)
|
|
136
|
+
# :nocov:
|
|
137
|
+
else
|
|
138
|
+
where("json_extract(object_changes, ?) IS NOT NULL", "$.#{attribute}")
|
|
139
|
+
end
|
|
140
|
+
}
|
|
53
141
|
|
|
142
|
+
# Reconstructs and returns the record's state *before* this entry's change.
|
|
143
|
+
# Uses the +object+ snapshot when available; falls back to deriving prior
|
|
144
|
+
# state from +object_changes+ (or the live record for +update+ entries).
|
|
145
|
+
#
|
|
146
|
+
# @return [ActiveRecord::Base, nil] an unpersisted instance; +nil+ for
|
|
147
|
+
# +create+ entries (there is no prior state)
|
|
54
148
|
def reify
|
|
55
149
|
return nil if event == "create"
|
|
56
150
|
|
|
@@ -82,18 +176,37 @@ module RailsAuditLog
|
|
|
82
176
|
instance
|
|
83
177
|
end
|
|
84
178
|
|
|
179
|
+
# Returns the entry immediately before this one in the version chain for
|
|
180
|
+
# the same record (lower +id+), or +nil+ if this is the first entry.
|
|
181
|
+
#
|
|
182
|
+
# @return [AuditLogEntry, nil]
|
|
85
183
|
def previous
|
|
86
184
|
self.class.where(item_type: item_type, item_id: item_id).where("id < ?", id).order(id: :desc).first
|
|
87
185
|
end
|
|
88
186
|
|
|
187
|
+
# Returns the entry immediately after this one in the version chain for
|
|
188
|
+
# the same record (higher +id+), or +nil+ if this is the last entry.
|
|
189
|
+
#
|
|
190
|
+
# @return [AuditLogEntry, nil]
|
|
89
191
|
def next
|
|
90
192
|
self.class.where(item_type: item_type, item_id: item_id).where("id > ?", id).order(id: :asc).first
|
|
91
193
|
end
|
|
92
194
|
|
|
195
|
+
# Returns the list of attribute (and association) names that changed in
|
|
196
|
+
# this entry, derived from the keys of +object_changes+.
|
|
197
|
+
#
|
|
198
|
+
# @return [Array<String>]
|
|
93
199
|
def changed_attributes
|
|
94
200
|
object_changes&.keys || []
|
|
95
201
|
end
|
|
96
202
|
|
|
203
|
+
# Returns +object_changes+ in a named-key format convenient for display.
|
|
204
|
+
#
|
|
205
|
+
# @return [Hash{String => Hash}] keys are attribute names; values are
|
|
206
|
+
# hashes with +:from+ and +:to+ keys
|
|
207
|
+
# @example
|
|
208
|
+
# entry.diff
|
|
209
|
+
# # => { "title" => { from: "Old", to: "New" }, ... }
|
|
97
210
|
def diff
|
|
98
211
|
return {} unless object_changes
|
|
99
212
|
|
|
@@ -105,18 +218,5 @@ module RailsAuditLog
|
|
|
105
218
|
def metadata_must_be_a_hash
|
|
106
219
|
errors.add(:metadata, "must be a Hash") if metadata.present? && !metadata.is_a?(Hash)
|
|
107
220
|
end
|
|
108
|
-
|
|
109
|
-
public
|
|
110
|
-
|
|
111
|
-
# Attribute scope — uses json_extract (SQLite/MySQL) or ->> (PostgreSQL)
|
|
112
|
-
scope :touching, ->(attribute) {
|
|
113
|
-
if connection.adapter_name =~ /PostgreSQL/i
|
|
114
|
-
# :nocov:
|
|
115
|
-
where("object_changes->>? IS NOT NULL", attribute.to_s)
|
|
116
|
-
# :nocov:
|
|
117
|
-
else
|
|
118
|
-
where("json_extract(object_changes, ?) IS NOT NULL", "$.#{attribute}")
|
|
119
|
-
end
|
|
120
|
-
}
|
|
121
221
|
end
|
|
122
222
|
end
|
|
@@ -22,6 +22,11 @@ RailsAuditLog.configure do |config|
|
|
|
22
22
|
# Per-model `audit_log version_limit: N` takes precedence.
|
|
23
23
|
# config.version_limit = 100
|
|
24
24
|
|
|
25
|
+
# Global time-based TTL — entries older than this duration are pruned after
|
|
26
|
+
# each write. Composes with version_limit: an entry is removed when it
|
|
27
|
+
# exceeds either constraint. Default: nil (no TTL)
|
|
28
|
+
# config.retention_period = 90.days
|
|
29
|
+
|
|
25
30
|
# Write all audit entries asynchronously via WriteAuditLogJob. Default: false
|
|
26
31
|
# Per-model `audit_log async: true` also works.
|
|
27
32
|
# config.async = true
|
data/lib/generators/rails_audit_log/migrate_from_paper_trail/migrate_from_paper_trail_generator.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module RailsAuditLog
|
|
5
|
+
module Generators
|
|
6
|
+
class MigrateFromPaperTrailGenerator < Rails::Generators::Base
|
|
7
|
+
include ActiveRecord::Generators::Migration
|
|
8
|
+
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Creates a data migration that copies PaperTrail versions to audit_log_entries."
|
|
12
|
+
|
|
13
|
+
def create_migration_file
|
|
14
|
+
migration_template(
|
|
15
|
+
"migrate_from_paper_trail.rb",
|
|
16
|
+
"db/migrate/migrate_from_paper_trail.rb"
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/generators/rails_audit_log/migrate_from_paper_trail/templates/migrate_from_paper_trail.rb
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
# Data migration: copies PaperTrail `versions` rows to `audit_log_entries`.
|
|
5
|
+
#
|
|
6
|
+
# Column mapping:
|
|
7
|
+
# versions.item_type → audit_log_entries.item_type
|
|
8
|
+
# versions.item_id → audit_log_entries.item_id
|
|
9
|
+
# versions.event → audit_log_entries.event (create/update/destroy only)
|
|
10
|
+
# versions.object_changes → audit_log_entries.object_changes (YAML or JSON → JSON)
|
|
11
|
+
# versions.object → audit_log_entries.object (YAML or JSON → JSON)
|
|
12
|
+
# versions.whodunnit → audit_log_entries.whodunnit_snapshot
|
|
13
|
+
# versions.created_at → audit_log_entries.created_at
|
|
14
|
+
#
|
|
15
|
+
# actor_type / actor_id are not populated — PaperTrail stores the actor
|
|
16
|
+
# only as a string (whodunnit), so polymorphic references cannot be inferred.
|
|
17
|
+
#
|
|
18
|
+
# This migration is irreversible.
|
|
19
|
+
class MigrateFromPaperTrail < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
20
|
+
BATCH_SIZE = 1_000
|
|
21
|
+
VALID_EVENTS = %w[create update destroy].freeze
|
|
22
|
+
|
|
23
|
+
# Isolated AR classes to avoid coupling to the host app's models.
|
|
24
|
+
class Version < ActiveRecord::Base # @api private
|
|
25
|
+
self.table_name = "versions"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class AuditEntry < ActiveRecord::Base # @api private
|
|
29
|
+
self.table_name = "audit_log_entries"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def up
|
|
33
|
+
unless table_exists?(:versions)
|
|
34
|
+
say " versions table not found — nothing to migrate."
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
total = Version.count
|
|
39
|
+
migrated = 0
|
|
40
|
+
skipped = 0
|
|
41
|
+
|
|
42
|
+
say_with_time "Migrating #{total} PaperTrail versions → audit_log_entries" do
|
|
43
|
+
Version.in_batches(of: BATCH_SIZE) do |batch|
|
|
44
|
+
rows = batch.filter_map do |v|
|
|
45
|
+
next unless VALID_EVENTS.include?(v.event)
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
event: v.event,
|
|
49
|
+
item_type: v.item_type,
|
|
50
|
+
item_id: v.item_id,
|
|
51
|
+
object_changes: parse_serialized(v.read_attribute_before_type_cast(:object_changes)),
|
|
52
|
+
object: parse_serialized(v.read_attribute_before_type_cast(:object)),
|
|
53
|
+
whodunnit_snapshot: v.whodunnit,
|
|
54
|
+
created_at: v.created_at,
|
|
55
|
+
updated_at: v.created_at
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
skipped += batch.size - rows.size
|
|
60
|
+
migrated += rows.size
|
|
61
|
+
AuditEntry.insert_all(rows) if rows.any?
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
say " Migrated: #{migrated} Skipped (unsupported event): #{skipped}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def down
|
|
69
|
+
raise ActiveRecord::IrreversibleMigration,
|
|
70
|
+
"Cannot reverse PaperTrail migration — rows already written to audit_log_entries " \
|
|
71
|
+
"cannot be reliably distinguished from native entries."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Parses a PaperTrail serialized column (YAML or JSON) and returns a Hash,
|
|
77
|
+
# or nil if the value is blank or unparseable.
|
|
78
|
+
def parse_serialized(value)
|
|
79
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
parsed = JSON.parse(value)
|
|
83
|
+
return parsed.is_a?(Hash) ? parsed : nil
|
|
84
|
+
rescue JSON::ParserError
|
|
85
|
+
# fall through to YAML
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
parsed = YAML.safe_load(
|
|
90
|
+
value,
|
|
91
|
+
permitted_classes: [Symbol, Date, Time, DateTime, BigDecimal,
|
|
92
|
+
ActiveSupport::TimeWithZone, ActiveSupport::Duration]
|
|
93
|
+
)
|
|
94
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
95
|
+
rescue StandardError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -1,24 +1,67 @@
|
|
|
1
1
|
module RailsAuditLog
|
|
2
|
+
# RSpec matchers for asserting audit log behaviour.
|
|
3
|
+
#
|
|
4
|
+
# == Setup
|
|
5
|
+
#
|
|
6
|
+
# # spec/rails_helper.rb
|
|
7
|
+
# require "rails_audit_log/matchers"
|
|
8
|
+
#
|
|
9
|
+
# RSpec.configure do |config|
|
|
10
|
+
# config.include RailsAuditLog::Matchers
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# == Usage
|
|
14
|
+
#
|
|
15
|
+
# # Assert a record already has an entry
|
|
16
|
+
# expect(post).to have_audit_log_entry(:update).touching(:title)
|
|
17
|
+
#
|
|
18
|
+
# # Assert a block creates a new entry
|
|
19
|
+
# expect { post.update!(title: "New") }.to create_audit_log_entry(event: :update, touching: :title)
|
|
2
20
|
module Matchers
|
|
21
|
+
# Returns a matcher that asserts the record has at least one matching
|
|
22
|
+
# {AuditLogEntry}.
|
|
23
|
+
#
|
|
24
|
+
# @param event [Symbol, String, nil] optional event filter
|
|
25
|
+
# (<tt>:create</tt>, <tt>:update</tt>, or <tt>:destroy</tt>)
|
|
26
|
+
# @return [HaveAuditLogEntry]
|
|
27
|
+
# @example
|
|
28
|
+
# expect(post).to have_audit_log_entry
|
|
29
|
+
# expect(post).to have_audit_log_entry(:update)
|
|
30
|
+
# expect(post).to have_audit_log_entry(:update).touching(:title)
|
|
3
31
|
def have_audit_log_entry(event = nil)
|
|
4
32
|
HaveAuditLogEntry.new(event)
|
|
5
33
|
end
|
|
6
34
|
|
|
35
|
+
# Returns a matcher that asserts the block creates at least one new
|
|
36
|
+
# {AuditLogEntry} matching the given filters.
|
|
37
|
+
#
|
|
38
|
+
# @param event [Symbol, String, nil] optional event filter
|
|
39
|
+
# @param touching [Symbol, String, nil] optional attribute filter
|
|
40
|
+
# @return [CreateAuditLogEntry]
|
|
41
|
+
# @example
|
|
42
|
+
# expect { post.update!(title: "x") }.to create_audit_log_entry(event: :update)
|
|
43
|
+
# expect { post.update!(title: "x") }.to create_audit_log_entry(touching: :title)
|
|
7
44
|
def create_audit_log_entry(event: nil, touching: nil)
|
|
8
45
|
CreateAuditLogEntry.new(event: event, touching: touching)
|
|
9
46
|
end
|
|
10
47
|
|
|
48
|
+
# RSpec matcher — asserts that a record already has a matching entry.
|
|
11
49
|
class HaveAuditLogEntry
|
|
12
50
|
def initialize(event)
|
|
13
51
|
@event = event
|
|
14
52
|
@touching = nil
|
|
15
53
|
end
|
|
16
54
|
|
|
55
|
+
# Chains an attribute filter onto the matcher.
|
|
56
|
+
#
|
|
57
|
+
# @param attribute [Symbol, String]
|
|
58
|
+
# @return [self]
|
|
17
59
|
def touching(attribute)
|
|
18
60
|
@touching = attribute
|
|
19
61
|
self
|
|
20
62
|
end
|
|
21
63
|
|
|
64
|
+
# @api private
|
|
22
65
|
def matches?(record)
|
|
23
66
|
@record = record
|
|
24
67
|
scope = record.audit_log_entries
|
|
@@ -27,14 +70,17 @@ module RailsAuditLog
|
|
|
27
70
|
scope.exists?
|
|
28
71
|
end
|
|
29
72
|
|
|
73
|
+
# @api private
|
|
30
74
|
def failure_message
|
|
31
75
|
"expected #{@record.class}##{@record.id} to have an audit log entry#{qualifier}"
|
|
32
76
|
end
|
|
33
77
|
|
|
78
|
+
# @api private
|
|
34
79
|
def failure_message_when_negated
|
|
35
80
|
"expected #{@record.class}##{@record.id} not to have an audit log entry#{qualifier}"
|
|
36
81
|
end
|
|
37
82
|
|
|
83
|
+
# @api private
|
|
38
84
|
def description
|
|
39
85
|
"have an audit log entry#{qualifier}"
|
|
40
86
|
end
|
|
@@ -49,21 +95,28 @@ module RailsAuditLog
|
|
|
49
95
|
end
|
|
50
96
|
end
|
|
51
97
|
|
|
98
|
+
# RSpec matcher — asserts that a block creates a new matching entry.
|
|
52
99
|
class CreateAuditLogEntry
|
|
53
100
|
def initialize(event:, touching:)
|
|
54
101
|
@event = event
|
|
55
102
|
@touching = touching
|
|
56
103
|
end
|
|
57
104
|
|
|
105
|
+
# Chains an attribute filter onto the matcher.
|
|
106
|
+
#
|
|
107
|
+
# @param attribute [Symbol, String]
|
|
108
|
+
# @return [self]
|
|
58
109
|
def touching(attribute)
|
|
59
110
|
@touching = attribute
|
|
60
111
|
self
|
|
61
112
|
end
|
|
62
113
|
|
|
114
|
+
# @api private
|
|
63
115
|
def supports_block_expectations?
|
|
64
116
|
true
|
|
65
117
|
end
|
|
66
118
|
|
|
119
|
+
# @api private
|
|
67
120
|
def matches?(block)
|
|
68
121
|
@before = matching_scope.count
|
|
69
122
|
block.call
|
|
@@ -71,14 +124,17 @@ module RailsAuditLog
|
|
|
71
124
|
@after > @before
|
|
72
125
|
end
|
|
73
126
|
|
|
127
|
+
# @api private
|
|
74
128
|
def failure_message
|
|
75
129
|
"expected block to create an audit log entry#{qualifier}, but none was created"
|
|
76
130
|
end
|
|
77
131
|
|
|
132
|
+
# @api private
|
|
78
133
|
def failure_message_when_negated
|
|
79
134
|
"expected block not to create an audit log entry#{qualifier}, but one was created"
|
|
80
135
|
end
|
|
81
136
|
|
|
137
|
+
# @api private
|
|
82
138
|
def description
|
|
83
139
|
"create an audit log entry#{qualifier}"
|
|
84
140
|
end
|