journaled 4.2.0 → 5.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +148 -46
  3. data/app/jobs/journaled/delivery_job.rb +17 -28
  4. data/app/models/concerns/journaled/changes.rb +1 -1
  5. data/app/models/journaled/audit_log/event.rb +87 -0
  6. data/app/models/journaled/event.rb +1 -1
  7. data/app/models/journaled/writer.rb +31 -15
  8. data/journaled_schemas/journaled/audit_log/event.json +31 -0
  9. data/lib/journaled/audit_log.rb +194 -0
  10. data/lib/journaled/connection.rb +48 -0
  11. data/lib/journaled/engine.rb +5 -0
  12. data/lib/journaled/errors.rb +3 -0
  13. data/lib/journaled/rspec.rb +86 -0
  14. data/lib/journaled/transaction_ext.rb +31 -0
  15. data/lib/journaled/version.rb +1 -1
  16. data/lib/journaled.rb +17 -11
  17. metadata +43 -84
  18. data/spec/dummy/README.rdoc +0 -28
  19. data/spec/dummy/Rakefile +0 -6
  20. data/spec/dummy/bin/bundle +0 -3
  21. data/spec/dummy/bin/rails +0 -4
  22. data/spec/dummy/bin/rake +0 -4
  23. data/spec/dummy/config/application.rb +0 -25
  24. data/spec/dummy/config/boot.rb +0 -5
  25. data/spec/dummy/config/database.yml +0 -6
  26. data/spec/dummy/config/environment.rb +0 -5
  27. data/spec/dummy/config/environments/development.rb +0 -24
  28. data/spec/dummy/config/environments/test.rb +0 -37
  29. data/spec/dummy/config/initializers/backtrace_silencers.rb +0 -7
  30. data/spec/dummy/config/initializers/cookies_serializer.rb +0 -3
  31. data/spec/dummy/config/initializers/filter_parameter_logging.rb +0 -4
  32. data/spec/dummy/config/initializers/inflections.rb +0 -16
  33. data/spec/dummy/config/initializers/mime_types.rb +0 -4
  34. data/spec/dummy/config/initializers/session_store.rb +0 -3
  35. data/spec/dummy/config/initializers/wrap_parameters.rb +0 -14
  36. data/spec/dummy/config/locales/en.yml +0 -23
  37. data/spec/dummy/config/routes.rb +0 -56
  38. data/spec/dummy/config/secrets.yml +0 -22
  39. data/spec/dummy/config.ru +0 -4
  40. data/spec/dummy/db/schema.rb +0 -18
  41. data/spec/dummy/public/404.html +0 -67
  42. data/spec/dummy/public/422.html +0 -67
  43. data/spec/dummy/public/500.html +0 -66
  44. data/spec/dummy/public/favicon.ico +0 -0
  45. data/spec/jobs/journaled/delivery_job_spec.rb +0 -276
  46. data/spec/lib/journaled_spec.rb +0 -89
  47. data/spec/models/concerns/journaled/actor_spec.rb +0 -47
  48. data/spec/models/concerns/journaled/changes_spec.rb +0 -106
  49. data/spec/models/database_change_protection_spec.rb +0 -109
  50. data/spec/models/journaled/actor_uri_provider_spec.rb +0 -42
  51. data/spec/models/journaled/change_writer_spec.rb +0 -281
  52. data/spec/models/journaled/event_spec.rb +0 -236
  53. data/spec/models/journaled/json_schema_model/validator_spec.rb +0 -133
  54. data/spec/models/journaled/writer_spec.rb +0 -174
  55. data/spec/rails_helper.rb +0 -19
  56. data/spec/spec_helper.rb +0 -24
  57. data/spec/support/environment_spec_helper.rb +0 -16
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bfdabb8b24bdfdf23c4f77ec819f03e89a4a0239ed997368eb1f07ac0bc2cd5b
4
- data.tar.gz: 97eeec1dfa22f7b683f23e322eb663e6fae4b47e0c2786690c4f44166b8cca51
3
+ metadata.gz: 5d0222aba969718f085949e0bf6b1fee163c70ed9512190c2b53abf75d0a120c
4
+ data.tar.gz: d3d2ac0e99d142aeb608f66f7d1a1ae15831d56483a8975bb7a991d3447058ac
5
5
  SHA512:
