standard_audit 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ab5fcec99d13f0796742d817c1dd42e45c0e8518f8967033a67194c3179ae6e
4
- data.tar.gz: b0f6272a600a665615cc533b439ad2b654d54c025bd7bc2b378b5c2eac7e850d
3
+ metadata.gz: 55028cd60be7b29a81c8bd0eccb5cbccadfba4f7691f0d6576214de4367e53c9
4
+ data.tar.gz: efcf7f59f891570b8076e870f64d632de6d707ed9a4aa1f4b6e35aa7116199d1
5
5
  SHA512:
6
- metadata.gz: 1ab1f514a13f1b03501cfdf18b3c2afcf8ff214a36992edb3e00f052384826c56bf09a35c4d7c4b8f92a4f28513cd8fe5534d946622112bb27bb6f8bfd8826c7
7
- data.tar.gz: 7f9fc2acdb292b06f72b2323ac0c5c780348da8d1b91e9850ae73a61eba2361fb21b3aa431888de9d0003dc2934c0ba1b2a9a060bd4bd3b2a6a1bfb71eea5680
6
+ metadata.gz: d1d0c3c3807faf64dc9e21c897da2e3317a6fee1ea202aae6b07a948f416bbb8d35c03f8977a3def8bdf5a2bca9944941e3fe1b3c860d7b2b3aa942a8f048e16
7
+ data.tar.gz: 16278c66d30fe401947d8748e6b675a9dd5bc49dac1d0183635faf27df8e1b9e5a5f2d933aa8a606c1b66a01894dc030088a3813b7ca8eb2aee68ed7bd811e0d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-07-30
11
+
12
+ ### Added
13
+
14
+ - **`previous_checksum` — every row now records the row it was appended to.** New nullable column; run `rails generate standard_audit:add_previous_checksum && rails db:migrate` to add it. The install migration includes it for new hosts, along with a composite `(created_at, id)` index for the verification walk.
15
+ - **`rake standard_audit:relink_checksums` / `AuditLog.relink_checksums!`** — records, for every row that has no `previous_checksum`, the parent digest it was *actually* signed against, recovering it by search where a concurrent append forked the log. It **never rewrites a `checksum`**: a parent is written only when it reproduces the digest the row has held since it was written, so it adds no attestation the rows did not already carry. Rows it cannot resolve are counted as `unresolved`, left untouched, and keep failing verification — which is the point. This is the repair path for a log broken by the defect below; `backfill_checksums!` is **not** (it re-signs rows from their current contents, so a green result attests only that a script ran).
16
+ - `AuditLog.chain_tip_checksum` and `AuditLog.chain_parent_column?`.
17
+
18
+ ### Fixed
19
+
20
+ - **The chained checksum was not concurrency-safe: `verify_chain` failed for 3,762 of 5,577 rows (67%) on one production log, continuously, while every row was untampered.** `compute_checksum` reads the chain tip with an unsynchronised query from `before_create`. Two transactions cannot see each other's uncommitted rows, so both sign against the same predecessor and the sequence forks; verification, which assumed a strict line, reported all but one of them as tampering. Any host with concurrent audit writes — two Puma threads, a web request and a job — has the same broken log. (`fundbright/delivery-ops#433`)
21
+
22
+ **The log is now treated as a DAG rather than a strict line, and each row records its parent.** A row is verified against the exact digest it signed, whether or not that is its predecessor in the walk, so a fork is verifiable instead of fatal. The tip read stays unlocked deliberately: forcing a strict line means holding a lock until the *enclosing business transaction* commits — a row is invisible to other writers until then, so releasing earlier reopens the race — which would serialise every audited request across the estate behind one mutex, on the hot path of every request. `previous_checksum` needs no protection of its own; it is an input to the row's own digest, so editing it invalidates the row.
23
+
24
+ What this deliberately stops claiming: a single total order, and detection of an *inserted* row. A concurrent append and an inserted row are indistinguishable, so a design that tolerates the first cannot detect the second. The strict-line reading did not detect insertions either — it reported every concurrent append as tampering, which on the measured log meant 67% red and any real signal lost in it.
25
+
26
+ **Rows written before this version verify unchanged, with no migration.** With no `previous_checksum`, verification falls back to the preceding row in the walk and then searches back through the last `recovery_window` (default 256) digests for the one that reproduces the row's checksum — recovering the true parent of a forked row without re-signing anything, and reporting how many needed it in the new `recovered:` count. Tamper detection is not weakened: a row whose fields were altered reproduces no candidate's digest. `verify_chain(strict: true)` skips the search and reports every fork as a failure. A host that never runs the migration keeps working on this path indefinitely.
27
+
28
+ - **`verify_chain` never noticed a row being removed from the middle of the log.** Failures now carry `reason:` — `:digest_mismatch`, or `:missing_parent` when the row a record was appended to is no longer present. Retention pruning does not trip it: if the walk opens on a row whose parent is already gone, that parent digest is exempt wherever else it appears — a pruned row can have several children, which is what a concurrent append leaves behind. A removal from the *middle* is still reported. Every row is exempt under `scope:`, because the log is global and a scoped row's parent usually belongs to another scope.
29
+
30
+ - **`verify_chain` did not walk the order it documents, and neither did `backfill_checksums!`.** Both claimed a `(created_at, id)` walk and both used `in_batches`, which paginates by **primary key** range and applies the sort only *within* each batch — so the global sequence was a concatenation of id-ranges, each internally time-sorted. Both now walk with a keyset cursor ordered by `(created_at, id)`, which is the documented order and still loads only `batch_size` rows at a time.
31
+
32
+ With the UUIDv7 ids `assign_uuid` generates, id order usually coincides with insertion order, which is why this stayed latent — but it is wrong for any host that assigns ids differently, backfills rows with an explicit `created_at`, or has clock skew between writers. It matters more than a latent ordering bug normally would: the chain is an *ordering* claim, so a verifier that walks a different order than the one it documents cannot be trusted to prove or disprove anything about chain integrity, including the concurrent-append defect above. `backfill_checksums!` had the same defect, and there it silently *writes* the wrong chain rather than misreading one.
33
+
10
34
  ## [0.7.0] - 2026-07-30
