standard_audit 0.11.0 → 0.12.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: b89dc74bd119d2e0f1ff5080cef706439e68b61c79d2a6b653fcd5a74e265ae1
4
- data.tar.gz: 86686ea3bb611a13e8c8947ef3e0b63aad63eb47bfa1e48b16abba377a426f7f
3
+ metadata.gz: 80719c54fd8a1d3859f52741cfc4a478ec670114ad5ab025b1b9b14a10a80625
4
+ data.tar.gz: d5baa91d4e29844e0e0c375e0ef6c9b3cd4f2d6eba0284696911891270f3befa
5
5
  SHA512:
6
- metadata.gz: b06e7ee9a3b5d79191cb47a532cf81efb95e3bb722015540be79ce4bb9412ab13b8e69985a66d368c8dcdfb099f34f1ca1d3f0d894bfc90cc409615782dde921
7
- data.tar.gz: bb1e9316eba648a8d238becb75d33f705b82f509310e8c3e0d562656accac2b44339e49551e026ae0357bf57dda8fe8251f6f1d6c7df655777d5b157c3d17b0a
6
+ metadata.gz: 3fc6d5267408dd0858b0cecac9e449c00b6ef6e75b12cb9cb74167634769cd09f95fa51805a948f9a20e100b7bbd43237f473f4508d211a8c66293c0af2bbe3f
7
+ data.tar.gz: cdb5c7130f54d124da4e2b02586e510b5a75693c729d30d495403d4e948b7d6fcc81b640cb241a138292d452ebb5a52f0d84a6a0f178e8ecb982d7a209c179ea
data/CHANGELOG.md CHANGED
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.0] - 2026-09-24
11
+
12
+ ### Upgrade steps
13
+
14
+ 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`.
15
+ 2. StandardId apps: delete `config.current_actor_resolver = -> { Current.account }` and `config.current_session_id_resolver = -> { Current.session&.id }` — they are now the defaults.
16
+ 3. If your `metadata_builder` must NOT run on direct `StandardAudit.record` / `audit!` writes, make it idempotent or move that logic (see Changed).
17
+ 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.
18
+
19
+ ### Added
20
+
21
+ - **`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.
22
+ - **`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.
23
+ - **`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.
24
+ - **`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?`.
25
+ - **`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.
26
+ - **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`.
27
+
28
+ ### Changed
29
+
30
+ - **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:
31
+ - **`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.
32
+ - **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).
33
+ - Subscriber failures are logged as `[StandardAudit] Error creating audit log for <event>: …` and reported with the same context as 0.11.1.
34
+ - **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.
35
+ - `Rails.event` reserved metadata (`_tags`, `_source`) is still merged after `metadata_builder`, so builders never see it.
36
+
37
+ ### Fixed
38
+
39
+ - **`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.
40
+
41
+ ### Deprecated
42
+
43
+ - **`standard_audit:add_checksums` generator** — the 0.2 → 0.3 upgrade path. It still works but warns; removal is planned.
44
+
45
+ ## [0.11.1] - 2026-09-24
46
+
47
+ ### Fixed
48
+
49
+ - **`rake standard_audit:anonymize_actor[gid]` and `standard_audit:export_actor[gid]` no longer raise `NoMethodError`.** The tasks passed the GlobalID string straight into `AuditLog.anonymize_actor!` / `export_for_actor`, which called `to_global_id` on it. Both methods now accept a record, a `GlobalID`, or a GlobalID string. A string is parsed, not located, so erasure works after the subject's row has been deleted; an invalid string raises `ArgumentError`. What gets anonymized is unchanged.
50
+ - **`rake standard_audit:cleanup` no longer deletes logs older than 90 days when `retention_days` is nil.** nil means keep forever, but the task fell back to a hard-coded 90. `cleanup` and `archive` (which always defaulted to 90) now take the days argument, else `config.retention_days`, else abort with a message saying how to set one.
51
+ - **`cleanup`/`archive` reject a days value that is not a positive integer.** `cleanup[abc]` used to become `0` days, i.e. delete every row. `0`, negatives and non-numeric values now abort. `StandardAudit::CleanupJob` is unchanged.
52
+ - **`StandardAudit::Subscriber` and `StandardAudit::EventSubscriber` report swallowed errors to `Rails.error`.** A failed audit write was only logged, so it never reached error tracking. It is now also reported with `handled: true` and context `{ <config.audit_error_context_key> => event_name, subscriber: <class name> }`. The log line is kept.
53
+
10
54
  ## [0.11.0] - 2026-07-31
