standard_audit 0.6.0 → 0.7.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: 84f59bffd4df4a9174ba0a88645a4c7d5818eddf862b5badfb55c3880fb5849d
4
- data.tar.gz: 7747fdff93658f34f3ba3a9bfa70ca40fdc581125d182c996319a161f5c30809
3
+ metadata.gz: 1ab5fcec99d13f0796742d817c1dd42e45c0e8518f8967033a67194c3179ae6e
4
+ data.tar.gz: b0f6272a600a665615cc533b439ad2b654d54c025bd7bc2b378b5c2eac7e850d
5
5
  SHA512:
6
- metadata.gz: 03f2f84e0418106b8899b9aaff009e95fdbb116f9eb98c37308d2b4ae41df3cfbfe9d9b1615bdd4beb4391d983989cb658e4195451cb56214a75e4cf46ba4375
7
- data.tar.gz: 6b2e84e5bcd6e6e933535a6ae9e654deb3502e69b575d613044c7b02de843e2a8886c43a0c82bd8cb6313ff012510d36a52f290ca36dd778634c4b3c70cefb80
6
+ metadata.gz: 1ab1f514a13f1b03501cfdf18b3c2afcf8ff214a36992edb3e00f052384826c56bf09a35c4d7c4b8f92a4f28513cd8fe5534d946622112bb27bb6f8bfd8826c7
7
+ data.tar.gz: 7f9fc2acdb292b06f72b2323ac0c5c780348da8d1b91e9850ae73a61eba2361fb21b3aa431888de9d0003dc2934c0ba1b2a9a060bd4bd3b2a6a1bfb71eea5680
data/CHANGELOG.md CHANGED
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-07-30
11
+
12
+ ### Added
13
+
14
+ - **Batch actor/target/scope preloading.** `AuditLog#actor` / `#target` / `#scope` resolved their GlobalID one row at a time, so every audit list N+1'd. Two consuming apps had already fixed this locally, both by reaching into private ivars (`instance_variable_set(:@preloaded_actor, …)`) because the gem exposed no setter. The gem now owns it:
15
+ - `AuditLog.preload_references(logs, refs: %i[actor target], only: [...], includes: {...})` resolves a page in one query per distinct stored `*_type`, built on `GlobalID::Locator.locate_many` with `ignore_missing: true`.
16
+ - `preloaded_actor=` / `preloaded_target=` / `preloaded_scope=` public writers, plus `actor_preloaded?` predicates.
17
+ - The memo is a Hash consulted with `key?`, so *preloaded-but-deleted* memoizes `nil` and reads back without a query — distinct from *not preloaded*. `actor=` / `target=` / `scope=` populate the memo (they already hold the record); `reload` clears it.
18
+ - `only:` is matched against the **stored type string by class name**. `GlobalID::Locator`'s own `:only` is evaluated as `gid.model_class <= klass`, which constantizes the historical type string *before* deciding whether it was permitted — so a renamed or deleted class raises `NameError` rather than being denied. Consequence: `only:` does not expand to subclasses or modules; list every concrete class. A non-whitelisted reference memoizes `nil`.
19
+ - `includes:` is treated as a per-type map when every key is a String or Class (`{ "Order" => [:user] }`), and as a uniform Active Record includes spec otherwise (so `{ account: :identifiers }` still works as you'd expect).
20
+ - A type string that no longer constantizes — or a blank `*_type` on a row that still carries a `*_gid` — is left *unmemoized*, so the per-row reader behaves exactly as it did before preloading was attempted. Preloading can never turn a resolvable reference into a permanent `nil`.
21
+ - `AuditLog#actor_model_id` / `#target_model_id` / `#scope_model_id` (and `AuditLog.reference_model_id(gid)`) — the model id extracted straight from the gid string, a helper three separate consumer call sites had hand-rolled. Deliberately **not** `GlobalID.parse(gid)&.model_id`: audit rows are historical and append-only, so a gid whose app segment differs from the current `GlobalID.app` is a real row that must still yield its id. Mirrors `URI::GID`'s own decoding (drops `?params`, `CGI.unescape`s each segment, returns an Array for a composite primary key) while ignoring the app name, so it agrees with `GlobalID#model_id` wherever that works and keeps working where it doesn't.
22
+
23
+
24
+ - **`config.before_checksum`** — registers a hook that runs on `before_create` **between `assign_uuid` and `compute_checksum`**. Definition order is execution order, so a hook may set a `CHECKSUM_FIELDS` member (back-fill `scope`, derive a column, rewrite `metadata`) and the row still passes `AuditLog.verify_chain`. Hosts previously had to register their own `before_create ..., prepend: true` to beat the gem's checksum callback — fragile ordering knowledge no host should need.
25
+ ```ruby
26
+ config.before_checksum { |log| log.scope = MyApp.derive_scope(log) }
27
+ config.before_checksum :backfill_scope # an AuditLog instance method
28
+ ```
29
+ Hooks accumulate and run in registration order. **Each is rescued individually** — a failing hook logs (and reports to `Rails.error`), is **rolled back to the attributes it started from**, and is skipped; the remaining hooks still run and the audit write never fails. The rollback is what makes "skipped" true: without it, a hook that assigns `scope_gid` and then fails a later lookup would leave a half-applied row for the following callbacks to checksum and persist. If a row is created with an explicit `checksum`, hooks still run (most derived columns are not checksummed) but the supplied digest is dropped and re-derived when a hook changed a `CHECKSUM_FIELDS` member. Hooks do not run on the batched `insert_all!` path, which never instantiates a model.
30
+ - **`StandardAudit.configure(baseline: true)`** — remembers the block so `reset_configuration!` replays it onto the fresh Configuration. **Required companion to the `before_checksum` hooks, not optional.** The config object holds *behaviour*, not just data, so a suite that installs `standard_audit/rspec` (whose per-example `reset_configuration!` restores gem defaults) would silently lose its write-time hooks after the first example — and the specs that would have caught it pass vacuously. The install template now uses `configure(baseline: true)`, and the `lib/standard_audit/rspec.rb` docstring no longer instructs hosts to re-apply configuration by hand. `reset_configuration!(replay_baseline: false)`, `clear_baseline_configuration!` and `baseline_configured?` round it out. A plain `configure` is unchanged and registers nothing.
31
+
32
+
33
+ - **`config.sensitive_key_patterns`** (Array of Regexp, always applied, default `[]`) — the supported way to catch a family of keys. Stripe's `client_secret` slipping past the exact-match `:secret` is solved by `/secret/i`.
34
+
35
+ **There is deliberately no substring matching mode**, and one should not be added. Against the current default key list, substring matching strips real audit content across the estate: `input_tokens` / `output_tokens` (live LLM cost accounting in luminality and sidekick), `token_digest` (rendered in luminality's staff audit UI), `password_reset_sent_at`, `authorization_endpoint`, even `onepassword`. Audit rows are append-only, so it cannot be undone — the content is simply never written from then on. Patterns are opt-in, per-app, and checkable in advance.
36
+ - **`config.sensitive_key_exceptions`** (Array of exact names or Regexps, default `[]`) — never redacted, even when `sensitive_keys` or a pattern matches. Lets an app adopt `[/token/i]` while keeping `input_tokens` / `output_tokens`.
37
+ - **`rake standard_audit:sensitive_keys:dry_run`** — read-only. Reports, per metadata key, what a candidate rule *would* have stripped from the rows you already have, what it would keep, and any nested matches that survive because `filter_nested_metadata` is off. Turns "is this rule safe for my app?" into a command rather than a guess, which matters precisely because the rows are append-only.
38
+ ```bash
39
+ bin/rails standard_audit:sensitive_keys:dry_run # current config
40
+ bin/rails "standard_audit:sensitive_keys:dry_run[secret]" # candidate pattern
41
+ NESTED=1 bin/rails "standard_audit:sensitive_keys:dry_run[secret|token]"
42
+ ```
43
+ Backed by `StandardAudit::SensitiveKeysDryRun.call(...)`, which extracts keys **in Ruby** rather than with `jsonb_object_keys` so it stays backend-neutral.
44
+ - `StandardAudit::MetadataFilter` — `MetadataFilter.call(metadata, config:)` filters metadata; `MetadataFilter.new.filter?(key)` answers the same question for a single key without writing a row. Matching stays **exact on the key name** (string/symbol insensitive), unchanged from 0.6.0. Anything hash-like is filtered (so `ActionController::Parameters` is redacted, not waved through because it isn't a `Hash`); `nil` passes; anything else raises `MetadataFilter::UnfilterableMetadataError` rather than writing unfiltered content to an append-only row.
45
+
46
+ ### Fixed
47
+
48
+ - **Nested metadata was never redacted on either write path.** `metadata: { stripe: { client_secret: … } }` was written intact even under exact matching — a larger real leak than the top-level case. Opt in with `config.filter_nested_metadata = true` (default `false`, so 0.6.0 behaviour is preserved); redaction then descends into nested Hashes and Hashes inside Arrays. `RESERVED_METADATA_KEYS` (`_tags`, `_source`) are preserved *and their subtree is never descended into, at every depth* — they are gem-owned, and `event_subscriber.rb` sets them after building metadata.
49
+ - **The two write paths applied different sensitive-key filters.** `StandardAudit.record` and `StandardAudit::Subscriber#extract_metadata` each carried an independent copy of the redaction logic, and they had already diverged: the subscriber copy did not subtract `RESERVED_METADATA_KEYS`, so an app that added `:_tags` or `:_source` to `sensitive_keys` had the reserved key preserved on the `record` path and stripped on the `ActiveSupport::Notifications` path. Both now call the single `StandardAudit::MetadataFilter`, and the divergence is resolved in favour of `record`'s behaviour — reserved keys can never be filtered on either path. Parity is driven from one shared example (`spec/support/shared_examples/metadata_filtering.rb`), so a future divergence fails the suite rather than shipping.
50
+
51
+ ### Notes
52
+
53
+ - `StandardAudit.batch { ... }` flushes via `insert_all!` and never instantiates a model, so none of the above runs on the batched write path — the memo is a no-op there by construction.
54
+ - Minor behaviour change on a single instance: `log.actor = user; user.destroy!; log.actor` now returns the (destroyed) in-memory record instead of `nil`, because the writer memoizes. Reading the row fresh (or calling `reload`) is unchanged and still returns `nil`. This matches how Active Record association writers behave.
55
+
10
56
  ## [0.6.0] - 2026-06-24
11
57
 
12
58
  ### Added
data/README.md CHANGED
@@ -164,8 +164,13 @@ organisation.scoped_audit_logs # all logs scoped to this organisation
164
164
 
165
165
  ## Configuration Reference
166
166
 
167
+ Use `configure(baseline: true)` in your initializer. It remembers the block so
168
+ `StandardAudit.reset_configuration!` replays it — required if your suite loads
169
+ `standard_audit/rspec`, because the config object holds behaviour (`before_checksum`
170
+ hooks) as well as data, and a per-example reset would otherwise drop it.
171
+
167
172
  ```ruby
168
- StandardAudit.configure do |config|
173
+ StandardAudit.configure(baseline: true) do |config|
169
174
  # -- Subscriptions --
170
175
  # Subscribe to ActiveSupport::Notifications patterns.
171
176
  # Supports wildcards.
@@ -189,9 +194,30 @@ StandardAudit.configure do |config|
189
194
  config.current_session_id_resolver = -> { Current.session_id }
190
195
 
191
196
  # -- Sensitive Data --
192
- # Keys automatically stripped from metadata.
197
+ # Keys automatically stripped from metadata. Matching is EXACT on the key
198
+ # name; there is deliberately no substring mode (see below).
193
199
  config.sensitive_keys += %i[my_custom_secret] # added to built-in defaults
194
200
 
201
+ # Regexps matched against every key name, in addition to the exact list.
202
+ # Solves e.g. Stripe's `client_secret`, which does not equal `:secret`.
203
+ config.sensitive_key_patterns = [/secret/i]
204
+
205
+ # Keys (exact names or Regexps) that are never redacted, so a broad pattern
206
+ # can keep the real audit keys it would otherwise swallow.
207
+ config.sensitive_key_exceptions = %i[input_tokens output_tokens]
208
+
209
+ # Descend into nested Hashes when redacting. OFF by default — without it,
210
+ # `metadata: { stripe: { client_secret: ... } }` is written intact.
211
+ config.filter_nested_metadata = true
212
+
213
+ # -- Write-time hooks --
214
+ # Run between the UUID assignment and the checksum computation, so a hook MAY
215
+ # set a checksummed column and the row still passes `verify_chain`. No
216
+ # `prepend: true` needed. Each hook is rescued individually and can never fail
217
+ # the audit write. Not run on the batched `insert_all!` path.
218
+ config.before_checksum { |log| log.scope = MyApp.derive_scope(log) }
219
+ config.before_checksum :backfill_scope # an AuditLog instance method
220
+
195
221
  # -- Metadata Builder --
196
222
  # Optional proc to transform metadata before storage.
197
223
  config.metadata_builder = ->(metadata) { metadata.slice(:relevant_key) }
@@ -423,8 +449,30 @@ t.jsonb :metadata, default: {}
423
449
 
424
450
  ```ruby
425
451
  config.sensitive_keys += %i[medical_record_number] # extend the built-in defaults
452
+ config.sensitive_key_patterns = [/secret/i] # catch a whole family
453
+ config.sensitive_key_exceptions = %i[input_tokens] # ...minus the real ones
454
+ config.filter_nested_metadata = true # redact nested Hashes too
426
455
  ```
427
456
 
457
+ `sensitive_keys` matches **exactly** on the key name. There is deliberately no
458
+ substring mode: against the default list it would strip real audit content —
459
+ `input_tokens` / `output_tokens`, `token_digest`, `password_reset_sent_at`,
460
+ `authorization_endpoint`, `onepassword`. Audit rows are append-only, so that
461
+ cannot be undone. Use `sensitive_key_patterns` and check any rule against your
462
+ own data first:
463
+
464
+ ```bash
465
+ bin/rails "standard_audit:sensitive_keys:dry_run[secret]" # NESTED=1 to model nesting
466
+ ```
467
+
468
+ The dry run writes nothing. It reports per key what would be stripped, what
469
+ would be kept, and nested matches that survive because
470
+ `filter_nested_metadata` is off.
471
+
472
+ Nested metadata is **not** redacted unless `filter_nested_metadata` is enabled
473
+ — `metadata: { stripe: { client_secret: ... } }` passes both write paths under
474
+ exact matching by default.
475
+
428
476
  **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.
429
477
 
430
478
  **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.
@@ -2,6 +2,8 @@ require "openssl"
2
2
 
3
3
  module StandardAudit
4
4
  class AuditLog < ApplicationRecord
5
+ include StandardAudit::ReferencePreloading
6
+
5
7
  self.table_name = "audit_logs"
6
8
 
7
9
  CHECKSUM_FIELDS = %w[
@@ -10,7 +12,15 @@ module StandardAudit
10
12
  user_agent session_id occurred_at
11
13
  ].freeze
12
14
 
15
+ # Callback ORDER IS THE CONTRACT here. Definition order is execution
16
+ # order, so registering the host hook list between assign_uuid and
17
+ # compute_checksum means a hook can set a CHECKSUM_FIELDS member (e.g.
18
+ # back-fill `scope`) and the row still verifies. Hosts previously had to
19
+ # register `before_create ..., prepend: true` themselves to beat
20
+ # compute_checksum — fragile ordering knowledge no host should need.
21
+ # Do not reorder these three lines.
13
22
  before_create :assign_uuid, if: -> { id.blank? }
23
+ before_create :run_before_checksum_hooks
14
24
  before_create :compute_checksum, if: -> { checksum.blank? }
15
25
  after_create_commit :emit_created_event
16
26
 
@@ -24,61 +34,35 @@ module StandardAudit
24
34
  validates :event_type, presence: true
25
35
  validates :occurred_at, presence: true
26
36
 
27
- # -- Actor assignment via GlobalID --
37
+ # -- actor / target / scope assignment via GlobalID --
38
+ #
39
+ # Reads consult the preload memo first (see ReferencePreloading), then fall
40
+ # back to a single `GlobalID::Locator.locate`. Writers populate the memo,
41
+ # since they already hold the record. If the underlying record was deleted
42
+ # the reader returns nil while the gid and type stay on the row.
28
43
 
29
44
  def actor=(record)
30
- if record.nil?
31
- self.actor_gid = nil
32
- self.actor_type = nil
33
- else
34
- self.actor_gid = record.to_global_id.to_s
35
- self.actor_type = record.class.name
36
- end
45
+ assign_reference(:actor, record)
37
46
  end
38
47
 
39
48
  def actor
40
- return nil if actor_gid.blank?
41
- GlobalID::Locator.locate(actor_gid)
42
- rescue ActiveRecord::RecordNotFound
43
- nil
49
+ read_reference(:actor)
44
50
  end
45
51
 
46
- # -- Target assignment via GlobalID --
47
-
48
52
  def target=(record)
49
- if record.nil?
50
- self.target_gid = nil
51
- self.target_type = nil
52
- else
53
- self.target_gid = record.to_global_id.to_s
54
- self.target_type = record.class.name
55
- end
53
+ assign_reference(:target, record)
56
54
  end
57
55
 
58
56
  def target
59
- return nil if target_gid.blank?
60
- GlobalID::Locator.locate(target_gid)
61
- rescue ActiveRecord::RecordNotFound
62
- nil
57
+ read_reference(:target)
63
58
  end
64
59
 
65
- # -- Scope assignment via GlobalID --
66
-
67
60
  def scope=(record)
68
- if record.nil?
69
- self.scope_gid = nil
70
- self.scope_type = nil
71
- else
72
- self.scope_gid = record.to_global_id.to_s
73
- self.scope_type = record.class.name
74
- end
61
+ assign_reference(:scope, record)
75
62
  end
76
63
 
77
64
  def scope
78
- return nil if scope_gid.blank?
79
- GlobalID::Locator.locate(scope_gid)
80
- rescue ActiveRecord::RecordNotFound
81
- nil
65
+ read_reference(:scope)
82
66
  end
83
67
 
84
68
  # -- Query scopes --
@@ -280,5 +264,60 @@ module StandardAudit
280
264
  def assign_uuid
281
265
  self.id = SecureRandom.uuid_v7
282
266
  end
267
+
268
+ # Runs config.before_checksum_hooks in registration order. Each hook is
269
+ # rescued individually: an audit write must never fail because a host's
270
+ # derived-column logic did. A hook that raises is rolled back to the
271
+ # attributes it started from and skipped; the remaining hooks still run.
272
+ def run_before_checksum_hooks
273
+ hooks = StandardAudit.config.before_checksum_hooks
274
+ return if hooks.blank?
275
+
276
+ # Only relevant when a checksum was supplied explicitly (the normal path
277
+ # has none yet, and compute_checksum runs next).
278
+ checksummed_before = checksum.present? ? attributes.slice(*CHECKSUM_FIELDS) : nil
279
+
280
+ hooks.each { |hook| run_before_checksum_hook(hook) }
281
+
282
+ # A caller-supplied checksum stops describing the row the moment a hook
283
+ # changes a checksummed field. Dropping it lets compute_checksum
284
+ # re-derive one, rather than persisting a row that fails verify_chain
285
+ # immediately. Hooks still run for such rows, because most derived
286
+ # columns (actor_role and friends) are not checksummed at all.
287
+ self.checksum = nil if checksummed_before && attributes.slice(*CHECKSUM_FIELDS) != checksummed_before
288
+ end
289
+
290
+ def run_before_checksum_hook(hook)
291
+ snapshot = attributes.deep_dup
292
+
293
+ begin
294
+ case hook
295
+ when Symbol, String then send(hook)
296
+ else hook.call(self)
297
+ end
298
+ rescue StandardError => e
299
+ # Roll the record back to where the hook found it. Without this a hook
300
+ # that assigns scope_gid and then fails a later lookup leaves a
301
+ # half-applied row that the following callbacks happily checksum and
302
+ # persist — so the hook would not actually be "skipped".
303
+ restore_attributes_from(snapshot)
304
+ Rails.logger.warn("[StandardAudit] before_checksum hook failed: #{e.class}: #{e.message}")
305
+ Rails.error.report(e, handled: true, context: { audit_event: event_type }) if Rails.respond_to?(:error)
306
+ nil
307
+ end
308
+ end
309
+
310
+ def restore_attributes_from(snapshot)
311
+ snapshot.each do |name, value|
312
+ write_attribute(name, value) unless read_attribute(name) == value
313
+ end
314
+
315
+ # The reference memo may hold a record the hook assigned; drop it so the
316
+ # restored gid columns are the source of truth again.
317
+ @preloaded_references = nil
318
+ rescue StandardError => e
319
+ Rails.logger.warn("[StandardAudit] could not roll back a failed before_checksum hook: #{e.class}: #{e.message}")
320
+ nil
321
+ end
283
322
  end
284
323
  end
@@ -1,4 +1,8 @@
1
- StandardAudit.configure do |config|
1
+ # `baseline: true` remembers this block so `StandardAudit.reset_configuration!`
2
+ # replays it. Required if you `require "standard_audit/rspec"` in your suite:
3
+ # without it, every example resets to gem defaults and loses the configuration
4
+ # below — including any `before_checksum` hooks, which are behaviour, not data.
5
+ StandardAudit.configure(baseline: true) do |config|
2
6
  # Subscribe to ActiveSupport::Notifications / Rails.event patterns.
3
7
  # Each gem documents its own event namespace; subscribe to whichever
4
8
  # patterns you want audited:
@@ -27,10 +31,35 @@ StandardAudit.configure do |config|
27
31
  # config.current_user_agent_resolver = -> { Current.user_agent }
28
32
  # config.current_session_id_resolver = -> { Current.session_id }
29
33
 
30
- # Keys to strip from metadata (defaults include: password, password_confirmation,
31
- # token, secret, api_key, access_token, refresh_token, private_key,
32
- # certificate_chain, ssn, credit_card, authorization)
34
+ # -- Metadata redaction ----------------------------------------------------
35
+ #
36
+ # Keys to strip from metadata. Matching is EXACT on the key name (defaults
37
+ # include: password, password_confirmation, token, secret, api_key,
38
+ # access_token, refresh_token, private_key, certificate_chain, ssn,
39
+ # credit_card, authorization).
33
40
  # config.sensitive_keys += %i[my_custom_secret]
41
+ #
42
+ # Regexps matched against every key name, in addition to the exact list.
43
+ # This is the supported way to catch a family of keys — e.g. Stripe's
44
+ # `client_secret`, which does not equal the default `:secret`:
45
+ # config.sensitive_key_patterns = [/secret/i]
46
+ #
47
+ # There is deliberately no "substring" matching mode. Against the default
48
+ # key list it would strip real audit content: input_tokens/output_tokens,
49
+ # token_digest, password_reset_sent_at, authorization_endpoint, onepassword.
50
+ # Audit rows are append-only, so that cannot be undone. Check any rule
51
+ # against your own data first:
52
+ #
53
+ # bin/rails "standard_audit:sensitive_keys:dry_run[secret]"
54
+ #
55
+ # Keys (exact names or Regexps) that are never redacted, so a broad pattern
56
+ # can keep the handful of real audit keys it would otherwise swallow:
57
+ # config.sensitive_key_exceptions = %i[input_tokens output_tokens]
58
+ #
59
+ # Descend into nested Hashes when redacting. Off by default because it
60
+ # changes what gets written; `metadata: { stripe: { client_secret: ... } }`
61
+ # is NOT redacted unless you turn this on. Run the dry run first.
62
+ # config.filter_nested_metadata = true
34
63
 
35
64
  # Run audit log creation in background job
36
65
  # config.async = false
@@ -44,4 +73,17 @@ StandardAudit.configure do |config|
44
73
 
45
74
  # Data retention (schedule StandardAudit::CleanupJob to enforce)
46
75
  # config.retention_days = nil
76
+
77
+ # -- Write-time hooks -------------------------------------------------------
78
+ #
79
+ # Run between the UUID assignment and the checksum computation, so a hook MAY
80
+ # set a checksummed column (scope_gid, metadata, ...) and the row still passes
81
+ # `AuditLog.verify_chain`. No `prepend: true` needed.
82
+ #
83
+ # Each hook is rescued individually — a failing hook is logged and skipped and
84
+ # never fails the audit write. Batched writes (StandardAudit.batch, which uses
85
+ # insert_all!) never instantiate a model, so hooks do not run there.
86
+ #
87
+ # config.before_checksum { |log| log.scope = MyApp.derive_scope(log) }
88
+ # config.before_checksum :backfill_scope # an AuditLog instance method
47
89
  end
@@ -5,7 +5,9 @@ module StandardAudit
5
5
  :current_actor_resolver, :current_request_id_resolver,
6
6
  :current_ip_address_resolver, :current_user_agent_resolver,
7
7
  :current_session_id_resolver,
8
- :sensitive_keys, :metadata_builder,
8
+ :sensitive_keys, :sensitive_key_patterns,
9
+ :sensitive_key_exceptions, :filter_nested_metadata,
10
+ :metadata_builder, :before_checksum_hooks,
9
11
  :anonymizable_metadata_keys, :retention_days
10
12
 
11
13
  def initialize
@@ -43,7 +45,37 @@ module StandardAudit
43
45
  private_key certificate_chain
44
46
  ssn credit_card authorization
45
47
  ]
48
+ # Regexps matched against every metadata key name, in addition to the
49
+ # exact-match `sensitive_keys` list. This is the supported way to catch a
50
+ # family of keys: `/secret/i` redacts `client_secret`, `webhook_secret`,
51
+ # and `secret` alike.
52
+ #
53
+ # There is deliberately NO substring *mode* for `sensitive_keys` — see
54
+ # the note in MetadataFilter. Patterns are opt-in and per-app, which is
55
+ # the only safe shape for a rule applied to append-only rows.
56
+ @sensitive_key_patterns = []
57
+
58
+ # Key names (String/Symbol, exact) or Regexps that are never redacted,
59
+ # even when `sensitive_keys` or `sensitive_key_patterns` matches. Lets an
60
+ # app adopt a broad pattern while keeping the handful of real audit keys
61
+ # it would otherwise swallow, e.g.
62
+ # `sensitive_key_patterns = [/token/i]` with
63
+ # `sensitive_key_exceptions = %i[input_tokens output_tokens]`.
64
+ @sensitive_key_exceptions = []
65
+
66
+ # When true, redaction descends into nested Hashes (and Hashes inside
67
+ # Arrays), so `metadata: { stripe: { client_secret: … } }` is caught.
68
+ # Defaults to false: it changes what gets written, and audit rows are
69
+ # append-only. Reserved keys are never descended into.
70
+ @filter_nested_metadata = false
71
+
46
72
  @metadata_builder = nil
73
+
74
+ # Callables (or Symbols naming an AuditLog instance method) run on
75
+ # `before_create` AFTER the UUID is assigned and BEFORE the checksum is
76
+ # computed. See Configuration#before_checksum.
77
+ @before_checksum_hooks = []
78
+
47
79
  @anonymizable_metadata_keys = %i[email name ip_address]
48
80
 
49
81
  # Retention defaults from ENV so it can be set per-environment without a
@@ -63,6 +95,37 @@ module StandardAudit
63
95
  days&.positive? ? days : nil
64
96
  end
65
97
 
98
+ # Registers a hook to run between `assign_uuid` and `compute_checksum` on
99
+ # every audit write that instantiates a model.
100
+ #
101
+ # config.before_checksum { |log| log.scope = derive_scope(log) }
102
+ # config.before_checksum :backfill_organization_scope
103
+ #
104
+ # A hook may set a `CHECKSUM_FIELDS` member (`scope_gid`, `metadata`, …) and
105
+ # the row will still verify, because the checksum is computed afterwards.
106
+ # That is the whole point: before this existed, hosts had to register their
107
+ # own `before_create ..., prepend: true` to beat the gem's checksum
108
+ # callback, which is fragile ordering knowledge no host should need.
109
+ #
110
+ # Hooks accumulate and run in registration order. Each is rescued
111
+ # individually — a failing hook logs and is skipped; it never fails the
112
+ # audit write.
113
+ #
114
+ # A Symbol/String is sent to the AuditLog instance (use this for methods
115
+ # supplied by a concern mixed into the model). A callable is passed the
116
+ # instance.
117
+ #
118
+ # NOTE: batched writes (`StandardAudit.batch { … }` → `insert_all!`) never
119
+ # instantiate a model, so hooks do not run there. A batched writer that
120
+ # needs a derived column has to set it on the buffered attrs.
121
+ def before_checksum(hook = nil, &block)
122
+ hook ||= block
123
+ raise ArgumentError, "before_checksum needs a callable, a Symbol, or a block" if hook.nil?
124
+
125
+ @before_checksum_hooks << hook
126
+ hook
127
+ end
128
+
66
129
  def subscribe_to(pattern)
67
130
  @subscriptions << pattern
68
131
  end
@@ -0,0 +1,155 @@
1
+ module StandardAudit
2
+ # The single implementation of sensitive-key redaction for audit metadata.
3
+ #
4
+ # There used to be two: one in `StandardAudit.record` and one in
5
+ # `Subscriber#extract_metadata`. They had already diverged — the subscriber
6
+ # copy did not subtract `RESERVED_METADATA_KEYS`, so an app that (reasonably)
7
+ # added `:_tags` to `sensitive_keys` got the reserved key preserved on the
8
+ # `record` path and stripped on the `ActiveSupport::Notifications` path. Two
9
+ # copies of a security filter drifting apart is exactly the failure mode
10
+ # worth designing out, so both paths now call here.
11
+ #
12
+ # The divergence is resolved in favour of `record`'s behaviour: reserved keys
13
+ # are subtracted from the sensitive set and can never be filtered.
14
+ #
15
+ # == Matching
16
+ #
17
+ # A key is redacted when it matches `config.sensitive_keys` **exactly** (by
18
+ # name, string/symbol insensitive) or matches one of
19
+ # `config.sensitive_key_patterns` (Regexps, always applied), unless it is
20
+ # listed in `config.sensitive_key_exceptions` or is a reserved key.
21
+ #
22
+ # == Why there is no substring mode
23
+ #
24
+ # Matching `sensitive_keys` by substring instead of exactly is the obvious
25
+ # "fix" for `client_secret` not matching `:secret`, and it is a trap. Against
26
+ # the current default key list it would strip real audit content across the
27
+ # estate:
28
+ #
29
+ # :token => input_tokens, output_tokens (live LLM cost accounting),
30
+ # token_digest (rendered in a staff audit UI)
31
+ # :password => password_reset_sent_at, onepassword
32
+ # :authorization => authorization_endpoint
33
+ #
34
+ # Audit rows are append-only, so a bad default cannot be undone — the content
35
+ # is simply never written. `sensitive_key_patterns` is the supported tool
36
+ # instead: `/secret/i` solves the motivating `client_secret` case exactly,
37
+ # opt-in and per-app, and `rake standard_audit:sensitive_keys:dry_run` turns
38
+ # "is this rule safe for my data?" into a command rather than a guess.
39
+ class MetadataFilter
40
+ # Raised when metadata is neither nil nor hash-like. Deliberately a raise
41
+ # rather than a pass-through: this filter **fails closed**. An
42
+ # `ActionController::Parameters` is not a `Hash`, and letting an unrecognised
43
+ # object through unfiltered would write raw params — passwords included —
44
+ # into an append-only row. Pre-0.7.0 the same input blew up with
45
+ # `NoMethodError` on `#reject`, so raising preserves the outcome while
46
+ # naming the cause.
47
+ class UnfilterableMetadataError < ArgumentError; end
48
+
49
+ class << self
50
+ def call(metadata, config: StandardAudit.config)
51
+ new(config: config).call(metadata)
52
+ end
53
+ end
54
+
55
+ def initialize(config: StandardAudit.config)
56
+ @config = config
57
+ end
58
+
59
+ # Filters anything hash-like, not just `Hash` — `ActionController::Parameters`
60
+ # and other `each_pair`-able wrappers are filtered rather than waved
61
+ # through. `nil` passes (there is nothing to leak); anything else raises.
62
+ #
63
+ # Descends into nested Hashes (and Hashes inside Arrays) only when
64
+ # `config.filter_nested_metadata` is true. Reserved keys are preserved and
65
+ # their values are left entirely alone, **at every depth** — `_tags` and
66
+ # `_source` are gem-owned, not host payload, and immunity that held only at
67
+ # the top level would be a confusing half-guarantee.
68
+ def call(metadata)
69
+ return nil if metadata.nil?
70
+
71
+ unless hash_like?(metadata)
72
+ raise UnfilterableMetadataError,
73
+ "audit metadata must be nil or hash-like (got #{metadata.class}); " \
74
+ "refusing to write it unfiltered"
75
+ end
76
+
77
+ filtered = metadata.reject { |key, _| filter?(key) }
78
+ return filtered unless @config.filter_nested_metadata
79
+
80
+ filter_values(filtered)
81
+ end
82
+
83
+ # True when `key` would be redacted from metadata. Public so hosts (and the
84
+ # dry-run tooling) can ask the question without writing a row.
85
+ def filter?(key)
86
+ key = key.to_s
87
+
88
+ # `_tags` and `_source` are owned by EventSubscriber and are never
89
+ # stripped, even if a consumer lists them in `sensitive_keys`.
90
+ return false if StandardAudit::RESERVED_METADATA_KEYS.include?(key)
91
+ return false if exception?(key)
92
+
93
+ sensitive_keys.include?(key) || sensitive_key_patterns.any? { |pattern| pattern.match?(key) }
94
+ end
95
+
96
+ private
97
+
98
+ def hash_like?(value)
99
+ value.respond_to?(:each_pair) && value.respond_to?(:reject)
100
+ end
101
+
102
+ # Rewrites each value in place on the already-rejected copy. In place
103
+ # because `transform_values` is not guaranteed across every hash-like
104
+ # wrapper, whereas `[]=` is.
105
+ def filter_values(hash)
106
+ hash.each_pair do |key, value|
107
+ # Reserved subtree: immune at every depth, never descended into.
108
+ next if StandardAudit::RESERVED_METADATA_KEYS.include?(key.to_s)
109
+
110
+ replacement = filter_descendant(value)
111
+ hash[key] = replacement unless replacement.equal?(value)
112
+ end
113
+
114
+ hash
115
+ end
116
+
117
+ def filter_descendant(value)
118
+ if hash_like?(value)
119
+ filter_values(value.reject { |key, _| filter?(key) })
120
+ elsif value.is_a?(Array)
121
+ value.map { |element| filter_descendant(element) }
122
+ else
123
+ value
124
+ end
125
+ end
126
+
127
+ def sensitive_keys
128
+ @sensitive_keys ||= @config.sensitive_keys.map(&:to_s) - StandardAudit::RESERVED_METADATA_KEYS
129
+ end
130
+
131
+ # A String entry is read as a Regexp source, so the rake task (and a
132
+ # `SENSITIVE_KEY_PATTERNS` env var) can pass one through without eval.
133
+ def sensitive_key_patterns
134
+ @sensitive_key_patterns ||= Array(@config.sensitive_key_patterns).map do |pattern|
135
+ pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern.to_s)
136
+ end
137
+ end
138
+
139
+ def exception?(key)
140
+ exact_exceptions.include?(key) || pattern_exceptions.any? { |pattern| pattern.match?(key) }
141
+ end
142
+
143
+ def exceptions
144
+ @exceptions ||= Array(@config.sensitive_key_exceptions).partition { |entry| entry.is_a?(Regexp) }
145
+ end
146
+
147
+ def pattern_exceptions
148
+ exceptions.first
149
+ end
150
+
151
+ def exact_exceptions
152
+ @exact_exceptions ||= exceptions.last.map(&:to_s)
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,298 @@
1
+ require "cgi"
2
+ require "active_support/concern"
3
+
4
+ module StandardAudit
5
+ # Batch resolution of the GlobalID-backed `actor` / `target` / `scope`
6
+ # references on a page of audit rows, plus the per-row memo the readers
7
+ # consult.
8
+ #
9
+ # Why this exists: `AuditLog#actor` resolves `actor_gid` through
10
+ # `GlobalID::Locator.locate`, which is one query per row. Rendering N rows
11
+ # that each read `actor` and `target` issues O(N) lookups — an N+1 that
12
+ # Prosopite fails in every consuming app. Before this concern existed, apps
13
+ # worked around it by defining their own `preloaded_actor=` writer (or
14
+ # reaching into `@preloaded_actor` with `instance_variable_set`) and
15
+ # hand-rolling a per-type whitelist loader.
16
+ #
17
+ # == Memo semantics
18
+ #
19
+ # The memo is a Hash consulted with `key?`, not a pair of ivars checked with
20
+ # `defined?`. That distinction matters: a reference that was preloaded but
21
+ # whose record has since been deleted memoizes `nil`, and reads back as
22
+ # `nil` *without* falling through to a query. "Preloaded, and the answer is
23
+ # nothing" is therefore distinct from "not preloaded".
24
+ #
25
+ # `actor=` / `target=` / `scope=` populate the memo too, since the writer
26
+ # already holds the record. `reload` clears it.
27
+ #
28
+ # == Batched writes
29
+ #
30
+ # `StandardAudit.batch { ... }` flushes through `insert_all!` and never
31
+ # instantiates an AuditLog, so nothing in this concern runs on that path —
32
+ # neither the memo nor the writers. It is a no-op for batched writes by
33
+ # construction, not by accident.
34
+ module ReferencePreloading
35
+ extend ActiveSupport::Concern
36
+
37
+ # The GlobalID-backed reference columns. `scope` is preloadable but not in
38
+ # the default `refs:` set, since most read surfaces render actor/target
39
+ # only.
40
+ REFERENCES = %i[actor target scope].freeze
41
+
42
+ DEFAULT_REFS = %i[actor target].freeze
43
+
44
+ class_methods do
45
+ # Resolves the given references for a whole collection of audit logs in a
46
+ # fixed number of queries (one per distinct stored `*_type`), then
47
+ # memoizes the result on each row.
48
+ #
49
+ # AuditLog.preload_references(
50
+ # logs,
51
+ # refs: %i[actor target],
52
+ # only: [Account, Profile, Order],
53
+ # includes: { "Profile" => [:account], "Order" => [:user] }
54
+ # )
55
+ #
56
+ # [refs:] Which references to resolve. Defaults to `%i[actor target]`.
57
+ # [only:] Optional whitelist of permitted classes. See the note below —
58
+ # this is matched against the *stored type string*, so it is
59
+ # a stricter gate than `GlobalID::Locator`'s own `:only`.
60
+ # [includes:] Either a uniform Active Record `includes` spec applied to
61
+ # every type, or a per-type Hash keyed by class name String
62
+ # or Class constant (e.g. `{ "Profile" => [:account] }`).
63
+ # A Hash counts as per-type only when *every* key is a String
64
+ # or a Module; `{ account: :identifiers }` is therefore read
65
+ # as a uniform nested-includes spec, as you would expect.
66
+ #
67
+ # == Why `only:` is matched on the stored string
68
+ #
69
+ # `GlobalID::Locator`'s `:only` option is evaluated as
70
+ # `gid.model_class <= klass`, which means it *constantizes the stored
71
+ # type string before deciding whether it was allowed*. That is the wrong
72
+ # order for audit rows, whose type strings are historical: a class that
73
+ # has since been renamed or removed raises `NameError` rather than being
74
+ # filtered out. So this method filters on the stored `*_type` string
75
+ # first, by class name, and only then hands the surviving gids to
76
+ # `locate_many` (still passing `only:` as a second gate).
77
+ #
78
+ # The consequence is that `only:` does **not** expand to subclasses or
79
+ # to modules the way GlobalID's does — list every concrete class whose
80
+ # name you expect to see in `actor_type` / `target_type`. Deny-by-default
81
+ # is the safe direction for a whitelist.
82
+ #
83
+ # A reference whose type is not in `only:` memoizes `nil`, so it reads
84
+ # back as nil without a query rather than silently re-N+1ing.
85
+ #
86
+ # Returns the logs as an Array.
87
+ def preload_references(logs, refs: DEFAULT_REFS, only: nil, includes: nil)
88
+ logs = logs.to_a
89
+ return logs if logs.empty?
90
+
91
+ Array(refs).each do |ref|
92
+ ref = ref.to_sym
93
+ unless REFERENCES.include?(ref)
94
+ raise ArgumentError, "unknown audit reference #{ref.inspect} (expected one of #{REFERENCES.inspect})"
95
+ end
96
+
97
+ preload_one_reference(logs, ref, only: only, includes: includes)
98
+ end
99
+
100
+ logs
101
+ end
102
+
103
+ # Extracts the model id from a GlobalID string without resolving it:
104
+ # "gid://some-app/Account/123" => "123".
105
+ #
106
+ # Deliberately *not* `GlobalID.parse(gid)&.model_id`. Audit gids are
107
+ # historical and the rows are append-only, so a gid written by another
108
+ # app — or before this app was renamed — is a real, permanent row whose
109
+ # id must still be readable. Parsing it back through the locator
110
+ # machinery resolves against the *current* `GlobalID.app`, which is not
111
+ # what these rows are keyed on.
112
+ #
113
+ # This mirrors `URI::GID#set_model_components` exactly, minus the app
114
+ # check and the validations: drop any `?params` query, take everything
115
+ # after the model-name segment, split composite ids on "/", and
116
+ # `CGI.unescape` each part (`URI::GID.build` `CGI.escape`s them). Returns
117
+ # a String for a single-column primary key and an Array of Strings for a
118
+ # composite one, matching `GlobalID#model_id`.
119
+ def reference_model_id(gid)
120
+ return nil if gid.blank?
121
+
122
+ # "gid:" , "" , app , ModelName , <id segment(s)>
123
+ segment = gid.to_s.split("?", 2).first.to_s.split("/", 5)[4]
124
+ return nil if segment.blank?
125
+
126
+ parts = segment.split("/").reject(&:blank?).map { |part| CGI.unescape(part) }
127
+ return nil if parts.empty?
128
+
129
+ parts.length == 1 ? parts.first : parts
130
+ end
131
+
132
+ private
133
+
134
+ def preload_one_reference(logs, ref, only:, includes:)
135
+ gid_attr = :"#{ref}_gid"
136
+ type_attr = :"#{ref}_type"
137
+ allowed_names = only && Array(only).map { |klass| klass.is_a?(Module) ? klass.name : klass.to_s }
138
+
139
+ index = {}
140
+ unresolvable_types = []
141
+
142
+ logs.group_by { |log| log.public_send(type_attr) }.each do |type, type_logs|
143
+ # A blank `*_type` with a populated `*_gid` happens on historical and
144
+ # partially-backfilled rows. The per-row reader can still resolve
145
+ # those (the gid carries the model name), so mark the group
146
+ # unresolvable rather than memoizing nil — batching must never make a
147
+ # resolvable reference read back as nothing.
148
+ if type.blank?
149
+ unresolvable_types << type
150
+ next
151
+ end
152
+
153
+ # Not whitelisted: intentionally left out of the index so it memoizes
154
+ # nil (no query, now or later).
155
+ next if allowed_names && !allowed_names.include?(type)
156
+
157
+ gids = type_logs.filter_map { |log| log.public_send(gid_attr).presence }.uniq
158
+ next if gids.empty?
159
+
160
+ begin
161
+ locate_reference_records(gids, only: only, includes: includes_for(includes, type)).each do |record|
162
+ index[[type, normalized_record_key(record.id)]] = record
163
+ end
164
+ rescue NameError, ActiveRecord::StatementInvalid => e
165
+ # A historical type string that no longer constantizes, or a
166
+ # relation the current schema can't satisfy. Leave these rows
167
+ # *unmemoized* so the per-row reader behaves exactly as it did
168
+ # before preloading was attempted — memoizing nil here would
169
+ # silently rewrite behaviour on a bad `includes:`.
170
+ unresolvable_types << type
171
+ Rails.logger.warn("[StandardAudit] Could not preload #{ref} for type #{type.inspect}: #{e.class}: #{e.message}")
172
+ end
173
+ end
174
+
175
+ logs.each do |log|
176
+ type = log.public_send(type_attr)
177
+ next if unresolvable_types.include?(type)
178
+
179
+ log.write_preloaded_reference(
180
+ ref,
181
+ index[[type, reference_model_id(log.public_send(gid_attr))]]
182
+ )
183
+ end
184
+ end
185
+
186
+ # `ignore_missing: true` makes this a `where(id: ids)` instead of
187
+ # `find(ids)`, so a deleted record is simply absent from the result
188
+ # rather than raising — which is what lets a deleted reference memoize
189
+ # nil.
190
+ def locate_reference_records(gids, only:, includes:)
191
+ options = { ignore_missing: true }
192
+ options[:only] = only if only
193
+ options[:includes] = includes if includes
194
+
195
+ GlobalID::Locator.locate_many(gids, options)
196
+ end
197
+
198
+ # Mirrors how `GlobalID::Locator::BaseLocator#locate_many` keys its own
199
+ # result index, so composite primary keys line up with
200
+ # `reference_model_id`.
201
+ def normalized_record_key(id)
202
+ id.is_a?(Array) ? id.map(&:to_s) : id.to_s
203
+ end
204
+
205
+ def includes_for(includes, type)
206
+ return nil if includes.nil?
207
+ return includes unless per_type_includes?(includes)
208
+
209
+ entry = includes.find { |key, _| (key.is_a?(Module) ? key.name : key.to_s) == type }
210
+ entry&.last
211
+ end
212
+
213
+ # A Hash counts as a per-type mapping only when every key names a class.
214
+ # Symbol keys mean the caller passed an Active Record includes spec such
215
+ # as `{ account: :identifiers }`, which must be applied uniformly.
216
+ def per_type_includes?(includes)
217
+ includes.is_a?(Hash) &&
218
+ includes.any? &&
219
+ includes.keys.all? { |key| key.is_a?(String) || key.is_a?(Module) }
220
+ end
221
+ end
222
+
223
+ # Writers. `preloaded_actor = record_or_nil` is the supported way to hand a
224
+ # separately-resolved record to a log; `nil` means "resolved to nothing",
225
+ # not "clear the memo".
226
+ REFERENCES.each do |ref|
227
+ define_method(:"preloaded_#{ref}=") do |record|
228
+ write_preloaded_reference(ref, record)
229
+ end
230
+
231
+ define_method(:"#{ref}_preloaded?") do
232
+ reference_preloaded?(ref)
233
+ end
234
+
235
+ # `actor_model_id` / `target_model_id` / `scope_model_id` — the trailing
236
+ # gid segment, without needing the record.
237
+ define_method(:"#{ref}_model_id") do
238
+ self.class.reference_model_id(public_send(:"#{ref}_gid"))
239
+ end
240
+ end
241
+
242
+ # The memo itself. Public because `preload_references` writes through it
243
+ # from the class side; treat it as gem-internal — `preloaded_actor=` and
244
+ # `actor_preloaded?` are the supported surface.
245
+ def preloaded_references
246
+ @preloaded_references ||= {}
247
+ end
248
+
249
+ def reference_preloaded?(ref)
250
+ preloaded_references.key?(ref.to_sym)
251
+ end
252
+
253
+ # See `preloaded_references` — gem-internal, but public so the class-level
254
+ # preloader can reach it.
255
+ def write_preloaded_reference(ref, record)
256
+ preloaded_references[ref.to_sym] = record
257
+ end
258
+
259
+ # Drops the memo so the next read re-resolves from the (possibly changed)
260
+ # gid columns.
261
+ def reload(...)
262
+ @preloaded_references = nil
263
+ super
264
+ end
265
+
266
+ private
267
+
268
+ # The single-row fallback: identical to the pre-0.7.0 reader behaviour.
269
+ def locate_reference(ref)
270
+ gid = public_send(:"#{ref}_gid")
271
+ return nil if gid.blank?
272
+
273
+ GlobalID::Locator.locate(gid)
274
+ rescue ActiveRecord::RecordNotFound
275
+ nil
276
+ end
277
+
278
+ def read_reference(ref)
279
+ ref = ref.to_sym
280
+ return preloaded_references[ref] if preloaded_references.key?(ref)
281
+
282
+ locate_reference(ref)
283
+ end
284
+
285
+ def assign_reference(ref, record)
286
+ if record.nil?
287
+ public_send(:"#{ref}_gid=", nil)
288
+ public_send(:"#{ref}_type=", nil)
289
+ else
290
+ public_send(:"#{ref}_gid=", record.to_global_id.to_s)
291
+ public_send(:"#{ref}_type=", record.class.name)
292
+ end
293
+
294
+ write_preloaded_reference(ref, record)
295
+ record
296
+ end
297
+ end
298
+ end
@@ -8,9 +8,23 @@ require "standard_audit"
8
8
  # - Resets the Configuration via `StandardAudit.reset_configuration!` so
9
9
  # that mutations to `StandardAudit.config` (subscriptions, sensitive
10
10
  # keys, async flag, custom resolvers, etc.) do not bleed across specs.
11
- # Consumers that customise configuration must re-call
12
- # `StandardAudit.configure { |c| ... }` from a `before` hook in their
13
- # own suite if they need a non-default baseline.
11
+ #
12
+ # IMPORTANT declare your configuration as a baseline. The reset restores
13
+ # gem defaults, and the config object holds *behaviour*, not just data:
14
+ # `before_checksum_hooks` live there. A suite that installs this plugin
15
+ # without a baseline silently loses its write-time hooks after the first
16
+ # example, and the specs that would have caught it pass vacuously.
17
+ #
18
+ # So in `config/initializers/standard_audit.rb`, use:
19
+ #
20
+ # StandardAudit.configure(baseline: true) do |config|
21
+ # config.subscribe_to "myapp.**"
22
+ # config.before_checksum :backfill_scope
23
+ # end
24
+ #
25
+ # `reset_configuration!` replays that block onto the fresh Configuration,
26
+ # so every example starts from your app's real configuration. There is
27
+ # nothing to re-apply by hand in a `before` hook.
14
28
  #
15
29
  # The memoized `Subscriber` and `EventSubscriber` instances are *not*
16
30
  # torn down here — they are wired up at engine boot via initializers and
@@ -0,0 +1,177 @@
1
+ require "set"
2
+
3
+ module StandardAudit
4
+ # Answers "is this redaction rule safe for MY data?" against the rows an app
5
+ # already has, before the rule is switched on.
6
+ #
7
+ # This matters more here than in most gems: audit rows are append-only, so a
8
+ # rule that swallows real audit content cannot be undone after the fact — the
9
+ # content is simply never written from then on. Reading the historical rows
10
+ # first turns the judgement call into a command.
11
+ #
12
+ # Keys are extracted **in Ruby**, not with `jsonb_object_keys`, so this works
13
+ # against SQLite (the dummy app), MySQL, and Postgres alike. The install
14
+ # template's migration uses jsonb + GIN, but the gem itself stays
15
+ # backend-neutral.
16
+ #
17
+ # StandardAudit::SensitiveKeysDryRun.call(sensitive_key_patterns: [/secret/i])
18
+ # # => #<Report rows_scanned=1204 stripped={"client_secret"=>18, ...} ...>
19
+ #
20
+ class SensitiveKeysDryRun
21
+ # [rows_scanned] how many audit rows were read
22
+ # [stripped] key path => row count, for keys the candidate rule
23
+ # would redact
24
+ # [kept] key path => row count, for keys it would keep
25
+ # [nested_unfiltered] key path => row count, for *nested* keys the rule
26
+ # matches but which are NOT redacted because
27
+ # `filter_nested_metadata` is off. This is the
28
+ # exposure `filter_nested_metadata` exists to close;
29
+ # empty when nested filtering is enabled.
30
+ Report = Struct.new(:rows_scanned, :stripped, :kept, :nested_unfiltered, :nested, keyword_init: true) do
31
+ def any_stripped?
32
+ stripped.any?
33
+ end
34
+
35
+ def to_s
36
+ lines = []
37
+ lines << "Rows scanned: #{rows_scanned}"
38
+ lines << "Nested filtering: #{nested ? 'on' : 'off'}"
39
+ lines << ""
40
+
41
+ lines << section("WOULD BE STRIPPED", stripped, "Nothing would be stripped.")
42
+ lines << ""
43
+ lines << section("KEPT", kept, "No metadata keys found.")
44
+
45
+ if nested_unfiltered.any?
46
+ lines << ""
47
+ lines << section(
48
+ "MATCHES BUT NOT STRIPPED (nested; set config.filter_nested_metadata = true to redact)",
49
+ nested_unfiltered,
50
+ nil
51
+ )
52
+ end
53
+
54
+ lines.join("\n")
55
+ end
56
+
57
+ private
58
+
59
+ def section(title, counts, empty_message)
60
+ out = [title, "=" * title.length]
61
+
62
+ if counts.empty?
63
+ out << (empty_message || "None.")
64
+ else
65
+ width = counts.keys.map(&:length).max
66
+ counts.sort_by { |key, count| [-count, key] }.each do |key, count|
67
+ out << format(" %-#{width}s %d row(s)", key, count)
68
+ end
69
+ end
70
+
71
+ out.join("\n")
72
+ end
73
+ end
74
+
75
+ class << self
76
+ # Every keyword defaults to the app's live configuration, so calling it
77
+ # with no arguments reports what the *current* config does.
78
+ def call(sensitive_keys: nil, sensitive_key_patterns: nil, sensitive_key_exceptions: nil,
79
+ nested: nil, relation: nil, batch_size: 1000)
80
+ new(
81
+ sensitive_keys: sensitive_keys,
82
+ sensitive_key_patterns: sensitive_key_patterns,
83
+ sensitive_key_exceptions: sensitive_key_exceptions,
84
+ nested: nested
85
+ ).call(relation: relation, batch_size: batch_size)
86
+ end
87
+ end
88
+
89
+ def initialize(sensitive_keys: nil, sensitive_key_patterns: nil, sensitive_key_exceptions: nil, nested: nil)
90
+ live = StandardAudit.config
91
+
92
+ candidate = Configuration.new
93
+ candidate.sensitive_keys = sensitive_keys || live.sensitive_keys
94
+ candidate.sensitive_key_patterns = sensitive_key_patterns || live.sensitive_key_patterns
95
+ candidate.sensitive_key_exceptions = sensitive_key_exceptions || live.sensitive_key_exceptions
96
+
97
+ @nested = nested.nil? ? live.filter_nested_metadata : nested
98
+ @filter = MetadataFilter.new(config: candidate)
99
+ end
100
+
101
+ def call(relation: nil, batch_size: 1000)
102
+ relation ||= StandardAudit::AuditLog.all
103
+
104
+ rows = 0
105
+ stripped = Hash.new(0)
106
+ kept = Hash.new(0)
107
+ nested_unfiltered = Hash.new(0)
108
+
109
+ relation.select(:id, :metadata).find_each(batch_size: batch_size) do |log|
110
+ rows += 1
111
+ metadata = log.metadata
112
+ next unless metadata.respond_to?(:each_pair)
113
+
114
+ # Collect distinct paths per row first, then increment once each. A row
115
+ # whose `charges[]` array holds two matching hashes is ONE affected row,
116
+ # not two — the counts are documented as row counts.
117
+ row = { stripped: Set.new, kept: Set.new, nested_unfiltered: Set.new }
118
+ walk(metadata, nil, row, top_level: true)
119
+
120
+ row[:stripped].each { |path| stripped[path] += 1 }
121
+ row[:kept].each { |path| kept[path] += 1 }
122
+ row[:nested_unfiltered].each { |path| nested_unfiltered[path] += 1 }
123
+ end
124
+
125
+ Report.new(
126
+ rows_scanned: rows,
127
+ stripped: stripped,
128
+ kept: kept,
129
+ nested_unfiltered: nested_unfiltered,
130
+ nested: @nested
131
+ )
132
+ end
133
+
134
+ private
135
+
136
+ def walk(hash, prefix, row, top_level:)
137
+ hash.each_pair do |key, value|
138
+ name = key.to_s
139
+ path = prefix ? "#{prefix}.#{name}" : name
140
+
141
+ # Reserved keys are immune at every depth, and their subtree is
142
+ # gem-owned — never descended into. Mirrors MetadataFilter exactly, so
143
+ # the dry run cannot misrepresent the rule it is modelling.
144
+ if StandardAudit::RESERVED_METADATA_KEYS.include?(name)
145
+ row[:kept] << path
146
+ next
147
+ end
148
+
149
+ matches = @filter.filter?(name)
150
+
151
+ if matches && (top_level || @nested)
152
+ row[:stripped] << path
153
+ next
154
+ end
155
+
156
+ # A nested match that survives because nested filtering is off. Reported
157
+ # separately rather than silently counted as "kept" — this is exactly
158
+ # the leak `filter_nested_metadata` closes.
159
+ if matches
160
+ row[:nested_unfiltered] << path
161
+ else
162
+ row[:kept] << path
163
+ end
164
+
165
+ descend(value, path, row)
166
+ end
167
+ end
168
+
169
+ def descend(value, path, row)
170
+ if value.respond_to?(:each_pair)
171
+ walk(value, path, row, top_level: false)
172
+ elsif value.is_a?(Array)
173
+ value.each { |element| descend(element, "#{path}[]", row) }
174
+ end
175
+ end
176
+ end
177
+ end
@@ -79,9 +79,11 @@ module StandardAudit
79
79
  raw_metadata = config.metadata_builder.call(raw_metadata)
80
80
  end
81
81
 
82
- # Filter sensitive keys
83
- sensitive = config.sensitive_keys.map(&:to_s)
84
- raw_metadata.reject { |k, _| sensitive.include?(k.to_s) }
82
+ # Redaction lives in MetadataFilter, shared with StandardAudit.record.
83
+ # This path previously carried its own copy that did *not* subtract
84
+ # RESERVED_METADATA_KEYS, so `_tags`/`_source` were strippable here and
85
+ # not there.
86
+ StandardAudit::MetadataFilter.call(raw_metadata, config: config)
85
87
  end
86
88
  end
87
89
  end
@@ -1,3 +1,3 @@
1
1
  module StandardAudit
2
- VERSION = "0.6.0"
2
+ VERSION = "0.7.0"
3
3
  end
@@ -1,8 +1,11 @@
1
1
  require "standard_audit/version"
2
2
  require "standard_audit/engine"
3
3
  require "standard_audit/configuration"
4
+ require "standard_audit/metadata_filter"
5
+ require "standard_audit/sensitive_keys_dry_run"
4
6
  require "standard_audit/subscriber"
5
7
  require "standard_audit/event_subscriber"
8
+ require "standard_audit/reference_preloading"
6
9
  require "standard_audit/auditable"
7
10
  require "standard_audit/audit_scope"
8
11
  require "standard_audit/checks/retention"
@@ -13,8 +16,30 @@ module StandardAudit
13
16
  RESERVED_METADATA_KEYS = %w[_tags _source].freeze
14
17
 
15
18
  class << self
16
- def configure
17
- yield(config) if block_given?
19
+ # Applies configuration to the single mutable Configuration instance.
20
+ #
21
+ # `baseline: true` also *remembers* the block, so `reset_configuration!`
22
+ # replays it. That matters because the config object holds behaviour, not
23
+ # just data — `before_checksum_hooks` in particular. Without a baseline, a
24
+ # suite that installs the rspec plugin (which calls `reset_configuration!`
25
+ # before every example) silently loses write-time hooks after the first
26
+ # example, and the specs that would notice pass vacuously.
27
+ #
28
+ # Idiomatic host usage, in config/initializers/standard_audit.rb:
29
+ #
30
+ # StandardAudit.configure(baseline: true) do |config|
31
+ # config.subscribe_to "myapp.**"
32
+ # config.before_checksum :backfill_scope
33
+ # end
34
+ #
35
+ # Only the most recent `baseline: true` block is remembered; call it once,
36
+ # from the initializer.
37
+ def configure(baseline: false, &block)
38
+ return config unless block
39
+
40
+ @baseline_configuration = block if baseline
41
+ block.call(config)
42
+ config
18
43
  end
19
44
 
20
45
  def config
@@ -26,10 +51,9 @@ module StandardAudit
26
51
 
27
52
  actor ||= config.current_actor_resolver.call
28
53
 
29
- # Filter sensitive keys. `_tags` and `_source` are reserved internal
30
- # metadata keys owned by EventSubscriber and are never stripped.
31
- sensitive = config.sensitive_keys.map(&:to_s) - RESERVED_METADATA_KEYS
32
- filtered_metadata = metadata.reject { |k, _| sensitive.include?(k.to_s) }
54
+ # Redaction lives in MetadataFilter, shared with Subscriber, so the two
55
+ # write paths cannot drift apart.
56
+ filtered_metadata = MetadataFilter.call(metadata, config: config)
33
57
 
34
58
  attrs = {
35
59
  event_type: event_type,
@@ -101,8 +125,24 @@ module StandardAudit
101
125
  @event_subscriber ||= EventSubscriber.new
102
126
  end
103
127
 
104
- def reset_configuration!
128
+ # Drops the memoized Configuration. Any block registered with
129
+ # `configure(baseline: true)` is replayed onto the fresh instance, so a
130
+ # per-example reset restores the app's real configuration rather than the
131
+ # gem defaults.
132
+ def reset_configuration!(replay_baseline: true)
105
133
  @configuration = nil
134
+ @baseline_configuration&.call(config) if replay_baseline
135
+ config
136
+ end
137
+
138
+ # Forgets the `configure(baseline: true)` block. Mainly for the gem's own
139
+ # specs and for a host that needs a genuinely pristine configuration.
140
+ def clear_baseline_configuration!
141
+ @baseline_configuration = nil
142
+ end
143
+
144
+ def baseline_configured?
145
+ !@baseline_configuration.nil?
106
146
  end
107
147
 
108
148
  private
@@ -79,6 +79,47 @@ namespace :standard_audit do
79
79
  puts "Backfilled checksums for #{count} audit log records"
80
80
  end
81
81
 
82
+ namespace :sensitive_keys do
83
+ desc "Report which historical metadata keys a redaction rule would strip (read-only)"
84
+ task :dry_run, [:pattern] => :environment do |_t, args|
85
+ # Audit rows are append-only, so a redaction rule that swallows real
86
+ # content cannot be undone. This reads the rows you already have and
87
+ # reports, per key, what the rule would have stripped.
88
+ #
89
+ # rake standard_audit:sensitive_keys:dry_run
90
+ # rake "standard_audit:sensitive_keys:dry_run[secret]"
91
+ # NESTED=1 rake "standard_audit:sensitive_keys:dry_run[secret|token]"
92
+ #
93
+ # The argument is a Regexp source, matched case-insensitively, and is
94
+ # applied *in addition to* the app's configured sensitive_keys.
95
+ # NESTED=1/0 overrides config.filter_nested_metadata for the run.
96
+ pattern = args[:pattern].presence || ENV["PATTERN"].presence
97
+ patterns = StandardAudit.config.sensitive_key_patterns.dup
98
+ patterns << Regexp.new(pattern, Regexp::IGNORECASE) if pattern
99
+
100
+ nested =
101
+ case ENV["NESTED"]
102
+ when nil, "" then nil
103
+ when "0", "false" then false
104
+ else true
105
+ end
106
+
107
+ report = StandardAudit::SensitiveKeysDryRun.call(
108
+ sensitive_key_patterns: patterns,
109
+ nested: nested,
110
+ batch_size: (ENV["BATCH_SIZE"] || 1000).to_i
111
+ )
112
+
113
+ puts "StandardAudit sensitive-key dry run"
114
+ puts "==================================="
115
+ puts "Candidate pattern: #{pattern ? Regexp.new(pattern, Regexp::IGNORECASE).inspect : '(none — reporting current config)'}"
116
+ puts ""
117
+ puts report
118
+ puts ""
119
+ puts "Nothing was written. Rows are append-only; a rule you enable applies only to future writes."
120
+ end
121
+ end
122
+
82
123
  desc "Export audit logs for a specific actor (GDPR right to access)"
83
124
  task :export_actor, [:actor_gid, :output] => :environment do |_t, args|
84
125
  raise "actor_gid is required" unless args[:actor_gid].present?
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.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -109,7 +109,10 @@ files:
109
109
  - lib/standard_audit/configuration.rb
110
110
  - lib/standard_audit/engine.rb
111
111
  - lib/standard_audit/event_subscriber.rb
112
+ - lib/standard_audit/metadata_filter.rb
113
+ - lib/standard_audit/reference_preloading.rb
112
114
  - lib/standard_audit/rspec.rb
115
+ - lib/standard_audit/sensitive_keys_dry_run.rb
113
116
  - lib/standard_audit/subscriber.rb
114
117
  - lib/standard_audit/version.rb
115
118
  - lib/tasks/standard_audit_tasks.rake