standard_audit 0.10.0 → 0.11.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 +31 -0
- data/README.md +62 -3
- data/app/models/standard_audit/audit_log.rb +23 -4
- data/lib/standard_audit/configuration.rb +20 -0
- data/lib/standard_audit/event_subscriber.rb +17 -0
- data/lib/standard_audit/record_reference.rb +141 -0
- data/lib/standard_audit/subscriber.rb +27 -0
- data/lib/standard_audit/version.rb +1 -1
- data/lib/standard_audit.rb +20 -12
- data/lib/tasks/standard_audit_tasks.rake +32 -4
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 697434bf73f4588971d9fda7503f13f53a848ab4fe058fbc05be5b567a186d6f
|
|
4
|
+
data.tar.gz: e5bad881dc6a5ab13ae22d13777176120967161f40356dcd6522ab4616492c1f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: feb2b3354808c948aff042e0a3354ac2344c3e4ac1b03f0c375645ff7b1aaf1ec27253e87e764652ee7d52710a4c7aecd7748ba13475afa001a0c27f55a2ca79
|
|
7
|
+
data.tar.gz: a83422f4ef6b6ca319c29c450ae7cf82457cc5ad342345947a867e2243ef701c2bb0310a5b1d99f5cbf9c322b756f5094c42e77e53eb6ea3b828a834f94bfacc
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.11.1] - 2026-09-24
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`rake standard_audit:anonymize_actor[gid]` and `standard_audit:export_actor[gid]` no longer raise `NoMethodError`.** The tasks passed the GlobalID string straight into `AuditLog.anonymize_actor!` / `export_for_actor`, which called `to_global_id` on it. Both methods now accept a record, a `GlobalID`, or a GlobalID string. A string is parsed, not located, so erasure works after the subject's row has been deleted; an invalid string raises `ArgumentError`. What gets anonymized is unchanged.
|
|
15
|
+
- **`rake standard_audit:cleanup` no longer deletes logs older than 90 days when `retention_days` is nil.** nil means keep forever, but the task fell back to a hard-coded 90. `cleanup` and `archive` (which always defaulted to 90) now take the days argument, else `config.retention_days`, else abort with a message saying how to set one.
|
|
16
|
+
- **`cleanup`/`archive` reject a days value that is not a positive integer.** `cleanup[abc]` used to become `0` days, i.e. delete every row. `0`, negatives and non-numeric values now abort. `StandardAudit::CleanupJob` is unchanged.
|
|
17
|
+
- **`StandardAudit::Subscriber` and `StandardAudit::EventSubscriber` report swallowed errors to `Rails.error`.** A failed audit write was only logged, so it never reached error tracking. It is now also reported with `handled: true` and context `{ <config.audit_error_context_key> => event_name, subscriber: <class name> }`. The log line is kept.
|
|
18
|
+
|
|
19
|
+
## [0.11.0] - 2026-07-31
|
|
20
|
+
|
|
21
|
+
### Security
|
|
22
|
+
|
|
23
|
+
- **ActiveRecord objects in audit metadata are no longer written with all of their attributes.** Any record found in metadata — at any depth, and inside Arrays, Hashes and `ActiveRecord::Relation`s — is now replaced with a reference: `{ "gid" => "gid://app/Account/1", "type" => "Account", "id" => "1" }`. Applies to both write paths (`ActiveSupport::Notifications` and `StandardAudit.record`).
|
|
24
|
+
|
|
25
|
+
`standard_id` publishes live records under payload keys like `account:`, `current_account:`, `session:` and `code_challenge:`, and `Subscriber#extract_metadata` excluded only `actor`/`target`/`scope`/`request_id`/`ip_address`/`user_agent`/`session_id` — every other key was written whole. Confirmed present in real `audit_logs` rows: `account.password_digest`, `account.password_reset_token_digest`, `session.token_digest`, `session.lookup_hash`, `code_challenge.code`.
|
|
26
|
+
|
|
27
|
+
All three existing defences missed, each for a different reason: `sensitive_keys` matches key names exactly and the secrets are attributes *underneath* `account:`; `filter_nested_metadata` is off by default so the filter never descended to them; and `account:` does not look sensitive at the top level. This is not a redaction bug — key-based redaction is the wrong tool for a value that is an entire database row — so the value is replaced by type, before any key filtering runs.
|
|
28
|
+
|
|
29
|
+
**This fix stops the bleeding; it does not clean up.** `audit_logs` is append-only by design (`before_update` and `before_destroy` raise `ReadOnlyRecord`), so every row already written keeps its digests, for the whole of a retention window that is intentionally long. Upgrading changes only what is written from now on. Assessing and remediating existing rows is a separate, host-side exercise.
|
|
30
|
+
|
|
31
|
+
`sensitive_keys` semantics are untouched: still exact-match, still no substring mode. See rarebit-one/rarebit-ops#296.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **`config.dereference_record_metadata`** (default `true`) — the escape hatch for the above. It defaults to the SAFE behaviour, unlike `filter_nested_metadata`, because the values it catches are ones no host asked to record: they arrive as a side effect of a payload carrying `account:` or `session:`, and an append-only row cannot be walked back. Set it to `false` only if an app genuinely depends on record attributes in metadata and has satisfied itself no secret-bearing column can reach a row.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **Consumer-visible beyond the secrets disappearing:** any metadata key that used to hold a record's attribute hash now holds a three-key reference, so dashboards, reports or queries reading e.g. `metadata->'account'->>'email'` will read `NULL` on new rows (old rows are unchanged, which makes the change look like a data gap rather than a schema change). Recover the specific fields you need with `metadata_builder`, which runs BEFORE dereferencing and still receives the record: `->(metadata) { metadata.merge(account_email: metadata[:account]&.email) }`. `actor`/`target`/`scope` are unaffected — they were already stored as GlobalIDs.
|
|
40
|
+
|
|
10
41
|
## [0.10.0] - 2026-07-31
|
|
11
42
|
|
|
12
43
|
### Added
|
data/README.md
CHANGED
|
@@ -332,6 +332,10 @@ StandardAudit.configure(baseline: true) do |config|
|
|
|
332
332
|
# `metadata: { stripe: { client_secret: ... } }` is written intact.
|
|
333
333
|
config.filter_nested_metadata = true
|
|
334
334
|
|
|
335
|
+
# Replace ActiveRecord objects in metadata with a reference instead of
|
|
336
|
+
# serialising every attribute. ON by default; see "Records in metadata".
|
|
337
|
+
config.dereference_record_metadata = true
|
|
338
|
+
|
|
335
339
|
# -- Write-time hooks --
|
|
336
340
|
# Run between the UUID assignment and the checksum computation, so a hook MAY
|
|
337
341
|
# set a checksummed column and the row still passes `verify_chain`. No
|
|
@@ -495,6 +499,16 @@ This:
|
|
|
495
499
|
- Clears `ip_address`, `user_agent`, and `session_id`
|
|
496
500
|
- Removes metadata keys listed in `anonymizable_metadata_keys`
|
|
497
501
|
|
|
502
|
+
Both `anonymize_actor!` and `export_for_actor` accept the subject as a record,
|
|
503
|
+
a `GlobalID`, or a GlobalID string (`"gid://myapp/User/123"`). A string is
|
|
504
|
+
parsed, never located, so erasure still works after the user's own row has been
|
|
505
|
+
deleted — the usual order for an erasure request. Anything that is not a valid
|
|
506
|
+
GlobalID raises `ArgumentError`.
|
|
507
|
+
|
|
508
|
+
```ruby
|
|
509
|
+
StandardAudit::AuditLog.anonymize_actor!("gid://myapp/User/123")
|
|
510
|
+
```
|
|
511
|
+
|
|
498
512
|
### Right to Access (Export)
|
|
499
513
|
|
|
500
514
|
Export all audit data for a specific user:
|
|
@@ -521,7 +535,9 @@ STANDARD_AUDIT_RETENTION_DAYS=365 # keep 365 days
|
|
|
521
535
|
```
|
|
522
536
|
|
|
523
537
|
Infinite retention (the default) is the compliance-safe behavior: nothing is
|
|
524
|
-
ever auto-deleted.
|
|
538
|
+
ever auto-deleted. The `standard_audit:cleanup` and `standard_audit:archive`
|
|
539
|
+
rake tasks respect this: with no days argument and a nil `retention_days` they
|
|
540
|
+
abort rather than fall back to a default window. For financial/legal domains that is usually what you want;
|
|
525
541
|
enabling a finite window is a deliberate decision.
|
|
526
542
|
|
|
527
543
|
### Production retention warning (StandardHealth)
|
|
@@ -631,10 +647,12 @@ rake standard_audit:verify
|
|
|
631
647
|
# Record the parent digest each existing row was signed against
|
|
632
648
|
rake standard_audit:relink_checksums
|
|
633
649
|
|
|
634
|
-
# Delete logs older than N days
|
|
650
|
+
# Delete logs older than N days
|
|
635
651
|
rake standard_audit:cleanup[180]
|
|
652
|
+
# ...or older than config.retention_days
|
|
653
|
+
rake standard_audit:cleanup
|
|
636
654
|
|
|
637
|
-
# Archive old logs to a JSON file before deleting
|
|
655
|
+
# Archive old logs to a JSON file before deleting (same days rules)
|
|
638
656
|
rake standard_audit:archive[90,audit_backup.json]
|
|
639
657
|
|
|
640
658
|
# Show statistics
|
|
@@ -647,6 +665,15 @@ rake "standard_audit:anonymize_actor[gid://myapp/User/123]"
|
|
|
647
665
|
rake "standard_audit:export_actor[gid://myapp/User/123,export.json]"
|
|
648
666
|
```
|
|
649
667
|
|
|
668
|
+
`cleanup` and `archive` take the window from the days argument, else
|
|
669
|
+
`config.retention_days`; if neither is set they abort (a nil `retention_days`
|
|
670
|
+
means keep forever, so there is no implicit 90-day default). Days must be a
|
|
671
|
+
positive integer — `0`, negatives and non-numeric values abort instead of
|
|
672
|
+
deleting everything.
|
|
673
|
+
|
|
674
|
+
`anonymize_actor` and `export_actor` take a GlobalID string and work even after
|
|
675
|
+
the user record has been deleted.
|
|
676
|
+
|
|
650
677
|
## Database Support
|
|
651
678
|
|
|
652
679
|
The migration uses `json` column type by default, which works across:
|
|
@@ -695,6 +722,38 @@ Nested metadata is **not** redacted unless `filter_nested_metadata` is enabled
|
|
|
695
722
|
— `metadata: { stripe: { client_secret: ... } }` passes both write paths under
|
|
696
723
|
exact matching by default.
|
|
697
724
|
|
|
725
|
+
### Records in metadata
|
|
726
|
+
|
|
727
|
+
An ActiveRecord object appearing anywhere in metadata is replaced, by default,
|
|
728
|
+
with a reference rather than a snapshot of the row:
|
|
729
|
+
|
|
730
|
+
```ruby
|
|
731
|
+
{ account: account }
|
|
732
|
+
# => { account: { "gid" => "gid://myapp/Account/1", "type" => "Account", "id" => "1" } }
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
This applies at any depth, inside Arrays, Hashes and `ActiveRecord::Relation`s,
|
|
736
|
+
on both write paths. It is on by default because key-based redaction cannot
|
|
737
|
+
reach the problem: a payload key like `account:` looks like exactly the kind of
|
|
738
|
+
key you want on an audit row, while the value serialises with
|
|
739
|
+
`password_digest`, `token_digest`, `lookup_hash` and anything else the table
|
|
740
|
+
happens to hold. Audit rows are append-only, so an unsafe default cannot be
|
|
741
|
+
walked back.
|
|
742
|
+
|
|
743
|
+
On the notifications path, records are dereferenced **after**
|
|
744
|
+
`metadata_builder` runs, so a builder that needs real attributes still gets the
|
|
745
|
+
record (`metadata_builder` has never applied to a direct `StandardAudit.record`
|
|
746
|
+
call — pass the attributes you want in `metadata` there):
|
|
747
|
+
|
|
748
|
+
```ruby
|
|
749
|
+
config.metadata_builder = ->(metadata) {
|
|
750
|
+
metadata.merge(account_email: metadata[:account]&.email)
|
|
751
|
+
}
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
`config.dereference_record_metadata = false` restores the pre-0.11.0 behaviour
|
|
755
|
+
of writing full attributes. Prefer `metadata_builder` over turning it off.
|
|
756
|
+
|
|
698
757
|
**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.
|
|
699
758
|
|
|
700
759
|
**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.
|
|
@@ -92,8 +92,11 @@ module StandardAudit
|
|
|
92
92
|
|
|
93
93
|
# -- GDPR methods --
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
# `subject` may be a record, a GlobalID, or a GlobalID string
|
|
96
|
+
# ("gid://app/User/1"). A string is parsed, never located, so erasure and
|
|
97
|
+
# export still work after the subject's own row has been deleted.
|
|
98
|
+
def self.anonymize_actor!(subject)
|
|
99
|
+
gid = subject_gid_for(subject)
|
|
97
100
|
logs = where("actor_gid = ? OR target_gid = ?", gid, gid)
|
|
98
101
|
count = logs.count
|
|
99
102
|
|
|
@@ -122,8 +125,8 @@ module StandardAudit
|
|
|
122
125
|
count
|
|
123
126
|
end
|
|
124
127
|
|
|
125
|
-
def self.export_for_actor(
|
|
126
|
-
gid =
|
|
128
|
+
def self.export_for_actor(subject)
|
|
129
|
+
gid = subject_gid_for(subject)
|
|
127
130
|
logs = where("actor_gid = ? OR target_gid = ?", gid, gid).chronological
|
|
128
131
|
|
|
129
132
|
records = logs.map do |log|
|
|
@@ -149,6 +152,22 @@ module StandardAudit
|
|
|
149
152
|
}
|
|
150
153
|
end
|
|
151
154
|
|
|
155
|
+
# Normalises a GDPR subject to its GlobalID string. Parses rather than
|
|
156
|
+
# locates, so it works when the subject's row no longer exists.
|
|
157
|
+
def self.subject_gid_for(subject)
|
|
158
|
+
gid =
|
|
159
|
+
case subject
|
|
160
|
+
when GlobalID then subject
|
|
161
|
+
when String then GlobalID.parse(subject)
|
|
162
|
+
else subject.respond_to?(:to_global_id) ? subject.to_global_id : nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
raise ArgumentError, "expected a record, a GlobalID, or a GlobalID string (gid://app/Model/id), got #{subject.inspect}" unless gid
|
|
166
|
+
|
|
167
|
+
gid.to_s
|
|
168
|
+
end
|
|
169
|
+
private_class_method :subject_gid_for
|
|
170
|
+
|
|
152
171
|
# Recomputes the checksum from the record's current field values and the
|
|
153
172
|
# given previous checksum. Useful for verification without saving.
|
|
154
173
|
def compute_checksum_value(previous_checksum: nil)
|
|
@@ -7,6 +7,7 @@ module StandardAudit
|
|
|
7
7
|
:current_session_id_resolver,
|
|
8
8
|
:sensitive_keys, :sensitive_key_patterns,
|
|
9
9
|
:sensitive_key_exceptions, :filter_nested_metadata,
|
|
10
|
+
:dereference_record_metadata,
|
|
10
11
|
:metadata_builder, :before_checksum_hooks,
|
|
11
12
|
:anonymizable_metadata_keys, :retention_days,
|
|
12
13
|
:audit_catalogue, :verify_audit_declarations,
|
|
@@ -72,6 +73,25 @@ module StandardAudit
|
|
|
72
73
|
# append-only. Reserved keys are never descended into.
|
|
73
74
|
@filter_nested_metadata = false
|
|
74
75
|
|
|
76
|
+
# When true (the default), an ActiveRecord object appearing anywhere in
|
|
77
|
+
# audit metadata is replaced by a reference —
|
|
78
|
+
# `{ "gid" => …, "type" => …, "id" => … }` — instead of being serialised
|
|
79
|
+
# with all of its attributes. See StandardAudit::RecordReference.
|
|
80
|
+
#
|
|
81
|
+
# Defaults to the SAFE behaviour, unlike `filter_nested_metadata`,
|
|
82
|
+
# because the values this catches are ones no host asked to record:
|
|
83
|
+
# `password_digest`, `token_digest`, `lookup_hash` and PKCE verifiers
|
|
84
|
+
# arriving as a side effect of a payload carrying `account:` or
|
|
85
|
+
# `session:`. Audit rows are append-only, so an unsafe default cannot be
|
|
86
|
+
# walked back — the rows already written keep whatever was in them.
|
|
87
|
+
#
|
|
88
|
+
# Set to false ONLY if an app genuinely depends on record attributes in
|
|
89
|
+
# metadata and has satisfied itself that no secret-bearing column can
|
|
90
|
+
# reach a row. Preferred alternative: keep this on and use
|
|
91
|
+
# `metadata_builder` to pull the specific attributes you want, which runs
|
|
92
|
+
# BEFORE dereferencing and still sees the record.
|
|
93
|
+
@dereference_record_metadata = true
|
|
94
|
+
|
|
75
95
|
@metadata_builder = nil
|
|
76
96
|
|
|
77
97
|
# Callables (or Symbols naming an AuditLog instance method) run on
|
|
@@ -48,10 +48,27 @@ module StandardAudit
|
|
|
48
48
|
)
|
|
49
49
|
rescue => e
|
|
50
50
|
Rails.logger.error("[StandardAudit] Error handling Rails.event: #{e.class}: #{e.message}")
|
|
51
|
+
report_error(e, name)
|
|
51
52
|
end
|
|
52
53
|
|
|
53
54
|
private
|
|
54
55
|
|
|
56
|
+
# The event handler rescues so a failed audit write cannot break the
|
|
57
|
+
# instrumented code path, but a log line alone is invisible to error
|
|
58
|
+
# tracking — so report it as handled too.
|
|
59
|
+
def report_error(error, event_name)
|
|
60
|
+
return unless Rails.respond_to?(:error) && Rails.error
|
|
61
|
+
|
|
62
|
+
Rails.error.report(
|
|
63
|
+
error,
|
|
64
|
+
handled: true,
|
|
65
|
+
context: { StandardAudit.config.audit_error_context_key => event_name,
|
|
66
|
+
subscriber: self.class.name }
|
|
67
|
+
)
|
|
68
|
+
rescue => report_failure
|
|
69
|
+
Rails.logger.error("[StandardAudit] Error reporting audit failure: #{report_failure.class}: #{report_failure.message}")
|
|
70
|
+
end
|
|
71
|
+
|
|
55
72
|
def matches_subscription?(name)
|
|
56
73
|
StandardAudit.config.subscriptions.any? { |pattern| pattern_match?(pattern, name) }
|
|
57
74
|
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
module StandardAudit
|
|
2
|
+
# Replaces ActiveRecord objects found in audit metadata with a stable
|
|
3
|
+
# *reference* instead of a snapshot of the record's whole attribute set.
|
|
4
|
+
#
|
|
5
|
+
# == Why this exists
|
|
6
|
+
#
|
|
7
|
+
# `ActiveSupport::Notifications` payloads routinely carry live records —
|
|
8
|
+
# `standard_id` publishes `account:`, `current_account:`, `session:` and
|
|
9
|
+
# `code_challenge:` — and an AR object serialises with every attribute it
|
|
10
|
+
# has. That put `account.password_digest`,
|
|
11
|
+
# `account.password_reset_token_digest`, `session.token_digest`,
|
|
12
|
+
# `session.lookup_hash` and `code_challenge.code` into `audit_logs` rows
|
|
13
|
+
# across the estate (rarebit-one/rarebit-ops#296).
|
|
14
|
+
#
|
|
15
|
+
# None of the three existing defences fired: `sensitive_keys` matches keys
|
|
16
|
+
# exactly and the secrets are attributes *underneath* `account:`;
|
|
17
|
+
# `filter_nested_metadata` is off by default so the filter never descended
|
|
18
|
+
# to them; and `account:` does not look sensitive at the top level.
|
|
19
|
+
#
|
|
20
|
+
# Key-based redaction is the wrong tool here — the leak is not "this key is
|
|
21
|
+
# sensitive", it is "this value is an entire database row". So the value is
|
|
22
|
+
# replaced wholesale, by type, before any key filtering happens.
|
|
23
|
+
#
|
|
24
|
+
# == What replaces a record
|
|
25
|
+
#
|
|
26
|
+
# { "gid" => "gid://dummy/Account/1", "type" => "Account", "id" => "1" }
|
|
27
|
+
#
|
|
28
|
+
# A GlobalID string is the identifier this gem already uses for `actor`,
|
|
29
|
+
# `target` and `scope` (`actor_gid`), so an audit row stays resolvable with
|
|
30
|
+
# `GlobalID::Locator`. `type` and `id` ride along because they survive a
|
|
31
|
+
# record being deleted, an app whose GlobalID app name changes, and records
|
|
32
|
+
# that have no GlobalID at all (unpersisted ones): for those, `gid` is
|
|
33
|
+
# simply absent and the reference is still meaningful.
|
|
34
|
+
module RecordReference
|
|
35
|
+
# Written in place of a container that contains itself. Depth alone is NOT
|
|
36
|
+
# used as the guard: a plain depth cap would rewrite deeply-but-finitely
|
|
37
|
+
# nested metadata that holds no records at all, and permanently lose audit
|
|
38
|
+
# content in an append-only row to protect against a structure that is
|
|
39
|
+
# merely large. Only a genuine cycle — the one thing that cannot be
|
|
40
|
+
# serialised anyway — is replaced.
|
|
41
|
+
CIRCULAR = "[standard_audit: circular reference]".freeze
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
# Returns a copy of `value` with every ActiveRecord object — at any
|
|
45
|
+
# depth, inside Arrays, Hashes and Relations — replaced by a reference
|
|
46
|
+
# Hash. Structures containing no records are returned untouched (same
|
|
47
|
+
# object), so this is a no-op for ordinary metadata.
|
|
48
|
+
#
|
|
49
|
+
# Never mutates the input: notification payloads are shared with every
|
|
50
|
+
# other subscriber.
|
|
51
|
+
def call(value, ancestors = nil)
|
|
52
|
+
return reference_for(value) if record?(value)
|
|
53
|
+
|
|
54
|
+
container = relation?(value) || value.is_a?(Array) || hash_like?(value)
|
|
55
|
+
return value unless container
|
|
56
|
+
|
|
57
|
+
ancestors ||= {}.compare_by_identity
|
|
58
|
+
return CIRCULAR if ancestors.key?(value)
|
|
59
|
+
|
|
60
|
+
ancestors[value] = true
|
|
61
|
+
begin
|
|
62
|
+
if relation?(value)
|
|
63
|
+
map_collection(value.to_a, ancestors)
|
|
64
|
+
elsif value.is_a?(Array)
|
|
65
|
+
map_collection(value, ancestors)
|
|
66
|
+
else
|
|
67
|
+
map_hash(value, ancestors)
|
|
68
|
+
end
|
|
69
|
+
ensure
|
|
70
|
+
ancestors.delete(value)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The reference written in place of a record.
|
|
75
|
+
def reference_for(record)
|
|
76
|
+
{
|
|
77
|
+
"gid" => global_id_for(record),
|
|
78
|
+
"type" => record.class.name,
|
|
79
|
+
"id" => record.id&.to_s
|
|
80
|
+
}.compact
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def record?(value)
|
|
86
|
+
defined?(ActiveRecord::Base) && value.is_a?(ActiveRecord::Base)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def relation?(value)
|
|
90
|
+
defined?(ActiveRecord::Relation) && value.is_a?(ActiveRecord::Relation)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def hash_like?(value)
|
|
94
|
+
value.respond_to?(:each_pair) && value.respond_to?(:key?)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def map_collection(array, ancestors)
|
|
98
|
+
changed = false
|
|
99
|
+
mapped = array.map do |element|
|
|
100
|
+
replacement = call(element, ancestors)
|
|
101
|
+
changed ||= !replacement.equal?(element)
|
|
102
|
+
replacement
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
changed ? mapped : array
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def map_hash(hash, ancestors)
|
|
109
|
+
changed = false
|
|
110
|
+
mapped = {}
|
|
111
|
+
|
|
112
|
+
hash.each_pair do |key, value|
|
|
113
|
+
# Reserved keys are gem-owned (`_tags`, `_source`) and are left
|
|
114
|
+
# exactly alone here, as they are in MetadataFilter.
|
|
115
|
+
if StandardAudit::RESERVED_METADATA_KEYS.include?(key.to_s)
|
|
116
|
+
mapped[key] = value
|
|
117
|
+
next
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
replacement = call(value, ancestors)
|
|
121
|
+
changed ||= !replacement.equal?(value)
|
|
122
|
+
mapped[key] = replacement
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
changed ? mapped : hash
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# `to_global_id` raises for an unpersisted record and for a model that
|
|
129
|
+
# does not include GlobalID::Identification. Neither is a reason to fail
|
|
130
|
+
# an audit write, and neither is a reason to fall back to writing the
|
|
131
|
+
# attributes.
|
|
132
|
+
def global_id_for(record)
|
|
133
|
+
return nil unless record.respond_to?(:to_global_id)
|
|
134
|
+
|
|
135
|
+
record.to_global_id.to_s
|
|
136
|
+
rescue StandardError
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -25,6 +25,22 @@ module StandardAudit
|
|
|
25
25
|
|
|
26
26
|
private
|
|
27
27
|
|
|
28
|
+
# The event handler rescues so a failed audit write cannot break the
|
|
29
|
+
# instrumented code path, but a log line alone is invisible to error
|
|
30
|
+
# tracking — so report it as handled too.
|
|
31
|
+
def report_error(error, event_name)
|
|
32
|
+
return unless Rails.respond_to?(:error) && Rails.error
|
|
33
|
+
|
|
34
|
+
Rails.error.report(
|
|
35
|
+
error,
|
|
36
|
+
handled: true,
|
|
37
|
+
context: { StandardAudit.config.audit_error_context_key => event_name,
|
|
38
|
+
subscriber: self.class.name }
|
|
39
|
+
)
|
|
40
|
+
rescue => report_failure
|
|
41
|
+
Rails.logger.error("[StandardAudit] Error reporting audit failure: #{report_failure.class}: #{report_failure.message}")
|
|
42
|
+
end
|
|
43
|
+
|
|
28
44
|
def handle_event(event)
|
|
29
45
|
return unless StandardAudit.config.enabled
|
|
30
46
|
|
|
@@ -68,6 +84,7 @@ module StandardAudit
|
|
|
68
84
|
end
|
|
69
85
|
rescue => e
|
|
70
86
|
Rails.logger.error("[StandardAudit] Error creating audit log: #{e.class}: #{e.message}")
|
|
87
|
+
report_error(e, event.name)
|
|
71
88
|
end
|
|
72
89
|
|
|
73
90
|
def extract_metadata(payload, config)
|
|
@@ -79,6 +96,16 @@ module StandardAudit
|
|
|
79
96
|
raw_metadata = config.metadata_builder.call(raw_metadata)
|
|
80
97
|
end
|
|
81
98
|
|
|
99
|
+
# ActiveRecord objects in the payload are replaced by a reference BEFORE
|
|
100
|
+
# any key-based redaction, because the leak they cause is not a
|
|
101
|
+
# sensitive *key* — it is a value that serialises as an entire database
|
|
102
|
+
# row (rarebit-one/rarebit-ops#296). Runs after `metadata_builder` so a
|
|
103
|
+
# host that derives fields from a record (`payload[:account].email`)
|
|
104
|
+
# still sees the record.
|
|
105
|
+
if config.dereference_record_metadata
|
|
106
|
+
raw_metadata = StandardAudit::RecordReference.call(raw_metadata)
|
|
107
|
+
end
|
|
108
|
+
|
|
82
109
|
# Redaction lives in MetadataFilter, shared with StandardAudit.record.
|
|
83
110
|
# This path previously carried its own copy that did *not* subtract
|
|
84
111
|
# RESERVED_METADATA_KEYS, so `_tags`/`_source` were strippable here and
|
data/lib/standard_audit.rb
CHANGED
|
@@ -2,6 +2,7 @@ require "standard_audit/version"
|
|
|
2
2
|
require "standard_audit/engine"
|
|
3
3
|
require "standard_audit/configuration"
|
|
4
4
|
require "standard_audit/metadata_filter"
|
|
5
|
+
require "standard_audit/record_reference"
|
|
5
6
|
require "standard_audit/sensitive_keys_dry_run"
|
|
6
7
|
require "standard_audit/subscriber"
|
|
7
8
|
require "standard_audit/event_subscriber"
|
|
@@ -52,9 +53,26 @@ module StandardAudit
|
|
|
52
53
|
|
|
53
54
|
actor ||= config.current_actor_resolver.call
|
|
54
55
|
|
|
56
|
+
if block_given?
|
|
57
|
+
# Block form: instrument via ActiveSupport::Notifications and let the
|
|
58
|
+
# Subscriber write the row, which it does with its own dereferencing and
|
|
59
|
+
# filtering. Nothing built below would be used, so it is not built —
|
|
60
|
+
# dereferencing a Relation here would load it eagerly, before the block
|
|
61
|
+
# has run, purely to discard the result.
|
|
62
|
+
ActiveSupport::Notifications.instrument(event_type, metadata.merge(
|
|
63
|
+
actor: actor, target: target, scope: scope
|
|
64
|
+
)) do
|
|
65
|
+
yield
|
|
66
|
+
end
|
|
67
|
+
return
|
|
68
|
+
end
|
|
69
|
+
|
|
55
70
|
# Redaction lives in MetadataFilter, shared with Subscriber, so the two
|
|
56
|
-
# write paths cannot drift apart.
|
|
57
|
-
|
|
71
|
+
# write paths cannot drift apart. Record dereferencing is applied on both
|
|
72
|
+
# paths for the same reason: a snapshot of a whole row is as unrecoverable
|
|
73
|
+
# here as it is on the notifications path.
|
|
74
|
+
dereferenced = config.dereference_record_metadata ? RecordReference.call(metadata) : metadata
|
|
75
|
+
filtered_metadata = MetadataFilter.call(dereferenced, config: config)
|
|
58
76
|
|
|
59
77
|
attrs = {
|
|
60
78
|
event_type: event_type,
|
|
@@ -66,16 +84,6 @@ module StandardAudit
|
|
|
66
84
|
metadata: filtered_metadata
|
|
67
85
|
}
|
|
68
86
|
|
|
69
|
-
if block_given?
|
|
70
|
-
# Block form: instrument via ActiveSupport::Notifications
|
|
71
|
-
ActiveSupport::Notifications.instrument(event_type, metadata.merge(
|
|
72
|
-
actor: actor, target: target, scope: scope
|
|
73
|
-
)) do
|
|
74
|
-
yield
|
|
75
|
-
end
|
|
76
|
-
return
|
|
77
|
-
end
|
|
78
|
-
|
|
79
87
|
gid_attrs = {
|
|
80
88
|
actor_gid: actor&.to_global_id&.to_s,
|
|
81
89
|
actor_type: actor&.class&.name,
|
|
@@ -1,16 +1,44 @@
|
|
|
1
|
+
module StandardAudit
|
|
2
|
+
module RakeSupport
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
# Resolves the retention window for cleanup/archive: the task argument,
|
|
6
|
+
# else config.retention_days, else abort. nil retention means "keep
|
|
7
|
+
# forever", so there is deliberately no hard-coded fallback. Anything that
|
|
8
|
+
# is not a positive integer aborts — `cleanup[abc]` used to become 0 days,
|
|
9
|
+
# i.e. delete everything.
|
|
10
|
+
def resolve_days!(task_name, arg)
|
|
11
|
+
raw = arg.presence || StandardAudit.config.retention_days
|
|
12
|
+
|
|
13
|
+
if raw.nil?
|
|
14
|
+
abort "standard_audit:#{task_name}: no retention window. Pass days " \
|
|
15
|
+
"(rake \"standard_audit:#{task_name}[90]\") or set config.retention_days " \
|
|
16
|
+
"/ STANDARD_AUDIT_RETENTION_DAYS. A nil retention_days means keep forever."
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
days = Integer(raw.to_s.strip, 10, exception: false)
|
|
20
|
+
unless days&.positive?
|
|
21
|
+
abort "standard_audit:#{task_name}: days must be a positive integer, got #{raw.inspect}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
days
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
1
29
|
namespace :standard_audit do
|
|
2
|
-
desc "Delete audit logs older than
|
|
30
|
+
desc "Delete audit logs older than N days (default: config.retention_days; aborts if neither is set)"
|
|
3
31
|
task :cleanup, [:days] => :environment do |_t, args|
|
|
4
|
-
days = (args[:days]
|
|
32
|
+
days = StandardAudit::RakeSupport.resolve_days!("cleanup", args[:days])
|
|
5
33
|
cutoff = days.days.ago
|
|
6
34
|
|
|
7
35
|
deleted = StandardAudit::AuditLog.where("occurred_at < ?", cutoff).delete_all
|
|
8
36
|
puts "Deleted #{deleted} audit logs older than #{days} days"
|
|
9
37
|
end
|
|
10
38
|
|
|
11
|
-
desc "Archive audit logs to JSON file"
|
|
39
|
+
desc "Archive audit logs older than N days to a JSON file (default: config.retention_days; aborts if neither is set)"
|
|
12
40
|
task :archive, [:days, :output] => :environment do |_t, args|
|
|
13
|
-
days = (args[:days]
|
|
41
|
+
days = StandardAudit::RakeSupport.resolve_days!("archive", args[:days])
|
|
14
42
|
output = args[:output] || "audit_logs_archive_#{Date.current}.json"
|
|
15
43
|
cutoff = days.days.ago
|
|
16
44
|
|
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.11.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -114,6 +114,7 @@ files:
|
|
|
114
114
|
- lib/standard_audit/metadata_filter.rb
|
|
115
115
|
- lib/standard_audit/operation.rb
|
|
116
116
|
- lib/standard_audit/operation/audit.rb
|
|
117
|
+
- lib/standard_audit/record_reference.rb
|
|
117
118
|
- lib/standard_audit/reference_preloading.rb
|
|
118
119
|
- lib/standard_audit/rspec.rb
|
|
119
120
|
- lib/standard_audit/rspec/operation.rb
|