standard_audit 0.11.0 → 0.12.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.
@@ -48,71 +48,92 @@ module StandardAudit
48
48
  @configuration ||= Configuration.new
49
49
  end
50
50
 
51
- def record(event_type, actor: nil, target: nil, scope: nil, metadata: {}, **options)
51
+ # Writes one audit row. Every write path in the gem ends here — direct
52
+ # calls, `Auditable#record_audit`, `Operation#audit!`, and both event
53
+ # subscribers — so actor/scope resolution, `metadata_builder`,
54
+ # `before_write`, redaction, batching and async all apply uniformly.
55
+ #
56
+ # Block form instruments `event_type` via ActiveSupport::Notifications
57
+ # around the block and lets the Subscriber write the row (which lands back
58
+ # here), so it records only when the event is subscribed to.
59
+ #
60
+ # `raise: false` makes a failed write non-fatal: the error is logged and
61
+ # reported to `Rails.error` as handled, and nil is returned. For call
62
+ # sites where a missing audit row must never break the request (auth
63
+ # failure logging, say). It governs only the audit write — in block form
64
+ # the subscriber already rescues, and the block's own errors always
65
+ # propagate.
66
+ def record(event_type, actor: nil, target: nil, scope: nil, metadata: {}, **options, &block)
67
+ raise_errors = options.key?(:raise) ? options.delete(:raise) : true
52
68
  return unless config.enabled
53
69
 
54
- actor ||= config.current_actor_resolver.call
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.
70
+ if block
71
+ actor ||= config.current_actor_resolver.call
72
+ # Nothing is built here: the Subscriber writes the row through
73
+ # `write_entry`. Dereferencing a Relation now would load it eagerly,
74
+ # before the block has run, purely to discard the result.
62
75
  ActiveSupport::Notifications.instrument(event_type, metadata.merge(
63
76
  actor: actor, target: target, scope: scope
64
- )) do
65
- yield
66
- end
77
+ ), &block)
67
78
  return
68
79
  end
69
80
 
70
- # Redaction lives in MetadataFilter, shared with Subscriber, so the two
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)
81
+ begin
82
+ write_entry(event_type, actor: actor, target: target, scope: scope,
83
+ metadata: metadata, context: options)
84
+ rescue => e
85
+ raise if raise_errors
76
86
 
