journaled 5.1.0 → 5.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d0222aba969718f085949e0bf6b1fee163c70ed9512190c2b53abf75d0a120c
4
- data.tar.gz: d3d2ac0e99d142aeb608f66f7d1a1ae15831d56483a8975bb7a991d3447058ac
3
+ metadata.gz: 7fc4b493edc90534706cd22dbb1fae76cdacc693d95e44734f9ce92db2d0716c
4
+ data.tar.gz: 46edc5873055e2a4e7ceae9f8dc63c558b331a821826803cd36aa971b42258f5
5
5
  SHA512:
6
- metadata.gz: a6b4789d6314447dad04cc152b76cdf861f21d92cf27af5030f2ce500a8f2c6b791f7adfe86b975e614cba3f3af5b811022e6895feb32a831893851fabc5426d
7
- data.tar.gz: 735a2305bb0599d1b6db9b355e39c1523079df60cbd975627eb5b273c155228d3740a3215341983ad15a90ff53dd6772f81a154829d13237ea8dde0eed92d34b
6
+ metadata.gz: 6c9871cbc8b6439f9d29e29bbc24068d2a25667eb42003bed42ca016505b463586b8b3bc0b4c33d2f130a76d5dbc70bf7815d41b992def7a8dc4f71e697113e8
7
+ data.tar.gz: 6770fd7d6a3c92bce0122def374fa1a7e31f74c8a67d8ebeb9c2ccf5e1e02c616aae42274b5fd145e6cd59d6aa7e5b483dca134d8479822652e65c0bd7c1df84
data/README.md CHANGED
@@ -122,25 +122,19 @@ Both model-level directives accept additional options to be passed into ActiveJo
122
122
  # For change journaling:
123
123
  journal_changes_to :email, as: :identity_change, enqueue_with: { priority: 10 }
124
124
 
125
+ # For audit logging:
126
+ has_audit_log enqueue_with: { priority: 30 }
127
+
125
128
  # Or for custom journaling:
126
129
  journal_attributes :email, enqueue_with: { priority: 20, queue: 'journaled' }
127
130
  ```
128
131
 
129
- ### Change Journaling
130
-
131
- Out of the box, `Journaled` provides an event type and ActiveRecord
132
- mix-in for durably journaling changes to your model, implemented via
133
- ActiveRecord hooks. Use it like so:
134
-
135
- ```ruby
136
- class User < ApplicationRecord
137
- include Journaled::Changes
138
-
139
- journal_changes_to :email, :first_name, :last_name, as: :identity_change
140
- end
141
- ```
132
+ ### Attribution
142
133
 
143
- Add the following to your controller base class for attribution:
134
+ Before using `Journaled::Changes` or `Journaled::AuditLog`, you will want to
135
+ set up automatic "actor" attribution (i.e. tracking the current user session).
136
+ To enable this feature, add the following to your controller base class for
137
+ attribution:
144
138
 
145
139
  ```ruby
146
140
  class ApplicationController < ActionController::Base
@@ -153,6 +147,20 @@ end
153
147
  Your authenticated entity must respond to `#to_global_id`, which ActiveRecords do by default.
154
148
  This feature relies on `ActiveSupport::CurrentAttributes` under the hood.
155
149
 
150
+ ### Change Journaling with `Journaled::Changes`
151
+
152
+ Out of the box, `Journaled` provides an event type and ActiveRecord
153
+ mix-in for durably journaling changes to your model, implemented via
154
+ ActiveRecord hooks. Use it like so:
155
+
156
+ ```ruby
157
+ class User < ApplicationRecord
158
+ include Journaled::Changes
159
+
160
+ journal_changes_to :email, :first_name, :last_name, as: :identity_change
161
+ end
162
+ ```
163
+
156
164
  Every time any of the specified attributes is modified, or a `User`
157
165
  record is created or destroyed, an event will be sent to Kinesis with the following attributes:
158
166
 
@@ -179,6 +187,222 @@ journaling. Note that the less-frequently-used methods `toggle`,
179
187
  `increment*`, `decrement*`, and `update_counters` are not intercepted at
180
188
  this time.
181
189
 
