standard_audit 0.11.1 → 0.12.1

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: 697434bf73f4588971d9fda7503f13f53a848ab4fe058fbc05be5b567a186d6f
4
- data.tar.gz: e5bad881dc6a5ab13ae22d13777176120967161f40356dcd6522ab4616492c1f
3
+ metadata.gz: a6da3bdedaecde5d44a57afe47372bf419b9290ace08a2312d1c522df5a67561
4
+ data.tar.gz: 283e8c2839ddce46fa2ec6468423c75380256f51f05a0a52844763d5454b7da3
5
5
  SHA512:
6
- metadata.gz: feb2b3354808c948aff042e0a3354ac2344c3e4ac1b03f0c375645ff7b1aaf1ec27253e87e764652ee7d52710a4c7aecd7748ba13475afa001a0c27f55a2ca79
7
- data.tar.gz: a83422f4ef6b6ca319c29c450ae7cf82457cc5ad342345947a867e2243ef701c2bb0310a5b1d99f5cbf9c322b756f5094c42e77e53eb6ea3b828a834f94bfacc
6
+ metadata.gz: 96cd2995bbaaab665bc485770604f37b0b4188cf052a30639b804882a70cc276fad7e10b7ee21c65fa0294018687e06cea921ae7e387f4666a3f7298d29bbd09
7
+ data.tar.gz: 810d2becd35d3e0a5325786b86415c48318b4c23d7c8df46742916053cff6eaf5f97e3cba1e94b8ab479437751ba94177c28b05bc4c12837208a975698788aa0
data/CHANGELOG.md CHANGED
@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.1] - 2026-09-24
11
+
12
+ ### Upgrade steps
13
+
14
+ 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.
15
+ 2. If an error-tracker search or alert matches `before_checksum` hook failures on the `audit_event` context key, see Fixed.
16
+
17
+ ### Fixed
18
+
19
+ - **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.
20
+ - **`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.
21
+
22
+ ### Documentation
23
+
24
+ - `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.
25
+ - 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.
26
+ - `record(raise: false)` reports through `Rails.error`, so failures reach Sentry only if a `Rails.error` subscriber is registered (`sentry-rails` registers one).
27
+
28
+ ## [0.12.0] - 2026-09-24
29
+
30
+ ### Upgrade steps
31
+
32
+ 1. Bump the gem and run `rails generate standard_audit:add_anonymized_at && rails db:migrate` (new nullable `audit_logs.anonymized_at`; idempotent, no table rewrite, strong_migrations-safe). Optional — without it everything works as in 0.11, and anonymized rows keep failing `verify_chain`.
33
+ 2. StandardId apps: delete `config.current_actor_resolver = -> { Current.account }` and `config.current_session_id_resolver = -> { Current.session&.id }` — they are now the defaults.
34
+ 3. If your `metadata_builder` must NOT run on direct `StandardAudit.record` / `audit!` writes, make it idempotent or move that logic (see Changed).
35
+ 4. Optionally adopt `current_scope_resolver`, `before_write`, `raise: false` and `require "standard_audit/rspec"`'s `have_audited` / baseline shared example — each README section says which host code it replaces.
36
+
37
+ ### Added
38
+
39
+ - **`config.before_write = ->(entry) { … }`** — runs on every write path (direct `record`, block form, `Auditable#record_audit`, `Operation#audit!`, both subscribers; sync, async and batched), after the Current resolvers and `metadata_builder` and before dereferencing/redaction. Mutate the entry in place; raising aborts the write (direct callers see the error; subscribers rescue and report it). Replaces host wrappers such as fundbright's `AuditWriting#record_audit!` and its `audit!` PII-guard/`surface` override.
40
+ - **`config.current_scope_resolver`** (default `nil`) — fallback audit scope when a write names none. Explicit `scope:` and `scope_extractor` results always win. Applied on every write path.
41
+ - **`StandardAudit.record(..., raise: false)`** — a failed write is logged, reported to `Rails.error` as handled, and returns nil. Replaces the `AuditAuthFailure` rescue-and-report wrappers.
42
+ - **`audit_logs.anonymized_at`** and the `standard_audit:add_anonymized_at` generator; the install migration now includes the column. `AuditLog#anonymized?` and `AuditLog.anonymization_column?`.
43
+ - **`verify_chain` result gains `redacted:`** — rows stamped `anonymized_at` are counted there instead of as `digest_mismatch` failures. Their stored checksum is kept, so the chain still links through them, and their declared parent is still checked for `missing_parent`. `rake standard_audit:verify` prints the count when nonzero.
44
+ - **RSpec support:** `have_audited("event").by(actor).on(target).within(scope).with_metadata(...).once` block matcher, and the `"a standard_audit baseline"` shared example. Loaded by `require "standard_audit/rspec"`, or individually from `standard_audit/rspec/matchers` and `standard_audit/rspec/baseline`.
45
+
46
+ ### Changed
47
+
48
+ - **One write path.** Every entry point now ends in `StandardAudit.write_entry`. `StandardAudit::Subscriber` no longer re-implements the write, which has three visible effects:
49
+ - **`metadata_builder` now runs on direct `StandardAudit.record` calls** (and therefore `audit!` / `record_audit`), not only on the subscriber paths. Builders that inject context (`engine_scope`) now reach direct writes, closing the gap fundbright-web's initializer documents. A builder that is not idempotent would now see operation metadata too — review before upgrading.
50
+ - **Events handled by the ActiveSupport::Notifications subscriber inside `StandardAudit.batch` are now buffered** and flushed with the batch, like the `Rails.event` subscriber and direct calls already were. `before_checksum` hooks run for them (see Fixed).
51
+ - Subscriber failures are logged as `[StandardAudit] Error creating audit log for <event>: …` and reported with the same context as 0.11.1.
52
+ - **Default resolvers match StandardId.** `current_actor_resolver` tries `Current.account`, then `Current.user`; `current_session_id_resolver` tries `Current.session&.id`, then `Current.session_id`. Each is `respond_to?`-guarded, so `Current.user` apps are unaffected. The README's zero-config claim for StandardId is now true.
53
+ - `Rails.event` reserved metadata (`_tags`, `_source`) is still merged after `metadata_builder`, so builders never see it.
54
+
55
+ ### Fixed
56
+
57
+ - **`before_checksum` hooks now run on batched writes.** `StandardAudit.batch` flushes with `insert_all!`, which never built a model, so hooks silently skipped every batched row. The flush now runs each buffered row through the hooks (per-hook isolation included) before checksumming, so batched rows get the same derived columns as `save!` and still verify.
58
+
59
+ ### Deprecated
60
+
61
+ - **`standard_audit:add_checksums` generator** — the 0.2 → 0.3 upgrade path. It still works but warns; removal is planned.
62
+
10
63
  ## [0.11.1] - 2026-09-24
