audited 4.6.0 → 4.10.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of audited might be problematic. Click here for more details.

Files changed (39) hide show
  1. checksums.yaml +5 -5
  2. data/.gitignore +0 -1
  3. data/.rubocop.yml +25 -0
  4. data/.travis.yml +35 -21
  5. data/Appraisals +29 -12
  6. data/CHANGELOG.md +108 -0
  7. data/README.md +125 -39
  8. data/gemfiles/rails42.gemfile +3 -0
  9. data/gemfiles/rails50.gemfile +3 -0
  10. data/gemfiles/rails51.gemfile +3 -0
  11. data/gemfiles/rails52.gemfile +4 -2
  12. data/gemfiles/rails60.gemfile +10 -0
  13. data/gemfiles/rails61.gemfile +10 -0
  14. data/lib/audited.rb +2 -1
  15. data/lib/audited/audit.rb +31 -25
  16. data/lib/audited/auditor.rb +199 -39
  17. data/lib/audited/rspec_matchers.rb +70 -21
  18. data/lib/audited/version.rb +1 -1
  19. data/lib/generators/audited/templates/add_version_to_auditable_index.rb +21 -0
  20. data/lib/generators/audited/templates/install.rb +1 -1
  21. data/lib/generators/audited/upgrade_generator.rb +4 -0
  22. data/spec/audited/audit_spec.rb +88 -21
  23. data/spec/audited/auditor_spec.rb +450 -57
  24. data/spec/audited/rspec_matchers_spec.rb +69 -0
  25. data/spec/audited/sweeper_spec.rb +15 -6
  26. data/spec/audited_spec_helpers.rb +16 -2
  27. data/spec/rails_app/app/assets/config/manifest.js +1 -0
  28. data/spec/rails_app/app/controllers/application_controller.rb +2 -0
  29. data/spec/rails_app/config/application.rb +5 -0
  30. data/spec/rails_app/config/database.yml +1 -0
  31. data/spec/spec_helper.rb +4 -1
  32. data/spec/support/active_record/models.rb +50 -3
  33. data/spec/support/active_record/schema.rb +4 -2
  34. data/test/db/version_6.rb +2 -0
  35. data/test/test_helper.rb +1 -2
  36. data/test/upgrade_generator_test.rb +10 -0
  37. metadata +60 -22
  38. data/gemfiles/rails40.gemfile +0 -9
  39. data/gemfiles/rails41.gemfile +0 -8
@@ -4,5 +4,8 @@ source "https://rubygems.org"
4
4
 
5
5
  gem "rails", "~> 4.2.0"
6
6
  gem "protected_attributes"
7
+ gem "mysql2", ">= 0.3.13", "< 0.6.0"
8
+ gem "pg", "~> 0.15"
9
+ gem "sqlite3", "~> 1.3.6"
7
10
 
8
11
  gemspec name: "audited", path: "../"
@@ -3,5 +3,8 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "rails", "~> 5.0.0"
6
+ gem "mysql2", ">= 0.3.18", "< 0.6.0"
7
+ gem "pg", ">= 0.18", "< 2.0"
8
+ gem "sqlite3", "~> 1.3.6"
6
9
 
7
10
  gemspec name: "audited", path: "../"
@@ -3,5 +3,8 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "rails", "~> 5.1.4"
6
+ gem "mysql2", ">= 0.3.18", "< 0.6.0"
7
+ gem "pg", ">= 0.18", "< 2.0"
8
+ gem "sqlite3", "~> 1.3.6"
6
9
 
7
10
  gemspec name: "audited", path: "../"
@@ -2,7 +2,9 @@
2
2
 
3
3
  source "https://rubygems.org"
4
4
 
5
- gem "rails", ">= 5.2.0.beta2", "< 5.3"
6
- gem "mysql2", "~> 0.4.4"
5
+ gem "rails", ">= 5.2.0", "< 5.3"
6
+ gem "mysql2", ">= 0.4.4", "< 0.6.0"
7
+ gem "pg", ">= 0.18", "< 2.0"
8
+ gem "sqlite3", "~> 1.3.6"
7
9
 
8
10
  gemspec name: "audited", path: "../"
