standard_audit 0.6.0 → 0.8.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.
@@ -2,6 +2,8 @@ require "openssl"
2
2
 
3
3
  module StandardAudit
4
4
  class AuditLog < ApplicationRecord
5
+ include StandardAudit::ReferencePreloading
6
+
5
7
  self.table_name = "audit_logs"
6
8
 
7
9
  CHECKSUM_FIELDS = %w[
@@ -10,7 +12,15 @@ module StandardAudit
10
12
  user_agent session_id occurred_at
11
13
  ].freeze
12
14
 
15
+ # Callback ORDER IS THE CONTRACT here. Definition order is execution
16
+ # order, so registering the host hook list between assign_uuid and
17
+ # compute_checksum means a hook can set a CHECKSUM_FIELDS member (e.g.
18
+ # back-fill `scope`) and the row still verifies. Hosts previously had to
19
+ # register `before_create ..., prepend: true` themselves to beat
20
+ # compute_checksum — fragile ordering knowledge no host should need.
21
+ # Do not reorder these three lines.
13
22
  before_create :assign_uuid, if: -> { id.blank? }
23
+ before_create :run_before_checksum_hooks
14
24
  before_create :compute_checksum, if: -> { checksum.blank? }
15
25
  after_create_commit :emit_created_event
16
26
 
@@ -24,61 +34,35 @@ module StandardAudit
24
34
  validates :event_type, presence: true
25
35
  validates :occurred_at, presence: true
26
36
 
27
- # -- Actor assignment via GlobalID --
37
+ # -- actor / target / scope assignment via GlobalID --
38
+ #
39
+ # Reads consult the preload memo first (see ReferencePreloading), then fall
40
+ # back to a single `GlobalID::Locator.locate`. Writers populate the memo,
41
+ # since they already hold the record. If the underlying record was deleted
42
+ # the reader returns nil while the gid and type stay on the row.
28
43
 
29
44
  def actor=(record)
30
- if record.nil?
31
- self.actor_gid = nil
32
- self.actor_type = nil
33
- else
34
- self.actor_gid = record.to_global_id.to_s
35
- self.actor_type = record.class.name
36
- end
45
+ assign_reference(:actor, record)
37
46
  end
38
47
 
39
48
  def actor
40
- return nil if actor_gid.blank?
41
- GlobalID::Locator.locate(actor_gid)
42
- rescue ActiveRecord::RecordNotFound
43
- nil
49
+ read_reference(:actor)
44
50
  end
45
51
 
46
- # -- Target assignment via GlobalID --
47
-
48
52
  def target=(record)
49
- if record.nil?
50
- self.target_gid = nil
51
- self.target_type = nil
52
- else
53
- self.target_gid = record.to_global_id.to_s
54
- self.target_type = record.class.name
55
- end
53
+ assign_reference(:target, record)
56
54
  end
57
55
 
58
56
  def target
59
- return nil if target_gid.blank?
60
- GlobalID::Locator.locate(target_gid)
61
- rescue ActiveRecord::RecordNotFound
62
- nil
57
+ read_reference(:target)
63
58
  end
64
59
 
65
- # -- Scope assignment via GlobalID --
66
-
67
60
  def scope=(record)
68
- if record.nil?
69
- self.scope_gid = nil
70
- self.scope_type = nil
71
- else
72
- self.scope_gid = record.to_global_id.to_s
73
- self.scope_type = record.class.name
74
- end
61
+ assign_reference(:scope, record)
75
62
  end
76
63
 
77
64
  def scope
78
- return nil if scope_gid.blank?
79
- GlobalID::Locator.locate(scope_gid)
80
- rescue ActiveRecord::RecordNotFound
81
- nil
65
+ read_reference(:scope)
82
66
  end
83
67
 
84
68
  # -- Query scopes --
@@ -187,45 +171,210 @@ module StandardAudit
187
171
  OpenSSL::Digest::SHA256.hexdigest(canonical)
188
172
  end
189
173
 
