standard_audit 0.9.0 → 0.11.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a1d506a4bb5ad3811dd4a70b6be817374ec9a88ced8d1ffd9415bfddd28cc50b
4
- data.tar.gz: 52c8aaa70f86810c6b308c86fa81e10c368ab5ddb011d16f422745e7681c88da
3
+ metadata.gz: b89dc74bd119d2e0f1ff5080cef706439e68b61c79d2a6b653fcd5a74e265ae1
4
+ data.tar.gz: 86686ea3bb611a13e8c8947ef3e0b63aad63eb47bfa1e48b16abba377a426f7f
5
5
  SHA512:
6
- metadata.gz: 2db9ee0ef4983ce2bd9a94d195bbbf973016f32a3e9e2a53b45476df6c9cc6d30f7edd2bfb7e9442d6839e05cc491263b91bb644462946510740c187eb861e29
7
- data.tar.gz: cce577cbc3f1b5829429ab30765c451f4a0fdca18b9f6283f075e1b3487b3fb763ceaeca846e39768c53707361bfc87024da65e0f8e662885a47766f814dd5c1
6
+ metadata.gz: b06e7ee9a3b5d79191cb47a532cf81efb95e3bb722015540be79ce4bb9412ab13b8e69985a66d368c8dcdfb099f34f1ca1d3f0d894bfc90cc409615782dde921
7
+ data.tar.gz: bb1e9316eba648a8d238becb75d33f705b82f509310e8c3e0d562656accac2b44339e49551e026ae0357bf57dda8fe8251f6f1d6c7df655777d5b157c3d17b0a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.11.0] - 2026-07-31
11
+
12
+ ### Security
13
+
14
+ - **ActiveRecord objects in audit metadata are no longer written with all of their attributes.** Any record found in metadata — at any depth, and inside Arrays, Hashes and `ActiveRecord::Relation`s — is now replaced with a reference: `{ "gid" => "gid://app/Account/1", "type" => "Account", "id" => "1" }`. Applies to both write paths (`ActiveSupport::Notifications` and `StandardAudit.record`).
15
+
16
+ `standard_id` publishes live records under payload keys like `account:`, `current_account:`, `session:` and `code_challenge:`, and `Subscriber#extract_metadata` excluded only `actor`/`target`/`scope`/`request_id`/`ip_address`/`user_agent`/`session_id` — every other key was written whole. Confirmed present in real `audit_logs` rows: `account.password_digest`, `account.password_reset_token_digest`, `session.token_digest`, `session.lookup_hash`, `code_challenge.code`.
17
+
18
+ All three existing defences missed, each for a different reason: `sensitive_keys` matches key names 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. This is not a redaction bug — key-based redaction is the wrong tool for a value that is an entire database row — so the value is replaced by type, before any key filtering runs.
19
+
20
+ **This fix stops the bleeding; it does not clean up.** `audit_logs` is append-only by design (`before_update` and `before_destroy` raise `ReadOnlyRecord`), so every row already written keeps its digests, for the whole of a retention window that is intentionally long. Upgrading changes only what is written from now on. Assessing and remediating existing rows is a separate, host-side exercise.
21
+
22
+ `sensitive_keys` semantics are untouched: still exact-match, still no substring mode. See rarebit-one/rarebit-ops#296.
23
+
24
+ ### Added
25
+
26
+ - **`config.dereference_record_metadata`** (default `true`) — the escape hatch for the above. It defaults to the SAFE behaviour, unlike `filter_nested_metadata`, because the values it catches are ones no host asked to record: they arrive as a side effect of a payload carrying `account:` or `session:`, and an append-only row cannot be walked back. Set it to `false` only if an app genuinely depends on record attributes in metadata and has satisfied itself no secret-bearing column can reach a row.
27
+
28
+ ### Changed
29
+
30
+ - **Consumer-visible beyond the secrets disappearing:** any metadata key that used to hold a record's attribute hash now holds a three-key reference, so dashboards, reports or queries reading e.g. `metadata->'account'->>'email'` will read `NULL` on new rows (old rows are unchanged, which makes the change look like a data gap rather than a schema change). Recover the specific fields you need with `metadata_builder`, which runs BEFORE dereferencing and still receives the record: `->(metadata) { metadata.merge(account_email: metadata[:account]&.email) }`. `actor`/`target`/`scope` are unaffected — they were already stored as GlobalIDs.
31
+
32
+ ## [0.10.0] - 2026-07-31
33
+
34
+ ### Added
35
+
36
+ - **`config.audit_error_context_key`** (default `:audit_action`) — renames the `Rails.error.report` context key naming the audit action, without needing a whole handler. Two apps had overridden `audit_write_error_handler` for nothing but this: both tag every OTHER audit-error report site with `audit_event:` (four sites in one, twelve across ten files in the other), so adopting the gem name left the operations layer as the only place forking the convention. A handler written to rename one key also silently opts out of every future improvement to the built-in reporter.
37
+
38
+ ### Changed
39
+
40
+ - **The built-in reporter no longer fires when `raise_on_audit_write_error` re-raises.** The caller receives the error and owns it, so reporting as well produced two events for one failure in every host that both sets the flag and reports on what it catches — which is most hosts that set it at all, since the flag exists for hosts treating an unaudited write as a failure worth handling. Two of the five apps hit this independently and each wrote a log-only handler to work around it. A host that does NOT catch the error still gets a report, via its framework unhandled-error path. An explicitly configured `audit_write_error_handler` is unaffected and still runs under either policy — only the built-in reporter steps aside.
41
+
42
+ ### Fixed
43
+
44
+ - **The write-site scan no longer reads comments as calls.** An operation declaring `audit_none!` that explained itself in prose naming `audit!` was flagged by `unexpected_write_sites` — a false failure, which hosts worked around by backticking the token in their own comments. Source is now lexed with `Ripper` and comment tokens dropped before scanning; a file that cannot be lexed falls back to raw source (the old behaviour). Lexing rather than stripping `#` to end-of-line, because the naive form eats `"#{interpolation}"` and `%w[#]` and would trade a false failure for a false pass.
45
+ - **Corrected a docstring that was wrong for half its own surface.** It claimed the source-scanning predicates "err towards a false pass rather than a false failure". That held only for `missing_write_sites`; in the `unexpected_write_sites` direction the same match produced a false failure. Worse than the bug itself, since it told anyone hitting the failure not to suspect the scanner.
46
+
10
47
  ## [0.9.0] - 2026-07-31
