standard_audit 0.12.0 → 0.13.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: 80719c54fd8a1d3859f52741cfc4a478ec670114ad5ab025b1b9b14a10a80625
4
- data.tar.gz: d5baa91d4e29844e0e0c375e0ef6c9b3cd4f2d6eba0284696911891270f3befa
3
+ metadata.gz: c41a6212ecc33a1cdba995a12cc4c2205cf9bafe5b7eeb6cdd5c83288e1738d7
4
+ data.tar.gz: 41111fb868232c50a068a8e21ba2597ed4e60f4c15bc3018afc72199ecfc659e
5
5
  SHA512:
6
- metadata.gz: 3fc6d5267408dd0858b0cecac9e449c00b6ef6e75b12cb9cb74167634769cd09f95fa51805a948f9a20e100b7bbd43237f473f4508d211a8c66293c0af2bbe3f
7
- data.tar.gz: cdb5c7130f54d124da4e2b02586e510b5a75693c729d30d495403d4e948b7d6fcc81b640cb241a138292d452ebb5a52f0d84a6a0f178e8ecb982d7a209c179ea
6
+ metadata.gz: 710317dda7bf0b14fcf77f72365a2edcaaa33d6e77e8177e96bdad8dd86e1ef84bb57427a749c42b50b10e3f8a92aafc3efb2597eb0014fdba1b5639758c6223
7
+ data.tar.gz: 8e1e70aeed7098a52a413e37f971a0f433d535e36e880618aa6ddf2c000c6a664c2f29fc124653e14849a3389a2badbcdab0450aa9282fa80d077f219a77e082
data/CHANGELOG.md CHANGED
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.0] - 2026-09-24
11
+
12
+ The Phase 4 release. It removes what 0.12 deprecated, the empty engine
13
+ routing, and the gaps the five app adoptions of 0.12 ran into.
14
+
15
+ ### Removed (breaking)
16
+
17
+ - **`standard_audit:add_checksums` generator** (deprecated in 0.12.0). It was the 0.2 → 0.3 upgrade path. Every install since 0.3 creates `checksum`; hosts older than 0.8 run `standard_audit:add_previous_checksum`.
18
+ - **`isolate_namespace StandardAudit` and the empty `config/routes.rb`.** The engine has no routes, controllers or views, and no host mounts it. `AuditLog` sets its own `table_name`, so table naming is unchanged. Side effects: `StandardAudit::Engine.routes` no longer holds an (empty) isolated route set, and `StandardAudit.table_name_prefix` / `railtie_namespace` are no longer set by isolation. The gem uses neither.
19
+
20
+ ### Added
21
+
22
+ - **`config.error_reporter = ->(error, context) { … }`**, the destination for every error the gem swallows: `record(raise: false)`, subscriber writes, raising `before_checksum` hooks, and `audit!` write failures under the default policy. nil (the default) keeps `Rails.error.report(error, handled: true, context:)`, so nothing changes unless you set it. Apps that don't forward `Rails.error` to their tracker (jumpdrive-web, nutripod-web) can report straight to Sentry instead of wrapping calls in their own rescue. A raising reporter is logged and ignored. `audit_write_error_handler` still takes precedence for `audit!`.
23
+ - **`entry[:via]` in `before_write`**: `:direct` (`record` without a block, `record_audit`, `audit!`), `:notification` (the ActiveSupport::Notifications subscriber, including `record` with a block), or `:rails_event`. A hook can now tell a direct write from a subscriber write. Not persisted. `StandardAudit::VIA` lists the values.
24
+ - **`hooks:` option on the `"a standard_audit baseline"` shared example**: an Integer (exact `before_checksum` hook count) or an Array of Symbol hook names. The mutation example clears the hooks before the reset, so it fails when a hook lives outside the baseline block.
25
+
26
+ ### Fixed
27
+
28
+ - **Upgrade generators number the migration after the host's newest migration.** `add_anonymized_at`, `add_previous_checksum` and `install` stamped `Time.now`, which sorts before future-dated host migrations. They now use the later of now and one second after the newest migration in the target directory (a real timestamp, unlike ActiveRecord's `+1`, which can produce `…235960`).
29
+ - **README `before_write` example.** It showed a PII guard in `before_write` next to a masking `metadata_builder`. `before_write` runs after the builder, so the guard only ever saw masked values. The README now documents the order (resolvers → `metadata_builder` → `before_write` → dereference → redact → persist) and shows guard-then-mask inside `before_write`.
30
+
31
+ ### Upgrade notes (0.12.x → 0.13.0)
32
+
33
+ Grepped `origin/main` of sidekick-web, jumpdrive-web (control-plane), fundbright-web, luminality-web and nutripod-web on 2026-09-24.
34
+
35
+ **Required host changes: none.** No app runs `add_checksums`, mounts `StandardAudit::Engine`, or uses its route helpers. Regenerate Sorbet RBIs (`bin/tapioca gem standard_audit`, `bin/tapioca dsl`) where the app uses Tapioca.
36
+
37
+ **Optional cleanups this release enables:**
38
+ - sidekick-web `spec/initializers/standard_audit_baseline_spec.rb:49-53` ("still carries the two classification hooks after a reset"): replace with `hooks: 2` on the `it_behaves_like` call.
39
+ - jumpdrive-web `control-plane/app/services/mcp/server.rb:98-104` (`audit_tool` rescue → `ErrorReporting.notify`): replace with `StandardAudit.record(..., raise: false)` plus a `config.error_reporter` that calls `ErrorReporting.notify`. Or keep it, since it also adds `component:` context.
40
+ - nutripod-web `app/controllers/concerns/audit_auth_failure.rb:92-96`: the rescue reports through `Rails.error` itself, so it can become `record(..., raise: false)` with `config.audit_error_context_key = :audit_event`, like the other apps. Add a `config.error_reporter` if nutripod-web does not forward `Rails.error` to Sentry.
41
+ - fundbright-web `AuditWritePolicy` (`before_write`) can use `entry[:via]` if its PII guard should skip gem-published subscriber payloads.
42
+ - Any `before_write` that iterates every `entry` key now also sees `:via`.
43
+
44
+ ## [0.12.1] - 2026-09-24
45
+
46
+ ### Upgrade steps
47
+
48
+ 1. **If you installed the 0.12.0 `add_anonymized_at` migration, check your copy.** The 0.12.0 template put `return if column_exists?(:audit_logs, :anonymized_at)` inside `change`. That early return also runs on rollback, so `db:rollback` deleted the `schema_migrations` row and silently left the column in place. Replace the body with `add_column :audit_logs, :anonymized_at, :datetime, if_not_exists: true` (in `change`), or copy the new up/down template. Nothing to do if your copy already uses `if_not_exists:` (sidekick-web, jumpdrive-web) or if you never ran the generator.
49
+ 2. If an error-tracker search or alert matches `before_checksum` hook failures on the `audit_event` context key, see Fixed.
50
+
51
+ ### Fixed
52
+
53
+ - **The `add_anonymized_at` migration is reversible.** The template now uses explicit `up` / `down` with `add_column ..., if_not_exists: true` and `remove_column ..., if_exists: true`. It is still idempotent, and rollback now removes the column. A new generator spec runs the generated migration up, down and up.
54
+ - **`before_checksum` hook failures use `config.audit_error_context_key`.** The hook's `Rails.error.report` hard-coded `audit_event:` as its context key. Every other audit-error report site uses the configured key (default `:audit_action`). Apps that set `audit_error_context_key = :audit_event` see no change. Apps on the default now get `audit_action:` for hook failures too, matching every other audit error.
55
+
56
+ ### Documentation
57
+
58
+ - `current_scope_resolver` is for scope derived from `Current`. Scope derived from the row (e.g. the target's organisation) belongs in a `before_checksum` or `before_write` hook. The README no longer describes sidekick-web's target-derived hook as a `Current` back-fill.
59
+ - New note: `before_write` / `before_checksum` run once per row, batched writes included. Memoize per-actor lookups (e.g. in a `CurrentAttributes` cache) to avoid an N+1 on a batch flush.
60
+ - `record(raise: false)` reports through `Rails.error`, so failures reach Sentry only if a `Rails.error` subscriber is registered (`sentry-rails` registers one).
61
+
10
62
  ## [0.12.0] - 2026-09-24
11
63
 
12
64
  ### Upgrade steps
data/README.md CHANGED
@@ -102,8 +102,8 @@ When `actor` is omitted, it falls back to the configured `current_actor_resolver
102
102
 
103
103
  Where a missing audit row must never break the request — logging an
104
104
  authentication failure, say — pass `raise: false`. A failed write is logged,
105
- reported to `Rails.error` as handled (context
106
- `{ <audit_error_context_key> => event_type, source: "StandardAudit.record" }`),
105
+ reported through `config.error_reporter` (default: `Rails.error`, as handled;
106
+ context `{ <audit_error_context_key> => event_type, source: "StandardAudit.record" }`),
107
107
  and `record` returns nil:
108
108
 
109
109
  ```ruby
@@ -116,6 +116,31 @@ StandardAudit.record("auth.token_invalid",
116
116
  The default is `raise: true` (unchanged). In block form the option only
117
117
  governs the audit write; errors from your block always propagate.
118
118
 
119
+ #### Where swallowed failures go: `config.error_reporter`
120
+
121
+ By default the gem reports every error it swallows with
122
+ `Rails.error.report(error, handled: true, context:)`. That covers a failed
123
+ `record(raise: false)`, a failed subscriber write, a raising `before_checksum`
124
+ hook, and a failed `audit!` write under the default policy. It reaches your
125
+ error tracker only if something subscribes to `Rails.error`. `sentry-rails`
126
+ registers that subscriber for you. If your app never forwards `Rails.error`
127
+ (a hand-rolled Sentry setup), point the gem straight at the tracker (0.13.0+):
128
+
129
+ ```ruby
130
+ config.error_reporter = ->(error, context) { Sentry.capture_exception(error, extra: context) }
131
+ ```
132
+
133
+ It receives the error and the context Hash (keyed by
134
+ `audit_error_context_key`) and **replaces** the `Rails.error` call. If you want
135
+ both, call `Rails.error.report` from it too. A reporter that raises is logged
136
+ and ignored, so it can never turn a swallowed audit failure into a raised one.
137
+ `audit_write_error_handler`, when set, still takes precedence for `audit!`
138
+ write failures.
139
+
140
+ **Replace your host code with** `config.error_reporter`. It supersedes any
141
+ rescue-and-report wrapper kept around `record(raise: false)` or `audit!` only
142
+ because the app does not forward `Rails.error` to its tracker.
143
+
119
144
  **Replace your host code with** `raise: false`. It supersedes the
120
145
  `AuditAuthFailure#record_auth_failure` rescue-and-report wrapper
121
146
  (sidekick-web, luminality-web, nutripod-web
@@ -166,18 +191,61 @@ never ran on direct `record` calls.)
166
191
  ```ruby
167
192
  config.before_write = ->(entry) {
168
193
  # entry: { event_type:, actor:, target:, scope:, metadata:,
169
- # request_id:, ip_address:, user_agent:, session_id: }
170
- AuditMetadataPii.verify!(entry[:metadata]) if StandardAudit::Operation::Audit.verify?
171
- if (surface = Current.audit_surface).present?
172
- entry[:metadata] = entry[:metadata].merge(surface: surface)
173
- end
194
+ # request_id:, ip_address:, user_agent:, session_id:, via: }
195
+ metadata = entry[:metadata]
196
+ AuditMetadataPii.verify!(metadata) if StandardAudit::Operation::Audit.verify? && entry[:via] == :direct
197
+ metadata = AuditMetadataPii.mask(metadata)
198
+ metadata = metadata.merge(surface: Current.audit_surface) if Current.audit_surface.present?
199
+ entry[:metadata] = metadata
174
200
  }
175
201
  ```
176
202
 
177
- It runs after the `Current` resolvers and `metadata_builder`, and **before**
178
- dereferencing and `sensitive_keys` redaction, so anything it injects is still
179
- filtered. Mutate `entry` in place; the return value is ignored. Raising aborts
180
- the write: direct callers see the error, the subscribers rescue and report it.
203
+ **Order, per write:** `Current` resolvers → `metadata_builder` → `before_write`
204
+ → record dereferencing → `sensitive_keys` redaction → persist (or buffer, or
205
+ enqueue). So:
206
+
207
+ - `before_write` **sees the builder's output, not the raw metadata.** If your
208
+ `metadata_builder` masks or rewrites values, a guard in `before_write` checks
209
+ the masked values and can never fire. Keep the builder to idempotent
210
+ injection (like `engine_scope`), and do guard-then-mask in `before_write`, in
211
+ that order, as above. Before 0.13 this README showed a guard in
212
+ `before_write` next to a masking builder, which does not work.
213
+ - Anything `before_write` injects is still dereferenced and redacted.
214
+
215
+ Mutate `entry` in place; the return value is ignored. Raising aborts the write:
216
+ direct callers see the error, the subscribers rescue and report it.
217
+
218
+ **`entry[:via]`** (0.13.0+) names the entry point, so a hook can treat direct
219
+ writes differently from subscriber writes. For example, it can apply a guard
220
+ only to rows your own code writes and not to payloads a gem publishes. It is
221
+ not persisted.
222
+
223
+ | `entry[:via]` | Written by |
224
+ |---|---|
225
+ | `:direct` | `StandardAudit.record` (no block), `Auditable#record_audit`, `Operation#audit!` |
226
+ | `:notification` | the ActiveSupport::Notifications subscriber (`subscribe_to` patterns), including `StandardAudit.record` **with a block** |
227
+ | `:rails_event` | the `Rails.event` subscriber (Rails 8.1+) |
228
+
229
+ **Hooks run once per row, batched writes included.** `before_write` and
230
+ `before_checksum` run for every row inside `StandardAudit.batch` too (at flush
231
+ time for `before_checksum`). A hook that looks something up per actor (a role,
232
+ a membership, a tenant) therefore runs one query per row and turns a batch into
233
+ an N+1. Memoize those lookups for the unit of work, for example in a
234
+ `CurrentAttributes` cache that resets with the request or job:
235
+
236
+ ```ruby
237
+ class Current < ActiveSupport::CurrentAttributes
238
+ attribute :audit_actor_roles
239
+
240
+ def self.audit_role_for(actor)
241
+ self.audit_actor_roles ||= {}
242
+ audit_actor_roles[actor.to_global_id.to_s] ||= actor.audit_role
243
+ end
244
+ end
245
+
246
+ # actor_role: a column your app added to audit_logs
247
+ config.before_checksum { |log| log.actor_role = Current.audit_role_for(log.actor) if log.actor }
248
+ ```
181
249
 
182
250
  **Replace your host code with** a `before_write`. It supersedes:
183
251
 
@@ -397,7 +465,8 @@ RSpec.describe "StandardAudit configuration baseline" do
397
465
  catalogue: -> { AuditCatalogue::ACTIONS },
398
466
  sensitive_keys: %i[source_payload],
399
467
  sensitive_key_patterns: [/secret/i],
400
- present: %i[metadata_builder before_write current_scope_resolver]
468
+ present: %i[metadata_builder before_write current_scope_resolver],
469
+ hooks: 2 # before_checksum hooks: a count, or %i[backfill_scope] by name
401
470
  end
402
471
  ```
403
472
 
@@ -406,6 +475,13 @@ restored after a mutation plus `reset_configuration!`. Behaviour held in
406
475
  lambdas can only be checked for presence; keep an app-specific example for
407
476
  anything whose *result* matters.
408
477
 
478
+ `hooks:` (0.13.0+) covers `before_checksum` hooks, which a reset drops unless
479
+ the baseline re-adds them. Pass an Integer to require exactly that many, or an
480
+ Array of Symbol hook names (`config.before_checksum :name`) to require each one.
481
+ The mutation example clears the hooks before the reset, so a hook registered
482
+ outside the baseline block fails it. This replaces hand-written "still carries
483
+ the N hooks after a reset" examples.
484
+
409
485
  **Replace your host code with** the shared example. It supersedes the bulk of
410
486
  each app's `spec/initializers/standard_audit_baseline_spec.rb` (or
411
487
  `spec/config/…`). The `Current.account` / `Current.session&.id` resolver
@@ -500,9 +576,14 @@ StandardAudit.configure(baseline: true) do |config|
500
576
  config.current_scope_resolver = -> { Current.organisation }
501
577
 
502
578
  # -- before_write --
503
- # Runs on every write path, before redaction. See "One write path".
579
+ # Runs on every write path, after metadata_builder and before redaction.
580
+ # entry[:via] is :direct, :notification or :rails_event. See "One write path".
504
581
  config.before_write = ->(entry) { entry[:metadata] = entry[:metadata].merge("surface" => Current.surface) }
505
582
 
583
+ # -- Error reporting --
584
+ # Where swallowed audit failures go. nil (default) = Rails.error.report.
585
+ # config.error_reporter = ->(error, context) { Sentry.capture_exception(error, extra: context) }
586
+
506
587
  # -- Async Processing --
507
588
  # Offload audit log creation to ActiveJob.
508
589
  config.async = false
@@ -641,14 +722,25 @@ It is a *fallback*: an explicit `scope:` and a scope found by `scope_extractor`
641
722
  always win. It applies on every write path (direct `record`, `audit!`,
642
723
  `record_audit`, both subscribers; sync, async and batched). Default `nil`.
643
724
 
644
- **Replace your host code with** the one line above. It supersedes:
725
+ `current_scope_resolver` is for scope that comes from **ambient request
726
+ state** (`Current`). It takes no arguments and never sees the row. Scope that
727
+ derives from the **row itself**, such as the organisation that owns the
728
+ target, belongs in a `before_checksum` hook (or `before_write`), which receives
729
+ the record or entry:
645
730
 
646
- - a `scope_extractor` that falls back to `Current` (nutripod-web:
647
- `->(payload) { payload[:scope] || Current.channel || Current.organisation }`),
648
- which only ever covered the subscriber path — keep `scope_extractor` for
649
- reading the payload, move the `Current` fallback here;
650
- - a `before_checksum` hook that back-fills `log.scope` from `Current`
651
- (sidekick-web), which before 0.12.0 never ran on the batched path.
731
+ ```ruby
732
+ config.before_checksum do |log|
733
+ log.scope ||= log.target.organisation if log.target.respond_to?(:organisation)
734
+ end
735
+ ```
736
+
737
+ **Replace your host code with** the one line above when your fallback reads
738
+ `Current`. It supersedes a `scope_extractor` that falls back to `Current`
739
+ (nutripod-web:
740
+ `->(payload) { payload[:scope] || Current.channel || Current.organisation }`),
741
+ which only ever covered the subscriber path. Keep `scope_extractor` for reading
742
+ the payload and move the `Current` fallback here. Target-derived scope hooks
743
+ stay as they are; since 0.12.0 they also run on the batched path.
652
744
 
653
745
  ## Async Processing
654
746
 
@@ -858,7 +950,12 @@ It is for rows that never had a checksum at all (pre-feature data).
858
950
  | `standard_audit:install` | `audit_logs` migration + initializer (new installs) |
859
951
  | `standard_audit:add_previous_checksum` | Adds `previous_checksum` (upgrading from < 0.8) |
860
952
  | `standard_audit:add_anonymized_at` | Adds `anonymized_at` (upgrading from < 0.12) |
861
- | `standard_audit:add_checksums` | **Deprecated** (0.12.0; to be removed). The 0.2 → 0.3 upgrade path; warns when run |
953
+
954
+ The upgrade generators number their migration one second after the newest
955
+ migration already in `db/migrate` when that is later than now (0.13.0+), so the
956
+ new migration sorts after future-dated host migrations instead of before them.
957
+ Before 0.13.0 they stamped the current time. (`standard_audit:add_checksums`,
958
+ the 0.2 → 0.3 upgrade path, was removed in 0.13.0.)
862
959
 
863
960
  ## Rake Tasks
864
961
 
@@ -597,7 +597,7 @@ module StandardAudit
597
597
  # persist — so the hook would not actually be "skipped".
598
598
  restore_attributes_from(snapshot)
599
599
  Rails.logger.warn("[StandardAudit] before_checksum hook failed: #{e.class}: #{e.message}")
600
- Rails.error.report(e, handled: true, context: { audit_event: event_type }) if Rails.respond_to?(:error)
600
+ StandardAudit.report_error(e, { StandardAudit.config.audit_error_context_key => event_type })
601
601
  nil
602
602
  end
603
603
  end
@@ -1,16 +1,15 @@
1
+ require "rails/generators"
2
+ require "generators/standard_audit/migration_number"
3
+
1
4
  module StandardAudit
2
5
  module Generators
3
6
  # Adds `audit_logs.anonymized_at` (0.12.0). With it, rows erased by
4
7
  # `AuditLog.anonymize_actor!` are reported by `verify_chain` as `redacted`
5
8
  # rather than as `digest_mismatch` failures.
6
9
  class AddAnonymizedAtGenerator < Rails::Generators::Base
7
- include Rails::Generators::Migration
10
+ include StandardAudit::Generators::MigrationNumber
8
11
  source_root File.expand_path("templates", __dir__)
9
12
 
10
- def self.next_migration_number(dirname)
11
- Time.now.utc.strftime("%Y%m%d%H%M%S")
12
- end
13
-
14
13
  def copy_migration
15
14
  migration_template "add_anonymized_at_to_audit_logs.rb.erb",
16
15
  "db/migrate/add_anonymized_at_to_audit_logs.rb"
@@ -1,15 +1,25 @@
1
1
  class AddAnonymizedAtToAuditLogs < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
- def change
3
- # A nullable column with no default: a metadata-only change on PostgreSQL,
4
- # no table rewrite, nothing for StrongMigrations to object to. Idempotent,
5
- # so it is safe on a host that already has the column (a 0.12+ install).
6
- #
7
- # Rows anonymized BEFORE this migration keep a NULL stamp and still fail
8
- # `verify_chain` as `digest_mismatch`; there is no way to tell them apart
9
- # from tampered rows after the fact. If your erasure records identify them,
10
- # stamp them by hand (`update_columns(anonymized_at: ...)`).
11
- return if column_exists?(:audit_logs, :anonymized_at)
2
+ # A nullable column with no default: a metadata-only change on PostgreSQL,
3
+ # no table rewrite, nothing for StrongMigrations to object to. Idempotent,
4
+ # so it is safe on a host that already has the column (a 0.12+ install).
5
+ #
6
+ # Rows anonymized BEFORE this migration keep a NULL stamp and still fail
7
+ # `verify_chain` as `digest_mismatch`; there is no way to tell them apart
8
+ # from tampered rows after the fact. If your erasure records identify them,
9
+ # stamp them by hand (`update_columns(anonymized_at: ...)`).
10
+ #
11
+ # Explicit up/down with `if_not_exists:` / `if_exists:` rather than an early
12
+ # `return if column_exists?` inside `change`: that return also runs on
13
+ # rollback, which then deletes the schema_migrations row and silently leaves
14
+ # the column behind.
15
+ #
16
+ # Rolling back drops the column even if it came from your create_audit_logs
17
+ # migration (0.12+ installs). Those installs do not need this migration.
18
+ def up
19
+ add_column :audit_logs, :anonymized_at, :datetime, if_not_exists: true
20
+ end
12
21
 
13
- add_column :audit_logs, :anonymized_at, :datetime
22
+ def down
23
+ remove_column :audit_logs, :anonymized_at, if_exists: true
14
24
  end
15
25
  end
@@ -1,13 +1,12 @@
1
+ require "rails/generators"
2
+ require "generators/standard_audit/migration_number"
3
+
1
4
  module StandardAudit
2
5
  module Generators
3
6
  class AddPreviousChecksumGenerator < Rails::Generators::Base
4
- include Rails::Generators::Migration
7
+ include StandardAudit::Generators::MigrationNumber
5
8
  source_root File.expand_path("templates", __dir__)
6
9
 
7
- def self.next_migration_number(dirname)
8
- Time.now.utc.strftime("%Y%m%d%H%M%S")
9
- end
10
-
11
10
  def copy_migration
12
11
  migration_template "add_previous_checksum_to_audit_logs.rb.erb",
13
12
  "db/migrate/add_previous_checksum_to_audit_logs.rb"
@@ -1,4 +1,5 @@
1
1
  require "rails/generators"
2
+ require "generators/standard_audit/migration_number"
2
3
 
3
4
  module StandardAudit
4
5
  module Generators
@@ -11,7 +12,7 @@ module StandardAudit
11
12
  # installed. Pass `--skip-*` flags to opt out of individual steps and
12
13
  # `--force` to overwrite an existing initializer.
13
14
  class InstallGenerator < Rails::Generators::Base
14
- include Rails::Generators::Migration
15
+ include StandardAudit::Generators::MigrationNumber
15
16
  source_root File.expand_path("templates", __dir__)
16
17
 
17
18
  desc <<~DESC
@@ -32,10 +33,6 @@ module StandardAudit
32
33
  class_option :force, type: :boolean, default: false,
33
34
  desc: "Overwrite config/initializers/standard_audit.rb if it already exists"
34
35
 
35
- def self.next_migration_number(dirname)
36
- Time.now.utc.strftime("%Y%m%d%H%M%S")
37
- end
38
-
39
36
  def copy_migration
40
37
  if options[:skip_migration]
41
38
  say_status("skip", "db/migrate/*_create_audit_logs.rb (--skip-migration)", :yellow)
@@ -0,0 +1,40 @@
1
+ require "rails/generators/migration"
2
+ require "time"
3
+
4
+ module StandardAudit
5
+ module Generators
6
+ # Migration numbering shared by the gem's migration generators.
7
+ #
8
+ # A plain `Time.now` stamp sorts BEFORE a host's future-dated migrations
9
+ # (several consumers date theirs ahead of the clock), so the generated
10
+ # migration would run out of order and look already-applied to tools that
11
+ # compare against the latest version. This picks whichever is later: now,
12
+ # or one second after the newest migration already in the target
13
+ # directory. (ActiveRecord's own generators add 1 to the number, which
14
+ # can produce an invalid timestamp such as ...235960; this adds a second.)
15
+ module MigrationNumber
16
+ def self.included(base)
17
+ base.include Rails::Generators::Migration
18
+ base.extend ClassMethods
19
+ end
20
+
21
+ module ClassMethods
22
+ FORMAT = "%Y%m%d%H%M%S".freeze
23
+
24
+ def next_migration_number(dirname)
25
+ [Time.now.utc.strftime(FORMAT), one_second_after(current_migration_number(dirname))].max
26
+ end
27
+
28
+ private
29
+
30
+ def one_second_after(number)
31
+ stamp = Kernel.format("%.14d", number)
32
+ (Time.strptime("#{stamp} +0000", "#{FORMAT} %z") + 1).utc.strftime(FORMAT)
33
+ rescue ArgumentError
34
+ # Not a timestamp (no migrations yet, or a sequential numbering).
35
+ Kernel.format("%.14d", number + 1)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -13,7 +13,7 @@ module StandardAudit
13
13
  :anonymizable_metadata_keys, :retention_days,
14
14
  :audit_catalogue, :verify_audit_declarations,
15
15
  :raise_on_audit_write_error, :audit_write_error_handler,
16
- :audit_error_context_key
16
+ :audit_error_context_key, :error_reporter
17
17
 
18
18
  def initialize
19
19
  @subscriptions = []
@@ -191,6 +191,27 @@ module StandardAudit
191
191
  # improvement to the built-in reporter. Default keeps existing behaviour.
192
192
  @audit_error_context_key = :audit_action
193
193
 
194
+ # Where the gem sends the errors it swallows: a failed subscriber write,
195
+ # a failed `record(raise: false)`, a raising `before_checksum` hook, and
196
+ # a failed `audit!` write under the default report-and-swallow policy.
197
+ # A callable taking `(error, context)`, where context is a Hash such as
198
+ # `{ audit_action: "orders.created", subscriber: "StandardAudit::Subscriber" }`
199
+ # (keyed by `audit_error_context_key`).
200
+ #
201
+ # nil (the default) means `Rails.error.report(error, handled: true,
202
+ # context: context)`. That reaches Sentry only when a `Rails.error`
203
+ # subscriber is registered (sentry-rails registers one); a host that
204
+ # never forwards `Rails.error` can point this straight at its tracker:
205
+ #
206
+ # config.error_reporter = ->(error, context) { Sentry.capture_exception(error, extra: context) }
207
+ #
208
+ # It replaces the built-in `Rails.error` call; call `Rails.error.report`
209
+ # from it too if you want both. A reporter that raises is logged and
210
+ # ignored, so it can never turn a swallowed audit failure into a raised
211
+ # one. `audit_write_error_handler`, when set, still takes precedence for
212
+ # `audit!` write failures.
213
+ @error_reporter = nil
214
+
194
215
  # Retention defaults from ENV so it can be set per-environment without a
195
216
  # code change. Unset/blank/non-positive => nil (infinite retention, the
196
217
  # compliance-safe default that never auto-deletes). A host app can still
@@ -1,7 +1,10 @@
1
1
  module StandardAudit
2
+ # A plain (non-isolated) engine: it contributes the AuditLog model, the two
3
+ # jobs and the subscriber wiring, and has no routes, controllers or views.
4
+ # 0.13.0 dropped `isolate_namespace` and the empty `config/routes.rb` it
5
+ # carried. Nothing depended on them: AuditLog sets its own `table_name`, and
6
+ # no host mounted the engine.
2
7
  class Engine < ::Rails::Engine
3
- isolate_namespace StandardAudit
4
-
5
8
  initializer "standard_audit.subscriber" do
6
9
  ActiveSupport.on_load(:active_record) do
7
10
  StandardAudit.subscriber.setup!
@@ -47,7 +47,8 @@ module StandardAudit
47
47
  ip_address: context[:ip_address] || payload[:ip_address],
48
48
  user_agent: context[:user_agent] || payload[:user_agent],
49
49
  session_id: context[:session_id] || payload[:session_id]
50
- }
50
+ },
51
+ via: :rails_event
51
52
  )