11
64
 
12
65
  ### Fixed
data/README.md CHANGED
@@ -96,7 +96,38 @@ StandardAudit.record("orders.created",
96
96
  )
97
97
  ```
98
98
 
99
- When `actor` is omitted, it falls back to the configured `current_actor_resolver` (which reads from `Current.user` by default).
99
+ When `actor` is omitted, it falls back to the configured `current_actor_resolver` (which reads `Current.account`, then `Current.user`, by default).
100
+
101
+ #### Non-fatal writes: `raise: false`
102
+
103
+ Where a missing audit row must never break the request — logging an
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" }`),
107
+ and `record` returns nil:
108
+
109
+ ```ruby
110
+ StandardAudit.record("auth.token_invalid",
111
+ actor: token,
112
+ metadata: { error_code: "AUTH_001", path: request.path },
113
+ raise: false)
114
+ ```
115
+
116
+ The default is `raise: true` (unchanged). In block form the option only
117
+ governs the audit write; errors from your block always propagate.
118
+
119
+ `raise: false` does not talk to Sentry (or any other tracker) itself. It calls
120
+ `Rails.error.report`, so a swallowed failure reaches your error tracker only if
121
+ something subscribes to `Rails.error`. `sentry-rails` registers that subscriber
122
+ for you. With a hand-rolled Sentry setup, register one
123
+ (`Rails.error.subscribe(...)`), or these failures show up only in the log.
124
+
125
+ **Replace your host code with** `raise: false`. It supersedes the
126
+ `AuditAuthFailure#record_auth_failure` rescue-and-report wrapper
127
+ (sidekick-web, luminality-web, nutripod-web
128
+ `app/controllers/concerns/audit_auth_failure.rb`); set
129
+ `config.audit_error_context_key = :audit_event` to keep those apps' Sentry
130
+ grouping key.
100
131
 
101
132
  ### ActiveSupport::Notifications
102
133
 
@@ -125,6 +156,67 @@ end
125
156
 
