rails_audit_log 1.0.0 → 1.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 +110 -4
- data/app/concerns/rails_audit_log/auditable.rb +43 -13
- data/app/jobs/rails_audit_log/prune_audit_log_job.rb +58 -0
- data/app/jobs/rails_audit_log/write_audit_log_job.rb +11 -10
- data/app/models/rails_audit_log/audit_log_entry.rb +25 -3
- data/lib/generators/rails_audit_log/encryption/encryption_generator.rb +41 -0
- data/lib/generators/rails_audit_log/encryption/templates/encrypt_rails_audit_log_entries.rb +85 -0
- data/lib/generators/rails_audit_log/encryption/templates/rails_audit_log_encryption.rb +41 -0
- data/lib/generators/rails_audit_log/initializer/templates/rails_audit_log.rb +12 -0
- data/lib/rails_audit_log/version.rb +1 -1
- data/lib/rails_audit_log.rb +18 -0
- data/lib/tasks/rails_audit_log_tasks.rake +7 -4
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 40a23f8ea1990a26ea7f575102fb5ddd5f1a7323c796315672de57f0154faec3
|
|
4
|
+
data.tar.gz: 8b0563eebeeb21fe9836f3ff091640ae4a1c5484b45dc515abfc4647510cfae9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 29cc8682459e2d7968555a49faa77e47c00da42a54bf633f286dd01e89979be8fff6790aa0880de5d7516d23f1fc16f4a88c10b1e4aa086921c6052fdb812784
|
|
7
|
+
data.tar.gz: f1a1ba92cfe1c9a1ff0640b9b8a78f94275195fb018da97d89557b9612d631c31293b228e0715d059b790a3f0d2888069066f9897f376e43a40467c8c0c6dbd2
|
data/README.md
CHANGED
|
@@ -23,6 +23,9 @@ Audit logging for Rails. Tracks `create`, `update`, and `destroy` events as stru
|
|
|
23
23
|
- [Bulk audit writes](#bulk-audit-writes)
|
|
24
24
|
- [Async audit writes](#async-audit-writes)
|
|
25
25
|
- [Capping history per record](#capping-history-per-record)
|
|
26
|
+
- [Time-based retention](#time-based-retention)
|
|
27
|
+
- [Scheduled and manual pruning](#scheduled-and-manual-pruning)
|
|
28
|
+
- [Encrypting audit data](#encrypting-audit-data)
|
|
26
29
|
- [Selective tracking](#selective-tracking)
|
|
27
30
|
- [Disabling auditing](#disabling-auditing)
|
|
28
31
|
- [Object reconstruction](#object-reconstruction)
|
|
@@ -95,6 +98,11 @@ RailsAuditLog.configure do |config|
|
|
|
95
98
|
# Per-model `audit_log version_limit: N` takes precedence.
|
|
96
99
|
# config.version_limit = 100
|
|
97
100
|
|
|
101
|
+
# Global time-based TTL — entries older than this duration are pruned after
|
|
102
|
+
# each write. Composes with version_limit: an entry is removed when it
|
|
103
|
+
# exceeds either constraint. Default: nil (no TTL)
|
|
104
|
+
# config.retention_period = 90.days
|
|
105
|
+
|
|
98
106
|
# Write all audit entries asynchronously via WriteAuditLogJob.
|
|
99
107
|
# Default: false — per-model `audit_log async: true` also works.
|
|
100
108
|
# config.async = true
|
|
@@ -323,6 +331,102 @@ Set a global default in an initializer — per-model values take precedence:
|
|
|
323
331
|
RailsAuditLog.version_limit = 50
|
|
324
332
|
```
|
|
325
333
|
|
|
334
|
+
### Time-based retention
|
|
335
|
+
|
|
336
|
+
Automatically prune entries older than a configured duration by setting `retention_period` in an initializer:
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
# config/initializers/rails_audit_log.rb
|
|
340
|
+
RailsAuditLog.retention_period = 90.days
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Entries whose `created_at` is older than the period are deleted after each write. `retention_period` and `version_limit` compose — an entry is pruned when it exceeds **either** constraint.
|
|
344
|
+
|
|
345
|
+
Override the global default per model with `retain_for:`:
|
|
346
|
+
|
|
347
|
+
```ruby
|
|
348
|
+
class Post < ApplicationRecord
|
|
349
|
+
include RailsAuditLog::Auditable
|
|
350
|
+
audit_log retain_for: 30.days # takes precedence over RailsAuditLog.retention_period
|
|
351
|
+
end
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
### Scheduled and manual pruning
|
|
355
|
+
|
|
356
|
+
`RailsAuditLog::PruneAuditLogJob` prunes all audited models in one pass. It iterates over every `item_type` present in `audit_log_entries`, resolves the effective `retain_for` / `retention_period` and `version_limit` per model, and deletes entries that exceed either constraint.
|
|
357
|
+
|
|
358
|
+
Enqueue it on a recurring schedule via your job backend:
|
|
359
|
+
|
|
360
|
+
```ruby
|
|
361
|
+
# config/recurring.yml (Solid Queue)
|
|
362
|
+
prune_audit_log:
|
|
363
|
+
class: RailsAuditLog::PruneAuditLogJob
|
|
364
|
+
schedule: every day at midnight
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
Or run it once manually via the rake task:
|
|
368
|
+
|
|
369
|
+
```bash
|
|
370
|
+
bin/rails rails_audit_log:prune
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Encrypting audit data
|
|
374
|
+
|
|
375
|
+
Encrypt `object_changes` and `object` at write time using `ActiveRecord::Encryption` (Rails 7.1+). Pass `encrypt: true` to `audit_log` in the model:
|
|
376
|
+
|
|
377
|
+
```ruby
|
|
378
|
+
class Payment < ApplicationRecord
|
|
379
|
+
include RailsAuditLog::Auditable
|
|
380
|
+
audit_log encrypt: true
|
|
381
|
+
end
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
The host app must configure `ActiveRecord::Encryption` with primary, deterministic, and key-derivation-salt keys — typically in `config/initializers/rails_audit_log.rb` or `config/application.rb`:
|
|
385
|
+
|
|
386
|
+
```ruby
|
|
387
|
+
config.active_record.encryption.primary_key = Rails.application.credentials.ral_primary_key
|
|
388
|
+
config.active_record.encryption.deterministic_key = Rails.application.credentials.ral_deterministic_key
|
|
389
|
+
config.active_record.encryption.key_derivation_salt = Rails.application.credentials.ral_kdf_salt
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Enable encryption globally so every audited model encrypts by default:
|
|
393
|
+
|
|
394
|
+
```ruby
|
|
395
|
+
# config/initializers/rails_audit_log.rb
|
|
396
|
+
RailsAuditLog.encrypt = true
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Opt a specific model out when the global default is on:
|
|
400
|
+
|
|
401
|
+
```ruby
|
|
402
|
+
class PublicLog < ApplicationRecord
|
|
403
|
+
include RailsAuditLog::Auditable
|
|
404
|
+
audit_log encrypt: false # plain JSON even when RailsAuditLog.encrypt = true
|
|
405
|
+
end
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Decryption is transparent — `#diff`, `#reify`, `#changed_attributes`, and all other instance methods work without any changes.
|
|
409
|
+
|
|
410
|
+
> **Note:** The `touching` scope uses database-level JSON extraction (`json_extract` / `->>`) and will not match encrypted entries. All Ruby-side query methods work normally.
|
|
411
|
+
|
|
412
|
+
#### Setting up encryption keys
|
|
413
|
+
|
|
414
|
+
Run the Rails built-in task to generate keys and store them in `config/credentials.yml.enc`:
|
|
415
|
+
|
|
416
|
+
```bash
|
|
417
|
+
bin/rails db:encryption:init
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Then run the encryption generator to produce a wired-up initializer and a re-encryption migration for existing entries:
|
|
421
|
+
|
|
422
|
+
```bash
|
|
423
|
+
bin/rails generate rails_audit_log:encryption
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
The generator creates:
|
|
427
|
+
- `config/initializers/rails_audit_log_encryption.rb` — reads the generated keys from credentials and passes them to `ActiveRecord::Encryption`
|
|
428
|
+
- `db/migrate/TIMESTAMP_encrypt_rails_audit_log_entries.rb` — re-encrypts existing plain-text audit entries; edit `ENCRYPTED_MODELS` to list your model class names, then run `bin/rails db:migrate`
|
|
429
|
+
|
|
326
430
|
### Selective tracking
|
|
327
431
|
|
|
328
432
|
Track only specific attributes, or exclude noisy ones:
|
|
@@ -575,13 +679,13 @@ refute_audit_log_entry post, event: :destroy
|
|
|
575
679
|
| `.connects_to=` | Route `AuditLogEntry` to a separate database |
|
|
576
680
|
| `.page_size=` | Entries per page in the web dashboard |
|
|
577
681
|
| `.whodunnit_display=` | Proc for actor display name snapshot |
|
|
578
|
-
| `.retention_period=` |
|
|
682
|
+
| `.retention_period=` | Global time-based TTL for audit entries |
|
|
579
683
|
|
|
580
684
|
**Concerns**
|
|
581
685
|
|
|
582
686
|
| Class | Include in | Key methods |
|
|
583
687
|
|---|---|---|
|
|
584
|
-
| `RailsAuditLog::Auditable` | ActiveRecord models | `audit_log(only:, ignore:, meta:, associations:, version_limit:, async:)`, `skip_audit_log { }` |
|
|
688
|
+
| `RailsAuditLog::Auditable` | ActiveRecord models | `audit_log(only:, ignore:, meta:, associations:, version_limit:, retain_for:, async:)`, `skip_audit_log { }` |
|
|
585
689
|
| `RailsAuditLog::Controller` | ActionController | `audit_log_actor { }` |
|
|
586
690
|
|
|
587
691
|
**Model — `RailsAuditLog::AuditLogEntry`**
|
|
@@ -600,10 +704,12 @@ Constants: `EVENTS`, `BLOB_COLUMNS`, `PERIODS`
|
|
|
600
704
|
| `RailsAuditLog::MinitestAssertions` | `require "rails_audit_log/minitest_assertions"` | Minitest: `assert_audit_log_entry`, `refute_audit_log_entry` |
|
|
601
705
|
| `RailsAuditLog::TestHelpers` | `require "rails_audit_log/test_helpers"` | `without_audit_log { }` |
|
|
602
706
|
|
|
603
|
-
**Jobs** (
|
|
707
|
+
**Jobs** (configure via ActiveJob)
|
|
604
708
|
|
|
605
709
|
`RailsAuditLog::WriteAuditLogJob` — do not instantiate directly; enqueued when `async: true` is set.
|
|
606
710
|
|
|
711
|
+
`RailsAuditLog::PruneAuditLogJob` — enqueue on a schedule to prune all audited models; also invoked by `bin/rails rails_audit_log:prune`.
|
|
712
|
+
|
|
607
713
|
**Generators**
|
|
608
714
|
|
|
609
715
|
`rails generate rails_audit_log:install` · `rails generate rails_audit_log:initializer`
|
|
@@ -706,7 +812,7 @@ bundle exec rake benchmark
|
|
|
706
812
|
|
|
707
813
|
[↑ Table of contents](#table-of-contents)
|
|
708
814
|
|
|
709
|
-
Bug reports and pull requests are welcome on [GitHub](https://github.com/eclectic-coding/rails_audit_log).
|
|
815
|
+
Bug reports and pull requests are welcome on [GitHub](https://github.com/eclectic-coding/rails_audit_log). See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, branch workflow, and CHANGELOG conventions.
|
|
710
816
|
|
|
711
817
|
## License
|
|
712
818
|
|
|
@@ -28,7 +28,9 @@ module RailsAuditLog
|
|
|
28
28
|
class_attribute :_audit_log_meta, default: nil
|
|
29
29
|
class_attribute :_audit_log_associations, default: nil
|
|
30
30
|
class_attribute :_audit_log_version_limit, default: nil
|
|
31
|
+
class_attribute :_audit_log_retain_for, default: nil
|
|
31
32
|
class_attribute :_audit_log_async, default: false
|
|
33
|
+
class_attribute :_audit_log_encrypt, default: nil
|
|
32
34
|
|
|
33
35
|
_warn_if_audit_table_missing
|
|
34
36
|
|
|
@@ -105,8 +107,18 @@ module RailsAuditLog
|
|
|
105
107
|
# @param version_limit [Integer, nil] maximum number of entries to retain
|
|
106
108
|
# per record; oldest entries are pruned after each write; overrides
|
|
107
109
|
# {RailsAuditLog.version_limit} for this model
|
|
110
|
+
# @param retain_for [ActiveSupport::Duration, nil] per-model time-based TTL;
|
|
111
|
+
# entries older than this duration are pruned after each write; overrides
|
|
112
|
+
# {RailsAuditLog.retention_period} for this model
|
|
108
113
|
# @param async [Boolean, nil] when +true+, writes are dispatched via
|
|
109
114
|
# +WriteAuditLogJob+; overrides {RailsAuditLog.async} for this model
|
|
115
|
+
# @param encrypt [Boolean, nil] when +true+, encrypts +object_changes+ and
|
|
116
|
+
# +object+ at write time using +ActiveRecord::Encryption+; when +false+,
|
|
117
|
+
# opts this model out even if {RailsAuditLog.encrypt} is +true+; when
|
|
118
|
+
# +nil+ (default), falls back to {RailsAuditLog.encrypt}; requires the
|
|
119
|
+
# host app to configure +config.active_record.encryption+; decryption is
|
|
120
|
+
# transparent — {AuditLogEntry#diff}, {AuditLogEntry#reify}, and
|
|
121
|
+
# {AuditLogEntry.touching} work unchanged for non-SQL access paths
|
|
110
122
|
# @return [void]
|
|
111
123
|
# @example
|
|
112
124
|
# class Article < ApplicationRecord
|
|
@@ -114,15 +126,19 @@ module RailsAuditLog
|
|
|
114
126
|
# audit_log only: %i[title body published_at],
|
|
115
127
|
# meta: { tenant_id: -> { Current.tenant_id } },
|
|
116
128
|
# associations: %i[tags],
|
|
117
|
-
# version_limit: 100
|
|
129
|
+
# version_limit: 100,
|
|
130
|
+
# retain_for: 30.days,
|
|
131
|
+
# encrypt: true
|
|
118
132
|
# end
|
|
119
|
-
def audit_log(only: nil, ignore: nil, meta: nil, associations: nil, version_limit: nil, async: nil)
|
|
133
|
+
def audit_log(only: nil, ignore: nil, meta: nil, associations: nil, version_limit: nil, retain_for: nil, async: nil, encrypt: nil)
|
|
120
134
|
self._audit_log_only = only.map(&:to_s) if only
|
|
121
135
|
self._audit_log_ignore = ignore.map(&:to_s) if ignore
|
|
122
136
|
self._audit_log_meta = meta if meta
|
|
123
137
|
self._audit_log_associations = associations unless associations.nil?
|
|
124
138
|
self._audit_log_version_limit = version_limit unless version_limit.nil?
|
|
139
|
+
self._audit_log_retain_for = retain_for unless retain_for.nil?
|
|
125
140
|
self._audit_log_async = async unless async.nil?
|
|
141
|
+
self._audit_log_encrypt = encrypt unless encrypt.nil?
|
|
126
142
|
end
|
|
127
143
|
end
|
|
128
144
|
|
|
@@ -163,7 +179,7 @@ module RailsAuditLog
|
|
|
163
179
|
event: "update",
|
|
164
180
|
item_type: self.class.name,
|
|
165
181
|
item_id: id,
|
|
166
|
-
object_changes: { assoc_name => [before, after] },
|
|
182
|
+
object_changes: maybe_encrypt({ assoc_name => [before, after] }),
|
|
167
183
|
object: nil,
|
|
168
184
|
reason: RailsAuditLog.reason,
|
|
169
185
|
metadata: meta.presence,
|
|
@@ -185,8 +201,8 @@ module RailsAuditLog
|
|
|
185
201
|
event: event,
|
|
186
202
|
item_type: self.class.name,
|
|
187
203
|
item_id: id,
|
|
188
|
-
object_changes: filtered,
|
|
189
|
-
object: snapshot,
|
|
204
|
+
object_changes: maybe_encrypt(filtered),
|
|
205
|
+
object: maybe_encrypt(snapshot),
|
|
190
206
|
reason: RailsAuditLog.reason,
|
|
191
207
|
metadata: meta.presence,
|
|
192
208
|
whodunnit_snapshot: actor ? RailsAuditLog.whodunnit_display.call(actor) : nil,
|
|
@@ -195,12 +211,21 @@ module RailsAuditLog
|
|
|
195
211
|
)
|
|
196
212
|
end
|
|
197
213
|
|
|
214
|
+
def maybe_encrypt(value)
|
|
215
|
+
return value unless value
|
|
216
|
+
encrypt = self.class._audit_log_encrypt.nil? ? RailsAuditLog.encrypt : self.class._audit_log_encrypt
|
|
217
|
+
return value unless encrypt
|
|
218
|
+
ciphertext = ActiveRecord::Encryption.encryptor.encrypt(value.to_json)
|
|
219
|
+
{ RailsAuditLog::AuditLogEntry::ENCRYPTION_MARKER => ciphertext }
|
|
220
|
+
end
|
|
221
|
+
|
|
198
222
|
def write_audit_entry(entry_attrs)
|
|
199
223
|
if (buffer = RailsAuditLog.batch_audit_buffer)
|
|
200
224
|
buffer << entry_attrs.stringify_keys.merge("created_at" => Time.current)
|
|
201
225
|
elsif _audit_log_async || RailsAuditLog.async
|
|
202
|
-
limit
|
|
203
|
-
|
|
226
|
+
limit = self.class._audit_log_version_limit || RailsAuditLog.version_limit
|
|
227
|
+
period = self.class._audit_log_retain_for || RailsAuditLog.retention_period
|
|
228
|
+
WriteAuditLogJob.perform_later(entry_attrs.stringify_keys, version_limit: limit, retention_period: period)
|
|
204
229
|
else
|
|
205
230
|
RailsAuditLog::AuditLogEntry.create!(entry_attrs)
|
|
206
231
|
prune_audit_entries
|
|
@@ -208,14 +233,19 @@ module RailsAuditLog
|
|
|
208
233
|
end
|
|
209
234
|
|
|
210
235
|
def prune_audit_entries
|
|
211
|
-
limit
|
|
212
|
-
|
|
236
|
+
limit = self.class._audit_log_version_limit || RailsAuditLog.version_limit
|
|
237
|
+
period = self.class._audit_log_retain_for || RailsAuditLog.retention_period
|
|
238
|
+
return unless limit || period
|
|
213
239
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
240
|
+
if period
|
|
241
|
+
audit_log_entries.where(created_at: ..period.ago).delete_all
|
|
242
|
+
end
|
|
217
243
|
|
|
218
|
-
|
|
244
|
+
if limit
|
|
245
|
+
count = audit_log_entries.count
|
|
246
|
+
excess = count - limit
|
|
247
|
+
audit_log_entries.order(id: :asc).limit(excess).delete_all if excess > 0
|
|
248
|
+
end
|
|
219
249
|
end
|
|
220
250
|
|
|
221
251
|
def build_audit_metadata
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module RailsAuditLog
|
|
2
|
+
# Background job that prunes {AuditLogEntry} records for every audited model
|
|
3
|
+
# that has a configured retention policy.
|
|
4
|
+
#
|
|
5
|
+
# Enqueue on a recurring schedule via your job backend (Solid Queue,
|
|
6
|
+
# Sidekiq, GoodJob, etc.):
|
|
7
|
+
#
|
|
8
|
+
# RailsAuditLog::PruneAuditLogJob.perform_later
|
|
9
|
+
#
|
|
10
|
+
# The job iterates over every +item_type+ present in +audit_log_entries+,
|
|
11
|
+
# resolves the effective +retention_period+ / +version_limit+ for that model,
|
|
12
|
+
# and deletes entries that exceed either constraint. Pruning is always scoped
|
|
13
|
+
# to one +item_type+ at a time so models do not interfere with each other.
|
|
14
|
+
class PruneAuditLogJob < ApplicationJob
|
|
15
|
+
def perform
|
|
16
|
+
AuditLogEntry.distinct.pluck(:item_type).each do |item_type|
|
|
17
|
+
prune_item_type(item_type)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def prune_item_type(item_type)
|
|
24
|
+
klass = item_type.safe_constantize
|
|
25
|
+
period = resolve_period(klass)
|
|
26
|
+
limit = resolve_limit(klass)
|
|
27
|
+
return unless period || limit
|
|
28
|
+
|
|
29
|
+
scope = AuditLogEntry.where(item_type: item_type)
|
|
30
|
+
|
|
31
|
+
scope.where(created_at: ..period.ago).delete_all if period
|
|
32
|
+
|
|
33
|
+
return unless limit
|
|
34
|
+
|
|
35
|
+
scope.select(:item_id).distinct.pluck(:item_id).each do |item_id|
|
|
36
|
+
record_scope = scope.where(item_id: item_id)
|
|
37
|
+
excess = record_scope.count - limit
|
|
38
|
+
record_scope.order(id: :asc).limit(excess).delete_all if excess > 0
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resolve_period(klass)
|
|
43
|
+
if klass.respond_to?(:_audit_log_retain_for)
|
|
44
|
+
klass._audit_log_retain_for || RailsAuditLog.retention_period
|
|
45
|
+
else
|
|
46
|
+
RailsAuditLog.retention_period
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolve_limit(klass)
|
|
51
|
+
if klass.respond_to?(:_audit_log_version_limit)
|
|
52
|
+
klass._audit_log_version_limit || RailsAuditLog.version_limit
|
|
53
|
+
else
|
|
54
|
+
RailsAuditLog.version_limit
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
module RailsAuditLog
|
|
2
2
|
class WriteAuditLogJob < ApplicationJob
|
|
3
|
-
def perform(entry_attrs, version_limit: nil)
|
|
3
|
+
def perform(entry_attrs, version_limit: nil, retention_period: nil)
|
|
4
4
|
AuditLogEntry.create!(entry_attrs)
|
|
5
5
|
|
|
6
|
-
return unless version_limit
|
|
7
|
-
|
|
8
6
|
item_type = entry_attrs["item_type"]
|
|
9
7
|
item_id = entry_attrs["item_id"]
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
scope = AuditLogEntry.where(item_type: item_type, item_id: item_id)
|
|
9
|
+
|
|
10
|
+
if retention_period
|
|
11
|
+
scope.where(created_at: ..retention_period.ago).delete_all
|
|
12
|
+
end
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
if version_limit
|
|
15
|
+
count = scope.count
|
|
16
|
+
excess = count - version_limit
|
|
17
|
+
scope.order(id: :asc).limit(excess).delete_all if excess > 0
|
|
18
|
+
end
|
|
18
19
|
end
|
|
19
20
|
end
|
|
20
21
|
end
|
|
@@ -21,9 +21,10 @@ module RailsAuditLog
|
|
|
21
21
|
class AuditLogEntry < ApplicationRecord
|
|
22
22
|
self.table_name = "audit_log_entries"
|
|
23
23
|
|
|
24
|
-
EVENTS
|
|
25
|
-
BLOB_COLUMNS
|
|
26
|
-
PERIODS
|
|
24
|
+
EVENTS = %w[create update destroy].freeze
|
|
25
|
+
BLOB_COLUMNS = %w[object_changes object metadata].freeze
|
|
26
|
+
PERIODS = { "1h" => 1.hour, "24h" => 24.hours, "7d" => 7.days }.freeze
|
|
27
|
+
ENCRYPTION_MARKER = "__ral_enc__"
|
|
27
28
|
|
|
28
29
|
# @api private
|
|
29
30
|
def self.configure_connection!
|
|
@@ -192,6 +193,21 @@ module RailsAuditLog
|
|
|
192
193
|
self.class.where(item_type: item_type, item_id: item_id).where("id > ?", id).order(id: :asc).first
|
|
193
194
|
end
|
|
194
195
|
|
|
196
|
+
# Returns the decrypted +object_changes+ hash. Transparent to callers —
|
|
197
|
+
# encrypted and non-encrypted entries behave identically.
|
|
198
|
+
#
|
|
199
|
+
# @return [Hash, nil]
|
|
200
|
+
def object_changes
|
|
201
|
+
decrypt_if_encrypted(super)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Returns the decrypted +object+ snapshot hash. Transparent to callers.
|
|
205
|
+
#
|
|
206
|
+
# @return [Hash, nil]
|
|
207
|
+
def object
|
|
208
|
+
decrypt_if_encrypted(super)
|
|
209
|
+
end
|
|
210
|
+
|
|
195
211
|
# Returns the list of attribute (and association) names that changed in
|
|
196
212
|
# this entry, derived from the keys of +object_changes+.
|
|
197
213
|
#
|
|
@@ -215,6 +231,12 @@ module RailsAuditLog
|
|
|
215
231
|
|
|
216
232
|
private
|
|
217
233
|
|
|
234
|
+
def decrypt_if_encrypted(value)
|
|
235
|
+
return value unless value.is_a?(Hash) && value.keys == [ENCRYPTION_MARKER]
|
|
236
|
+
json = ActiveRecord::Encryption.encryptor.decrypt(value[ENCRYPTION_MARKER])
|
|
237
|
+
JSON.parse(json)
|
|
238
|
+
end
|
|
239
|
+
|
|
218
240
|
def metadata_must_be_a_hash
|
|
219
241
|
errors.add(:metadata, "must be a Hash") if metadata.present? && !metadata.is_a?(Hash)
|
|
220
242
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module RailsAuditLog
|
|
5
|
+
module Generators
|
|
6
|
+
class EncryptionGenerator < Rails::Generators::Base
|
|
7
|
+
include ActiveRecord::Generators::Migration
|
|
8
|
+
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Creates an ActiveRecord::Encryption config initializer and a data migration " \
|
|
12
|
+
"that re-encrypts existing audit entries for models using audit_log encrypt: true."
|
|
13
|
+
|
|
14
|
+
def create_initializer_file
|
|
15
|
+
template "rails_audit_log_encryption.rb",
|
|
16
|
+
"config/initializers/rails_audit_log_encryption.rb"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create_migration_file
|
|
20
|
+
migration_template(
|
|
21
|
+
"encrypt_rails_audit_log_entries.rb",
|
|
22
|
+
"db/migrate/encrypt_rails_audit_log_entries.rb"
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def print_next_steps
|
|
27
|
+
say ""
|
|
28
|
+
say "Next steps:", :green
|
|
29
|
+
say " 1. Run `bin/rails db:encryption:init` to generate encryption keys"
|
|
30
|
+
say " and store them in config/credentials.yml.enc."
|
|
31
|
+
say " 2. Review config/initializers/rails_audit_log_encryption.rb and wire"
|
|
32
|
+
say " the generated keys into ActiveRecord::Encryption."
|
|
33
|
+
say " 3. Add `audit_log encrypt: true` (or set RailsAuditLog.encrypt = true)"
|
|
34
|
+
say " on the models whose audit data you want to protect."
|
|
35
|
+
say " 4. Edit the generated migration — add your encrypted model class names"
|
|
36
|
+
say " to ENCRYPTED_MODELS — then run `bin/rails db:migrate`."
|
|
37
|
+
say ""
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Data migration: re-encrypts existing plain-text audit entries for every
|
|
2
|
+
# model listed in ENCRYPTED_MODELS.
|
|
3
|
+
#
|
|
4
|
+
# == Before running
|
|
5
|
+
#
|
|
6
|
+
# 1. Add the class names of every model that uses `audit_log encrypt: true`
|
|
7
|
+
# (or that will be covered by `RailsAuditLog.encrypt = true`) to the
|
|
8
|
+
# ENCRYPTED_MODELS constant below.
|
|
9
|
+
#
|
|
10
|
+
# 2. Ensure ActiveRecord::Encryption is configured with your production keys
|
|
11
|
+
# (config/initializers/rails_audit_log_encryption.rb).
|
|
12
|
+
#
|
|
13
|
+
# 3. Run: bin/rails db:migrate
|
|
14
|
+
#
|
|
15
|
+
# Entries that are already encrypted (detected by the internal envelope format)
|
|
16
|
+
# are skipped automatically — the migration is safe to re-run.
|
|
17
|
+
#
|
|
18
|
+
# This migration is irreversible.
|
|
19
|
+
|
|
20
|
+
class EncryptRailsAuditLogEntries < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
21
|
+
BATCH_SIZE = 500
|
|
22
|
+
ENCRYPTION_MARKER = RailsAuditLog::AuditLogEntry::ENCRYPTION_MARKER
|
|
23
|
+
|
|
24
|
+
# Add the class names of every model whose audit entries should be encrypted:
|
|
25
|
+
ENCRYPTED_MODELS = %w[
|
|
26
|
+
# Post
|
|
27
|
+
# Payment
|
|
28
|
+
# User
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
# Isolated AR class — avoids coupling to host-app model overrides.
|
|
32
|
+
class Entry < ActiveRecord::Base # @api private
|
|
33
|
+
self.table_name = "audit_log_entries"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def up
|
|
37
|
+
if ENCRYPTED_MODELS.empty?
|
|
38
|
+
say " No ENCRYPTED_MODELS configured — nothing to re-encrypt. " \
|
|
39
|
+
"Edit the migration and add your model class names, then re-run."
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
ENCRYPTED_MODELS.each do |model_name|
|
|
44
|
+
say_with_time "Re-encrypting audit entries for #{model_name}" do
|
|
45
|
+
re_encrypt_for(model_name)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def down
|
|
51
|
+
raise ActiveRecord::IrreversibleMigration,
|
|
52
|
+
"Cannot reverse encryption migration — decryption would require the original " \
|
|
53
|
+
"keys and there is no way to distinguish re-encrypted rows from native ones."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def re_encrypt_for(item_type)
|
|
59
|
+
count = 0
|
|
60
|
+
Entry.where(item_type: item_type).find_in_batches(batch_size: BATCH_SIZE) do |batch|
|
|
61
|
+
batch.each do |entry|
|
|
62
|
+
changes = entry.read_attribute(:object_changes)
|
|
63
|
+
object = entry.read_attribute(:object)
|
|
64
|
+
|
|
65
|
+
updates = {}
|
|
66
|
+
updates[:object_changes] = encrypt(changes) if changes && !encrypted?(changes)
|
|
67
|
+
updates[:object] = encrypt(object) if object && !encrypted?(object)
|
|
68
|
+
next if updates.empty?
|
|
69
|
+
|
|
70
|
+
entry.update_columns(updates)
|
|
71
|
+
count += 1
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
count
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def encrypted?(value)
|
|
78
|
+
value.is_a?(Hash) && value.keys == [ENCRYPTION_MARKER]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def encrypt(value)
|
|
82
|
+
ciphertext = ActiveRecord::Encryption.encryptor.encrypt(value.to_json)
|
|
83
|
+
{ ENCRYPTION_MARKER => ciphertext }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# config/initializers/rails_audit_log_encryption.rb
|
|
2
|
+
# Generated by `bin/rails generate rails_audit_log:encryption`
|
|
3
|
+
#
|
|
4
|
+
# == Setup
|
|
5
|
+
#
|
|
6
|
+
# 1. Run `bin/rails db:encryption:init` to generate encryption keys and store
|
|
7
|
+
# them in config/credentials.yml.enc under the :active_record_encryption key.
|
|
8
|
+
#
|
|
9
|
+
# Rails adds entries like:
|
|
10
|
+
# active_record_encryption:
|
|
11
|
+
# primary_key: <generated>
|
|
12
|
+
# deterministic_key: <generated>
|
|
13
|
+
# key_derivation_salt: <generated>
|
|
14
|
+
#
|
|
15
|
+
# 2. Wire the keys into ActiveRecord::Encryption (already done below).
|
|
16
|
+
#
|
|
17
|
+
# 3. Enable encryption globally or per model:
|
|
18
|
+
#
|
|
19
|
+
# # Global — every audited model encrypts by default:
|
|
20
|
+
# # RailsAuditLog.encrypt = true
|
|
21
|
+
#
|
|
22
|
+
# # Per model — opt individual models in:
|
|
23
|
+
# # class Payment < ApplicationRecord
|
|
24
|
+
# # include RailsAuditLog::Auditable
|
|
25
|
+
# # audit_log encrypt: true
|
|
26
|
+
# # end
|
|
27
|
+
#
|
|
28
|
+
# # Per model — opt a model out when the global default is on:
|
|
29
|
+
# # class PublicLog < ApplicationRecord
|
|
30
|
+
# # include RailsAuditLog::Auditable
|
|
31
|
+
# # audit_log encrypt: false
|
|
32
|
+
# # end
|
|
33
|
+
|
|
34
|
+
Rails.application.configure do
|
|
35
|
+
config.active_record.encryption.primary_key =
|
|
36
|
+
Rails.application.credentials.dig(:active_record_encryption, :primary_key)
|
|
37
|
+
config.active_record.encryption.deterministic_key =
|
|
38
|
+
Rails.application.credentials.dig(:active_record_encryption, :deterministic_key)
|
|
39
|
+
config.active_record.encryption.key_derivation_salt =
|
|
40
|
+
Rails.application.credentials.dig(:active_record_encryption, :key_derivation_salt)
|
|
41
|
+
end
|
|
@@ -22,10 +22,22 @@ RailsAuditLog.configure do |config|
|
|
|
22
22
|
# Per-model `audit_log version_limit: N` takes precedence.
|
|
23
23
|
# config.version_limit = 100
|
|
24
24
|
|
|
25
|
+
# Global time-based TTL — entries older than this duration are pruned after
|
|
26
|
+
# each write. Composes with version_limit: an entry is removed when it
|
|
27
|
+
# exceeds either constraint. Default: nil (no TTL)
|
|
28
|
+
# config.retention_period = 90.days
|
|
29
|
+
|
|
25
30
|
# Write all audit entries asynchronously via WriteAuditLogJob. Default: false
|
|
26
31
|
# Per-model `audit_log async: true` also works.
|
|
27
32
|
# config.async = true
|
|
28
33
|
|
|
34
|
+
# Encrypt object_changes and object for all audited models using
|
|
35
|
+
# ActiveRecord::Encryption (Rails 7.1+). Requires config.active_record.encryption
|
|
36
|
+
# to be configured — run `bin/rails generate rails_audit_log:encryption` for the
|
|
37
|
+
# setup initializer and re-encryption migration. Default: false
|
|
38
|
+
# Per-model `audit_log encrypt: false` opts a specific model out.
|
|
39
|
+
# config.encrypt = true
|
|
40
|
+
|
|
29
41
|
# Route AuditLogEntry to a dedicated database (Rails multi-DB). Default: nil
|
|
30
42
|
# config.connects_to = { database: { writing: :audit_log, reading: :audit_log } }
|
|
31
43
|
|
data/lib/rails_audit_log.rb
CHANGED
|
@@ -53,12 +53,30 @@ module RailsAuditLog
|
|
|
53
53
|
# @return [Integer, nil]
|
|
54
54
|
mattr_accessor :version_limit, default: nil
|
|
55
55
|
|
|
56
|
+
# Global time-based TTL for audit entries. Entries whose +created_at+ is
|
|
57
|
+
# older than this duration are pruned automatically after each write.
|
|
58
|
+
# Composes with {.version_limit} — an entry is removed when it exceeds
|
|
59
|
+
# either constraint.
|
|
60
|
+
#
|
|
61
|
+
# @return [ActiveSupport::Duration, nil]
|
|
62
|
+
# @example
|
|
63
|
+
# RailsAuditLog.retention_period = 90.days
|
|
64
|
+
mattr_accessor :retention_period, default: nil
|
|
65
|
+
|
|
56
66
|
# When +true+, all audit writes are dispatched via +WriteAuditLogJob+ instead
|
|
57
67
|
# of being written inline. Override per-model with <tt>audit_log async: true</tt>.
|
|
58
68
|
#
|
|
59
69
|
# @return [Boolean]
|
|
60
70
|
mattr_accessor :async, default: false
|
|
61
71
|
|
|
72
|
+
# When +true+, encrypts +object_changes+ and +object+ for all audited models
|
|
73
|
+
# using +ActiveRecord::Encryption+. Requires the host app to configure
|
|
74
|
+
# +config.active_record.encryption+. Override per-model with
|
|
75
|
+
# <tt>audit_log encrypt: false</tt> to opt a specific model out.
|
|
76
|
+
#
|
|
77
|
+
# @return [Boolean]
|
|
78
|
+
mattr_accessor :encrypt, default: false
|
|
79
|
+
|
|
62
80
|
# Passes +connects_to+ options directly to {AuditLogEntry} so audit entries
|
|
63
81
|
# can be stored on a separate database.
|
|
64
82
|
#
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
namespace :rails_audit_log do
|
|
2
|
+
desc "Prune audit log entries that exceed the configured retention_period or version_limit. " \
|
|
3
|
+
"Delegates to RailsAuditLog::PruneAuditLogJob."
|
|
4
|
+
task prune: :environment do
|
|
5
|
+
RailsAuditLog::PruneAuditLogJob.perform_now
|
|
6
|
+
end
|
|
7
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_audit_log
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chuck Smith
|
|
@@ -98,6 +98,7 @@ files:
|
|
|
98
98
|
- app/javascript/rails_audit_log/diff_controller.js
|
|
99
99
|
- app/javascript/rails_audit_log/search_controller.js
|
|
100
100
|
- app/jobs/rails_audit_log/application_job.rb
|
|
101
|
+
- app/jobs/rails_audit_log/prune_audit_log_job.rb
|
|
101
102
|
- app/jobs/rails_audit_log/write_audit_log_job.rb
|
|
102
103
|
- app/models/rails_audit_log/application_record.rb
|
|
103
104
|
- app/models/rails_audit_log/audit_log_entry.rb
|
|
@@ -108,6 +109,9 @@ files:
|
|
|
108
109
|
- app/views/rails_audit_log/resources/show.html.erb
|
|
109
110
|
- config/importmap.rb
|
|
110
111
|
- config/routes.rb
|
|
112
|
+
- lib/generators/rails_audit_log/encryption/encryption_generator.rb
|
|
113
|
+
- lib/generators/rails_audit_log/encryption/templates/encrypt_rails_audit_log_entries.rb
|
|
114
|
+
- lib/generators/rails_audit_log/encryption/templates/rails_audit_log_encryption.rb
|
|
111
115
|
- lib/generators/rails_audit_log/initializer/initializer_generator.rb
|
|
112
116
|
- lib/generators/rails_audit_log/initializer/templates/rails_audit_log.rb
|
|
113
117
|
- lib/generators/rails_audit_log/install/install_generator.rb
|