journaled 4.2.0 → 5.1.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 (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! }