52
53
  rescue => e
53
54
  StandardAudit.report_write_error(e, name, subscriber: self.class.name)
@@ -282,13 +282,9 @@ module StandardAudit
282
282
  message = "[StandardAudit] Failed to record #{action}: #{error.class} #{error.message}"
283
283
  Rails.logger&.error(message) if defined?(Rails) && Rails.respond_to?(:logger)
284
284
 
285
- return unless defined?(Rails) && Rails.respond_to?(:error) && Rails.error
286
-
287
- Rails.error.report(
285
+ StandardAudit.report_error(
288
286
  error,
289
- handled: true,
290
- context: { StandardAudit.config.audit_error_context_key => action,
291
- operation: operation.class.name }
287
+ { StandardAudit.config.audit_error_context_key => action, operation: operation.class.name }
292
288
  )
293
289
  end
294
290
  end
@@ -18,12 +18,19 @@ require "standard_audit"
18
18
  # catalogue: -> { AuditCatalogue::ACTIONS },
19
19
  # sensitive_keys: %i[source_payload],
20
20
  # sensitive_key_patterns: [/secret/i],
21
- # present: %i[metadata_builder before_write current_scope_resolver]
21
+ # present: %i[metadata_builder before_write current_scope_resolver],
22
+ # hooks: 2 # or %i[backfill_scope classify_actor]
22
23
  # end
23
24
  #
24
25
  # Every option is optional. Behaviour held in lambdas (resolvers, builders)
25
26
  # can't be compared by value, so list them under `present:` to assert they
26
27
  # survive a reset, and keep an app-specific example for what they return.
28
+ #
29
+ # `hooks:` covers `before_checksum` hooks, which live in a list rather than a
30
+ # named setting. Pass an Integer to assert exactly that many are registered,
31
+ # or an Array of the Symbol hook names (`config.before_checksum :name`) to
32
+ # assert each is registered. The mutation example also clears the hooks
33
+ # before the reset, so it fails if the baseline block doesn't re-add them.
27
34
  RSpec.shared_examples "a standard_audit baseline" do |options = {}|
28
35
  subscriptions = Array(options[:subscriptions])
29
36
  settings = options.fetch(:settings, {})
@@ -31,9 +38,14 @@ RSpec.shared_examples "a standard_audit baseline" do |options = {}|
31
38
  sensitive_keys = Array(options[:sensitive_keys])
32
39
  sensitive_key_patterns = Array(options[:sensitive_key_patterns])
33
40
  present = Array(options[:present])
41
+ hooks = options[:hooks]
42
+
43
+ unless hooks.nil? || hooks.is_a?(Integer) || (hooks.is_a?(Array) && hooks.all? { |h| h.is_a?(Symbol) || h.is_a?(String) })
44
+ raise ArgumentError, "hooks: must be an Integer (hook count) or an Array of Symbol hook names; got #{hooks.inspect}"
45
+ end
34
46
 
35
47
  def standard_audit_baseline_assertions(subscriptions:, settings:, catalogue:, sensitive_keys:,
36
- sensitive_key_patterns:, present:)
48
+ sensitive_key_patterns:, present:, hooks:)
37
49
  config = StandardAudit.config
