Class: StandardAudit::AuditLog

Inherits:
ApplicationRecord show all
Includes:
ReferencePreloading
Defined in:
app/models/standard_audit/audit_log.rb

Constant Summary collapse

CHECKSUM_FIELDS =
%w[
  id event_type actor_gid actor_type target_gid target_type
  scope_gid scope_type metadata request_id ip_address
  user_agent session_id occurred_at
].freeze

Constants included from ReferencePreloading

ReferencePreloading::DEFAULT_REFS, ReferencePreloading::REFERENCES

Class Method Summary collapse

Instance Method Summary collapse

Methods included from ReferencePreloading

#preloaded_references, #reference_preloaded?, #reload, #write_preloaded_reference

Class Method Details

.anonymize_actor!(record) ⇒ Object

-- GDPR methods --



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'app/models/standard_audit/audit_log.rb', line 95

def self.anonymize_actor!(record)
  gid = record.to_global_id.to_s
  logs = where("actor_gid = ? OR target_gid = ?", gid, gid)
  count = logs.count

  anonymizable_keys = StandardAudit.config..map(&:to_s)

  logs.find_each do |log|
    attrs = {
      ip_address: nil,
      user_agent: nil,
      session_id: nil
    }

    attrs[:actor_gid] = "[anonymized]" if log.actor_gid == gid
    attrs[:actor_type] = "[anonymized]" if log.actor_gid == gid
    attrs[:target_gid] = "[anonymized]" if log.target_gid == gid
    attrs[:target_type] = "[anonymized]" if log.target_gid == gid

    if log..present? && anonymizable_keys.any?
       = log..reject { |k, _| anonymizable_keys.include?(k.to_s) }
      attrs[:metadata] = 
    end

    log.update_columns(attrs)
  end

  count
end

.backfill_checksums!(batch_size: 1000) ⇒ Object

Backfills checksums for records that don't have them (e.g. pre-existing records before the checksum feature was added).



381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# File 'app/models/standard_audit/audit_log.rb', line 381

def self.backfill_checksums!(batch_size: 1000)
  previous_checksum = nil
  count = 0

  each_in_chain_order(all, batch_size: batch_size) do |record|
    if record.checksum.present?
      previous_checksum = record.checksum
      next
    end

    new_checksum = compute_checksum_value(
      record.attributes.slice(*CHECKSUM_FIELDS),
      previous_checksum: previous_checksum
    )
    columns = { checksum: new_checksum }
    columns[:previous_checksum] = previous_checksum if chain_parent_column?
    record.update_columns(columns)

    previous_checksum = new_checksum
    count += 1
  end

  count
end

.chain_parent_column?Boolean

True when the table carries the previous_checksum column, i.e. the host has run the standard_audit:add_previous_checksum migration. Rows written without it are verified by position and parent recovery instead, so the gem keeps working unmigrated.

Returns:

  • (Boolean)


183
184
185
# File 'app/models/standard_audit/audit_log.rb', line 183

def self.chain_parent_column?
  column_names.include?("previous_checksum")
end

.chain_tip_checksumObject

The checksum of the most recent row — the node a new row links to.



175
176
177
# File 'app/models/standard_audit/audit_log.rb', line 175

def self.chain_tip_checksum
  order(created_at: :desc, id: :desc).limit(1).pick(:checksum)
end

.compute_checksum_value(attrs, previous_checksum: nil) ⇒ Object



161
162
163
164
165
166
167
168
169
170
171
172
# File 'app/models/standard_audit/audit_log.rb', line 161

def self.compute_checksum_value(attrs, previous_checksum: nil)
  canonical = CHECKSUM_FIELDS.map { |f|
    value = attrs[f]
    value = value.to_json if value.is_a?(Hash)
    value = value.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ") if value.respond_to?(:strftime) && value.respond_to?(:utc)
    "#{f}=#{value}"
  }.join("|")

  canonical = "#{previous_checksum}|#{canonical}" if previous_checksum.present?

  OpenSSL::Digest::SHA256.hexdigest(canonical)