@@ -0,0 +1,10 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", ">= 6.0.0", "< 6.1"
6
+ gem "mysql2", ">= 0.4.4"
7
+ gem "pg", ">= 0.18", "< 2.0"
8
+ gem "sqlite3", "~> 1.4"
9
+
10
+ gemspec name: "audited", path: "../"
@@ -0,0 +1,10 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", ">= 6.1.0", "< 6.2"
6
+ gem "mysql2", ">= 0.4.4"
7
+ gem "pg", ">= 1.1", "< 2.0"
8
+ gem "sqlite3", "~> 1.4"
9
+
10
+ gemspec name: "audited", path: "../"
@@ -2,7 +2,7 @@ require 'active_record'
2
2
 
3
3
  module Audited
4
4
  class << self
5
- attr_accessor :ignored_attributes, :current_user_method
5
+ attr_accessor :ignored_attributes, :current_user_method, :max_audits, :auditing_enabled
6
6
  attr_writer :audit_class
7
7
 
8
8
  def audit_class
@@ -21,6 +21,7 @@ module Audited
21
21
  @ignored_attributes = %w(lock_version created_at updated_at created_on updated_on)
22
22
 
23
23
  @current_user_method = :current_user
24
+ @auditing_enabled = true
24
25
  end
25
26
 
26
27
  require 'audited/auditor'
@@ -16,7 +16,7 @@ module Audited
16
16
  class YAMLIfTextColumnType
17
17
  class << self
18
18
  def load(obj)
19
- if Audited.audit_class.columns_hash["audited_changes"].type.to_s == "text"
19
+ if text_column?
20
20
  ActiveRecord::Coders::YAMLColumn.new(Object).load(obj)
21
21
  else
22
22
  obj
@@ -24,12 +24,16 @@ module Audited
24
24
  end
25
25
 
26
26
  def dump(obj)
27
- if Audited.audit_class.columns_hash["audited_changes"].type.to_s == "text"
27
+ if text_column?
28
28
  ActiveRecord::Coders::YAMLColumn.new(Object).dump(obj)
29
29
  else
30
30
  obj
31
31
  end
32
32
  end
33
+
34
+ def text_column?
35
+ Audited.audit_class.columns_hash["audited_changes"].type.to_s == "text"
36
+ end
33
37
  end
34
38
  end
35
39
 
@@ -65,7 +69,7 @@ module Audited
65
69
  def revision
66
70
  clazz = auditable_type.constantize
67
71
  (clazz.find_by_id(auditable_id) || clazz.new).tap do |m|
68
- self.class.assign_revision_attributes(m, self.class.reconstruct_attributes(ancestors).merge(version: version))
72
+ self.class.assign_revision_attributes(m, self.class.reconstruct_attributes(ancestors).merge(audit_version: version))
69
73
  end
70
74
  end
71
75
 
@@ -88,20 +92,18 @@ module Audited
88
92
 
89
93
  # Allows user to undo changes
90
94
  def undo
91
- model = self.auditable_type.constantize
92
- if action == 'create'
95
+ case action
96
+ when 'create'
93
97
  # destroys a newly created record
94
- model.find(auditable_id).destroy!
95
- elsif action == 'destroy'
98
+ auditable.destroy!
99
+ when 'destroy'
96
100
  # creates a new record with the destroyed record attributes
97
- model.create(audited_changes)
98
- else
101
+ auditable_type.constantize.create!(audited_changes)
102
+ when 'update'
99
103
  # changes back attributes
100
- audited_object = model.find(auditable_id)
101
- self.audited_changes.each do |k, v|
102
- audited_object[k] = v[0]
103
- end
104
- audited_object.save
104
+ auditable.update!(audited_changes.transform_values(&:first))
105
+ else
106
+ raise StandardError, "invalid action given #{action}"
105
107
  end
106
108
  end
107
109
 
@@ -132,21 +134,20 @@ module Audited
132
134
  # All audits made during the block called will be recorded as made
133
135
  # by +user+. This method is hopefully threadsafe, making it ideal
134
136
  # for background operations that require audit information.
135
- def self.as_user(user, &block)
137
+ def self.as_user(user)
138
+ last_audited_user = ::Audited.store[:audited_user]
136
139
  ::Audited.store[:audited_user] = user