6
- metadata.gz: 78c8b888f9c00a084e8d60c2f271d56a9a188f60287cb30edfbadc7a4f9fc1c3fc3210c763e757658a0040fb57f6f94f1a713fc1f95942db801e2e114dd50ec3
7
- data.tar.gz: f0e56609680ecfc3e1f3f3a9d2ba801515762311307f0a208757a70a82ff7c37ba6af040f8ee5a64ebbf7a08a910579c27d65e940680b2406383b8eca022f323
6
+ metadata.gz: a6b4789d6314447dad04cc152b76cdf861f21d92cf27af5030f2ce500a8f2c6b791f7adfe86b975e614cba3f3af5b811022e6895feb32a831893851fabc5426d
7
+ data.tar.gz: 735a2305bb0599d1b6db9b355e39c1523079df60cbd975627eb5b273c155228d3740a3215341983ad15a90ff53dd6772f81a154829d13237ea8dde0eed92d34b
data/README.md CHANGED
@@ -179,52 +179,6 @@ journaling. Note that the less-frequently-used methods `toggle`,
179
179
  `increment*`, `decrement*`, and `update_counters` are not intercepted at
180
180
  this time.
181
181
 
182
- ### Tagged Events
183
-
184
- Events may be optionally marked as "tagged." This will add a `tags` field, intended for tracing and
185
- auditing purposes.
186
-
187
- ```ruby
188
- class MyEvent
189
- include Journaled::Event
190
-
191
- journal_attributes :attr_1, :attr_2, tagged: true
192
- end
193
- ```
194
-
195
- You may then use `Journaled.tag!` and `Journaled.tagged` inside of your
196
- `ApplicationController` and `ApplicationJob` classes (or anywhere else!) to tag
197
- all events with request and job metadata:
198
-
199
- ```ruby
200
- class ApplicationController < ActionController::Base
201
- before_action do
202
- Journaled.tag!(request_id: request.request_id, current_user_id: current_user&.id)
203
- end
204
- end
205
-
206
- class ApplicationJob < ActiveJob::Base
207
- around_perform do |job, perform|
208
- Journaled.tagged(job_id: job.id) { perform.call }
209
- end
210
- end
211
- ```
212
-
213
- This feature relies on `ActiveSupport::CurrentAttributes` under the hood, so these tags are local to
214
- the current thread, and will be cleared at the end of each request request/job.
215
-
216
- #### Testing
217
-
218
- If you use RSpec (and have required `journaled/rspec` in your
219
- `spec/rails_helper.rb`), you can regression-protect important journaling
220
- config with the `journal_changes_to` matcher:
221
-
222
- ```ruby
223
- it "journals exactly these things or there will be heck to pay" do
224
- expect(User).to journal_changes_to(:email, :first_name, :last_name, as: :identity_change)
225
- end
226
- ```
227
-
228
182
  ### Custom Journaling
229
183
 
230
184
  For every custom implementation of journaling in your application, define the JSON schema for the attributes in your event.
@@ -309,6 +263,40 @@ An event like the following will be journaled to kinesis:
309
263
  }