end

.export_for_actor(record) ⇒ Object



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'app/models/standard_audit/audit_log.rb', line 125

def self.export_for_actor(record)
  gid = record.to_global_id.to_s
  logs = where("actor_gid = ? OR target_gid = ?", gid, gid).chronological

  records = logs.map do |log|
    {
      id: log.id,
      event_type: log.event_type,
      actor_gid: log.actor_gid,
      target_gid: log.target_gid,
      scope_gid: log.scope_gid,
      metadata: log.,
      occurred_at: log.occurred_at.iso8601,
      ip_address: log.ip_address,
      user_agent: log.user_agent,
      request_id: log.request_id
    }
  end

  {
    subject: gid,
    exported_at: Time.current.iso8601,
    total_records: records.size,
    records: records
  }
end

Records, for every row that does not already carry one, the parent digest it was actually signed against — recovering it by search where a concurrent append forked the chain. Returns { relinked:, unresolved:, skipped: }.

This NEVER rewrites a checksum. It writes only the previously-empty previous_checksum column, so it adds no attestation the rows did not already carry: a parent is recorded only when it reproduces the digest the row has held since it was written. Rows whose parent cannot be reproduced are left untouched and counted in :unresolved — they are the rows verification should keep reporting.



301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
# File 'app/models/standard_audit/audit_log.rb', line 301

def self.relink_checksums!(batch_size: 1000, recovery_window: 256)
  return { relinked: 0, unresolved: 0, skipped: 0 } unless chain_parent_column?

  previous_checksum = nil
  relinked = 0
  unresolved = 0
  skipped = 0
  window = []

  each_in_chain_order(all, batch_size: batch_size) do |record|
    if record.checksum.blank?
      previous_checksum = nil
      window.clear
      next
    end

    found = resolve_parent(record, previous_checksum, window) if record.previous_checksum.blank?

    if record.previous_checksum.present?
      skipped += 1
    elsif found.nil?
      unresolved += 1
    elsif found.first.present?
      record.update_columns(previous_checksum: found.first)
      relinked += 1
    else
      # A genuine segment root. NULL already says so; nothing to write.
      skipped += 1
    end

    previous_checksum = record.checksum
    window << record.checksum
    window.shift if window.size > recovery_window
  end

  { relinked: relinked, unresolved: unresolved, skipped: skipped }
end

.verify_chain(scope: nil, batch_size: 1000, recovery_window: 256, strict: false) ⇒ Object

Verifies the integrity of the audit log. Returns a result hash with :valid (boolean), :verified (count), :recovered (count) and :failures (array of hashes carrying :id, :event_type, :created_at, :expected, :actual and :reason).

Records are processed in (created_at, id) order. Records without a checksum (pre-feature data) reset the walk — the next checksummed record starts a new independent segment.

The log is a DAG, not a strict line. Concurrent writers link to whichever node was the tip when they read it, so two rows can share a parent and the sequence forks. Every row is still verified against the exact digest it signed, in one of two ways:

* `previous_checksum` present (written by 0.8+ on a migrated host): the
parent the writer asserts. It is covered by this row's own digest, so
it cannot be edited to excuse a tampered row.
* `previous_checksum` NULL (pre-0.8 rows, or an unmigrated host): the
preceding row in the walk, and — unless `strict:` — a search back
through the last `recovery_window` digests (and finally "no parent at
all", for a row written against an empty table) for the one that
actually reproduces this row's checksum. That recovers the true parent
of a forked row without re-signing anything. It does not weaken tamper
detection: a row whose fields were altered reproduces no candidate's
digest, so it still fails.

A row whose parent digest is absent from the log is reported with reason: :missing_parent — a row was removed. Two exemptions:

* `scope:` — the log is global, so a scoped row's parent usually
belongs to another scope and is absent for an innocent reason.
* a pruned start. If the walk *opens* on a row whose parent is already
gone, the log has had its start removed (retention cleanup). That
parent digest is then exempt wherever else it appears, because a
pruned row can have several children — which is exactly what a
concurrent append leaves behind. Removing a row from the middle is
still reported: its digest is not the one the walk opened on.

standard_audit:cleanup prunes by occurred_at while the walk orders by created_at. Rows whose two timestamps disagree (a backdated occurred_at) can leave a hole rather than a prefix, and a hole is reported — truthfully, since rows really are missing.



229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'app/models/standard_audit/audit_log.rb', line 229

def self.verify_chain(scope: nil, batch_size: 1000, recovery_window: 256, strict: false)
  relation = scope ? where(scope_gid: scope.to_global_id.to_s) : all
  check_parents = scope.nil?
  declared_parents = chain_parent_column?

  previous_checksum = nil
  verified = 0
  recovered = 0
  failures = []
  window = []
  first_row = true
  pruned_parents = []

  each_in_chain_order(relation, batch_size: batch_size) do |record|
    if record.checksum.blank?
      previous_checksum = nil
      window.clear
      next
    end

    verified += 1
    declared = record.previous_checksum if declared_parents

    if declared.present?
      expected = record.compute_checksum_value(previous_checksum: declared)

      if record.checksum != expected
        failures << chain_failure(record, expected: expected, reason: :digest_mismatch)
      elsif check_parents && !parent_present?(declared, window, relation)
        if first_row
          # The walk opens on a row whose parent is already gone, so the
          # log has had its start removed — retention pruning, typically.
          # That parent is unknowable, and it can have several children
          # (which is what a concurrent append leaves behind), so the
          # exemption is remembered per digest rather than for one row.
          pruned_parents << declared
        elsif !pruned_parents.include?(declared)
          failures << chain_failure(record, expected: expected, reason: :missing_parent)
        end
      end
    else
      expected = record.compute_checksum_value(previous_checksum: previous_checksum)

      if record.checksum == expected
        # Links to the row before it, as a linear chain does.
      elsif !strict && recover_parent(record, window)
        recovered += 1
      else
        failures << chain_failure(record, expected: expected, reason: :digest_mismatch)
      end
    end

    previous_checksum = record.checksum
    first_row = false
    window << record.checksum
    window.shift if window.size > recovery_window
  end

  { valid: failures.empty?, verified: verified, recovered: recovered, failures: failures }
end

Instance Method Details

#actorObject



48
49
50
# File 'app/models/standard_audit/audit_log.rb', line 48

def actor
  read_reference(:actor)
end

#actor=(record) ⇒ Object

-- actor / target / scope assignment via GlobalID --

Reads consult the preload memo first (see ReferencePreloading), then fall back to a single GlobalID::Locator.locate. Writers populate the memo, since they already hold the record. If the underlying record was deleted the reader returns nil while the gid and type stay on the row.



44
45
46
# File 'app/models/standard_audit/audit_log.rb', line 44

def actor=(record)
  assign_reference(:actor, record)
end

#compute_checksum_value(previous_checksum: nil) ⇒ Object

Recomputes the checksum from the record's current field values and the given previous checksum. Useful for verification without saving.



154
155
156
157
158
159
# File 'app/models/standard_audit/audit_log.rb', line 154

def compute_checksum_value(previous_checksum: nil)
  self.class.compute_checksum_value(
    attributes.slice(*CHECKSUM_FIELDS),
    previous_checksum: previous_checksum
  )
end

#scopeObject



64
65
66
# File 'app/models/standard_audit/audit_log.rb', line 64

def scope
  read_reference(:scope)
end

#scope=(record) ⇒ Object



60
61
62
# File 'app/models/standard_audit/audit_log.rb', line 60

def scope=(record)
  assign_reference(:scope, record)
end

#targetObject



56
57
58
# File 'app/models/standard_audit/audit_log.rb', line 56

def target
  read_reference(:target)
end

#target=(record) ⇒ Object



52
53
54
# File 'app/models/standard_audit/audit_log.rb', line 52

def target=(record)
  assign_reference(:target, record)
end