38
50
 
39
51
  expect(config.subscriptions).to include(*subscriptions) if subscriptions.any?
@@ -49,11 +61,22 @@ RSpec.shared_examples "a standard_audit baseline" do |options = {}|
49
61
  present.each do |name|
50
62
  expect(config.public_send(name)).not_to be_nil, "expected config.#{name} to survive a reset"
51
63
  end
64
+ case hooks
65
+ when Integer
66
+ expect(config.before_checksum_hooks.size).to eq(hooks),
67
+ "expected #{hooks} before_checksum hook(s) after a reset, found #{config.before_checksum_hooks.size}"
68
+ when Array
69
+ registered = config.before_checksum_hooks.select { |h| h.is_a?(Symbol) || h.is_a?(String) }.map(&:to_sym)
70
+ hooks.map(&:to_sym).each do |name|
71
+ expect(registered).to include(name), "expected before_checksum :#{name} to survive a reset"
72
+ end
73
+ end
52
74
  end
53
75
 
54
76
  let(:standard_audit_baseline_options) do
55
77
  { subscriptions: subscriptions, settings: settings, catalogue: catalogue,
56
- sensitive_keys: sensitive_keys, sensitive_key_patterns: sensitive_key_patterns, present: present }
78
+ sensitive_keys: sensitive_keys, sensitive_key_patterns: sensitive_key_patterns, present: present,
79
+ hooks: hooks }
57
80
  end