11
48
 
12
49
  ### Added
data/README.md CHANGED
@@ -332,6 +332,10 @@ StandardAudit.configure(baseline: true) do |config|
332
332
  # `metadata: { stripe: { client_secret: ... } }` is written intact.
333
333
  config.filter_nested_metadata = true
334
334
 
335
+ # Replace ActiveRecord objects in metadata with a reference instead of
336
+ # serialising every attribute. ON by default; see "Records in metadata".
337
+ config.dereference_record_metadata = true
338
+
335
339
  # -- Write-time hooks --
336
340
  # Run between the UUID assignment and the checksum computation, so a hook MAY
337
341
  # set a checksummed column and the row still passes `verify_chain`. No
@@ -695,6 +699,38 @@ Nested metadata is **not** redacted unless `filter_nested_metadata` is enabled
695
699
  — `metadata: { stripe: { client_secret: ... } }` passes both write paths under
696
700
  exact matching by default.
697
701
 
702
+ ### Records in metadata
703
+
704
+ An ActiveRecord object appearing anywhere in metadata is replaced, by default,
705
+ with a reference rather than a snapshot of the row:
706
+
707
+ ```ruby
708
+ { account: account }
709
+ # => { account: { "gid" => "gid://myapp/Account/1", "type" => "Account", "id" => "1" } }
710
+ ```
711
+
712
+ This applies at any depth, inside Arrays, Hashes and `ActiveRecord::Relation`s,
713
+ on both write paths. It is on by default because key-based redaction cannot
714
+ reach the problem: a payload key like `account:` looks like exactly the kind of
715
+ key you want on an audit row, while the value serialises with
716
+ `password_digest`, `token_digest`, `lookup_hash` and anything else the table
717
+ happens to hold. Audit rows are append-only, so an unsafe default cannot be
718
+ walked back.
719
+
720
+ On the notifications path, records are dereferenced **after**
721
+ `metadata_builder` runs, so a builder that needs real attributes still gets the
722
+ record (`metadata_builder` has never applied to a direct `StandardAudit.record`
723
+ call — pass the attributes you want in `metadata` there):
724
+
725
+ ```ruby
726
+ config.metadata_builder = ->(metadata) {
727
+ metadata.merge(account_email: metadata[:account]&.email)
728
+ }
729
+ ```
730
+
731
+ `config.dereference_record_metadata = false` restores the pre-0.11.0 behaviour
732
+ of writing full attributes. Prefer `metadata_builder` over turning it off.
733
+
698
734
  **Performance**: For high-volume applications, enable async processing and ensure your `audit_logs` table has appropriate indexes (the install generator adds them by default). Consider partitioning by `occurred_at` for very large tables.
