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 +4 -4
- data/CHANGELOG.md +53 -0
- data/README.md +271 -11
- data/app/models/standard_audit/audit_log.rb +71 -7
- data/lib/generators/standard_audit/add_anonymized_at/add_anonymized_at_generator.rb +20 -0
- data/lib/generators/standard_audit/add_anonymized_at/templates/add_anonymized_at_to_audit_logs.rb.erb +25 -0
- data/lib/generators/standard_audit/add_checksums/add_checksums_generator.rb +14 -0
- data/lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb +3 -0
- data/lib/standard_audit/configuration.rb +43 -6
- data/lib/standard_audit/event_subscriber.rb +18 -34
- data/lib/standard_audit/rspec/baseline.rb +81 -0
- data/lib/standard_audit/rspec/matchers.rb +187 -0
- data/lib/standard_audit/rspec.rb +7 -0
- data/lib/standard_audit/subscriber.rb +20 -78
- data/lib/standard_audit/version.rb +1 -1
- data/lib/standard_audit.rb +132 -50
- data/lib/tasks/standard_audit_tasks.rake +1 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a6da3bdedaecde5d44a57afe47372bf419b9290ace08a2312d1c522df5a67561
|
|
4
|
+
data.tar.gz: 283e8c2839ddce46fa2ec6468423c75380256f51f05a0a52844763d5454b7da3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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)
|
|
208
|
-
# (array of hashes carrying :id, :event_type,
|
|
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
|
|
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.
|
|
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
|