126
157
  This uses `ActiveSupport::Notifications.instrument` under the hood.
127
158
 
159
+ ### One write path, and `before_write`
160
+
161
+ Every entry point — `StandardAudit.record` (plain and block form),
162
+ `Auditable#record_audit`, `Operation#audit!`, the ActiveSupport::Notifications
163
+ subscriber and the `Rails.event` subscriber — ends in the same write path.
164
+ Actor/session/scope resolution, `metadata_builder`, `before_write`, record
165
+ dereferencing, redaction, `StandardAudit.batch` and `async` therefore behave
166
+ identically whichever way a row is written. (Before 0.12.0 the notifications
167
+ subscriber carried its own copy: it ignored `batch`, and `metadata_builder`
168
+ never ran on direct `record` calls.)
169
+
170
+ `config.before_write` is the seam for host policy that must apply to every row:
171
+
172
+ ```ruby
173
+ config.before_write = ->(entry) {
174
+ # entry: { event_type:, actor:, target:, scope:, metadata:,
175
+ # request_id:, ip_address:, user_agent:, session_id: }
176
+ AuditMetadataPii.verify!(entry[:metadata]) if StandardAudit::Operation::Audit.verify?
177
+ if (surface = Current.audit_surface).present?
178
+ entry[:metadata] = entry[:metadata].merge(surface: surface)
179
+ end
180
+ }
181
+ ```
182
+
183
+ It runs after the `Current` resolvers and `metadata_builder`, and **before**
184
+ dereferencing and `sensitive_keys` redaction, so anything it injects is still
185
+ filtered. Mutate `entry` in place; the return value is ignored. Raising aborts
186
+ the write: direct callers see the error, the subscribers rescue and report it.
187
+
188
+ **Hooks run once per row, batched writes included.** `before_write` and
189
+ `before_checksum` run for every row inside `StandardAudit.batch` too (at flush
190
+ time for `before_checksum`). A hook that looks something up per actor (a role,
191
+ a membership, a tenant) therefore runs one query per row and turns a batch into
192
+ an N+1. Memoize those lookups for the unit of work, for example in a
193
+ `CurrentAttributes` cache that resets with the request or job:
194
+
195
+ ```ruby
196
+ class Current < ActiveSupport::CurrentAttributes
197
+ attribute :audit_actor_roles
198
+
199
+ def self.audit_role_for(actor)
200
+ self.audit_actor_roles ||= {}
201
+ audit_actor_roles[actor.to_global_id.to_s] ||= actor.audit_role
202
+ end
203
+ end
204
+
205
+ # actor_role: a column your app added to audit_logs
206
+ config.before_checksum { |log| log.actor_role = Current.audit_role_for(log.actor) if log.actor }
207
+ ```
208
+
209
+ **Replace your host code with** a `before_write`. It supersedes:
210
+
211
+ - a hand-built job-side wrapper that runs a guard before `StandardAudit.record`
212
+ (fundbright-web `AuditWriting#record_audit!`);
213
+ - an `audit!` override that runs a PII guard and injects metadata
214
+ (fundbright-web `ApplicationOperation#audit!`);
215
+ - a `metadata_builder` whose injection (`engine_scope`) is documented as
216
+ missing direct writes (fundbright-web / luminality-web initializers) —
217
+ `metadata_builder` now applies to direct writes too, so no change is needed
218
+ beyond deleting the caveat.
219
+
128
220
  ## Model Concerns
129
221
 
130
222
  ### Auditable
@@ -284,6 +376,69 @@ outside `app/operations/`, which would otherwise always look orphaned.
284
376
  The registry can only see loaded classes, so the shared example calls
285
377
  `Rails.application.eager_load!` by default (`eager_load: false` to opt out).
286
378
 