190
- # Verifies the integrity of the audit log chain. Returns a result hash with
191
- # :valid (boolean), :verified (count), and :failures (array of hashes).
174
+ # The checksum of the most recent row the node a new row links to.
175
+ def self.chain_tip_checksum
176
+ order(created_at: :desc, id: :desc).limit(1).pick(:checksum)
177
+ end
178
+
179
+ # True when the table carries the `previous_checksum` column, i.e. the host
180
+ # has run the `standard_audit:add_previous_checksum` migration. Rows written
181
+ # without it are verified by position and parent recovery instead, so the
182
+ # gem keeps working unmigrated.
183
+ def self.chain_parent_column?
184
+ column_names.include?("previous_checksum")
185
+ end
186
+
187
+ # Verifies the integrity of the audit log. Returns a result hash with
188
+ # :valid (boolean), :verified (count), :recovered (count) and :failures
189
+ # (array of hashes carrying :id, :event_type, :created_at, :expected,
190
+ # :actual and :reason).
192
191
  #
193
192
  # Records are processed in (created_at, id) order. Records without a
194
- # checksum (pre-feature data) reset the chain — the next checksummed
195
- # record starts a new independent chain segment.
196
- def self.verify_chain(scope: nil, batch_size: 1000)
193
+ # checksum (pre-feature data) reset the walk — the next checksummed record
194
+ # starts a new independent segment.
195
+ #
196
+ # The log is a DAG, not a strict line. Concurrent writers link to whichever
197
+ # node was the tip when they read it, so two rows can share a parent and
198
+ # the sequence forks. Every row is still verified against the exact digest
199
+ # it signed, in one of two ways:
200
+ #
201
+ # * `previous_checksum` present (written by 0.8+ on a migrated host): the
202
+ # parent the writer asserts. It is covered by this row's own digest, so
203
+ # it cannot be edited to excuse a tampered row.
204
+ # * `previous_checksum` NULL (pre-0.8 rows, or an unmigrated host): the
205
+ # preceding row in the walk, and — unless `strict:` — a search back
206
+ # through the last `recovery_window` digests (and finally "no parent at
207
+ # all", for a row written against an empty table) for the one that
208
+ # actually reproduces this row's checksum. That recovers the true parent
209
+ # of a forked row without re-signing anything. It does not weaken tamper
210
+ # detection: a row whose fields were altered reproduces no candidate's
211
+ # digest, so it still fails.
212
+ #
213
+ # A row whose parent digest is absent from the log is reported with
214
+ # `reason: :missing_parent` — a row was removed. Two exemptions:
215
+ #
216
+ # * `scope:` — the log is global, so a scoped row's parent usually
217
+ # belongs to another scope and is absent for an innocent reason.
218
+ # * a pruned start. If the walk *opens* on a row whose parent is already
219
+ # gone, the log has had its start removed (retention cleanup). That
220
+ # parent digest is then exempt wherever else it appears, because a
221
+ # pruned row can have several children — which is exactly what a
222
+ # concurrent append leaves behind. Removing a row from the middle is
223
+ # still reported: its digest is not the one the walk opened on.
224
+ #
225
+ # `standard_audit:cleanup` prunes by `occurred_at` while the walk orders by
226
+ # `created_at`. Rows whose two timestamps disagree (a backdated
227
+ # `occurred_at`) can leave a hole rather than a prefix, and a hole is
228
+ # reported — truthfully, since rows really are missing.
229
+ def self.verify_chain(scope: nil, batch_size: 1000, recovery_window: 256, strict: false)
197
230
  relation = scope ? where(scope_gid: scope.to_global_id.to_s) : all
231
+ check_parents = scope.nil?
232
+ declared_parents = chain_parent_column?
198
233
 
199
234
  previous_checksum = nil
200
235
  verified = 0
236
+ recovered = 0
201
237
  failures = []
238
+ window = []
239
+ first_row = true
240
+ pruned_parents = []
241
+
242
+ each_in_chain_order(relation, batch_size: batch_size) do |record|
243
+ if record.checksum.blank?
244
+ previous_checksum = nil
245
+ window.clear
246
+ next
247
+ end
202
248
 
203
- relation.in_batches(of: batch_size) do |batch|
204
- batch.order(created_at: :asc, id: :asc).each do |record|
205
- if record.checksum.blank?
206
- previous_checksum = nil
207
- next
208
- end
249
+ verified += 1
250
+ declared = record.previous_checksum if declared_parents
209
251
 
