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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -0
- data/README.md +259 -14
- data/app/models/standard_audit/audit_log.rb +87 -10
- data/lib/generators/standard_audit/add_anonymized_at/add_anonymized_at_generator.rb +20 -0
- data/lib/generators/standard_audit/add_anonymized_at/templates/add_anonymized_at_to_audit_logs.rb.erb +15 -0
- data/lib/generators/standard_audit/add_checksums/add_checksums_generator.rb +14 -0
- data/lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb +3 -0
- data/lib/standard_audit/configuration.rb +43 -6
- data/lib/standard_audit/event_subscriber.rb +18 -17
- data/lib/standard_audit/rspec/baseline.rb +81 -0
- data/lib/standard_audit/rspec/matchers.rb +187 -0
- data/lib/standard_audit/rspec.rb +7 -0
- data/lib/standard_audit/subscriber.rb +22 -63
- data/lib/standard_audit/version.rb +1 -1
- data/lib/standard_audit.rb +132 -50
- data/lib/tasks/standard_audit_tasks.rake +33 -4
- metadata +5 -1
data/lib/standard_audit.rb
CHANGED
|
@@ -48,71 +48,92 @@ module StandardAudit
|
|
|
48
48
|
@configuration ||= Configuration.new
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
#
|
|
58
|
-
#
|
|
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
|
-
))
|
|
65
|
-
yield
|
|
66
|
-
end
|
|
77
|
+
), &block)
|
|
67
78
|
return
|
|
68
79
|
end
|
|
69
80
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
115
|
-
# and
|
|
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
|
|
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
|
|
|
@@ -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.
|
|
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
|