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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +70 -0
- data/README.md +133 -2
- data/app/models/standard_audit/audit_log.rb +336 -79
- data/lib/generators/standard_audit/add_previous_checksum/add_previous_checksum_generator.rb +17 -0
- data/lib/generators/standard_audit/add_previous_checksum/templates/add_previous_checksum_to_audit_logs.rb.erb +23 -0
- data/lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb +15 -0
- data/lib/generators/standard_audit/install/templates/initializer.rb.erb +46 -4
- data/lib/standard_audit/configuration.rb +64 -1
- data/lib/standard_audit/metadata_filter.rb +155 -0
- data/lib/standard_audit/reference_preloading.rb +298 -0
- data/lib/standard_audit/rspec.rb +17 -3
- data/lib/standard_audit/sensitive_keys_dry_run.rb +177 -0
- data/lib/standard_audit/subscriber.rb +5 -3
- data/lib/standard_audit/version.rb +1 -1
- data/lib/standard_audit.rb +50 -11
- data/lib/tasks/standard_audit_tasks.rake +61 -2
- metadata +6 -1
|
@@ -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
|
-
# --
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
191
|
-
|
|
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
|
|
195
|
-
#
|
|
196
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
|
|
252
|
+
if declared.present?
|
|
253
|
+
expected = record.compute_checksum_value(previous_checksum: declared)
|
|
211
254
|
|
|
212
255
|
if record.checksum != expected
|
|
213
|
-
failures <<
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
#
|
|
272
|
-
#
|
|
273
|
-
#
|
|
274
|
-
#
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
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
|