journaled 5.1.0 → 5.1.1

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: 4d27e8931e64963620ecd1fd44c4a46c244470a6715dc19c56a7a1a29aaa97b6
4
+ data.tar.gz: f95df32775abd3f65cb8d526c60fae948fc69425957a378e4007765fdade14c0
5
5
  SHA512:
6
- metadata.gz: a6b4789d6314447dad04cc152b76cdf861f21d92cf27af5030f2ce500a8f2c6b791f7adfe86b975e614cba3f3af5b811022e6895feb32a831893851fabc5426d
7
- data.tar.gz: 735a2305bb0599d1b6db9b355e39c1523079df60cbd975627eb5b273c155228d3740a3215341983ad15a90ff53dd6772f81a154829d13237ea8dde0eed92d34b
6
+ metadata.gz: 1cd4a144b2cc6dbb398b082dfe97b93829ab5d0e2ad7c22fb807ff63be9d9f5e1b24278db14eb947ce247c92f8fd09e135010ce9bc546e6e34589f98fdccaeb9
7
+ data.tar.gz: 10da7fde07cfe2e7d654978a99c0734976f7199efd1d1d5e6aa11684d30d537d625acf238e0f636f8dec75b298920a8bf3f64a8c41105ddab40f624b03f1e8d6
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,213 @@ 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
+ Events with snapshots will continue to populate the `changes` field, but will
349
+ additionally contain a snapshot with the full state of the user:
350
+
351
+ ```json
352
+ {
353
+ "...": "...",
354
+ "changes": { "name": ["Homer", "Bart"] },
355
+ "snapshot": { "name": "Bart", "email": "simpson@example.com", "favorite_food": "pizza" },
356
+ "...": "..."
357
+ }
358
+ ```
359
+
360
+ #### Handling Sensitive Data
361
+
362
+ Both `changes` and `snapshot` will filter out sensitive fields, as defined by
363
+ your `Rails.application.config.filter_parameters` list:
364
+
365
+ ```json
366
+ {
367
+ "...": "...",
368
+ "changes": { "ssn": ["[FILTERED]", "[FILTERED]"] },
369
+ "snapshot": { "ssn": "[FILTERED]" },
370
+ "...": "..."
371
+ }
372
+ ```
373
+
374
+ They will also filter out any fields whose name ends in `_crypt` or `_hmac`, as
375
+ well as fields that rely on Active Record Encryption / `encrypts` ([introduced
376
+ in Rails 7](https://edgeguides.rubyonrails.org/active_record_encryption.html)).
377
+
378
+ This is done to avoid emitting values to locations where it is difficult or
379
+ impossible to rotate encryption keys (or otherwise scrub values after the
380
+ fact), and currently there is no built-in configuration to bypass this
381
+ behavior. If you need to track changes to sensitive/encrypted fields, it is
382
+ recommended that you store the values in a local history table (still
383
+ encrypted, of course!).
384
+
385
+ #### Caveats
386
+
387
+ Because Journaled events are not guaranteed to arrive in order, events emitted
388
+ by `Journaled::AuditLog` must be sorted by their `created_at` value, which
389
+ should correspond roughly to the time that the SQL statement was issued.
390
+ **There is currently no other means of globally ordering audit log events**,
391
+ making them susceptible to clock drift and race conditions.
392
+
393
+ These issues may be mitigated on a per-model basis via
394
+ `ActiveRecord::Locking::Optimistic` (and its auto-incrementing `lock_version`
395
+ column), and/or by careful use of other locking mechanisms.
396
+
182
397
  ### Custom Journaling
183
398
 
184
399
  For every custom implementation of journaling in your application, define the JSON schema for the attributes in your event.
@@ -338,7 +553,7 @@ Returns one of the following in order of preference:
338
553
  * a string of the form `gid://[app_name]` as a fallback
339
554
 
340
555
  In order for this to be most useful, you must configure your controller
341
- as described in [Change Journaling](#change-journaling) above.
556
+ as described in [Attribution](#attribution) above.
342
557
 
343
558
  ### Testing
344
559
 
@@ -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'
@@ -15,6 +15,7 @@ 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 }
20
21
  thread_mattr_accessor(:_disabled) { false }
@@ -64,18 +65,37 @@ module Journaled
64
65
  end
65
66
  end
66
67
 
67
- Config = Struct.new(:enabled, :ignored_columns) do
68
- private :enabled
68
+ Config = Struct.new(:enabled, :ignored_columns, :enqueue_opts) do
69
+ def self.default
70
+ new(false, AuditLog.default_ignored_columns.dup, AuditLog.default_enqueue_opts.dup)
71
+ end
72
+
73
+ def initialize(*)
74
+ super
75
+ self.ignored_columns ||= []
76
+ self.enqueue_opts ||= {}
77
+ end
78
+
69
79
  def enabled?
70
80
  !AuditLog._disabled && self[:enabled].present?
71
81
  end
82
+
83
+ def dup
84
+ super.tap do |config|
85
+ config.ignored_columns = ignored_columns.dup
86
+ config.enqueue_opts = enqueue_opts.dup
87
+ end
88
+ end
89
+
90
+ private :enabled
72
91
  end
73
92
 
74
93
  included do
75
94
  prepend BlockedMethods
76
95
  singleton_class.prepend BlockedClassMethods
77
96
 
78
- class_attribute :audit_log_config, default: Config.new(false, AuditLog.default_ignored_columns)
97
+ class_attribute :audit_log_config, default: Config.default
98
+
79
99
  attr_accessor :_log_snapshot
80
100
 
81
101
  after_create { _emit_audit_log!('insert') }
@@ -84,19 +104,16 @@ module Journaled
84
104
  end
85
105
 
86
106
  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)
107
+ def has_audit_log(ignore: [], enqueue_with: {})
108
+ self.audit_log_config = audit_log_config.dup
109
+ audit_log_config.enabled = true
110
+ audit_log_config.ignored_columns |= [ignore].flatten(1)
111
+ audit_log_config.enqueue_opts.merge!(enqueue_with)
90
112
  end
91
113
 
92
114
  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
115
+ self.audit_log_config = audit_log_config.dup
116
+ audit_log_config.enabled = false
100
117
  end
101
118
  end
102
119
 
@@ -177,7 +194,7 @@ module Journaled
177
194
 
178
195
  def _emit_audit_log!(database_operation)
179
196
  if audit_log_config.enabled?
180
- event = Journaled::AuditLog::Event.new(self, database_operation, _audit_log_changes)
197
+ event = Journaled::AuditLog::Event.new(self, database_operation, _audit_log_changes, audit_log_config.enqueue_opts)
181
198
  ActiveSupport::Notifications.instrument('journaled.audit_log.journal', event: event) do
182
199
  event.journal!
183
200
  end
@@ -1,3 +1,3 @@
1
1
  module Journaled
2
- VERSION = "5.1.0".freeze
2
+ VERSION = "5.1.1".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.1.1
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-09-16 00:00:00.000000000 Z
15
15
  dependencies:
16
16
  - !ruby/object:Gem::Dependency
17
17
  name: activejob