699
735
 
700
736
  **Retention**: Set `retention_days` in your configuration and run `rake standard_audit:cleanup` via a scheduled job (e.g., cron or SolidQueue recurring). Archive before deleting if you need long-term storage.
@@ -7,10 +7,12 @@ module StandardAudit
7
7
  :current_session_id_resolver,
8
8
  :sensitive_keys, :sensitive_key_patterns,
9
9
  :sensitive_key_exceptions, :filter_nested_metadata,
10
+ :dereference_record_metadata,
10
11
  :metadata_builder, :before_checksum_hooks,
11
12
  :anonymizable_metadata_keys, :retention_days,
12
13
  :audit_catalogue, :verify_audit_declarations,
13
- :raise_on_audit_write_error, :audit_write_error_handler
14
+ :raise_on_audit_write_error, :audit_write_error_handler,
15
+ :audit_error_context_key
14
16
 
15
17
  def initialize
16
18
  @subscriptions = []
@@ -71,6 +73,25 @@ module StandardAudit
71
73
  # append-only. Reserved keys are never descended into.
72
74
  @filter_nested_metadata = false
73
75
 
76
+ # When true (the default), an ActiveRecord object appearing anywhere in
77
+ # audit metadata is replaced by a reference —
78
+ # `{ "gid" => …, "type" => …, "id" => … }` — instead of being serialised
79
+ # with all of its attributes. See StandardAudit::RecordReference.
80
+ #
81
+ # Defaults to the SAFE behaviour, unlike `filter_nested_metadata`,
82
+ # because the values this catches are ones no host asked to record:
83
+ # `password_digest`, `token_digest`, `lookup_hash` and PKCE verifiers
84
+ # arriving as a side effect of a payload carrying `account:` or
85
+ # `session:`. Audit rows are append-only, so an unsafe default cannot be
86
+ # walked back — the rows already written keep whatever was in them.
87
+ #
88
+ # Set to false ONLY if an app genuinely depends on record attributes in
89
+ # metadata and has satisfied itself that no secret-bearing column can
90
+ # reach a row. Preferred alternative: keep this on and use
91
+ # `metadata_builder` to pull the specific attributes you want, which runs
92
+ # BEFORE dereferencing and still sees the record.
93
+ @dereference_record_metadata = true
94
+
74
95
  @metadata_builder = nil
75
96
 
76
97
  # Callables (or Symbols naming an AuditLog instance method) run on
@@ -124,6 +145,23 @@ module StandardAudit
124
145
  # write failure under either policy.
125
146
  @audit_write_error_handler = nil
126
147
 