137
140
  yield
138
141
  ensure
139
- ::Audited.store[:audited_user] = nil
142
+ ::Audited.store[:audited_user] = last_audited_user
140
143
  end
141
144
 
142
145
  # @private
143
146
  def self.reconstruct_attributes(audits)
144
- attributes = {}
145
- result = audits.collect do |audit|
146
- attributes.merge!(audit.new_attributes)[:version] = audit.version
147
- yield attributes if block_given?
148
- end
149
- block_given? ? result : attributes
147
+ audits.each_with_object({}) do |audit, all|
148
+ all.merge!(audit.new_attributes)
149
+ all[:audit_version] = audit.version
150
+ end
150
151
  end
151
152
 
152
153
  # @private
@@ -164,15 +165,20 @@ module Audited
164
165
  end
165
166
 
166
167
  # use created_at as timestamp cache key
167
- def self.collection_cache_key(collection = all, timestamp_column = :created_at)
168
+ def self.collection_cache_key(collection = all, *)
168
169
  super(collection, :created_at)
169
170
  end
170
171
 
171
172
  private
172
173
 
173
174
  def set_version_number
174
- max = self.class.auditable_finder(auditable_id, auditable_type).descending.first.try(:version) || 0
175
- self.version = max + 1
175
+ if action == 'create'
176
+ self.version = 1
177
+ else
178
+ collection = Rails::VERSION::MAJOR == 6 ? self.class.unscoped : self.class
179
+ max = collection.auditable_finder(auditable_id, auditable_type).maximum(:version) || 0
180
+ self.version = max + 1
181
+ end
176
182
  end
177
183
 
178
184
  def set_audit_user
@@ -33,6 +33,28 @@ module Audited
33
33
  #
34
34
  # * +require_comment+ - Ensures that audit_comment is supplied before
35
35
  # any create, update or destroy operation.
36
+ # * +max_audits+ - Limits the number of stored audits.
37
+
38
+ # * +redacted+ - Changes to these fields will be logged, but the values
39
+ # will not. This is useful, for example, if you wish to audit when a
40
+ # password is changed, without saving the actual password in the log.
41
+ # To store values as something other than '[REDACTED]', pass an argument
42
+ # to the redaction_value option.
43
+ #
44
+ # class User < ActiveRecord::Base
45
+ # audited redacted: :password, redaction_value: SecureRandom.uuid
46
+ # end
47
+ #
48
+ # * +if+ - Only audit the model when the given function returns true
49
+ # * +unless+ - Only audit the model when the given function returns false
50
+ #
51
+ # class User < ActiveRecord::Base
52
+ # audited :if => :active?
53
+ #
54
+ # def active?
55
+ # self.status == 'active'
56
+ # end
57
+ # end
36
58
  #
37
59
  def audited(options = {})
38
60
  # don't allow multiple calls
@@ -41,9 +63,9 @@ module Audited
41
63
  extend Audited::Auditor::AuditedClassMethods
42
64
  include Audited::Auditor::AuditedInstanceMethods
43
65
 
44
- class_attribute :audit_associated_with, instance_writer: false
66
+ class_attribute :audit_associated_with, instance_writer: false
45
67
  class_attribute :audited_options, instance_writer: false
46
- attr_accessor :version, :audit_comment
68
+ attr_accessor :audit_version, :audit_comment
47
69
 
48
70
  self.audited_options = options
49
71
  normalize_audited_options
@@ -51,11 +73,11 @@ module Audited
51
73
  self.audit_associated_with = audited_options[:associated_with]
52
74
 
53
75
  if audited_options[:comment_required]
54
- validates_presence_of :audit_comment, if: :auditing_enabled
55
- before_destroy :require_comment
76
+ validate :presence_of_audit_comment
77
+ before_destroy :require_comment if audited_options[:on].include?(:destroy)
56
78
  end
57
79
 
58
- has_many :audits, -> { order(version: :asc) }, as: :auditable, class_name: Audited.audit_class.name
80
+ has_many :audits, -> { order(version: :asc) }, as: :auditable, class_name: Audited.audit_class.name, inverse_of: :auditable
59
81
  Audited.audit_class.audited_class_names << to_s
