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.
- checksums.yaml +4 -4
- data/README.md +148 -46
- data/app/jobs/journaled/delivery_job.rb +17 -28
- data/app/models/concerns/journaled/changes.rb +1 -1
- data/app/models/journaled/audit_log/event.rb +87 -0
- data/app/models/journaled/event.rb +1 -1
- data/app/models/journaled/writer.rb +31 -15
- data/journaled_schemas/journaled/audit_log/event.json +31 -0
- data/lib/journaled/audit_log.rb +194 -0
- data/lib/journaled/connection.rb +48 -0
- data/lib/journaled/engine.rb +5 -0
- data/lib/journaled/errors.rb +3 -0
- data/lib/journaled/rspec.rb +86 -0
- data/lib/journaled/transaction_ext.rb +31 -0
- data/lib/journaled/version.rb +1 -1
- data/lib/journaled.rb +17 -11
- metadata +43 -84
- data/spec/dummy/README.rdoc +0 -28
- data/spec/dummy/Rakefile +0 -6
- data/spec/dummy/bin/bundle +0 -3
- data/spec/dummy/bin/rails +0 -4
- data/spec/dummy/bin/rake +0 -4
- data/spec/dummy/config/application.rb +0 -25
- data/spec/dummy/config/boot.rb +0 -5
- data/spec/dummy/config/database.yml +0 -6
- data/spec/dummy/config/environment.rb +0 -5
- data/spec/dummy/config/environments/development.rb +0 -24
- data/spec/dummy/config/environments/test.rb +0 -37
- data/spec/dummy/config/initializers/backtrace_silencers.rb +0 -7
- data/spec/dummy/config/initializers/cookies_serializer.rb +0 -3
- data/spec/dummy/config/initializers/filter_parameter_logging.rb +0 -4
- data/spec/dummy/config/initializers/inflections.rb +0 -16
- data/spec/dummy/config/initializers/mime_types.rb +0 -4
- data/spec/dummy/config/initializers/session_store.rb +0 -3
- data/spec/dummy/config/initializers/wrap_parameters.rb +0 -14
- data/spec/dummy/config/locales/en.yml +0 -23
- data/spec/dummy/config/routes.rb +0 -56
- data/spec/dummy/config/secrets.yml +0 -22
- data/spec/dummy/config.ru +0 -4
- data/spec/dummy/db/schema.rb +0 -18
- data/spec/dummy/public/404.html +0 -67
- data/spec/dummy/public/422.html +0 -67
- data/spec/dummy/public/500.html +0 -66
- data/spec/dummy/public/favicon.ico +0 -0
- data/spec/jobs/journaled/delivery_job_spec.rb +0 -276
- data/spec/lib/journaled_spec.rb +0 -89
- data/spec/models/concerns/journaled/actor_spec.rb +0 -47
- data/spec/models/concerns/journaled/changes_spec.rb +0 -106
- data/spec/models/database_change_protection_spec.rb +0 -109
- data/spec/models/journaled/actor_uri_provider_spec.rb +0 -42
- data/spec/models/journaled/change_writer_spec.rb +0 -281
- data/spec/models/journaled/event_spec.rb +0 -236
- data/spec/models/journaled/json_schema_model/validator_spec.rb +0 -133
- data/spec/models/journaled/writer_spec.rb +0 -174
- data/spec/rails_helper.rb +0 -19
- data/spec/spec_helper.rb +0 -24
- data/spec/support/environment_spec_helper.rb +0 -16
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5d0222aba969718f085949e0bf6b1fee163c70ed9512190c2b53abf75d0a120c
|
4
|
+
data.tar.gz: d3d2ac0e99d142aeb608f66f7d1a1ae15831d56483a8975bb7a991d3447058ac
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
16
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
52
|
-
|
39
|
+
def to_h
|
40
|
+
{ stream_name: stream_name, data: serialized_event, partition_key: partition_key }
|
41
|
+
end
|
53
42
|
end
|
54
43
|
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
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: {})
|
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
|
@@ -26,9 +26,35 @@ class Journaled::Writer
|
|
26
26
|
|
27
27
|
def journal!
|
28
28
|
validate!
|
29
|
-
|
30
|
-
|
31
|
-
.
|
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! }
|