379
+ ## Testing
380
+
381
+ ```ruby
382
+ # spec/rails_helper.rb
383
+ require "standard_audit/rspec"
384
+ ```
385
+
386
+ This resets StandardAudit state before every example (so declare your
387
+ configuration with `configure(baseline: true)`), and loads two helpers. Each is
388
+ also loadable on its own: `standard_audit/rspec/matchers`,
389
+ `standard_audit/rspec/baseline`.
390
+
391
+ ### `have_audited`
392
+
393
+ ```ruby
394
+ expect { Orders::Create.call(order) }
395
+ .to have_audited("order.created")
396
+ .by(account) # a record, an RSpec matcher, or no arg = "any resolvable actor"
397
+ .on(order) # target, same forms
398
+ .within(organisation) # scope, same forms
399
+ .with_metadata(total: 100, tags: include("vip")) # subset; values may be matchers
400
+ .once # or .times(n); default "at least one"
401
+
402
+ expect { noop }.not_to have_audited("order.created")
403
+ ```
404
+
405
+ Only rows persisted during the block count. On failure it lists the rows that
406
+ were written, so a wrong actor or metadata shows up directly.
407
+
408
+ **Replace your host code with** `have_audited`. It supersedes hand-rolled
409
+ helpers such as `expect_well_formed_audit(action)` (sidekick-web
410
+ `spec/support/audit_log_coverage.rb`) — the equivalent is
411
+ `have_audited(action).by.on` — and the
412
+ `change(StandardAudit::AuditLog, :count)` + `AuditLog.last` pairs across the
413
+ five apps' coverage specs.
414
+
415
+ ### `"a standard_audit baseline"`
416
+
417
+ Guards that your configuration survives the per-example reset:
418
+
419
+ ```ruby
420
+ RSpec.describe "StandardAudit configuration baseline" do
421
+ it_behaves_like "a standard_audit baseline",
422
+ subscriptions: [/\Astandard_id\./, /\Aauthorization\./],
423
+ settings: { retention_days: 1826, raise_on_audit_write_error: true, filter_nested_metadata: true },
424
+ catalogue: -> { AuditCatalogue::ACTIONS },
425
+ sensitive_keys: %i[source_payload],
426
+ sensitive_key_patterns: [/secret/i],
427
+ present: %i[metadata_builder before_write current_scope_resolver]
428
+ end
429
+ ```
430
+
431
+ It checks the baseline is registered, that each value holds, and that it is
432
+ restored after a mutation plus `reset_configuration!`. Behaviour held in
433
+ lambdas can only be checked for presence; keep an app-specific example for
434
+ anything whose *result* matters.
435
+
436
+ **Replace your host code with** the shared example. It supersedes the bulk of
437
+ each app's `spec/initializers/standard_audit_baseline_spec.rb` (or
438
+ `spec/config/…`). The `Current.account` / `Current.session&.id` resolver
439
+ examples can go too once those overrides are deleted (they are the 0.12.0
440
+ defaults).
441
+
287
442
  ## Configuration Reference
288
443
 
289
444
  Use `configure(baseline: true)` in your initializer. It remembers the block so
@@ -309,11 +464,12 @@ StandardAudit.configure(baseline: true) do |config|
309
464
  # -- Current Attribute Resolvers --
310
465
  # Fallbacks used when payload values are nil.
311
466
  # Designed to work with Rails Current attributes.
312
- config.current_actor_resolver = -> { Current.user }
467
+ # (Defaults shown in simplified form — each is respond_to?-guarded.)
468
+ config.current_actor_resolver = -> { Current.account || Current.user }
313
469
  config.current_request_id_resolver = -> { Current.request_id }
314
470
  config.current_ip_address_resolver = -> { Current.ip_address }
315
471
  config.current_user_agent_resolver = -> { Current.user_agent }
316
- config.current_session_id_resolver = -> { Current.session_id }
472
+ config.current_session_id_resolver = -> { Current.session&.id || Current.session_id }
317
473
 
318
474
  # -- Sensitive Data --
319
475
  # Keys automatically stripped from metadata. Matching is EXACT on the key
@@ -340,7 +496,7 @@ StandardAudit.configure(baseline: true) do |config|
340
496
  # Run between the UUID assignment and the checksum computation, so a hook MAY
341
497
  # set a checksummed column and the row still passes `verify_chain`. No
342
498
  # `prepend: true` needed. Each hook is rescued individually and can never fail
343
- # the audit write. Not run on the batched `insert_all!` path.
499
+ # the audit write. Also run on batched writes, at flush time (since 0.12.0).
344
500
  config.before_checksum { |log| log.scope = MyApp.derive_scope(log) }
345
501
  config.before_checksum :backfill_scope # an AuditLog instance method
346
502
 
@@ -362,9 +518,18 @@ StandardAudit.configure(baseline: true) do |config|
362
518
  }
363
519
 
364
520
  # -- Metadata Builder --
365
- # Optional proc to transform metadata before storage.
521
+ # Optional proc to transform metadata before storage. Runs on EVERY write
522
+ # path (since 0.12.0 — previously only the two subscribers).
366
523
  config.metadata_builder = ->(metadata) { metadata.slice(:relevant_key) }