11
55
 
12
56
  ### Security
data/README.md CHANGED
@@ -96,7 +96,32 @@ 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
+ **Replace your host code with** `raise: false`. It supersedes the
120
+ `AuditAuthFailure#record_auth_failure` rescue-and-report wrapper
121
+ (sidekick-web, luminality-web, nutripod-web
122
+ `app/controllers/concerns/audit_auth_failure.rb`); set
123
+ `config.audit_error_context_key = :audit_event` to keep those apps' Sentry
124
+ grouping key.
100
125
 
101
126
  ### ActiveSupport::Notifications
102
127
 
@@ -125,6 +150,46 @@ end
125
150
 
126
151
  This uses `ActiveSupport::Notifications.instrument` under the hood.
127
152
 
153
+ ### One write path, and `before_write`
154
+
155
+ Every entry point — `StandardAudit.record` (plain and block form),
156
+ `Auditable#record_audit`, `Operation#audit!`, the ActiveSupport::Notifications
157
+ subscriber and the `Rails.event` subscriber — ends in the same write path.
158
+ Actor/session/scope resolution, `metadata_builder`, `before_write`, record
159
+ dereferencing, redaction, `StandardAudit.batch` and `async` therefore behave
160
+ identically whichever way a row is written. (Before 0.12.0 the notifications
161
+ subscriber carried its own copy: it ignored `batch`, and `metadata_builder`
162
+ never ran on direct `record` calls.)
163
+
164
+ `config.before_write` is the seam for host policy that must apply to every row:
165
+
166
+ ```ruby
167
+ config.before_write = ->(entry) {
168
+ # 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
174
+ }
175
+ ```
176
+
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.
181
+
182
+ **Replace your host code with** a `before_write`. It supersedes:
183
+
184
+ - a hand-built job-side wrapper that runs a guard before `StandardAudit.record`
185
+ (fundbright-web `AuditWriting#record_audit!`);
186
+ - an `audit!` override that runs a PII guard and injects metadata
187
+ (fundbright-web `ApplicationOperation#audit!`);
188
+ - a `metadata_builder` whose injection (`engine_scope`) is documented as
189
+ missing direct writes (fundbright-web / luminality-web initializers) —
190
+ `metadata_builder` now applies to direct writes too, so no change is needed
191
+ beyond deleting the caveat.
192
+
128
193
  ## Model Concerns
129
194
 
130
195
  ### Auditable
@@ -284,6 +349,69 @@ outside `app/operations/`, which would otherwise always look orphaned.
284
349
  The registry can only see loaded classes, so the shared example calls
285
350
  `Rails.application.eager_load!` by default (`eager_load: false` to opt out).
286
351
 
352
+ ## Testing
353
+
354
+ ```ruby
355
+ # spec/rails_helper.rb
356
+ require "standard_audit/rspec"
357
+ ```
358
+
359
+ This resets StandardAudit state before every example (so declare your
360
+ configuration with `configure(baseline: true)`), and loads two helpers. Each is
361
+ also loadable on its own: `standard_audit/rspec/matchers`,
362
+ `standard_audit/rspec/baseline`.
363
+
364
+ ### `have_audited`
365
+
366
+ ```ruby
367
+ expect { Orders::Create.call(order) }
368
+ .to have_audited("order.created")
369
+ .by(account) # a record, an RSpec matcher, or no arg = "any resolvable actor"
370
+ .on(order) # target, same forms
371
+ .within(organisation) # scope, same forms
372
+ .with_metadata(total: 100, tags: include("vip")) # subset; values may be matchers
373
+ .once # or .times(n); default "at least one"
374
+
375
+ expect { noop }.not_to have_audited("order.created")
376
+ ```
377
+
378
+ Only rows persisted during the block count. On failure it lists the rows that
379
+ were written, so a wrong actor or metadata shows up directly.
380
+
381
+ **Replace your host code with** `have_audited`. It supersedes hand-rolled
382
+ helpers such as `expect_well_formed_audit(action)` (sidekick-web
383
+ `spec/support/audit_log_coverage.rb`) — the equivalent is
384
+ `have_audited(action).by.on` — and the
385
+ `change(StandardAudit::AuditLog, :count)` + `AuditLog.last` pairs across the
386
+ five apps' coverage specs.
387
+
388
+ ### `"a standard_audit baseline"`
389
+
390
+ Guards that your configuration survives the per-example reset:
391
+
392
+ ```ruby
393
+ RSpec.describe "StandardAudit configuration baseline" do
394
+ it_behaves_like "a standard_audit baseline",
395
+ subscriptions: [/\Astandard_id\./, /\Aauthorization\./],
396
+ settings: { retention_days: 1826, raise_on_audit_write_error: true, filter_nested_metadata: true },
397
+ catalogue: -> { AuditCatalogue::ACTIONS },
398
+ sensitive_keys: %i[source_payload],
399
+ sensitive_key_patterns: [/secret/i],
400
+ present: %i[metadata_builder before_write current_scope_resolver]
401
+ end
402
+ ```
403
+
404
+ It checks the baseline is registered, that each value holds, and that it is
405
+ restored after a mutation plus `reset_configuration!`. Behaviour held in
406
+ lambdas can only be checked for presence; keep an app-specific example for
407
+ anything whose *result* matters.
408
+
409
+ **Replace your host code with** the shared example. It supersedes the bulk of
410
+ each app's `spec/initializers/standard_audit_baseline_spec.rb` (or
411
+ `spec/config/…`). The `Current.account` / `Current.session&.id` resolver
412
+ examples can go too once those overrides are deleted (they are the 0.12.0
413
+ defaults).
414
+
287
415
  ## Configuration Reference