310
264
  ```
311
265
 
266
+ ### Tagged Events
267
+
268
+ Events may be optionally marked as "tagged." This will add a `tags` field, intended for tracing and
269
+ auditing purposes.
270
+
271
+ ```ruby
272
+ class MyEvent
273
+ include Journaled::Event
274
+
275
+ journal_attributes :attr_1, :attr_2, tagged: true
276
+ end
277
+ ```
278
+
279
+ You may then use `Journaled.tag!` and `Journaled.tagged` inside of your
280
+ `ApplicationController` and `ApplicationJob` classes (or anywhere else!) to tag
281
+ all events with request and job metadata:
282
+
283
+ ```ruby
284
+ class ApplicationController < ActionController::Base
285
+ before_action do
286
+ Journaled.tag!(request_id: request.request_id, current_user_id: current_user&.id)
287
+ end
288
+ end
289
+
290
+ class ApplicationJob < ActiveJob::Base
291
+ around_perform do |job, perform|
292
+ Journaled.tagged(job_id: job.id) { perform.call }
293
+ end
294
+ end
295
+ ```
296
+
297
+ This feature relies on `ActiveSupport::CurrentAttributes` under the hood, so these tags are local to
298
+ the current thread, and will be cleared at the end of each request request/job.
299
+
312
300
  ### Helper methods for custom events
313
301
 
314
302
  Journaled provides a couple helper methods that may be useful in your
@@ -352,6 +340,102 @@ Returns one of the following in order of preference:
352
340
  In order for this to be most useful, you must configure your controller
353
341
  as described in [Change Journaling](#change-journaling) above.
354
342
 
343
+ ### Testing
344
+
345
+ If you use RSpec, you can test for journaling behaviors with the
346
+ `journal_event(s)_including` and `journal_changes_to` matchers. First, make
347
+ sure to require `journaled/rspec` in your spec setup (e.g.
348
+ `spec/rails_helper.rb`):
349
+
350
+ ```ruby
351
+ require 'journaled/rspec'
352
+ ```
353
+
354
+ #### Checking for specific events
355
+
356
+ The `journal_event_including` and `journal_events_including` matchers allow you
357
+ to check for one or more matching event being journaled:
358
+
359
+ ```ruby
360
+ expect { my_code }
361
+ .to journal_event_including(name: 'foo')
362
+ expect { my_code }
363
+ .to journal_events_including({ name: 'foo', value: 1 }, { name: 'foo', value: 2 })
364
+ ```
365
+
366
+ This will only perform matches on the specified fields (and will not match one
367
+ way or the other against unspecified fields). These matchers will also ignore
368
+ any extraneous events that are not positively matched (as they may be unrelated
369
+ to behavior under test).
370
+
371
+ When writing tests, pairing every positive assertion with a negative assertion
372
+ is a good practice, and so negative matching is also supported (via both
373
+ `.not_to` and `.to not_`):
374
+
375
+ ```ruby
376
+ expect { my_code }
377
+ .not_to journal_events_including({ name: 'foo' }, { name: 'bar' })
378
+ expect { my_code }
379
+ .to raise_error(SomeError)
380
+ .and not_journal_event_including(name: 'foo') # the `not_` variant can chain off of `.and`
381
+ ```
382
+
383
+ Several chainable modifiers are also available:
384
+
385
+ ```ruby
386
+ expect { my_code }.to journal_event_including(name: 'foo')
387
+ .with_schema_name('my_event_schema')
388
+ .with_partition_key(user.id)
389
+ .with_stream_name('my_stream_name')
390
+ .with_enqueue_opts(run_at: future_time)
391
+ .with_priority(999)
392
+ ```
393
+
394
+ All of this can be chained together to test for multiple sets of events with
395
+ multiple sets of options:
396
+
397
+ ```ruby
398
+ expect { subject.journal! }
399
+ .to journal_events_including({ name: 'event1', value: 300 }, { name: 'event2', value: 200 })
400
+ .with_priority(10)
401
+ .and journal_event_including(name: 'event3', value: 100)
402
+ .with_priority(20)
403
+ .and not_journal_event_including(name: 'other_event')
404
+ ```
405
+
406
+ #### Checking for `Journaled::Changes` declarations
407
+
408
+ The `journal_changes_to` matcher checks against the list of attributes specified
409
+ on the model. It does not actually test that an event is emitted within a given
410
+ codepath, and is instead intended to guard against accidental regressions that
411
+ may impact external consumers of these events:
412
+
413
+ ```ruby
414
+ it "journals exactly these things or there will be heck to pay" do
415
+ expect(User).to journal_changes_to(:email, :first_name, :last_name, as: :identity_change)
416
+ end
417
+ ```
418
+
419
+ ### Instrumentation
420
+
421
+ When an event is enqueued, an `ActiveSupport::Notification` titled
422
+ `journaled.event.enqueue` is emitted. Its payload will include the `:event` and
423
+ its background job `:priority`.
424
+
425
+ This can be forwarded along to your preferred monitoring solution via a Rails
426
+ initializer:
427
+
428
+ ```ruby
429
+ ActiveSupport::Notifications.subscribe('journaled.event.enqueue') do |*args|
430
+ payload = ActiveSupport::Notifications::Event.new(*args).payload
431
+ journaled_event = payload[:event]
432
+
433
+ tags = { priority: payload[:priority], event_type: journaled_event.journaled_attributes[:event_type] }
434
+
435
+ Statsd.increment('journaled.event.enqueue', tags: tags.map { |k,v| "#{k.to_s[0..64]}:#{v.to_s[0..255]}" })
436
+ end
437
+ ```
438
+
355
439
  ## Upgrades
356
440
 
357
441
  Since this gem relies on background jobs (which can remain in the queue across
@@ -360,6 +444,24 @@ gem version.
360
444
 
361
445
  As such, **we always recommend upgrading only one major version at a time.**
362
446
 
447
+ ### Upgrading from 4.3.0
448
+
449
+ Versions of Journaled prior to 5.0 would enqueue events one at a time, but 5.0
450
+ introduces a new transaction-aware feature that will bundle up all events
451
+ emitted within a transaction and enqueue them all in a single "batch" job
452
+ directly before the SQL `COMMIT` statement. This reduces the database impact of
453
+ emitting a large volume of events at once.
454
+
455
+ This feature can be disabled conditionally:
456
+
457
+ ```ruby
458
+ Journaled.transactional_batching_enabled = false
459
+ ```
460
+
461
+ Backwards compatibility has been included for background jobs enqueued by
462
+ version 4.0 and above, but **has been dropped for jobs emitted by versions prior
463
+ to 4.0**. (Again, be sure to upgrade only one major version at a time.)
464
+
363
465
  ### Upgrading from 3.1.0
364
466
 
365
467
  Versions of Journaled prior to 4.0 relied directly on environment variables for stream names, but now stream names are configured directly.
@@ -12,26 +12,11 @@ module Journaled
12
12
  raise KinesisTemporaryFailure
13
13
  end
14
14
 
15
- UNSPECIFIED = Object.new
16
- private_constant :UNSPECIFIED
17
-
18
- def perform(serialized_event:, partition_key:, stream_name: UNSPECIFIED, app_name: UNSPECIFIED)
19
- @serialized_event = serialized_event
20
- @partition_key = partition_key
21
- if app_name != UNSPECIFIED
22
- @stream_name = self.class.legacy_computed_stream_name(app_name: app_name)
23
- elsif stream_name != UNSPECIFIED && !stream_name.nil?
24
- @stream_name = stream_name
25
- else
26
- raise(ArgumentError, 'missing keyword: stream_name')
27
- end
28
-
29
- journal!
30
- end
15
+ def perform(*events, **legacy_kwargs)
16
+ events << legacy_kwargs if legacy_kwargs.present?
17
+ @kinesis_records = events.map { |e| KinesisRecord.new(**e.delete_if { |_k, v| v.nil? }) }
31
18
 
32
- def self.legacy_computed_stream_name(app_name:)
33
- env_var_name = [app_name&.upcase, 'JOURNALED_STREAM_NAME'].compact.join('_')
34
- ENV.fetch(env_var_name)
19
+ journal! if Journaled.enabled?
35
20
  end
36
21
 
37
22
  def kinesis_client_config
@@ -46,18 +31,22 @@ module Journaled
46
31
 
47
32
  private
48
33
 
49
- attr_reader :serialized_event, :partition_key, :stream_name
34
+ KinesisRecord = Struct.new(:serialized_event, :partition_key, :stream_name, keyword_init: true) do
35
+ def initialize(serialized_event:, partition_key:, stream_name:)
36
+ super(serialized_event: serialized_event, partition_key: partition_key, stream_name: stream_name)
37
+ end
50
38
 
51
- def journal!
52
- kinesis_client.put_record record if Journaled.enabled?
39
+ def to_h
40
+ { stream_name: stream_name, data: serialized_event, partition_key: partition_key }
41
+ end
53
42
  end
54
43
 
55
- def record
56
- {
57
- stream_name: stream_name,
58
- data: serialized_event,
59
- partition_key: partition_key,
60
- }
44
+ attr_reader :kinesis_records
45
+
46
+ def journal!
47
+ kinesis_records.map do |record|
48
+ kinesis_client.put_record(**record.to_h)
49
+ end
61
50
  end
62
51
 
63
52
  def kinesis_client
@@ -56,7 +56,7 @@ module Journaled::Changes
56
56
  end
57
57
 
58
58
  class_methods do
59
- def journal_changes_to(*attribute_names, as:, enqueue_with: {}) # rubocop:disable Naming/MethodParameterName
59
+ def journal_changes_to(*attribute_names, as:, enqueue_with: {})
60
60
  if attribute_names.empty? || attribute_names.any? { |n| !n.is_a?(Symbol) }
61
61
  raise "one or more symbol attribute_name arguments is required"
62
62
  end
@@ -0,0 +1,87 @@
1
+ # FIXME: This cannot be included in lib/ because Journaled::Event is autoloaded via app/models
2
+ # Autoloading Journaled::Event isn't strictly necessary, and for compatibility it would
3
+ # make sense to move it to lib/.
4
+ module Journaled
5
+ module AuditLog
6
+ Event = Struct.new(:record, :database_operation, :unfiltered_changes) do
7
+ include Journaled::Event
8
+
9
+ journal_attributes :class_name, :table_name, :record_id,
10
+ :database_operation, :changes, :snapshot, :actor, tagged: true
11
+
12
+ def journaled_stream_name
13
+ AuditLog.default_stream_name || super
14
+ end
15
+
16
+ def created_at
17
+ case database_operation
18
+ when 'insert'
19
+ record_created_at
20
+ when 'update'
21
+ record_updated_at
22
+ when 'delete'
23
+ Time.zone.now
24
+ else
25
+ raise "Unhandled database operation type: #{database_operation}"
26
+ end
27
+ end
28
+
29
+ def record_created_at
30
+ record.try(:created_at) || Time.zone.now
31
+ end
32
+
33
+ def record_updated_at
34
+ record.try(:updated_at) || Time.zone.now
35
+ end
36
+
37
+ def class_name
38
+ record.class.name
39
+ end
40
+
41
+ def table_name
42
+ record.class.table_name
43
+ end
44
+
45
+ def record_id
46
+ record.id
47
+ end
48
+
49
+ def changes
50
+ filtered_changes = unfiltered_changes.deep_dup.deep_symbolize_keys
51
+ filtered_changes.each do |key, value|
52
+ filtered_changes[key] = value.map { |val| '[FILTERED]' if val } if filter_key?(key)
53
+ end
54
+ end
55
+
56
+ def snapshot
57
+ filtered_attributes if record._log_snapshot || AuditLog.snapshots_enabled
58
+ end
59
+
60
+ def actor
61
+ Journaled.actor_uri
62
+ end
63
+
64
+ private
65
+
66
+ def filter_key?(key)
67
+ filter_params.include?(key) || encrypted_column?(key)
68
+ end
69
+
70
+ def encrypted_column?(key)
71
+ key.to_s.end_with?('_crypt', '_hmac') ||
72
+ (Rails::VERSION::MAJOR >= 7 && record.encrypted_attribute?(key))
73
+ end
74
+
75
+ def filter_params
76
+ Rails.application.config.filter_parameters
77
+ end
78
+
79
+ def filtered_attributes
80
+ attrs = record.attributes.dup.symbolize_keys
81
+ attrs.each do |key, _value|
82
+ attrs[key] = '[FILTERED]' if filter_key?(key)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -63,7 +63,7 @@ module Journaled::Event
63
63
  end
64
64
 
65
65
  included do
66
- cattr_accessor(:journaled_enqueue_opts, instance_writer: false) { {} }
66
+ class_attribute :journaled_enqueue_opts, default: {}
67
67
 
68
68
  journal_attributes :id, :event_type, :created_at
69
69
  end
@@ -26,9 +26,35 @@ class Journaled::Writer
26
26
 
27
27
  def journal!
28
28
  validate!
29
- Journaled::DeliveryJob
30
- .set(journaled_enqueue_opts.reverse_merge(priority: Journaled.job_priority))
31
- .perform_later(**delivery_perform_args)
29
+
30
+ ActiveSupport::Notifications.instrument('journaled.event.stage', event: journaled_event, **journaled_enqueue_opts) do
31
+ if Journaled::Connection.available?
32
+ Journaled::Connection.stage!(journaled_event)
33
+ else
34
+ self.class.enqueue!(journaled_event)
35
+ end
36
+ end
37
+ end
38
+
39
+ def self.enqueue!(*events)
40
+ events.group_by(&:journaled_enqueue_opts).each do |enqueue_opts, batch|
41
+ job_opts = enqueue_opts.reverse_merge(priority: Journaled.job_priority)
42
+ ActiveSupport::Notifications.instrument('journaled.batch.enqueue', batch: batch, **job_opts) do
43
+ Journaled::DeliveryJob.set(job_opts).perform_later(*delivery_perform_args(batch))
44
+
45
+ batch.each { |event| ActiveSupport::Notifications.instrument('journaled.event.enqueue', event: event, **job_opts) }
46
+ end
47
+ end
48
+ end
49
+
50
+ def self.delivery_perform_args(events)
51
+ events.map do |event|
52
+ {
53
+ serialized_event: event.journaled_attributes.to_json,
54
+ partition_key: event.journaled_partition_key,
55
+ stream_name: event.journaled_stream_name,
56
+ }
57
+ end
32
58
  end
33
59
 
34
60
  private
@@ -38,23 +64,13 @@ class Journaled::Writer
38
64
  delegate(*EVENT_METHOD_NAMES, to: :journaled_event)
39
65
 
40
66
  def validate!
67
+ serialized_event = journaled_event.journaled_attributes.to_json
68
+
41
69
  schema_validator('base_event').validate! serialized_event
42
70
  schema_validator('tagged_event').validate! serialized_event if journaled_event.tagged?
43
71
  schema_validator(journaled_schema_name).validate! serialized_event
44
72
  end
45
73
 
46
- def delivery_perform_args
47
- {
48
- serialized_event: serialized_event,
49
- partition_key: journaled_partition_key,
50
- stream_name: journaled_stream_name,
51
- }
52
- end
53
-
54
- def serialized_event
55
- @serialized_event ||= journaled_attributes.to_json
56
- end
57
-
58
74
  def schema_validator(schema_name)
59
75
  Journaled::JsonSchemaModel::Validator.new(schema_name)
60
76
  end
@@ -0,0 +1,31 @@
1
+ {
2
+ "type": "object",
3
+ "title": "audit_log_event",
4
+ "additionalProperties": false,
5
+ "required": [
6
+ "id",
7
+ "event_type",
8
+ "created_at",
9
+ "class_name",
10
+ "table_name",
11
+ "record_id",
12
+ "database_operation",
13
+ "changes",
14
+ "snapshot",
15
+ "actor",
16
+ "tags"
17
+ ],
18
+ "properties": {
19
+ "id": { "type": "string" },
20
+ "event_type": { "type": "string" },
21
+ "created_at": { "type": "string" },
22
+ "class_name": { "type": "string" },
23
+ "table_name": { "type": "string" },
24
+ "record_id": { "type": ["string", "integer"] },
25
+ "database_operation": { "type": "string" },
26
+ "changes": { "type": "object", "additionalProperties": true },
27
+ "snapshot": { "type": ["object", "null"], "additionalProperties": true },
28
+ "actor": { "type": "string" },
29
+ "tags": { "type": "object", "additionalProperties": true }
30
+ }
31
+ }
@@ -0,0 +1,194 @@
1
+ require 'active_support/core_ext/module/attribute_accessors_per_thread'
2
+
3
+ module Journaled
4
+ module AuditLog
5
+ extend ActiveSupport::Concern
6
+
7
+ DEFAULT_EXCLUDED_CLASSES = %w(
8
+ Delayed::Job
9
+ PaperTrail::Version
10
+ ActiveStorage::Attachment
11
+ ActiveStorage::Blob
12
+ ActiveRecord::InternalMetadata
13
+ ActiveRecord::SchemaMigration
14
+ ).freeze
15
+
16
+ mattr_accessor(:default_ignored_columns) { %i(created_at updated_at) }
17
+ mattr_accessor(:default_stream_name) { Journaled.default_stream_name }
18
+ mattr_accessor(:excluded_classes) { DEFAULT_EXCLUDED_CLASSES.dup }
19
+ thread_mattr_accessor(:snapshots_enabled) { false }
20
+ thread_mattr_accessor(:_disabled) { false }
21
+ thread_mattr_accessor(:_force) { false }
22
+
23
+ class << self
24
+ def exclude_classes!
25
+ excluded_classes.each do |name|
26
+ if Rails::VERSION::MAJOR >= 6 && Rails.autoloaders.zeitwerk_enabled?
27
+ zeitwerk_exclude!(name)
28
+ else
29
+ classic_exclude!(name)
30
+ end
31
+ end
32
+ end
33
+
34
+ def with_snapshots
35
+ snapshots_enabled_was = snapshots_enabled
36
+ self.snapshots_enabled = true
37
+ yield
38
+ ensure
39
+ self.snapshots_enabled = snapshots_enabled_was
40
+ end
41
+
42
+ def without_audit_logging
43
+ disabled_was = _disabled
44
+ self._disabled = true
45
+ yield
46
+ ensure
47
+ self._disabled = disabled_was
48
+ end
49
+
50
+ private
51
+
52
+ def zeitwerk_exclude!(name)
53
+ if Object.const_defined?(name)
54
+ name.constantize.skip_audit_log
55
+ else
56
+ Rails.autoloaders.main.on_load(name) { |klass, _path| klass.skip_audit_log }
57
+ end
58
+ end
59
+
60
+ def classic_exclude!(name)
61
+ name.constantize.skip_audit_log
62
+ rescue NameError
63
+ nil
64
+ end
65
+ end
66
+
67
+ Config = Struct.new(:enabled, :ignored_columns) do
68
+ private :enabled
69
+ def enabled?
70
+ !AuditLog._disabled && self[:enabled].present?
71
+ end
72
+ end
73
+
74
+ included do
75
+ prepend BlockedMethods
76
+ singleton_class.prepend BlockedClassMethods
77
+
78
+ class_attribute :audit_log_config, default: Config.new(false, AuditLog.default_ignored_columns)
79
+ attr_accessor :_log_snapshot
80
+
81
+ after_create { _emit_audit_log!('insert') }
82
+ after_update { _emit_audit_log!('update') if _audit_log_changes.any? }
83
+ after_destroy { _emit_audit_log!('delete') }
84
+ end
85
+
86
+ class_methods do
87
+ def has_audit_log(ignore: [])
88
+ ignored_columns = _audit_log_inherited_ignored_columns + [ignore].flatten(1)
89
+ self.audit_log_config = Config.new(true, ignored_columns.uniq)
90
+ end
91
+
92
+ def skip_audit_log
93
+ self.audit_log_config = Config.new(false, _audit_log_inherited_ignored_columns.uniq)
94
+ end
95
+
96
+ private
97
+
98
+ def _audit_log_inherited_ignored_columns
99
+ (superclass.try(:audit_log_config)&.ignored_columns || []) + audit_log_config.ignored_columns
100
+ end
101
+ end
102
+
103
+ module BlockedMethods
104
+ BLOCKED_METHODS = {
105
+ delete: '#destroy',
106
+ update_column: '#update!',
107
+ update_columns: '#update!',
108
+ }.freeze
109
+
110
+ def delete(**kwargs)
111
+ _journaled_audit_log_check!(:delete, **kwargs) do
112
+ super()
113
+ end
114
+ end
115
+
116
+ def update_column(name, value, **kwargs)
117
+ _journaled_audit_log_check!(:update_column, **kwargs.merge(name => value)) do
118
+ super(name, value)
119
+ end
120
+ end
121
+
122
+ def update_columns(args = {}, **kwargs)
123
+ _journaled_audit_log_check!(:update_columns, **args.merge(kwargs)) do
124
+ super(args.merge(kwargs).except(:_force))
125
+ end
126
+ end
127
+
128
+ def _journaled_audit_log_check!(method, **kwargs) # rubocop:disable Metrics/AbcSize
129
+ force_was = AuditLog._force
130
+ AuditLog._force = kwargs.delete(:_force) if kwargs.key?(:_force)
131
+ audited_columns = kwargs.keys - audit_log_config.ignored_columns
132
+
133
+ if method == :delete || audited_columns.any?
134
+ column_message = <<~MSG if kwargs.any?
135
+ You are attempting to change the following audited columns:
136
+ #{audited_columns.inspect}
137
+
138
+ MSG
139
+ raise <<~MSG if audit_log_config.enabled? && !AuditLog._force
140
+ #{column_message}Using `#{method}` is blocked because it skips audit logging (and other Rails callbacks)!
141
+ Consider using `#{BLOCKED_METHODS[method]}` instead, or pass `_force: true` as an argument.
142
+ MSG
143
+ end
144
+
145
+ yield
146
+ ensure
147
+ AuditLog._force = force_was
148
+ end
149
+ end
150
+
151
+ module BlockedClassMethods
152
+ BLOCKED_METHODS = {
153
+ delete_all: '.destroy_all',
154
+ insert: '.create!',
155
+ insert_all: '.each { create!(...) }',
156
+ update_all: '.find_each { update!(...) }',
157
+ upsert: '.create_or_find_by!',
158
+ upsert_all: '.each { create_or_find_by!(...) }',
159
+ }.freeze
160
+
161
+ BLOCKED_METHODS.each do |method, alternative|
162
+ define_method(method) do |*args, **kwargs, &block|
163
+ force_was = AuditLog._force
164
+ AuditLog._force = kwargs.delete(:_force) if kwargs.key?(:_force)
165
+
166
+ raise <<~MSG if audit_log_config.enabled? && !AuditLog._force
167
+ `#{method}` is blocked because it skips callbacks and audit logs!
168
+ Consider using `#{alternative}` instead, or pass `_force: true` as an argument.
169
+ MSG
170
+
171
+ super(*args, **kwargs, &block)
172
+ ensure
173
+ AuditLog._force = force_was
174
+ end
175
+ end
176
+ end
177
+
178
+ def _emit_audit_log!(database_operation)
179
+ if audit_log_config.enabled?
180
+ event = Journaled::AuditLog::Event.new(self, database_operation, _audit_log_changes)
181
+ ActiveSupport::Notifications.instrument('journaled.audit_log.journal', event: event) do
182
+ event.journal!
183
+ end
184
+ end
185
+ end
186
+
187
+ def _audit_log_changes
188
+ previous_changes.except(*audit_log_config.ignored_columns)
189
+ end
190
+ end
191
+ end
192
+
193
+ ActiveSupport.on_load(:active_record) { include Journaled::AuditLog }
194
+ Journaled::Engine.config.after_initialize { Journaled::AuditLog.exclude_classes! }