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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7fca49bbca23bc24c89aa7249f0ea4718c2f4fa6bae03748ec93a45df17c4bd0
4
- data.tar.gz: 7920932fea8af270498c7620d7cb4e18e0cfaab1c0deb7f299715ce578882dd2
3
+ metadata.gz: 697434bf73f4588971d9fda7503f13f53a848ab4fe058fbc05be5b567a186d6f
4
+ data.tar.gz: e5bad881dc6a5ab13ae22d13777176120967161f40356dcd6522ab4616492c1f
5
5
  SHA512:
6
- metadata.gz: 7117529ed69a81588ea18b081e3b8a8047dc9441fe116bef9e594beacc692108a1c8d625e2d4c1a959ac01a1d03857d9d94e91a4dfe65c52c35ce894b980e797
7
- data.tar.gz: bdb36b281136e0928c4bac601bbee408de0cf040d70222c8d3339153c53312e1bd1820f136488086184426f68ce9f930b75d521b75fd25950f4f7b3bb232c66d
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. For financial/legal domains that is usually what you want;
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 (default: retention_days config or 90)
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
- def self.anonymize_actor!(record)
96
- gid = record.to_global_id.to_s
95
+ # `subject` may be a record, a GlobalID, or a GlobalID string
96
+ # ("gid://app/User/1"). A string is parsed, never located, so erasure and
97
+ # export still work after the subject's own row has been deleted.
98
+ 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(record)
126
- gid = record.to_global_id.to_s
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
@@ -1,3 +1,3 @@
1
1
  module StandardAudit
2
- VERSION = "0.10.0"
2
+ VERSION = "0.11.1"
3
3
  end
@@ -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
- filtered_metadata = MetadataFilter.call(metadata, config: config)
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 specified days (default: 90)"
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] || StandardAudit.config.retention_days || 90).to_i
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] || 90).to_i
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.10.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