288
416
 
289
417
  Use `configure(baseline: true)` in your initializer. It remembers the block so
@@ -309,11 +437,12 @@ StandardAudit.configure(baseline: true) do |config|
309
437
  # -- Current Attribute Resolvers --
310
438
  # Fallbacks used when payload values are nil.
311
439
  # Designed to work with Rails Current attributes.
312
- config.current_actor_resolver = -> { Current.user }
440
+ # (Defaults shown in simplified form — each is respond_to?-guarded.)
441
+ config.current_actor_resolver = -> { Current.account || Current.user }
313
442
  config.current_request_id_resolver = -> { Current.request_id }
314
443
  config.current_ip_address_resolver = -> { Current.ip_address }
315
444
  config.current_user_agent_resolver = -> { Current.user_agent }
316
- config.current_session_id_resolver = -> { Current.session_id }
445
+ config.current_session_id_resolver = -> { Current.session&.id || Current.session_id }
317
446
 
318
447
  # -- Sensitive Data --
319
448
  # Keys automatically stripped from metadata. Matching is EXACT on the key
@@ -340,7 +469,7 @@ StandardAudit.configure(baseline: true) do |config|
340
469
  # Run between the UUID assignment and the checksum computation, so a hook MAY
341
470
  # set a checksummed column and the row still passes `verify_chain`. No
342
471
  # `prepend: true` needed. Each hook is rescued individually and can never fail
343
- # the audit write. Not run on the batched `insert_all!` path.
472
+ # the audit write. Also run on batched writes, at flush time (since 0.12.0).
344
473
  config.before_checksum { |log| log.scope = MyApp.derive_scope(log) }
345
474
  config.before_checksum :backfill_scope # an AuditLog instance method
346
475
 
@@ -362,9 +491,18 @@ StandardAudit.configure(baseline: true) do |config|
362
491
  }
363
492
 
364
493
  # -- Metadata Builder --
365
- # Optional proc to transform metadata before storage.
494
+ # Optional proc to transform metadata before storage. Runs on EVERY write
495
+ # path (since 0.12.0 — previously only the two subscribers).
366
496
  config.metadata_builder = ->(metadata) { metadata.slice(:relevant_key) }
367
497
 
498
+ # -- Ambient scope --
499
+ # Fallback tenant when a write names none. See "Multi-Tenancy".
500
+ config.current_scope_resolver = -> { Current.organisation }
501
+
502
+ # -- before_write --
503
+ # Runs on every write path, before redaction. See "One write path".
504
+ config.before_write = ->(entry) { entry[:metadata] = entry[:metadata].merge("surface" => Current.surface) }
505
+
368
506
  # -- Async Processing --
369
507
  # Offload audit log creation to ActiveJob.
370
508
  config.async = false
@@ -386,7 +524,26 @@ end
386
524
 
387
525
  ### Default Current Attribute Resolvers