11
35
 
12
36
  ### Added
data/README.md CHANGED
@@ -406,9 +406,92 @@ which is **still HTTP 200** — it surfaces the advisory in the readiness JSON
406
406
  without failing the probe or blocking a deploy. The check is duck-typed and has
407
407
  no hard dependency on `standard_health`.
408
408
 
409
+ ## Chain Integrity
410
+
411
+ Every row carries a SHA-256 `checksum` over its own fields **and the digest of
412
+ the row it was appended to**, so editing a row in place invalidates it. Each row
413
+ also records that parent explicitly in `previous_checksum`.
414
+
415
+ ### The log is a DAG, not a strict line
416
+
417
+ A writer reads the current tip and links to it. That read is deliberately
418
+ unlocked, so two concurrent transactions — two Puma threads, a web request and a
419
+ job — can read the same tip and the sequence forks. **That is normal for a
420
+ multi-process writer, and it is not an integrity failure.** Forcing a strict
421
+ line would mean holding a lock until the enclosing business transaction commits
422
+ (a row is invisible to other writers until then, so releasing earlier reopens
423
+ the race), which serialises every audited request behind one mutex.
424
+
425
+ Storing the parent is what keeps a forked log verifiable: every row is checked
426
+ against the exact digest it signed, whether or not it is the walk's predecessor.
427
+ `previous_checksum` needs no protection of its own — it is an input to the row's
428
+ own digest, so editing it invalidates the row.
429
+
430
+ ```ruby
431
+ result = StandardAudit::AuditLog.verify_chain
432
+ # => { valid: true, verified: 5577, recovered: 0, failures: [] }
433
+ ```
434
+
435
+ - `failures` carries `reason: :digest_mismatch` (the row's fields no longer
436
+ produce its digest) or `reason: :missing_parent` (the row it was appended to
437
+ is no longer in the log). Retention pruning does not trip `:missing_parent`:
438
+ if the walk opens on a row whose parent is already gone, that parent digest
439
+ is exempt wherever else it appears — a pruned row can have several children.
440
+ A removal from the *middle* is still reported. One caveat:
441
+ `standard_audit:cleanup` prunes by `occurred_at` while the walk orders by
442
+ `created_at`, so rows whose two timestamps disagree can leave a hole rather
443
+ than a pruned start, and a hole is reported — truthfully, since rows really
444
+ are missing.
445
+ - `recovered` counts rows with no `previous_checksum` whose parent had to be
446
+ found by searching back through recent digests — see below.
447
+ - `verify_chain(scope: org)` skips the missing-parent check, because the log is
448
+ global and a scoped row's parent usually belongs to another scope.
449
+
450
+ What this deliberately does **not** claim: that the log is in a single total
451
+ order, or that no row was inserted. A concurrent append and an inserted row look
452
+ alike, so a design that tolerates the first cannot detect the second. The
453
+ previous strict-line reading did not actually detect insertions either — it
454
+ reported every concurrent append as tampering, which on one production log meant
455
+ 67% of rows red and any real signal lost in the noise.
456
+
457
+ ### Rows written before 0.8.0
458
+
459
+ They have no `previous_checksum`. Verification falls back to the preceding row
460
+ in the walk and, failing that, searches back through the last `recovery_window`
461
+ (default 256) digests for the one that reproduces the row's checksum — which
462
+ recovers the true parent of a row that forked, **without re-signing anything**.
463
+ This does not weaken tamper detection: a row whose fields were altered
464
+ reproduces no candidate's digest. Pass `strict: true` to skip the search and see
465
+ every fork as a failure.
466
+
467
+ To make that permanent, add the column and record what each row was actually
468
+ signed against:
469
+
470
+ ```bash
471
+ rails generate standard_audit:add_previous_checksum
472
+ rails db:migrate
473
+ rake standard_audit:relink_checksums
474
+ ```
475
+
476
+ `relink_checksums` never rewrites a `checksum`. It fills in the previously-empty
477
+ `previous_checksum` only when that value reproduces the digest the row has held
478
+ since it was written, so it adds no attestation the rows did not already carry.
479
+ Rows it cannot resolve are reported as `unresolved` and keep failing
480
+ verification — which is the point.
481
+
482
+ **Do not run `backfill_checksums!` to make a red `verify_chain` go green.** It
483
+ re-signs rows from their current contents, so it attests only that a script ran.
484
+ It is for rows that never had a checksum at all (pre-feature data).
485
+
409
486
  ## Rake Tasks