77
- attrs = {
87
+ report_write_error(e, event_type, source: "StandardAudit.record")
88
+ nil
89
+ end
90
+ end
91
+
92
+ # @api private — the single write path shared by `record` and the two
93
+ # subscribers. Hosts call `record`.
94
+ #
95
+ # `context` carries explicit request_id/ip_address/user_agent/session_id
96
+ # values; nil entries fall back to the Current resolvers. `reserved` is
97
+ # merged into metadata AFTER `metadata_builder` (the Rails.event subscriber
98
+ # uses it for `_tags` / `_source`, which a builder never saw before 0.12).
99
+ def write_entry(event_type, actor:, target:, scope:, metadata:, context: {}, reserved: {})
100
+ actor ||= config.current_actor_resolver.call
101
+ scope ||= config.current_scope_resolver&.call
102
+
103
+ metadata = (metadata || {}).dup
104
+ metadata = config.metadata_builder.call(metadata) if config.metadata_builder
105
+ metadata = metadata.merge(reserved) if reserved.present?
106
+
107
+ entry = {
78
108
  event_type: event_type,
79
- occurred_at: Time.current,
80
- request_id: options[:request_id] || config.current_request_id_resolver.call,
81
- ip_address: options[:ip_address] || config.current_ip_address_resolver.call,
82
- user_agent: options[:user_agent] || config.current_user_agent_resolver.call,
83
- session_id: options[:session_id] || config.current_session_id_resolver.call,
84
- metadata: filtered_metadata
109
+ actor: actor,
110
+ target: target,
111
+ scope: scope,
112
+ metadata: metadata,
113
+ request_id: context[:request_id] || config.current_request_id_resolver.call,
114
+ ip_address: context[:ip_address] || config.current_ip_address_resolver.call,
115
+ user_agent: context[:user_agent] || config.current_user_agent_resolver.call,
116
+ session_id: context[:session_id] || config.current_session_id_resolver.call
85
117
  }
86
118
 
87
- gid_attrs = {
88
- actor_gid: actor&.to_global_id&.to_s,
89
- actor_type: actor&.class&.name,
90
- target_gid: target&.to_global_id&.to_s,
91
- target_type: target&.class&.name,
92
- scope_gid: scope&.to_global_id&.to_s,
93
- scope_type: scope&.class&.name
94
- }
119
+ # Runs on EVERY path, before redaction, so anything it injects into
120
+ # metadata is still subject to `sensitive_keys` and dereferencing. It may
121
+ # mutate `entry` in place; its return value is ignored. It may raise —
122
+ # that is how a host guard rejects a write — and the error propagates
123
+ # exactly as a failed save would.
124
+ config.before_write&.call(entry)
95
125
 
96
- if batching?
97
- Thread.current[:standard_audit_batch] << attrs.merge(gid_attrs)
98
- nil
99
- elsif config.async
100
- StandardAudit::CreateAuditLogJob.perform_later(attrs.merge(gid_attrs).stringify_keys)
101
- else
102
- log = StandardAudit::AuditLog.new(attrs)
103
- log.actor = actor
104
- log.target = target
105
- log.scope = scope
106
- log.save!
107
- log
108
- end
126
+ persist(entry)
109
127
  end
110
128
 
111
129
  # Buffers record calls and flushes them via insert_all! on block exit.
112
130
  # If the block raises, buffered records are dropped — only successful
113
131
  # batches are persisted. Nested batches flush independently.
114
- # Block-form record calls (with AS::Notifications) bypass the buffer
115
- # and are processed normally since they don't persist records directly.
132
+ # Every write path buffers here, including events delivered by the
133
+ # ActiveSupport::Notifications and Rails.event subscribers (the former
134
+ # bypassed the buffer before 0.12.0). `before_write` runs before buffering,
135
+ # and `before_checksum` hooks run at flush time, before each row's checksum
136
+ # is computed — the same pipeline as a non-batched write.
116
137
  # Note: uses Thread.current for storage, which is not fiber-safe.
117
138
  # Apps using async adapters (Falcon) should avoid concurrent batches.
118
139
  def batch
@@ -126,6 +147,22 @@ module StandardAudit
126
147
  Thread.current[:standard_audit_batch] = previous
127
148
  end
128
149
 
150
+ # @api private — logs a failed audit write and reports it to Rails.error
151
+ # as handled. Used by the subscribers, which must never let an audit
152
+ # failure break the instrumented code path.
153
+ def report_write_error(error, event_type, **context)
154
+ Rails.logger.error("[StandardAudit] Error creating audit log for #{event_type}: #{error.class}: #{error.message}")
155
+ return unless Rails.respond_to?(:error) && Rails.error
156
+
157
+ Rails.error.report(
158
+ error,
159
+ handled: true,
160
+ context: { config.audit_error_context_key => event_type, **context }
161
+ )
162
+ rescue => report_failure
163
+ Rails.logger.error("[StandardAudit] Error reporting audit failure: #{report_failure.class}: #{report_failure.message}")
164
+ end
165
+
129
166
  def subscriber
130
167
  @subscriber ||= Subscriber.new
131
168
  end
@@ -156,6 +193,51 @@ module StandardAudit
156
193
 
157
194
  private
158
195
 
196
+ def persist(entry)
197
+ actor = entry[:actor]
198
+ target = entry[:target]
199
+ scope = entry[:scope]
200
+
201
+ # Redaction lives in MetadataFilter and record dereferencing in
202
+ # RecordReference; both run here, once, for every write path.
203
+ metadata = entry[:metadata] || {}
204
+ metadata = RecordReference.call(metadata) if config.dereference_record_metadata
205
+ metadata = MetadataFilter.call(metadata, config: config)
206
+
207
+ attrs = {
208
+ event_type: entry[:event_type],
209
+ occurred_at: Time.current,
210
+ request_id: entry[:request_id],
211
+ ip_address: entry[:ip_address],
212
+ user_agent: entry[:user_agent],
213
+ session_id: entry[:session_id],
214
+ metadata: metadata
215
+ }
216
+
217
+ gid_attrs = {
218
+ actor_gid: actor&.to_global_id&.to_s,
219
+ actor_type: actor&.class&.name,
220
+ target_gid: target&.to_global_id&.to_s,
221
+ target_type: target&.class&.name,
222
+ scope_gid: scope&.to_global_id&.to_s,
223
+ scope_type: scope&.class&.name
224
+ }
225
+
226
+ if batching?
227
+ Thread.current[:standard_audit_batch] << attrs.merge(gid_attrs)
228
+ nil
229
+ elsif config.async
230
+ StandardAudit::CreateAuditLogJob.perform_later(attrs.merge(gid_attrs).stringify_keys)
231
+ else
232
+ log = StandardAudit::AuditLog.new(attrs)
233
+ log.actor = actor
234
+ log.target = target
235
+ log.scope = scope
236
+ log.save!
237
+ log
238
+ end
239
+ end
240
+
159
241
  def batching?
160
242
  Thread.current[:standard_audit_batch].is_a?(Array)
161
243
  end
@@ -173,11 +255,11 @@ module StandardAudit
173
255
  ids = buffer.size.times.map { SecureRandom.uuid_v7 }.sort
174
256
 
175
257
  rows = buffer.each_with_index.map do |attrs, i|
176
- row = attrs.merge(
258
+ row = StandardAudit::AuditLog.apply_before_checksum_hooks(attrs.merge(
177
259
  id: ids[i],
178
260
  created_at: now,
179
261
  updated_at: now
180
- )
262
+ ))
181
263
  checksum = StandardAudit::AuditLog.compute_checksum_value(
182
264
  row.stringify_keys,
183
265
  previous_checksum: previous_checksum
@@ -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
 
@@ -64,6 +92,7 @@ namespace :standard_audit do
64
92
  puts "Records verified: #{result[:verified]}"
65
93
  puts "Chain valid: #{result[:valid]}"
66
94
  puts "Forked links recovered: #{result[:recovered]}"
95
+ puts "Anonymized (redacted) records: #{result[:redacted]}" if result[:redacted].to_i.positive?
67
96
 
68
97
  if result[:failures].any?
69
98
  puts "\nUnverifiable records detected: #{result[:failures].size}"
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.11.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -97,6 +97,8 @@ files:
97
97
  - app/models/standard_audit/application_record.rb
98
98
  - app/models/standard_audit/audit_log.rb
99
99
  - config/routes.rb
100
+ - lib/generators/standard_audit/add_anonymized_at/add_anonymized_at_generator.rb
101
+ - lib/generators/standard_audit/add_anonymized_at/templates/add_anonymized_at_to_audit_logs.rb.erb
100
102
  - lib/generators/standard_audit/add_checksums/add_checksums_generator.rb
101
103
  - lib/generators/standard_audit/add_checksums/templates/add_checksum_to_audit_logs.rb.erb
102
104
  - lib/generators/standard_audit/add_previous_checksum/add_previous_checksum_generator.rb
@@ -117,6 +119,8 @@ files:
117
119
  - lib/standard_audit/record_reference.rb
118
120
  - lib/standard_audit/reference_preloading.rb
119
121
  - lib/standard_audit/rspec.rb
122
+ - lib/standard_audit/rspec/baseline.rb
123
+ - lib/standard_audit/rspec/matchers.rb
120
124
  - lib/standard_audit/rspec/operation.rb
121
125
  - lib/standard_audit/sensitive_keys_dry_run.rb
122
126
  - lib/standard_audit/subscriber.rb