388
526
 
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.
527
+ Out of the box, StandardAudit reads from a top-level `Current` if it responds to the relevant method:
528
+
529
+ | Column | Default resolution (first non-nil wins) |
530
+ |--------------|--------------------------------------------------|
531
+ | actor | `Current.account`, then `Current.user` |
532
+ | session_id | `Current.session&.id`, then `Current.session_id` |
533
+ | request_id | `Current.request_id` |
534
+ | ip_address | `Current.ip_address` |
535
+ | user_agent | `Current.user_agent` |
536
+
537
+ 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.
538
+
539
+ **Replace your host code with:** nothing. If your initializer carries
540
+
541
+ ```ruby
542
+ config.current_actor_resolver = -> { Current.account }
543
+ config.current_session_id_resolver = -> { Current.session&.id }
544
+ ```
545
+
546
+ both lines are now the defaults and can be deleted.
390
547
 
391
548
  ## Query Interface
392
549
 
@@ -471,6 +628,28 @@ StandardAudit::AuditLog.for_scope(current_organisation)
471
628
 
472
629
  The scope is stored as a GlobalID string, so it works with any model class.
473
630
 
631
+ ### Ambient scope: `current_scope_resolver`
632
+
633
+ When most writes happen inside a tenant-scoped request, resolve the scope from
634
+ `Current` instead of threading it through every call:
635
+
636
+ ```ruby
637
+ config.current_scope_resolver = -> { Current.channel || Current.organisation }
638
+ ```
639
+
640
+ It is a *fallback*: an explicit `scope:` and a scope found by `scope_extractor`
641
+ always win. It applies on every write path (direct `record`, `audit!`,
642
+ `record_audit`, both subscribers; sync, async and batched). Default `nil`.
643
+
644
+ **Replace your host code with** the one line above. It supersedes:
645
+
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.
652
+
474
653
  ## Async Processing
475
654
 
476
655
  For high-throughput applications, offload audit log creation to a background job:
@@ -499,6 +678,47 @@ This:
499
678
  - Clears `ip_address`, `user_agent`, and `session_id`
500
679
  - Removes metadata keys listed in `anonymizable_metadata_keys`
501
680
 
681
+ Both `anonymize_actor!` and `export_for_actor` accept the subject as a record,
682
+ a `GlobalID`, or a GlobalID string (`"gid://myapp/User/123"`). A string is
683
+ parsed, never located, so erasure still works after the user's own row has been
684
+ deleted — the usual order for an erasure request. Anything that is not a valid
685
+ GlobalID raises `ArgumentError`.
686
+
687
+ ```ruby
688
+ StandardAudit::AuditLog.anonymize_actor!("gid://myapp/User/123")
689
+ ```
690
+
691
+ #### Anonymization and the checksum chain
692
+
693
+ Anonymizing rewrites checksummed columns, so an anonymized row can no longer
694
+ reproduce its own digest. Since 0.12.0 its stored `checksum` is left untouched
695
+ (the rows after it still link to it) and, when the table has an
696
+ `anonymized_at` column, the row is stamped. `verify_chain` then counts it under
697
+ `redacted` instead of reporting a `digest_mismatch`:
698
+
699
+ ```ruby
700
+ StandardAudit::AuditLog.verify_chain
701
+ # => { valid: true, verified: 5576, recovered: 0, redacted: 1, failures: [] }
702
+ ```
703
+
704
+ A redacted row's declared parent is still checked, so deleting the row before
705
+ it is still reported as `missing_parent`. Reconcile a nonzero `redacted`
706
+ against your erasure records: anyone who can write `anonymized_at` can hide an
707
+ edit behind it.
708
+
709
+ Existing installs add the column with:
710
+
711
+ ```bash
712
+ rails generate standard_audit:add_anonymized_at
713
+ rails db:migrate
714
+ ```
715
+
716
+ (nullable, no default, idempotent; safe under strong_migrations). Without it,
717
+ `anonymize_actor!` works exactly as before and anonymized rows keep failing
718
+ `verify_chain` as `digest_mismatch`. Rows anonymized before the migration are
719
+ not stamped retroactively — they are indistinguishable from tampered rows after
720
+ the fact; stamp them by hand if your erasure log identifies them.
721
+
502
722
  ### Right to Access (Export)
503
723
 
504
724
  Export all audit data for a specific user:
@@ -525,7 +745,9 @@ STANDARD_AUDIT_RETENTION_DAYS=365 # keep 365 days
525
745
  ```
526
746
 
527
747
  Infinite retention (the default) is the compliance-safe behavior: nothing is
528
- ever auto-deleted. For financial/legal domains that is usually what you want;
748
+ ever auto-deleted. The `standard_audit:cleanup` and `standard_audit:archive`
749
+ rake tasks respect this: with no days argument and a nil `retention_days` they
750
+ abort rather than fall back to a default window. For financial/legal domains that is usually what you want;
529
751
  enabling a finite window is a deliberate decision.
530
752
 
531
753
  ### Production retention warning (StandardHealth)
@@ -572,9 +794,12 @@ own digest, so editing it invalidates the row.
572
794
 
573
795
  ```ruby