210
- expected = record.compute_checksum_value(previous_checksum: previous_checksum)
252
+ if declared.present?
253
+ expected = record.compute_checksum_value(previous_checksum: declared)
211
254
 
212
255
  if record.checksum != expected
213
- failures << {
214
- id: record.id,
215
- event_type: record.event_type,
216
- created_at: record.created_at,
217
- expected: expected,
218
- actual: record.checksum
219
- }
256
+ failures << chain_failure(record, expected: expected, reason: :digest_mismatch)
257
+ elsif check_parents && !parent_present?(declared, window, relation)
258
+ if first_row
259
+ # The walk opens on a row whose parent is already gone, so the
260
+ # log has had its start removed — retention pruning, typically.
261
+ # That parent is unknowable, and it can have several children
262
+ # (which is what a concurrent append leaves behind), so the
263
+ # exemption is remembered per digest rather than for one row.
264
+ pruned_parents << declared
265
+ elsif !pruned_parents.include?(declared)
266
+ failures << chain_failure(record, expected: expected, reason: :missing_parent)
267
+ end
220
268
  end
269
+ else
270
+ expected = record.compute_checksum_value(previous_checksum: previous_checksum)
221
271
 
222
- verified += 1
223
- previous_checksum = record.checksum
272
+ if record.checksum == expected
273
+ # Links to the row before it, as a linear chain does.
274
+ elsif !strict && recover_parent(record, window)
275
+ recovered += 1
276
+ else
277
+ failures << chain_failure(record, expected: expected, reason: :digest_mismatch)
278
+ end
279
+ end
280
+
281
+ previous_checksum = record.checksum
282
+ first_row = false
283
+ window << record.checksum
284
+ window.shift if window.size > recovery_window
285
+ end
286
+
287
+ { valid: failures.empty?, verified: verified, recovered: recovered, failures: failures }
288
+ end
289
+
290
+ # Records, for every row that does not already carry one, the parent digest
291
+ # it was actually signed against — recovering it by search where a
292
+ # concurrent append forked the chain. Returns
293
+ # `{ relinked:, unresolved:, skipped: }`.
294
+ #
295
+ # This NEVER rewrites a `checksum`. It writes only the previously-empty
296
+ # `previous_checksum` column, so it adds no attestation the rows did not
297
+ # already carry: a parent is recorded only when it reproduces the digest
298
+ # the row has held since it was written. Rows whose parent cannot be
299
+ # reproduced are left untouched and counted in :unresolved — they are the
300
+ # rows verification should keep reporting.
301
+ def self.relink_checksums!(batch_size: 1000, recovery_window: 256)
302
+ return { relinked: 0, unresolved: 0, skipped: 0 } unless chain_parent_column?
303
+
304
+ previous_checksum = nil
305
+ relinked = 0
306
+ unresolved = 0
307
+ skipped = 0
308
+ window = []
309
+
310
+ each_in_chain_order(all, batch_size: batch_size) do |record|
311
+ if record.checksum.blank?
312
+ previous_checksum = nil
313
+ window.clear
314
+ next
315
+ end
316
+
317
+ found = resolve_parent(record, previous_checksum, window) if record.previous_checksum.blank?
318
+
319
+ if record.previous_checksum.present?
320
+ skipped += 1
321
+ elsif found.nil?
322
+ unresolved += 1
323
+ elsif found.first.present?
324
+ record.update_columns(previous_checksum: found.first)
325
+ relinked += 1
326
+ else
327
+ # A genuine segment root. NULL already says so; nothing to write.
328
+ skipped += 1
224
329
  end
330
+
331
+ previous_checksum = record.checksum
332
+ window << record.checksum
333
+ window.shift if window.size > recovery_window
225
334
  end
226
335
 
227
- { valid: failures.empty?, verified: verified, failures: failures }
336
+ { relinked: relinked, unresolved: unresolved, skipped: skipped }
337
+ end
338
+
339
+ def self.chain_failure(record, expected:, reason:)
340
+ {
341
+ id: record.id,
342
+ event_type: record.event_type,
343
+ created_at: record.created_at,
344
+ expected: expected,
345
+ actual: record.checksum,
346
+ reason: reason
347
+ }
228
348
  end
