rails_audit_log 1.1.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: 26fdf8335990094a7278cbcf9921e123fbf44f6ec464b974b1c73e1f41047691
4
- data.tar.gz: 9700fef384bb8f23eb929b0c62cb82880ba8217a44f64660f97823c4ffe6f1c0
3
+ metadata.gz: 40a23f8ea1990a26ea7f575102fb5ddd5f1a7323c796315672de57f0154faec3
4
+ data.tar.gz: 8b0563eebeeb21fe9836f3ff091640ae4a1c5484b45dc515abfc4647510cfae9
5
5
  SHA512:
6
- metadata.gz: 8c0450b8f9228ec6ea98e7e026e0554d73004accf1a284433fa6260b5acd941d8314fd33b9bf873156e214f65f8bcd1d687613077f60c310dacd2a2b4e1f98e8
7
- data.tar.gz: 733c560b234f96900a760cc76b74a18bd39303179e36ae8272c0f150560139272eb3fb0de9ee435883fbd2c128bd408b373601f407818ff9e5c72cb9c465e503
6
+ metadata.gz: 29cc8682459e2d7968555a49faa77e47c00da42a54bf633f286dd01e89979be8fff6790aa0880de5d7516d23f1fc16f4a88c10b1e4aa086921c6052fdb812784
7
+ data.tar.gz: f1a1ba92cfe1c9a1ff0640b9b8a78f94275195fb018da97d89557b9612d631c31293b228e0715d059b790a3f0d2888069066f9897f376e43a40467c8c0c6dbd2
data/README.md CHANGED
@@ -25,6 +25,7 @@ Audit logging for Rails. Tracks `create`, `update`, and `destroy` events as stru
25
25
  - [Capping history per record](#capping-history-per-record)
26
26
  - [Time-based retention](#time-based-retention)
27
27
  - [Scheduled and manual pruning](#scheduled-and-manual-pruning)
28
+ - [Encrypting audit data](#encrypting-audit-data)
28
29
  - [Selective tracking](#selective-tracking)
29
30
  - [Disabling auditing](#disabling-auditing)
30
31
  - [Object reconstruction](#object-reconstruction)
@@ -369,6 +370,63 @@ Or run it once manually via the rake task:
369
370
  bin/rails rails_audit_log:prune
370
371
  ```
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
+
372
430
  ### Selective tracking
373
431
 
374
432
  Track only specific attributes, or exclude noisy ones:
@@ -754,7 +812,7 @@ bundle exec rake benchmark
754
812
 
755
813
  [↑ Table of contents](#table-of-contents)
756
814
 
757
- 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.
758
816
 
759
817
  ## License
760
818
 
@@ -30,6 +30,7 @@ module RailsAuditLog
30
30
  class_attribute :_audit_log_version_limit, default: nil
31
31
  class_attribute :_audit_log_retain_for, default: nil
32
32
  class_attribute :_audit_log_async, default: false
33
+ class_attribute :_audit_log_encrypt, default: nil
33
34
 
34
35
  _warn_if_audit_table_missing
35
36
 
@@ -111,6 +112,13 @@ module RailsAuditLog
111
112
  # {RailsAuditLog.retention_period} for this model
112
113
  # @param async [Boolean, nil] when +true+, writes are dispatched via
113
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
114
122
  # @return [void]
115
123
  # @example
116
124
  # class Article < ApplicationRecord
@@ -119,9 +127,10 @@ module RailsAuditLog
119
127
  # meta: { tenant_id: -> { Current.tenant_id } },
120
128
  # associations: %i[tags],
121
129
  # version_limit: 100,
122
- # retain_for: 30.days
130
+ # retain_for: 30.days,
131
+ # encrypt: true
123
132
  # end
124
- def audit_log(only: nil, ignore: nil, meta: nil, associations: nil, version_limit: nil, retain_for: 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)
125
134
  self._audit_log_only = only.map(&:to_s) if only
126
135
  self._audit_log_ignore = ignore.map(&:to_s) if ignore
127
136
  self._audit_log_meta = meta if meta
@@ -129,6 +138,7 @@ module RailsAuditLog
129
138
  self._audit_log_version_limit = version_limit unless version_limit.nil?
130
139
  self._audit_log_retain_for = retain_for unless retain_for.nil?
131
140
  self._audit_log_async = async unless async.nil?
141
+ self._audit_log_encrypt = encrypt unless encrypt.nil?
132
142
  end
133
143
  end
134
144
 
@@ -169,7 +179,7 @@ module RailsAuditLog
169
179
  event: "update",
170
180
  item_type: self.class.name,
171
181
  item_id: id,
172
- object_changes: { assoc_name => [before, after] },
182
+ object_changes: maybe_encrypt({ assoc_name => [before, after] }),
173
183
  object: nil,
174
184
  reason: RailsAuditLog.reason,
175
185
  metadata: meta.presence,
@@ -191,8 +201,8 @@ module RailsAuditLog
191
201
  event: event,
192
202
  item_type: self.class.name,
193
203
  item_id: id,
194
- object_changes: filtered,
195
- object: snapshot,
204
+ object_changes: maybe_encrypt(filtered),
205
+ object: maybe_encrypt(snapshot),
196
206
  reason: RailsAuditLog.reason,
197
207
  metadata: meta.presence,
198
208
  whodunnit_snapshot: actor ? RailsAuditLog.whodunnit_display.call(actor) : nil,
@@ -201,6 +211,14 @@ module RailsAuditLog
201
211
  )
202
212
  end
203
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
+
204
222
  def write_audit_entry(entry_attrs)
205
223
  if (buffer = RailsAuditLog.batch_audit_buffer)
206
224
  buffer << entry_attrs.stringify_keys.merge("created_at" => Time.current)
@@ -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
@@ -31,6 +31,13 @@ RailsAuditLog.configure do |config|
31
31
  # Per-model `audit_log async: true` also works.
32
32
  # config.async = true
33
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
+
34
41
  # Route AuditLogEntry to a dedicated database (Rails multi-DB). Default: nil
35
42
  # config.connects_to = { database: { writing: :audit_log, reading: :audit_log } }
36
43
 
@@ -1,3 +1,3 @@
1
1
  module RailsAuditLog
2
- VERSION = "1.1.0"
2
+ VERSION = "1.2.0"
3
3
  end
@@ -69,6 +69,14 @@ module RailsAuditLog
69
69
  # @return [Boolean]
70
70
  mattr_accessor :async, default: false
71
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
+
72
80
  # Passes +connects_to+ options directly to {AuditLogEntry} so audit entries
73
81
  # can be stored on a separate database.
74
82
  #
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.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -109,6 +109,9 @@ files:
109
109
  - app/views/rails_audit_log/resources/show.html.erb
110
110
  - config/importmap.rb
111
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
112
115
  - lib/generators/rails_audit_log/initializer/initializer_generator.rb
113
116
  - lib/generators/rails_audit_log/initializer/templates/rails_audit_log.rb
114
117
  - lib/generators/rails_audit_log/install/install_generator.rb