190
+
191
+ ### Audit Logging with `Journaled::AuditLog`
192
+
193
+ Journaled includes a feature for producing audit logs of changes to your model.
194
+ Unlike `Journaled::Changes`, which will emit individual sets of changes as
195
+ "logical" events, `Journaled::AuditLog` will log all changes in their entirety,
196
+ unless otherwise told to ignore changes to specific columns.
197
+
198
+ This behavior is similar to
199
+ [papertrail](https://github.com/paper-trail-gem/paper_trail),
200
+ [audited](https://github.com/collectiveidea/audited), and
201
+ [logidze](https://github.com/palkan/logidze), except instead of storing
202
+ changes/versions locally (in your application's database), it emits them to
203
+ Kinesis (as Journaled events).
204
+
205
+ #### Audit Log Configuration
206
+
207
+ To enable audit logging for a given record, use the `has_audit_log` directive:
208
+
209
+ ```ruby
210
+ class MyModel < ApplicationRecord
211
+ has_audit_log
212
+
213
+ # This class will now be audited,
214
+ # but will ignore changes to `created_at` and `updated_at`.
215
+ end
216
+ ```
217
+
218
+ To ignore changes to additional columns, use the `ignore` option:
219
+
220
+ ```ruby
221
+ class MyModel < ApplicationRecord
222
+ has_audit_log ignore: :last_synced_at
223
+
224
+ # This class will be audited,
225
+ # and will ignore changes to `created_at`, `updated_at`, and `last_synced_at`.
226
+ end
227
+ ```
228
+
229
+ By default, changes to `updated_at` and `created_at` will be ignored (since
230
+ these generally change on every update), but this behavior can be reconfigured:
231
+
232
+ ```ruby
233
+ # change the defaults:
234
+ Journaled::AuditLog.default_ignored_columns = %i(createdAt updatedAt)
235
+
236
+ # or append new defaults:
237
+ Journaled::AuditLog.default_ignored_columns += %i(modified_at)
238
+
239
+ # or disable defaults entirely:
240
+ Journaled::AuditLog.default_ignored_columns = []
241
+ ```
242
+
243
+ Subclasses will inherit audit log configs:
244
+
245
+ ```ruby
246
+ class MyModel < ApplicationRecord
247
+ has_audit_log ignore: :last_synced_at
248
+ end
249
+
250
+ class MySubclass < MyModel
251
+ # this class will be audited,
252
+ # and will ignore `created_at`, `updated_at`, and `last_synced_at`.
253
+ end
254
+ ```
255
+
256
+ To disable audit logs on subclasses, use `skip_audit_log`:
257
+
258
+ ```ruby
259
+ class MySubclass < MyModel
260
+ skip_audit_log
261
+ end
262
+ ```
263
+
264
+ Subclasses may specify additional columns to ignore (which will be merged into
265
+ the inherited list):
266
+
267
+ ```ruby
268
+ class MySubclass < MyModel
269
+ has_audit_log ignore: :another_field
270
+
271
+ # this class will ignore `another_field`, IN ADDITION TO `created_at`, `updated_at`,
272
+ # and any other fields specified by the parent class.
273
+ end
274
+ ```
275
+
276
+ To temporarily disable audit logging globally, use the `without_audit_logging` directive:
277
+
278
+ ```ruby
279
+ Journaled::AuditLog.without_audit_logging do
280
+ # Any operation in here will skip audit logging
281
+ end
282
+ ```
283
+
284
+ #### Audit Log Events
285
+
286
+ Whenever an audited record is created, updated, or destroyed, a
287
+ `journaled_audit_log` event is emitted. For example, calling
288
+ `user.update!(name: 'Bart')` would result in an event that looks something like
289
+ this:
290
+
291
+ ```json
292
+ {
293
+ "id": "bc7cb6a6-88cf-4849-a4f0-a31b0b199c47",
294
+ "event_type": "journaled_audit_log",
295
+ "created_at": "2022-01-28T11:06:54.928-05:00",
296
+ "class_name": "User",
297
+ "table_name": "users",
298
+ "record_id": "123",
299
+ "database_operation": "update",
300
+ "changes": { "name": ["Homer", "Bart"] },
301
+ "snapshot": null,
302
+ "actor": "gid://app_name/AdminUser/456",
303
+ "tags": {}
304
+ }
305
+ ```
306
+
307
+ The field breakdown is as follows:
308
+
309
+ - `id`: a randomly-generated ID for the event itself
310
+ - `event_type`: the type of event (always `journaled_audit_log`)
311
+ - `created_at`: the time that the action occurred (should match `updated_at` on
312
+ the ActiveRecord)
313
+ - `class_name`: the name of the ActiveRecord class
314
+ - `table_name`: the underlying table that the class interfaces with
315
+ - `record_id`: the primary key of the ActiveRecord
316
+ - `database_operation`: the type of operation (`insert`, `update`, or `delete`)
317
+ - `changes`: the changes to the record, in the form of `"field_name":
318
+ ["from_value", "to_value"]`
319
+ - `snapshot`: an (optional) snapshot of all of the record's columns and their
320
+ values (see below).
321
+ - `actor`: the current `Journaled.actor`
322
+ - `tags`: the current `Journaled.tags`
323
+
324
+ #### Snapshots
325
+
326
+ When records are created, updated, and deleted, the `changes` field is populated
327
+ with only the columns that changed. While this keeps event payload size down, it
328
+ may make it harder to reconstruct the state of the record at a given point in
329
+ time.
330
+
331
+ This is where the `snapshot` field comes in! To produce a full snapshot of a
332
+ record as part of an update, set use the virtual `_log_snapshot` attribute, like
333
+ so:
334
+
335
+ ```ruby
336
+ my_user.update!(name: 'Bart', _log_snapshot: true)
337
+ ```
338
+
339
+ Or to produce snapshots for all records that change for a given operation,
340
+ wrap it a `with_snapshots` block, like so:
341
+
342
+ ```ruby
343
+ Journaled::AuditLog.with_snapshots do
344
+ ComplicatedOperation.run!
345
+ end
346
+ ```
347
+
348
+ Snapshots can also be enabled globally for all _deletion_ operations. Since
349
+ `changes` will be empty on deletion, you should consider using this if you care
350
+ about the contents of any records being deleted (and/or don't have a full audit
351
+ trail from their time of creation):
352
+
353
+ ```ruby
354
+ Journaled::AuditLog.snapshot_on_deletion = true
355
+ ```
356
+
357
+ Events with snapshots will continue to populate the `changes` field, but will
358
+ additionally contain a snapshot with the full state of the user:
359
+
360
+ ```json
361
+ {
362
+ "...": "...",
363
+ "changes": { "name": ["Homer", "Bart"] },
364
+ "snapshot": { "name": "Bart", "email": "simpson@example.com", "favorite_food": "pizza" },
365
+ "...": "..."
366
+ }
367
+ ```
368
+
369
+ #### Handling Sensitive Data
370
+
371
+ Both `changes` and `snapshot` will filter out sensitive fields, as defined by
372
+ your `Rails.application.config.filter_parameters` list:
373
+
374
+ ```json
375
+ {
376
+ "...": "...",
377
+ "changes": { "ssn": ["[FILTERED]", "[FILTERED]"] },
378
+ "snapshot": { "ssn": "[FILTERED]" },
379
+ "...": "..."
380
+ }
381
+ ```
382
+
383
+ They will also filter out any fields whose name ends in `_crypt` or `_hmac`, as
384
+ well as fields that rely on Active Record Encryption / `encrypts` ([introduced
385
+ in Rails 7](https://edgeguides.rubyonrails.org/active_record_encryption.html)).
386
+
387
+ This is done to avoid emitting values to locations where it is difficult or
388
+ impossible to rotate encryption keys (or otherwise scrub values after the
389
+ fact), and currently there is no built-in configuration to bypass this
390
+ behavior. If you need to track changes to sensitive/encrypted fields, it is
391
+ recommended that you store the values in a local history table (still
392
+ encrypted, of course!).
393
+
394
+ #### Caveats
395
+
396
+ Because Journaled events are not guaranteed to arrive in order, events emitted
397
+ by `Journaled::AuditLog` must be sorted by their `created_at` value, which
398
+ should correspond roughly to the time that the SQL statement was issued.
399
+ **There is currently no other means of globally ordering audit log events**,
400
+ making them susceptible to clock drift and race conditions.
401
+
402
+ These issues may be mitigated on a per-model basis via
403
+ `ActiveRecord::Locking::Optimistic` (and its auto-incrementing `lock_version`
404
+ column), and/or by careful use of other locking mechanisms.
405
+
182
406
  ### Custom Journaling
183
407
 
184
408
  For every custom implementation of journaling in your application, define the JSON schema for the attributes in your event.
@@ -338,7 +562,7 @@ Returns one of the following in order of preference:
338
562
  * a string of the form `gid://[app_name]` as a fallback
339
563
 
340
564
  In order for this to be most useful, you must configure your controller
341
- as described in [Change Journaling](#change-journaling) above.
565
+ as described in [Attribution](#attribution) above.
342
566
 
343
567
  ### Testing
344
568
 
@@ -3,7 +3,7 @@
3
3
  # make sense to move it to lib/.
4
4
  module Journaled
5
5
  module AuditLog
6
- Event = Struct.new(:record, :database_operation, :unfiltered_changes) do
6
+ Event = Struct.new(:record, :database_operation, :unfiltered_changes, :enqueue_opts) do
7
7
  include Journaled::Event
8
8
 
9
9
  journal_attributes :class_name, :table_name, :record_id,
@@ -13,6 +13,10 @@ module Journaled
13
13
  AuditLog.default_stream_name || super
14
14
  end
15
15
 
16
+ def journaled_enqueue_opts
17
+ record.class.audit_log_config.enqueue_opts
18
+ end
19
+
16
20
  def created_at
17
21
  case database_operation
18
22
  when 'insert'
@@ -54,7 +58,8 @@ module Journaled
54
58
  end
55
59
 
56
60
  def snapshot
57
- filtered_attributes if record._log_snapshot || AuditLog.snapshots_enabled
61
+ filtered_attributes if record._log_snapshot || AuditLog.snapshots_enabled ||
62
+ (database_operation == 'delete' && AuditLog.snapshot_on_deletion)
58
63
  end
59
64
 
60
65
  def actor
@@ -15,8 +15,10 @@ module Journaled
15
15
 
16
16
  mattr_accessor(:default_ignored_columns) { %i(created_at updated_at) }
17
17
  mattr_accessor(:default_stream_name) { Journaled.default_stream_name }
18
+ mattr_accessor(:default_enqueue_opts) { {} }
18
19
  mattr_accessor(:excluded_classes) { DEFAULT_EXCLUDED_CLASSES.dup }
19
20
  thread_mattr_accessor(:snapshots_enabled) { false }
21
+ thread_mattr_accessor(:snapshot_on_deletion) { false }
20
22
  thread_mattr_accessor(:_disabled) { false }
21
23
  thread_mattr_accessor(:_force) { false }
22
24
 
@@ -64,18 +66,37 @@ module Journaled
64
66
  end
65
67
  end
66
68
 
67
- Config = Struct.new(:enabled, :ignored_columns) do
68
- private :enabled
69
+ Config = Struct.new(:enabled, :ignored_columns, :enqueue_opts) do
70
+ def self.default
71
+ new(false, AuditLog.default_ignored_columns.dup, AuditLog.default_enqueue_opts.dup)
72
+ end
73
+
74
+ def initialize(*)
75
+ super
76
+ self.ignored_columns ||= []
77
+ self.enqueue_opts ||= {}
78
+ end
79
+
69
80
  def enabled?
70
81
  !AuditLog._disabled && self[:enabled].present?
71
82
  end
83
+
84
+ def dup
85
+ super.tap do |config|
86
+ config.ignored_columns = ignored_columns.dup
87
+ config.enqueue_opts = enqueue_opts.dup
88
+ end
89
+ end
90
+
91
+ private :enabled
72
92
  end
73
93
 
74
94
  included do
75
95
  prepend BlockedMethods
76
96
  singleton_class.prepend BlockedClassMethods
77
97
 
78
- class_attribute :audit_log_config, default: Config.new(false, AuditLog.default_ignored_columns)
98
+ class_attribute :audit_log_config, default: Config.default
99
+
79
100
  attr_accessor :_log_snapshot
80
101
 
81
102
  after_create { _emit_audit_log!('insert') }
@@ -84,19 +105,16 @@ module Journaled
84
105
  end
85
106
 
86
107
  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)
108
+ def has_audit_log(ignore: [], enqueue_with: {})
109
+ self.audit_log_config = audit_log_config.dup
110
+ audit_log_config.enabled = true
111
+ audit_log_config.ignored_columns |= [ignore].flatten(1)
112
+ audit_log_config.enqueue_opts.merge!(enqueue_with)
90
113
  end
91
114
 
92
115
  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
116
+ self.audit_log_config = audit_log_config.dup
117
+ audit_log_config.enabled = false
100
118
  end
101
119
  end
102
120
 
@@ -177,7 +195,7 @@ module Journaled
177
195
 
178
196
  def _emit_audit_log!(database_operation)
179
197
  if audit_log_config.enabled?
180
- event = Journaled::AuditLog::Event.new(self, database_operation, _audit_log_changes)
198
+ event = Journaled::AuditLog::Event.new(self, database_operation, _audit_log_changes, audit_log_config.enqueue_opts)
181
199
  ActiveSupport::Notifications.instrument('journaled.audit_log.journal', event: event) do
182
200
  event.journal!
183
201
  end
@@ -1,3 +1,3 @@
1
1
  module Journaled
2
- VERSION = "5.1.0".freeze
2
+ VERSION = "5.2.0".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: journaled
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.1.0
4
+ version: 5.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jake Lipson
@@ -11,7 +11,7 @@ authors:
11
11
  autorequire:
12
12
  bindir: bin
13
13
  cert_chain: []
14
- date: 2022-09-09 00:00:00.000000000 Z
14
+ date: 2022-10-13 00:00:00.000000000 Z
15
15
  dependencies:
16
16
  - !ruby/object:Gem::Dependency
17
17
  name: activejob