60
82
 
61
83
  after_create :audit_create if audited_options[:on].include?(:create)
@@ -78,6 +100,8 @@ module Audited
78
100
  end
79
101
 
80
102
  module AuditedInstanceMethods
103
+ REDACTED = '[REDACTED]'
104
+
81
105
  # Temporarily turns off auditing while saving.
82
106
  def save_without_auditing
83
107
  without_auditing { save }
@@ -93,6 +117,21 @@ module Audited
93
117
  self.class.without_auditing(&block)
94
118
  end
95
119
 
120
+ # Temporarily turns on auditing while saving.
121
+ def save_with_auditing
122
+ with_auditing { save }
123
+ end
124
+
125
+ # Executes the block with the auditing callbacks enabled.
126
+ #
127
+ # @foo.with_auditing do
128
+ # @foo.save
129
+ # end
130
+ #
131
+ def with_auditing(&block)
132
+ self.class.with_auditing(&block)
133
+ end
134
+
96
135
  # Gets an array of the revisions available
97
136
  #
98
137
  # user.revisions.each do |revision|
@@ -101,9 +140,17 @@ module Audited
101
140
  # end
102
141
  #
103
142
  def revisions(from_version = 1)
104
- audits = self.audits.from_version(from_version)
105
- return [] if audits.empty?
106
- audits.map(&:revision)
143
+ return [] unless audits.from_version(from_version).exists?
144
+
145
+ all_audits = audits.select([:audited_changes, :version]).to_a
146
+ targeted_audits = all_audits.select { |audit| audit.version >= from_version }
147
+
148
+ previous_attributes = reconstruct_attributes(all_audits - targeted_audits)
149
+
150
+ targeted_audits.map do |audit|
151
+ previous_attributes.merge!(audit.new_attributes)
152
+ revision_with(previous_attributes.merge!(version: audit.version))
153
+ end
107
154
  end
108
155
 
109
156
  # Get a specific revision specified by the version number, or +:previous+
@@ -122,23 +169,35 @@ module Audited
122
169
 
123
170
  # List of attributes that are audited.
124
171
  def audited_attributes
125
- attributes.except(*non_audited_columns)
172
+ audited_attributes = attributes.except(*self.class.non_audited_columns)
173
+ normalize_enum_changes(audited_attributes)
126
174
  end
127
175
 
128
- protected
129
-
130
- def non_audited_columns
131
- self.class.non_audited_columns
176
+ # Returns a list combined of record audits and associated audits.
177
+ def own_and_associated_audits
178
+ Audited.audit_class.unscoped
179
+ .where('(auditable_type = :type AND auditable_id = :id) OR (associated_type = :type AND associated_id = :id)',
180
+ type: self.class.name, id: id)
181
+ .order(created_at: :desc)
132
182
  end
133
183
 
134
- def audited_columns
135
- self.class.audited_columns
184
+ # Combine multiple audits into one.
185
+ def combine_audits(audits_to_combine)
186
+ combine_target = audits_to_combine.last
187
+ combine_target.audited_changes = audits_to_combine.pluck(:audited_changes).reduce(&:merge)
188
+ combine_target.comment = "#{combine_target.comment}\nThis audit is the result of multiple audits being combined."
189
+
190
+ transaction do
191
+ combine_target.save!
192
+ audits_to_combine.unscope(:limit).where("version < ?", combine_target.version).delete_all
193
+ end
136
194
  end
137
195
 
196
+ protected
197
+
138
198
  def revision_with(attributes)
139
199
  dup.tap do |revision|
140
200
  revision.id = id
141
- revision.send :instance_variable_set, '@attributes', self.attributes if rails_below?('4.2.0')
142
201
  revision.send :instance_variable_set, '@new_record', destroyed?
143
202
  revision.send :instance_variable_set, '@persisted', !destroyed?
144
203
  revision.send :instance_variable_set, '@readonly', false
@@ -161,25 +220,62 @@ module Audited
161
220
  end
162
221
  end
163
222
 
164
- def rails_below?(rails_version)
165
- Gem::Version.new(Rails::VERSION::STRING) < Gem::Version.new(rails_version)
166
- end
167
-
168
223
  private