58
81
 
59
82
  it "is registered with configure(baseline: true)" do
@@ -71,6 +94,7 @@ RSpec.shared_examples "a standard_audit baseline" do |options = {}|
71
94
  StandardAudit.config.subscribe_to "standard_audit.isolation_canary"
72
95
  settings.each_key { |name| StandardAudit.config.public_send(:"#{name}=", nil) }
73
96
  present.each { |name| StandardAudit.config.public_send(:"#{name}=", nil) }
97
+ StandardAudit.config.before_checksum_hooks = [] unless hooks.nil?
74
98
 
75
99
  StandardAudit.reset_configuration!
76
100
 
@@ -46,7 +46,8 @@ module StandardAudit
46
46
  target: config.target_extractor.call(payload),
47
47
  scope: config.scope_extractor.call(payload),
48
48
  metadata: payload.except(*EXCLUDED_PAYLOAD_KEYS),
49
- context: payload.slice(:request_id, :ip_address, :user_agent, :session_id)
49
+ context: payload.slice(:request_id, :ip_address, :user_agent, :session_id),
50
+ via: :notification
50
51
  )
51
52
  rescue => e
52
53
  StandardAudit.report_write_error(e, event.name, subscriber: self.class.name)
@@ -1,3 +1,3 @@
1
1
  module StandardAudit