349
+ private_class_method :chain_failure
350
+
351
+ # Searches `window` (most recent first, then "no parent at all") for the
352
+ # digest that reproduces the record's stored checksum. Returns a one-element
353
+ # array holding the parent — which may itself be nil, for a row written
354
+ # against an empty table — or nil when nothing reproduces the digest.
355
+ #
356
+ # SHA-256 preimage resistance is what makes this safe: a row whose fields
357
+ # were altered reproduces no candidate's digest, so it is still reported.
358
+ def self.recover_parent(record, window)
359
+ window.reverse_each do |candidate|
360
+ return [candidate] if record.checksum == record.compute_checksum_value(previous_checksum: candidate)
361
+ end
362
+
363
+ [nil] if record.checksum == record.compute_checksum_value(previous_checksum: nil)
364
+ end
365
+ private_class_method :recover_parent
366
+
367
+ def self.resolve_parent(record, previous_checksum, window)
368
+ return [previous_checksum] if record.checksum == record.compute_checksum_value(previous_checksum: previous_checksum)
369
+
370
+ recover_parent(record, window)
371
+ end
372
+ private_class_method :resolve_parent
373
+
374
+ def self.parent_present?(digest, window, relation)
375
+ window.include?(digest) || relation.exists?(checksum: digest)
376
+ end
377
+ private_class_method :parent_present?
229
378
 
230
379
  # Backfills checksums for records that don't have them (e.g. pre-existing
231
380
  # records before the checksum feature was added).
@@ -233,27 +382,67 @@ module StandardAudit
233
382
  previous_checksum = nil
234
383
  count = 0
235
384
 
236
- in_batches(of: batch_size) do |batch|
237
- batch.order(created_at: :asc, id: :asc).each do |record|
238
- if record.checksum.present?
239
- previous_checksum = record.checksum
240
- next
241
- end
385
+ each_in_chain_order(all, batch_size: batch_size) do |record|
386
+ if record.checksum.present?
387
+ previous_checksum = record.checksum
388
+ next
389
+ end
242
390
 
243
- new_checksum = compute_checksum_value(
244
- record.attributes.slice(*CHECKSUM_FIELDS),
245
- previous_checksum: previous_checksum
246
- )
247
- record.update_columns(checksum: new_checksum)
391
+ new_checksum = compute_checksum_value(
392
+ record.attributes.slice(*CHECKSUM_FIELDS),
393
+ previous_checksum: previous_checksum
394
+ )
395
+ columns = { checksum: new_checksum }
396
+ columns[:previous_checksum] = previous_checksum if chain_parent_column?
397
+ record.update_columns(columns)
248
398
 
249
- previous_checksum = new_checksum
250
- count += 1
251
- end
399
+ previous_checksum = new_checksum
400
+ count += 1
252
401
  end
253
402
 
254
403
  count
255
404
  end
256
405
 
406
+ # Yields every record of `relation` in true (created_at, id) order, loading
407
+ # at most `batch_size` rows at a time via a keyset cursor.
408
+ #
409
+ # `in_batches` cannot do this, and used to be used here: it paginates by
410
+ # PRIMARY KEY range and applies any ordering only *within* each batch, so
411
+ # the global sequence it yields is a concatenation of id-ranges, each
412
+ # internally time-sorted. With the UUIDv7 ids `assign_uuid` generates that
413
+ # usually coincides with insertion order, which is why it went unnoticed —
414
+ # but it is wrong for any host that assigns ids differently, backfills rows
415
+ # with an explicit created_at, or has clock skew between writers. The chain
416
+ # is an ordering claim, so a verifier that walks a different order than the
417
+ # one it documents cannot be trusted to prove or disprove anything about it.
418
+ #
419
+ # The cursor predicate assumes created_at is NOT NULL, which the install
420
+ # migration's `t.timestamps` guarantees. A NULL created_at would compare as
421
+ # NULL and end the walk early rather than loop forever.
422
+ def self.each_in_chain_order(relation, batch_size: 1000)
423
+ cursor = nil
424
+
425
+ loop do
426
+ page = relation.reorder(created_at: :asc, id: :asc).limit(batch_size)
427
+
428
+ if cursor
429
+ page = page.where(
430
+ "created_at > :created_at OR (created_at = :created_at AND id > :id)",
431
+ created_at: cursor.first, id: cursor.last
432
+ )
433
+ end
434
+
435
+ records = page.to_a
436
+ break if records.empty?
437
+
438
+ records.each { |record| yield record }
439
+ break if records.size < batch_size
440
+
441
+ cursor = [records.last.created_at, records.last.id]
442
+ end
443
+ end
444
+ private_class_method :each_in_chain_order
445
+
257
446
  private
