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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aa624cc1cc0ba6984f41de2f19b22891f9a7c376aae0625f94ebe86f2c48ef78
4
- data.tar.gz: 75bfb038bb1fe170bdf1a893ec87c40b3e7711602ad355a9c0788d20558ef337
3
+ metadata.gz: 40a23f8ea1990a26ea7f575102fb5ddd5f1a7323c796315672de57f0154faec3
4
+ data.tar.gz: 8b0563eebeeb21fe9836f3ff091640ae4a1c5484b45dc515abfc4647510cfae9
5
5
  SHA512:
6
- metadata.gz: fd59c42a1522f4301e8c46c5b25186784847f5e56529fb7baf4d7b3d87d0f5391fb0bbcb33b8abac53bbf47a85a7529eae02d878c0484148a496b28950573541
7
- data.tar.gz: 7b2be961c2b1f6091c1426d7be966ceb99647a7415faa4a540f3876d63ea6ff2a10fab515b61074b9538a7f43b63d99481c1743e35aeb09e313d2e76c4c3107f
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=` | _(1.1.0)_ Global time-based TTL |
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** (enqueued internally; configure via ActiveJob)
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 = self.class._audit_log_version_limit || RailsAuditLog.version_limit
203
- WriteAuditLogJob.perform_later(entry_attrs.stringify_keys, version_limit: limit)
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 = self.class._audit_log_version_limit || RailsAuditLog.version_limit
212
- return unless limit
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
- count = audit_log_entries.count
215
- excess = count - limit
216
- return unless excess > 0
240
+ if period
241
+ audit_log_entries.where(created_at: ..period.ago).delete_all
242
+ end
217
243
 
218
- audit_log_entries.order(id: :asc).limit(excess).delete_all
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
- count = AuditLogEntry.where(item_type: item_type, item_id: item_id).count
11
- excess = count - version_limit
12
- return unless excess > 0
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
- AuditLogEntry.where(item_type: item_type, item_id: item_id)
15
- .order(id: :asc)
16
- .limit(excess)
17
- .delete_all
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 = %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
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
 
@@ -1,3 +1,3 @@
1
1
  module RailsAuditLog
2
- VERSION = "1.0.0"
2
+ VERSION = "1.2.0"
3
3
  end
@@ -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
- # desc "Explaining what the task does"
2
- # task :rails_audit_log do
3
- # # Task goes here
4
- # end
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.0.0
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