2
- VERSION = "0.12.0"
2
+ VERSION = "0.13.0"
3
3
  end
@@ -17,6 +17,16 @@ module StandardAudit
17
17
  # `sensitive_keys` even if a user adds them there.
18
18
  RESERVED_METADATA_KEYS = %w[_tags _source].freeze
19
19
 
20
+ # Values of `entry[:via]` as `before_write` sees it:
21
+ #
22
+ # * `:direct` — `StandardAudit.record` without a block, and therefore
23
+ # `Auditable#record_audit` and `Operation#audit!`.
24
+ # * `:notification` — the ActiveSupport::Notifications subscriber
25
+ # (`subscribe_to` patterns, and `StandardAudit.record` WITH a block, which
26
+ # instruments the event and lets this subscriber write it).
27
+ # * `:rails_event` — the `Rails.event` subscriber (Rails 8.1+).
28
+ VIA = %i[direct notification rails_event].freeze
29
+
20
30
  class << self
21
31
  # Applies configuration to the single mutable Configuration instance.
22
32
  #
@@ -58,7 +68,8 @@ module StandardAudit
58
68
  # here), so it records only when the event is subscribed to.
59
69
  #
60
70
  # `raise: false` makes a failed write non-fatal: the error is logged and
61
- # reported to `Rails.error` as handled, and nil is returned. For call
71
+ # reported through `config.error_reporter` (default: `Rails.error`, as
72
+ # handled), and nil is returned. For call
62
73
  # sites where a missing audit row must never break the request (auth