410
487
 
411
488
  ```bash
489
+ # Verify chain integrity (exits non-zero on failures)
490
+ rake standard_audit:verify
491
+
492
+ # Record the parent digest each existing row was signed against
493
+ rake standard_audit:relink_checksums
494
+
412
495
  # Delete logs older than N days (default: retention_days config or 90)
413
496
  rake standard_audit:cleanup[180]
414
497
 
@@ -171,45 +171,210 @@ module StandardAudit
171
171
  OpenSSL::Digest::SHA256.hexdigest(canonical)
172
172
  end
173
173
 
174
- # Verifies the integrity of the audit log chain. Returns a result hash with
175
- # :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).
176
191
  #
177
192
  # Records are processed in (created_at, id) order. Records without a
178
- # checksum (pre-feature data) reset the chain — the next checksummed
179
- # record starts a new independent chain segment.
180
- 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)
181
230
  relation = scope ? where(scope_gid: scope.to_global_id.to_s) : all
231
+ check_parents = scope.nil?
232
+ declared_parents = chain_parent_column?
182
233
 
183
234
  previous_checksum = nil
184
235
  verified = 0
236
+ recovered = 0
185
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
186
248
 
187
- relation.in_batches(of: batch_size) do |batch|
188
- batch.order(created_at: :asc, id: :asc).each do |record|
189
- if record.checksum.blank?
190
- previous_checksum = nil
191
- next
192
- end
249
+ verified += 1
250
+ declared = record.previous_checksum if declared_parents
193
251
 
194
- expected = record.compute_checksum_value(previous_checksum: previous_checksum)
252
+ if declared.present?
253
+ expected = record.compute_checksum_value(previous_checksum: declared)
195
254
 
196
255
  if record.checksum != expected
197
- failures << {
198
- id: record.id,
199
- event_type: record.event_type,
200
- created_at: record.created_at,
201
- expected: expected,
202
- actual: record.checksum
203
- }
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
204
268
  end
269
+ else
270
+ expected = record.compute_checksum_value(previous_checksum: previous_checksum)
205
271
 
206
- verified += 1
207
- 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
208
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
329
+ end
330
+
331
+ previous_checksum = record.checksum
332
+ window << record.checksum
333
+ window.shift if window.size > recovery_window
334
+ end
335
+
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
+ }
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)
209
361
  end
210
362
 
211
- { valid: failures.empty?, verified: verified, failures: failures }
363
+ [nil] if record.checksum == record.compute_checksum_value(previous_checksum: nil)
212
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?
213
378
 
214
379
  # Backfills checksums for records that don't have them (e.g. pre-existing
215
380
  # records before the checksum feature was added).
@@ -217,27 +382,67 @@ module StandardAudit
217
382
  previous_checksum = nil
218
383
  count = 0
219
384
 
220
- in_batches(of: batch_size) do |batch|
221
- batch.order(created_at: :asc, id: :asc).each do |record|
222
- if record.checksum.present?
223
- previous_checksum = record.checksum
224
- next
225
- 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
226
390
 
227
- new_checksum = compute_checksum_value(
228
- record.attributes.slice(*CHECKSUM_FIELDS),
229
- previous_checksum: previous_checksum
230
- )
231
- 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)
232
398
 
233
- previous_checksum = new_checksum
234
- count += 1
235
- end
399
+ previous_checksum = new_checksum
400
+ count += 1
236
401
  end
237
402
 
238
403
  count
239
404
  end
240
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
+
241
446
  private
242
447
 
243
448
  def emit_created_event
@@ -252,12 +457,25 @@ module StandardAudit
252
457
  Rails.logger.warn("[StandardAudit] Failed to emit event: #{e.class}: #{e.message}")
253
458
  end
254
459
 
255
- # Fetches the most recent record's checksum and chains the new record to it.
256
- # Note: concurrent inserts can read the same "previous" record, forking
257
- # the chain. Use database-level advisory locks if you need serializable
258
- # 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.
259
476
  def compute_checksum
260
- 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?
261
479
  self.checksum = compute_checksum_value(previous_checksum: previous)
262
480
  end
263
481
 
@@ -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,3 +1,3 @@
1
1
  module StandardAudit
2
- VERSION = "0.7.0"
2
+ VERSION = "0.8.0"
3
3
  end
@@ -153,10 +153,8 @@ module StandardAudit
153
153
 
154
154
  def flush_batch(buffer)
155
155
  now = Time.current
156
- previous_checksum = StandardAudit::AuditLog
157
- .order(created_at: :desc, id: :desc)
158
- .limit(1)
159
- .pick(:checksum)
156
+ records_parent = StandardAudit::AuditLog.chain_parent_column?
157
+ previous_checksum = StandardAudit::AuditLog.chain_tip_checksum
160
158
 
161
159
  # Generate sorted UUIDs to ensure batch ordering matches id ordering.
162
160
  # UUIDv7 within the same millisecond can have non-monotonic lower bits;
@@ -175,6 +173,7 @@ module StandardAudit
175
173
  row.stringify_keys,
176
174
  previous_checksum: previous_checksum
177
175
  )
176
+ row[:previous_checksum] = previous_checksum if records_parent
178
177
  row[:checksum] = checksum
179
178
  previous_checksum = checksum
180
179
  row
@@ -63,16 +63,34 @@ namespace :standard_audit do
63
63
  puts "============================="
64
64
  puts "Records verified: #{result[:verified]}"
65
65
  puts "Chain valid: #{result[:valid]}"
66
+ puts "Forked links recovered: #{result[:recovered]}"
66
67
 
67
68
  if result[:failures].any?
68
- puts "\nTampered records detected: #{result[:failures].size}"
69
+ puts "\nUnverifiable records detected: #{result[:failures].size}"
69
70
  result[:failures].each do |failure|
70
- puts " #{failure[:id]} (#{failure[:event_type]}) at #{failure[:created_at]}"
71
+ puts " #{failure[:id]} (#{failure[:event_type]}) at #{failure[:created_at]} — #{failure[:reason]}"
71
72
  end
72
73
  abort "Chain verification failed"
73
74
  end
74
75
  end
75
76
 
77
+ desc "Record the parent digest each existing row was signed against (never re-signs)"
78
+ task relink_checksums: :environment do
79
+ unless StandardAudit::AuditLog.chain_parent_column?
80
+ abort "audit_logs has no previous_checksum column — run `rails generate standard_audit:add_previous_checksum` first"
81
+ end
82
+
83
+ result = StandardAudit::AuditLog.relink_checksums!
84
+
85
+ puts "Relinked: #{result[:relinked]}"
86
+ puts "Left as-is (already linked, or a segment root): #{result[:skipped]}"
87
+ puts "Unresolved: #{result[:unresolved]}"
88
+ puts ""
89
+ puts "No checksum was rewritten. A parent is recorded only when it reproduces"
90
+ puts "the digest the row has held since it was written; unresolved rows keep"
91
+ puts "failing verification, which is the point."
92
+ end
93
+
76
94
  desc "Backfill checksums for records that don't have them"
77
95
  task backfill_checksums: :environment do
78
96
  count = StandardAudit::AuditLog.backfill_checksums!
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_audit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -99,6 +99,8 @@ files:
99
99
  - config/routes.rb
100
100
  - lib/generators/standard_audit/add_checksums/add_checksums_generator.rb
101
101
  - lib/generators/standard_audit/add_checksums/templates/add_checksum_to_audit_logs.rb.erb
102
+ - lib/generators/standard_audit/add_previous_checksum/add_previous_checksum_generator.rb
103
+ - lib/generators/standard_audit/add_previous_checksum/templates/add_previous_checksum_to_audit_logs.rb.erb
102
104
  - lib/generators/standard_audit/install/install_generator.rb
103
105
  - lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb
104
106
  - lib/generators/standard_audit/install/templates/initializer.rb.erb