367
524
 
525
+ # -- Ambient scope --
526
+ # Fallback tenant when a write names none. See "Multi-Tenancy".
527
+ config.current_scope_resolver = -> { Current.organisation }
528
+
529
+ # -- before_write --
530
+ # Runs on every write path, before redaction. See "One write path".
531
+ config.before_write = ->(entry) { entry[:metadata] = entry[:metadata].merge("surface" => Current.surface) }
532
+
368
533
  # -- Async Processing --
369
534
  # Offload audit log creation to ActiveJob.
370
535
  config.async = false
@@ -386,7 +551,26 @@ end
386
551
 
387
552
  ### Default Current Attribute Resolvers
388
553
 
389
- Out of the box, StandardAudit reads from `Current` if it responds to the relevant method. This means if your app (or an auth library like StandardId) populates `Current.user`, `Current.request_id`, etc., audit logs automatically capture request context with zero configuration.
554
+ Out of the box, StandardAudit reads from a top-level `Current` if it responds to the relevant method:
555
+
556
+ | Column | Default resolution (first non-nil wins) |
557
+ |--------------|--------------------------------------------------|
558
+ | actor | `Current.account`, then `Current.user` |
559
+ | session_id | `Current.session&.id`, then `Current.session_id` |
560
+ | request_id | `Current.request_id` |
561
+ | ip_address | `Current.ip_address` |
562
+ | user_agent | `Current.user_agent` |
563
+
564
+ The `account`/`session` pair is what StandardId's `Current` exposes, so a StandardId app gets actor and session attribution with zero configuration (since 0.12.0 — earlier versions read only `Current.user`/`Current.session_id`, which StandardId does not define). Apps following the Rails generator convention (`Current.user`) keep working unchanged.
565
+
566
+ **Replace your host code with:** nothing. If your initializer carries
567
+
568
+ ```ruby
569
+ config.current_actor_resolver = -> { Current.account }
570
+ config.current_session_id_resolver = -> { Current.session&.id }
571
+ ```
572
+
573
+ both lines are now the defaults and can be deleted.
390
574
 
391
575
  ## Query Interface
392
576
 
@@ -471,6 +655,39 @@ StandardAudit::AuditLog.for_scope(current_organisation)
471
655
 
472
656
  The scope is stored as a GlobalID string, so it works with any model class.
473
657
 
658
+ ### Ambient scope: `current_scope_resolver`
659
+
660
+ When most writes happen inside a tenant-scoped request, resolve the scope from
661
+ `Current` instead of threading it through every call:
662
+
663
+ ```ruby
664
+ config.current_scope_resolver = -> { Current.channel || Current.organisation }
665
+ ```
666
+
667
+ It is a *fallback*: an explicit `scope:` and a scope found by `scope_extractor`
668
+ always win. It applies on every write path (direct `record`, `audit!`,
669
+ `record_audit`, both subscribers; sync, async and batched). Default `nil`.
670
+
671
+ `current_scope_resolver` is for scope that comes from **ambient request
672
+ state** (`Current`). It takes no arguments and never sees the row. Scope that
673
+ derives from the **row itself**, such as the organisation that owns the
674
+ target, belongs in a `before_checksum` hook (or `before_write`), which receives
675
+ the record or entry:
676
+
677
+ ```ruby
678
+ config.before_checksum do |log|
679
+ log.scope ||= log.target.organisation if log.target.respond_to?(:organisation)
680
+ end
681
+ ```
682
+
683
+ **Replace your host code with** the one line above when your fallback reads
684
+ `Current`. It supersedes a `scope_extractor` that falls back to `Current`
685
+ (nutripod-web:
686
+ `->(payload) { payload[:scope] || Current.channel || Current.organisation }`),
687
+ which only ever covered the subscriber path. Keep `scope_extractor` for reading
688
+ the payload and move the `Current` fallback here. Target-derived scope hooks
689
+ stay as they are; since 0.12.0 they also run on the batched path.
690
+
474
691
  ## Async Processing
475
692
 
476
693
  For high-throughput applications, offload audit log creation to a background job:
@@ -509,6 +726,37 @@ GlobalID raises `ArgumentError`.
509
726
  StandardAudit::AuditLog.anonymize_actor!("gid://myapp/User/123")