258
447
 
259
448
  def emit_created_event
@@ -268,17 +457,85 @@ module StandardAudit
268
457
  Rails.logger.warn("[StandardAudit] Failed to emit event: #{e.class}: #{e.message}")
269
458
  end
270
459
 
271
- # Fetches the most recent record's checksum and chains the new record to it.
272
- # Note: concurrent inserts can read the same "previous" record, forking
273
- # the chain. Use database-level advisory locks if you need serializable
274
- # chain integrity under concurrent writes.
460
+ # Links the new record to whichever row was the chain tip when this writer
461
+ # read it, and RECORDS WHICH ONE THAT WAS.
462
+ #
463
+ # The tip read is deliberately unlocked. Two concurrent transactions cannot
464
+ # see each other's uncommitted rows, so both may read the same tip and the
465
+ # sequence forks — that is not an error, it is what a multi-process writer
466
+ # does, and it is why 67% of one production log failed verification while
467
+ # the rows themselves were untampered (fundbright/delivery-ops#433). The
468
+ # alternative is a lock held until the *enclosing business transaction*
469
+ # commits (a row is invisible to other writers until then, so releasing
470
+ # earlier reopens the race), which would serialise every audited request in
471
+ # the estate behind one mutex. Recording the parent instead makes the fork
472
+ # verifiable rather than preventing it.
473
+ #
474
+ # `previous_checksum` needs no protection of its own: it is an input to
475
+ # this row's own digest, so editing it invalidates the row.
275
476
  def compute_checksum
276
- previous = self.class.order(created_at: :desc, id: :desc).limit(1).pick(:checksum)
477
+ previous = self.class.chain_tip_checksum
478
+ self.previous_checksum = previous if self.class.chain_parent_column?
277
479
  self.checksum = compute_checksum_value(previous_checksum: previous)
278
480
  end
279
481
 
280
482
  def assign_uuid
281
483
  self.id = SecureRandom.uuid_v7
282
484
  end
485
+
486
+ # Runs config.before_checksum_hooks in registration order. Each hook is
487
+ # rescued individually: an audit write must never fail because a host's
488
+ # derived-column logic did. A hook that raises is rolled back to the
489
+ # attributes it started from and skipped; the remaining hooks still run.
490
+ def run_before_checksum_hooks
491
+ hooks = StandardAudit.config.before_checksum_hooks
492
+ return if hooks.blank?
493
+
494
+ # Only relevant when a checksum was supplied explicitly (the normal path
495
+ # has none yet, and compute_checksum runs next).
496
+ checksummed_before = checksum.present? ? attributes.slice(*CHECKSUM_FIELDS) : nil
497
+
498
+ hooks.each { |hook| run_before_checksum_hook(hook) }
499
+
500
+ # A caller-supplied checksum stops describing the row the moment a hook
501
+ # changes a checksummed field. Dropping it lets compute_checksum
502
+ # re-derive one, rather than persisting a row that fails verify_chain
503
+ # immediately. Hooks still run for such rows, because most derived
504
+ # columns (actor_role and friends) are not checksummed at all.
505
+ self.checksum = nil if checksummed_before && attributes.slice(*CHECKSUM_FIELDS) != checksummed_before
506
+ end
507
+
508
+ def run_before_checksum_hook(hook)
509
+ snapshot = attributes.deep_dup
510
+
511
+ begin
512
+ case hook
513
+ when Symbol, String then send(hook)
514
+ else hook.call(self)
515
+ end
516
+ rescue StandardError => e
517
+ # Roll the record back to where the hook found it. Without this a hook
518
+ # that assigns scope_gid and then fails a later lookup leaves a
519
+ # half-applied row that the following callbacks happily checksum and
520
+ # persist — so the hook would not actually be "skipped".
521
+ restore_attributes_from(snapshot)
522
+ Rails.logger.warn("[StandardAudit] before_checksum hook failed: #{e.class}: #{e.message}")
523
+ Rails.error.report(e, handled: true, context: { audit_event: event_type }) if Rails.respond_to?(:error)
524
+ nil
525
+ end
526
+ end
527
+
528
+ def restore_attributes_from(snapshot)
529
+ snapshot.each do |name, value|
530
+ write_attribute(name, value) unless read_attribute(name) == value
531
+ end
532
+
533
+ # The reference memo may hold a record the hook assigned; drop it so the
534
+ # restored gid columns are the source of truth again.
535
+ @preloaded_references = nil
536
+ rescue StandardError => e
537
+ Rails.logger.warn("[StandardAudit] could not roll back a failed before_checksum hook: #{e.class}: #{e.message}")
538
+ nil
539
+ end
283
540
  end
