Module: StandardAudit::RecordReference

Defined in:
lib/standard_audit/record_reference.rb

Overview

Replaces ActiveRecord objects found in audit metadata with a stable reference instead of a snapshot of the record's whole attribute set.

Why this exists

ActiveSupport::Notifications payloads routinely carry live records — standard_id publishes account:, current_account:, session: and code_challenge: — and an AR object serialises with every attribute it has. That put account.password_digest, account.password_reset_token_digest, session.token_digest, session.lookup_hash and code_challenge.code into audit_logs rows across the estate (rarebit-one/rarebit-ops#296).

None of the three existing defences fired: sensitive_keys matches keys exactly and the secrets are attributes underneath account:; filter_nested_metadata is off by default so the filter never descended to them; and account: does not look sensitive at the top level.

Key-based redaction is the wrong tool here — the leak is not "this key is sensitive", it is "this value is an entire database row". So the value is replaced wholesale, by type, before any key filtering happens.

What replaces a record

{ "gid" => "gid://dummy/Account/1", "type" => "Account", "id" => "1" }

A GlobalID string is the identifier this gem already uses for actor, target and scope (actor_gid), so an audit row stays resolvable with GlobalID::Locator. type and id ride along because they survive a record being deleted, an app whose GlobalID app name changes, and records that have no GlobalID at all (unpersisted ones): for those, gid is simply absent and the reference is still meaningful.

Constant Summary collapse

CIRCULAR =

Written in place of a container that contains itself. Depth alone is NOT used as the guard: a plain depth cap would rewrite deeply-but-finitely nested metadata that holds no records at all, and permanently lose audit content in an append-only row to protect against a structure that is merely large. Only a genuine cycle — the one thing that cannot be serialised anyway — is replaced.

"[standard_audit: circular reference]".freeze

Class Method Summary collapse

Class Method Details

.call(value, ancestors = nil) ⇒ Object

Returns a copy of value with every ActiveRecord object — at any depth, inside Arrays, Hashes and Relations — replaced by a reference Hash. Structures containing no records are returned untouched (same object), so this is a no-op for ordinary metadata.

Never mutates the input: notification payloads are shared with every other subscriber.



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/standard_audit/record_reference.rb', line 51

def call(value, ancestors = nil)
  return reference_for(value) if record?(value)

  container = relation?(value) || value.is_a?(Array) || hash_like?(value)
  return value unless container

  ancestors ||= {}.compare_by_identity
  return CIRCULAR if ancestors.key?(value)

  ancestors[value] = true
  begin
    if relation?(value)
      map_collection(value.to_a, ancestors)
    elsif value.is_a?(Array)
      map_collection(value, ancestors)
    else
      map_hash(value, ancestors)
    end
  ensure
    ancestors.delete(value)
  end
end

.reference_for(record) ⇒ Object

The reference written in place of a record.



75
76
77
78
79
80
81
# File 'lib/standard_audit/record_reference.rb', line 75

def reference_for(record)
  {
    "gid" => global_id_for(record),
    "type" => record.class.name,
    "id" => record.id&.to_s
  }.compact
end