journaled 5.0.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 +4 -4
- data/README.md +230 -15
- data/app/models/concerns/journaled/changes.rb +1 -1
- data/app/models/journaled/audit_log/event.rb +91 -0
- data/journaled_schemas/journaled/audit_log/event.json +31 -0
- data/lib/journaled/audit_log.rb +211 -0
- data/lib/journaled/version.rb +1 -1
- data/lib/journaled.rb +2 -0
- metadata +19 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4d27e8931e64963620ecd1fd44c4a46c244470a6715dc19c56a7a1a29aaa97b6
|
4
|
+
data.tar.gz: f95df32775abd3f65cb8d526c60fae948fc69425957a378e4007765fdade14c0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
###
|
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,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 [
|
556
|
+
as described in [Attribution](#attribution) above.
|
342
557
|
|
343
558
|
### Testing
|
344
559
|
|
@@ -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,91 @@
|
|
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, :enqueue_opts) 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 journaled_enqueue_opts
|
17
|
+
record.class.audit_log_config.enqueue_opts
|
18
|
+
end
|
19
|
+
|
20
|
+
def created_at
|
21
|
+
case database_operation
|
22
|
+
when 'insert'
|
23
|
+
record_created_at
|
24
|
+
when 'update'
|
25
|
+
record_updated_at
|
26
|
+
when 'delete'
|
27
|
+
Time.zone.now
|
28
|
+
else
|
29
|
+
raise "Unhandled database operation type: #{database_operation}"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def record_created_at
|
34
|
+
record.try(:created_at) || Time.zone.now
|
35
|
+
end
|
36
|
+
|
37
|
+
def record_updated_at
|
38
|
+
record.try(:updated_at) || Time.zone.now
|
39
|
+
end
|
40
|
+
|
41
|
+
def class_name
|
42
|
+
record.class.name
|
43
|
+
end
|
44
|
+
|
45
|
+
def table_name
|
46
|
+
record.class.table_name
|
47
|
+
end
|
48
|
+
|
49
|
+
def record_id
|
50
|
+
record.id
|
51
|
+
end
|
52
|
+
|
53
|
+
def changes
|
54
|
+
filtered_changes = unfiltered_changes.deep_dup.deep_symbolize_keys
|
55
|
+
filtered_changes.each do |key, value|
|
56
|
+
filtered_changes[key] = value.map { |val| '[FILTERED]' if val } if filter_key?(key)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def snapshot
|
61
|
+
filtered_attributes if record._log_snapshot || AuditLog.snapshots_enabled
|
62
|
+
end
|
63
|
+
|
64
|
+
def actor
|
65
|
+
Journaled.actor_uri
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def filter_key?(key)
|
71
|
+
filter_params.include?(key) || encrypted_column?(key)
|
72
|
+
end
|
73
|
+
|
74
|
+
def encrypted_column?(key)
|
75
|
+
key.to_s.end_with?('_crypt', '_hmac') ||
|
76
|
+
(Rails::VERSION::MAJOR >= 7 && record.encrypted_attribute?(key))
|
77
|
+
end
|
78
|
+
|
79
|
+
def filter_params
|
80
|
+
Rails.application.config.filter_parameters
|
81
|
+
end
|
82
|
+
|
83
|
+
def filtered_attributes
|
84
|
+
attrs = record.attributes.dup.symbolize_keys
|
85
|
+
attrs.each do |key, _value|
|
86
|
+
attrs[key] = '[FILTERED]' if filter_key?(key)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
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,211 @@
|
|
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(:default_enqueue_opts) { {} }
|
19
|
+
mattr_accessor(:excluded_classes) { DEFAULT_EXCLUDED_CLASSES.dup }
|
20
|
+
thread_mattr_accessor(:snapshots_enabled) { false }
|
21
|
+
thread_mattr_accessor(:_disabled) { false }
|
22
|
+
thread_mattr_accessor(:_force) { false }
|
23
|
+
|
24
|
+
class << self
|
25
|
+
def exclude_classes!
|
26
|
+
excluded_classes.each do |name|
|
27
|
+
if Rails::VERSION::MAJOR >= 6 && Rails.autoloaders.zeitwerk_enabled?
|
28
|
+
zeitwerk_exclude!(name)
|
29
|
+
else
|
30
|
+
classic_exclude!(name)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def with_snapshots
|
36
|
+
snapshots_enabled_was = snapshots_enabled
|
37
|
+
self.snapshots_enabled = true
|
38
|
+
yield
|
39
|
+
ensure
|
40
|
+
self.snapshots_enabled = snapshots_enabled_was
|
41
|
+
end
|
42
|
+
|
43
|
+
def without_audit_logging
|
44
|
+
disabled_was = _disabled
|
45
|
+
self._disabled = true
|
46
|
+
yield
|
47
|
+
ensure
|
48
|
+
self._disabled = disabled_was
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def zeitwerk_exclude!(name)
|
54
|
+
if Object.const_defined?(name)
|
55
|
+
name.constantize.skip_audit_log
|
56
|
+
else
|
57
|
+
Rails.autoloaders.main.on_load(name) { |klass, _path| klass.skip_audit_log }
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def classic_exclude!(name)
|
62
|
+
name.constantize.skip_audit_log
|
63
|
+
rescue NameError
|
64
|
+
nil
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
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
|
+
|
79
|
+
def enabled?
|
80
|
+
!AuditLog._disabled && self[:enabled].present?
|
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
|
91
|
+
end
|
92
|
+
|
93
|
+
included do
|
94
|
+
prepend BlockedMethods
|
95
|
+
singleton_class.prepend BlockedClassMethods
|
96
|
+
|
97
|
+
class_attribute :audit_log_config, default: Config.default
|
98
|
+
|
99
|
+
attr_accessor :_log_snapshot
|
100
|
+
|
101
|
+
after_create { _emit_audit_log!('insert') }
|
102
|
+
after_update { _emit_audit_log!('update') if _audit_log_changes.any? }
|
103
|
+
after_destroy { _emit_audit_log!('delete') }
|
104
|
+
end
|
105
|
+
|
106
|
+
class_methods do
|
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)
|
112
|
+
end
|
113
|
+
|
114
|
+
def skip_audit_log
|
115
|
+
self.audit_log_config = audit_log_config.dup
|
116
|
+
audit_log_config.enabled = false
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
module BlockedMethods
|
121
|
+
BLOCKED_METHODS = {
|
122
|
+
delete: '#destroy',
|
123
|
+
update_column: '#update!',
|
124
|
+
update_columns: '#update!',
|
125
|
+
}.freeze
|
126
|
+
|
127
|
+
def delete(**kwargs)
|
128
|
+
_journaled_audit_log_check!(:delete, **kwargs) do
|
129
|
+
super()
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def update_column(name, value, **kwargs)
|
134
|
+
_journaled_audit_log_check!(:update_column, **kwargs.merge(name => value)) do
|
135
|
+
super(name, value)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def update_columns(args = {}, **kwargs)
|
140
|
+
_journaled_audit_log_check!(:update_columns, **args.merge(kwargs)) do
|
141
|
+
super(args.merge(kwargs).except(:_force))
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def _journaled_audit_log_check!(method, **kwargs) # rubocop:disable Metrics/AbcSize
|
146
|
+
force_was = AuditLog._force
|
147
|
+
AuditLog._force = kwargs.delete(:_force) if kwargs.key?(:_force)
|
148
|
+
audited_columns = kwargs.keys - audit_log_config.ignored_columns
|
149
|
+
|
150
|
+
if method == :delete || audited_columns.any?
|
151
|
+
column_message = <<~MSG if kwargs.any?
|
152
|
+
You are attempting to change the following audited columns:
|
153
|
+
#{audited_columns.inspect}
|
154
|
+
|
155
|
+
MSG
|
156
|
+
raise <<~MSG if audit_log_config.enabled? && !AuditLog._force
|
157
|
+
#{column_message}Using `#{method}` is blocked because it skips audit logging (and other Rails callbacks)!
|
158
|
+
Consider using `#{BLOCKED_METHODS[method]}` instead, or pass `_force: true` as an argument.
|
159
|
+
MSG
|
160
|
+
end
|
161
|
+
|
162
|
+
yield
|
163
|
+
ensure
|
164
|
+
AuditLog._force = force_was
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
module BlockedClassMethods
|
169
|
+
BLOCKED_METHODS = {
|
170
|
+
delete_all: '.destroy_all',
|
171
|
+
insert: '.create!',
|
172
|
+
insert_all: '.each { create!(...) }',
|
173
|
+
update_all: '.find_each { update!(...) }',
|
174
|
+
upsert: '.create_or_find_by!',
|
175
|
+
upsert_all: '.each { create_or_find_by!(...) }',
|
176
|
+
}.freeze
|
177
|
+
|
178
|
+
BLOCKED_METHODS.each do |method, alternative|
|
179
|
+
define_method(method) do |*args, **kwargs, &block|
|
180
|
+
force_was = AuditLog._force
|
181
|
+
AuditLog._force = kwargs.delete(:_force) if kwargs.key?(:_force)
|
182
|
+
|
183
|
+
raise <<~MSG if audit_log_config.enabled? && !AuditLog._force
|
184
|
+
`#{method}` is blocked because it skips callbacks and audit logs!
|
185
|
+
Consider using `#{alternative}` instead, or pass `_force: true` as an argument.
|
186
|
+
MSG
|
187
|
+
|
188
|
+
super(*args, **kwargs, &block)
|
189
|
+
ensure
|
190
|
+
AuditLog._force = force_was
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
def _emit_audit_log!(database_operation)
|
196
|
+
if audit_log_config.enabled?
|
197
|
+
event = Journaled::AuditLog::Event.new(self, database_operation, _audit_log_changes, audit_log_config.enqueue_opts)
|
198
|
+
ActiveSupport::Notifications.instrument('journaled.audit_log.journal', event: event) do
|
199
|
+
event.journal!
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
def _audit_log_changes
|
205
|
+
previous_changes.except(*audit_log_config.ignored_columns)
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
ActiveSupport.on_load(:active_record) { include Journaled::AuditLog }
|
211
|
+
Journaled::Engine.config.after_initialize { Journaled::AuditLog.exclude_classes! }
|
data/lib/journaled/version.rb
CHANGED
data/lib/journaled.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.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-
|
14
|
+
date: 2022-09-16 00:00:00.000000000 Z
|
15
15
|
dependencies:
|
16
16
|
- !ruby/object:Gem::Dependency
|
17
17
|
name: activejob
|
@@ -41,6 +41,20 @@ dependencies:
|
|
41
41
|
- - ">="
|
42
42
|
- !ruby/object:Gem::Version
|
43
43
|
version: '0'
|
44
|
+
- !ruby/object:Gem::Dependency
|
45
|
+
name: activesupport
|
46
|
+
requirement: !ruby/object:Gem::Requirement
|
47
|
+
requirements:
|
48
|
+
- - ">="
|
49
|
+
- !ruby/object:Gem::Version
|
50
|
+
version: '0'
|
51
|
+
type: :runtime
|
52
|
+
prerelease: false
|
53
|
+
version_requirements: !ruby/object:Gem::Requirement
|
54
|
+
requirements:
|
55
|
+
- - ">="
|
56
|
+
- !ruby/object:Gem::Version
|
57
|
+
version: '0'
|
44
58
|
- !ruby/object:Gem::Dependency
|
45
59
|
name: aws-sdk-kinesis
|
46
60
|
requirement: !ruby/object:Gem::Requirement
|
@@ -242,6 +256,7 @@ files:
|
|
242
256
|
- app/jobs/journaled/delivery_job.rb
|
243
257
|
- app/models/concerns/journaled/changes.rb
|
244
258
|
- app/models/journaled/actor_uri_provider.rb
|
259
|
+
- app/models/journaled/audit_log/event.rb
|
245
260
|
- app/models/journaled/change.rb
|
246
261
|
- app/models/journaled/change_definition.rb
|
247
262
|
- app/models/journaled/change_writer.rb
|
@@ -252,9 +267,11 @@ files:
|
|
252
267
|
- config/initializers/change_protection.rb
|
253
268
|
- config/spring.rb
|
254
269
|
- journaled_schemas/base_event.json
|
270
|
+
- journaled_schemas/journaled/audit_log/event.json
|
255
271
|
- journaled_schemas/journaled/change.json
|
256
272
|
- journaled_schemas/tagged_event.json
|
257
273
|
- lib/journaled.rb
|
274
|
+
- lib/journaled/audit_log.rb
|
258
275
|
- lib/journaled/connection.rb
|
259
276
|
- lib/journaled/current.rb
|
260
277
|
- lib/journaled/engine.rb
|