provenance 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.
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_record"
5
+
6
+ module Provenance
7
+ # Model-side concern. Captures create/update/destroy through ActiveRecord
8
+ # callbacks, groups changes by transaction key, discards them on rollback and
9
+ # flushes the audit log once every transaction has committed. Also tracks
10
+ # `has_and_belongs_to_many` join-table changes, which bypass model callbacks,
11
+ # via SQL notifications.
12
+ module Trackable
13
+ extend ActiveSupport::Concern
14
+
15
+ INSERT_DELETE_REGEX = /\b(INSERT|DELETE)\s+(INTO|FROM)\b/i
16
+ INSERT_INTO_REGEX = /\bINSERT\s+INTO\b/i
17
+ TABLE_NAME_REGEX = /(?:INSERT\s+INTO|DELETE\s+FROM)\s+["`]?(\w+)["`]?/i
18
+ INSERT_COLUMNS_REGEX = /INSERT\s+INTO\s+["`]?\w+["`]?\s*\(([^)]+)\)/i
19
+ WHERE_REGEX = /WHERE\s+(.+?)(?:\s+RETURNING|\s*$)/i
20
+
21
+ def self.included(base)
22
+ super
23
+
24
+ base.class_eval do
25
+ before_create :capture_transaction_id
26
+ before_update :capture_transaction_id
27
+ before_destroy :capture_transaction_id
28
+
29
+ after_update :capture_update
30
+ after_create :capture_create
31
+ after_destroy :capture_destroy
32
+
33
+ after_commit :try_send_audit_after_commit
34
+ after_rollback :clear_tracked_changes
35
+
36
+ class << self
37
+ alias_method :original_has_and_belongs_to_many, :has_and_belongs_to_many
38
+
39
+ def has_and_belongs_to_many(name, scope = nil, **options, &extension)
40
+ original_has_and_belongs_to_many(name, scope, **options, &extension).tap do
41
+ association = reflect_on_association(name)
42
+ next unless association
43
+
44
+ Provenance::Trackable.register_habtm_join_table_from_association(association, self.name, name)
45
+ end
46
+ end
47
+ end
48
+
49
+ base.setup_habtm_tracking
50
+ end
51
+
52
+ # has_and_belongs_to_many join-table writes bypass model callbacks, so we
53
+ # observe them through SQL notifications and fold them into the journal.
54
+ # The change is recorded synchronously on the request thread; delivery is
55
+ # handled by Auditable once every transaction has committed, so no extra
56
+ # post-commit scheduling is required here.
57
+ return if @habtm_sql_subscribed
58
+
59
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |_name, _start, _finish, _id, payload|
60
+ Provenance::Trackable.track_habtm_sql_changes(payload)
61
+ end
62
+ @habtm_sql_subscribed = true
63
+ end
64
+
65
+ class_methods do
66
+ def sensitive_attributes(*attributes)
67
+ @sensitive_attributes = Array(attributes).flatten
68
+ end
69
+
70
+ def sensitive_attributes_list
71
+ @sensitive_attributes || []
72
+ end
73
+
74
+ def setup_habtm_tracking
75
+ reflect_on_all_associations(:has_and_belongs_to_many).each do |association|
76
+ Provenance::Trackable.register_habtm_join_table_from_association(association, name, association.name)
77
+ end
78
+ end
79
+ end
80
+
81
+ # Registry of join tables we watch, keyed by table name. Populated when a
82
+ # model that includes Trackable declares a has_and_belongs_to_many.
83
+ def self.register_habtm_join_table_from_association(association, model_class_name, association_name)
84
+ join_table = association.join_table
85
+ (@habtm_join_tables ||= {})[join_table] ||= []
86
+ @habtm_join_tables[join_table] << {
87
+ model_class_name: model_class_name,
88
+ association_name: association_name,
89
+ foreign_key: association.foreign_key,
90
+ association_foreign_key: association.association_foreign_key
91
+ }
92
+ end
93
+
94
+ def self.habtm_join_tables
95
+ @habtm_join_tables ||= {}
96
+ end
97
+
98
+ # Inspects a SQL statement; if it touches a watched join table, reconstructs
99
+ # the affected ids and folds the change into the journal as an update.
100
+ def self.track_habtm_sql_changes(payload)
101
+ journal = Provenance::Context.journal
102
+ return unless journal
103
+
104
+ sql = payload[:sql].to_s
105
+ return unless sql.match?(INSERT_DELETE_REGEX)
106
+
107
+ table_name = sql.match(TABLE_NAME_REGEX)&.[](1)
108
+ return unless table_name && (info_list = habtm_join_tables[table_name])
109
+
110
+ action = sql.match?(INSERT_INTO_REGEX) ? :add : :remove
111
+
112
+ info_list.each do |info|
113
+ values_list = extract_habtm_values(sql, payload[:binds], action, info[:foreign_key], info[:association_foreign_key])
114
+ next unless values_list
115
+
116
+ model = info[:model_class_name].constantize.find_by(id: values_list[0])
117
+ next unless model&.persisted?
118
+
119
+ associated_ids = Array(values_list[1])
120
+ model.send(:track_habtm_changes, info[:association_name], action, associated_ids)
121
+ end
122
+ end
123
+
124
+ def self.extract_habtm_values(sql, binds, action, foreign_key, association_foreign_key)
125
+ return nil unless binds
126
+
127
+ if action == :add
128
+ extract_insert_values(sql, binds, foreign_key, association_foreign_key)
129
+ else
130
+ extract_delete_values(sql, binds, foreign_key, association_foreign_key)
131
+ end
132
+ end
133
+
134
+ def self.extract_insert_values(sql, binds, foreign_key, association_foreign_key)
135
+ columns = sql.match(INSERT_COLUMNS_REGEX)&.[](1)
136
+ return nil unless columns
137
+
138
+ columns = columns.split(",").map { |c| c.strip.delete('"`') }
139
+ fk_idx = columns.index(foreign_key)
140
+ afk_idx = columns.index(association_foreign_key)
141
+ return nil unless fk_idx && afk_idx && binds[fk_idx] && binds[afk_idx]
142
+
143
+ [extract_value(binds[fk_idx]), extract_value(binds[afk_idx])]
144
+ end
145
+
146
+ def self.extract_delete_values(sql, binds, foreign_key, association_foreign_key)
147
+ conditions = sql.match(WHERE_REGEX)&.[](1)
148
+ return nil unless conditions
149
+
150
+ fk_pattern = /(?:["`]\w+["`]\.)?["`]?#{Regexp.escape(foreign_key)}["`]?\s*[=<>]\s*\$(\d+)/i
151
+ fk_match = conditions.match(fk_pattern)
152
+ return nil unless fk_match
153
+
154
+ fk_idx = fk_match[1].to_i - 1
155
+ return nil unless binds[fk_idx]
156
+
157
+ afk_pattern = /(?:["`]\w+["`]\.)?["`]?#{Regexp.escape(association_foreign_key)}["`]?\s*[=<>]\s*\$(\d+)/i
158
+ afk_match = conditions.match(afk_pattern)
159
+
160
+ if afk_match
161
+ afk_idx = afk_match[1].to_i - 1
162
+ return nil unless binds[afk_idx]
163
+
164
+ [extract_value(binds[fk_idx]), extract_value(binds[afk_idx])]
165
+ else
166
+ in_params = conditions.match(/#{Regexp.escape(association_foreign_key)}["`]?\s+IN\s*\(([^)]+)\)/i)&.[](1)
167
+ return nil unless in_params
168
+
169
+ param_indices = in_params.scan(/\$(\d+)/).flatten.map(&:to_i).map { |i| i - 1 }
170
+ associated_ids = param_indices.filter_map { |idx| extract_value(binds[idx]) if binds[idx] }
171
+ return nil if associated_ids.empty?
172
+
173
+ [extract_value(binds[fk_idx]), associated_ids]
174
+ end
175
+ end
176
+
177
+ def self.extract_value(bind)
178
+ return bind.value if bind.respond_to?(:value)
179
+ return bind.value_for_database if bind.respond_to?(:value_for_database)
180
+
181
+ bind
182
+ end
183
+
184
+ private
185
+
186
+ def capture_transaction_id
187
+ @_audit_transaction_id = current_transaction_id
188
+ journal = Provenance::Context.journal
189
+ return unless journal && self.class.connection.transaction_open?
190
+
191
+ journal.register_transaction(@_audit_transaction_id)
192
+ end
193
+
194
+ def capture_create
195
+ journal = Provenance::Context.journal
196
+ return unless journal
197
+
198
+ attributes_data = if saved_changes.any?
199
+ saved_changes.transform_values(&:last)
200
+ else
201
+ attributes.except("id", "created_at", "updated_at")
202
+ end
203
+
204
+ journal.add_change(
205
+ self,
206
+ :create,
207
+ {
208
+ attributes: attributes_data,
209
+ transaction_id: captured_transaction_id
210
+ }
211
+ )
212
+ end
213
+
214
+ def capture_update
215
+ journal = Provenance::Context.journal
216
+ return unless journal
217
+
218
+ changed_attributes = saved_changes.transform_values(&:last)
219
+ previous_changes = saved_changes.transform_values(&:first)
220
+
221
+ journal.add_change(
222
+ self,
223
+ :update,
224
+ {
225
+ changed_attributes: changed_attributes,
226
+ previous_changes: previous_changes,
227
+ transaction_id: captured_transaction_id
228
+ }
229
+ )
230
+ end
231
+
232
+ def capture_destroy
233
+ journal = Provenance::Context.journal
234
+ return unless journal
235
+
236
+ journal.add_change(
237
+ self,
238
+ :destroy,
239
+ {
240
+ attributes: attributes.except("id", "created_at", "updated_at"),
241
+ transaction_id: captured_transaction_id
242
+ }
243
+ )
244
+ end
245
+
246
+ def try_send_audit_after_commit
247
+ return unless Provenance::Context.pending_audit_log
248
+
249
+ journal = Provenance::Context.journal
250
+ return unless journal
251
+
252
+ journal.complete_transaction(@_audit_transaction_id) if @_audit_transaction_id
253
+
254
+ active_transactions = journal.instance_variable_get(:@active_transactions)
255
+ return if active_transactions.any? || Thread.current[:provenance_send_scheduled]
256
+
257
+ Thread.current[:provenance_send_scheduled] = true
258
+ begin
259
+ Provenance::Context.pending_audit_log.call
260
+ ensure
261
+ Thread.current[:provenance_send_scheduled] = nil
262
+ end
263
+ end
264
+
265
+ def clear_tracked_changes
266
+ journal = Provenance::Context.journal
267
+ return unless journal && @_audit_transaction_id
268
+
269
+ journal.remove_changes_for_transaction(@_audit_transaction_id)
270
+ end
271
+
272
+ def captured_transaction_id
273
+ @_audit_transaction_id || Provenance::Context.request_id || "no-request-id"
274
+ end
275
+
276
+ def current_transaction_id
277
+ Provenance::TransactionKey.for_connection(self.class.connection)
278
+ end
279
+
280
+ def track_habtm_changes(association_name, action, associated_ids)
281
+ journal = Provenance::Context.journal
282
+ return unless journal && persisted?
283
+
284
+ association = self.class.reflect_on_association(association_name)
285
+ return unless association&.macro == :has_and_belongs_to_many
286
+
287
+ connection = self.class.connection
288
+ register_transaction_if_needed(journal, connection)
289
+
290
+ ids_key = "#{association_name}_ids"
291
+ transaction_id = captured_transaction_id
292
+ request_id_value = Provenance::Context.request_id.to_s
293
+
294
+ existing_change = find_existing_habtm_change(journal, ids_key, transaction_id, request_id_value)
295
+ current_ids = get_current_habtm_ids(connection, association)
296
+ associated_ids_str = associated_ids.map(&:to_s)
297
+
298
+ if existing_change
299
+ existing_change[:changes][:changed_attributes][ids_key] = current_ids
300
+ if action == :remove
301
+ existing_previous = existing_change[:changes][:previous_changes][ids_key] || []
302
+ existing_change[:changes][:previous_changes][ids_key] = (existing_previous + associated_ids_str).uniq
303
+ end
304
+ else
305
+ previous_ids = action == :add ? current_ids - associated_ids_str : current_ids + associated_ids_str
306
+ journal.add_change(self, :update, {
307
+ changed_attributes: { ids_key => current_ids },
308
+ previous_changes: { ids_key => previous_ids },
309
+ transaction_id: transaction_id
310
+ })
311
+ end
312
+
313
+ journal.complete_transaction(transaction_id)
314
+ end
315
+
316
+ def register_transaction_if_needed(journal, connection)
317
+ return unless connection.transaction_open?
318
+
319
+ @_audit_transaction_id ||= current_transaction_id
320
+ journal.register_transaction(@_audit_transaction_id)
321
+ end
322
+
323
+ def find_existing_habtm_change(journal, ids_key, transaction_id, request_id_value)
324
+ model_name = self.class.name
325
+ journal.changes.find do |change|
326
+ change[:model] == model_name &&
327
+ change[:model_id] == id &&
328
+ change[:action] == "update" &&
329
+ change[:changes][:changed_attributes]&.key?(ids_key) &&
330
+ (change[:transaction_id]&.start_with?(request_id_value) || change[:transaction_id] == transaction_id)
331
+ end
332
+ end
333
+
334
+ def get_current_habtm_ids(connection, association)
335
+ sql = "SELECT #{connection.quote_column_name(association.association_foreign_key)} " \
336
+ "FROM #{connection.quote_table_name(association.join_table)} " \
337
+ "WHERE #{connection.quote_column_name(association.foreign_key)} = #{connection.quote(id)}"
338
+ connection.select_values(sql).map(&:to_s)
339
+ end
340
+ end
341
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Provenance
4
+ # Builds the transaction key used to group changes inside the journal.
5
+ # A single format is shared across the gem (Trackable and BulkOperations);
6
+ # otherwise rollback cleanup (`remove_changes_for_transaction`) would fail to
7
+ # match the paired records.
8
+ module TransactionKey
9
+ module_function
10
+
11
+ def for_connection(connection)
12
+ request_id = Provenance::Context.request_id
13
+ return request_id unless connection.transaction_open?
14
+
15
+ current = connection.current_transaction
16
+ current ? "#{request_id}:#{current.object_id}" : "#{request_id}:#{connection.open_transactions}"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Provenance
4
+ VERSION = "1.0.0"
5
+ end
data/lib/provenance.rb ADDED
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/module/attribute_accessors"
5
+ require "active_support/core_ext/hash/keys"
6
+ require "json"
7
+ require "logger"
8
+ require "rails"
9
+
10
+ require_relative "provenance/version"
11
+ require_relative "provenance/configuration"
12
+ require_relative "provenance/context"
13
+ require_relative "provenance/transaction_key"
14
+ require_relative "provenance/journal"
15
+ require_relative "provenance/trackers/providers"
16
+ require_relative "provenance/trackers/trackable"
17
+ require_relative "provenance/trackers/bulk_operations"
18
+ require_relative "provenance/trackers/auditable"
19
+ require_relative "provenance/trackers/error_reporting"
20
+
21
+ # Provenance is a self-contained audit trail for Rails applications. It records
22
+ # user actions and model changes, sanitizes sensitive data and ships the
23
+ # resulting events to any sink you configure through audit hooks.
24
+ module Provenance
25
+ class Error < StandardError; end
26
+
27
+ class << self
28
+ def configure
29
+ yield config
30
+ end
31
+
32
+ def config
33
+ @config ||= Configuration.new
34
+ end
35
+
36
+ def setup_username_provider(provider)
37
+ config.username_provider = provider
38
+ end
39
+
40
+ def setup_roles_provider(provider)
41
+ config.roles_provider = provider
42
+ end
43
+
44
+ def setup_remote_ip_provider(provider)
45
+ config.remote_ip_provider = provider
46
+ end
47
+
48
+ def setup_origin_ip_provider(provider)
49
+ config.origin_ip_provider = provider
50
+ end
51
+
52
+ def setup_session_id_provider(provider)
53
+ config.session_id_provider = provider
54
+ end
55
+ end
56
+ end
metadata ADDED
@@ -0,0 +1,122 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: provenance
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Ivan Nikolaev
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-06-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: actionpack
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activerecord
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '6.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '6.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activesupport
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '6.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '6.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rails
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '6.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '6.0'
69
+ description: |
70
+ Provenance records user actions and ActiveRecord model changes in Rails
71
+ applications. It groups changes per request and transaction, sanitizes
72
+ sensitive data, tracks bulk operations and has_and_belongs_to_many changes,
73
+ and ships structured audit events to any sink through configurable hooks.
74
+ email:
75
+ - ivan_n6_20@icloud.com
76
+ executables: []
77
+ extensions: []
78
+ extra_rdoc_files: []
79
+ files:
80
+ - CHANGELOG.md
81
+ - LICENSE
82
+ - README.md
83
+ - lib/provenance.rb
84
+ - lib/provenance/configuration.rb
85
+ - lib/provenance/context.rb
86
+ - lib/provenance/journal.rb
87
+ - lib/provenance/trackers/auditable.rb
88
+ - lib/provenance/trackers/bulk_operations.rb
89
+ - lib/provenance/trackers/error_reporting.rb
90
+ - lib/provenance/trackers/providers.rb
91
+ - lib/provenance/trackers/trackable.rb
92
+ - lib/provenance/transaction_key.rb
93
+ - lib/provenance/version.rb
94
+ homepage: https://github.com/inikalaev/provenance
95
+ licenses:
96
+ - MIT
97
+ metadata:
98
+ homepage_uri: https://github.com/inikalaev/provenance
99
+ source_code_uri: https://github.com/inikalaev/provenance
100
+ changelog_uri: https://github.com/inikalaev/provenance/blob/main/CHANGELOG.md
101
+ bug_tracker_uri: https://github.com/inikalaev/provenance/issues
102
+ rubygems_mfa_required: 'true'
103
+ post_install_message:
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: 3.2.2
112
+ required_rubygems_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ requirements: []
118
+ rubygems_version: 3.5.22
119
+ signing_key:
120
+ specification_version: 4
121
+ summary: 'Audit trail for Rails: user actions and model changes'
122
+ test_files: []