63
74
  # failure logging, say). It governs only the audit write — in block form
64
75
  # the subscriber already rescues, and the block's own errors always
@@ -80,7 +91,7 @@ module StandardAudit
80
91
 
81
92
  begin
82
93
  write_entry(event_type, actor: actor, target: target, scope: scope,
83
- metadata: metadata, context: options)
94
+ metadata: metadata, context: options, via: :direct)
84
95
  rescue => e
85
96
  raise if raise_errors
86
97
 
@@ -96,7 +107,11 @@ module StandardAudit
96
107
  # values; nil entries fall back to the Current resolvers. `reserved` is
97
108
  # merged into metadata AFTER `metadata_builder` (the Rails.event subscriber
98
109
  # uses it for `_tags` / `_source`, which a builder never saw before 0.12).
99
- def write_entry(event_type, actor:, target:, scope:, metadata:, context: {}, reserved: {})
110
+ # `via` names the entry point (see VIA) and is handed to `before_write`
111
+ # as `entry[:via]`; it is not persisted.
112
+ def write_entry(event_type, actor:, target:, scope:, metadata:, context: {}, reserved: {}, via: :direct)
113
+ raise ArgumentError, "via must be one of #{VIA.inspect}; got #{via.inspect}" unless VIA.include?(via)
114
+
100
115
  actor ||= config.current_actor_resolver.call
