rails_audit_log 1.1.0 → 1.3.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 +109 -1
- data/app/concerns/rails_audit_log/auditable.rb +36 -6
- data/app/controllers/rails_audit_log/application_controller.rb +5 -0
- data/app/controllers/rails_audit_log/audit_log_entries_controller.rb +1 -1
- data/app/controllers/rails_audit_log/resources_controller.rb +1 -1
- data/app/models/rails_audit_log/audit_log_entry.rb +35 -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 +7 -0
- data/lib/generators/rails_audit_log/tenant/templates/add_tenant_id_to_audit_log_entries.rb +6 -0
- data/lib/generators/rails_audit_log/tenant/tenant_generator.rb +33 -0
- data/lib/rails_audit_log/version.rb +1 -1
- data/lib/rails_audit_log.rb +37 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fb6120f7ce9f0f2a2fb19553a77ff4c6bf5c24a1945270e2e6393403b9ffc3a0
|
|
4
|
+
data.tar.gz: a978f7082c30a68c37344e0b819af1e70d077b3092337c5ea31c4e35473ffef6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8670a4ceb147508b45d03679eb3765c6984393c144434f4ad90b40bf69c64478ff8b92b0ab3bdb0b054cb0f64d2794f05044d546f81fa2fde17496ff65fde4de
|
|
7
|
+
data.tar.gz: 5ba060114539abc8555a5873aa7671c226cdc2c19a3bd66a4549595f68ea170372a968de324485dc0bf2eabb0bd253fa14e5c14c98b7b4ad9fe691bc15973fbb
|
data/README.md
CHANGED
|
@@ -25,6 +25,8 @@ 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)
|
|
29
|
+
- [Multi-tenancy](#multi-tenancy)
|
|
28
30
|
- [Selective tracking](#selective-tracking)
|
|
29
31
|
- [Disabling auditing](#disabling-auditing)
|
|
30
32
|
- [Object reconstruction](#object-reconstruction)
|
|
@@ -369,6 +371,112 @@ Or run it once manually via the rake task:
|
|
|
369
371
|
bin/rails rails_audit_log:prune
|
|
370
372
|
```
|
|
371
373
|
|
|
374
|
+
### Encrypting audit data
|
|
375
|
+
|
|
376
|
+
Encrypt `object_changes` and `object` at write time using `ActiveRecord::Encryption` (Rails 7.1+). Pass `encrypt: true` to `audit_log` in the model:
|
|
377
|
+
|
|
378
|
+
```ruby
|
|
379
|
+
class Payment < ApplicationRecord
|
|
380
|
+
include RailsAuditLog::Auditable
|
|
381
|
+
audit_log encrypt: true
|
|
382
|
+
end
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
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`:
|
|
386
|
+
|
|
387
|
+
```ruby
|
|
388
|
+
config.active_record.encryption.primary_key = Rails.application.credentials.ral_primary_key
|
|
389
|
+
config.active_record.encryption.deterministic_key = Rails.application.credentials.ral_deterministic_key
|
|
390
|
+
config.active_record.encryption.key_derivation_salt = Rails.application.credentials.ral_kdf_salt
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Enable encryption globally so every audited model encrypts by default:
|
|
394
|
+
|
|
395
|
+
```ruby
|
|
396
|
+
# config/initializers/rails_audit_log.rb
|
|
397
|
+
RailsAuditLog.encrypt = true
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Opt a specific model out when the global default is on:
|
|
401
|
+
|
|
402
|
+
```ruby
|
|
403
|
+
class PublicLog < ApplicationRecord
|
|
404
|
+
include RailsAuditLog::Auditable
|
|
405
|
+
audit_log encrypt: false # plain JSON even when RailsAuditLog.encrypt = true
|
|
406
|
+
end
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Decryption is transparent — `#diff`, `#reify`, `#changed_attributes`, and all other instance methods work without any changes.
|
|
410
|
+
|
|
411
|
+
> **Note:** The `touching` scope uses database-level JSON extraction (`json_extract` / `->>`) and will not match encrypted entries. All Ruby-side query methods work normally.
|
|
412
|
+
|
|
413
|
+
#### Setting up encryption keys
|
|
414
|
+
|
|
415
|
+
Run the Rails built-in task to generate keys and store them in `config/credentials.yml.enc`:
|
|
416
|
+
|
|
417
|
+
```bash
|
|
418
|
+
bin/rails db:encryption:init
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Then run the encryption generator to produce a wired-up initializer and a re-encryption migration for existing entries:
|
|
422
|
+
|
|
423
|
+
```bash
|
|
424
|
+
bin/rails generate rails_audit_log:encryption
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
The generator creates:
|
|
428
|
+
- `config/initializers/rails_audit_log_encryption.rb` — reads the generated keys from credentials and passes them to `ActiveRecord::Encryption`
|
|
429
|
+
- `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`
|
|
430
|
+
|
|
431
|
+
### Multi-tenancy
|
|
432
|
+
|
|
433
|
+
Store the current tenant on every audit entry so queries are naturally isolated per tenant.
|
|
434
|
+
|
|
435
|
+
Run the generator to add the `tenant_id` column:
|
|
436
|
+
|
|
437
|
+
```bash
|
|
438
|
+
bin/rails generate rails_audit_log:tenant
|
|
439
|
+
bin/rails db:migrate
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
Set a global resolver in your initializer — the block is called at write time:
|
|
443
|
+
|
|
444
|
+
```ruby
|
|
445
|
+
# config/initializers/rails_audit_log.rb
|
|
446
|
+
RailsAuditLog.current_tenant { Current.tenant_id }
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Or override per model:
|
|
450
|
+
|
|
451
|
+
```ruby
|
|
452
|
+
class Order < ApplicationRecord
|
|
453
|
+
include RailsAuditLog::Auditable
|
|
454
|
+
audit_log tenant: -> { Current.tenant_id }
|
|
455
|
+
end
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
The per-model lambda takes precedence over the global resolver. Both accept zero-argument lambdas and store whatever the block returns in the `tenant_id` string column.
|
|
459
|
+
|
|
460
|
+
Scope queries to a single tenant with `for_tenant`:
|
|
461
|
+
|
|
462
|
+
```ruby
|
|
463
|
+
AuditLogEntry.for_tenant("acme")
|
|
464
|
+
AuditLogEntry.for_tenant(Current.tenant_id).updated_events.since(1.week.ago)
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
The web dashboard (`/audit`) automatically applies `for_tenant` when `current_tenant` is configured, so entries from other tenants are never exposed.
|
|
468
|
+
|
|
469
|
+
#### Acts As Tenant integration
|
|
470
|
+
|
|
471
|
+
Wire the resolver to `ActsAsTenant` in one line:
|
|
472
|
+
|
|
473
|
+
```ruby
|
|
474
|
+
# config/initializers/rails_audit_log.rb
|
|
475
|
+
RailsAuditLog.acts_as_tenant!
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
This is equivalent to `RailsAuditLog.current_tenant { ActsAsTenant.current_tenant&.id }`.
|
|
479
|
+
|
|
372
480
|
### Selective tracking
|
|
373
481
|
|
|
374
482
|
Track only specific attributes, or exclude noisy ones:
|
|
@@ -754,7 +862,7 @@ bundle exec rake benchmark
|
|
|
754
862
|
|
|
755
863
|
[↑ Table of contents](#table-of-contents)
|
|
756
864
|
|
|
757
|
-
Bug reports and pull requests are welcome on [GitHub](https://github.com/eclectic-coding/rails_audit_log).
|
|
865
|
+
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
866
|
|
|
759
867
|
## License
|
|
760
868
|
|
|
@@ -30,6 +30,8 @@ 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
|
|
34
|
+
class_attribute :_audit_log_tenant, default: nil
|
|
33
35
|
|
|
34
36
|
_warn_if_audit_table_missing
|
|
35
37
|
|
|
@@ -111,17 +113,28 @@ module RailsAuditLog
|
|
|
111
113
|
# {RailsAuditLog.retention_period} for this model
|
|
112
114
|
# @param async [Boolean, nil] when +true+, writes are dispatched via
|
|
113
115
|
# +WriteAuditLogJob+; overrides {RailsAuditLog.async} for this model
|
|
116
|
+
# @param encrypt [Boolean, nil] when +true+, encrypts +object_changes+ and
|
|
117
|
+
# +object+ at write time using +ActiveRecord::Encryption+; when +false+,
|
|
118
|
+
# opts this model out even if {RailsAuditLog.encrypt} is +true+; when
|
|
119
|
+
# +nil+ (default), falls back to {RailsAuditLog.encrypt}; requires the
|
|
120
|
+
# host app to configure +config.active_record.encryption+; decryption is
|
|
121
|
+
# transparent — {AuditLogEntry#diff}, {AuditLogEntry#reify}, and
|
|
122
|
+
# {AuditLogEntry.touching} work unchanged for non-SQL access paths
|
|
123
|
+
# @param tenant [Proc, nil] zero-argument lambda evaluated at write time;
|
|
124
|
+
# return value is stored in the +tenant_id+ column; overrides
|
|
125
|
+
# {RailsAuditLog.current_tenant} for this model
|
|
114
126
|
# @return [void]
|
|
115
127
|
# @example
|
|
116
128
|
# class Article < ApplicationRecord
|
|
117
129
|
# include RailsAuditLog::Auditable
|
|
118
130
|
# audit_log only: %i[title body published_at],
|
|
119
|
-
#
|
|
131
|
+
# tenant: -> { Current.tenant_id },
|
|
120
132
|
# associations: %i[tags],
|
|
121
133
|
# version_limit: 100,
|
|
122
|
-
# retain_for: 30.days
|
|
134
|
+
# retain_for: 30.days,
|
|
135
|
+
# encrypt: true
|
|
123
136
|
# end
|
|
124
|
-
def audit_log(only: nil, ignore: nil, meta: nil, associations: nil, version_limit: nil, retain_for: nil, async: nil)
|
|
137
|
+
def audit_log(only: nil, ignore: nil, meta: nil, associations: nil, version_limit: nil, retain_for: nil, async: nil, encrypt: nil, tenant: nil)
|
|
125
138
|
self._audit_log_only = only.map(&:to_s) if only
|
|
126
139
|
self._audit_log_ignore = ignore.map(&:to_s) if ignore
|
|
127
140
|
self._audit_log_meta = meta if meta
|
|
@@ -129,6 +142,8 @@ module RailsAuditLog
|
|
|
129
142
|
self._audit_log_version_limit = version_limit unless version_limit.nil?
|
|
130
143
|
self._audit_log_retain_for = retain_for unless retain_for.nil?
|
|
131
144
|
self._audit_log_async = async unless async.nil?
|
|
145
|
+
self._audit_log_encrypt = encrypt unless encrypt.nil?
|
|
146
|
+
self._audit_log_tenant = tenant unless tenant.nil?
|
|
132
147
|
end
|
|
133
148
|
end
|
|
134
149
|
|
|
@@ -169,10 +184,11 @@ module RailsAuditLog
|
|
|
169
184
|
event: "update",
|
|
170
185
|
item_type: self.class.name,
|
|
171
186
|
item_id: id,
|
|
172
|
-
object_changes: { assoc_name => [before, after] },
|
|
187
|
+
object_changes: maybe_encrypt({ assoc_name => [before, after] }),
|
|
173
188
|
object: nil,
|
|
174
189
|
reason: RailsAuditLog.reason,
|
|
175
190
|
metadata: meta.presence,
|
|
191
|
+
tenant_id: resolve_tenant_id,
|
|
176
192
|
whodunnit_snapshot: actor ? RailsAuditLog.whodunnit_display.call(actor) : nil,
|
|
177
193
|
actor_type: actor&.class&.name,
|
|
178
194
|
actor_id: actor.respond_to?(:id) ? actor.id : nil
|
|
@@ -191,16 +207,25 @@ module RailsAuditLog
|
|
|
191
207
|
event: event,
|
|
192
208
|
item_type: self.class.name,
|
|
193
209
|
item_id: id,
|
|
194
|
-
object_changes: filtered,
|
|
195
|
-
object: snapshot,
|
|
210
|
+
object_changes: maybe_encrypt(filtered),
|
|
211
|
+
object: maybe_encrypt(snapshot),
|
|
196
212
|
reason: RailsAuditLog.reason,
|
|
197
213
|
metadata: meta.presence,
|
|
214
|
+
tenant_id: resolve_tenant_id,
|
|
198
215
|
whodunnit_snapshot: actor ? RailsAuditLog.whodunnit_display.call(actor) : nil,
|
|
199
216
|
actor_type: actor&.class&.name,
|
|
200
217
|
actor_id: actor.respond_to?(:id) ? actor.id : nil
|
|
201
218
|
)
|
|
202
219
|
end
|
|
203
220
|
|
|
221
|
+
def maybe_encrypt(value)
|
|
222
|
+
return value unless value
|
|
223
|
+
encrypt = self.class._audit_log_encrypt.nil? ? RailsAuditLog.encrypt : self.class._audit_log_encrypt
|
|
224
|
+
return value unless encrypt
|
|
225
|
+
ciphertext = ActiveRecord::Encryption.encryptor.encrypt(value.to_json)
|
|
226
|
+
{ RailsAuditLog::AuditLogEntry::ENCRYPTION_MARKER => ciphertext }
|
|
227
|
+
end
|
|
228
|
+
|
|
204
229
|
def write_audit_entry(entry_attrs)
|
|
205
230
|
if (buffer = RailsAuditLog.batch_audit_buffer)
|
|
206
231
|
buffer << entry_attrs.stringify_keys.merge("created_at" => Time.current)
|
|
@@ -230,6 +255,11 @@ module RailsAuditLog
|
|
|
230
255
|
end
|
|
231
256
|
end
|
|
232
257
|
|
|
258
|
+
def resolve_tenant_id
|
|
259
|
+
tenant_proc = self.class._audit_log_tenant || RailsAuditLog.current_tenant
|
|
260
|
+
tenant_proc&.call
|
|
261
|
+
end
|
|
262
|
+
|
|
233
263
|
def build_audit_metadata
|
|
234
264
|
meta = {}
|
|
235
265
|
if self.class._audit_log_meta
|
|
@@ -13,5 +13,10 @@ module RailsAuditLog
|
|
|
13
13
|
|
|
14
14
|
instance_exec(self, &auth) || request_http_basic_authentication("Audit Log")
|
|
15
15
|
end
|
|
16
|
+
|
|
17
|
+
def base_audit_scope
|
|
18
|
+
tenant_id = RailsAuditLog.current_tenant&.call
|
|
19
|
+
tenant_id ? AuditLogEntry.for_tenant(tenant_id) : AuditLogEntry.all
|
|
20
|
+
end
|
|
16
21
|
end
|
|
17
22
|
end
|
|
@@ -21,7 +21,7 @@ module RailsAuditLog
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def filtered_scope
|
|
24
|
-
scope =
|
|
24
|
+
scope = base_audit_scope.order(created_at: :desc)
|
|
25
25
|
scope = scope.where(event: @event) if @event
|
|
26
26
|
scope = scope.where(item_type: @item_type) if @item_type
|
|
27
27
|
scope = scope.for_period(@period) if @period
|
|
@@ -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!
|
|
@@ -90,6 +91,16 @@ module RailsAuditLog
|
|
|
90
91
|
end
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
# Entries belonging to a specific tenant.
|
|
95
|
+
# Composable with all other scopes.
|
|
96
|
+
#
|
|
97
|
+
# @param id [String, Integer] the tenant identifier stored in +tenant_id+
|
|
98
|
+
# @return [ActiveRecord::Relation]
|
|
99
|
+
# @example
|
|
100
|
+
# AuditLogEntry.for_tenant("acme")
|
|
101
|
+
# AuditLogEntry.for_tenant(Current.tenant_id).updated_events
|
|
102
|
+
scope :for_tenant, ->(id) { where(tenant_id: id) }
|
|
103
|
+
|
|
93
104
|
# @!endgroup
|
|
94
105
|
|
|
95
106
|
# @!group Time scopes
|
|
@@ -192,6 +203,21 @@ module RailsAuditLog
|
|
|
192
203
|
self.class.where(item_type: item_type, item_id: item_id).where("id > ?", id).order(id: :asc).first
|
|
193
204
|
end
|
|
194
205
|
|
|
206
|
+
# Returns the decrypted +object_changes+ hash. Transparent to callers —
|
|
207
|
+
# encrypted and non-encrypted entries behave identically.
|
|
208
|
+
#
|
|
209
|
+
# @return [Hash, nil]
|
|
210
|
+
def object_changes
|
|
211
|
+
decrypt_if_encrypted(super)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Returns the decrypted +object+ snapshot hash. Transparent to callers.
|
|
215
|
+
#
|
|
216
|
+
# @return [Hash, nil]
|
|
217
|
+
def object
|
|
218
|
+
decrypt_if_encrypted(super)
|
|
219
|
+
end
|
|
220
|
+
|
|
195
221
|
# Returns the list of attribute (and association) names that changed in
|
|
196
222
|
# this entry, derived from the keys of +object_changes+.
|
|
197
223
|
#
|
|
@@ -215,6 +241,12 @@ module RailsAuditLog
|
|
|
215
241
|
|
|
216
242
|
private
|
|
217
243
|
|
|
244
|
+
def decrypt_if_encrypted(value)
|
|
245
|
+
return value unless value.is_a?(Hash) && value.keys == [ENCRYPTION_MARKER]
|
|
246
|
+
json = ActiveRecord::Encryption.encryptor.decrypt(value[ENCRYPTION_MARKER])
|
|
247
|
+
JSON.parse(json)
|
|
248
|
+
end
|
|
249
|
+
|
|
218
250
|
def metadata_must_be_a_hash
|
|
219
251
|
errors.add(:metadata, "must be a Hash") if metadata.present? && !metadata.is_a?(Hash)
|
|
220
252
|
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
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module RailsAuditLog
|
|
5
|
+
module Generators
|
|
6
|
+
class TenantGenerator < Rails::Generators::Base
|
|
7
|
+
include ActiveRecord::Generators::Migration
|
|
8
|
+
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Creates a migration that adds a tenant_id column and index to audit_log_entries."
|
|
12
|
+
|
|
13
|
+
def create_migration_file
|
|
14
|
+
migration_template(
|
|
15
|
+
"add_tenant_id_to_audit_log_entries.rb",
|
|
16
|
+
"db/migrate/add_tenant_id_to_audit_log_entries.rb"
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def print_next_steps
|
|
21
|
+
say ""
|
|
22
|
+
say "Next steps:", :green
|
|
23
|
+
say " 1. Run `bin/rails db:migrate` to add the tenant_id column."
|
|
24
|
+
say " 2. Set a global resolver in your initializer:"
|
|
25
|
+
say " RailsAuditLog.current_tenant { Current.tenant_id }"
|
|
26
|
+
say " or per-model:"
|
|
27
|
+
say " audit_log tenant: -> { Current.tenant_id }"
|
|
28
|
+
say " 3. Use AuditLogEntry.for_tenant(id) to scope queries to a tenant."
|
|
29
|
+
say ""
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/rails_audit_log.rb
CHANGED
|
@@ -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
|
#
|
|
@@ -106,6 +114,35 @@ module RailsAuditLog
|
|
|
106
114
|
yield self
|
|
107
115
|
end
|
|
108
116
|
|
|
117
|
+
# Sets or returns the global tenant resolver block. The block is called at
|
|
118
|
+
# write time and its return value is stored in the +tenant_id+ column of each
|
|
119
|
+
# {AuditLogEntry}. Override per-model with <tt>audit_log tenant: -> { ... }</tt>.
|
|
120
|
+
#
|
|
121
|
+
# @yield block called with no arguments at write time; return the tenant id
|
|
122
|
+
# @return [Proc, nil] the stored block, or +nil+ when not configured
|
|
123
|
+
# @example
|
|
124
|
+
# RailsAuditLog.current_tenant { Current.tenant_id }
|
|
125
|
+
def self.current_tenant(&block)
|
|
126
|
+
@current_tenant = block if block_given?
|
|
127
|
+
@current_tenant
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Wires {.current_tenant} to +ActsAsTenant.current_tenant&.id+ so audit
|
|
131
|
+
# entries are automatically scoped to the Acts As Tenant context.
|
|
132
|
+
# Call once in an initializer after the gem is loaded.
|
|
133
|
+
#
|
|
134
|
+
# @raise [RuntimeError] if the +acts_as_tenant+ gem is not loaded
|
|
135
|
+
# @return [void]
|
|
136
|
+
# @example
|
|
137
|
+
# RailsAuditLog.acts_as_tenant!
|
|
138
|
+
def self.acts_as_tenant!
|
|
139
|
+
unless defined?(ActsAsTenant)
|
|
140
|
+
raise "ActsAsTenant is not loaded. Add the `acts_as_tenant` gem to your Gemfile."
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
current_tenant { ActsAsTenant.current_tenant&.id }
|
|
144
|
+
end
|
|
145
|
+
|
|
109
146
|
# Sets or returns the authentication block used to gate the web dashboard.
|
|
110
147
|
# The block is evaluated in controller context, so controller helpers
|
|
111
148
|
# (e.g. +current_user+) are available directly.
|
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chuck Smith
|
|
@@ -109,12 +109,17 @@ 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
|
|
115
118
|
- lib/generators/rails_audit_log/install/templates/create_audit_log_entries.rb
|
|
116
119
|
- lib/generators/rails_audit_log/migrate_from_paper_trail/migrate_from_paper_trail_generator.rb
|
|
117
120
|
- lib/generators/rails_audit_log/migrate_from_paper_trail/templates/migrate_from_paper_trail.rb
|
|
121
|
+
- lib/generators/rails_audit_log/tenant/templates/add_tenant_id_to_audit_log_entries.rb
|
|
122
|
+
- lib/generators/rails_audit_log/tenant/tenant_generator.rb
|
|
118
123
|
- lib/rails_audit_log.rb
|
|
119
124
|
- lib/rails_audit_log/engine.rb
|
|
120
125
|
- lib/rails_audit_log/matchers.rb
|