standard_audit 0.12.1 → 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 +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +78 -19
- data/app/models/standard_audit/audit_log.rb +1 -7
- data/lib/generators/standard_audit/add_anonymized_at/add_anonymized_at_generator.rb +4 -5
- data/lib/generators/standard_audit/add_previous_checksum/add_previous_checksum_generator.rb +4 -5
- data/lib/generators/standard_audit/install/install_generator.rb +2 -5
- data/lib/generators/standard_audit/migration_number.rb +40 -0
- data/lib/standard_audit/configuration.rb +22 -1
- data/lib/standard_audit/engine.rb +5 -2
- data/lib/standard_audit/event_subscriber.rb +2 -1
- data/lib/standard_audit/operation/audit.rb +2 -6
- data/lib/standard_audit/rspec/baseline.rb +27 -3
- data/lib/standard_audit/subscriber.rb +2 -1
- data/lib/standard_audit/version.rb +1 -1
- data/lib/standard_audit.rb +45 -19
- metadata +2 -4
- data/config/routes.rb +0 -2
- data/lib/generators/standard_audit/add_checksums/add_checksums_generator.rb +0 -30
- data/lib/generators/standard_audit/add_checksums/templates/add_checksum_to_audit_logs.rb.erb +0 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c41a6212ecc33a1cdba995a12cc4c2205cf9bafe5b7eeb6cdd5c83288e1738d7
|
|
4
|
+
data.tar.gz: 41111fb868232c50a068a8e21ba2597ed4e60f4c15bc3018afc72199ecfc659e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 710317dda7bf0b14fcf77f72365a2edcaaa33d6e77e8177e96bdad8dd86e1ef84bb57427a749c42b50b10e3f8a92aafc3efb2597eb0014fdba1b5639758c6223
|
|
7
|
+
data.tar.gz: 8e1e70aeed7098a52a413e37f971a0f433d535e36e880618aa6ddf2c000c6a664c2f29fc124653e14849a3389a2badbcdab0450aa9282fa80d077f219a77e082
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,40 @@ 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
|
+
|
|
10
44
|
## [0.12.1] - 2026-09-24
|
|
11
45
|
|
|
12
46
|
### 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
|
|
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,11 +116,30 @@ 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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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.
|
|
124
143
|
|
|
125
144
|
**Replace your host code with** `raise: false`. It supersedes the
|
|
126
145
|
`AuditAuthFailure#record_auth_failure` rescue-and-report wrapper
|
|
@@ -172,18 +191,40 @@ never ran on direct `record` calls.)
|
|
|
172
191
|
```ruby
|
|
173
192
|
config.before_write = ->(entry) {
|
|
174
193
|
# entry: { event_type:, actor:, target:, scope:, metadata:,
|
|
175
|
-
# request_id:, ip_address:, user_agent:, session_id: }
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
180
200
|
}
|
|
181
201
|
```
|
|
182
202
|
|
|
183
|
-
|
|
184
|
-
dereferencing
|
|
185
|
-
|
|
186
|
-
|
|
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+) |
|
|
187
228
|
|
|
188
229
|
**Hooks run once per row, batched writes included.** `before_write` and
|
|
189
230
|
`before_checksum` run for every row inside `StandardAudit.batch` too (at flush
|
|
@@ -424,7 +465,8 @@ RSpec.describe "StandardAudit configuration baseline" do
|
|
|
424
465
|
catalogue: -> { AuditCatalogue::ACTIONS },
|
|
425
466
|
sensitive_keys: %i[source_payload],
|
|
426
467
|
sensitive_key_patterns: [/secret/i],
|
|
427
|
-
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
|
|
428
470
|
end
|
|
429
471
|
```
|
|
430
472
|
|
|
@@ -433,6 +475,13 @@ restored after a mutation plus `reset_configuration!`. Behaviour held in
|
|
|
433
475
|
lambdas can only be checked for presence; keep an app-specific example for
|
|
434
476
|
anything whose *result* matters.
|
|
435
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
|
+
|
|
436
485
|
**Replace your host code with** the shared example. It supersedes the bulk of
|
|
437
486
|
each app's `spec/initializers/standard_audit_baseline_spec.rb` (or
|
|
438
487
|
`spec/config/…`). The `Current.account` / `Current.session&.id` resolver
|
|
@@ -527,9 +576,14 @@ StandardAudit.configure(baseline: true) do |config|
|
|
|
527
576
|
config.current_scope_resolver = -> { Current.organisation }
|
|
528
577
|
|
|
529
578
|
# -- before_write --
|
|
530
|
-
# Runs on every 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".
|
|
531
581
|
config.before_write = ->(entry) { entry[:metadata] = entry[:metadata].merge("surface" => Current.surface) }
|
|
532
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
|
+
|
|
533
587
|
# -- Async Processing --
|
|
534
588
|
# Offload audit log creation to ActiveJob.
|
|
535
589
|
config.async = false
|
|
@@ -896,7 +950,12 @@ It is for rows that never had a checksum at all (pre-feature data).
|
|
|
896
950
|
| `standard_audit:install` | `audit_logs` migration + initializer (new installs) |
|
|
897
951
|
| `standard_audit:add_previous_checksum` | Adds `previous_checksum` (upgrading from < 0.8) |
|
|
898
952
|
| `standard_audit:add_anonymized_at` | Adds `anonymized_at` (upgrading from < 0.12) |
|
|
899
|
-
|
|
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.)
|
|
900
959
|
|
|
901
960
|
## Rake Tasks
|
|
902
961
|
|
|
@@ -597,13 +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
|
-
|
|
601
|
-
Rails.error.report(
|
|
602
|
-
e,
|
|
603
|
-
handled: true,
|
|
604
|
-
context: { StandardAudit.config.audit_error_context_key => event_type }
|
|
605
|
-
)
|
|
606
|
-
end
|
|
600
|
+
StandardAudit.report_error(e, { StandardAudit.config.audit_error_context_key => event_type })
|
|
607
601
|
nil
|
|
608
602
|
end
|
|
609
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
|
|
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,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
|
|
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
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
Rails.error.report(
|
|
285
|
+
StandardAudit.report_error(
|
|
288
286
|
error,
|
|
289
|
-
|
|
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)
|
data/lib/standard_audit.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
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
|
|
151
|
-
#
|
|
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
|
-
|
|
172
|
+
report_error(error, { config.audit_error_context_key => event_type, **context })
|
|
173
|
+
end
|
|
156
174
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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.
|
|
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,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
|