standard_audit 0.6.0 → 0.8.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 +70 -0
- data/README.md +133 -2
- data/app/models/standard_audit/audit_log.rb +336 -79
- data/lib/generators/standard_audit/add_previous_checksum/add_previous_checksum_generator.rb +17 -0
- data/lib/generators/standard_audit/add_previous_checksum/templates/add_previous_checksum_to_audit_logs.rb.erb +23 -0
- data/lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb +15 -0
- data/lib/generators/standard_audit/install/templates/initializer.rb.erb +46 -4
- data/lib/standard_audit/configuration.rb +64 -1
- data/lib/standard_audit/metadata_filter.rb +155 -0
- data/lib/standard_audit/reference_preloading.rb +298 -0
- data/lib/standard_audit/rspec.rb +17 -3
- data/lib/standard_audit/sensitive_keys_dry_run.rb +177 -0
- data/lib/standard_audit/subscriber.rb +5 -3
- data/lib/standard_audit/version.rb +1 -1
- data/lib/standard_audit.rb +50 -11
- data/lib/tasks/standard_audit_tasks.rake +61 -2
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 55028cd60be7b29a81c8bd0eccb5cbccadfba4f7691f0d6576214de4367e53c9
|
|
4
|
+
data.tar.gz: efcf7f59f891570b8076e870f64d632de6d707ed9a4aa1f4b6e35aa7116199d1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d1d0c3c3807faf64dc9e21c897da2e3317a6fee1ea202aae6b07a948f416bbb8d35c03f8977a3def8bdf5a2bca9944941e3fe1b3c860d7b2b3aa942a8f048e16
|
|
7
|
+
data.tar.gz: 16278c66d30fe401947d8748e6b675a9dd5bc49dac1d0183635faf27df8e1b9e5a5f2d933aa8a606c1b66a01894dc030088a3813b7ca8eb2aee68ed7bd811e0d
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.8.0] - 2026-07-30
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`previous_checksum` — every row now records the row it was appended to.** New nullable column; run `rails generate standard_audit:add_previous_checksum && rails db:migrate` to add it. The install migration includes it for new hosts, along with a composite `(created_at, id)` index for the verification walk.
|
|
15
|
+
- **`rake standard_audit:relink_checksums` / `AuditLog.relink_checksums!`** — records, for every row that has no `previous_checksum`, the parent digest it was *actually* signed against, recovering it by search where a concurrent append forked the log. It **never rewrites a `checksum`**: a parent is written only when it reproduces the digest the row has held since it was written, so it adds no attestation the rows did not already carry. Rows it cannot resolve are counted as `unresolved`, left untouched, and keep failing verification — which is the point. This is the repair path for a log broken by the defect below; `backfill_checksums!` is **not** (it re-signs rows from their current contents, so a green result attests only that a script ran).
|
|
16
|
+
- `AuditLog.chain_tip_checksum` and `AuditLog.chain_parent_column?`.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **The chained checksum was not concurrency-safe: `verify_chain` failed for 3,762 of 5,577 rows (67%) on one production log, continuously, while every row was untampered.** `compute_checksum` reads the chain tip with an unsynchronised query from `before_create`. Two transactions cannot see each other's uncommitted rows, so both sign against the same predecessor and the sequence forks; verification, which assumed a strict line, reported all but one of them as tampering. Any host with concurrent audit writes — two Puma threads, a web request and a job — has the same broken log. (`fundbright/delivery-ops#433`)
|
|
21
|
+
|
|
22
|
+
**The log is now treated as a DAG rather than a strict line, and each row records its parent.** A row is verified against the exact digest it signed, whether or not that is its predecessor in the walk, so a fork is verifiable instead of fatal. The tip read stays unlocked deliberately: forcing a strict line means holding a lock until the *enclosing business transaction* commits — a row is invisible to other writers until then, so releasing earlier reopens the race — which would serialise every audited request across the estate behind one mutex, on the hot path of every request. `previous_checksum` needs no protection of its own; it is an input to the row's own digest, so editing it invalidates the row.
|
|
23
|
+
|
|
24
|
+
What this deliberately stops claiming: a single total order, and detection of an *inserted* row. A concurrent append and an inserted row are indistinguishable, so a design that tolerates the first cannot detect the second. The strict-line reading did not detect insertions either — it reported every concurrent append as tampering, which on the measured log meant 67% red and any real signal lost in it.
|
|
25
|
+
|
|
26
|
+
**Rows written before this version verify unchanged, with no migration.** With no `previous_checksum`, verification falls back to the preceding row in the walk and then searches back through the last `recovery_window` (default 256) digests for the one that reproduces the row's checksum — recovering the true parent of a forked row without re-signing anything, and reporting how many needed it in the new `recovered:` count. Tamper detection is not weakened: a row whose fields were altered reproduces no candidate's digest. `verify_chain(strict: true)` skips the search and reports every fork as a failure. A host that never runs the migration keeps working on this path indefinitely.
|
|
27
|
+
|
|
28
|
+
- **`verify_chain` never noticed a row being removed from the middle of the log.** Failures now carry `reason:` — `:digest_mismatch`, or `:missing_parent` when the row a record was appended to is no longer present. Retention pruning does not trip it: if the walk opens on a row whose parent is already gone, that parent digest is exempt wherever else it appears — a pruned row can have several children, which is what a concurrent append leaves behind. A removal from the *middle* is still reported. Every row is exempt under `scope:`, because the log is global and a scoped row's parent usually belongs to another scope.
|
|
29
|
+
|
|
30
|
+
- **`verify_chain` did not walk the order it documents, and neither did `backfill_checksums!`.** Both claimed a `(created_at, id)` walk and both used `in_batches`, which paginates by **primary key** range and applies the sort only *within* each batch — so the global sequence was a concatenation of id-ranges, each internally time-sorted. Both now walk with a keyset cursor ordered by `(created_at, id)`, which is the documented order and still loads only `batch_size` rows at a time.
|
|
31
|
+
|
|
32
|
+
With the UUIDv7 ids `assign_uuid` generates, id order usually coincides with insertion order, which is why this stayed latent — but it is wrong for any host that assigns ids differently, backfills rows with an explicit `created_at`, or has clock skew between writers. It matters more than a latent ordering bug normally would: the chain is an *ordering* claim, so a verifier that walks a different order than the one it documents cannot be trusted to prove or disprove anything about chain integrity, including the concurrent-append defect above. `backfill_checksums!` had the same defect, and there it silently *writes* the wrong chain rather than misreading one.
|
|
33
|
+
|
|
34
|
+
## [0.7.0] - 2026-07-30
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **Batch actor/target/scope preloading.** `AuditLog#actor` / `#target` / `#scope` resolved their GlobalID one row at a time, so every audit list N+1'd. Two consuming apps had already fixed this locally, both by reaching into private ivars (`instance_variable_set(:@preloaded_actor, …)`) because the gem exposed no setter. The gem now owns it:
|
|
39
|
+
- `AuditLog.preload_references(logs, refs: %i[actor target], only: [...], includes: {...})` resolves a page in one query per distinct stored `*_type`, built on `GlobalID::Locator.locate_many` with `ignore_missing: true`.
|
|
40
|
+
- `preloaded_actor=` / `preloaded_target=` / `preloaded_scope=` public writers, plus `actor_preloaded?` predicates.
|
|
41
|
+
- The memo is a Hash consulted with `key?`, so *preloaded-but-deleted* memoizes `nil` and reads back without a query — distinct from *not preloaded*. `actor=` / `target=` / `scope=` populate the memo (they already hold the record); `reload` clears it.
|
|
42
|
+
- `only:` is matched against the **stored type string by class name**. `GlobalID::Locator`'s own `:only` is evaluated as `gid.model_class <= klass`, which constantizes the historical type string *before* deciding whether it was permitted — so a renamed or deleted class raises `NameError` rather than being denied. Consequence: `only:` does not expand to subclasses or modules; list every concrete class. A non-whitelisted reference memoizes `nil`.
|
|
43
|
+
- `includes:` is treated as a per-type map when every key is a String or Class (`{ "Order" => [:user] }`), and as a uniform Active Record includes spec otherwise (so `{ account: :identifiers }` still works as you'd expect).
|
|
44
|
+
- A type string that no longer constantizes — or a blank `*_type` on a row that still carries a `*_gid` — is left *unmemoized*, so the per-row reader behaves exactly as it did before preloading was attempted. Preloading can never turn a resolvable reference into a permanent `nil`.
|
|
45
|
+
- `AuditLog#actor_model_id` / `#target_model_id` / `#scope_model_id` (and `AuditLog.reference_model_id(gid)`) — the model id extracted straight from the gid string, a helper three separate consumer call sites had hand-rolled. Deliberately **not** `GlobalID.parse(gid)&.model_id`: audit rows are historical and append-only, so a gid whose app segment differs from the current `GlobalID.app` is a real row that must still yield its id. Mirrors `URI::GID`'s own decoding (drops `?params`, `CGI.unescape`s each segment, returns an Array for a composite primary key) while ignoring the app name, so it agrees with `GlobalID#model_id` wherever that works and keeps working where it doesn't.
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
- **`config.before_checksum`** — registers a hook that runs on `before_create` **between `assign_uuid` and `compute_checksum`**. Definition order is execution order, so a hook may set a `CHECKSUM_FIELDS` member (back-fill `scope`, derive a column, rewrite `metadata`) and the row still passes `AuditLog.verify_chain`. Hosts previously had to register their own `before_create ..., prepend: true` to beat the gem's checksum callback — fragile ordering knowledge no host should need.
|
|
49
|
+
```ruby
|
|
50
|
+
config.before_checksum { |log| log.scope = MyApp.derive_scope(log) }
|
|
51
|
+
config.before_checksum :backfill_scope # an AuditLog instance method
|
|
52
|
+
```
|
|
53
|
+
Hooks accumulate and run in registration order. **Each is rescued individually** — a failing hook logs (and reports to `Rails.error`), is **rolled back to the attributes it started from**, and is skipped; the remaining hooks still run and the audit write never fails. The rollback is what makes "skipped" true: without it, a hook that assigns `scope_gid` and then fails a later lookup would leave a half-applied row for the following callbacks to checksum and persist. If a row is created with an explicit `checksum`, hooks still run (most derived columns are not checksummed) but the supplied digest is dropped and re-derived when a hook changed a `CHECKSUM_FIELDS` member. Hooks do not run on the batched `insert_all!` path, which never instantiates a model.
|
|
54
|
+
- **`StandardAudit.configure(baseline: true)`** — remembers the block so `reset_configuration!` replays it onto the fresh Configuration. **Required companion to the `before_checksum` hooks, not optional.** The config object holds *behaviour*, not just data, so a suite that installs `standard_audit/rspec` (whose per-example `reset_configuration!` restores gem defaults) would silently lose its write-time hooks after the first example — and the specs that would have caught it pass vacuously. The install template now uses `configure(baseline: true)`, and the `lib/standard_audit/rspec.rb` docstring no longer instructs hosts to re-apply configuration by hand. `reset_configuration!(replay_baseline: false)`, `clear_baseline_configuration!` and `baseline_configured?` round it out. A plain `configure` is unchanged and registers nothing.
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
- **`config.sensitive_key_patterns`** (Array of Regexp, always applied, default `[]`) — the supported way to catch a family of keys. Stripe's `client_secret` slipping past the exact-match `:secret` is solved by `/secret/i`.
|
|
58
|
+
|
|
59
|
+
**There is deliberately no substring matching mode**, and one should not be added. Against the current default key list, substring matching strips real audit content across the estate: `input_tokens` / `output_tokens` (live LLM cost accounting in luminality and sidekick), `token_digest` (rendered in luminality's staff audit UI), `password_reset_sent_at`, `authorization_endpoint`, even `onepassword`. Audit rows are append-only, so it cannot be undone — the content is simply never written from then on. Patterns are opt-in, per-app, and checkable in advance.
|
|
60
|
+
- **`config.sensitive_key_exceptions`** (Array of exact names or Regexps, default `[]`) — never redacted, even when `sensitive_keys` or a pattern matches. Lets an app adopt `[/token/i]` while keeping `input_tokens` / `output_tokens`.
|
|
61
|
+
- **`rake standard_audit:sensitive_keys:dry_run`** — read-only. Reports, per metadata key, what a candidate rule *would* have stripped from the rows you already have, what it would keep, and any nested matches that survive because `filter_nested_metadata` is off. Turns "is this rule safe for my app?" into a command rather than a guess, which matters precisely because the rows are append-only.
|
|
62
|
+
```bash
|
|
63
|
+
bin/rails standard_audit:sensitive_keys:dry_run # current config
|
|
64
|
+
bin/rails "standard_audit:sensitive_keys:dry_run[secret]" # candidate pattern
|
|
65
|
+
NESTED=1 bin/rails "standard_audit:sensitive_keys:dry_run[secret|token]"
|
|
66
|
+
```
|
|
67
|
+
Backed by `StandardAudit::SensitiveKeysDryRun.call(...)`, which extracts keys **in Ruby** rather than with `jsonb_object_keys` so it stays backend-neutral.
|
|
68
|
+
- `StandardAudit::MetadataFilter` — `MetadataFilter.call(metadata, config:)` filters metadata; `MetadataFilter.new.filter?(key)` answers the same question for a single key without writing a row. Matching stays **exact on the key name** (string/symbol insensitive), unchanged from 0.6.0. Anything hash-like is filtered (so `ActionController::Parameters` is redacted, not waved through because it isn't a `Hash`); `nil` passes; anything else raises `MetadataFilter::UnfilterableMetadataError` rather than writing unfiltered content to an append-only row.
|
|
69
|
+
|
|
70
|
+
### Fixed
|
|
71
|
+
|
|
72
|
+
- **Nested metadata was never redacted on either write path.** `metadata: { stripe: { client_secret: … } }` was written intact even under exact matching — a larger real leak than the top-level case. Opt in with `config.filter_nested_metadata = true` (default `false`, so 0.6.0 behaviour is preserved); redaction then descends into nested Hashes and Hashes inside Arrays. `RESERVED_METADATA_KEYS` (`_tags`, `_source`) are preserved *and their subtree is never descended into, at every depth* — they are gem-owned, and `event_subscriber.rb` sets them after building metadata.
|
|
73
|
+
- **The two write paths applied different sensitive-key filters.** `StandardAudit.record` and `StandardAudit::Subscriber#extract_metadata` each carried an independent copy of the redaction logic, and they had already diverged: the subscriber copy did not subtract `RESERVED_METADATA_KEYS`, so an app that added `:_tags` or `:_source` to `sensitive_keys` had the reserved key preserved on the `record` path and stripped on the `ActiveSupport::Notifications` path. Both now call the single `StandardAudit::MetadataFilter`, and the divergence is resolved in favour of `record`'s behaviour — reserved keys can never be filtered on either path. Parity is driven from one shared example (`spec/support/shared_examples/metadata_filtering.rb`), so a future divergence fails the suite rather than shipping.
|
|
74
|
+
|
|
75
|
+
### Notes
|
|
76
|
+
|
|
77
|
+
- `StandardAudit.batch { ... }` flushes via `insert_all!` and never instantiates a model, so none of the above runs on the batched write path — the memo is a no-op there by construction.
|
|
78
|
+
- Minor behaviour change on a single instance: `log.actor = user; user.destroy!; log.actor` now returns the (destroyed) in-memory record instead of `nil`, because the writer memoizes. Reading the row fresh (or calling `reload`) is unchanged and still returns `nil`. This matches how Active Record association writers behave.
|
|
79
|
+
|
|
10
80
|
## [0.6.0] - 2026-06-24
|
|
11
81
|
|
|
12
82
|
### Added
|
data/README.md
CHANGED
|
@@ -164,8 +164,13 @@ organisation.scoped_audit_logs # all logs scoped to this organisation
|
|
|
164
164
|
|
|
165
165
|
## Configuration Reference
|
|
166
166
|
|
|
167
|
+
Use `configure(baseline: true)` in your initializer. It remembers the block so
|
|
168
|
+
`StandardAudit.reset_configuration!` replays it — required if your suite loads
|
|
169
|
+
`standard_audit/rspec`, because the config object holds behaviour (`before_checksum`
|
|
170
|
+
hooks) as well as data, and a per-example reset would otherwise drop it.
|
|
171
|
+
|
|
167
172
|
```ruby
|
|
168
|
-
StandardAudit.configure do |config|
|
|
173
|
+
StandardAudit.configure(baseline: true) do |config|
|
|
169
174
|
# -- Subscriptions --
|
|
170
175
|
# Subscribe to ActiveSupport::Notifications patterns.
|
|
171
176
|
# Supports wildcards.
|
|
@@ -189,9 +194,30 @@ StandardAudit.configure do |config|
|
|
|
189
194
|
config.current_session_id_resolver = -> { Current.session_id }
|
|
190
195
|
|
|
191
196
|
# -- Sensitive Data --
|
|
192
|
-
# Keys automatically stripped from metadata.
|
|
197
|
+
# Keys automatically stripped from metadata. Matching is EXACT on the key
|
|
198
|
+
# name; there is deliberately no substring mode (see below).
|
|
193
199
|
config.sensitive_keys += %i[my_custom_secret] # added to built-in defaults
|
|
194
200
|
|
|
201
|
+
# Regexps matched against every key name, in addition to the exact list.
|
|
202
|
+
# Solves e.g. Stripe's `client_secret`, which does not equal `:secret`.
|
|
203
|
+
config.sensitive_key_patterns = [/secret/i]
|
|
204
|
+
|
|
205
|
+
# Keys (exact names or Regexps) that are never redacted, so a broad pattern
|
|
206
|
+
# can keep the real audit keys it would otherwise swallow.
|
|
207
|
+
config.sensitive_key_exceptions = %i[input_tokens output_tokens]
|
|
208
|
+
|
|
209
|
+
# Descend into nested Hashes when redacting. OFF by default — without it,
|
|
210
|
+
# `metadata: { stripe: { client_secret: ... } }` is written intact.
|
|
211
|
+
config.filter_nested_metadata = true
|
|
212
|
+
|
|
213
|
+
# -- Write-time hooks --
|
|
214
|
+
# Run between the UUID assignment and the checksum computation, so a hook MAY
|
|
215
|
+
# set a checksummed column and the row still passes `verify_chain`. No
|
|
216
|
+
# `prepend: true` needed. Each hook is rescued individually and can never fail
|
|
217
|
+
# the audit write. Not run on the batched `insert_all!` path.
|
|
218
|
+
config.before_checksum { |log| log.scope = MyApp.derive_scope(log) }
|
|
219
|
+
config.before_checksum :backfill_scope # an AuditLog instance method
|
|
220
|
+
|
|
195
221
|
# -- Metadata Builder --
|
|
196
222
|
# Optional proc to transform metadata before storage.
|
|
197
223
|
config.metadata_builder = ->(metadata) { metadata.slice(:relevant_key) }
|
|
@@ -380,9 +406,92 @@ which is **still HTTP 200** — it surfaces the advisory in the readiness JSON
|
|
|
380
406
|
without failing the probe or blocking a deploy. The check is duck-typed and has
|
|
381
407
|
no hard dependency on `standard_health`.
|
|
382
408
|
|
|
409
|
+
## Chain Integrity
|
|
410
|
+
|
|
411
|
+
Every row carries a SHA-256 `checksum` over its own fields **and the digest of
|
|
412
|
+
the row it was appended to**, so editing a row in place invalidates it. Each row
|
|
413
|
+
also records that parent explicitly in `previous_checksum`.
|
|
414
|
+
|
|
415
|
+
### The log is a DAG, not a strict line
|
|
416
|
+
|
|
417
|
+
A writer reads the current tip and links to it. That read is deliberately
|
|
418
|
+
unlocked, so two concurrent transactions — two Puma threads, a web request and a
|
|
419
|
+
job — can read the same tip and the sequence forks. **That is normal for a
|
|
420
|
+
multi-process writer, and it is not an integrity failure.** Forcing a strict
|
|
421
|
+
line would mean holding a lock until the enclosing business transaction commits
|
|
422
|
+
(a row is invisible to other writers until then, so releasing earlier reopens
|
|
423
|
+
the race), which serialises every audited request behind one mutex.
|
|
424
|
+
|
|
425
|
+
Storing the parent is what keeps a forked log verifiable: every row is checked
|
|
426
|
+
against the exact digest it signed, whether or not it is the walk's predecessor.
|
|
427
|
+
`previous_checksum` needs no protection of its own — it is an input to the row's
|
|
428
|
+
own digest, so editing it invalidates the row.
|
|
429
|
+
|
|
430
|
+
```ruby
|
|
431
|
+
result = StandardAudit::AuditLog.verify_chain
|
|
432
|
+
# => { valid: true, verified: 5577, recovered: 0, failures: [] }
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
- `failures` carries `reason: :digest_mismatch` (the row's fields no longer
|
|
436
|
+
produce its digest) or `reason: :missing_parent` (the row it was appended to
|
|
437
|
+
is no longer in the log). Retention pruning does not trip `:missing_parent`:
|
|
438
|
+
if the walk opens on a row whose parent is already gone, that parent digest
|
|
439
|
+
is exempt wherever else it appears — a pruned row can have several children.
|
|
440
|
+
A removal from the *middle* is still reported. One caveat:
|
|
441
|
+
`standard_audit:cleanup` prunes by `occurred_at` while the walk orders by
|
|
442
|
+
`created_at`, so rows whose two timestamps disagree can leave a hole rather
|
|
443
|
+
than a pruned start, and a hole is reported — truthfully, since rows really
|
|
444
|
+
are missing.
|
|
445
|
+
- `recovered` counts rows with no `previous_checksum` whose parent had to be
|
|
446
|
+
found by searching back through recent digests — see below.
|
|
447
|
+
- `verify_chain(scope: org)` skips the missing-parent check, because the log is
|
|
448
|
+
global and a scoped row's parent usually belongs to another scope.
|
|
449
|
+
|
|
450
|
+
What this deliberately does **not** claim: that the log is in a single total
|
|
451
|
+
order, or that no row was inserted. A concurrent append and an inserted row look
|
|
452
|
+
alike, so a design that tolerates the first cannot detect the second. The
|
|
453
|
+
previous strict-line reading did not actually detect insertions either — it
|
|
454
|
+
reported every concurrent append as tampering, which on one production log meant
|
|
455
|
+
67% of rows red and any real signal lost in the noise.
|
|
456
|
+
|
|
457
|
+
### Rows written before 0.8.0
|
|
458
|
+
|
|
459
|
+
They have no `previous_checksum`. Verification falls back to the preceding row
|
|
460
|
+
in the walk and, failing that, searches back through the last `recovery_window`
|
|
461
|
+
(default 256) digests for the one that reproduces the row's checksum — which
|
|
462
|
+
recovers the true parent of a row that forked, **without re-signing anything**.
|
|
463
|
+
This does not weaken tamper detection: a row whose fields were altered
|
|
464
|
+
reproduces no candidate's digest. Pass `strict: true` to skip the search and see
|
|
465
|
+
every fork as a failure.
|
|
466
|
+
|
|
467
|
+
To make that permanent, add the column and record what each row was actually
|
|
468
|
+
signed against:
|
|
469
|
+
|
|
470
|
+
```bash
|
|
471
|
+
rails generate standard_audit:add_previous_checksum
|
|
472
|
+
rails db:migrate
|
|
473
|
+
rake standard_audit:relink_checksums
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
`relink_checksums` never rewrites a `checksum`. It fills in the previously-empty
|
|
477
|
+
`previous_checksum` only when that value reproduces the digest the row has held
|
|
478
|
+
since it was written, so it adds no attestation the rows did not already carry.
|
|
479
|
+
Rows it cannot resolve are reported as `unresolved` and keep failing
|
|
480
|
+
verification — which is the point.
|
|
481
|
+
|
|
482
|
+
**Do not run `backfill_checksums!` to make a red `verify_chain` go green.** It
|
|
483
|
+
re-signs rows from their current contents, so it attests only that a script ran.
|
|
484
|
+
It is for rows that never had a checksum at all (pre-feature data).
|
|
485
|
+
|
|
383
486
|
## Rake Tasks
|
|
384
487
|
|
|
385
488
|
```bash
|
|
489
|
+
# Verify chain integrity (exits non-zero on failures)
|
|
490
|
+
rake standard_audit:verify
|
|
491
|
+
|
|
492
|
+
# Record the parent digest each existing row was signed against
|
|
493
|
+
rake standard_audit:relink_checksums
|
|
494
|
+
|
|
386
495
|
# Delete logs older than N days (default: retention_days config or 90)
|
|
387
496
|
rake standard_audit:cleanup[180]
|
|
388
497
|
|
|
@@ -423,8 +532,30 @@ t.jsonb :metadata, default: {}
|
|
|
423
532
|
|
|
424
533
|
```ruby
|
|
425
534
|
config.sensitive_keys += %i[medical_record_number] # extend the built-in defaults
|
|
535
|
+
config.sensitive_key_patterns = [/secret/i] # catch a whole family
|
|
536
|
+
config.sensitive_key_exceptions = %i[input_tokens] # ...minus the real ones
|
|
537
|
+
config.filter_nested_metadata = true # redact nested Hashes too
|
|
426
538
|
```
|
|
427
539
|
|
|
540
|
+
`sensitive_keys` matches **exactly** on the key name. There is deliberately no
|
|
541
|
+
substring mode: against the default list it would strip real audit content —
|
|
542
|
+
`input_tokens` / `output_tokens`, `token_digest`, `password_reset_sent_at`,
|
|
543
|
+
`authorization_endpoint`, `onepassword`. Audit rows are append-only, so that
|
|
544
|
+
cannot be undone. Use `sensitive_key_patterns` and check any rule against your
|
|
545
|
+
own data first:
|
|
546
|
+
|
|
547
|
+
```bash
|
|
548
|
+
bin/rails "standard_audit:sensitive_keys:dry_run[secret]" # NESTED=1 to model nesting
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
The dry run writes nothing. It reports per key what would be stripped, what
|
|
552
|
+
would be kept, and nested matches that survive because
|
|
553
|
+
`filter_nested_metadata` is off.
|
|
554
|
+
|
|
555
|
+
Nested metadata is **not** redacted unless `filter_nested_metadata` is enabled
|
|
556
|
+
— `metadata: { stripe: { client_secret: ... } }` passes both write paths under
|
|
557
|
+
exact matching by default.
|
|
558
|
+
|
|
428
559
|
**Performance**: For high-volume applications, enable async processing and ensure your `audit_logs` table has appropriate indexes (the install generator adds them by default). Consider partitioning by `occurred_at` for very large tables.
|
|
429
560
|
|
|
430
561
|
**Retention**: Set `retention_days` in your configuration and run `rake standard_audit:cleanup` via a scheduled job (e.g., cron or SolidQueue recurring). Archive before deleting if you need long-term storage.
|