tangledwires-audited 6.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.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/Appraisals +37 -0
  3. data/CHANGELOG.md +539 -0
  4. data/Gemfile +3 -0
  5. data/LICENSE +19 -0
  6. data/README.md +447 -0
  7. data/Rakefile +16 -0
  8. data/audited.gemspec +38 -0
  9. data/lib/audited/audit.rb +204 -0
  10. data/lib/audited/audit_associate.rb +8 -0
  11. data/lib/audited/auditor.rb +564 -0
  12. data/lib/audited/railtie.rb +16 -0
  13. data/lib/audited/rspec_matchers.rb +228 -0
  14. data/lib/audited/sweeper.rb +42 -0
  15. data/lib/audited/version.rb +5 -0
  16. data/lib/audited-rspec.rb +6 -0
  17. data/lib/audited.rb +60 -0
  18. data/lib/generators/audited/install_generator.rb +27 -0
  19. data/lib/generators/audited/migration.rb +25 -0
  20. data/lib/generators/audited/migration_helper.rb +11 -0
  21. data/lib/generators/audited/templates/add_association_to_audits.rb +13 -0
  22. data/lib/generators/audited/templates/add_comment_to_audits.rb +11 -0
  23. data/lib/generators/audited/templates/add_remote_address_to_audits.rb +12 -0
  24. data/lib/generators/audited/templates/add_request_uuid_to_audits.rb +12 -0
  25. data/lib/generators/audited/templates/add_version_to_auditable_index.rb +23 -0
  26. data/lib/generators/audited/templates/create_audit_associates.rb +26 -0
  27. data/lib/generators/audited/templates/install.rb +39 -0
  28. data/lib/generators/audited/templates/rename_association_to_associated.rb +25 -0
  29. data/lib/generators/audited/templates/rename_changes_to_audited_changes.rb +11 -0
  30. data/lib/generators/audited/templates/rename_parent_to_association.rb +13 -0
  31. data/lib/generators/audited/templates/revert_polymorphic_indexes_order.rb +22 -0
  32. data/lib/generators/audited/upgrade_generator.rb +74 -0
  33. data/shell.nix +8 -0
  34. metadata +241 -0