574
796
  result = StandardAudit::AuditLog.verify_chain
575
- # => { valid: true, verified: 5577, recovered: 0, failures: [] }
797
+ # => { valid: true, verified: 5577, recovered: 0, redacted: 0, failures: [] }
576
798
  ```
577
799
 
800
+ `redacted` counts rows anonymized by `anonymize_actor!` — see "Anonymization
801
+ and the checksum chain".
802
+
578
803
  - `failures` carries `reason: :digest_mismatch` (the row's fields no longer
579
804
  produce its digest) or `reason: :missing_parent` (the row it was appended to
580
805
  is no longer in the log). Retention pruning does not trip `:missing_parent`:
@@ -626,6 +851,15 @@ verification — which is the point.
626
851
  re-signs rows from their current contents, so it attests only that a script ran.
627
852
  It is for rows that never had a checksum at all (pre-feature data).
628
853
 
854
+ ## Generators
855
+
856
+ | Generator | Purpose |
857
+ |-----------|---------|
858
+ | `standard_audit:install` | `audit_logs` migration + initializer (new installs) |
859
+ | `standard_audit:add_previous_checksum` | Adds `previous_checksum` (upgrading from < 0.8) |
860
+ | `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 |
862
+
629
863
  ## Rake Tasks
630
864
 
631
865
  ```bash
@@ -635,10 +869,12 @@ rake standard_audit:verify
635
869
  # Record the parent digest each existing row was signed against
636
870
  rake standard_audit:relink_checksums
637
871
 
638
- # Delete logs older than N days (default: retention_days config or 90)
872
+ # Delete logs older than N days
639
873
  rake standard_audit:cleanup[180]
874
+ # ...or older than config.retention_days
875
+ rake standard_audit:cleanup
640
876
 
641
- # Archive old logs to a JSON file before deleting
877
+ # Archive old logs to a JSON file before deleting (same days rules)
642
878
  rake standard_audit:archive[90,audit_backup.json]
643
879
 
644
880
  # Show statistics
@@ -651,6 +887,15 @@ rake "standard_audit:anonymize_actor[gid://myapp/User/123]"
651
887
  rake "standard_audit:export_actor[gid://myapp/User/123,export.json]"
652
888
  ```
653
889
 
890
+ `cleanup` and `archive` take the window from the days argument, else
891
+ `config.retention_days`; if neither is set they abort (a nil `retention_days`
892
+ means keep forever, so there is no implicit 90-day default). Days must be a
893
+ positive integer — `0`, negatives and non-numeric values abort instead of
894
+ deleting everything.
895
+
896
+ `anonymize_actor` and `export_actor` take a GlobalID string and work even after
897
+ the user record has been deleted.
898
+
654
899
  ## Database Support
655
900
 
656
901
  The migration uses `json` column type by default, which works across:
@@ -717,10 +962,10 @@ key you want on an audit row, while the value serialises with
717
962
  happens to hold. Audit rows are append-only, so an unsafe default cannot be
718
963
  walked back.
719
964
 
720
- On the notifications path, records are dereferenced **after**
721
- `metadata_builder` runs, so a builder that needs real attributes still gets the
722
- record (`metadata_builder` has never applied to a direct `StandardAudit.record`
723
- call — pass the attributes you want in `metadata` there):
965
+ Records are dereferenced **after** `metadata_builder` (and `before_write`)
966
+ run, so a builder that needs real attributes still gets the record. Since
967
+ 0.12.0 this holds on every write path, including direct `StandardAudit.record`
968
+ calls:
724
969
 