101
116
  scope ||= config.current_scope_resolver&.call
102
117
 
@@ -113,14 +128,16 @@ module StandardAudit
113
128
  request_id: context[:request_id] || config.current_request_id_resolver.call,
114
129
  ip_address: context[:ip_address] || config.current_ip_address_resolver.call,
115
130
  user_agent: context[:user_agent] || config.current_user_agent_resolver.call,
116
- session_id: context[:session_id] || config.current_session_id_resolver.call
131
+ session_id: context[:session_id] || config.current_session_id_resolver.call,
132
+ via: via
117
133
  }
118
134
 
119
- # Runs on EVERY path, before redaction, so anything it injects into
120
- # metadata is still subject to `sensitive_keys` and dereferencing. It may
121
- # mutate `entry` in place; its return value is ignored. It may raise —
122
- # that is how a host guard rejects a write — and the error propagates
123
- # exactly as a failed save would.
135
+ # Runs on EVERY path, AFTER `metadata_builder` and before redaction, so
136
+ # it sees the builder's output and anything it injects into metadata is
137
+ # still subject to `sensitive_keys` and dereferencing. It may mutate
138
+ # `entry` in place; its return value is ignored. It may raise — that is
139
+ # how a host guard rejects a write — and the error propagates exactly as
140
+ # a failed save would. `entry[:via]` says which entry point wrote it.
124
141
  config.before_write&.call(entry)
