standard_audit 0.12.1 → 0.13.1
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 +207 -0
- data/README.md +199 -21
- data/app/models/standard_audit/audit_log.rb +190 -67
- data/lib/generators/standard_audit/add_anonymized_at/add_anonymized_at_generator.rb +4 -5
- data/lib/generators/standard_audit/add_previous_checksum/add_previous_checksum_generator.rb +4 -5
- data/lib/generators/standard_audit/install/install_generator.rb +2 -5
- data/lib/generators/standard_audit/migration_number.rb +40 -0
- data/lib/standard_audit/checksum/key_order_search.rb +153 -0
- data/lib/standard_audit/checksum.rb +208 -0
- data/lib/standard_audit/configuration.rb +33 -1
- data/lib/standard_audit/engine.rb +5 -2
- data/lib/standard_audit/event_subscriber.rb +2 -1
- data/lib/standard_audit/operation/audit.rb +2 -6
- data/lib/standard_audit/rspec/baseline.rb +27 -3
- data/lib/standard_audit/subscriber.rb +2 -1
- data/lib/standard_audit/version.rb +1 -1
- data/lib/standard_audit.rb +58 -19
- data/lib/tasks/standard_audit_tasks.rake +18 -1
- metadata +11 -11
- data/config/routes.rb +0 -2
- data/lib/generators/standard_audit/add_checksums/add_checksums_generator.rb +0 -30
- data/lib/generators/standard_audit/add_checksums/templates/add_checksum_to_audit_logs.rb.erb +0 -6
|
@@ -193,24 +193,28 @@ module StandardAudit
|
|
|
193
193
|
|
|
194
194
|
# Recomputes the checksum from the record's current field values and the
|
|
195
195
|
# given previous checksum. Useful for verification without saving.
|
|
196
|
-
|
|
196
|
+
# The algorithm (StandardAudit::Checksum::LEGACY or CANONICAL) follows the
|
|
197
|
+
# row's `created_at` against `config.canonical_checksum_since`; pass
|
|
198
|
+
# `version:` to force one.
|
|
199
|
+
def compute_checksum_value(previous_checksum: nil, version: nil)
|
|
197
200
|
self.class.compute_checksum_value(
|
|
198
201
|
attributes.slice(*CHECKSUM_FIELDS),
|
|
199
|
-
previous_checksum: previous_checksum
|
|
202
|
+
previous_checksum: previous_checksum,
|
|
203
|
+
version: version || StandardAudit::Checksum.algorithm_for(created_at)
|
|
200
204
|
)
|
|
201
205
|
end
|
|
202
206
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
value = value.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ") if value.respond_to?(:strftime) && value.respond_to?(:utc)
|
|
208
|
-
"#{f}=#{value}"
|
|
209
|
-
}.join("|")
|
|
207
|
+
# `attrs` may carry "created_at" / :created_at, which picks the algorithm
|
|
208
|
+
# as for a stored row; without it, the algorithm a write now would use.
|
|
209
|
+
def self.compute_checksum_value(attrs, previous_checksum: nil, version: nil)
|
|
210
|
+
version ||= StandardAudit::Checksum.algorithm_for(attrs["created_at"] || attrs[:created_at])
|
|
210
211
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
StandardAudit::Checksum.digest(
|
|
213
|
+
attrs,
|
|
214
|
+
fields: CHECKSUM_FIELDS,
|
|
215
|
+
previous_checksum: previous_checksum,
|
|
216
|
+
version: version
|
|
217
|
+
)
|
|
214
218
|
end
|
|
215
219
|
|
|
216
220
|
# Runs the configured `before_checksum` hooks against a row that will be
|
|
@@ -240,10 +244,65 @@ module StandardAudit
|
|
|
240
244
|
column_names.include?("previous_checksum")
|
|
241
245
|
end
|
|
242
246
|
|
|
243
|
-
# Verifies the integrity of the audit log. Returns a result hash
|
|
244
|
-
#
|
|
245
|
-
#
|
|
246
|
-
#
|
|
247
|
+
# Verifies the integrity of the audit log. Returns a result hash:
|
|
248
|
+
#
|
|
249
|
+
# valid: true when :failures is empty (see "Legacy rows")
|
|
250
|
+
# verified: rows whose digest was checked (every checksummed,
|
|
251
|
+
# non-anonymized row)
|
|
252
|
+
# recovered: rows verified against a searched-for parent
|
|
253
|
+
# reordered: legacy rows verified by reconstructing the
|
|
254
|
+
# metadata key order they were signed with
|
|
255
|
+
# redacted: anonymized rows (not digest-checked)
|
|
256
|
+
# legacy_unverifiable: count of :unverifiable
|
|
257
|
+
# unverifiable: legacy rows whose digest can't be reproduced
|
|
258
|
+
# because the key order they were signed with is
|
|
259
|
+
# lost — same shape as a failure, reason
|
|
260
|
+
# :legacy_key_order_unverifiable
|
|
261
|
+
# failures: hashes with :id, :event_type, :created_at,
|
|
262
|
+
# :expected, :actual and :reason
|
|
263
|
+
#
|
|
264
|
+
# == Checksum algorithms and the cutover
|
|
265
|
+
#
|
|
266
|
+
# A row created at or after `config.canonical_checksum_since` (default
|
|
267
|
+
# StandardAudit::CANONICAL_CHECKSUM_CUTOVER) is signed with the canonical
|
|
268
|
+
# digest, which does not depend on how the database orders JSON object
|
|
269
|
+
# keys, and is verified STRICTLY with it: a mismatch is :digest_mismatch,
|
|
270
|
+
# never a legacy classification. An earlier row is legacy: its digest
|
|
271
|
+
# hashed `metadata.to_json` in Ruby insertion order, which `jsonb`
|
|
272
|
+
# discards (fundbright/delivery-ops#689). See StandardAudit::Checksum.
|
|
273
|
+
# The decision is recomputed from each row's stored `created_at`, the
|
|
274
|
+
# same value the writer used.
|
|
275
|
+
#
|
|
276
|
+
# == Legacy rows
|
|
277
|
+
#
|
|
278
|
+
# A legacy row is checked with the legacy digest, exactly as before —
|
|
279
|
+
# declared parent, else the preceding row, else the recovery search
|
|
280
|
+
# below. One that still does not reproduce is, in this order:
|
|
281
|
+
#
|
|
282
|
+
# 1. searched for the metadata key order it was signed with. A key order
|
|
283
|
+
# that reproduces the stored digest is a witness, like the parent
|
|
284
|
+
# search; the row counts in :reordered and is valid. Bounded by
|
|
285
|
+
# `key_order_search_limit` orderings per row
|
|
286
|
+
# (Checksum::KeyOrderSearch), against the declared parent or else the
|
|
287
|
+
# preceding row and "no parent";
|
|
288
|
+
# 2. reported `:digest_mismatch` when that search was EXHAUSTIVE against
|
|
289
|
+
# the parent the row declares (no key order explains it), or when the
|
|
290
|
+
# row has no JSON object with more than one key (key order cannot be
|
|
291
|
+
# why it fails);
|
|
292
|
+
# 3. reported `:missing_parent` when its declared parent is absent;
|
|
293
|
+
# 4. otherwise listed in :unverifiable with reason
|
|
294
|
+
# `:legacy_key_order_unverifiable`.
|
|
295
|
+
#
|
|
296
|
+
# `:legacy_key_order_unverifiable` means "cannot be proven either way":
|
|
297
|
+
# an edited legacy row looks exactly like one whose key order was lost.
|
|
298
|
+
# Such rows do NOT make `valid` false on their own — the gem cannot tell,
|
|
299
|
+
# and the policy for them belongs to the host — but they are never
|
|
300
|
+
# silent: they are counted in :legacy_unverifiable and listed in
|
|
301
|
+
# :unverifiable. Pass `fail_on_legacy_unverifiable: true` to report them
|
|
302
|
+
# as failures instead. After the cutover no new legacy row can be
|
|
303
|
+
# written, so under honest operation :legacy_unverifiable never grows;
|
|
304
|
+
# alert if it does (an edited pre-cutover row, or a `created_at` moved
|
|
305
|
+
# back across the cutover).
|
|
247
306
|
#
|
|
248
307
|
# A row stamped `anonymized_at` (GDPR erasure via `anonymize_actor!`) is
|
|
249
308
|
# counted in :redacted instead of being digest-checked: its checksummed
|
|
@@ -273,7 +332,12 @@ module StandardAudit
|
|
|
273
332
|
# actually reproduces this row's checksum. That recovers the true parent
|
|
274
333
|
# of a forked row without re-signing anything. It does not weaken tamper
|
|
275
334
|
# detection: a row whose fields were altered reproduces no candidate's
|
|
276
|
-
# digest, so it still fails.
|
|
335
|
+
# digest, so it still fails. (The key-order search is not combined with
|
|
336
|
+
# this window search, so a legacy row that was both forked and
|
|
337
|
+
# reordered stays unverifiable.)
|
|
338
|
+
#
|
|
339
|
+
# The chain links across the cutover like anywhere else: the first
|
|
340
|
+
# canonical row's parent is the last legacy row's stored checksum.
|
|
277
341
|
#
|
|
278
342
|
# A row whose parent digest is absent from the log is reported with
|
|
279
343
|
# `reason: :missing_parent` — a row was removed. Two exemptions:
|
|
@@ -291,20 +355,46 @@ module StandardAudit
|
|
|
291
355
|
# `created_at`. Rows whose two timestamps disagree (a backdated
|
|
292
356
|
# `occurred_at`) can leave a hole rather than a prefix, and a hole is
|
|
293
357
|
# reported — truthfully, since rows really are missing.
|
|
294
|
-
def self.verify_chain(scope: nil, batch_size: 1000, recovery_window: 256, strict: false
|
|
358
|
+
def self.verify_chain(scope: nil, batch_size: 1000, recovery_window: 256, strict: false,
|
|
359
|
+
key_order_search_limit: StandardAudit::Checksum::KeyOrderSearch::DEFAULT_LIMIT,
|
|
360
|
+
fail_on_legacy_unverifiable: false)
|
|
295
361
|
relation = scope ? where(scope_gid: scope.to_global_id.to_s) : all
|
|
296
362
|
check_parents = scope.nil?
|
|
297
363
|
declared_parents = chain_parent_column?
|
|
364
|
+
key_orders = StandardAudit::Checksum::KeyOrderSearch.new(limit: key_order_search_limit)
|
|
298
365
|
|
|
299
366
|
previous_checksum = nil
|
|
300
367
|
verified = 0
|
|
301
368
|
recovered = 0
|
|
369
|
+
reordered = 0
|
|
302
370
|
redacted = 0
|
|
371
|
+
unverifiable = []
|
|
303
372
|
failures = []
|
|
304
373
|
window = []
|
|
305
374
|
first_row = true
|
|
306
375
|
pruned_parents = []
|
|
307
376
|
|
|
377
|
+
# Records a :missing_parent failure when `declared` is absent from the
|
|
378
|
+
# log, unless the walk opened on it (a pruned start). True when reported.
|
|
379
|
+
report_missing_parent = lambda do |record, declared, expected|
|
|
380
|
+
next false unless declared.present? && check_parents && !parent_present?(declared, window, relation)
|
|
381
|
+
|
|
382
|
+
if first_row
|
|
383
|
+
# The walk opens on a row whose parent is already gone, so the log
|
|
384
|
+
# has had its start removed — retention pruning, typically. That
|
|
385
|
+
# parent is unknowable, and it can have several children (which is
|
|
386
|
+
# what a concurrent append leaves behind), so the exemption is
|
|
387
|
+
# remembered per digest rather than for one row.
|
|
388
|
+
pruned_parents << declared
|
|
389
|
+
false
|
|
390
|
+
elsif pruned_parents.include?(declared)
|
|
391
|
+
false
|
|
392
|
+
else
|
|
393
|
+
failures << chain_failure(record, expected: expected, reason: :missing_parent)
|
|
394
|
+
true
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
308
398
|
each_in_chain_order(relation, batch_size: batch_size) do |record|
|
|
309
399
|
if record.checksum.blank?
|
|
310
400
|
previous_checksum = nil
|
|
@@ -312,44 +402,45 @@ module StandardAudit
|
|
|
312
402
|
next
|
|
313
403
|
end
|
|
314
404
|
|
|
315
|
-
declared = record.previous_checksum
|
|
405
|
+
declared = declared_parents ? record.previous_checksum : nil
|
|
316
406
|
|
|
317
407
|
if record.anonymized?
|
|
318
408
|
redacted += 1
|
|
319
409
|
# The digest cannot be checked, but the parent it declares can.
|
|
320
|
-
|
|
321
|
-
if first_row
|
|
322
|
-
pruned_parents << declared
|
|
323
|
-
elsif !pruned_parents.include?(declared)
|
|
324
|
-
failures << chain_failure(record, expected: nil, reason: :missing_parent)
|
|
325
|
-
end
|
|
326
|
-
end
|
|
327
|
-
elsif declared.present?
|
|
328
|
-
verified += 1
|
|
329
|
-
expected = record.compute_checksum_value(previous_checksum: declared)
|
|
330
|
-
|
|
331
|
-
if record.checksum != expected
|
|
332
|
-
failures << chain_failure(record, expected: expected, reason: :digest_mismatch)
|
|
333
|
-
elsif check_parents && !parent_present?(declared, window, relation)
|
|
334
|
-
if first_row
|
|
335
|
-
# The walk opens on a row whose parent is already gone, so the
|
|
336
|
-
# log has had its start removed — retention pruning, typically.
|
|
337
|
-
# That parent is unknowable, and it can have several children
|
|
338
|
-
# (which is what a concurrent append leaves behind), so the
|
|
339
|
-
# exemption is remembered per digest rather than for one row.
|
|
340
|
-
pruned_parents << declared
|
|
341
|
-
elsif !pruned_parents.include?(declared)
|
|
342
|
-
failures << chain_failure(record, expected: expected, reason: :missing_parent)
|
|
343
|
-
end
|
|
344
|
-
end
|
|
410
|
+
report_missing_parent.call(record, declared, nil)
|
|
345
411
|
else
|
|
346
412
|
verified += 1
|
|
347
|
-
|
|
413
|
+
algorithm = StandardAudit::Checksum.algorithm_for(record.created_at)
|
|
414
|
+
parent = declared.presence || previous_checksum
|
|
415
|
+
expected = walk_digester(record, algorithm).call(parent)
|
|
348
416
|
|
|
349
417
|
if record.checksum == expected
|
|
350
|
-
# Links to the row before it
|
|
351
|
-
|
|
418
|
+
# Links to its declared parent, or to the row before it as a
|
|
419
|
+
# linear chain does.
|
|
420
|
+
report_missing_parent.call(record, declared, expected)
|
|
421
|
+
elsif declared.blank? && !strict && recover_parent(record, window, algorithm)
|
|
352
422
|
recovered += 1
|
|
423
|
+
elsif algorithm == StandardAudit::Checksum::LEGACY && legacy_key_order_ambiguous?(record)
|
|
424
|
+
attrs = record.attributes.slice(*CHECKSUM_FIELDS)
|
|
425
|
+
parents = declared.present? || strict ? [parent] : [parent, nil].uniq
|
|
426
|
+
|
|
427
|
+
if key_orders.search(attrs, fields: CHECKSUM_FIELDS, checksum: record.checksum, parents: parents)
|
|
428
|
+
reordered += 1
|
|
429
|
+
# The row verified under a reconstructed order, so the stored-
|
|
430
|
+
# order `expected` means nothing for a missing-parent report.
|
|
431
|
+
report_missing_parent.call(record, declared, nil)
|
|
432
|
+
elsif declared.present? && key_orders.exhaustive?(attrs, fields: CHECKSUM_FIELDS)
|
|
433
|
+
# Every key order was tried against the parent the row itself
|
|
434
|
+
# declares, so key order does not explain this row.
|
|
435
|
+
failures << chain_failure(record, expected: expected, reason: :digest_mismatch)
|
|
436
|
+
elsif report_missing_parent.call(record, declared, nil)
|
|
437
|
+
# Reported as a removed row, which is the stronger finding. No
|
|
438
|
+
# `expected`: no digest was established for this row.
|
|
439
|
+
else
|
|
440
|
+
entry = chain_failure(record, expected: expected, reason: :legacy_key_order_unverifiable)
|
|
441
|
+
unverifiable << entry
|
|
442
|
+
failures << entry if fail_on_legacy_unverifiable
|
|
443
|
+
end
|
|
353
444
|
else
|
|
354
445
|
failures << chain_failure(record, expected: expected, reason: :digest_mismatch)
|
|
355
446
|
end
|
|
@@ -361,7 +452,16 @@ module StandardAudit
|
|
|
361
452
|
window.shift if window.size > recovery_window
|
|
362
453
|
end
|
|
363
454
|
|
|
364
|
-
{
|
|
455
|
+
{
|
|
456
|
+
valid: failures.empty?,
|
|
457
|
+
verified: verified,
|
|
458
|
+
recovered: recovered,
|
|
459
|
+
reordered: reordered,
|
|
460
|
+
redacted: redacted,
|
|
461
|
+
legacy_unverifiable: unverifiable.size,
|
|
462
|
+
unverifiable: unverifiable,
|
|
463
|
+
failures: failures
|
|
464
|
+
}
|
|
365
465
|
end
|
|
366
466
|
|
|
367
467
|
# Records, for every row that does not already carry one, the parent digest
|
|
@@ -426,28 +526,54 @@ module StandardAudit
|
|
|
426
526
|
private_class_method :chain_failure
|
|
427
527
|
|
|
428
528
|
# Searches `window` (most recent first, then "no parent at all") for the
|
|
429
|
-
# digest that reproduces the record's stored checksum
|
|
430
|
-
# array holding the parent — which may
|
|
431
|
-
# against an empty table — or nil when
|
|
529
|
+
# digest that reproduces the record's stored checksum under `algorithm`.
|
|
530
|
+
# Returns a one-element array holding the parent — which may
|
|
531
|
+
# itself be nil, for a row written against an empty table — or nil when
|
|
532
|
+
# nothing reproduces the digest.
|
|
432
533
|
#
|
|
433
534
|
# SHA-256 preimage resistance is what makes this safe: a row whose fields
|
|
434
535
|
# were altered reproduces no candidate's digest, so it is still reported.
|
|
435
|
-
def self.recover_parent(record, window)
|
|
536
|
+
def self.recover_parent(record, window, algorithm)
|
|
436
537
|
window.reverse_each do |candidate|
|
|
437
|
-
return [candidate] if record
|
|
538
|
+
return [candidate] if digest_matches?(record, candidate, algorithm)
|
|
438
539
|
end
|
|
439
540
|
|
|
440
|
-
[nil] if record
|
|
541
|
+
[nil] if digest_matches?(record, nil, algorithm)
|
|
441
542
|
end
|
|
442
543
|
private_class_method :recover_parent
|
|
443
544
|
|
|
444
545
|
def self.resolve_parent(record, previous_checksum, window)
|
|
445
|
-
|
|
546
|
+
algorithm = StandardAudit::Checksum.algorithm_for(record.created_at)
|
|
547
|
+
return [previous_checksum] if digest_matches?(record, previous_checksum, algorithm)
|
|
446
548
|
|
|
447
|
-
recover_parent(record, window)
|
|
549
|
+
recover_parent(record, window, algorithm)
|
|
448
550
|
end
|
|
449
551
|
private_class_method :resolve_parent
|
|
450
552
|
|
|
553
|
+
def self.digest_matches?(record, parent, algorithm)
|
|
554
|
+
record.checksum == walk_digester(record, algorithm).call(parent)
|
|
555
|
+
end
|
|
556
|
+
private_class_method :digest_matches?
|
|
557
|
+
|
|
558
|
+
# The row's `parent -> digest` function for `algorithm`, memoised on the
|
|
559
|
+
# loaded record: the parent searches try hundreds of parents per row, and
|
|
560
|
+
# this makes each try one SHA-256 instead of re-serialising the row. Only
|
|
561
|
+
# for records the walk loaded and never mutates.
|
|
562
|
+
def self.walk_digester(record, algorithm)
|
|
563
|
+
memo = record.instance_variable_get(:@walk_digesters) || record.instance_variable_set(:@walk_digesters, {})
|
|
564
|
+
memo[algorithm] ||= StandardAudit::Checksum.digester(
|
|
565
|
+
record.attributes.slice(*CHECKSUM_FIELDS), fields: CHECKSUM_FIELDS, version: algorithm
|
|
566
|
+
)
|
|
567
|
+
end
|
|
568
|
+
private_class_method :walk_digester
|
|
569
|
+
|
|
570
|
+
# True when the row's hashed JSON could have been reordered by the store:
|
|
571
|
+
# some object in it has more than one key.
|
|
572
|
+
def self.legacy_key_order_ambiguous?(record)
|
|
573
|
+
CHECKSUM_FIELDS.any? { |f| StandardAudit::Checksum.key_order_ambiguous?(record[f]) }
|
|
574
|
+
end
|
|
575
|
+
private_class_method :legacy_key_order_ambiguous?
|
|
576
|
+
|
|
451
577
|
def self.parent_present?(digest, window, relation)
|
|
452
578
|
window.include?(digest) || relation.exists?(checksum: digest)
|
|
453
579
|
end
|
|
@@ -465,10 +591,8 @@ module StandardAudit
|
|
|
465
591
|
next
|
|
466
592
|
end
|
|
467
593
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
previous_checksum: previous_checksum
|
|
471
|
-
)
|
|
594
|
+
# The row's own created_at picks the algorithm, as verification will.
|
|
595
|
+
new_checksum = record.compute_checksum_value(previous_checksum: previous_checksum)
|
|
472
596
|
columns = { checksum: new_checksum }
|
|
473
597
|
columns[:previous_checksum] = previous_checksum if chain_parent_column?
|
|
474
598
|
record.update_columns(columns)
|
|
@@ -550,7 +674,12 @@ module StandardAudit
|
|
|
550
674
|
#
|
|
551
675
|
# `previous_checksum` needs no protection of its own: it is an input to
|
|
552
676
|
# this row's own digest, so editing it invalidates the row.
|
|
677
|
+
#
|
|
678
|
+
# The algorithm is chosen from `created_at`, which is fixed here (Active
|
|
679
|
+
# Record keeps a timestamp that is already set) so the decision is made
|
|
680
|
+
# from exactly the value that is stored and that verification re-reads.
|
|
553
681
|
def compute_checksum
|
|
682
|
+
self.created_at ||= Time.current if has_attribute?(:created_at)
|
|
554
683
|
previous = self.class.chain_tip_checksum
|
|
555
684
|
self.previous_checksum = previous if self.class.chain_parent_column?
|
|
556
685
|
self.checksum = compute_checksum_value(previous_checksum: previous)
|
|
@@ -597,13 +726,7 @@ module StandardAudit
|
|
|
597
726
|
# persist — so the hook would not actually be "skipped".
|
|
598
727
|
restore_attributes_from(snapshot)
|
|
599
728
|
Rails.logger.warn("[StandardAudit] before_checksum hook failed: #{e.class}: #{e.message}")
|
|
600
|
-
|
|
601
|
-
Rails.error.report(
|
|
602
|
-
e,
|
|
603
|
-
handled: true,
|
|
604
|
-
context: { StandardAudit.config.audit_error_context_key => event_type }
|
|
605
|
-
)
|
|
606
|
-
end
|
|
729
|
+
StandardAudit.report_error(e, { StandardAudit.config.audit_error_context_key => event_type })
|
|
607
730
|
nil
|
|
608
731
|
end
|
|
609
732
|
end
|
|
@@ -1,16 +1,15 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "generators/standard_audit/migration_number"
|
|
3
|
+
|
|
1
4
|
module StandardAudit
|
|
2
5
|
module Generators
|
|
3
6
|
# Adds `audit_logs.anonymized_at` (0.12.0). With it, rows erased by
|
|
4
7
|
# `AuditLog.anonymize_actor!` are reported by `verify_chain` as `redacted`
|
|
5
8
|
# rather than as `digest_mismatch` failures.
|
|
6
9
|
class AddAnonymizedAtGenerator < Rails::Generators::Base
|
|
7
|
-
include
|
|
10
|
+
include StandardAudit::Generators::MigrationNumber
|
|
8
11
|
source_root File.expand_path("templates", __dir__)
|
|
9
12
|
|
|
10
|
-
def self.next_migration_number(dirname)
|
|
11
|
-
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
12
|
-
end
|
|
13
|
-
|
|
14
13
|
def copy_migration
|
|
15
14
|
migration_template "add_anonymized_at_to_audit_logs.rb.erb",
|
|
16
15
|
"db/migrate/add_anonymized_at_to_audit_logs.rb"
|
|
@@ -1,13 +1,12 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "generators/standard_audit/migration_number"
|
|
3
|
+
|
|
1
4
|
module StandardAudit
|
|
2
5
|
module Generators
|
|
3
6
|
class AddPreviousChecksumGenerator < Rails::Generators::Base
|
|
4
|
-
include
|
|
7
|
+
include StandardAudit::Generators::MigrationNumber
|
|
5
8
|
source_root File.expand_path("templates", __dir__)
|
|
6
9
|
|
|
7
|
-
def self.next_migration_number(dirname)
|
|
8
|
-
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
9
|
-
end
|
|
10
|
-
|
|
11
10
|
def copy_migration
|
|
12
11
|
migration_template "add_previous_checksum_to_audit_logs.rb.erb",
|
|
13
12
|
"db/migrate/add_previous_checksum_to_audit_logs.rb"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "rails/generators"
|
|
2
|
+
require "generators/standard_audit/migration_number"
|
|
2
3
|
|
|
3
4
|
module StandardAudit
|
|
4
5
|
module Generators
|
|
@@ -11,7 +12,7 @@ module StandardAudit
|
|
|
11
12
|
# installed. Pass `--skip-*` flags to opt out of individual steps and
|
|
12
13
|
# `--force` to overwrite an existing initializer.
|
|
13
14
|
class InstallGenerator < Rails::Generators::Base
|
|
14
|
-
include
|
|
15
|
+
include StandardAudit::Generators::MigrationNumber
|
|
15
16
|
source_root File.expand_path("templates", __dir__)
|
|
16
17
|
|
|
17
18
|
desc <<~DESC
|
|
@@ -32,10 +33,6 @@ module StandardAudit
|
|
|
32
33
|
class_option :force, type: :boolean, default: false,
|
|
33
34
|
desc: "Overwrite config/initializers/standard_audit.rb if it already exists"
|
|
34
35
|
|
|
35
|
-
def self.next_migration_number(dirname)
|
|
36
|
-
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
37
|
-
end
|
|
38
|
-
|
|
39
36
|
def copy_migration
|
|
40
37
|
if options[:skip_migration]
|
|
41
38
|
say_status("skip", "db/migrate/*_create_audit_logs.rb (--skip-migration)", :yellow)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "rails/generators/migration"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module StandardAudit
|
|
5
|
+
module Generators
|
|
6
|
+
# Migration numbering shared by the gem's migration generators.
|
|
7
|
+
#
|
|
8
|
+
# A plain `Time.now` stamp sorts BEFORE a host's future-dated migrations
|
|
9
|
+
# (several consumers date theirs ahead of the clock), so the generated
|
|
10
|
+
# migration would run out of order and look already-applied to tools that
|
|
11
|
+
# compare against the latest version. This picks whichever is later: now,
|
|
12
|
+
# or one second after the newest migration already in the target
|
|
13
|
+
# directory. (ActiveRecord's own generators add 1 to the number, which
|
|
14
|
+
# can produce an invalid timestamp such as ...235960; this adds a second.)
|
|
15
|
+
module MigrationNumber
|
|
16
|
+
def self.included(base)
|
|
17
|
+
base.include Rails::Generators::Migration
|
|
18
|
+
base.extend ClassMethods
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
module ClassMethods
|
|
22
|
+
FORMAT = "%Y%m%d%H%M%S".freeze
|
|
23
|
+
|
|
24
|
+
def next_migration_number(dirname)
|
|
25
|
+
[Time.now.utc.strftime(FORMAT), one_second_after(current_migration_number(dirname))].max
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def one_second_after(number)
|
|
31
|
+
stamp = Kernel.format("%.14d", number)
|
|
32
|
+
(Time.strptime("#{stamp} +0000", "#{FORMAT} %z") + 1).utc.strftime(FORMAT)
|
|
33
|
+
rescue ArgumentError
|
|
34
|
+
# Not a timestamp (no migrations yet, or a sequential numbering).
|
|
35
|
+
Kernel.format("%.14d", number + 1)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
module StandardAudit
|
|
2
|
+
module Checksum
|
|
3
|
+
# Reconstructs the key order a legacy row was signed with.
|
|
4
|
+
#
|
|
5
|
+
# A legacy digest covers `metadata.to_json` in the writer's Ruby
|
|
6
|
+
# insertion order. A `jsonb` column has since discarded that order, and it
|
|
7
|
+
# cannot be derived from the stored row: the storage order is a function
|
|
8
|
+
# of the key set alone, so every insertion order maps to the same stored
|
|
9
|
+
# value. What CAN be done is to try the orders. If some ordering of the
|
|
10
|
+
# stored keys, hashed with the row's own parent, reproduces the digest the
|
|
11
|
+
# row has held since it was written, that is a witness in the same sense
|
|
12
|
+
# as the parent recovery search: SHA-256 preimage resistance means a row
|
|
13
|
+
# whose values were altered reproduces no ordering. Trying N orderings
|
|
14
|
+
# costs log2(N) bits of a 256-bit margin.
|
|
15
|
+
#
|
|
16
|
+
# The search is bounded. The number of orderings is the product of `n!`
|
|
17
|
+
# over every object in the value, so it is only enumerated in full when
|
|
18
|
+
# that product is at most `limit`. Orders that reproduced an earlier row
|
|
19
|
+
# with the same key structure are remembered and tried first, because a
|
|
20
|
+
# given event is almost always built by the same code path in the same
|
|
21
|
+
# order — that is what makes the search cheap across a whole log.
|
|
22
|
+
#
|
|
23
|
+
# One instance lives for one verify_chain walk. It never writes anything.
|
|
24
|
+
class KeyOrderSearch
|
|
25
|
+
DEFAULT_LIMIT = 720 # 6 keys in one object, or e.g. 3 × 3! nested
|
|
26
|
+
REMEMBERED_ORDERS = 8
|
|
27
|
+
|
|
28
|
+
attr_reader :limit
|
|
29
|
+
|
|
30
|
+
def initialize(limit: DEFAULT_LIMIT)
|
|
31
|
+
@limit = limit.to_i
|
|
32
|
+
@learned = Hash.new { |h, k| h[k] = [] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Searches the orderings of every Hash-valued field in `attrs` for one
|
|
36
|
+
# that reproduces `checksum` under the legacy digest with one of `parents`.
|
|
37
|
+
# Returns `{ parent:, attrs: }` on a match, nil otherwise.
|
|
38
|
+
def search(attrs, fields:, checksum:, parents:)
|
|
39
|
+
hashed = fields.select { |f| attrs[f].is_a?(Hash) }
|
|
40
|
+
return nil if hashed.empty? || limit <= 0
|
|
41
|
+
|
|
42
|
+
signature = hashed.map { |f| [f, self.class.signature(attrs[f])] }
|
|
43
|
+
|
|
44
|
+
# Remembered orders first: one try each, however large the object.
|
|
45
|
+
@learned[signature].dup.each do |templates|
|
|
46
|
+
candidate = attrs.merge(hashed.zip(templates).to_h { |f, t| [f, self.class.apply(t, attrs[f])] })
|
|
47
|
+
match = try(candidate, fields, checksum, parents)
|
|
48
|
+
return remember(signature, hashed, candidate, match) if match
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
return nil unless exhaustive?(attrs, fields: fields)
|
|
52
|
+
|
|
53
|
+
per_field = hashed.map { |f| self.class.variants(attrs[f]) }
|
|
54
|
+
combos = per_field.first.product(*per_field.drop(1))
|
|
55
|
+
|
|
56
|
+
combos.each do |values|
|
|
57
|
+
candidate = attrs.merge(hashed.zip(values).to_h)
|
|
58
|
+
match = try(candidate, fields, checksum, parents)
|
|
59
|
+
return remember(signature, hashed, candidate, match) if match
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# True when `search` enumerates EVERY ordering of this row, so a miss
|
|
66
|
+
# means no key order explains the digest — with a known parent, the row
|
|
67
|
+
# does not match its signed content.
|
|
68
|
+
def exhaustive?(attrs, fields:)
|
|
69
|
+
count = fields.select { |f| attrs[f].is_a?(Hash) }.reduce(1) { |acc, f| acc * self.class.variant_count(attrs[f]) }
|
|
70
|
+
count <= limit
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class << self
|
|
74
|
+
def variant_count(node)
|
|
75
|
+
case node
|
|
76
|
+
when Hash then (1..node.size).reduce(1, :*) * node.each_value.reduce(1) { |acc, v| acc * variant_count(v) }
|
|
77
|
+
when Array then node.reduce(1) { |acc, v| acc * variant_count(v) }
|
|
78
|
+
else 1
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Every ordering of every object inside `node`. The first is the
|
|
83
|
+
# stored order. Callers bound the size with variant_count.
|
|
84
|
+
def variants(node)
|
|
85
|
+
case node
|
|
86
|
+
when Hash
|
|
87
|
+
keys = node.keys
|
|
88
|
+
children = keys.to_h { |k| [k, variants(node[k])] }
|
|
89
|
+
keys.permutation.flat_map do |perm|
|
|
90
|
+
choices = perm.map { |k| children[k] }
|
|
91
|
+
product(choices).map { |values| perm.zip(values).to_h }
|
|
92
|
+
end
|
|
93
|
+
when Array
|
|
94
|
+
product(node.map { |v| variants(v) })
|
|
95
|
+
else
|
|
96
|
+
[node]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The key structure of a value, independent of order.
|
|
101
|
+
def signature(node)
|
|
102
|
+
case node
|
|
103
|
+
when Hash then [:h, node.keys.map(&:to_s).sort.map { |k| [k, signature(node[k])] }]
|
|
104
|
+
when Array then [:a, node.map { |v| signature(v) }]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The ordering of `node`, as a template `apply` can replay.
|
|
109
|
+
def template(node)
|
|
110
|
+
case node
|
|
111
|
+
when Hash then [:h, node.map { |k, v| [k, template(v)] }]
|
|
112
|
+
when Array then [:a, node.map { |v| template(v) }]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def apply(template, node)
|
|
117
|
+
case template&.first
|
|
118
|
+
when :h then template.last.to_h { |k, t| [k, apply(t, node[k])] }
|
|
119
|
+
when :a then node.each_with_index.map { |v, i| apply(template.last[i], v) }
|
|
120
|
+
else node
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def product(lists)
|
|
127
|
+
return [[]] if lists.empty?
|
|
128
|
+
|
|
129
|
+
lists.first.product(*lists.drop(1))
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def try(candidate, fields, checksum, parents)
|
|
136
|
+
parents.each do |parent|
|
|
137
|
+
digest = Checksum.legacy_digest(candidate, fields: fields, previous_checksum: parent)
|
|
138
|
+
return { parent: parent } if digest == checksum
|
|
139
|
+
end
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def remember(signature, hashed, candidate, match)
|
|
144
|
+
templates = hashed.map { |f| self.class.template(candidate[f]) }
|
|
145
|
+
list = @learned[signature]
|
|
146
|
+
list.delete(templates)
|
|
147
|
+
list.unshift(templates)
|
|
148
|
+
list.pop while list.size > REMEMBERED_ORDERS
|
|
149
|
+
match.merge(attrs: candidate)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|