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
-
.call(value, ancestors = nil) ⇒ Object
Returns a copy of
valuewith every ActiveRecord object — at any depth, inside Arrays, Hashes and Relations — replaced by a reference Hash. -
.reference_for(record) ⇒ Object
The reference written in place of a record.
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 |