standard_audit 0.6.0 → 0.7.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 +46 -0
- data/README.md +50 -2
- data/app/models/standard_audit/audit_log.rb +77 -38
- 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 +47 -7
- data/lib/tasks/standard_audit_tasks.rake +41 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1ab5fcec99d13f0796742d817c1dd42e45c0e8518f8967033a67194c3179ae6e
|
|
4
|
+
data.tar.gz: b0f6272a600a665615cc533b439ad2b654d54c025bd7bc2b378b5c2eac7e850d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1ab1f514a13f1b03501cfdf18b3c2afcf8ff214a36992edb3e00f052384826c56bf09a35c4d7c4b8f92a4f28513cd8fe5534d946622112bb27bb6f8bfd8826c7
|
|
7
|
+
data.tar.gz: 7f9fc2acdb292b06f72b2323ac0c5c780348da8d1b91e9850ae73a61eba2361fb21b3aa431888de9d0003dc2934c0ba1b2a9a060bd4bd3b2a6a1bfb71eea5680
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.0] - 2026-07-30
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **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:
|
|
15
|
+
- `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`.
|
|
16
|
+
- `preloaded_actor=` / `preloaded_target=` / `preloaded_scope=` public writers, plus `actor_preloaded?` predicates.
|
|
17
|
+
- 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.
|
|
18
|
+
- `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`.
|
|
19
|
+
- `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).
|
|
20
|
+
- 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`.
|
|
21
|
+
- `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.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
- **`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.
|
|
25
|
+
```ruby
|
|
26
|
+
config.before_checksum { |log| log.scope = MyApp.derive_scope(log) }
|
|
27
|
+
config.before_checksum :backfill_scope # an AuditLog instance method
|
|
28
|
+
```
|
|
29
|
+
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.
|
|
30
|
+
- **`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.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
- **`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`.
|
|
34
|
+
|
|
35
|
+
**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.
|
|
36
|
+
- **`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`.
|
|
37
|
+
- **`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.
|
|
38
|
+
```bash
|
|
39
|
+
bin/rails standard_audit:sensitive_keys:dry_run # current config
|
|
40
|
+
bin/rails "standard_audit:sensitive_keys:dry_run[secret]" # candidate pattern
|
|
41
|
+
NESTED=1 bin/rails "standard_audit:sensitive_keys:dry_run[secret|token]"
|
|
42
|
+
```
|
|
43
|
+
Backed by `StandardAudit::SensitiveKeysDryRun.call(...)`, which extracts keys **in Ruby** rather than with `jsonb_object_keys` so it stays backend-neutral.
|
|
44
|
+
- `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.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- **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.
|
|
49
|
+
- **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.
|
|
50
|
+
|
|
51
|
+
### Notes
|
|
52
|
+
|
|
53
|
+
- `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.
|
|
54
|
+
- 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.
|
|
55
|
+
|
|
10
56
|
## [0.6.0] - 2026-06-24
|
|
11
57
|
|
|
12
58
|
### 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) }
|
|
@@ -423,8 +449,30 @@ t.jsonb :metadata, default: {}
|
|
|
423
449
|
|
|
424
450
|
```ruby
|
|
425
451
|
config.sensitive_keys += %i[medical_record_number] # extend the built-in defaults
|
|
452
|
+
config.sensitive_key_patterns = [/secret/i] # catch a whole family
|
|
453
|
+
config.sensitive_key_exceptions = %i[input_tokens] # ...minus the real ones
|
|
454
|
+
config.filter_nested_metadata = true # redact nested Hashes too
|
|
426
455
|
```
|
|
427
456
|
|
|
457
|
+
`sensitive_keys` matches **exactly** on the key name. There is deliberately no
|
|
458
|
+
substring mode: against the default list it would strip real audit content —
|
|
459
|
+
`input_tokens` / `output_tokens`, `token_digest`, `password_reset_sent_at`,
|
|
460
|
+
`authorization_endpoint`, `onepassword`. Audit rows are append-only, so that
|
|
461
|
+
cannot be undone. Use `sensitive_key_patterns` and check any rule against your
|
|
462
|
+
own data first:
|
|
463
|
+
|
|
464
|
+
```bash
|
|
465
|
+
bin/rails "standard_audit:sensitive_keys:dry_run[secret]" # NESTED=1 to model nesting
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
The dry run writes nothing. It reports per key what would be stripped, what
|
|
469
|
+
would be kept, and nested matches that survive because
|
|
470
|
+
`filter_nested_metadata` is off.
|
|
471
|
+
|
|
472
|
+
Nested metadata is **not** redacted unless `filter_nested_metadata` is enabled
|
|
473
|
+
— `metadata: { stripe: { client_secret: ... } }` passes both write paths under
|
|
474
|
+
exact matching by default.
|
|
475
|
+
|
|
428
476
|
**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
477
|
|
|
430
478
|
**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.
|
|
@@ -2,6 +2,8 @@ require "openssl"
|
|
|
2
2
|
|
|
3
3
|
module StandardAudit
|
|
4
4
|
class AuditLog < ApplicationRecord
|
|
5
|
+
include StandardAudit::ReferencePreloading
|
|
6
|
+
|
|
5
7
|
self.table_name = "audit_logs"
|
|
6
8
|
|
|
7
9
|
CHECKSUM_FIELDS = %w[
|
|
@@ -10,7 +12,15 @@ module StandardAudit
|
|
|
10
12
|
user_agent session_id occurred_at
|
|
11
13
|
].freeze
|
|
12
14
|
|
|
15
|
+
# Callback ORDER IS THE CONTRACT here. Definition order is execution
|
|
16
|
+
# order, so registering the host hook list between assign_uuid and
|
|
17
|
+
# compute_checksum means a hook can set a CHECKSUM_FIELDS member (e.g.
|
|
18
|
+
# back-fill `scope`) and the row still verifies. Hosts previously had to
|
|
19
|
+
# register `before_create ..., prepend: true` themselves to beat
|
|
20
|
+
# compute_checksum — fragile ordering knowledge no host should need.
|
|
21
|
+
# Do not reorder these three lines.
|
|
13
22
|
before_create :assign_uuid, if: -> { id.blank? }
|
|
23
|
+
before_create :run_before_checksum_hooks
|
|
14
24
|
before_create :compute_checksum, if: -> { checksum.blank? }
|
|
15
25
|
after_create_commit :emit_created_event
|
|
16
26
|
|
|
@@ -24,61 +34,35 @@ module StandardAudit
|
|
|
24
34
|
validates :event_type, presence: true
|
|
25
35
|
validates :occurred_at, presence: true
|
|
26
36
|
|
|
27
|
-
# --
|
|
37
|
+
# -- actor / target / scope assignment via GlobalID --
|
|
38
|
+
#
|
|
39
|
+
# Reads consult the preload memo first (see ReferencePreloading), then fall
|
|
40
|
+
# back to a single `GlobalID::Locator.locate`. Writers populate the memo,
|
|
41
|
+
# since they already hold the record. If the underlying record was deleted
|
|
42
|
+
# the reader returns nil while the gid and type stay on the row.
|
|
28
43
|
|
|
29
44
|
def actor=(record)
|
|
30
|
-
|
|
31
|
-
self.actor_gid = nil
|
|
32
|
-
self.actor_type = nil
|
|
33
|
-
else
|
|
34
|
-
self.actor_gid = record.to_global_id.to_s
|
|
35
|
-
self.actor_type = record.class.name
|
|
36
|
-
end
|
|
45
|
+
assign_reference(:actor, record)
|
|
37
46
|
end
|
|
38
47
|
|
|
39
48
|
def actor
|
|
40
|
-
|
|
41
|
-
GlobalID::Locator.locate(actor_gid)
|
|
42
|
-
rescue ActiveRecord::RecordNotFound
|
|
43
|
-
nil
|
|
49
|
+
read_reference(:actor)
|
|
44
50
|
end
|
|
45
51
|
|
|
46
|
-
# -- Target assignment via GlobalID --
|
|
47
|
-
|
|
48
52
|
def target=(record)
|
|
49
|
-
|
|
50
|
-
self.target_gid = nil
|
|
51
|
-
self.target_type = nil
|
|
52
|
-
else
|
|
53
|
-
self.target_gid = record.to_global_id.to_s
|
|
54
|
-
self.target_type = record.class.name
|
|
55
|
-
end
|
|
53
|
+
assign_reference(:target, record)
|
|
56
54
|
end
|
|
57
55
|
|
|
58
56
|
def target
|
|
59
|
-
|
|
60
|
-
GlobalID::Locator.locate(target_gid)
|
|
61
|
-
rescue ActiveRecord::RecordNotFound
|
|
62
|
-
nil
|
|
57
|
+
read_reference(:target)
|
|
63
58
|
end
|
|
64
59
|
|
|
65
|
-
# -- Scope assignment via GlobalID --
|
|
66
|
-
|
|
67
60
|
def scope=(record)
|
|
68
|
-
|
|
69
|
-
self.scope_gid = nil
|
|
70
|
-
self.scope_type = nil
|
|
71
|
-
else
|
|
72
|
-
self.scope_gid = record.to_global_id.to_s
|
|
73
|
-
self.scope_type = record.class.name
|
|
74
|
-
end
|
|
61
|
+
assign_reference(:scope, record)
|
|
75
62
|
end
|
|
76
63
|
|
|
77
64
|
def scope
|
|
78
|
-
|
|
79
|
-
GlobalID::Locator.locate(scope_gid)
|
|
80
|
-
rescue ActiveRecord::RecordNotFound
|
|
81
|
-
nil
|
|
65
|
+
read_reference(:scope)
|
|
82
66
|
end
|
|
83
67
|
|
|
84
68
|
# -- Query scopes --
|
|
@@ -280,5 +264,60 @@ module StandardAudit
|
|
|
280
264
|
def assign_uuid
|
|
281
265
|
self.id = SecureRandom.uuid_v7
|
|
282
266
|
end
|
|
267
|
+
|
|
268
|
+
# Runs config.before_checksum_hooks in registration order. Each hook is
|
|
269
|
+
# rescued individually: an audit write must never fail because a host's
|
|
270
|
+
# derived-column logic did. A hook that raises is rolled back to the
|
|
271
|
+
# attributes it started from and skipped; the remaining hooks still run.
|
|
272
|
+
def run_before_checksum_hooks
|
|
273
|
+
hooks = StandardAudit.config.before_checksum_hooks
|
|
274
|
+
return if hooks.blank?
|
|
275
|
+
|
|
276
|
+
# Only relevant when a checksum was supplied explicitly (the normal path
|
|
277
|
+
# has none yet, and compute_checksum runs next).
|
|
278
|
+
checksummed_before = checksum.present? ? attributes.slice(*CHECKSUM_FIELDS) : nil
|
|
279
|
+
|
|
280
|
+
hooks.each { |hook| run_before_checksum_hook(hook) }
|
|
281
|
+
|
|
282
|
+
# A caller-supplied checksum stops describing the row the moment a hook
|
|
283
|
+
# changes a checksummed field. Dropping it lets compute_checksum
|
|
284
|
+
# re-derive one, rather than persisting a row that fails verify_chain
|
|
285
|
+
# immediately. Hooks still run for such rows, because most derived
|
|
286
|
+
# columns (actor_role and friends) are not checksummed at all.
|
|
287
|
+
self.checksum = nil if checksummed_before && attributes.slice(*CHECKSUM_FIELDS) != checksummed_before
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def run_before_checksum_hook(hook)
|
|
291
|
+
snapshot = attributes.deep_dup
|
|
292
|
+
|
|
293
|
+
begin
|
|
294
|
+
case hook
|
|
295
|
+
when Symbol, String then send(hook)
|
|
296
|
+
else hook.call(self)
|
|
297
|
+
end
|
|
298
|
+
rescue StandardError => e
|
|
299
|
+
# Roll the record back to where the hook found it. Without this a hook
|
|
300
|
+
# that assigns scope_gid and then fails a later lookup leaves a
|
|
301
|
+
# half-applied row that the following callbacks happily checksum and
|
|
302
|
+
# persist — so the hook would not actually be "skipped".
|
|
303
|
+
restore_attributes_from(snapshot)
|
|
304
|
+
Rails.logger.warn("[StandardAudit] before_checksum hook failed: #{e.class}: #{e.message}")
|
|
305
|
+
Rails.error.report(e, handled: true, context: { audit_event: event_type }) if Rails.respond_to?(:error)
|
|
306
|
+
nil
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def restore_attributes_from(snapshot)
|
|
311
|
+
snapshot.each do |name, value|
|
|
312
|
+
write_attribute(name, value) unless read_attribute(name) == value
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# The reference memo may hold a record the hook assigned; drop it so the
|
|
316
|
+
# restored gid columns are the source of truth again.
|
|
317
|
+
@preloaded_references = nil
|
|
318
|
+
rescue StandardError => e
|
|
319
|
+
Rails.logger.warn("[StandardAudit] could not roll back a failed before_checksum hook: #{e.class}: #{e.message}")
|
|
320
|
+
nil
|
|
321
|
+
end
|
|
283
322
|
end
|
|
284
323
|
end
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
StandardAudit.
|
|
1
|
+
# `baseline: true` remembers this block so `StandardAudit.reset_configuration!`
|
|
2
|
+
# replays it. Required if you `require "standard_audit/rspec"` in your suite:
|
|
3
|
+
# without it, every example resets to gem defaults and loses the configuration
|
|
4
|
+
# below — including any `before_checksum` hooks, which are behaviour, not data.
|
|
5
|
+
StandardAudit.configure(baseline: true) do |config|
|
|
2
6
|
# Subscribe to ActiveSupport::Notifications / Rails.event patterns.
|
|
3
7
|
# Each gem documents its own event namespace; subscribe to whichever
|
|
4
8
|
# patterns you want audited:
|
|
@@ -27,10 +31,35 @@ StandardAudit.configure do |config|
|
|
|
27
31
|
# config.current_user_agent_resolver = -> { Current.user_agent }
|
|
28
32
|
# config.current_session_id_resolver = -> { Current.session_id }
|
|
29
33
|
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
34
|
+
# -- Metadata redaction ----------------------------------------------------
|
|
35
|
+
#
|
|
36
|
+
# Keys to strip from metadata. Matching is EXACT on the key name (defaults
|
|
37
|
+
# include: password, password_confirmation, token, secret, api_key,
|
|
38
|
+
# access_token, refresh_token, private_key, certificate_chain, ssn,
|
|
39
|
+
# credit_card, authorization).
|
|
33
40
|
# config.sensitive_keys += %i[my_custom_secret]
|
|
41
|
+
#
|
|
42
|
+
# Regexps matched against every key name, in addition to the exact list.
|
|
43
|
+
# This is the supported way to catch a family of keys — e.g. Stripe's
|
|
44
|
+
# `client_secret`, which does not equal the default `:secret`:
|
|
45
|
+
# config.sensitive_key_patterns = [/secret/i]
|
|
46
|
+
#
|
|
47
|
+
# There is deliberately no "substring" matching mode. Against the default
|
|
48
|
+
# key list it would strip real audit content: input_tokens/output_tokens,
|
|
49
|
+
# token_digest, password_reset_sent_at, authorization_endpoint, onepassword.
|
|
50
|
+
# Audit rows are append-only, so that cannot be undone. Check any rule
|
|
51
|
+
# against your own data first:
|
|
52
|
+
#
|
|
53
|
+
# bin/rails "standard_audit:sensitive_keys:dry_run[secret]"
|
|
54
|
+
#
|
|
55
|
+
# Keys (exact names or Regexps) that are never redacted, so a broad pattern
|
|
56
|
+
# can keep the handful of real audit keys it would otherwise swallow:
|
|
57
|
+
# config.sensitive_key_exceptions = %i[input_tokens output_tokens]
|
|
58
|
+
#
|
|
59
|
+
# Descend into nested Hashes when redacting. Off by default because it
|
|
60
|
+
# changes what gets written; `metadata: { stripe: { client_secret: ... } }`
|
|
61
|
+
# is NOT redacted unless you turn this on. Run the dry run first.
|
|
62
|
+
# config.filter_nested_metadata = true
|
|
34
63
|
|
|
35
64
|
# Run audit log creation in background job
|
|
36
65
|
# config.async = false
|
|
@@ -44,4 +73,17 @@ StandardAudit.configure do |config|
|
|
|
44
73
|
|
|
45
74
|
# Data retention (schedule StandardAudit::CleanupJob to enforce)
|
|
46
75
|
# config.retention_days = nil
|
|
76
|
+
|
|
77
|
+
# -- Write-time hooks -------------------------------------------------------
|
|
78
|
+
#
|
|
79
|
+
# Run between the UUID assignment and the checksum computation, so a hook MAY
|
|
80
|
+
# set a checksummed column (scope_gid, metadata, ...) and the row still passes
|
|
81
|
+
# `AuditLog.verify_chain`. No `prepend: true` needed.
|
|
82
|
+
#
|
|
83
|
+
# Each hook is rescued individually — a failing hook is logged and skipped and
|
|
84
|
+
# never fails the audit write. Batched writes (StandardAudit.batch, which uses
|
|
85
|
+
# insert_all!) never instantiate a model, so hooks do not run there.
|
|
86
|
+
#
|
|
87
|
+
# config.before_checksum { |log| log.scope = MyApp.derive_scope(log) }
|
|
88
|
+
# config.before_checksum :backfill_scope # an AuditLog instance method
|
|
47
89
|
end
|
|
@@ -5,7 +5,9 @@ module StandardAudit
|
|
|
5
5
|
:current_actor_resolver, :current_request_id_resolver,
|
|
6
6
|
:current_ip_address_resolver, :current_user_agent_resolver,
|
|
7
7
|
:current_session_id_resolver,
|
|
8
|
-
:sensitive_keys, :
|
|
8
|
+
:sensitive_keys, :sensitive_key_patterns,
|
|
9
|
+
:sensitive_key_exceptions, :filter_nested_metadata,
|
|
10
|
+
:metadata_builder, :before_checksum_hooks,
|
|
9
11
|
:anonymizable_metadata_keys, :retention_days
|
|
10
12
|
|
|
11
13
|
def initialize
|
|
@@ -43,7 +45,37 @@ module StandardAudit
|
|
|
43
45
|
private_key certificate_chain
|
|
44
46
|
ssn credit_card authorization
|
|
45
47
|
]
|
|
48
|
+
# Regexps matched against every metadata key name, in addition to the
|
|
49
|
+
# exact-match `sensitive_keys` list. This is the supported way to catch a
|
|
50
|
+
# family of keys: `/secret/i` redacts `client_secret`, `webhook_secret`,
|
|
51
|
+
# and `secret` alike.
|
|
52
|
+
#
|
|
53
|
+
# There is deliberately NO substring *mode* for `sensitive_keys` — see
|
|
54
|
+
# the note in MetadataFilter. Patterns are opt-in and per-app, which is
|
|
55
|
+
# the only safe shape for a rule applied to append-only rows.
|
|
56
|
+
@sensitive_key_patterns = []
|
|
57
|
+
|
|
58
|
+
# Key names (String/Symbol, exact) or Regexps that are never redacted,
|
|
59
|
+
# even when `sensitive_keys` or `sensitive_key_patterns` matches. Lets an
|
|
60
|
+
# app adopt a broad pattern while keeping the handful of real audit keys
|
|
61
|
+
# it would otherwise swallow, e.g.
|
|
62
|
+
# `sensitive_key_patterns = [/token/i]` with
|
|
63
|
+
# `sensitive_key_exceptions = %i[input_tokens output_tokens]`.
|
|
64
|
+
@sensitive_key_exceptions = []
|
|
65
|
+
|
|
66
|
+
# When true, redaction descends into nested Hashes (and Hashes inside
|
|
67
|
+
# Arrays), so `metadata: { stripe: { client_secret: … } }` is caught.
|
|
68
|
+
# Defaults to false: it changes what gets written, and audit rows are
|
|
69
|
+
# append-only. Reserved keys are never descended into.
|
|
70
|
+
@filter_nested_metadata = false
|
|
71
|
+
|
|
46
72
|
@metadata_builder = nil
|
|
73
|
+
|
|
74
|
+
# Callables (or Symbols naming an AuditLog instance method) run on
|
|
75
|
+
# `before_create` AFTER the UUID is assigned and BEFORE the checksum is
|
|
76
|
+
# computed. See Configuration#before_checksum.
|
|
77
|
+
@before_checksum_hooks = []
|
|
78
|
+
|
|
47
79
|
@anonymizable_metadata_keys = %i[email name ip_address]
|
|
48
80
|
|
|
49
81
|
# Retention defaults from ENV so it can be set per-environment without a
|
|
@@ -63,6 +95,37 @@ module StandardAudit
|
|
|
63
95
|
days&.positive? ? days : nil
|
|
64
96
|
end
|
|
65
97
|
|
|
98
|
+
# Registers a hook to run between `assign_uuid` and `compute_checksum` on
|
|
99
|
+
# every audit write that instantiates a model.
|
|
100
|
+
#
|
|
101
|
+
# config.before_checksum { |log| log.scope = derive_scope(log) }
|
|
102
|
+
# config.before_checksum :backfill_organization_scope
|
|
103
|
+
#
|
|
104
|
+
# A hook may set a `CHECKSUM_FIELDS` member (`scope_gid`, `metadata`, …) and
|
|
105
|
+
# the row will still verify, because the checksum is computed afterwards.
|
|
106
|
+
# That is the whole point: before this existed, hosts had to register their
|
|
107
|
+
# own `before_create ..., prepend: true` to beat the gem's checksum
|
|
108
|
+
# callback, which is fragile ordering knowledge no host should need.
|
|
109
|
+
#
|
|
110
|
+
# Hooks accumulate and run in registration order. Each is rescued
|
|
111
|
+
# individually — a failing hook logs and is skipped; it never fails the
|
|
112
|
+
# audit write.
|
|
113
|
+
#
|
|
114
|
+
# A Symbol/String is sent to the AuditLog instance (use this for methods
|
|
115
|
+
# supplied by a concern mixed into the model). A callable is passed the
|
|
116
|
+
# instance.
|
|
117
|
+
#
|
|
118
|
+
# NOTE: batched writes (`StandardAudit.batch { … }` → `insert_all!`) never
|
|
119
|
+
# instantiate a model, so hooks do not run there. A batched writer that
|
|
120
|
+
# needs a derived column has to set it on the buffered attrs.
|
|
121
|
+
def before_checksum(hook = nil, &block)
|
|
122
|
+
hook ||= block
|
|
123
|
+
raise ArgumentError, "before_checksum needs a callable, a Symbol, or a block" if hook.nil?
|
|
124
|
+
|
|
125
|
+
@before_checksum_hooks << hook
|
|
126
|
+
hook
|
|
127
|
+
end
|
|
128
|
+
|
|
66
129
|
def subscribe_to(pattern)
|
|
67
130
|
@subscriptions << pattern
|
|
68
131
|
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
module StandardAudit
|
|
2
|
+
# The single implementation of sensitive-key redaction for audit metadata.
|
|
3
|
+
#
|
|
4
|
+
# There used to be two: one in `StandardAudit.record` and one in
|
|
5
|
+
# `Subscriber#extract_metadata`. They had already diverged — the subscriber
|
|
6
|
+
# copy did not subtract `RESERVED_METADATA_KEYS`, so an app that (reasonably)
|
|
7
|
+
# added `:_tags` to `sensitive_keys` got the reserved key preserved on the
|
|
8
|
+
# `record` path and stripped on the `ActiveSupport::Notifications` path. Two
|
|
9
|
+
# copies of a security filter drifting apart is exactly the failure mode
|
|
10
|
+
# worth designing out, so both paths now call here.
|
|
11
|
+
#
|
|
12
|
+
# The divergence is resolved in favour of `record`'s behaviour: reserved keys
|
|
13
|
+
# are subtracted from the sensitive set and can never be filtered.
|
|
14
|
+
#
|
|
15
|
+
# == Matching
|
|
16
|
+
#
|
|
17
|
+
# A key is redacted when it matches `config.sensitive_keys` **exactly** (by
|
|
18
|
+
# name, string/symbol insensitive) or matches one of
|
|
19
|
+
# `config.sensitive_key_patterns` (Regexps, always applied), unless it is
|
|
20
|
+
# listed in `config.sensitive_key_exceptions` or is a reserved key.
|
|
21
|
+
#
|
|
22
|
+
# == Why there is no substring mode
|
|
23
|
+
#
|
|
24
|
+
# Matching `sensitive_keys` by substring instead of exactly is the obvious
|
|
25
|
+
# "fix" for `client_secret` not matching `:secret`, and it is a trap. Against
|
|
26
|
+
# the current default key list it would strip real audit content across the
|
|
27
|
+
# estate:
|
|
28
|
+
#
|
|
29
|
+
# :token => input_tokens, output_tokens (live LLM cost accounting),
|
|
30
|
+
# token_digest (rendered in a staff audit UI)
|
|
31
|
+
# :password => password_reset_sent_at, onepassword
|
|
32
|
+
# :authorization => authorization_endpoint
|
|
33
|
+
#
|
|
34
|
+
# Audit rows are append-only, so a bad default cannot be undone — the content
|
|
35
|
+
# is simply never written. `sensitive_key_patterns` is the supported tool
|
|
36
|
+
# instead: `/secret/i` solves the motivating `client_secret` case exactly,
|
|
37
|
+
# opt-in and per-app, and `rake standard_audit:sensitive_keys:dry_run` turns
|
|
38
|
+
# "is this rule safe for my data?" into a command rather than a guess.
|
|
39
|
+
class MetadataFilter
|
|
40
|
+
# Raised when metadata is neither nil nor hash-like. Deliberately a raise
|
|
41
|
+
# rather than a pass-through: this filter **fails closed**. An
|
|
42
|
+
# `ActionController::Parameters` is not a `Hash`, and letting an unrecognised
|
|
43
|
+
# object through unfiltered would write raw params — passwords included —
|
|
44
|
+
# into an append-only row. Pre-0.7.0 the same input blew up with
|
|
45
|
+
# `NoMethodError` on `#reject`, so raising preserves the outcome while
|
|
46
|
+
# naming the cause.
|
|
47
|
+
class UnfilterableMetadataError < ArgumentError; end
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
def call(metadata, config: StandardAudit.config)
|
|
51
|
+
new(config: config).call(metadata)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize(config: StandardAudit.config)
|
|
56
|
+
@config = config
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Filters anything hash-like, not just `Hash` — `ActionController::Parameters`
|
|
60
|
+
# and other `each_pair`-able wrappers are filtered rather than waved
|
|
61
|
+
# through. `nil` passes (there is nothing to leak); anything else raises.
|
|
62
|
+
#
|
|
63
|
+
# Descends into nested Hashes (and Hashes inside Arrays) only when
|
|
64
|
+
# `config.filter_nested_metadata` is true. Reserved keys are preserved and
|
|
65
|
+
# their values are left entirely alone, **at every depth** — `_tags` and
|
|
66
|
+
# `_source` are gem-owned, not host payload, and immunity that held only at
|
|
67
|
+
# the top level would be a confusing half-guarantee.
|
|
68
|
+
def call(metadata)
|
|
69
|
+
return nil if metadata.nil?
|
|
70
|
+
|
|
71
|
+
unless hash_like?(metadata)
|
|
72
|
+
raise UnfilterableMetadataError,
|
|
73
|
+
"audit metadata must be nil or hash-like (got #{metadata.class}); " \
|
|
74
|
+
"refusing to write it unfiltered"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
filtered = metadata.reject { |key, _| filter?(key) }
|
|
78
|
+
return filtered unless @config.filter_nested_metadata
|
|
79
|
+
|
|
80
|
+
filter_values(filtered)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# True when `key` would be redacted from metadata. Public so hosts (and the
|
|
84
|
+
# dry-run tooling) can ask the question without writing a row.
|
|
85
|
+
def filter?(key)
|
|
86
|
+
key = key.to_s
|
|
87
|
+
|
|
88
|
+
# `_tags` and `_source` are owned by EventSubscriber and are never
|
|
89
|
+
# stripped, even if a consumer lists them in `sensitive_keys`.
|
|
90
|
+
return false if StandardAudit::RESERVED_METADATA_KEYS.include?(key)
|
|
91
|
+
return false if exception?(key)
|
|
92
|
+
|
|
93
|
+
sensitive_keys.include?(key) || sensitive_key_patterns.any? { |pattern| pattern.match?(key) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def hash_like?(value)
|
|
99
|
+
value.respond_to?(:each_pair) && value.respond_to?(:reject)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Rewrites each value in place on the already-rejected copy. In place
|
|
103
|
+
# because `transform_values` is not guaranteed across every hash-like
|
|
104
|
+
# wrapper, whereas `[]=` is.
|
|
105
|
+
def filter_values(hash)
|
|
106
|
+
hash.each_pair do |key, value|
|
|
107
|
+
# Reserved subtree: immune at every depth, never descended into.
|
|
108
|
+
next if StandardAudit::RESERVED_METADATA_KEYS.include?(key.to_s)
|
|
109
|
+
|
|
110
|
+
replacement = filter_descendant(value)
|
|
111
|
+
hash[key] = replacement unless replacement.equal?(value)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
hash
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def filter_descendant(value)
|
|
118
|
+
if hash_like?(value)
|
|
119
|
+
filter_values(value.reject { |key, _| filter?(key) })
|
|
120
|
+
elsif value.is_a?(Array)
|
|
121
|
+
value.map { |element| filter_descendant(element) }
|
|
122
|
+
else
|
|
123
|
+
value
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def sensitive_keys
|
|
128
|
+
@sensitive_keys ||= @config.sensitive_keys.map(&:to_s) - StandardAudit::RESERVED_METADATA_KEYS
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# A String entry is read as a Regexp source, so the rake task (and a
|
|
132
|
+
# `SENSITIVE_KEY_PATTERNS` env var) can pass one through without eval.
|
|
133
|
+
def sensitive_key_patterns
|
|
134
|
+
@sensitive_key_patterns ||= Array(@config.sensitive_key_patterns).map do |pattern|
|
|
135
|
+
pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern.to_s)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def exception?(key)
|
|
140
|
+
exact_exceptions.include?(key) || pattern_exceptions.any? { |pattern| pattern.match?(key) }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def exceptions
|
|
144
|
+
@exceptions ||= Array(@config.sensitive_key_exceptions).partition { |entry| entry.is_a?(Regexp) }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def pattern_exceptions
|
|
148
|
+
exceptions.first
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def exact_exceptions
|
|
152
|
+
@exact_exceptions ||= exceptions.last.map(&:to_s)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
require "active_support/concern"
|
|
3
|
+
|
|
4
|
+
module StandardAudit
|
|
5
|
+
# Batch resolution of the GlobalID-backed `actor` / `target` / `scope`
|
|
6
|
+
# references on a page of audit rows, plus the per-row memo the readers
|
|
7
|
+
# consult.
|
|
8
|
+
#
|
|
9
|
+
# Why this exists: `AuditLog#actor` resolves `actor_gid` through
|
|
10
|
+
# `GlobalID::Locator.locate`, which is one query per row. Rendering N rows
|
|
11
|
+
# that each read `actor` and `target` issues O(N) lookups — an N+1 that
|
|
12
|
+
# Prosopite fails in every consuming app. Before this concern existed, apps
|
|
13
|
+
# worked around it by defining their own `preloaded_actor=` writer (or
|
|
14
|
+
# reaching into `@preloaded_actor` with `instance_variable_set`) and
|
|
15
|
+
# hand-rolling a per-type whitelist loader.
|
|
16
|
+
#
|
|
17
|
+
# == Memo semantics
|
|
18
|
+
#
|
|
19
|
+
# The memo is a Hash consulted with `key?`, not a pair of ivars checked with
|
|
20
|
+
# `defined?`. That distinction matters: a reference that was preloaded but
|
|
21
|
+
# whose record has since been deleted memoizes `nil`, and reads back as
|
|
22
|
+
# `nil` *without* falling through to a query. "Preloaded, and the answer is
|
|
23
|
+
# nothing" is therefore distinct from "not preloaded".
|
|
24
|
+
#
|
|
25
|
+
# `actor=` / `target=` / `scope=` populate the memo too, since the writer
|
|
26
|
+
# already holds the record. `reload` clears it.
|
|
27
|
+
#
|
|
28
|
+
# == Batched writes
|
|
29
|
+
#
|
|
30
|
+
# `StandardAudit.batch { ... }` flushes through `insert_all!` and never
|
|
31
|
+
# instantiates an AuditLog, so nothing in this concern runs on that path —
|
|
32
|
+
# neither the memo nor the writers. It is a no-op for batched writes by
|
|
33
|
+
# construction, not by accident.
|
|
34
|
+
module ReferencePreloading
|
|
35
|
+
extend ActiveSupport::Concern
|
|
36
|
+
|
|
37
|
+
# The GlobalID-backed reference columns. `scope` is preloadable but not in
|
|
38
|
+
# the default `refs:` set, since most read surfaces render actor/target
|
|
39
|
+
# only.
|
|
40
|
+
REFERENCES = %i[actor target scope].freeze
|
|
41
|
+
|
|
42
|
+
DEFAULT_REFS = %i[actor target].freeze
|
|
43
|
+
|
|
44
|
+
class_methods do
|
|
45
|
+
# Resolves the given references for a whole collection of audit logs in a
|
|
46
|
+
# fixed number of queries (one per distinct stored `*_type`), then
|
|
47
|
+
# memoizes the result on each row.
|
|
48
|
+
#
|
|
49
|
+
# AuditLog.preload_references(
|
|
50
|
+
# logs,
|
|
51
|
+
# refs: %i[actor target],
|
|
52
|
+
# only: [Account, Profile, Order],
|
|
53
|
+
# includes: { "Profile" => [:account], "Order" => [:user] }
|
|
54
|
+
# )
|
|
55
|
+
#
|
|
56
|
+
# [refs:] Which references to resolve. Defaults to `%i[actor target]`.
|
|
57
|
+
# [only:] Optional whitelist of permitted classes. See the note below —
|
|
58
|
+
# this is matched against the *stored type string*, so it is
|
|
59
|
+
# a stricter gate than `GlobalID::Locator`'s own `:only`.
|
|
60
|
+
# [includes:] Either a uniform Active Record `includes` spec applied to
|
|
61
|
+
# every type, or a per-type Hash keyed by class name String
|
|
62
|
+
# or Class constant (e.g. `{ "Profile" => [:account] }`).
|
|
63
|
+
# A Hash counts as per-type only when *every* key is a String
|
|
64
|
+
# or a Module; `{ account: :identifiers }` is therefore read
|
|
65
|
+
# as a uniform nested-includes spec, as you would expect.
|
|
66
|
+
#
|
|
67
|
+
# == Why `only:` is matched on the stored string
|
|
68
|
+
#
|
|
69
|
+
# `GlobalID::Locator`'s `:only` option is evaluated as
|
|
70
|
+
# `gid.model_class <= klass`, which means it *constantizes the stored
|
|
71
|
+
# type string before deciding whether it was allowed*. That is the wrong
|
|
72
|
+
# order for audit rows, whose type strings are historical: a class that
|
|
73
|
+
# has since been renamed or removed raises `NameError` rather than being
|
|
74
|
+
# filtered out. So this method filters on the stored `*_type` string
|
|
75
|
+
# first, by class name, and only then hands the surviving gids to
|
|
76
|
+
# `locate_many` (still passing `only:` as a second gate).
|
|
77
|
+
#
|
|
78
|
+
# The consequence is that `only:` does **not** expand to subclasses or
|
|
79
|
+
# to modules the way GlobalID's does — list every concrete class whose
|
|
80
|
+
# name you expect to see in `actor_type` / `target_type`. Deny-by-default
|
|
81
|
+
# is the safe direction for a whitelist.
|
|
82
|
+
#
|
|
83
|
+
# A reference whose type is not in `only:` memoizes `nil`, so it reads
|
|
84
|
+
# back as nil without a query rather than silently re-N+1ing.
|
|
85
|
+
#
|
|
86
|
+
# Returns the logs as an Array.
|
|
87
|
+
def preload_references(logs, refs: DEFAULT_REFS, only: nil, includes: nil)
|
|
88
|
+
logs = logs.to_a
|
|
89
|
+
return logs if logs.empty?
|
|
90
|
+
|
|
91
|
+
Array(refs).each do |ref|
|
|
92
|
+
ref = ref.to_sym
|
|
93
|
+
unless REFERENCES.include?(ref)
|
|
94
|
+
raise ArgumentError, "unknown audit reference #{ref.inspect} (expected one of #{REFERENCES.inspect})"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
preload_one_reference(logs, ref, only: only, includes: includes)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
logs
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Extracts the model id from a GlobalID string without resolving it:
|
|
104
|
+
# "gid://some-app/Account/123" => "123".
|
|
105
|
+
#
|
|
106
|
+
# Deliberately *not* `GlobalID.parse(gid)&.model_id`. Audit gids are
|
|
107
|
+
# historical and the rows are append-only, so a gid written by another
|
|
108
|
+
# app — or before this app was renamed — is a real, permanent row whose
|
|
109
|
+
# id must still be readable. Parsing it back through the locator
|
|
110
|
+
# machinery resolves against the *current* `GlobalID.app`, which is not
|
|
111
|
+
# what these rows are keyed on.
|
|
112
|
+
#
|
|
113
|
+
# This mirrors `URI::GID#set_model_components` exactly, minus the app
|
|
114
|
+
# check and the validations: drop any `?params` query, take everything
|
|
115
|
+
# after the model-name segment, split composite ids on "/", and
|
|
116
|
+
# `CGI.unescape` each part (`URI::GID.build` `CGI.escape`s them). Returns
|
|
117
|
+
# a String for a single-column primary key and an Array of Strings for a
|
|
118
|
+
# composite one, matching `GlobalID#model_id`.
|
|
119
|
+
def reference_model_id(gid)
|
|
120
|
+
return nil if gid.blank?
|
|
121
|
+
|
|
122
|
+
# "gid:" , "" , app , ModelName , <id segment(s)>
|
|
123
|
+
segment = gid.to_s.split("?", 2).first.to_s.split("/", 5)[4]
|
|
124
|
+
return nil if segment.blank?
|
|
125
|
+
|
|
126
|
+
parts = segment.split("/").reject(&:blank?).map { |part| CGI.unescape(part) }
|
|
127
|
+
return nil if parts.empty?
|
|
128
|
+
|
|
129
|
+
parts.length == 1 ? parts.first : parts
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def preload_one_reference(logs, ref, only:, includes:)
|
|
135
|
+
gid_attr = :"#{ref}_gid"
|
|
136
|
+
type_attr = :"#{ref}_type"
|
|
137
|
+
allowed_names = only && Array(only).map { |klass| klass.is_a?(Module) ? klass.name : klass.to_s }
|
|
138
|
+
|
|
139
|
+
index = {}
|
|
140
|
+
unresolvable_types = []
|
|
141
|
+
|
|
142
|
+
logs.group_by { |log| log.public_send(type_attr) }.each do |type, type_logs|
|
|
143
|
+
# A blank `*_type` with a populated `*_gid` happens on historical and
|
|
144
|
+
# partially-backfilled rows. The per-row reader can still resolve
|
|
145
|
+
# those (the gid carries the model name), so mark the group
|
|
146
|
+
# unresolvable rather than memoizing nil — batching must never make a
|
|
147
|
+
# resolvable reference read back as nothing.
|
|
148
|
+
if type.blank?
|
|
149
|
+
unresolvable_types << type
|
|
150
|
+
next
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Not whitelisted: intentionally left out of the index so it memoizes
|
|
154
|
+
# nil (no query, now or later).
|
|
155
|
+
next if allowed_names && !allowed_names.include?(type)
|
|
156
|
+
|
|
157
|
+
gids = type_logs.filter_map { |log| log.public_send(gid_attr).presence }.uniq
|
|
158
|
+
next if gids.empty?
|
|
159
|
+
|
|
160
|
+
begin
|
|
161
|
+
locate_reference_records(gids, only: only, includes: includes_for(includes, type)).each do |record|
|
|
162
|
+
index[[type, normalized_record_key(record.id)]] = record
|
|
163
|
+
end
|
|
164
|
+
rescue NameError, ActiveRecord::StatementInvalid => e
|
|
165
|
+
# A historical type string that no longer constantizes, or a
|
|
166
|
+
# relation the current schema can't satisfy. Leave these rows
|
|
167
|
+
# *unmemoized* so the per-row reader behaves exactly as it did
|
|
168
|
+
# before preloading was attempted — memoizing nil here would
|
|
169
|
+
# silently rewrite behaviour on a bad `includes:`.
|
|
170
|
+
unresolvable_types << type
|
|
171
|
+
Rails.logger.warn("[StandardAudit] Could not preload #{ref} for type #{type.inspect}: #{e.class}: #{e.message}")
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
logs.each do |log|
|
|
176
|
+
type = log.public_send(type_attr)
|
|
177
|
+
next if unresolvable_types.include?(type)
|
|
178
|
+
|
|
179
|
+
log.write_preloaded_reference(
|
|
180
|
+
ref,
|
|
181
|
+
index[[type, reference_model_id(log.public_send(gid_attr))]]
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# `ignore_missing: true` makes this a `where(id: ids)` instead of
|
|
187
|
+
# `find(ids)`, so a deleted record is simply absent from the result
|
|
188
|
+
# rather than raising — which is what lets a deleted reference memoize
|
|
189
|
+
# nil.
|
|
190
|
+
def locate_reference_records(gids, only:, includes:)
|
|
191
|
+
options = { ignore_missing: true }
|
|
192
|
+
options[:only] = only if only
|
|
193
|
+
options[:includes] = includes if includes
|
|
194
|
+
|
|
195
|
+
GlobalID::Locator.locate_many(gids, options)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Mirrors how `GlobalID::Locator::BaseLocator#locate_many` keys its own
|
|
199
|
+
# result index, so composite primary keys line up with
|
|
200
|
+
# `reference_model_id`.
|
|
201
|
+
def normalized_record_key(id)
|
|
202
|
+
id.is_a?(Array) ? id.map(&:to_s) : id.to_s
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def includes_for(includes, type)
|
|
206
|
+
return nil if includes.nil?
|
|
207
|
+
return includes unless per_type_includes?(includes)
|
|
208
|
+
|
|
209
|
+
entry = includes.find { |key, _| (key.is_a?(Module) ? key.name : key.to_s) == type }
|
|
210
|
+
entry&.last
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# A Hash counts as a per-type mapping only when every key names a class.
|
|
214
|
+
# Symbol keys mean the caller passed an Active Record includes spec such
|
|
215
|
+
# as `{ account: :identifiers }`, which must be applied uniformly.
|
|
216
|
+
def per_type_includes?(includes)
|
|
217
|
+
includes.is_a?(Hash) &&
|
|
218
|
+
includes.any? &&
|
|
219
|
+
includes.keys.all? { |key| key.is_a?(String) || key.is_a?(Module) }
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Writers. `preloaded_actor = record_or_nil` is the supported way to hand a
|
|
224
|
+
# separately-resolved record to a log; `nil` means "resolved to nothing",
|
|
225
|
+
# not "clear the memo".
|
|
226
|
+
REFERENCES.each do |ref|
|
|
227
|
+
define_method(:"preloaded_#{ref}=") do |record|
|
|
228
|
+
write_preloaded_reference(ref, record)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
define_method(:"#{ref}_preloaded?") do
|
|
232
|
+
reference_preloaded?(ref)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# `actor_model_id` / `target_model_id` / `scope_model_id` — the trailing
|
|
236
|
+
# gid segment, without needing the record.
|
|
237
|
+
define_method(:"#{ref}_model_id") do
|
|
238
|
+
self.class.reference_model_id(public_send(:"#{ref}_gid"))
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# The memo itself. Public because `preload_references` writes through it
|
|
243
|
+
# from the class side; treat it as gem-internal — `preloaded_actor=` and
|
|
244
|
+
# `actor_preloaded?` are the supported surface.
|
|
245
|
+
def preloaded_references
|
|
246
|
+
@preloaded_references ||= {}
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def reference_preloaded?(ref)
|
|
250
|
+
preloaded_references.key?(ref.to_sym)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# See `preloaded_references` — gem-internal, but public so the class-level
|
|
254
|
+
# preloader can reach it.
|
|
255
|
+
def write_preloaded_reference(ref, record)
|
|
256
|
+
preloaded_references[ref.to_sym] = record
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Drops the memo so the next read re-resolves from the (possibly changed)
|
|
260
|
+
# gid columns.
|
|
261
|
+
def reload(...)
|
|
262
|
+
@preloaded_references = nil
|
|
263
|
+
super
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
private
|
|
267
|
+
|
|
268
|
+
# The single-row fallback: identical to the pre-0.7.0 reader behaviour.
|
|
269
|
+
def locate_reference(ref)
|
|
270
|
+
gid = public_send(:"#{ref}_gid")
|
|
271
|
+
return nil if gid.blank?
|
|
272
|
+
|
|
273
|
+
GlobalID::Locator.locate(gid)
|
|
274
|
+
rescue ActiveRecord::RecordNotFound
|
|
275
|
+
nil
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def read_reference(ref)
|
|
279
|
+
ref = ref.to_sym
|
|
280
|
+
return preloaded_references[ref] if preloaded_references.key?(ref)
|
|
281
|
+
|
|
282
|
+
locate_reference(ref)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def assign_reference(ref, record)
|
|
286
|
+
if record.nil?
|
|
287
|
+
public_send(:"#{ref}_gid=", nil)
|
|
288
|
+
public_send(:"#{ref}_type=", nil)
|
|
289
|
+
else
|
|
290
|
+
public_send(:"#{ref}_gid=", record.to_global_id.to_s)
|
|
291
|
+
public_send(:"#{ref}_type=", record.class.name)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
write_preloaded_reference(ref, record)
|
|
295
|
+
record
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
data/lib/standard_audit/rspec.rb
CHANGED
|
@@ -8,9 +8,23 @@ require "standard_audit"
|
|
|
8
8
|
# - Resets the Configuration via `StandardAudit.reset_configuration!` so
|
|
9
9
|
# that mutations to `StandardAudit.config` (subscriptions, sensitive
|
|
10
10
|
# keys, async flag, custom resolvers, etc.) do not bleed across specs.
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
11
|
+
#
|
|
12
|
+
# IMPORTANT — declare your configuration as a baseline. The reset restores
|
|
13
|
+
# gem defaults, and the config object holds *behaviour*, not just data:
|
|
14
|
+
# `before_checksum_hooks` live there. A suite that installs this plugin
|
|
15
|
+
# without a baseline silently loses its write-time hooks after the first
|
|
16
|
+
# example, and the specs that would have caught it pass vacuously.
|
|
17
|
+
#
|
|
18
|
+
# So in `config/initializers/standard_audit.rb`, use:
|
|
19
|
+
#
|
|
20
|
+
# StandardAudit.configure(baseline: true) do |config|
|
|
21
|
+
# config.subscribe_to "myapp.**"
|
|
22
|
+
# config.before_checksum :backfill_scope
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# `reset_configuration!` replays that block onto the fresh Configuration,
|
|
26
|
+
# so every example starts from your app's real configuration. There is
|
|
27
|
+
# nothing to re-apply by hand in a `before` hook.
|
|
14
28
|
#
|
|
15
29
|
# The memoized `Subscriber` and `EventSubscriber` instances are *not*
|
|
16
30
|
# torn down here — they are wired up at engine boot via initializers and
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
3
|
+
module StandardAudit
|
|
4
|
+
# Answers "is this redaction rule safe for MY data?" against the rows an app
|
|
5
|
+
# already has, before the rule is switched on.
|
|
6
|
+
#
|
|
7
|
+
# This matters more here than in most gems: audit rows are append-only, so a
|
|
8
|
+
# rule that swallows real audit content cannot be undone after the fact — the
|
|
9
|
+
# content is simply never written from then on. Reading the historical rows
|
|
10
|
+
# first turns the judgement call into a command.
|
|
11
|
+
#
|
|
12
|
+
# Keys are extracted **in Ruby**, not with `jsonb_object_keys`, so this works
|
|
13
|
+
# against SQLite (the dummy app), MySQL, and Postgres alike. The install
|
|
14
|
+
# template's migration uses jsonb + GIN, but the gem itself stays
|
|
15
|
+
# backend-neutral.
|
|
16
|
+
#
|
|
17
|
+
# StandardAudit::SensitiveKeysDryRun.call(sensitive_key_patterns: [/secret/i])
|
|
18
|
+
# # => #<Report rows_scanned=1204 stripped={"client_secret"=>18, ...} ...>
|
|
19
|
+
#
|
|
20
|
+
class SensitiveKeysDryRun
|
|
21
|
+
# [rows_scanned] how many audit rows were read
|
|
22
|
+
# [stripped] key path => row count, for keys the candidate rule
|
|
23
|
+
# would redact
|
|
24
|
+
# [kept] key path => row count, for keys it would keep
|
|
25
|
+
# [nested_unfiltered] key path => row count, for *nested* keys the rule
|
|
26
|
+
# matches but which are NOT redacted because
|
|
27
|
+
# `filter_nested_metadata` is off. This is the
|
|
28
|
+
# exposure `filter_nested_metadata` exists to close;
|
|
29
|
+
# empty when nested filtering is enabled.
|
|
30
|
+
Report = Struct.new(:rows_scanned, :stripped, :kept, :nested_unfiltered, :nested, keyword_init: true) do
|
|
31
|
+
def any_stripped?
|
|
32
|
+
stripped.any?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_s
|
|
36
|
+
lines = []
|
|
37
|
+
lines << "Rows scanned: #{rows_scanned}"
|
|
38
|
+
lines << "Nested filtering: #{nested ? 'on' : 'off'}"
|
|
39
|
+
lines << ""
|
|
40
|
+
|
|
41
|
+
lines << section("WOULD BE STRIPPED", stripped, "Nothing would be stripped.")
|
|
42
|
+
lines << ""
|
|
43
|
+
lines << section("KEPT", kept, "No metadata keys found.")
|
|
44
|
+
|
|
45
|
+
if nested_unfiltered.any?
|
|
46
|
+
lines << ""
|
|
47
|
+
lines << section(
|
|
48
|
+
"MATCHES BUT NOT STRIPPED (nested; set config.filter_nested_metadata = true to redact)",
|
|
49
|
+
nested_unfiltered,
|
|
50
|
+
nil
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
lines.join("\n")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def section(title, counts, empty_message)
|
|
60
|
+
out = [title, "=" * title.length]
|
|
61
|
+
|
|
62
|
+
if counts.empty?
|
|
63
|
+
out << (empty_message || "None.")
|
|
64
|
+
else
|
|
65
|
+
width = counts.keys.map(&:length).max
|
|
66
|
+
counts.sort_by { |key, count| [-count, key] }.each do |key, count|
|
|
67
|
+
out << format(" %-#{width}s %d row(s)", key, count)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
out.join("\n")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class << self
|
|
76
|
+
# Every keyword defaults to the app's live configuration, so calling it
|
|
77
|
+
# with no arguments reports what the *current* config does.
|
|
78
|
+
def call(sensitive_keys: nil, sensitive_key_patterns: nil, sensitive_key_exceptions: nil,
|
|
79
|
+
nested: nil, relation: nil, batch_size: 1000)
|
|
80
|
+
new(
|
|
81
|
+
sensitive_keys: sensitive_keys,
|
|
82
|
+
sensitive_key_patterns: sensitive_key_patterns,
|
|
83
|
+
sensitive_key_exceptions: sensitive_key_exceptions,
|
|
84
|
+
nested: nested
|
|
85
|
+
).call(relation: relation, batch_size: batch_size)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def initialize(sensitive_keys: nil, sensitive_key_patterns: nil, sensitive_key_exceptions: nil, nested: nil)
|
|
90
|
+
live = StandardAudit.config
|
|
91
|
+
|
|
92
|
+
candidate = Configuration.new
|
|
93
|
+
candidate.sensitive_keys = sensitive_keys || live.sensitive_keys
|
|
94
|
+
candidate.sensitive_key_patterns = sensitive_key_patterns || live.sensitive_key_patterns
|
|
95
|
+
candidate.sensitive_key_exceptions = sensitive_key_exceptions || live.sensitive_key_exceptions
|
|
96
|
+
|
|
97
|
+
@nested = nested.nil? ? live.filter_nested_metadata : nested
|
|
98
|
+
@filter = MetadataFilter.new(config: candidate)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def call(relation: nil, batch_size: 1000)
|
|
102
|
+
relation ||= StandardAudit::AuditLog.all
|
|
103
|
+
|
|
104
|
+
rows = 0
|
|
105
|
+
stripped = Hash.new(0)
|
|
106
|
+
kept = Hash.new(0)
|
|
107
|
+
nested_unfiltered = Hash.new(0)
|
|
108
|
+
|
|
109
|
+
relation.select(:id, :metadata).find_each(batch_size: batch_size) do |log|
|
|
110
|
+
rows += 1
|
|
111
|
+
metadata = log.metadata
|
|
112
|
+
next unless metadata.respond_to?(:each_pair)
|
|
113
|
+
|
|
114
|
+
# Collect distinct paths per row first, then increment once each. A row
|
|
115
|
+
# whose `charges[]` array holds two matching hashes is ONE affected row,
|
|
116
|
+
# not two — the counts are documented as row counts.
|
|
117
|
+
row = { stripped: Set.new, kept: Set.new, nested_unfiltered: Set.new }
|
|
118
|
+
walk(metadata, nil, row, top_level: true)
|
|
119
|
+
|
|
120
|
+
row[:stripped].each { |path| stripped[path] += 1 }
|
|
121
|
+
row[:kept].each { |path| kept[path] += 1 }
|
|
122
|
+
row[:nested_unfiltered].each { |path| nested_unfiltered[path] += 1 }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
Report.new(
|
|
126
|
+
rows_scanned: rows,
|
|
127
|
+
stripped: stripped,
|
|
128
|
+
kept: kept,
|
|
129
|
+
nested_unfiltered: nested_unfiltered,
|
|
130
|
+
nested: @nested
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def walk(hash, prefix, row, top_level:)
|
|
137
|
+
hash.each_pair do |key, value|
|
|
138
|
+
name = key.to_s
|
|
139
|
+
path = prefix ? "#{prefix}.#{name}" : name
|
|
140
|
+
|
|
141
|
+
# Reserved keys are immune at every depth, and their subtree is
|
|
142
|
+
# gem-owned — never descended into. Mirrors MetadataFilter exactly, so
|
|
143
|
+
# the dry run cannot misrepresent the rule it is modelling.
|
|
144
|
+
if StandardAudit::RESERVED_METADATA_KEYS.include?(name)
|
|
145
|
+
row[:kept] << path
|
|
146
|
+
next
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
matches = @filter.filter?(name)
|
|
150
|
+
|
|
151
|
+
if matches && (top_level || @nested)
|
|
152
|
+
row[:stripped] << path
|
|
153
|
+
next
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# A nested match that survives because nested filtering is off. Reported
|
|
157
|
+
# separately rather than silently counted as "kept" — this is exactly
|
|
158
|
+
# the leak `filter_nested_metadata` closes.
|
|
159
|
+
if matches
|
|
160
|
+
row[:nested_unfiltered] << path
|
|
161
|
+
else
|
|
162
|
+
row[:kept] << path
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
descend(value, path, row)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def descend(value, path, row)
|
|
170
|
+
if value.respond_to?(:each_pair)
|
|
171
|
+
walk(value, path, row, top_level: false)
|
|
172
|
+
elsif value.is_a?(Array)
|
|
173
|
+
value.each { |element| descend(element, "#{path}[]", row) }
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -79,9 +79,11 @@ module StandardAudit
|
|
|
79
79
|
raw_metadata = config.metadata_builder.call(raw_metadata)
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
# Redaction lives in MetadataFilter, shared with StandardAudit.record.
|
|
83
|
+
# This path previously carried its own copy that did *not* subtract
|
|
84
|
+
# RESERVED_METADATA_KEYS, so `_tags`/`_source` were strippable here and
|
|
85
|
+
# not there.
|
|
86
|
+
StandardAudit::MetadataFilter.call(raw_metadata, config: config)
|
|
85
87
|
end
|
|
86
88
|
end
|
|
87
89
|
end
|
data/lib/standard_audit.rb
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
require "standard_audit/version"
|
|
2
2
|
require "standard_audit/engine"
|
|
3
3
|
require "standard_audit/configuration"
|
|
4
|
+
require "standard_audit/metadata_filter"
|
|
5
|
+
require "standard_audit/sensitive_keys_dry_run"
|
|
4
6
|
require "standard_audit/subscriber"
|
|
5
7
|
require "standard_audit/event_subscriber"
|
|
8
|
+
require "standard_audit/reference_preloading"
|
|
6
9
|
require "standard_audit/auditable"
|
|
7
10
|
require "standard_audit/audit_scope"
|
|
8
11
|
require "standard_audit/checks/retention"
|
|
@@ -13,8 +16,30 @@ module StandardAudit
|
|
|
13
16
|
RESERVED_METADATA_KEYS = %w[_tags _source].freeze
|
|
14
17
|
|
|
15
18
|
class << self
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
# Applies configuration to the single mutable Configuration instance.
|
|
20
|
+
#
|
|
21
|
+
# `baseline: true` also *remembers* the block, so `reset_configuration!`
|
|
22
|
+
# replays it. That matters because the config object holds behaviour, not
|
|
23
|
+
# just data — `before_checksum_hooks` in particular. Without a baseline, a
|
|
24
|
+
# suite that installs the rspec plugin (which calls `reset_configuration!`
|
|
25
|
+
# before every example) silently loses write-time hooks after the first
|
|
26
|
+
# example, and the specs that would notice pass vacuously.
|
|
27
|
+
#
|
|
28
|
+
# Idiomatic host usage, in config/initializers/standard_audit.rb:
|
|
29
|
+
#
|
|
30
|
+
# StandardAudit.configure(baseline: true) do |config|
|
|
31
|
+
# config.subscribe_to "myapp.**"
|
|
32
|
+
# config.before_checksum :backfill_scope
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# Only the most recent `baseline: true` block is remembered; call it once,
|
|
36
|
+
# from the initializer.
|
|
37
|
+
def configure(baseline: false, &block)
|
|
38
|
+
return config unless block
|
|
39
|
+
|
|
40
|
+
@baseline_configuration = block if baseline
|
|
41
|
+
block.call(config)
|
|
42
|
+
config
|
|
18
43
|
end
|
|
19
44
|
|
|
20
45
|
def config
|
|
@@ -26,10 +51,9 @@ module StandardAudit
|
|
|
26
51
|
|
|
27
52
|
actor ||= config.current_actor_resolver.call
|
|
28
53
|
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
filtered_metadata = metadata.reject { |k, _| sensitive.include?(k.to_s) }
|
|
54
|
+
# Redaction lives in MetadataFilter, shared with Subscriber, so the two
|
|
55
|
+
# write paths cannot drift apart.
|
|
56
|
+
filtered_metadata = MetadataFilter.call(metadata, config: config)
|
|
33
57
|
|
|
34
58
|
attrs = {
|
|
35
59
|
event_type: event_type,
|
|
@@ -101,8 +125,24 @@ module StandardAudit
|
|
|
101
125
|
@event_subscriber ||= EventSubscriber.new
|
|
102
126
|
end
|
|
103
127
|
|
|
104
|
-
|
|
128
|
+
# Drops the memoized Configuration. Any block registered with
|
|
129
|
+
# `configure(baseline: true)` is replayed onto the fresh instance, so a
|
|
130
|
+
# per-example reset restores the app's real configuration rather than the
|
|
131
|
+
# gem defaults.
|
|
132
|
+
def reset_configuration!(replay_baseline: true)
|
|
105
133
|
@configuration = nil
|
|
134
|
+
@baseline_configuration&.call(config) if replay_baseline
|
|
135
|
+
config
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Forgets the `configure(baseline: true)` block. Mainly for the gem's own
|
|
139
|
+
# specs and for a host that needs a genuinely pristine configuration.
|
|
140
|
+
def clear_baseline_configuration!
|
|
141
|
+
@baseline_configuration = nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def baseline_configured?
|
|
145
|
+
!@baseline_configuration.nil?
|
|
106
146
|
end
|
|
107
147
|
|
|
108
148
|
private
|
|
@@ -79,6 +79,47 @@ namespace :standard_audit do
|
|
|
79
79
|
puts "Backfilled checksums for #{count} audit log records"
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
namespace :sensitive_keys do
|
|
83
|
+
desc "Report which historical metadata keys a redaction rule would strip (read-only)"
|
|
84
|
+
task :dry_run, [:pattern] => :environment do |_t, args|
|
|
85
|
+
# Audit rows are append-only, so a redaction rule that swallows real
|
|
86
|
+
# content cannot be undone. This reads the rows you already have and
|
|
87
|
+
# reports, per key, what the rule would have stripped.
|
|
88
|
+
#
|
|
89
|
+
# rake standard_audit:sensitive_keys:dry_run
|
|
90
|
+
# rake "standard_audit:sensitive_keys:dry_run[secret]"
|
|
91
|
+
# NESTED=1 rake "standard_audit:sensitive_keys:dry_run[secret|token]"
|
|
92
|
+
#
|
|
93
|
+
# The argument is a Regexp source, matched case-insensitively, and is
|
|
94
|
+
# applied *in addition to* the app's configured sensitive_keys.
|
|
95
|
+
# NESTED=1/0 overrides config.filter_nested_metadata for the run.
|
|
96
|
+
pattern = args[:pattern].presence || ENV["PATTERN"].presence
|
|
97
|
+
patterns = StandardAudit.config.sensitive_key_patterns.dup
|
|
98
|
+
patterns << Regexp.new(pattern, Regexp::IGNORECASE) if pattern
|
|
99
|
+
|
|
100
|
+
nested =
|
|
101
|
+
case ENV["NESTED"]
|
|
102
|
+
when nil, "" then nil
|
|
103
|
+
when "0", "false" then false
|
|
104
|
+
else true
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
report = StandardAudit::SensitiveKeysDryRun.call(
|
|
108
|
+
sensitive_key_patterns: patterns,
|
|
109
|
+
nested: nested,
|
|
110
|
+
batch_size: (ENV["BATCH_SIZE"] || 1000).to_i
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
puts "StandardAudit sensitive-key dry run"
|
|
114
|
+
puts "==================================="
|
|
115
|
+
puts "Candidate pattern: #{pattern ? Regexp.new(pattern, Regexp::IGNORECASE).inspect : '(none — reporting current config)'}"
|
|
116
|
+
puts ""
|
|
117
|
+
puts report
|
|
118
|
+
puts ""
|
|
119
|
+
puts "Nothing was written. Rows are append-only; a rule you enable applies only to future writes."
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
82
123
|
desc "Export audit logs for a specific actor (GDPR right to access)"
|
|
83
124
|
task :export_actor, [:actor_gid, :output] => :environment do |_t, args|
|
|
84
125
|
raise "actor_gid is required" unless args[:actor_gid].present?
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: standard_audit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -109,7 +109,10 @@ files:
|
|
|
109
109
|
- lib/standard_audit/configuration.rb
|
|
110
110
|
- lib/standard_audit/engine.rb
|
|
111
111
|
- lib/standard_audit/event_subscriber.rb
|
|
112
|
+
- lib/standard_audit/metadata_filter.rb
|
|
113
|
+
- lib/standard_audit/reference_preloading.rb
|
|
112
114
|
- lib/standard_audit/rspec.rb
|
|
115
|
+
- lib/standard_audit/sensitive_keys_dry_run.rb
|
|
113
116
|
- lib/standard_audit/subscriber.rb
|
|
114
117
|
- lib/standard_audit/version.rb
|
|
115
118
|
- lib/tasks/standard_audit_tasks.rake
|