148
+ # The `Rails.error.report` context key naming the audit action, for the
149
+ # built-in reporter. Exists because two apps independently overrode
150
+ # `audit_write_error_handler` for nothing but this key: both tag every
151
+ # OTHER audit-error report site with `audit_event:` (four sites in one
152
+ # app, twelve across ten files in the other), so adopting the gem's name
153
+ # left the operations layer as the only place forking the convention.
154
+ #
155
+ # That divergence fails silently and asymmetrically — a saved error-tracker
156
+ # search grouped on the host's key keeps working and simply stops
157
+ # containing operation write failures. Nothing errors, nothing goes red,
158
+ # and the search still looks healthy.
159
+ #
160
+ # A whole handler to rename one key is a lot of ceremony, and a handler
161
+ # written for that reason also silently opts out of every future
162
+ # improvement to the built-in reporter. Default keeps existing behaviour.
163
+ @audit_error_context_key = :audit_action
164
+
127
165
  # Retention defaults from ENV so it can be set per-environment without a
128
166
  # code change. Unset/blank/non-positive => nil (infinite retention, the
129
167
  # compliance-safe default that never auto-deletes). A host app can still
@@ -1,3 +1,5 @@
1
+ require "ripper"
2
+
1
3
  module StandardAudit
2
4
  module Operation
3
5
  # The registry, the catalogue resolver, the write-error policy, and the
@@ -19,10 +21,20 @@ module StandardAudit
19
21
  #
20
22
  # The scan is FILE-scoped, not class-scoped — two operations defined in
21
23
  # one file share a verdict. Zeitwerk requires one class per file in a