@@ -0,0 +1,564 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audited
4
+ # Specify this act if you want changes to your model to be saved in an
5
+ # audit table. This assumes there is an audits table ready.
6
+ #
7
+ # class User < ActiveRecord::Base
8
+ # audited
9
+ # end
10
+ #
11
+ # To store an audit comment set model.audit_comment to your comment before
12
+ # a create, update or destroy operation.
13
+ #
14
+ # See <tt>Audited::Auditor::ClassMethods#audited</tt>
15
+ # for configuration options
16
+ module Auditor # :nodoc:
17
+ extend ActiveSupport::Concern
18
+
19
+ CALLBACKS = [:audit_create, :audit_update, :audit_destroy]
20
+
21
+ module ClassMethods
22
+ # == Configuration options
23
+ #
24
+ #
25
+ # * +only+ - Only audit the given attributes
26
+ # * +except+ - Excludes fields from being saved in the audit log.
27
+ # By default, Audited will audit all but these fields:
28
+ #
29
+ # [self.primary_key, inheritance_column, 'lock_version', 'created_at', 'updated_at']
30
+ # You can add to those by passing one or an array of fields to skip.
31
+ #
32
+ # class User < ActiveRecord::Base
33
+ # audited except: :password
34
+ # end
35
+ #
36
+ # * +require_comment+ - Ensures that audit_comment is supplied before
37
+ # any create, update or destroy operation.
38
+ # * +max_audits+ - Limits the number of stored audits.
39
+
40
+ # * +redacted+ - Changes to these fields will be logged, but the values
41
+ # will not. This is useful, for example, if you wish to audit when a
42
+ # password is changed, without saving the actual password in the log.
43
+ # To store values as something other than '[REDACTED]', pass an argument
44
+ # to the redaction_value option.
45
+ #
46
+ # class User < ActiveRecord::Base
47
+ # audited redacted: :password, redaction_value: SecureRandom.uuid
48
+ # end
49
+ #
50
+ # * +if+ - Only audit the model when the given function returns true
51
+ # * +unless+ - Only audit the model when the given function returns false
52
+ #
53
+ # class User < ActiveRecord::Base
54
+ # audited :if => :active?
55
+ #
56
+ # def active?
57
+ # self.status == 'active'
58
+ # end
59
+ # end
60
+ #
61
+ def audited(options = {})
62
+ audited? ? update_audited_options(options) : set_audit(options)
63
+ end
64
+
65
+ private
66
+
67
+ def audited?
68
+ included_modules.include?(Audited::Auditor::AuditedInstanceMethods)
69
+ end
70
+
71
+ def set_audit(options)
72
+ extend Audited::Auditor::AuditedClassMethods
73
+ include Audited::Auditor::AuditedInstanceMethods
74
+
75
+ class_attribute :audit_associated_with, instance_writer: false
76
+ class_attribute :audited_options, instance_writer: false
77
+ attr_accessor :audit_version, :audit_comment
78
+
79
+ set_audited_options(options)
80
+
81
+ if audited_options[:comment_required]
82
+ validate :presence_of_audit_comment
83
+ before_destroy :require_comment if audited_options[:on].include?(:destroy)
84
+ end
85
+
86
+ has_many :audits, -> { order(version: :asc) }, as: :auditable, class_name: Audited.audit_class.name, inverse_of: :auditable
87
+ Audited.audit_class.audited_class_names << to_s
88
+
89
+ after_create :audit_create if audited_options[:on].include?(:create)
90
+ before_update :audit_update if audited_options[:on].include?(:update)
91
+ after_touch :audit_touch if audited_options[:on].include?(:touch) && ::ActiveRecord::VERSION::MAJOR >= 6
92
+ before_destroy :audit_destroy if audited_options[:on].include?(:destroy)
93
+
94
+ # Define and set after_audit and around_audit callbacks. This might be useful if you want
95
+ # to notify a party after the audit has been created or if you want to access the newly-created
96
+ # audit.
97
+ define_callbacks :audit
98
+ set_callback :audit, :after, :after_audit, if: lambda { respond_to?(:after_audit, true) }
99
+ set_callback :audit, :around, :around_audit, if: lambda { respond_to?(:around_audit, true) }
100
+
101
+ enable_auditing
102
+ end
103
+
104
+ def has_associated_audits
105
+ has_many :audit_associates, as: :associated, class_name: Audited::AuditAssociate.name
106
+ has_many :associated_audits, through: :audit_associates, source: :audit, class_name: Audited.audit_class.name
107
+ end
108
+
109
+ def update_audited_options(new_options)
110
+ previous_audit_options = self.audited_options
111
+ set_audited_options(new_options)
112
+ self.reset_audited_columns
113
+ end
114
+
115
+ def set_audited_options(options)
116
+ self.audited_options = options
117
+ normalize_audited_options
118
+ self.audit_associated_with = audited_options[:associated_with]
119
+ end
120
+ end
121
+
122
+ module AuditedInstanceMethods
123
+ REDACTED = "[REDACTED]"
124
+
125
+ # Temporarily turns off auditing while saving.
126
+ def save_without_auditing
127
+ without_auditing { save }
128
+ end
129
+
130
+ # Executes the block with the auditing callbacks disabled.
131
+ #
132
+ # @foo.without_auditing do
133
+ # @foo.save
134
+ # end
135
+ #
136
+ def without_auditing(&block)
137
+ self.class.without_auditing(&block)
138
+ end
139
+
140
+ # Temporarily turns on auditing while saving.
141
+ def save_with_auditing
142
+ with_auditing { save }
143
+ end
144
+
145
+ # Executes the block with the auditing callbacks enabled.
146
+ #
147
+ # @foo.with_auditing do
148
+ # @foo.save
149
+ # end
150
+ #
151
+ def with_auditing(&block)
152
+ self.class.with_auditing(&block)
153
+ end
154
+
155
+ # Gets an array of the revisions available
156
+ #
157
+ # user.revisions.each do |revision|
158
+ # user.name
159
+ # user.version
160
+ # end
161
+ #
162
+ def revisions(from_version = 1)
163
+ return [] unless audits.from_version(from_version).exists?
164
+
165
+ all_audits = audits.select([:audited_changes, :version, :action]).to_a
166
+ targeted_audits = all_audits.select { |audit| audit.version >= from_version }
167
+
168
+ previous_attributes = reconstruct_attributes(all_audits - targeted_audits)
169
+
170
+ targeted_audits.map do |audit|
171
+ previous_attributes.merge!(audit.new_attributes)
172
+ revision_with(previous_attributes.merge!(version: audit.version))
173
+ end
174
+ end
175
+
176
+ # Get a specific revision specified by the version number, or +:previous+
177
+ # Returns nil for versions greater than revisions count
178
+ def revision(version)
179
+ if version == :previous || audits.last.version >= version
180
+ revision_with Audited.audit_class.reconstruct_attributes(audits_to(version))
181
+ end
182
+ end
183
+
184
+ # Find the oldest revision recorded prior to the date/time provided.
185
+ def revision_at(date_or_time)
186
+ audits = self.audits.up_until(date_or_time)
187
+ revision_with Audited.audit_class.reconstruct_attributes(audits) unless audits.empty?
188
+ end
189
+
190
+ # List of attributes that are audited.
191
+ def audited_attributes
192
+ audited_attributes = attributes.except(*self.class.non_audited_columns)
193
+ audited_attributes = redact_values(audited_attributes)
194
+ audited_attributes = filter_encrypted_attrs(audited_attributes)
195
+ normalize_enum_changes(audited_attributes)
196
+ end
197
+
198
+ # Returns a list combined of record audits and associated audits.
199
+ def own_and_associated_audits
200
+ Audited.audit_class.unscoped
201
+ .includes(:audit_associates)
202
+ .where(auditable: self)
203
+ .or(Audited.audit_class.unscoped.includes(:audit_associates).where(audit_associates: { associated: self }))
204
+ .order(created_at: :desc)
205
+ end
206
+
207
+ # Combine multiple audits into one.
208
+ def combine_audits(audits_to_combine)
209
+ combine_target = audits_to_combine.last
210
+ combine_target.audited_changes = audits_to_combine.pluck(:audited_changes).reduce(&:merge)
211
+ combine_target.comment = "#{combine_target.comment}\nThis audit is the result of multiple audits being combined."
212
+
213
+ transaction do
214
+ begin
215
+ combine_target.save!
216
+ audits_to_combine.unscope(:limit).where("version < ?", combine_target.version).delete_all
217
+ rescue ActiveRecord::Deadlocked
218
+ # Ignore Deadlocks, if the same record is getting its old audits combined more than once at the same time then
219
+ # both combining operations will be the same. Ignoring this error allows one of the combines to go through successfully.
220
+ end
221
+ end
222
+ end
223
+
224
+ protected
225
+
226
+ def revision_with(attributes)
227
+ dup.tap do |revision|
228
+ revision.id = id
229
+ revision.send :instance_variable_set, "@new_record", destroyed?
230
+ revision.send :instance_variable_set, "@persisted", !destroyed?
231
+ revision.send :instance_variable_set, "@readonly", false
232
+ revision.send :instance_variable_set, "@destroyed", false
233
+ revision.send :instance_variable_set, "@_destroyed", false
234
+ revision.send :instance_variable_set, "@marked_for_destruction", false
235
+ Audited.audit_class.assign_revision_attributes(revision, attributes)
236
+
237
+ # Remove any association proxies so that they will be recreated
238
+ # and reference the correct object for this revision. The only way
239
+ # to determine if an instance variable is a proxy object is to
240
+ # see if it responds to certain methods, as it forwards almost
241
+ # everything to its target.
242
+ revision.instance_variables.each do |ivar|
243
+ proxy = revision.instance_variable_get ivar
244
+ if !proxy.nil? && proxy.respond_to?(:proxy_respond_to?)
245
+ revision.instance_variable_set ivar, nil
246
+ end
247
+ end
248
+ end
249
+ end
250
+
251
+ private
252
+
253
+ def audited_changes(for_touch: false, exclude_readonly_attrs: false)
254
+ all_changes = if for_touch
255
+ previous_changes
256
+ elsif respond_to?(:changes_to_save)
257
+ changes_to_save
258
+ else
259
+ changes
260
+ end
261
+
262
+ all_changes = all_changes.except(*self.class.readonly_attributes.to_a) if exclude_readonly_attrs
263
+
264
+ filtered_changes = \
265
+ if audited_options[:only].present?
266
+ all_changes.slice(*self.class.audited_columns)
267
+ else
268
+ all_changes.except(*self.class.non_audited_columns)
269
+ end
270
+
271
+ filtered_changes = normalize_enum_changes(filtered_changes)
272
+
273
+ if for_touch && (last_audit = audits.last&.audited_changes)
274
+ filtered_changes.reject! do |k, v|
275
+ last_audit[k].to_json == v.to_json ||
276
+ last_audit[k].to_json == v[1].to_json
277
+ end
278
+ end
279
+
280
+ filtered_changes = redact_values(filtered_changes)
281
+ filtered_changes = filter_encrypted_attrs(filtered_changes)
282
+ filtered_changes.to_hash
283
+ end
284
+
285
+ def normalize_enum_changes(changes)
286
+ return changes if Audited.store_synthesized_enums
287
+
288
+ self.class.defined_enums.each do |name, values|
289
+ if changes.has_key?(name)
290
+ changes[name] = \
291
+ if changes[name].is_a?(Array)
292
+ changes[name].map { |v| values[v] }
293
+ elsif rails_below?("5.0")
294
+ changes[name]
295
+ else
296
+ values[changes[name]]
297
+ end
298
+ end
299
+ end
300
+ changes
301
+ end
302
+
303
+ def redact_values(filtered_changes)
304
+ filter_attr_values(
305
+ audited_changes: filtered_changes,
306
+ attrs: Array(audited_options[:redacted]).map(&:to_s),
307
+ placeholder: audited_options[:redaction_value] || REDACTED
308
+ )
309
+ end
310
+
311
+ def filter_encrypted_attrs(filtered_changes)
312
+ filter_attr_values(
313
+ audited_changes: filtered_changes,
314
+ attrs: respond_to?(:encrypted_attributes) ? Array(encrypted_attributes).map(&:to_s) : []
315
+ )
316
+ end
317
+
318
+ # Replace values for given attrs to a placeholder and return modified hash
319
+ #
320
+ # @param audited_changes [Hash] Hash of changes to be saved to audited version record
321
+ # @param attrs [Array<String>] Array of attrs, values of which will be replaced to placeholder value
322
+ # @param placeholder [String] Placeholder to replace original attr values
323
+ def filter_attr_values(audited_changes: {}, attrs: [], placeholder: "[FILTERED]")
324
+ attrs.each do |attr|
325
+ next unless audited_changes.key?(attr)
326
+
327
+ changes = audited_changes[attr]
328
+ values = changes.is_a?(Array) ? changes.map { placeholder } : placeholder
329
+
330
+ audited_changes[attr] = values
331
+ end
332
+
333
+ audited_changes
334
+ end
335
+
336
+ def rails_below?(rails_version)
337
+ ::ActiveRecord.version < Gem::Version.new(rails_version)
338
+ end
339
+
340
+ def audits_to(version = nil)
341
+ if version == :previous
342
+ version = if audit_version
343
+ audit_version - 1
344
+ else
345
+ previous = audits.descending.offset(1).first
346
+ previous ? previous.version : 1
347
+ end
348
+ end
349
+ audits.to_version(version)
350
+ end
351
+
352
+ def audit_create
353
+ write_audit(action: "create", audited_changes: audited_attributes,
354
+ comment: audit_comment)
355
+ end
356
+
357
+ def audit_update
358
+ unless (changes = audited_changes(exclude_readonly_attrs: true)).empty? && (audit_comment.blank? || audited_options[:update_with_comment_only] == false)
359
+ write_audit(action: "update", audited_changes: changes,
360
+ comment: audit_comment)
361
+ end
362
+ end
363
+
364
+ def audit_touch
365
+ unless (changes = audited_changes(for_touch: true, exclude_readonly_attrs: true)).empty?
366
+ write_audit(action: "update", audited_changes: changes,
367
+ comment: audit_comment)
368
+ end
369
+ end
370
+
371
+ def audit_destroy
372
+ unless new_record?
373
+ write_audit(action: "destroy", audited_changes: audited_attributes,
374
+ comment: audit_comment)
375
+ end
376
+ end
377
+
378
+ def write_audit(attrs)
379
+ self.audit_comment = nil
380
+
381
+ if auditing_enabled
382
+ run_callbacks(:audit) do
383
+ audit = audits.create(attrs)
384
+ audit.audit_associates << collect_audit_associated_with unless audit_associated_with.nil?
385
+ combine_audits_if_needed if attrs[:action] != 'create'
386
+ audit
387
+ end
388
+ end
389
+ end
390
+
391
+ def presence_of_audit_comment
392
+ if comment_required_state?
393
+ errors.add(:audit_comment, :blank) unless audit_comment.present?
394
+ end
395
+ end
396
+
397
+ def comment_required_state?
398
+ auditing_enabled &&
399
+ audited_changes.present? &&
400
+ ((audited_options[:on].include?(:create) && new_record?) ||
401
+ (audited_options[:on].include?(:update) && persisted? && changed?))
402
+ end
403
+
404
+ def combine_audits_if_needed
405
+ max_audits = evaluate_max_audits
406
+
407
+ if max_audits && (extra_count = audits.count - max_audits) > 0
408
+ audits_to_combine = audits.limit(extra_count + 1)
409
+ combine_audits(audits_to_combine)
410
+ end
411
+ end
412
+
413
+ def evaluate_max_audits
414
+ max_audits = case (option = audited_options[:max_audits])
415
+ when Proc then option.call
416
+ when Symbol then send(option)
417
+ else
418
+ option
419
+ end
420
+
421
+ Integer(max_audits).abs if max_audits
422
+ end
423
+
424
+ def require_comment
425
+ if auditing_enabled && audit_comment.blank?
426
+ errors.add(:audit_comment, :blank)
427
+ throw(:abort)
428
+ end
429
+ end
430
+
431
+ CALLBACKS.each do |attr_name|
432
+ alias_method "#{attr_name}_callback".to_sym, attr_name
433
+ end
434
+
435
+ def auditing_enabled
436
+ run_conditional_check(audited_options[:if]) &&
437
+ run_conditional_check(audited_options[:unless], matching: false) &&
438
+ self.class.auditing_enabled
439
+ end
440
+
441
+ def run_conditional_check(condition, matching: true)
442
+ return true if condition.blank?
443
+ return condition.call(self) == matching if condition.respond_to?(:call)
444
+ return send(condition) == matching if respond_to?(condition.to_sym, true)
445
+
446
+ true
447
+ end
448
+
449
+ def reconstruct_attributes(audits)
450
+ attributes = {}
451
+ audits.each { |audit| attributes.merge!(audit.new_attributes) }
452
+ attributes
453
+ end
454
+
455
+ def collect_audit_associated_with
456
+ Array(audit_associated_with).map do |associated|
457
+ Audited::AuditAssociate.new(associated: send(associated))
458
+ end
459
+ end
460
+ end
461
+
462
+ module AuditedClassMethods
463
+ # Returns an array of columns that are audited. See non_audited_columns
464
+ def audited_columns
465
+ @audited_columns ||= column_names - non_audited_columns
466
+ end
467
+
468
+ # We have to calculate this here since column_names may not be available when `audited` is called
469
+ def non_audited_columns
470
+ @non_audited_columns ||= calculate_non_audited_columns
471
+ end
472
+
473
+ def non_audited_columns=(columns)
474
+ @audited_columns = nil # reset cached audited columns on assignment
475
+ @non_audited_columns = columns.map(&:to_s)
476
+ end
477
+
478
+ # Executes the block with auditing disabled.
479
+ #
480
+ # Foo.without_auditing do
481
+ # @foo.save
482
+ # end
483
+ #
484
+ def without_auditing
485
+ auditing_was_enabled = class_auditing_enabled
486
+ disable_auditing
487
+ yield
488
+ ensure
489
+ enable_auditing if auditing_was_enabled
490
+ end
491
+
492
+ # Executes the block with auditing enabled.
493
+ #
494
+ # Foo.with_auditing do
495
+ # @foo.save
496
+ # end
497
+ #
498
+ def with_auditing
499
+ auditing_was_enabled = class_auditing_enabled
500
+ enable_auditing
501
+ yield
502
+ ensure
503
+ disable_auditing unless auditing_was_enabled
504
+ end
505
+
506
+ def disable_auditing
507
+ self.auditing_enabled = false
508
+ end
509
+
510
+ def enable_auditing
511
+ self.auditing_enabled = true
512
+ end
513
+
514
+ # All audit operations during the block are recorded as being
515
+ # made by +user+. This is not model specific, the method is a
516
+ # convenience wrapper around
517
+ # @see Audit#as_user.
518
+ def audit_as(user, &block)
519
+ Audited.audit_class.as_user(user, &block)
520
+ end
521
+
522
+ def auditing_enabled
523
+ class_auditing_enabled && Audited.auditing_enabled
524
+ end
525
+
526
+ def auditing_enabled=(val)
527
+ Audited.store["#{table_name}_auditing_enabled"] = val
528
+ end
529
+
530
+ def default_ignored_attributes
531
+ [primary_key, inheritance_column] | Audited.ignored_attributes
532
+ end
533
+
534
+ protected
535
+
536
+ def normalize_audited_options
537
+ audited_options[:on] = Array.wrap(audited_options[:on])
538
+ audited_options[:on] = ([:create, :update, :touch, :destroy] - Audited.ignored_default_callbacks) if audited_options[:on].empty?
539
+ audited_options[:only] = Array.wrap(audited_options[:only]).map(&:to_s)
540
+ audited_options[:except] = Array.wrap(audited_options[:except]).map(&:to_s)
541
+ audited_options[:max_audits] ||= Audited.max_audits
542
+ end
543
+
544
+ def calculate_non_audited_columns
545
+ if audited_options[:only].present?
546
+ (column_names | default_ignored_attributes) - audited_options[:only]
547
+ elsif audited_options[:except].present?
548
+ default_ignored_attributes | audited_options[:except]
549
+ else
550
+ default_ignored_attributes
551
+ end
552
+ end
553
+
554
+ def class_auditing_enabled
555
+ Audited.store.fetch("#{table_name}_auditing_enabled", true)
556
+ end
557
+
558
+ def reset_audited_columns
559
+ @audited_columns = nil
560
+ @non_audited_columns = nil
561
+ end
562
+ end
563
+ end
564
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audited
4
+ class Railtie < Rails::Railtie
5
+ initializer "audited.sweeper" do
6
+ ActiveSupport.on_load(:action_controller) do
7
+ if defined?(ActionController::Base)
8
+ ActionController::Base.around_action Audited::Sweeper.new
9
+ end
10
+ if defined?(ActionController::API)
11
+ ActionController::API.around_action Audited::Sweeper.new
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end