510
727
  ```
511
728
 
729
+ #### Anonymization and the checksum chain
730
+
731
+ Anonymizing rewrites checksummed columns, so an anonymized row can no longer
732
+ reproduce its own digest. Since 0.12.0 its stored `checksum` is left untouched
733
+ (the rows after it still link to it) and, when the table has an
734
+ `anonymized_at` column, the row is stamped. `verify_chain` then counts it under
735
+ `redacted` instead of reporting a `digest_mismatch`:
736
+
737
+ ```ruby
738
+ StandardAudit::AuditLog.verify_chain
739
+ # => { valid: true, verified: 5576, recovered: 0, redacted: 1, failures: [] }
740
+ ```
741
+
742
+ A redacted row's declared parent is still checked, so deleting the row before
743
+ it is still reported as `missing_parent`. Reconcile a nonzero `redacted`
744
+ against your erasure records: anyone who can write `anonymized_at` can hide an
745
+ edit behind it.
746
+
747
+ Existing installs add the column with:
748
+
749
+ ```bash
750
+ rails generate standard_audit:add_anonymized_at
751
+ rails db:migrate
752
+ ```
753
+
754
+ (nullable, no default, idempotent; safe under strong_migrations). Without it,
755
+ `anonymize_actor!` works exactly as before and anonymized rows keep failing
756
+ `verify_chain` as `digest_mismatch`. Rows anonymized before the migration are
757
+ not stamped retroactively — they are indistinguishable from tampered rows after
758
+ the fact; stamp them by hand if your erasure log identifies them.
759
+
512
760
  ### Right to Access (Export)
513
761
 
514
762
  Export all audit data for a specific user:
@@ -584,9 +832,12 @@ own digest, so editing it invalidates the row.
584
832
 
585
833
  ```ruby
586
834
  result = StandardAudit::AuditLog.verify_chain
587
- # => { valid: true, verified: 5577, recovered: 0, failures: [] }
835
+ # => { valid: true, verified: 5577, recovered: 0, redacted: 0, failures: [] }
588
836
  ```
589
837
 
838
+ `redacted` counts rows anonymized by `anonymize_actor!` — see "Anonymization
839
+ and the checksum chain".
840
+
590
841
  - `failures` carries `reason: :digest_mismatch` (the row's fields no longer
591
842
  produce its digest) or `reason: :missing_parent` (the row it was appended to
592
843
  is no longer in the log). Retention pruning does not trip `:missing_parent`:
@@ -638,6 +889,15 @@ verification — which is the point.
638
889
  re-signs rows from their current contents, so it attests only that a script ran.
639
890
  It is for rows that never had a checksum at all (pre-feature data).
640
891
 
892
+ ## Generators
893
+
894
+ | Generator | Purpose |
895
+ |-----------|---------|
896
+ | `standard_audit:install` | `audit_logs` migration + initializer (new installs) |
897
+ | `standard_audit:add_previous_checksum` | Adds `previous_checksum` (upgrading from < 0.8) |
898
+ | `standard_audit:add_anonymized_at` | Adds `anonymized_at` (upgrading from < 0.12) |
899
+ | `standard_audit:add_checksums` | **Deprecated** (0.12.0; to be removed). The 0.2 → 0.3 upgrade path; warns when run |
900
+
641
901
  ## Rake Tasks
642
902
 
643
903
  ```bash
@@ -740,10 +1000,10 @@ key you want on an audit row, while the value serialises with
740
1000
  happens to hold. Audit rows are append-only, so an unsafe default cannot be
741
1001
  walked back.
742
1002
 
743
- On the notifications path, records are dereferenced **after**
744
- `metadata_builder` runs, so a builder that needs real attributes still gets the
745
- record (`metadata_builder` has never applied to a direct `StandardAudit.record`
746
- call — pass the attributes you want in `metadata` there):
1003
+ Records are dereferenced **after** `metadata_builder` (and `before_write`)
1004
+ run, so a builder that needs real attributes still gets the record. Since
1005
+ 0.12.0 this holds on every write path, including direct `StandardAudit.record`
1006
+ calls:
747
1007
 
