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 +4 -4
- data/README.md +239 -15
- data/app/models/journaled/audit_log/event.rb +7 -2
- data/lib/journaled/audit_log.rb +32 -14
- data/lib/journaled/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7fc4b493edc90534706cd22dbb1fae76cdacc693d95e44734f9ce92db2d0716c
|
4
|
+
data.tar.gz: 46edc5873055e2a4e7ceae9f8dc63c558b331a821826803cd36aa971b42258f5
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
###
|
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
|
-
|
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 [
|
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
|
data/lib/journaled/audit_log.rb
CHANGED
@@ -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
|
-
|
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.
|
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
|
-
|
89
|
-
|
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 =
|
94
|
-
|
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
|
data/lib/journaled/version.rb
CHANGED
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.
|
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-
|
14
|
+
date: 2022-10-13 00:00:00.000000000 Z
|
15
15
|
dependencies:
|
16
16
|
- !ruby/object:Gem::Dependency
|
17
17
|
name: activejob
|