rails_audit_log 0.8.0 → 1.0.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 +294 -55
- data/Rakefile +5 -0
- data/app/assets/stylesheets/rails_audit_log/_01_base.css +32 -0
- data/app/assets/stylesheets/rails_audit_log/_02_layout.css +34 -0
- data/app/assets/stylesheets/rails_audit_log/_03_table.css +39 -0
- data/app/assets/stylesheets/rails_audit_log/_04_badges.css +13 -0
- data/app/assets/stylesheets/rails_audit_log/_05_diff.css +94 -0
- data/app/assets/stylesheets/rails_audit_log/_06_timeline.css +21 -0
- data/app/assets/stylesheets/rails_audit_log/_07_detail.css +15 -0
- data/app/assets/stylesheets/rails_audit_log/_08_pagination.css +56 -0
- data/app/assets/stylesheets/rails_audit_log/_09_filters.css +54 -0
- data/app/assets/stylesheets/rails_audit_log/application.css +1 -15
- data/app/concerns/rails_audit_log/auditable.rb +57 -0
- data/app/concerns/rails_audit_log/controller.rb +29 -0
- data/app/controllers/rails_audit_log/application_controller.rb +13 -0
- data/app/controllers/rails_audit_log/audit_log_entries_controller.rb +32 -0
- data/app/controllers/rails_audit_log/resources_controller.rb +14 -0
- data/app/helpers/rails_audit_log/application_helper.rb +14 -0
- data/app/javascript/rails_audit_log/application.js +8 -0
- data/app/javascript/rails_audit_log/diff_controller.js +20 -0
- data/app/javascript/rails_audit_log/search_controller.js +12 -0
- data/app/jobs/rails_audit_log/application_job.rb +1 -0
- data/app/models/rails_audit_log/application_record.rb +1 -0
- data/app/models/rails_audit_log/audit_log_entry.rb +127 -25
- data/app/views/layouts/rails_audit_log/application.html.erb +16 -10
- data/app/views/rails_audit_log/audit_log_entries/_diff.html.erb +55 -0
- data/app/views/rails_audit_log/audit_log_entries/index.html.erb +76 -0
- data/app/views/rails_audit_log/audit_log_entries/show.html.erb +50 -0
- data/app/views/rails_audit_log/resources/show.html.erb +35 -0
- data/config/importmap.rb +5 -0
- data/config/routes.rb +3 -0
- data/lib/generators/rails_audit_log/initializer/templates/rails_audit_log.rb +10 -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 +29 -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 +173 -5
- metadata +70 -5
|
@@ -1,37 +1,87 @@
|
|
|
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
|
|
|
5
|
-
EVENTS
|
|
24
|
+
EVENTS = %w[create update destroy].freeze
|
|
6
25
|
BLOB_COLUMNS = %w[object_changes object metadata].freeze
|
|
26
|
+
PERIODS = { "1h" => 1.hour, "24h" => 24.hours, "7d" => 7.days }.freeze
|
|
7
27
|
|
|
28
|
+
# @api private
|
|
8
29
|
def self.configure_connection!
|
|
9
30
|
return unless (opts = RailsAuditLog.connects_to)
|
|
10
31
|
|
|
11
32
|
connects_to(**opts)
|
|
12
33
|
end
|
|
13
34
|
|
|
14
|
-
belongs_to :item,
|
|
35
|
+
belongs_to :item, polymorphic: true, optional: true
|
|
15
36
|
belongs_to :actor, polymorphic: true, optional: true
|
|
16
37
|
|
|
17
|
-
validates :event,
|
|
38
|
+
validates :event, presence: true, inclusion: { in: EVENTS }
|
|
18
39
|
validates :item_type, presence: true
|
|
19
|
-
validates :item_id,
|
|
20
|
-
validate
|
|
40
|
+
validates :item_id, presence: true
|
|
41
|
+
validate :metadata_must_be_a_hash
|
|
21
42
|
|
|
22
|
-
# Event scopes
|
|
43
|
+
# @!group Event scopes
|
|
44
|
+
|
|
45
|
+
# Entries for +create+ events.
|
|
46
|
+
# @return [ActiveRecord::Relation]
|
|
23
47
|
scope :created_events, -> { where(event: "create") }
|
|
48
|
+
|
|
49
|
+
# Entries for +update+ events.
|
|
50
|
+
# @return [ActiveRecord::Relation]
|
|
24
51
|
scope :updated_events, -> { where(event: "update") }
|
|
25
|
-
scope :destroyed_events, -> { where(event: "destroy") }
|
|
26
52
|
|
|
27
|
-
#
|
|
53
|
+
# Entries for +destroy+ events.
|
|
54
|
+
# @return [ActiveRecord::Relation]
|
|
55
|
+
scope :destroyed_events, -> { where(event: "destroy") }
|
|
28
56
|
|
|
57
|
+
# @deprecated Use {.created_events} instead.
|
|
29
58
|
scope :creates, -> { created_events }
|
|
59
|
+
# @deprecated Use {.updated_events} instead.
|
|
30
60
|
scope :updates, -> { updated_events }
|
|
61
|
+
# @deprecated Use {.destroyed_events} instead.
|
|
31
62
|
scope :destroys, -> { destroyed_events }
|
|
32
63
|
|
|
33
|
-
#
|
|
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)
|
|
34
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)
|
|
35
85
|
scope :for_resource, lambda { |resource|
|
|
36
86
|
if resource.is_a?(Class)
|
|
37
87
|
where(item_type: resource.name)
|
|
@@ -40,15 +90,61 @@ module RailsAuditLog
|
|
|
40
90
|
end
|
|
41
91
|
}
|
|
42
92
|
|
|
43
|
-
#
|
|
93
|
+
# @!endgroup
|
|
94
|
+
|
|
95
|
+
# @!group Time scopes
|
|
96
|
+
|
|
97
|
+
# Entries created at or after +time+.
|
|
98
|
+
#
|
|
99
|
+
# @param time [Time]
|
|
100
|
+
# @return [ActiveRecord::Relation]
|
|
44
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]
|
|
45
107
|
scope :until, ->(time) { where(created_at: ..time) }
|
|
46
108
|
|
|
47
|
-
#
|
|
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]
|
|
113
|
+
scope :for_period, ->(period) { where(created_at: PERIODS[period].ago..) }
|
|
114
|
+
|
|
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]
|
|
48
122
|
scope :slim, -> { select(column_names - BLOB_COLUMNS) }
|
|
49
123
|
|
|
50
|
-
#
|
|
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
|
+
}
|
|
51
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)
|
|
52
148
|
def reify
|
|
53
149
|
return nil if event == "create"
|
|
54
150
|
|
|
@@ -80,18 +176,37 @@ module RailsAuditLog
|
|
|
80
176
|
instance
|
|
81
177
|
end
|
|
82
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]
|
|
83
183
|
def previous
|
|
84
184
|
self.class.where(item_type: item_type, item_id: item_id).where("id < ?", id).order(id: :desc).first
|
|
85
185
|
end
|
|
86
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]
|
|
87
191
|
def next
|
|
88
192
|
self.class.where(item_type: item_type, item_id: item_id).where("id > ?", id).order(id: :asc).first
|
|
89
193
|
end
|
|
90
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>]
|
|
91
199
|
def changed_attributes
|
|
92
200
|
object_changes&.keys || []
|
|
93
201
|
end
|
|
94
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" }, ... }
|
|
95
210
|
def diff
|
|
96
211
|
return {} unless object_changes
|
|
97
212
|
|
|
@@ -103,18 +218,5 @@ module RailsAuditLog
|
|
|
103
218
|
def metadata_must_be_a_hash
|
|
104
219
|
errors.add(:metadata, "must be a Hash") if metadata.present? && !metadata.is_a?(Hash)
|
|
105
220
|
end
|
|
106
|
-
|
|
107
|
-
public
|
|
108
|
-
|
|
109
|
-
# Attribute scope — uses json_extract (SQLite/MySQL) or ->> (PostgreSQL)
|
|
110
|
-
scope :touching, ->(attribute) {
|
|
111
|
-
if connection.adapter_name =~ /PostgreSQL/i
|
|
112
|
-
# :nocov:
|
|
113
|
-
where("object_changes->>? IS NOT NULL", attribute.to_s)
|
|
114
|
-
# :nocov:
|
|
115
|
-
else
|
|
116
|
-
where("json_extract(object_changes, ?) IS NOT NULL", "$.#{attribute}")
|
|
117
|
-
end
|
|
118
|
-
}
|
|
119
221
|
end
|
|
120
222
|
end
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html>
|
|
2
|
+
<html lang="en">
|
|
3
3
|
<head>
|
|
4
|
-
<
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Audit Log</title>
|
|
7
|
+
<link rel="icon" href="data:,">
|
|
5
8
|
<%= csrf_meta_tags %>
|
|
6
9
|
<%= csp_meta_tag %>
|
|
7
|
-
|
|
8
|
-
<%=
|
|
9
|
-
|
|
10
|
-
<%= stylesheet_link_tag "rails_audit_log/application", media: "all" %>
|
|
10
|
+
<%= dashboard_stylesheets %>
|
|
11
|
+
<%= javascript_importmap_tags "rails_audit_log" %>
|
|
11
12
|
</head>
|
|
12
13
|
<body>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
<header class="ral-header">
|
|
15
|
+
<div class="ral-header__inner">
|
|
16
|
+
<%= link_to "Audit Log", root_path, class: "ral-header__logo" %>
|
|
17
|
+
</div>
|
|
18
|
+
</header>
|
|
19
|
+
<main class="ral-main">
|
|
20
|
+
<%= yield %>
|
|
21
|
+
</main>
|
|
16
22
|
</body>
|
|
17
|
-
</html>
|
|
23
|
+
</html>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<% changes = entry.object_changes.presence %>
|
|
2
|
+
<% if changes %>
|
|
3
|
+
<div class="ral-diff-wrap" data-controller="diff">
|
|
4
|
+
<div class="ral-diff-toggle">
|
|
5
|
+
<button class="ral-diff-btn ral-diff-btn--active"
|
|
6
|
+
data-diff-target="inlineBtn"
|
|
7
|
+
data-action="click->diff#setInline">Inline</button>
|
|
8
|
+
<button class="ral-diff-btn"
|
|
9
|
+
data-diff-target="sideBtn"
|
|
10
|
+
data-action="click->diff#setSide">Side by side</button>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<table class="ral-diff" data-diff-target="inline">
|
|
14
|
+
<thead>
|
|
15
|
+
<tr>
|
|
16
|
+
<th>Attribute</th>
|
|
17
|
+
<th>Before</th>
|
|
18
|
+
<th>After</th>
|
|
19
|
+
</tr>
|
|
20
|
+
</thead>
|
|
21
|
+
<tbody>
|
|
22
|
+
<% changes.each do |attr, (before, after)| %>
|
|
23
|
+
<tr>
|
|
24
|
+
<td class="ral-diff__attr"><%= attr %></td>
|
|
25
|
+
<td class="ral-diff__before"><%= format_diff_value(before) %></td>
|
|
26
|
+
<td class="ral-diff__after"><%= format_diff_value(after) %></td>
|
|
27
|
+
</tr>
|
|
28
|
+
<% end %>
|
|
29
|
+
</tbody>
|
|
30
|
+
</table>
|
|
31
|
+
|
|
32
|
+
<div class="ral-diff-side" data-diff-target="side" hidden>
|
|
33
|
+
<div class="ral-diff-side__panel ral-diff-side__panel--before">
|
|
34
|
+
<div class="ral-diff-side__header">Before</div>
|
|
35
|
+
<% changes.each do |attr, (before, _after)| %>
|
|
36
|
+
<div class="ral-diff-side__row">
|
|
37
|
+
<span class="ral-diff-side__attr"><%= attr %></span>
|
|
38
|
+
<span class="ral-diff-side__value"><%= format_diff_value(before) %></span>
|
|
39
|
+
</div>
|
|
40
|
+
<% end %>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="ral-diff-side__panel ral-diff-side__panel--after">
|
|
43
|
+
<div class="ral-diff-side__header">After</div>
|
|
44
|
+
<% changes.each do |attr, (_before, after)| %>
|
|
45
|
+
<div class="ral-diff-side__row">
|
|
46
|
+
<span class="ral-diff-side__attr"><%= attr %></span>
|
|
47
|
+
<span class="ral-diff-side__value"><%= format_diff_value(after) %></span>
|
|
48
|
+
</div>
|
|
49
|
+
<% end %>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
<% else %>
|
|
54
|
+
<p class="ral-muted">No changes recorded.</p>
|
|
55
|
+
<% end %>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<div class="ral-page-header">
|
|
2
|
+
<h1 class="ral-page-header__title">Audit Entries</h1>
|
|
3
|
+
<span class="ral-page-header__meta"><%= @pagy.count %> <%= "entry".pluralize(@pagy.count) %></span>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<%= turbo_frame_tag "ral-entries", data: { turbo_action: "advance" } do %>
|
|
7
|
+
<form class="ral-filters" action="<%= audit_log_entries_path %>" method="get"
|
|
8
|
+
data-controller="search">
|
|
9
|
+
<%= hidden_field_tag :period, @period if @period %>
|
|
10
|
+
|
|
11
|
+
<input type="search" name="q" value="<%= @q %>"
|
|
12
|
+
class="ral-filter-input" placeholder="Filter by actor…"
|
|
13
|
+
autocomplete="off" aria-label="Filter by actor"
|
|
14
|
+
data-action="input->search#filter">
|
|
15
|
+
|
|
16
|
+
<select name="event" class="ral-filter-select" aria-label="Filter by event"
|
|
17
|
+
data-action="change->search#select">
|
|
18
|
+
<option value="">All events</option>
|
|
19
|
+
<% RailsAuditLog::AuditLogEntry::EVENTS.each do |event| %>
|
|
20
|
+
<option value="<%= event %>" <%= "selected" if @event == event %>><%= event.capitalize %></option>
|
|
21
|
+
<% end %>
|
|
22
|
+
</select>
|
|
23
|
+
|
|
24
|
+
<% if @item_types.size > 1 %>
|
|
25
|
+
<select name="item_type" class="ral-filter-select" aria-label="Filter by resource"
|
|
26
|
+
data-action="change->search#select">
|
|
27
|
+
<option value="">All resources</option>
|
|
28
|
+
<% @item_types.each do |type| %>
|
|
29
|
+
<option value="<%= type %>" <%= "selected" if @item_type == type %>><%= type %></option>
|
|
30
|
+
<% end %>
|
|
31
|
+
</select>
|
|
32
|
+
<% end %>
|
|
33
|
+
|
|
34
|
+
<div class="ral-period-filter" role="group" aria-label="Time period">
|
|
35
|
+
<%= link_to "All", audit_log_entries_path(event: @event, item_type: @item_type, q: @q),
|
|
36
|
+
class: "ral-period-btn#{" ral-period-btn--active" if @period.blank?}" %>
|
|
37
|
+
<% RailsAuditLog::AuditLogEntry::PERIODS.each_key do |p| %>
|
|
38
|
+
<%= link_to p, audit_log_entries_path(event: @event, item_type: @item_type, q: @q, period: p),
|
|
39
|
+
class: "ral-period-btn#{" ral-period-btn--active" if @period == p}" %>
|
|
40
|
+
<% end %>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<% if @event || @item_type || @period || @q %>
|
|
44
|
+
<%= link_to "Clear", audit_log_entries_path, class: "ral-link ral-filters__clear" %>
|
|
45
|
+
<% end %>
|
|
46
|
+
</form>
|
|
47
|
+
|
|
48
|
+
<% if @entries.any? %>
|
|
49
|
+
<div class="ral-table-wrap">
|
|
50
|
+
<table class="ral-table">
|
|
51
|
+
<thead>
|
|
52
|
+
<tr>
|
|
53
|
+
<th>Event</th>
|
|
54
|
+
<th>Resource</th>
|
|
55
|
+
<th>Actor</th>
|
|
56
|
+
<th>When</th>
|
|
57
|
+
</tr>
|
|
58
|
+
</thead>
|
|
59
|
+
<tbody>
|
|
60
|
+
<% @entries.each do |entry| %>
|
|
61
|
+
<tr>
|
|
62
|
+
<td><span class="ral-badge ral-badge--<%= entry.event %>"><%= entry.event %></span></td>
|
|
63
|
+
<td><%= link_to "#{entry.item_type} ##{entry.item_id}", resource_path(item_type: entry.item_type, item_id: entry.item_id), class: "ral-link", data: { turbo_frame: "_top" } %></td>
|
|
64
|
+
<td><%= entry.whodunnit_snapshot.presence || (entry.actor_type ? "#{entry.actor_type} \##{entry.actor_id}" : "—") %></td>
|
|
65
|
+
<td class="ral-timestamp"><%= entry.created_at.utc.strftime("%Y-%m-%d %H:%M UTC") %></td>
|
|
66
|
+
</tr>
|
|
67
|
+
<% end %>
|
|
68
|
+
</tbody>
|
|
69
|
+
</table>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<%== @pagy.series_nav(aria_label: "Pagination") if @pagy.pages > 1 %>
|
|
73
|
+
<% else %>
|
|
74
|
+
<p class="ral-empty">No audit entries found.</p>
|
|
75
|
+
<% end %>
|
|
76
|
+
<% end %>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<div class="ral-page-header">
|
|
2
|
+
<div>
|
|
3
|
+
<p class="ral-breadcrumb">
|
|
4
|
+
<%= link_to "Audit Entries", audit_log_entries_path, class: "ral-link" %> /
|
|
5
|
+
<%= link_to "#{@entry.item_type} ##{@entry.item_id}", resource_path(item_type: @entry.item_type, item_id: @entry.item_id), class: "ral-link" %>
|
|
6
|
+
</p>
|
|
7
|
+
<h1 class="ral-page-header__title">Entry #<%= @entry.id %></h1>
|
|
8
|
+
</div>
|
|
9
|
+
<nav class="ral-entry-nav" aria-label="Entry navigation">
|
|
10
|
+
<% if (prev_entry = @entry.previous) %>
|
|
11
|
+
<%= link_to "← Previous", audit_log_entry_path(prev_entry), class: "ral-pagination__link" %>
|
|
12
|
+
<% end %>
|
|
13
|
+
<% if (next_entry = @entry.next) %>
|
|
14
|
+
<%= link_to "Next →", audit_log_entry_path(next_entry), class: "ral-pagination__link" %>
|
|
15
|
+
<% end %>
|
|
16
|
+
</nav>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="ral-card">
|
|
20
|
+
<dl class="ral-meta">
|
|
21
|
+
<div class="ral-meta__row">
|
|
22
|
+
<dt>Event</dt>
|
|
23
|
+
<dd><span class="ral-badge ral-badge--<%= @entry.event %>"><%= @entry.event %></span></dd>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="ral-meta__row">
|
|
26
|
+
<dt>Resource</dt>
|
|
27
|
+
<dd><%= link_to "#{@entry.item_type} ##{@entry.item_id}", resource_path(item_type: @entry.item_type, item_id: @entry.item_id), class: "ral-link" %></dd>
|
|
28
|
+
</div>
|
|
29
|
+
<% actor = @entry.whodunnit_snapshot.presence || (@entry.actor_type ? "#{@entry.actor_type} \##{@entry.actor_id}" : "—") %>
|
|
30
|
+
<div class="ral-meta__row">
|
|
31
|
+
<dt>Actor</dt>
|
|
32
|
+
<dd><%= actor %></dd>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="ral-meta__row">
|
|
35
|
+
<dt>When</dt>
|
|
36
|
+
<dd class="ral-timestamp"><%= @entry.created_at.utc.strftime("%Y-%m-%d %H:%M:%S UTC") %></dd>
|
|
37
|
+
</div>
|
|
38
|
+
<% if @entry.reason.present? %>
|
|
39
|
+
<div class="ral-meta__row">
|
|
40
|
+
<dt>Reason</dt>
|
|
41
|
+
<dd><%= @entry.reason %></dd>
|
|
42
|
+
</div>
|
|
43
|
+
<% end %>
|
|
44
|
+
</dl>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<h2 class="ral-section-title">Changes</h2>
|
|
48
|
+
<div class="ral-card">
|
|
49
|
+
<%= render "diff", entry: @entry %>
|
|
50
|
+
</div>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<div class="ral-page-header">
|
|
2
|
+
<div>
|
|
3
|
+
<p class="ral-breadcrumb"><%= link_to "Audit Entries", audit_log_entries_path, class: "ral-link" %></p>
|
|
4
|
+
<h1 class="ral-page-header__title"><%= @item_type %> #<%= @item_id %></h1>
|
|
5
|
+
</div>
|
|
6
|
+
<span class="ral-page-header__meta"><%= @pagy.count %> <%= "entry".pluralize(@pagy.count) %></span>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<%= turbo_frame_tag "ral-resource-entries", data: { turbo_action: "advance" } do %>
|
|
10
|
+
<% if @entries.any? %>
|
|
11
|
+
<div class="ral-timeline">
|
|
12
|
+
<% @entries.each do |entry| %>
|
|
13
|
+
<div class="ral-timeline__entry">
|
|
14
|
+
<div class="ral-timeline__header">
|
|
15
|
+
<span class="ral-badge ral-badge--<%= entry.event %>"><%= entry.event %></span>
|
|
16
|
+
<span class="ral-timestamp"><%= entry.created_at.utc.strftime("%Y-%m-%d %H:%M UTC") %></span>
|
|
17
|
+
<% actor = entry.whodunnit_snapshot.presence || (entry.actor_type ? "#{entry.actor_type} \##{entry.actor_id}" : nil) %>
|
|
18
|
+
<% if actor %>
|
|
19
|
+
<span class="ral-muted">by <%= actor %></span>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% if entry.reason.present? %>
|
|
22
|
+
<span class="ral-reason">"<%= entry.reason %>"</span>
|
|
23
|
+
<% end %>
|
|
24
|
+
<%= link_to "Details →", audit_log_entry_path(entry), class: "ral-link ral-timeline__detail-link", data: { turbo_frame: "_top" } %>
|
|
25
|
+
</div>
|
|
26
|
+
<%= render "rails_audit_log/audit_log_entries/diff", entry: entry %>
|
|
27
|
+
</div>
|
|
28
|
+
<% end %>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<%== @pagy.series_nav(aria_label: "Pagination") if @pagy.pages > 1 %>
|
|
32
|
+
<% else %>
|
|
33
|
+
<p class="ral-empty">No audit entries for this record.</p>
|
|
34
|
+
<% end %>
|
|
35
|
+
<% end %>
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
pin "@hotwired/turbo", to: "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.23/dist/turbo.es2017-esm.js"
|
|
2
|
+
pin "@hotwired/stimulus", to: "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js"
|
|
3
|
+
pin "rails_audit_log", to: "rails_audit_log/application.js"
|
|
4
|
+
pin "rails_audit_log/search_controller", to: "rails_audit_log/search_controller.js"
|
|
5
|
+
pin "rails_audit_log/diff_controller", to: "rails_audit_log/diff_controller.js"
|
data/config/routes.rb
CHANGED
|
@@ -28,4 +28,14 @@ RailsAuditLog.configure do |config|
|
|
|
28
28
|
|
|
29
29
|
# Route AuditLogEntry to a dedicated database (Rails multi-DB). Default: nil
|
|
30
30
|
# config.connects_to = { database: { writing: :audit_log, reading: :audit_log } }
|
|
31
|
+
|
|
32
|
+
# Number of entries per page in the web dashboard. Default: 25
|
|
33
|
+
# config.page_size = 50
|
|
34
|
+
|
|
35
|
+
# Gate web dashboard access. Block runs in controller context — controller
|
|
36
|
+
# methods like current_user are available directly, or accept the controller
|
|
37
|
+
# as an argument. Falls back to HTTP Basic auth if the block returns falsy.
|
|
38
|
+
# Leave unset to allow unauthenticated access (development default).
|
|
39
|
+
# config.authenticate { current_user&.admin? }
|
|
40
|
+
# config.authenticate { |c| c.current_user&.admin? }
|
|
31
41
|
end
|
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,7 +1,36 @@
|
|
|
1
|
+
require "turbo-rails"
|
|
2
|
+
require "importmap-rails"
|
|
3
|
+
require "pagy"
|
|
4
|
+
require "pagy/toolbox/paginators/method"
|
|
5
|
+
|
|
1
6
|
module RailsAuditLog
|
|
7
|
+
# @api private
|
|
2
8
|
class Engine < ::Rails::Engine
|
|
3
9
|
isolate_namespace RailsAuditLog
|
|
4
10
|
|
|
11
|
+
config.i18n.load_path += Gem.find_files("pagy/locales/en.yml")
|
|
12
|
+
|
|
13
|
+
initializer "rails_audit_log.pagy" do |app|
|
|
14
|
+
app.config.after_initialize do
|
|
15
|
+
Pagy::OPTIONS[:limit] = RailsAuditLog.page_size
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
initializer "rails_audit_log.assets" do |app|
|
|
20
|
+
if app.config.respond_to?(:assets)
|
|
21
|
+
app.config.assets.paths << root.join("app/assets/stylesheets")
|
|
22
|
+
app.config.assets.paths << root.join("app/assets/images")
|
|
23
|
+
app.config.assets.paths << root.join("app/javascript")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
initializer "rails_audit_log.importmap", before: "importmap" do |app|
|
|
28
|
+
if app.config.respond_to?(:importmap)
|
|
29
|
+
app.config.importmap.paths << root.join("config/importmap.rb")
|
|
30
|
+
app.config.importmap.cache_sweepers << root.join("app/javascript")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
5
34
|
initializer "rails_audit_log.connect_audit_db" do
|
|
6
35
|
ActiveSupport.on_load(:active_record) do
|
|
7
36
|
RailsAuditLog::AuditLogEntry.configure_connection!
|