748
1008
  ```ruby
749
1009
  config.metadata_builder = ->(metadata) {
@@ -95,12 +95,21 @@ module StandardAudit
95
95
  # `subject` may be a record, a GlobalID, or a GlobalID string
96
96
  # ("gid://app/User/1"). A string is parsed, never located, so erasure and
97
97
  # export still work after the subject's own row has been deleted.
98
+ #
99
+ # Anonymization rewrites CHECKSUM_FIELDS, so an anonymized row can no
100
+ # longer reproduce its own digest. Its stored `checksum` is deliberately
101
+ # left untouched, so the rows after it still link to it. When the host has
102
+ # the `anonymized_at` column (`standard_audit:add_anonymized_at`), the row
103
+ # is stamped and `verify_chain` counts it as `redacted` rather than as a
104
+ # `digest_mismatch` failure. Without the column, anonymization works
105
+ # exactly as before and such rows still fail verification.
98
106
  def self.anonymize_actor!(subject)
99
107
  gid = subject_gid_for(subject)
100
108
  logs = where("actor_gid = ? OR target_gid = ?", gid, gid)
101
109
  count = logs.count
102
110
 
103
111
  anonymizable_keys = StandardAudit.config.anonymizable_metadata_keys.map(&:to_s)
112
+ stamp = anonymization_column? ? Time.current : nil
104
113
 
105
114
  logs.find_each do |log|
106
115
  attrs = {
@@ -119,12 +128,26 @@ module StandardAudit
119
128
  attrs[:metadata] = cleaned_metadata
120
129
  end
121
130
 
131
+ attrs[:anonymized_at] = log.anonymized_at || stamp if stamp
132
+
122
133
  log.update_columns(attrs)
123
134
  end
124
135
 
125
136
  count
126
137
  end
127
138
 
139
+ # True when the table carries `anonymized_at` (the
140
+ # `standard_audit:add_anonymized_at` migration, or a 0.12+ install).
141
+ def self.anonymization_column?
142
+ column_names.include?("anonymized_at")
143
+ end
144
+
145
+ # True when this row's checksummed fields were rewritten by
146
+ # `anonymize_actor!`, so its digest is unverifiable by construction.
147
+ def anonymized?
148
+ has_attribute?(:anonymized_at) && anonymized_at.present?
149
+ end
150
+
128
151
  def self.export_for_actor(subject)
129
152
  gid = subject_gid_for(subject)
130
153
  logs = where("actor_gid = ? OR target_gid = ?", gid, gid).chronological
@@ -190,6 +213,20 @@ module StandardAudit
190
213
  OpenSSL::Digest::SHA256.hexdigest(canonical)
191
214
  end
192
215
 
216
+ # Runs the configured `before_checksum` hooks against a row that will be
217
+ # written by `insert_all!` (the `StandardAudit.batch` flush), so batched
218
+ # writes get the same derived columns as a `save!`. The row is loaded into
219
+ # an unsaved instance, the hooks run exactly as on `before_create`, and the
220
+ # resulting attributes are returned for checksumming. With no hooks
221
+ # registered the row is returned untouched and no model is built.
222
+ def self.apply_before_checksum_hooks(row)
223
+ return row if StandardAudit.config.before_checksum_hooks.blank?
224
+
225
+ log = new(row)
226
+ log.send(:run_before_checksum_hooks)
227
+ log.attributes.symbolize_keys.except(:checksum, :previous_checksum)
228
+ end
229
+
193
230
  # The checksum of the most recent row — the node a new row links to.
194
231
  def self.chain_tip_checksum
195
232
  order(created_at: :desc, id: :desc).limit(1).pick(:checksum)
@@ -204,9 +241,18 @@ module StandardAudit
204
241
  end
205
242
 
206
243
  # Verifies the integrity of the audit log. Returns a result hash with
207
- # :valid (boolean), :verified (count), :recovered (count) and :failures
208
- # (array of hashes carrying :id, :event_type, :created_at, :expected,
209
- # :actual and :reason).
244
+ # :valid (boolean), :verified (count), :recovered (count), :redacted
245
+ # (count) and :failures (array of hashes carrying :id, :event_type,
246
+ # :created_at, :expected, :actual and :reason).
247
+ #
248
+ # A row stamped `anonymized_at` (GDPR erasure via `anonymize_actor!`) is
249
+ # counted in :redacted instead of being digest-checked: its checksummed
250
+ # fields were rewritten on purpose, so its digest cannot reproduce. Its
251
+ # stored checksum is unchanged, so the rest of the chain still links
252
+ # through it, and a declared parent is still checked for presence. Treat a
253
+ # nonzero :redacted as something to reconcile against your erasure
254
+ # records — anyone able to write `anonymized_at` can also hide an edit
255
+ # behind it.
210
256
  #
211
257
  # Records are processed in (created_at, id) order. Records without a
212
258
  # checksum (pre-feature data) reset the walk — the next checksummed record
@@ -253,6 +299,7 @@ module StandardAudit
253
299
  previous_checksum = nil
254
300
  verified = 0
255
301
  recovered = 0
302
+ redacted = 0
256
303
  failures = []
257
304
  window = []
258
305
  first_row = true
@@ -265,10 +312,20 @@ module StandardAudit
265
312
  next
266
313
  end
267
314
 
268
- verified += 1
269
315
  declared = record.previous_checksum if declared_parents
270
316
 
271
- if declared.present?
317
+ if record.anonymized?
318
+ redacted += 1
319
+ # The digest cannot be checked, but the parent it declares can.
320
+ if declared.present? && check_parents && !parent_present?(declared, window, relation)
321
+ if first_row
322
+ pruned_parents << declared
323
+ elsif !pruned_parents.include?(declared)
324
+ failures << chain_failure(record, expected: nil, reason: :missing_parent)
325
+ end
326
+ end
327
+ elsif declared.present?
328
+ verified += 1
272
329
  expected = record.compute_checksum_value(previous_checksum: declared)
273
330
 
274
331
  if record.checksum != expected
@@ -286,6 +343,7 @@ module StandardAudit
286
343
  end
287
344
  end
288
345
  else
346
+ verified += 1
289
347
  expected = record.compute_checksum_value(previous_checksum: previous_checksum)
290
348
 
291
349
  if record.checksum == expected
@@ -303,7 +361,7 @@ module StandardAudit
303
361
  window.shift if window.size > recovery_window
304
362
  end
305
363
 
306
- { valid: failures.empty?, verified: verified, recovered: recovered, failures: failures }
364
+ { valid: failures.empty?, verified: verified, recovered: recovered, redacted: redacted, failures: failures }
307
365
  end
308
366
 
309
367
  # Records, for every row that does not already carry one, the parent digest
@@ -539,7 +597,13 @@ module StandardAudit
539
597
  # persist — so the hook would not actually be "skipped".
540
598
  restore_attributes_from(snapshot)
541
599
  Rails.logger.warn("[StandardAudit] before_checksum hook failed: #{e.class}: #{e.message}")
542
- Rails.error.report(e, handled: true, context: { audit_event: event_type }) if Rails.respond_to?(:error)
600
+ if Rails.respond_to?(:error) && Rails.error
601
+ Rails.error.report(
602
+ e,
603
+ handled: true,
604
+ context: { StandardAudit.config.audit_error_context_key => event_type }
605
+ )
606
+ end
543
607
  nil
544
608
  end
545
609
  end
@@ -0,0 +1,20 @@
1
+ module StandardAudit
2
+ module Generators
3
+ # Adds `audit_logs.anonymized_at` (0.12.0). With it, rows erased by
4
+ # `AuditLog.anonymize_actor!` are reported by `verify_chain` as `redacted`
5
+ # rather than as `digest_mismatch` failures.
6
+ class AddAnonymizedAtGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ def self.next_migration_number(dirname)
11
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
12
+ end
13
+
14
+ def copy_migration
15
+ migration_template "add_anonymized_at_to_audit_logs.rb.erb",
16
+ "db/migrate/add_anonymized_at_to_audit_logs.rb"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,25 @@
1
+ class AddAnonymizedAtToAuditLogs < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
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
21
+
22
+ def down
23
+ remove_column :audit_logs, :anonymized_at, if_exists: true
24
+ end
25
+ end
@@ -1,13 +1,27 @@
1
1
  module StandardAudit
2
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.
3
8
  class AddChecksumsGenerator < Rails::Generators::Base
4
9
  include Rails::Generators::Migration
5
10
  source_root File.expand_path("templates", __dir__)
6
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
+
7
16
  def self.next_migration_number(dirname)
8
17
  Time.now.utc.strftime("%Y%m%d%H%M%S")
9
18
  end
10
19
 
20
+ def warn_deprecated
21
+ say_status :deprecated, DEPRECATION_MESSAGE, :yellow
22
+ warn "[StandardAudit] DEPRECATION: #{DEPRECATION_MESSAGE}"
23
+ end
24
+
11
25
  def copy_migration
12
26
  migration_template "add_checksum_to_audit_logs.rb.erb", "db/migrate/add_checksum_to_audit_logs.rb"
13
27
  end