169
224
 
170
225
  def audited_changes
171
226
  all_changes = respond_to?(:changes_to_save) ? changes_to_save : changes
172
- if audited_options[:only].present?
173
- all_changes.slice(*audited_columns)
174
- else
175
- all_changes.except(*non_audited_columns)
227
+ filtered_changes = \
228
+ if audited_options[:only].present?
229
+ all_changes.slice(*self.class.audited_columns)
230
+ else
231
+ all_changes.except(*self.class.non_audited_columns)
232
+ end
233
+
234
+ filtered_changes = redact_values(filtered_changes)
235
+ filtered_changes = normalize_enum_changes(filtered_changes)
236
+ filtered_changes.to_hash
237
+ end
238
+
239
+ def normalize_enum_changes(changes)
240
+ self.class.defined_enums.each do |name, values|
241
+ if changes.has_key?(name)
242
+ changes[name] = \
243
+ if changes[name].is_a?(Array)
244
+ changes[name].map { |v| values[v] }
245
+ elsif rails_below?('5.0')
246
+ changes[name]
247
+ else
248
+ values[changes[name]]
249
+ end
250
+ end
176
251
  end
252
+ changes
253
+ end
254
+
255
+ def redact_values(filtered_changes)
256
+ [audited_options[:redacted]].flatten.compact.each do |option|
257
+ changes = filtered_changes[option.to_s]
258
+ new_value = audited_options[:redaction_value] || REDACTED
259
+ if changes.is_a? Array
260
+ values = changes.map { new_value }
261
+ else
262
+ values = new_value
263
+ end
264
+ hash = Hash[option.to_s, values]
265
+ filtered_changes.merge!(hash)
266
+ end
267
+
268
+ filtered_changes
269
+ end
270
+
271
+ def rails_below?(rails_version)
272
+ Gem::Version.new(Rails::VERSION::STRING) < Gem::Version.new(rails_version)
177
273
  end
178
274
 
179
275
  def audits_to(version = nil)
180
276
  if version == :previous
181
- version = if self.version
182
- self.version - 1
277
+ version = if self.audit_version
278
+ self.audit_version - 1
183
279
  else
184
280
  previous = audits.descending.offset(1).first
185
281
  previous ? previous.version : 1
@@ -194,7 +290,7 @@ module Audited
194
290
  end
195
291
 
196
292
  def audit_update
197
- unless (changes = audited_changes).empty? && audit_comment.blank?
293
+ unless (changes = audited_changes).empty? && (audit_comment.blank? || audited_options[:update_with_comment_only] == false)
198
294
  write_audit(action: 'update', audited_changes: changes,
199
295
  comment: audit_comment)
200
296
  end
@@ -208,14 +304,41 @@ module Audited
208
304
  def write_audit(attrs)
209
305
  attrs[:associated] = send(audit_associated_with) unless audit_associated_with.nil?
210
306
  self.audit_comment = nil
211
- run_callbacks(:audit) { audits.create(attrs) } if auditing_enabled
307
+
308
+ if auditing_enabled
309
+ run_callbacks(:audit) {
310
+ audit = audits.create(attrs)
311
+ combine_audits_if_needed if attrs[:action] != 'create'
312
+ audit
313
+ }
314
+ end
315
+ end
316
+
317
+ def presence_of_audit_comment
318
+ if comment_required_state?
319
+ errors.add(:audit_comment, "Comment can't be blank!") unless audit_comment.present?
320
+ end
321
+ end
322
+
323
+ def comment_required_state?
324
+ auditing_enabled &&
325
+ ((audited_options[:on].include?(:create) && self.new_record?) ||
326
+ (audited_options[:on].include?(:update) && self.persisted? && self.changed?))
327
+ end
328
+
329
+ def combine_audits_if_needed
330
+ max_audits = audited_options[:max_audits]
331
+ if max_audits && (extra_count = audits.count - max_audits) > 0
332
+ audits_to_combine = audits.limit(extra_count + 1)
333
+ combine_audits(audits_to_combine)
334
+ end
212
335
  end
213
336
 
214
337
  def require_comment