725
970
  ```ruby
726
971
  config.metadata_builder = ->(metadata) {
@@ -92,12 +92,24 @@ module StandardAudit
92
92
 
93
93
  # -- GDPR methods --
94
94
 
95
- def self.anonymize_actor!(record)
96
- gid = record.to_global_id.to_s
95
+ # `subject` may be a record, a GlobalID, or a GlobalID string
96
+ # ("gid://app/User/1"). A string is parsed, never located, so erasure and
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.
106
+ def self.anonymize_actor!(subject)
107
+ gid = subject_gid_for(subject)
97
108
  logs = where("actor_gid = ? OR target_gid = ?", gid, gid)
98
109
  count = logs.count
99
110
 
100
111
  anonymizable_keys = StandardAudit.config.anonymizable_metadata_keys.map(&:to_s)
112
+ stamp = anonymization_column? ? Time.current : nil
101
113
 
102
114
  logs.find_each do |log|
103
115
  attrs = {
@@ -116,14 +128,28 @@ module StandardAudit
116
128
  attrs[:metadata] = cleaned_metadata
117
129
  end
118
130
 
131
+ attrs[:anonymized_at] = log.anonymized_at || stamp if stamp
132
+
119
133
  log.update_columns(attrs)
120
134
  end
121
135
 
122
136
  count
123
137
  end
124
138
 
125
- def self.export_for_actor(record)
126
- gid = record.to_global_id.to_s
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
+
151
+ def self.export_for_actor(subject)
152
+ gid = subject_gid_for(subject)
127
153
  logs = where("actor_gid = ? OR target_gid = ?", gid, gid).chronological
128
154
 
129
155
  records = logs.map do |log|
@@ -149,6 +175,22 @@ module StandardAudit
149
175
  }
150
176
  end
151
177
 
178
+ # Normalises a GDPR subject to its GlobalID string. Parses rather than
179
+ # locates, so it works when the subject's row no longer exists.
180
+ def self.subject_gid_for(subject)
181
+ gid =
182
+ case subject
183
+ when GlobalID then subject
184
+ when String then GlobalID.parse(subject)
185
+ else subject.respond_to?(:to_global_id) ? subject.to_global_id : nil
186
+ end
187
+
188
+ raise ArgumentError, "expected a record, a GlobalID, or a GlobalID string (gid://app/Model/id), got #{subject.inspect}" unless gid
189
+
190
+ gid.to_s
191
+ end
192
+ private_class_method :subject_gid_for
193
+
152
194
  # Recomputes the checksum from the record's current field values and the
153
195
  # given previous checksum. Useful for verification without saving.
154
196
  def compute_checksum_value(previous_checksum: nil)
@@ -171,6 +213,20 @@ module StandardAudit
171
213
  OpenSSL::Digest::SHA256.hexdigest(canonical)
172
214
  end
173
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
+
174
230
  # The checksum of the most recent row — the node a new row links to.
175
231
  def self.chain_tip_checksum
176
232
  order(created_at: :desc, id: :desc).limit(1).pick(:checksum)
@@ -185,9 +241,18 @@ module StandardAudit
185
241
  end
186
242
 
187
243
  # Verifies the integrity of the audit log. Returns a result hash with
188
- # :valid (boolean), :verified (count), :recovered (count) and :failures
189
- # (array of hashes carrying :id, :event_type, :created_at, :expected,
190
- # :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.
191
256
  #
192
257
  # Records are processed in (created_at, id) order. Records without a
193
258
  # checksum (pre-feature data) reset the walk — the next checksummed record
@@ -234,6 +299,7 @@ module StandardAudit
234
299
  previous_checksum = nil
235
300
  verified = 0
236
301
  recovered = 0
302
+ redacted = 0
237
303
  failures = []
238
304
  window = []
239
305
  first_row = true
@@ -246,10 +312,20 @@ module StandardAudit
246
312
  next
247
313
  end
248
314
 
249
- verified += 1
250
315
  declared = record.previous_checksum if declared_parents
251
316
 
252
- 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
253
329
  expected = record.compute_checksum_value(previous_checksum: declared)
254
330
 
255
331
  if record.checksum != expected
@@ -267,6 +343,7 @@ module StandardAudit
267
343
  end
268
344
  end
269
345
  else
346
+ verified += 1
270
347
  expected = record.compute_checksum_value(previous_checksum: previous_checksum)
271
348
 
272
349
  if record.checksum == expected
@@ -284,7 +361,7 @@ module StandardAudit
284
361
  window.shift if window.size > recovery_window
285
362
  end
286
363
 
287
- { valid: failures.empty?, verified: verified, recovered: recovered, failures: failures }
364
+ { valid: failures.empty?, verified: verified, recovered: recovered, redacted: redacted, failures: failures }
288
365
  end
289
366
 
290
367
  # Records, for every row that does not already carry one, the parent digest
@@ -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,15 @@
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)
12
+
13
+ add_column :audit_logs, :anonymized_at, :datetime
14
+ end
15
+ end