284
541
  end
@@ -0,0 +1,17 @@
1
+ module StandardAudit
2
+ module Generators
3
+ class AddPreviousChecksumGenerator < Rails::Generators::Base
4
+ include Rails::Generators::Migration
5
+ source_root File.expand_path("templates", __dir__)
6
+
7
+ def self.next_migration_number(dirname)
8
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
9
+ end
10
+
11
+ def copy_migration
12
+ migration_template "add_previous_checksum_to_audit_logs.rb.erb",
13
+ "db/migrate/add_previous_checksum_to_audit_logs.rb"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,23 @@
1
+ class AddPreviousChecksumToAuditLogs < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ # On PostgreSQL with a large audit_logs table, add
4
+ # `disable_ddl_transaction!` above and pass `algorithm: :concurrently` to
5
+ # the add_index calls — StrongMigrations will ask for it.
6
+ #
7
+ # Nullable and index-only: no data is rewritten. Existing rows keep a NULL
8
+ # parent and are verified by position and parent recovery, exactly as they
9
+ # are today. Run `rake standard_audit:relink_checksums` afterwards to
10
+ # record the parent each existing row was actually signed against.
11
+ add_column :audit_logs, :previous_checksum, :string, limit: 64
12
+ # Not used by the gem itself: verification reads previous_checksum off rows
13
+ # it has already loaded. It is here for forensics — "what forked off this
14
+ # row?" — which is the question you ask once verification flags something.
15
+ add_index :audit_logs, :previous_checksum
16
+ # verify_chain / backfill_checksums! walk the table with a keyset cursor
17
+ # ordered by (created_at, id).
18
+ add_index :audit_logs, [:created_at, :id] unless index_exists?(:audit_logs, [:created_at, :id])
19
+ # verify_chain looks a declared parent up by digest when it falls outside
20
+ # the in-memory recovery window — the path that reports a removed row.
21
+ add_index :audit_logs, :checksum unless index_exists?(:audit_logs, :checksum)
22
+ end
23
+ end
@@ -17,6 +17,10 @@ class CreateAuditLogs < ActiveRecord::Migration[<%= ActiveRecord::Migration.curr
17
17
  t.jsonb :metadata, default: {}
18
18
  t.datetime :occurred_at, null: false
19
19
  t.string :checksum, limit: 64
20
+ # The digest of the row this one was chained to. Concurrent writers can
21
+ # share a parent, so the log is a DAG rather than a strict line; storing
22
+ # the parent is what keeps every row verifiable when it forks.
23
+ t.string :previous_checksum, limit: 64
20
24
  t.timestamps
21
25
  end
22
26
 
@@ -27,6 +31,17 @@ class CreateAuditLogs < ActiveRecord::Migration[<%= ActiveRecord::Migration.curr
27
31
  add_index :audit_logs, :request_id
28
32
  add_index :audit_logs, [:occurred_at, :created_at]
29
33
  add_index :audit_logs, :created_at
34
+ # verify_chain / backfill_checksums! walk the table with a keyset cursor
35
+ # ordered by (created_at, id); the composite index makes each page an index
36
+ # range scan rather than a scan plus filter.
37
+ add_index :audit_logs, [:created_at, :id]
38
+ # verify_chain looks a declared parent up by digest when it falls outside
39
+ # the in-memory recovery window — the path that reports a removed row.
40
+ add_index :audit_logs, :checksum
41
+ # Not used by the gem itself: verification reads previous_checksum off rows
42
+ # it has already loaded. It is here for forensics — "what forked off this
43
+ # row?" — which is the question you ask once verification flags something.
44
+ add_index :audit_logs, :previous_checksum
30
45
  add_index :audit_logs, :session_id
31
46
  # GIN index requires PostgreSQL; remove if using another database
32
47
  add_index :audit_logs, :metadata, using: :gin
@@ -1,4 +1,8 @@
1
- StandardAudit.configure do |config|
1
+ # `baseline: true` remembers this block so `StandardAudit.reset_configuration!`
2
+ # replays it. Required if you `require "standard_audit/rspec"` in your suite:
3
+ # without it, every example resets to gem defaults and loses the configuration
4
+ # below — including any `before_checksum` hooks, which are behaviour, not data.
5
+ StandardAudit.configure(baseline: true) do |config|
2
6
  # Subscribe to ActiveSupport::Notifications / Rails.event patterns.
3
7
  # Each gem documents its own event namespace; subscribe to whichever
4
8
  # patterns you want audited:
@@ -27,10 +31,35 @@ StandardAudit.configure do |config|
27
31
  # config.current_user_agent_resolver = -> { Current.user_agent }
28
32
  # config.current_session_id_resolver = -> { Current.session_id }
29
33
 
30
- # Keys to strip from metadata (defaults include: password, password_confirmation,
31
- # token, secret, api_key, access_token, refresh_token, private_key,
32
- # certificate_chain, ssn, credit_card, authorization)
34
+ # -- Metadata redaction ----------------------------------------------------
35
+ #
36
+ # Keys to strip from metadata. Matching is EXACT on the key name (defaults
37
+ # include: password, password_confirmation, token, secret, api_key,
38
+ # access_token, refresh_token, private_key, certificate_chain, ssn,
39
+ # credit_card, authorization).
33
40
  # config.sensitive_keys += %i[my_custom_secret]
41
+ #
42
+ # Regexps matched against every key name, in addition to the exact list.
43
+ # This is the supported way to catch a family of keys — e.g. Stripe's
44
+ # `client_secret`, which does not equal the default `:secret`:
45
+ # config.sensitive_key_patterns = [/secret/i]
46
+ #
47
+ # There is deliberately no "substring" matching mode. Against the default
48
+ # key list it would strip real audit content: input_tokens/output_tokens,
49
+ # token_digest, password_reset_sent_at, authorization_endpoint, onepassword.
50
+ # Audit rows are append-only, so that cannot be undone. Check any rule
51
+ # against your own data first:
52
+ #
53
+ # bin/rails "standard_audit:sensitive_keys:dry_run[secret]"
54
+ #
55
+ # Keys (exact names or Regexps) that are never redacted, so a broad pattern
56
+ # can keep the handful of real audit keys it would otherwise swallow:
57
+ # config.sensitive_key_exceptions = %i[input_tokens output_tokens]
58
+ #
59
+ # Descend into nested Hashes when redacting. Off by default because it
60
+ # changes what gets written; `metadata: { stripe: { client_secret: ... } }`
61
+ # is NOT redacted unless you turn this on. Run the dry run first.
62
+ # config.filter_nested_metadata = true
34
63
 
35
64
  # Run audit log creation in background job
36
65
  # config.async = false
@@ -44,4 +73,17 @@ StandardAudit.configure do |config|
44
73
 
45
74
  # Data retention (schedule StandardAudit::CleanupJob to enforce)
46
75
  # config.retention_days = nil
76
+
77
+ # -- Write-time hooks -------------------------------------------------------
78
+ #
79
+ # Run between the UUID assignment and the checksum computation, so a hook MAY
80
+ # set a checksummed column (scope_gid, metadata, ...) and the row still passes
81
+ # `AuditLog.verify_chain`. No `prepend: true` needed.
82
+ #
83
+ # Each hook is rescued individually — a failing hook is logged and skipped and
84
+ # never fails the audit write. Batched writes (StandardAudit.batch, which uses
85
+ # insert_all!) never instantiate a model, so hooks do not run there.
86
+ #
87
+ # config.before_checksum { |log| log.scope = MyApp.derive_scope(log) }
88
+ # config.before_checksum :backfill_scope # an AuditLog instance method
47
89
  end