215
338
  if auditing_enabled && audit_comment.blank?
216
- errors.add(:audit_comment, "Comment required before destruction")
339
+ errors.add(:audit_comment, "Comment can't be blank!")
217
340
  return false if Rails.version.start_with?('4.')
218
- throw :abort
341
+ throw(:abort)
219
342
  end
220
343
  end
221
344
 
@@ -224,11 +347,23 @@ module Audited
224
347
  end
225
348
 
226
349
  def auditing_enabled
227
- self.class.auditing_enabled
350
+ return run_conditional_check(audited_options[:if]) &&
351
+ run_conditional_check(audited_options[:unless], matching: false) &&
352
+ self.class.auditing_enabled
228
353
  end
229
354
 
230
- def auditing_enabled=(val)
231
- self.class.auditing_enabled = val
355
+ def run_conditional_check(condition, matching: true)
356
+ return true if condition.blank?
357
+ return condition.call(self) == matching if condition.respond_to?(:call)
358
+ return send(condition) == matching if respond_to?(condition.to_sym, true)
359
+
360
+ true
361
+ end
362
+
363
+ def reconstruct_attributes(audits)
364
+ attributes = {}
365
+ audits.each { |audit| attributes.merge!(audit.new_attributes) }
366
+ attributes
232
367
  end
233
368
  end # InstanceMethods
234
369
 
@@ -240,9 +375,7 @@ module Audited
240
375
 
241
376
  # We have to calculate this here since column_names may not be available when `audited` is called
242
377
  def non_audited_columns
243
- @non_audited_columns ||= audited_options[:only].present? ?
244
- column_names - audited_options[:only] :
245
- default_ignored_attributes | audited_options[:except]
378
+ @non_audited_columns ||= calculate_non_audited_columns
246
379
  end
247
380
 
248
381
  def non_audited_columns=(columns)
@@ -264,6 +397,20 @@ module Audited
264
397
  enable_auditing if auditing_was_enabled
265
398
  end
266
399
 
400
+ # Executes the block with auditing enabled.
401
+ #
402
+ # Foo.with_auditing do
403
+ # @foo.save
404
+ # end
405
+ #
406
+ def with_auditing
407
+ auditing_was_enabled = auditing_enabled
408
+ enable_auditing
409
+ yield
410
+ ensure
411
+ disable_auditing unless auditing_was_enabled
412
+ end
413
+
267
414
  def disable_auditing
268
415
  self.auditing_enabled = false
269
416
  end
@@ -281,23 +428,36 @@ module Audited
281
428
  end
282
429
 
283
430
  def auditing_enabled
284
- Audited.store.fetch("#{table_name}_auditing_enabled", true)
431
+ Audited.store.fetch("#{table_name}_auditing_enabled", true) && Audited.auditing_enabled
285
432
  end
286
433
 
287
434
  def auditing_enabled=(val)
288
435
  Audited.store["#{table_name}_auditing_enabled"] = val
289
436
  end
290
437
 
291
- protected
292
438
  def default_ignored_attributes
293
- [primary_key, inheritance_column] + Audited.ignored_attributes
439
+ [primary_key, inheritance_column] | Audited.ignored_attributes
294
440
  end
295
441
 
442
+ protected
443
+
296
444
  def normalize_audited_options
297
445
  audited_options[:on] = Array.wrap(audited_options[:on])
298
446
  audited_options[:on] = [:create, :update, :destroy] if audited_options[:on].empty?
299
447
  audited_options[:only] = Array.wrap(audited_options[:only]).map(&:to_s)
300
448
  audited_options[:except] = Array.wrap(audited_options[:except]).map(&:to_s)
449
+ max_audits = audited_options[:max_audits] || Audited.max_audits
450
+ audited_options[:max_audits] = Integer(max_audits).abs if max_audits
451
+ end
452
+
453
+ def calculate_non_audited_columns
454
+ if audited_options[:only].present?
455
+ (column_names | default_ignored_attributes) - audited_options[:only]
456
+ elsif audited_options[:except].present?
457
+ default_ignored_attributes | audited_options[:except]
458
+ else
459
+ default_ignored_attributes
460
+ end
301
461
  end
302
462
  end
303
463
  end