125
142
 
126
143
  persist(entry)
@@ -147,20 +164,29 @@ module StandardAudit
147
164
  Thread.current[:standard_audit_batch] = previous
148
165
  end
149
166
 
150
- # @api private — logs a failed audit write and reports it to Rails.error
151
- # as handled. Used by the subscribers, which must never let an audit
152
- # failure break the instrumented code path.
167
+ # @api private — logs a failed audit write and reports it through
168
+ # `report_error`. Used by the subscribers, which must never let an audit
169
+ # failure break the instrumented code path, and by `record(raise: false)`.
153
170
  def report_write_error(error, event_type, **context)
154
171
  Rails.logger.error("[StandardAudit] Error creating audit log for #{event_type}: #{error.class}: #{error.message}")
155
- return unless Rails.respond_to?(:error) && Rails.error
172
+ report_error(error, { config.audit_error_context_key => event_type, **context })
173
+ end
156
174
 
157
- Rails.error.report(
158
- error,
159
- handled: true,
160
- context: { config.audit_error_context_key => event_type, **context }
161
- )
175
+ # @api private — the one place the gem reports an error it swallows.
176
+ # Calls `config.error_reporter` when set, otherwise
177
+ # `Rails.error.report(error, handled: true, context:)`. A reporter that
178
+ # raises is logged and ignored.
179
+ def report_error(error, context)
180
+ if config.error_reporter
181
+ config.error_reporter.call(error, context)
182
+ elsif defined?(Rails) && Rails.respond_to?(:error) && Rails.error
183
+ Rails.error.report(error, handled: true, context: context)
184
+ end
185
+ nil
162
186
  rescue => report_failure
163
- Rails.logger.error("[StandardAudit] Error reporting audit failure: #{report_failure.class}: #{report_failure.message}")
187
+ message = "[StandardAudit] Error reporting audit failure: #{report_failure.class}: #{report_failure.message}"
188
+ Rails.logger&.error(message) if defined?(Rails) && Rails.respond_to?(:logger)
189
+ nil
164
190
  end
165
191
 
166
192
  def subscriber
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.12.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -96,16 +96,14 @@ files:
96
96
  - app/jobs/standard_audit/create_audit_log_job.rb
97
97
  - app/models/standard_audit/application_record.rb
98
98
  - app/models/standard_audit/audit_log.rb
99
- - config/routes.rb
100
99
  - lib/generators/standard_audit/add_anonymized_at/add_anonymized_at_generator.rb
101
100
  - lib/generators/standard_audit/add_anonymized_at/templates/add_anonymized_at_to_audit_logs.rb.erb
102
- - lib/generators/standard_audit/add_checksums/add_checksums_generator.rb
103
- - lib/generators/standard_audit/add_checksums/templates/add_checksum_to_audit_logs.rb.erb
104
101
  - lib/generators/standard_audit/add_previous_checksum/add_previous_checksum_generator.rb
105
102
  - lib/generators/standard_audit/add_previous_checksum/templates/add_previous_checksum_to_audit_logs.rb.erb
106
103
  - lib/generators/standard_audit/install/install_generator.rb
107
104
  - lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb
108
105
  - lib/generators/standard_audit/install/templates/initializer.rb.erb
106
+ - lib/generators/standard_audit/migration_number.rb
109
107
  - lib/standard_audit.rb
110
108
  - lib/standard_audit/audit_scope.rb
111
109
  - lib/standard_audit/auditable.rb
data/config/routes.rb DELETED
@@ -1,2 +0,0 @@
1
- StandardAudit::Engine.routes.draw do
2
- end
@@ -1,30 +0,0 @@
1
- module StandardAudit
2
- module Generators
3
- # DEPRECATED (0.12.0); scheduled for removal. This is the 0.2 -> 0.3
4
- # upgrade path that added the `checksum` column. Every install since 0.3
5
- # creates the column via `standard_audit:install`, and no known host still
6
- # needs it. Run `standard_audit:add_previous_checksum` on hosts that
7
- # predate 0.8 instead.
8
- class AddChecksumsGenerator < Rails::Generators::Base
9
- include Rails::Generators::Migration
10
- source_root File.expand_path("templates", __dir__)
11
-
12
- DEPRECATION_MESSAGE = "standard_audit:add_checksums is deprecated and will be removed in a " \
13
- "future release. It only upgrades pre-0.3 installs; the install " \
14
- "generator has created the checksum column since 0.3.".freeze
15
-
16
- def self.next_migration_number(dirname)
17
- Time.now.utc.strftime("%Y%m%d%H%M%S")
18
- end
19
-
20
- def warn_deprecated
21
- say_status :deprecated, DEPRECATION_MESSAGE, :yellow
22
- warn "[StandardAudit] DEPRECATION: #{DEPRECATION_MESSAGE}"
23
- end
24
-
25
- def copy_migration
26
- migration_template "add_checksum_to_audit_logs.rb.erb", "db/migrate/add_checksum_to_audit_logs.rb"
27
- end
28
- end
29
- end
30
- end
@@ -1,6 +0,0 @@
1
- class AddChecksumToAuditLogs < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
- def change
3
- add_column :audit_logs, :checksum, :string, limit: 64
4
- add_index :audit_logs, :created_at
5
- end
6
- end