22
- # real app, so this only matters for fixtures. A commented-out or
23
- # documented `audit!` in the same file also counts as a write site; the
24
- # predicates deliberately err towards a false pass rather than a false
25
- # failure, since the runtime guard is the authoritative check.
24
+ # real app, so this only matters for fixtures.
25
+ #
26
+ # Comments are stripped before scanning (see .strip_comments), so a
27
+ # documented or commented-out `audit!` is not a write site. Until 0.9.1
28
+ # it was, and the docstring here claimed the predicates "err towards a
29
+ # false pass rather than a false failure" — which was only ever true of
30
+ # {.missing_write_sites}. In the {.unexpected_write_sites} direction the
31
+ # same match produces a false FAILURE, so an `audit_none!` class that
32
+ # explained itself in prose failed the check. The claim was wrong for
33
+ # half its own surface, which is worse than the bug: it told anyone
34
+ # hitting the failure not to suspect the scanner.
35
+ #
36
+ # What remains genuinely heuristic: a string literal containing `audit!`
37
+ # still counts, and the runtime guard is still the authoritative check.
26
38
  WRITE_SITE_PATTERN = /(?<![\w.:])audit!\s*(?:\(|["':@$\w])/
27
39
 
28
40
  class << self
@@ -118,15 +130,24 @@ module StandardAudit
118
130
  # @raise [StandardError] the original error when
119
131
  # `config.raise_on_audit_write_error` is true
120
132
  def handle_write_error(error, action:, operation:)
133
+ raising = StandardAudit.config.raise_on_audit_write_error
121
134
  handler = StandardAudit.config.audit_write_error_handler
122
135
 
123
136
  if handler
124
137
  handler.call(error, action: action, operation: operation)
125
- else
138
+ elsif !raising
139
+ # Deliberately NOT reported when re-raising: the caller receives the
140
+ # error and owns it from here. Reporting as well produced two events
141
+ # for one failure in every host that both sets
142
+ # `raise_on_audit_write_error` and reports on the error it catches —
143
+ # which is most hosts that set the flag at all, since the flag exists
144
+ # for hosts that treat an unaudited write as a failure worth
145
+ # handling. And a host that does NOT catch it still gets the report,
146
+ # via its framework's own unhandled-error path.
126
147
  report_write_error(error, action: action, operation: operation)
127
148
  end
128
149
 
129
- raise error if StandardAudit.config.raise_on_audit_write_error
150
+ raise error if raising
130
151
 
131
152
  nil
132
153
  end
@@ -228,7 +249,29 @@ module StandardAudit
228
249
  path = source_path(klass)
229
250
  return nil unless path && File.exist?(path)
230
251
 
231
- File.read(path)
252
+ strip_comments(File.read(path))
253
+ end
254
+
255
+ # Comments are not write sites. Scanning raw source made a class that
256
+ # declares `audit_none!` and *explains why* in prose ("No direct audit!
257
+ # here") fail `unexpected_write_sites` — the scanner matched the word in
258
+ # the comment. That is a false failure, and it forced hosts to backtick
259
+ # the token in their own comments to appease a static check.
260
+ #
261
+ # Lexing rather than regex-stripping `#` to end-of-line, because the
262
+ # naive form eats `"#{interpolation}"` and `%w[#]`, which would trade a
263
+ # false failure for a false pass. A file Ripper cannot lex (syntax the
264
+ # running Ruby doesn't accept) falls back to the raw source: the old
265
+ # behaviour, which is conservative in the `audits` direction.
266
+ def strip_comments(src)
267
+ tokens = Ripper.lex(src)
268
+ return src if tokens.nil?
269
+
270
+ tokens.reject { |(_pos, type, _tok, _state)| type == :on_comment }
271
+ .map { |(_pos, _type, tok, _state)| tok }
272
+ .join
273
+ rescue StandardError
274
+ src
232
275
  end
233
276
 
234
277
  def subclassed?(klass)
@@ -244,7 +287,8 @@ module StandardAudit
244
287
  Rails.error.report(
245
288
  error,
246
289
  handled: true,
247
- context: { audit_action: action, operation: operation.class.name }
290
+ context: { StandardAudit.config.audit_error_context_key => action,
291
+ operation: operation.class.name }
248
292
  )
249
293
  end
250
294
  end
@@ -0,0 +1,141 @@
1
+ module StandardAudit
2
+ # Replaces ActiveRecord objects found in audit metadata with a stable
3
+ # *reference* instead of a snapshot of the record's whole attribute set.
4
+ #
5
+ # == Why this exists
6
+ #
7
+ # `ActiveSupport::Notifications` payloads routinely carry live records —
8
+ # `standard_id` publishes `account:`, `current_account:`, `session:` and
9
+ # `code_challenge:` — and an AR object serialises with every attribute it
10
+ # has. That put `account.password_digest`,
11
+ # `account.password_reset_token_digest`, `session.token_digest`,
12
+ # `session.lookup_hash` and `code_challenge.code` into `audit_logs` rows
13
+ # across the estate (rarebit-one/rarebit-ops#296).
14
+ #
15
+ # None of the three existing defences fired: `sensitive_keys` matches keys
16
+ # exactly and the secrets are attributes *underneath* `account:`;
17
+ # `filter_nested_metadata` is off by default so the filter never descended
18
+ # to them; and `account:` does not look sensitive at the top level.
19
+ #
20
+ # Key-based redaction is the wrong tool here — the leak is not "this key is
21
+ # sensitive", it is "this value is an entire database row". So the value is
22
+ # replaced wholesale, by type, before any key filtering happens.
23
+ #
24
+ # == What replaces a record
25
+ #
26
+ # { "gid" => "gid://dummy/Account/1", "type" => "Account", "id" => "1" }
27
+ #
28
+ # A GlobalID string is the identifier this gem already uses for `actor`,
29
+ # `target` and `scope` (`actor_gid`), so an audit row stays resolvable with
30
+ # `GlobalID::Locator`. `type` and `id` ride along because they survive a
31
+ # record being deleted, an app whose GlobalID app name changes, and records
32
+ # that have no GlobalID at all (unpersisted ones): for those, `gid` is
33
+ # simply absent and the reference is still meaningful.
34
+ module RecordReference
35
+ # Written in place of a container that contains itself. Depth alone is NOT
36
+ # used as the guard: a plain depth cap would rewrite deeply-but-finitely
37
+ # nested metadata that holds no records at all, and permanently lose audit
38
+ # content in an append-only row to protect against a structure that is
39
+ # merely large. Only a genuine cycle — the one thing that cannot be
40
+ # serialised anyway — is replaced.
41
+ CIRCULAR = "[standard_audit: circular reference]".freeze
42
+
43
+ class << self
44
+ # Returns a copy of `value` with every ActiveRecord object — at any
45
+ # depth, inside Arrays, Hashes and Relations — replaced by a reference
46
+ # Hash. Structures containing no records are returned untouched (same
47
+ # object), so this is a no-op for ordinary metadata.
48
+ #
49
+ # Never mutates the input: notification payloads are shared with every
50
+ # other subscriber.
51
+ def call(value, ancestors = nil)
52
+ return reference_for(value) if record?(value)
53
+
54
+ container = relation?(value) || value.is_a?(Array) || hash_like?(value)
55
+ return value unless container
56
+
57
+ ancestors ||= {}.compare_by_identity
58
+ return CIRCULAR if ancestors.key?(value)
59
+
60
+ ancestors[value] = true
61
+ begin
62
+ if relation?(value)
63
+ map_collection(value.to_a, ancestors)
64
+ elsif value.is_a?(Array)
65
+ map_collection(value, ancestors)
66
+ else
67
+ map_hash(value, ancestors)
68
+ end
69
+ ensure
70
+ ancestors.delete(value)
71
+ end
72
+ end
73
+
74
+ # The reference written in place of a record.
75
+ def reference_for(record)
76
+ {
77
+ "gid" => global_id_for(record),
78
+ "type" => record.class.name,
79
+ "id" => record.id&.to_s
80
+ }.compact
81
+ end
82
+
83
+ private
84
+
85
+ def record?(value)
86
+ defined?(ActiveRecord::Base) && value.is_a?(ActiveRecord::Base)
87
+ end
88
+
89
+ def relation?(value)
90
+ defined?(ActiveRecord::Relation) && value.is_a?(ActiveRecord::Relation)
91
+ end
92
+
93
+ def hash_like?(value)
94
+ value.respond_to?(:each_pair) && value.respond_to?(:key?)
95
+ end
96
+
97
+ def map_collection(array, ancestors)
98
+ changed = false
99
+ mapped = array.map do |element|
100
+ replacement = call(element, ancestors)
101
+ changed ||= !replacement.equal?(element)
102
+ replacement
103
+ end
104
+
105
+ changed ? mapped : array
106
+ end
107
+
108
+ def map_hash(hash, ancestors)
109
+ changed = false
110
+ mapped = {}
111
+
112
+ hash.each_pair do |key, value|
113
+ # Reserved keys are gem-owned (`_tags`, `_source`) and are left
114
+ # exactly alone here, as they are in MetadataFilter.
115
+ if StandardAudit::RESERVED_METADATA_KEYS.include?(key.to_s)
116
+ mapped[key] = value
117
+ next
118
+ end
119
+
120
+ replacement = call(value, ancestors)
121
+ changed ||= !replacement.equal?(value)
122
+ mapped[key] = replacement
123
+ end
124
+
125
+ changed ? mapped : hash
126
+ end
127
+
128
+ # `to_global_id` raises for an unpersisted record and for a model that
129
+ # does not include GlobalID::Identification. Neither is a reason to fail
130
+ # an audit write, and neither is a reason to fall back to writing the
131
+ # attributes.
132
+ def global_id_for(record)
133
+ return nil unless record.respond_to?(:to_global_id)
134
+
135
+ record.to_global_id.to_s
136
+ rescue StandardError
137
+ nil
138
+ end
139
+ end
140
+ end
141
+ end
@@ -79,6 +79,16 @@ module StandardAudit
79
79
  raw_metadata = config.metadata_builder.call(raw_metadata)
80
80
  end
81
81
 
82
+ # ActiveRecord objects in the payload are replaced by a reference BEFORE
83
+ # any key-based redaction, because the leak they cause is not a
84
+ # sensitive *key* — it is a value that serialises as an entire database
85
+ # row (rarebit-one/rarebit-ops#296). Runs after `metadata_builder` so a
86
+ # host that derives fields from a record (`payload[:account].email`)
87
+ # still sees the record.
88
+ if config.dereference_record_metadata
89
+ raw_metadata = StandardAudit::RecordReference.call(raw_metadata)
90
+ end
91
+
82
92
  # Redaction lives in MetadataFilter, shared with StandardAudit.record.
83
93
  # This path previously carried its own copy that did *not* subtract
84
94
  # RESERVED_METADATA_KEYS, so `_tags`/`_source` were strippable here and
@@ -1,3 +1,3 @@
1
1
  module StandardAudit
2
- VERSION = "0.9.0"
2
+ VERSION = "0.11.0"
3
3
  end
@@ -2,6 +2,7 @@ require "standard_audit/version"
2
2
  require "standard_audit/engine"
3
3
  require "standard_audit/configuration"
4
4
  require "standard_audit/metadata_filter"
5
+ require "standard_audit/record_reference"
5
6
  require "standard_audit/sensitive_keys_dry_run"
6
7
  require "standard_audit/subscriber"
7
8
  require "standard_audit/event_subscriber"
@@ -52,9 +53,26 @@ module StandardAudit
52
53
 
53
54
  actor ||= config.current_actor_resolver.call
54
55
 
56
+ if block_given?
57
+ # Block form: instrument via ActiveSupport::Notifications and let the
58
+ # Subscriber write the row, which it does with its own dereferencing and
59
+ # filtering. Nothing built below would be used, so it is not built —
60
+ # dereferencing a Relation here would load it eagerly, before the block
61
+ # has run, purely to discard the result.
62
+ ActiveSupport::Notifications.instrument(event_type, metadata.merge(
63
+ actor: actor, target: target, scope: scope
64
+ )) do
65
+ yield
66
+ end
67
+ return
68
+ end
69
+
55
70
  # Redaction lives in MetadataFilter, shared with Subscriber, so the two
56
- # write paths cannot drift apart.
57
- filtered_metadata = MetadataFilter.call(metadata, config: config)
71
+ # write paths cannot drift apart. Record dereferencing is applied on both
72
+ # paths for the same reason: a snapshot of a whole row is as unrecoverable
73
+ # here as it is on the notifications path.
74
+ dereferenced = config.dereference_record_metadata ? RecordReference.call(metadata) : metadata
75
+ filtered_metadata = MetadataFilter.call(dereferenced, config: config)
58
76
 
59
77
  attrs = {
60
78
  event_type: event_type,
@@ -66,16 +84,6 @@ module StandardAudit
66
84
  metadata: filtered_metadata
67
85
  }
68
86
 
69
- if block_given?
70
- # Block form: instrument via ActiveSupport::Notifications
71
- ActiveSupport::Notifications.instrument(event_type, metadata.merge(
72
- actor: actor, target: target, scope: scope
73
- )) do
74
- yield
75
- end
76
- return
77
- end
78
-
79
87
  gid_attrs = {
80
88
  actor_gid: actor&.to_global_id&.to_s,
81
89
  actor_type: actor&.class&.name,
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_audit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -114,6 +114,7 @@ files:
114
114
  - lib/standard_audit/metadata_filter.rb
115
115
  - lib/standard_audit/operation.rb
116
116
  - lib/standard_audit/operation/audit.rb
117
+ - lib/standard_audit/record_reference.rb
117
118
  - lib/standard_audit/reference_preloading.rb
118
119
  - lib/standard_audit/rspec.rb
119
120
  - lib/standard_audit/rspec/operation.rb