rails_audit_log 0.9.0 → 1.1.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 +332 -55
- data/Rakefile +5 -0
- data/app/concerns/rails_audit_log/auditable.rb +78 -9
- data/app/concerns/rails_audit_log/controller.rb +29 -0
- data/app/controllers/rails_audit_log/application_controller.rb +1 -0
- data/app/controllers/rails_audit_log/audit_log_entries_controller.rb +1 -0
- data/app/controllers/rails_audit_log/resources_controller.rb +1 -0
- data/app/helpers/rails_audit_log/application_helper.rb +1 -0
- data/app/jobs/rails_audit_log/application_job.rb +1 -0
- 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/application_record.rb +1 -0
- data/app/models/rails_audit_log/audit_log_entry.rb +126 -26
- data/lib/generators/rails_audit_log/initializer/templates/rails_audit_log.rb +5 -0
- data/lib/generators/rails_audit_log/migrate_from_paper_trail/migrate_from_paper_trail_generator.rb +21 -0
- data/lib/generators/rails_audit_log/migrate_from_paper_trail/templates/migrate_from_paper_trail.rb +99 -0
- data/lib/rails_audit_log/engine.rb +1 -0
- data/lib/rails_audit_log/matchers.rb +56 -0
- data/lib/rails_audit_log/minitest_assertions.rb +36 -0
- data/lib/rails_audit_log/paper_trail_compat.rb +76 -0
- data/lib/rails_audit_log/test_helpers.rb +20 -0
- data/lib/rails_audit_log/version.rb +1 -1
- data/lib/rails_audit_log.rb +181 -9
- data/lib/tasks/rails_audit_log_tasks.rake +7 -4
- metadata +10 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 26fdf8335990094a7278cbcf9921e123fbf44f6ec464b974b1c73e1f41047691
|
|
4
|
+
data.tar.gz: 9700fef384bb8f23eb929b0c62cb82880ba8217a44f64660f97823c4ffe6f1c0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8c0450b8f9228ec6ea98e7e026e0554d73004accf1a284433fa6260b5acd941d8314fd33b9bf873156e214f65f8bcd1d687613077f60c310dacd2a2b4e1f98e8
|
|
7
|
+
data.tar.gz: 733c560b234f96900a760cc76b74a18bd39303179e36ae8272c0f150560139272eb3fb0de9ee435883fbd2c128bd408b373601f407818ff9e5c72cb9c465e503
|
data/README.md
CHANGED
|
@@ -6,10 +6,47 @@
|
|
|
6
6
|
[](https://www.ruby-lang.org)
|
|
7
7
|
[](https://codecov.io/gh/eclectic-coding/rails_audit_log)
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Audit logging for Rails. Tracks `create`, `update`, and `destroy` events as structured JSON records with a mountable web dashboard, whodunnit actor context, batch writes, time-travel reconstruction, and test helpers — a clean, well-documented replacement for PaperTrail with a built-in migration path.
|
|
10
|
+
|
|
11
|
+
## Table of contents
|
|
12
|
+
|
|
13
|
+
- [Installation](#installation)
|
|
14
|
+
- [Configuration](#configuration)
|
|
15
|
+
- [Web dashboard](#web-dashboard)
|
|
16
|
+
- [Usage](#usage)
|
|
17
|
+
- [Tracking a model](#tracking-a-model)
|
|
18
|
+
- [Recording who made the change](#recording-who-made-the-change)
|
|
19
|
+
- [Actor context outside of controllers](#actor-context-outside-of-controllers)
|
|
20
|
+
- [Querying the audit log](#querying-the-audit-log)
|
|
21
|
+
- [Lightweight queries](#lightweight-queries)
|
|
22
|
+
- [Association tracking](#association-tracking)
|
|
23
|
+
- [Bulk audit writes](#bulk-audit-writes)
|
|
24
|
+
- [Async audit writes](#async-audit-writes)
|
|
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
|
+
- [Selective tracking](#selective-tracking)
|
|
29
|
+
- [Disabling auditing](#disabling-auditing)
|
|
30
|
+
- [Object reconstruction](#object-reconstruction)
|
|
31
|
+
- [Attaching a reason](#attaching-a-reason)
|
|
32
|
+
- [Arbitrary metadata](#arbitrary-metadata)
|
|
33
|
+
- [Request metadata capture](#request-metadata-capture)
|
|
34
|
+
- [Actor display name snapshot](#actor-display-name-snapshot)
|
|
35
|
+
- [Object snapshot storage](#object-snapshot-storage)
|
|
36
|
+
- [Separate audit database](#separate-audit-database)
|
|
37
|
+
- [Test helpers](#test-helpers)
|
|
38
|
+
- [Stability and versioning](#stability-and-versioning)
|
|
39
|
+
- [Migrating from PaperTrail](#migrating-from-papertrail)
|
|
40
|
+
- [Performance](#performance)
|
|
41
|
+
- [Companion gems](#companion-gems)
|
|
42
|
+
- [Requirements](#requirements)
|
|
43
|
+
- [Contributing](#contributing)
|
|
44
|
+
- [License](#license)
|
|
10
45
|
|
|
11
46
|
## Installation
|
|
12
47
|
|
|
48
|
+
[↑ Table of contents](#table-of-contents)
|
|
49
|
+
|
|
13
50
|
Add to your `Gemfile`:
|
|
14
51
|
|
|
15
52
|
```ruby
|
|
@@ -23,16 +60,72 @@ bin/rails generate rails_audit_log:install
|
|
|
23
60
|
bin/rails db:migrate
|
|
24
61
|
```
|
|
25
62
|
|
|
26
|
-
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
[↑ Table of contents](#table-of-contents)
|
|
66
|
+
|
|
67
|
+
Run the initializer generator to create `config/initializers/rails_audit_log.rb` with every option documented as a commented example:
|
|
27
68
|
|
|
28
69
|
```bash
|
|
29
70
|
bin/rails generate rails_audit_log:initializer
|
|
30
71
|
```
|
|
31
72
|
|
|
32
|
-
|
|
73
|
+
The generated file (shown below with all options) uses the block-style `configure` API. Every setting has a sensible default — uncomment only what you need:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
# config/initializers/rails_audit_log.rb
|
|
77
|
+
|
|
78
|
+
RailsAuditLog.configure do |config|
|
|
79
|
+
# Global columns excluded from all audited models.
|
|
80
|
+
# Default: ["updated_at"]
|
|
81
|
+
# config.ignored_attributes = %w[updated_at cached_at]
|
|
82
|
+
|
|
83
|
+
# Store a full attribute snapshot alongside object_changes.
|
|
84
|
+
# Default: true
|
|
85
|
+
# Disable to save storage; reify and version_at fall back to diff-only reconstruction.
|
|
86
|
+
# config.store_snapshot = false
|
|
87
|
+
|
|
88
|
+
# Capture remote_ip and user_agent into each entry's metadata column.
|
|
89
|
+
# Default: false — requires RailsAuditLog::Controller in ApplicationController.
|
|
90
|
+
# config.capture_request_metadata = true
|
|
91
|
+
|
|
92
|
+
# Customise how the actor's display name is stored at write time.
|
|
93
|
+
# Default: actor.name if available, otherwise actor.to_s
|
|
94
|
+
# config.whodunnit_display = ->(actor) { actor.email }
|
|
95
|
+
|
|
96
|
+
# Global cap on entries retained per tracked record. nil = no limit.
|
|
97
|
+
# Per-model `audit_log version_limit: N` takes precedence.
|
|
98
|
+
# config.version_limit = 100
|
|
99
|
+
|
|
100
|
+
# Global time-based TTL — entries older than this duration are pruned after
|
|
101
|
+
# each write. Composes with version_limit: an entry is removed when it
|
|
102
|
+
# exceeds either constraint. Default: nil (no TTL)
|
|
103
|
+
# config.retention_period = 90.days
|
|
104
|
+
|
|
105
|
+
# Write all audit entries asynchronously via WriteAuditLogJob.
|
|
106
|
+
# Default: false — per-model `audit_log async: true` also works.
|
|
107
|
+
# config.async = true
|
|
108
|
+
|
|
109
|
+
# Route AuditLogEntry to a dedicated database (Rails multi-DB).
|
|
110
|
+
# config.connects_to = { database: { writing: :audit_log, reading: :audit_log } }
|
|
111
|
+
|
|
112
|
+
# Entries per page in the web dashboard. Default: 25
|
|
113
|
+
# config.page_size = 50
|
|
114
|
+
|
|
115
|
+
# Gate web dashboard access. Block runs in controller context so controller
|
|
116
|
+
# helpers like current_user are available directly. Falls back to HTTP Basic
|
|
117
|
+
# auth when the block returns falsy. Leave unset for unauthenticated access.
|
|
118
|
+
# config.authenticate { current_user&.admin? }
|
|
119
|
+
# config.authenticate { |c| c.current_user&.admin? }
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Each option is documented in detail in its own Usage section below.
|
|
33
124
|
|
|
34
125
|
## Web dashboard
|
|
35
126
|
|
|
127
|
+
[↑ Table of contents](#table-of-contents)
|
|
128
|
+
|
|
36
129
|
Mount the engine in `config/routes.rb` to enable the built-in audit trail browser:
|
|
37
130
|
|
|
38
131
|
```ruby
|
|
@@ -43,6 +136,8 @@ Then visit `/audit` to browse all audit entries. The dashboard is delivered via
|
|
|
43
136
|
|
|
44
137
|
## Usage
|
|
45
138
|
|
|
139
|
+
[↑ Table of contents](#table-of-contents)
|
|
140
|
+
|
|
46
141
|
### Tracking a model
|
|
47
142
|
|
|
48
143
|
Include `RailsAuditLog::Auditable` in any ActiveRecord model:
|
|
@@ -120,19 +215,6 @@ entry.diff
|
|
|
120
215
|
# => { "title" => { from: "Hello", to: "World" } }
|
|
121
216
|
```
|
|
122
217
|
|
|
123
|
-
### Separate audit database
|
|
124
|
-
|
|
125
|
-
Route all audit writes to a dedicated database by setting `connects_to` in an initializer. The engine applies it to `AuditLogEntry` at boot:
|
|
126
|
-
|
|
127
|
-
```ruby
|
|
128
|
-
# config/initializers/rails_audit_log.rb
|
|
129
|
-
RailsAuditLog.connects_to = {
|
|
130
|
-
database: { writing: :audit_log, reading: :audit_log }
|
|
131
|
-
}
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
The key (e.g. `:audit_log`) must match a database key in `config/database.yml`. All reads and writes on `AuditLogEntry` — including `batch_audit` inserts and `WriteAuditLogJob` — use that connection automatically.
|
|
135
|
-
|
|
136
218
|
### Lightweight queries
|
|
137
219
|
|
|
138
220
|
Use `.slim` to exclude the three JSON blob columns (`object_changes`, `object`, `metadata`) from the SQL projection. This is useful for index or listing views where you only need the entry header (who, what event, when):
|
|
@@ -248,6 +330,45 @@ Set a global default in an initializer — per-model values take precedence:
|
|
|
248
330
|
RailsAuditLog.version_limit = 50
|
|
249
331
|
```
|
|
250
332
|
|
|
333
|
+
### Time-based retention
|
|
334
|
+
|
|
335
|
+
Automatically prune entries older than a configured duration by setting `retention_period` in an initializer:
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
# config/initializers/rails_audit_log.rb
|
|
339
|
+
RailsAuditLog.retention_period = 90.days
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
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.
|
|
343
|
+
|
|
344
|
+
Override the global default per model with `retain_for:`:
|
|
345
|
+
|
|
346
|
+
```ruby
|
|
347
|
+
class Post < ApplicationRecord
|
|
348
|
+
include RailsAuditLog::Auditable
|
|
349
|
+
audit_log retain_for: 30.days # takes precedence over RailsAuditLog.retention_period
|
|
350
|
+
end
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### Scheduled and manual pruning
|
|
354
|
+
|
|
355
|
+
`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.
|
|
356
|
+
|
|
357
|
+
Enqueue it on a recurring schedule via your job backend:
|
|
358
|
+
|
|
359
|
+
```ruby
|
|
360
|
+
# config/recurring.yml (Solid Queue)
|
|
361
|
+
prune_audit_log:
|
|
362
|
+
class: RailsAuditLog::PruneAuditLogJob
|
|
363
|
+
schedule: every day at midnight
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Or run it once manually via the rake task:
|
|
367
|
+
|
|
368
|
+
```bash
|
|
369
|
+
bin/rails rails_audit_log:prune
|
|
370
|
+
```
|
|
371
|
+
|
|
251
372
|
### Selective tracking
|
|
252
373
|
|
|
253
374
|
Track only specific attributes, or exclude noisy ones:
|
|
@@ -293,22 +414,20 @@ article.skip_audit_log { article.update!(cached_at: Time.current) }
|
|
|
293
414
|
|
|
294
415
|
### Object reconstruction
|
|
295
416
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
`AuditLogEntry#reify` returns an unsaved ActiveRecord instance reflecting the record's state **before** the entry was recorded:
|
|
417
|
+
**Reify a single entry** — `AuditLogEntry#reify` returns an unsaved ActiveRecord instance reflecting the record's state **before** the entry was recorded:
|
|
299
418
|
|
|
300
419
|
```ruby
|
|
301
420
|
article.update!(title: "v2")
|
|
302
421
|
entry = article.audit_log_entries.updated_events.last
|
|
303
422
|
|
|
304
423
|
previous = entry.reify
|
|
305
|
-
previous.title
|
|
424
|
+
previous.title # => "v1" (the pre-update state)
|
|
306
425
|
previous.persisted? # => false
|
|
307
426
|
```
|
|
308
427
|
|
|
309
428
|
Returns `nil` for `create` entries (nothing existed before).
|
|
310
429
|
|
|
311
|
-
|
|
430
|
+
**Reconstruct state at any point in time:**
|
|
312
431
|
|
|
313
432
|
```ruby
|
|
314
433
|
snapshot = RailsAuditLog.version_at(article, 1.week.ago)
|
|
@@ -317,7 +436,7 @@ snapshot.title # => whatever the title was a week ago
|
|
|
317
436
|
|
|
318
437
|
Returns `nil` if the record had no history at that time or was already destroyed.
|
|
319
438
|
|
|
320
|
-
|
|
439
|
+
**Navigate the version chain:**
|
|
321
440
|
|
|
322
441
|
```ruby
|
|
323
442
|
entry = article.audit_log_entries.updated_events.last
|
|
@@ -402,31 +521,58 @@ To save storage at the cost of reduced reification accuracy, switch to diff-only
|
|
|
402
521
|
RailsAuditLog.store_snapshot = false
|
|
403
522
|
```
|
|
404
523
|
|
|
405
|
-
###
|
|
524
|
+
### Separate audit database
|
|
406
525
|
|
|
407
|
-
|
|
526
|
+
Route all audit writes to a dedicated database by setting `connects_to` in an initializer. The engine applies it to `AuditLogEntry` at boot:
|
|
408
527
|
|
|
409
528
|
```ruby
|
|
410
|
-
#
|
|
529
|
+
# config/initializers/rails_audit_log.rb
|
|
530
|
+
RailsAuditLog.connects_to = {
|
|
531
|
+
database: { writing: :audit_log, reading: :audit_log }
|
|
532
|
+
}
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
The key (e.g. `:audit_log`) must match a database key in `config/database.yml`. All reads and writes on `AuditLogEntry` — including `batch_audit` inserts and `WriteAuditLogJob` — use that connection automatically.
|
|
536
|
+
|
|
537
|
+
### Test helpers
|
|
538
|
+
|
|
539
|
+
**`RailsAuditLog::TestHelpers`** — silences audit tracking inside a block; useful in FactoryBot factories and seed data:
|
|
540
|
+
|
|
541
|
+
```ruby
|
|
542
|
+
# spec/rails_helper.rb
|
|
411
543
|
require "rails_audit_log/test_helpers"
|
|
412
544
|
|
|
413
545
|
RSpec.configure do |config|
|
|
414
546
|
config.include RailsAuditLog::TestHelpers
|
|
415
547
|
end
|
|
548
|
+
```
|
|
416
549
|
|
|
417
|
-
|
|
418
|
-
FactoryBot
|
|
419
|
-
|
|
420
|
-
after(:create) { |p| without_audit_log { p.update!(cached_at: Time.current) } }
|
|
421
|
-
end
|
|
422
|
-
end
|
|
550
|
+
```ruby
|
|
551
|
+
# Or in a FactoryBot factory:
|
|
552
|
+
after(:create) { |p| without_audit_log { p.update!(cached_at: Time.current) } }
|
|
423
553
|
```
|
|
424
554
|
|
|
425
555
|
`without_audit_log` is a prefix-free wrapper around `RailsAuditLog.disable` — thread-safe and restores tracking even if the block raises.
|
|
426
556
|
|
|
427
|
-
|
|
557
|
+
**`RailsAuditLog::Matchers`** (RSpec) — add to `spec/rails_helper.rb`:
|
|
428
558
|
|
|
429
|
-
|
|
559
|
+
```ruby
|
|
560
|
+
require "rails_audit_log/matchers"
|
|
561
|
+
|
|
562
|
+
RSpec.configure do |config|
|
|
563
|
+
config.include RailsAuditLog::Matchers
|
|
564
|
+
end
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
```ruby
|
|
568
|
+
expect(post).to have_audit_log_entry
|
|
569
|
+
expect(post).to have_audit_log_entry(:update)
|
|
570
|
+
expect(post).to have_audit_log_entry(:update).touching(:title)
|
|
571
|
+
|
|
572
|
+
expect { post.update!(title: "New") }.to create_audit_log_entry(event: :update).touching(:title)
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
**`RailsAuditLog::MinitestAssertions`** — add to `test/test_helper.rb`:
|
|
430
576
|
|
|
431
577
|
```ruby
|
|
432
578
|
require "rails_audit_log/minitest_assertions"
|
|
@@ -436,51 +582,182 @@ class ActiveSupport::TestCase
|
|
|
436
582
|
end
|
|
437
583
|
```
|
|
438
584
|
|
|
439
|
-
Then use the assertions in any test:
|
|
440
|
-
|
|
441
585
|
```ruby
|
|
442
|
-
assert_audit_log_entry post
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
586
|
+
assert_audit_log_entry post, event: :update, touching: :title
|
|
587
|
+
refute_audit_log_entry post, event: :destroy
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
## Stability and versioning
|
|
591
|
+
|
|
592
|
+
[↑ Table of contents](#table-of-contents)
|
|
593
|
+
|
|
594
|
+
`rails_audit_log` follows [Semantic Versioning](https://semver.org/) from 1.0.0. The public API is everything documented in this README. Anything not listed here is internal and may change between minor versions.
|
|
595
|
+
|
|
596
|
+
| Version bump | Meaning |
|
|
597
|
+
|---|---|
|
|
598
|
+
| **Patch** (1.0.x) | Bug fixes only — no API changes |
|
|
599
|
+
| **Minor** (1.x.0) | New features, backward-compatible |
|
|
600
|
+
| **Major** (x.0.0) | Breaking changes with a documented migration path |
|
|
601
|
+
|
|
602
|
+
### Public API surface
|
|
603
|
+
|
|
604
|
+
**Module-level** — `RailsAuditLog`
|
|
605
|
+
|
|
606
|
+
| Method / accessor | Purpose |
|
|
607
|
+
|---|---|
|
|
608
|
+
| `.configure { \|config\| }` | Block-style configuration |
|
|
609
|
+
| `.with_actor(actor) { }` | Set whodunnit context for a block |
|
|
610
|
+
| `.disable { }` | Suppress all audit writes for a block |
|
|
611
|
+
| `.enabled?` | Whether audit writes are active |
|
|
612
|
+
| `.audit_log_reason(value) { }` | Attach a free-text reason for a block |
|
|
613
|
+
| `.batch_audit { }` | Buffer writes and flush with a single `insert_all!` |
|
|
614
|
+
| `.version_at(record, time)` | Reconstruct record state at a point in time |
|
|
615
|
+
| `.authenticate { }` | Gate web dashboard access |
|
|
616
|
+
| `.ignored_attributes=` | Global columns excluded from all models |
|
|
617
|
+
| `.store_snapshot=` | Store full object snapshot alongside diffs |
|
|
618
|
+
| `.capture_request_metadata=` | Capture `remote_ip` and `user_agent` |
|
|
619
|
+
| `.version_limit=` | Global entry cap per record |
|
|
620
|
+
| `.async=` | Write audit entries via `WriteAuditLogJob` |
|
|
621
|
+
| `.connects_to=` | Route `AuditLogEntry` to a separate database |
|
|
622
|
+
| `.page_size=` | Entries per page in the web dashboard |
|
|
623
|
+
| `.whodunnit_display=` | Proc for actor display name snapshot |
|
|
624
|
+
| `.retention_period=` | Global time-based TTL for audit entries |
|
|
625
|
+
|
|
626
|
+
**Concerns**
|
|
627
|
+
|
|
628
|
+
| Class | Include in | Key methods |
|
|
629
|
+
|---|---|---|
|
|
630
|
+
| `RailsAuditLog::Auditable` | ActiveRecord models | `audit_log(only:, ignore:, meta:, associations:, version_limit:, retain_for:, async:)`, `skip_audit_log { }` |
|
|
631
|
+
| `RailsAuditLog::Controller` | ActionController | `audit_log_actor { }` |
|
|
632
|
+
|
|
633
|
+
**Model — `RailsAuditLog::AuditLogEntry`**
|
|
634
|
+
|
|
635
|
+
Scopes: `created_events`, `updated_events`, `destroyed_events`, `by_actor`, `for_resource`, `since`, `until`, `touching`, `slim`, `for_period`
|
|
636
|
+
|
|
637
|
+
Instance methods: `reify`, `previous`, `next`, `diff`, `changed_attributes`, `actor`
|
|
638
|
+
|
|
639
|
+
Constants: `EVENTS`, `BLOB_COLUMNS`, `PERIODS`
|
|
640
|
+
|
|
641
|
+
**Testing helpers**
|
|
642
|
+
|
|
643
|
+
| Module | Require | Usage |
|
|
644
|
+
|---|---|---|
|
|
645
|
+
| `RailsAuditLog::Matchers` | `require "rails_audit_log/matchers"` | RSpec: `have_audit_log_entry`, `create_audit_log_entry` |
|
|
646
|
+
| `RailsAuditLog::MinitestAssertions` | `require "rails_audit_log/minitest_assertions"` | Minitest: `assert_audit_log_entry`, `refute_audit_log_entry` |
|
|
647
|
+
| `RailsAuditLog::TestHelpers` | `require "rails_audit_log/test_helpers"` | `without_audit_log { }` |
|
|
648
|
+
|
|
649
|
+
**Jobs** (configure via ActiveJob)
|
|
650
|
+
|
|
651
|
+
`RailsAuditLog::WriteAuditLogJob` — do not instantiate directly; enqueued when `async: true` is set.
|
|
652
|
+
|
|
653
|
+
`RailsAuditLog::PruneAuditLogJob` — enqueue on a schedule to prune all audited models; also invoked by `bin/rails rails_audit_log:prune`.
|
|
654
|
+
|
|
655
|
+
**Generators**
|
|
656
|
+
|
|
657
|
+
`rails generate rails_audit_log:install` · `rails generate rails_audit_log:initializer`
|
|
658
|
+
|
|
659
|
+
---
|
|
660
|
+
|
|
661
|
+
## Migrating from PaperTrail
|
|
662
|
+
|
|
663
|
+
[↑ Table of contents](#table-of-contents)
|
|
664
|
+
|
|
665
|
+
Run the migration generator to produce a timestamped data migration:
|
|
666
|
+
|
|
667
|
+
```bash
|
|
668
|
+
bin/rails generate rails_audit_log:migrate_from_paper_trail
|
|
669
|
+
bin/rails db:migrate
|
|
447
670
|
```
|
|
448
671
|
|
|
449
|
-
|
|
672
|
+
The generated migration reads every row from PaperTrail's `versions` table and inserts it into `audit_log_entries`.
|
|
673
|
+
|
|
674
|
+
### Column mapping
|
|
675
|
+
|
|
676
|
+
| PaperTrail `versions` | `audit_log_entries` | Notes |
|
|
677
|
+
|---|---|---|
|
|
678
|
+
| `item_type` | `item_type` | Direct copy |
|
|
679
|
+
| `item_id` | `item_id` | Direct copy |
|
|
680
|
+
| `event` | `event` | `create` / `update` / `destroy` only; other values are skipped |
|
|
681
|
+
| `object_changes` | `object_changes` | YAML or JSON → JSON (see below) |
|
|
682
|
+
| `object` | `object` | YAML or JSON → JSON (see below) |
|
|
683
|
+
| `whodunnit` | `whodunnit_snapshot` | PaperTrail stores actor as a plain string |
|
|
684
|
+
| `created_at` | `created_at` | Direct copy |
|
|
685
|
+
| — | `actor_type` / `actor_id` | **Not populated** — cannot be inferred from a string `whodunnit` |
|
|
686
|
+
|
|
687
|
+
### YAML and JSON serialization
|
|
688
|
+
|
|
689
|
+
PaperTrail serializes `object` and `object_changes` as **YAML** by default and as **JSON** when `PaperTrail.serializer = PaperTrail::Serializers::JSON` is configured. The migration handles both transparently — it tries JSON first, then falls back to YAML (with a permissive class list for the Ruby types PaperTrail commonly serializes).
|
|
690
|
+
|
|
691
|
+
### Compatibility shim for gradual migration
|
|
450
692
|
|
|
451
|
-
|
|
693
|
+
If you need your codebase to keep using PaperTrail's API while migrating, include `RailsAuditLog::PaperTrailCompat` alongside `Auditable`:
|
|
452
694
|
|
|
453
695
|
```ruby
|
|
454
|
-
require "rails_audit_log/
|
|
696
|
+
require "rails_audit_log/paper_trail_compat"
|
|
455
697
|
|
|
456
|
-
|
|
457
|
-
|
|
698
|
+
class Article < ApplicationRecord
|
|
699
|
+
include RailsAuditLog::Auditable
|
|
700
|
+
include RailsAuditLog::PaperTrailCompat
|
|
458
701
|
end
|
|
459
702
|
```
|
|
460
703
|
|
|
461
|
-
|
|
704
|
+
This adds the familiar PaperTrail surface:
|
|
462
705
|
|
|
463
706
|
```ruby
|
|
464
|
-
#
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
# Assert a block creates a matching audit entry
|
|
470
|
-
expect { post.update!(title: "New") }.to create_audit_log_entry
|
|
471
|
-
expect { post.update!(title: "New") }.to create_audit_log_entry(event: :update)
|
|
472
|
-
expect { post.update!(title: "New") }.to create_audit_log_entry(event: :update).touching(:title)
|
|
707
|
+
article.versions # audit_log_entries ordered oldest-first
|
|
708
|
+
article.paper_trail.version # most recent AuditLogEntry
|
|
709
|
+
article.paper_trail.previous_version # reconstructed previous state (reify)
|
|
710
|
+
article.paper_trail.originator # whodunnit_snapshot string
|
|
711
|
+
article.paper_trail.version_at(1.week.ago) # reconstructed state at a point in time
|
|
473
712
|
```
|
|
474
713
|
|
|
714
|
+
`AuditLogEntry#reify` already matches PaperTrail's `Version#reify` — no additional alias needed.
|
|
715
|
+
|
|
716
|
+
### What is not migrated
|
|
717
|
+
|
|
718
|
+
- `versions` rows with an `event` value outside `create`, `update`, `destroy` (custom events added by some apps)
|
|
719
|
+
- `actor_type` and `actor_id` — PaperTrail's `whodunnit` is a plain string and does not identify the actor model
|
|
720
|
+
|
|
721
|
+
---
|
|
722
|
+
|
|
723
|
+
## Companion gems
|
|
724
|
+
|
|
725
|
+
[↑ Table of contents](#table-of-contents)
|
|
726
|
+
|
|
727
|
+
> Coming in the 1.x series
|
|
728
|
+
|
|
729
|
+
| Gem | What it adds |
|
|
730
|
+
|---|---|
|
|
731
|
+
| `rails_audit_log-graphql` | Mountable GraphQL endpoint at `/audit/graphql` — queryable audit trail for API-first apps without forcing `graphql-ruby` on users who don't need it |
|
|
732
|
+
|
|
733
|
+
Each companion gem declares `rails_audit_log` as a dependency so you only add what you use.
|
|
734
|
+
|
|
475
735
|
## Requirements
|
|
476
736
|
|
|
737
|
+
[↑ Table of contents](#table-of-contents)
|
|
738
|
+
|
|
477
739
|
- Ruby >= 3.3
|
|
478
740
|
- Rails >= 7.2
|
|
479
741
|
|
|
742
|
+
## Performance
|
|
743
|
+
|
|
744
|
+
[↑ Table of contents](#table-of-contents)
|
|
745
|
+
|
|
746
|
+
See [BENCHMARKS.md](BENCHMARKS.md) for write throughput, `batch_audit` gains, query performance, storage efficiency, and notes on comparing against PaperTrail. To run the suite locally:
|
|
747
|
+
|
|
748
|
+
```bash
|
|
749
|
+
bundle exec rake dev:setup
|
|
750
|
+
bundle exec rake benchmark
|
|
751
|
+
```
|
|
752
|
+
|
|
480
753
|
## Contributing
|
|
481
754
|
|
|
755
|
+
[↑ Table of contents](#table-of-contents)
|
|
756
|
+
|
|
482
757
|
Bug reports and pull requests are welcome on [GitHub](https://github.com/eclectic-coding/rails_audit_log).
|
|
483
758
|
|
|
484
759
|
## License
|
|
485
760
|
|
|
761
|
+
[↑ Table of contents](#table-of-contents)
|
|
762
|
+
|
|
486
763
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
CHANGED
|
@@ -16,6 +16,11 @@ end
|
|
|
16
16
|
|
|
17
17
|
task default: ["bundle:audit:update", "bundle:audit:check", :rubocop, :zeitwerk, :spec]
|
|
18
18
|
|
|
19
|
+
desc "Run the performance benchmark suite (requires dev:setup first)"
|
|
20
|
+
task :benchmark do
|
|
21
|
+
sh "bundle exec ruby benchmarks/suite.rb"
|
|
22
|
+
end
|
|
23
|
+
|
|
19
24
|
# Development database tasks (operate on spec/dummy)
|
|
20
25
|
namespace :dev do
|
|
21
26
|
dummy_env = { "RAILS_ENV" => "development" }
|
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
module RailsAuditLog
|
|
2
|
+
# Include in any ActiveRecord model to automatically track +create+, +update+,
|
|
3
|
+
# and +destroy+ events as {AuditLogEntry} records.
|
|
4
|
+
#
|
|
5
|
+
# == Basic usage
|
|
6
|
+
#
|
|
7
|
+
# class Article < ApplicationRecord
|
|
8
|
+
# include RailsAuditLog::Auditable
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# == Configuring tracking
|
|
12
|
+
#
|
|
13
|
+
# class Article < ApplicationRecord
|
|
14
|
+
# include RailsAuditLog::Auditable
|
|
15
|
+
# audit_log only: %i[title body],
|
|
16
|
+
# meta: { tenant_id: -> { Current.tenant_id } },
|
|
17
|
+
# version_limit: 50,
|
|
18
|
+
# async: true
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# Adds a polymorphic +has_many :audit_log_entries+ association to the model.
|
|
2
22
|
module Auditable
|
|
3
23
|
extend ActiveSupport::Concern
|
|
4
24
|
|
|
@@ -8,10 +28,13 @@ module RailsAuditLog
|
|
|
8
28
|
class_attribute :_audit_log_meta, default: nil
|
|
9
29
|
class_attribute :_audit_log_associations, default: nil
|
|
10
30
|
class_attribute :_audit_log_version_limit, default: nil
|
|
31
|
+
class_attribute :_audit_log_retain_for, default: nil
|
|
11
32
|
class_attribute :_audit_log_async, default: false
|
|
12
33
|
|
|
13
34
|
_warn_if_audit_table_missing
|
|
14
35
|
|
|
36
|
+
# All {AuditLogEntry} records for this object, newest first by default.
|
|
37
|
+
# Destroyed when the object is destroyed.
|
|
15
38
|
has_many :audit_log_entries,
|
|
16
39
|
class_name: "RailsAuditLog::AuditLogEntry",
|
|
17
40
|
as: :item,
|
|
@@ -54,6 +77,7 @@ module RailsAuditLog
|
|
|
54
77
|
end
|
|
55
78
|
|
|
56
79
|
class_methods do
|
|
80
|
+
# @api private
|
|
57
81
|
def _warn_if_audit_table_missing
|
|
58
82
|
return if connection.table_exists?("audit_log_entries")
|
|
59
83
|
|
|
@@ -66,16 +90,55 @@ module RailsAuditLog
|
|
|
66
90
|
# DB not reachable during this phase (e.g. before db:create) — skip the check
|
|
67
91
|
end
|
|
68
92
|
|
|
69
|
-
|
|
93
|
+
# Configures auditing options for this model. Call once in the class body
|
|
94
|
+
# after +include RailsAuditLog::Auditable+.
|
|
95
|
+
#
|
|
96
|
+
# @param only [Array<Symbol>, nil] whitelist of attributes to track;
|
|
97
|
+
# when set, all other attributes are ignored regardless of +ignore:+
|
|
98
|
+
# @param ignore [Array<Symbol>, nil] additional attributes to exclude on
|
|
99
|
+
# top of {RailsAuditLog.ignored_attributes}; ignored when +only:+ is set
|
|
100
|
+
# @param meta [Hash{Symbol => Proc}, nil] per-entry metadata; each value
|
|
101
|
+
# is a lambda called at write time — zero-argument lambdas receive no
|
|
102
|
+
# arguments, one-argument lambdas receive the record instance
|
|
103
|
+
# @param associations [Boolean, Array<Symbol>, nil] when +true+, tracks
|
|
104
|
+
# all +has_many+ and +has_and_belongs_to_many+ associations; pass an
|
|
105
|
+
# array of association names to track only specific ones
|
|
106
|
+
# @param version_limit [Integer, nil] maximum number of entries to retain
|
|
107
|
+
# per record; oldest entries are pruned after each write; overrides
|
|
108
|
+
# {RailsAuditLog.version_limit} for this model
|
|
109
|
+
# @param retain_for [ActiveSupport::Duration, nil] per-model time-based TTL;
|
|
110
|
+
# entries older than this duration are pruned after each write; overrides
|
|
111
|
+
# {RailsAuditLog.retention_period} for this model
|
|
112
|
+
# @param async [Boolean, nil] when +true+, writes are dispatched via
|
|
113
|
+
# +WriteAuditLogJob+; overrides {RailsAuditLog.async} for this model
|
|
114
|
+
# @return [void]
|
|
115
|
+
# @example
|
|
116
|
+
# class Article < ApplicationRecord
|
|
117
|
+
# include RailsAuditLog::Auditable
|
|
118
|
+
# audit_log only: %i[title body published_at],
|
|
119
|
+
# meta: { tenant_id: -> { Current.tenant_id } },
|
|
120
|
+
# associations: %i[tags],
|
|
121
|
+
# version_limit: 100,
|
|
122
|
+
# retain_for: 30.days
|
|
123
|
+
# end
|
|
124
|
+
def audit_log(only: nil, ignore: nil, meta: nil, associations: nil, version_limit: nil, retain_for: nil, async: nil)
|
|
70
125
|
self._audit_log_only = only.map(&:to_s) if only
|
|
71
126
|
self._audit_log_ignore = ignore.map(&:to_s) if ignore
|
|
72
127
|
self._audit_log_meta = meta if meta
|
|
73
128
|
self._audit_log_associations = associations unless associations.nil?
|
|
74
129
|
self._audit_log_version_limit = version_limit unless version_limit.nil?
|
|
130
|
+
self._audit_log_retain_for = retain_for unless retain_for.nil?
|
|
75
131
|
self._audit_log_async = async unless async.nil?
|
|
76
132
|
end
|
|
77
133
|
end
|
|
78
134
|
|
|
135
|
+
# Executes the block with audit logging disabled for this record's writes.
|
|
136
|
+
# A convenience wrapper around {RailsAuditLog.disable}.
|
|
137
|
+
#
|
|
138
|
+
# @yield executes the block without recording any audit entries
|
|
139
|
+
# @return [Object] the return value of the block
|
|
140
|
+
# @example Skip auditing during a bulk update
|
|
141
|
+
# post.skip_audit_log { post.update!(cached_at: Time.current) }
|
|
79
142
|
def skip_audit_log
|
|
80
143
|
RailsAuditLog.disable { yield }
|
|
81
144
|
end
|
|
@@ -142,8 +205,9 @@ module RailsAuditLog
|
|
|
142
205
|
if (buffer = RailsAuditLog.batch_audit_buffer)
|
|
143
206
|
buffer << entry_attrs.stringify_keys.merge("created_at" => Time.current)
|
|
144
207
|
elsif _audit_log_async || RailsAuditLog.async
|
|
145
|
-
limit
|
|
146
|
-
|
|
208
|
+
limit = self.class._audit_log_version_limit || RailsAuditLog.version_limit
|
|
209
|
+
period = self.class._audit_log_retain_for || RailsAuditLog.retention_period
|
|
210
|
+
WriteAuditLogJob.perform_later(entry_attrs.stringify_keys, version_limit: limit, retention_period: period)
|
|
147
211
|
else
|
|
148
212
|
RailsAuditLog::AuditLogEntry.create!(entry_attrs)
|
|
149
213
|
prune_audit_entries
|
|
@@ -151,14 +215,19 @@ module RailsAuditLog
|
|
|
151
215
|
end
|
|
152
216
|
|
|
153
217
|
def prune_audit_entries
|
|
154
|
-
limit
|
|
155
|
-
|
|
218
|
+
limit = self.class._audit_log_version_limit || RailsAuditLog.version_limit
|
|
219
|
+
period = self.class._audit_log_retain_for || RailsAuditLog.retention_period
|
|
220
|
+
return unless limit || period
|
|
156
221
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
222
|
+
if period
|
|
223
|
+
audit_log_entries.where(created_at: ..period.ago).delete_all
|
|
224
|
+
end
|
|
160
225
|
|
|
161
|
-
|
|
226
|
+
if limit
|
|
227
|
+
count = audit_log_entries.count
|
|
228
|
+
excess = count - limit
|
|
229
|
+
audit_log_entries.order(id: :asc).limit(excess).delete_all if excess > 0
|
|
230
|
+
end
|
|
162
231
|
end
|
|
163
232
|
|
|
164
233
|
def build_audit_metadata
|