Class: StandardAudit::AuditLog
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- StandardAudit::AuditLog
- 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
-
.anonymize_actor!(record) ⇒ Object
-- GDPR methods --.
-
.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).
-
.chain_parent_column? ⇒ Boolean
True when the table carries the
previous_checksumcolumn, i.e. -
.chain_tip_checksum ⇒ Object
The checksum of the most recent row — the node a new row links to.
- .compute_checksum_value(attrs, previous_checksum: nil) ⇒ Object
- .export_for_actor(record) ⇒ Object
-
.relink_checksums!(batch_size: 1000, recovery_window: 256) ⇒ Object
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.
-
.verify_chain(scope: nil, batch_size: 1000, recovery_window: 256, strict: false) ⇒ Object
Verifies the integrity of the audit log.
Instance Method Summary collapse
- #actor ⇒ Object
-
#actor=(record) ⇒ Object
-- actor / target / scope assignment via GlobalID --.
-
#compute_checksum_value(previous_checksum: nil) ⇒ Object
Recomputes the checksum from the record's current field values and the given previous checksum.
- #scope ⇒ Object
- #scope=(record) ⇒ Object
- #target ⇒ Object
- #target=(record) ⇒ Object
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.
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_checksum ⇒ Object
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 |
.relink_checksums!(batch_size: 1000, recovery_window: 256) ⇒ Object
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
#actor ⇒ Object
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 |
#scope ⇒ Object
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 |
#target ⇒ Object
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 |