standard_audit 0.2.0 → 0.4.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 +36 -0
- data/README.md +30 -3
- data/app/models/standard_audit/audit_log.rb +108 -1
- data/lib/generators/standard_audit/add_checksums/add_checksums_generator.rb +16 -0
- data/lib/generators/standard_audit/add_checksums/templates/add_checksum_to_audit_logs.rb.erb +6 -0
- data/lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb +2 -0
- data/lib/standard_audit/engine.rb +6 -0
- data/lib/standard_audit/event_subscriber.rb +101 -0
- data/lib/standard_audit/subscriber.rb +1 -1
- data/lib/standard_audit/version.rb +1 -1
- data/lib/standard_audit.rb +34 -5
- data/lib/tasks/standard_audit_tasks.rake +24 -0
- metadata +8 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6a9a29ab9754c6dbc24134d000864e8d99f60966227691ee782b782423ac7dc7
|
|
4
|
+
data.tar.gz: 0ee9864f7b073c3376041facfafe575006179b443937883377a5bdda605bc81a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e156d464655ca40f791b064e264b18ac587b63819f454d8bc98b9b7a9168cbd02519795c14d0333cd148be191715d29498244d1218f505365a1ef669c6f591fe
|
|
7
|
+
data.tar.gz: 2816bb0d249b20d8464cde9655b5799d8b7525cba615b9a97528bd9ef78ca1b1254fd528bca77ac3390001bdf34adc384add86c5b924d43a2e93bf695620ae89
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-04-19
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Rails 8.1+ structured event reporter (`Rails.event`) integration. A new `StandardAudit::EventSubscriber` is registered automatically when `Rails.event` is available, so `Rails.event.notify("myapp.orders.created", actor: user, target: order)` persists an `AuditLog` the same way an `ActiveSupport::Notifications.instrument` call does. Event name is matched against the existing `subscribe_to` patterns (supports `*`, `**`, and `Regexp`). `Rails.event.set_context(...)` values take precedence over the `Current.*` resolvers for `request_id`, `ip_address`, `user_agent`, and `session_id`. `Rails.event.tagged(...)` and `source_location` are captured under the reserved metadata keys `_tags` and `_source`.
|
|
15
|
+
|
|
16
|
+
## [0.3.0] - 2026-03-31
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Tamper detection via chained SHA-256 checksums — each record's `checksum` column hashes its content plus the previous record's checksum
|
|
21
|
+
- `AuditLog.verify_chain` to walk the chain and detect modified records
|
|
22
|
+
- `AuditLog.backfill_checksums!` to retroactively checksum pre-existing records
|
|
23
|
+
- Rake tasks: `standard_audit:verify` (exits non-zero on failure) and `standard_audit:backfill_checksums`
|
|
24
|
+
- Upgrade generator: `rails g standard_audit:add_checksums` adds the checksum column and created_at index
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- Primary keys now use UUIDv7 (time-ordered) instead of UUIDv4 for deterministic chain ordering
|
|
29
|
+
- Batch inserts (`StandardAudit.batch`) now compute chained checksums
|
|
30
|
+
|
|
31
|
+
### Upgrade
|
|
32
|
+
|
|
33
|
+
Run the upgrade generator to add the checksum column:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
rails generate standard_audit:add_checksums
|
|
37
|
+
rails db:migrate
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Optionally backfill checksums for existing records:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
rake standard_audit:backfill_checksums
|
|
44
|
+
```
|
|
45
|
+
|
|
10
46
|
## [0.2.0] - 2026-03-25
|
|
11
47
|
|
|
12
48
|
### Added
|
data/README.md
CHANGED
|
@@ -52,11 +52,38 @@ StandardAudit::AuditLog.for_actor(current_user).this_week
|
|
|
52
52
|
|
|
53
53
|
## Recording Events
|
|
54
54
|
|
|
55
|
-
StandardAudit provides
|
|
55
|
+
StandardAudit provides four ways to record audit events. On Rails 8.1+, prefer `Rails.event` — it is the standard Rails interface for structured events.
|
|
56
|
+
|
|
57
|
+
### Rails.event (Rails 8.1+)
|
|
58
|
+
|
|
59
|
+
StandardAudit registers a `Rails.event` subscriber at boot, so any `notify` call whose name matches a configured `subscribe_to` pattern is persisted automatically:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
class ApplicationController < ActionController::Base
|
|
63
|
+
before_action do
|
|
64
|
+
Rails.event.set_context(
|
|
65
|
+
request_id: request.request_id,
|
|
66
|
+
ip_address: request.remote_ip,
|
|
67
|
+
user_agent: request.user_agent
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
Rails.event.tagged("checkout") do
|
|
73
|
+
Rails.event.notify("myapp.orders.created",
|
|
74
|
+
actor: current_user,
|
|
75
|
+
target: @order,
|
|
76
|
+
scope: current_organisation,
|
|
77
|
+
total: @order.total
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`Rails.event.set_context` values override the `Current.*` resolvers for `request_id`, `ip_address`, `user_agent`, and `session_id`. Tags and `source_location` are captured as metadata under the reserved keys `_tags` and `_source`.
|
|
56
83
|
|
|
57
84
|
### Convenience API
|
|
58
85
|
|
|
59
|
-
|
|
86
|
+
Call `StandardAudit.record` directly:
|
|
60
87
|
|
|
61
88
|
```ruby
|
|
62
89
|
StandardAudit.record("orders.created",
|
|
@@ -71,7 +98,7 @@ When `actor` is omitted, it falls back to the configured `current_actor_resolver
|
|
|
71
98
|
|
|
72
99
|
### ActiveSupport::Notifications
|
|
73
100
|
|
|
74
|
-
|
|
101
|
+
For Rails < 8.1, or when `Rails.event` is unavailable, instrument events via `ActiveSupport::Notifications`:
|
|
75
102
|
|
|
76
103
|
```ruby
|
|
77
104
|
ActiveSupport::Notifications.instrument("myapp.orders.created", {
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
1
3
|
module StandardAudit
|
|
2
4
|
class AuditLog < ApplicationRecord
|
|
3
5
|
self.table_name = "audit_logs"
|
|
4
6
|
|
|
7
|
+
CHECKSUM_FIELDS = %w[
|
|
8
|
+
id event_type actor_gid actor_type target_gid target_type
|
|
9
|
+
scope_gid scope_type metadata request_id ip_address
|
|
10
|
+
user_agent session_id occurred_at
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
5
13
|
before_create :assign_uuid, if: -> { id.blank? }
|
|
14
|
+
before_create :compute_checksum, if: -> { checksum.blank? }
|
|
6
15
|
after_create_commit :emit_created_event
|
|
7
16
|
|
|
8
17
|
# Audit logs are append-only. Use update_columns for privileged
|
|
@@ -156,6 +165,95 @@ module StandardAudit
|
|
|
156
165
|
}
|
|
157
166
|
end
|
|
158
167
|
|
|
168
|
+
# Recomputes the checksum from the record's current field values and the
|
|
169
|
+
# given previous checksum. Useful for verification without saving.
|
|
170
|
+
def compute_checksum_value(previous_checksum: nil)
|
|
171
|
+
self.class.compute_checksum_value(
|
|
172
|
+
attributes.slice(*CHECKSUM_FIELDS),
|
|
173
|
+
previous_checksum: previous_checksum
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def self.compute_checksum_value(attrs, previous_checksum: nil)
|
|
178
|
+
canonical = CHECKSUM_FIELDS.map { |f|
|
|
179
|
+
value = attrs[f]
|
|
180
|
+
value = value.to_json if value.is_a?(Hash)
|
|
181
|
+
value = value.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ") if value.respond_to?(:strftime) && value.respond_to?(:utc)
|
|
182
|
+
"#{f}=#{value}"
|
|
183
|
+
}.join("|")
|
|
184
|
+
|
|
185
|
+
canonical = "#{previous_checksum}|#{canonical}" if previous_checksum.present?
|
|
186
|
+
|
|
187
|
+
OpenSSL::Digest::SHA256.hexdigest(canonical)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Verifies the integrity of the audit log chain. Returns a result hash with
|
|
191
|
+
# :valid (boolean), :verified (count), and :failures (array of hashes).
|
|
192
|
+
#
|
|
193
|
+
# Records are processed in (created_at, id) order. Records without a
|
|
194
|
+
# checksum (pre-feature data) reset the chain — the next checksummed
|
|
195
|
+
# record starts a new independent chain segment.
|
|
196
|
+
def self.verify_chain(scope: nil, batch_size: 1000)
|
|
197
|
+
relation = scope ? where(scope_gid: scope.to_global_id.to_s) : all
|
|
198
|
+
|
|
199
|
+
previous_checksum = nil
|
|
200
|
+
verified = 0
|
|
201
|
+
failures = []
|
|
202
|
+
|
|
203
|
+
relation.in_batches(of: batch_size) do |batch|
|
|
204
|
+
batch.order(created_at: :asc, id: :asc).each do |record|
|
|
205
|
+
if record.checksum.blank?
|
|
206
|
+
previous_checksum = nil
|
|
207
|
+
next
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
expected = record.compute_checksum_value(previous_checksum: previous_checksum)
|
|
211
|
+
|
|
212
|
+
if record.checksum != expected
|
|
213
|
+
failures << {
|
|
214
|
+
id: record.id,
|
|
215
|
+
event_type: record.event_type,
|
|
216
|
+
created_at: record.created_at,
|
|
217
|
+
expected: expected,
|
|
218
|
+
actual: record.checksum
|
|
219
|
+
}
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
verified += 1
|
|
223
|
+
previous_checksum = record.checksum
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
{ valid: failures.empty?, verified: verified, failures: failures }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Backfills checksums for records that don't have them (e.g. pre-existing
|
|
231
|
+
# records before the checksum feature was added).
|
|
232
|
+
def self.backfill_checksums!(batch_size: 1000)
|
|
233
|
+
previous_checksum = nil
|
|
234
|
+
count = 0
|
|
235
|
+
|
|
236
|
+
in_batches(of: batch_size) do |batch|
|
|
237
|
+
batch.order(created_at: :asc, id: :asc).each do |record|
|
|
238
|
+
if record.checksum.present?
|
|
239
|
+
previous_checksum = record.checksum
|
|
240
|
+
next
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
new_checksum = compute_checksum_value(
|
|
244
|
+
record.attributes.slice(*CHECKSUM_FIELDS),
|
|
245
|
+
previous_checksum: previous_checksum
|
|
246
|
+
)
|
|
247
|
+
record.update_columns(checksum: new_checksum)
|
|
248
|
+
|
|
249
|
+
previous_checksum = new_checksum
|
|
250
|
+
count += 1
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
count
|
|
255
|
+
end
|
|
256
|
+
|
|
159
257
|
private
|
|
160
258
|
|
|
161
259
|
def emit_created_event
|
|
@@ -170,8 +268,17 @@ module StandardAudit
|
|
|
170
268
|
Rails.logger.warn("[StandardAudit] Failed to emit event: #{e.class}: #{e.message}")
|
|
171
269
|
end
|
|
172
270
|
|
|
271
|
+
# Fetches the most recent record's checksum and chains the new record to it.
|
|
272
|
+
# Note: concurrent inserts can read the same "previous" record, forking
|
|
273
|
+
# the chain. Use database-level advisory locks if you need serializable
|
|
274
|
+
# chain integrity under concurrent writes.
|
|
275
|
+
def compute_checksum
|
|
276
|
+
previous = self.class.order(created_at: :desc, id: :desc).limit(1).pick(:checksum)
|
|
277
|
+
self.checksum = compute_checksum_value(previous_checksum: previous)
|
|
278
|
+
end
|
|
279
|
+
|
|
173
280
|
def assign_uuid
|
|
174
|
-
self.id = SecureRandom.
|
|
281
|
+
self.id = SecureRandom.uuid_v7
|
|
175
282
|
end
|
|
176
283
|
end
|
|
177
284
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module StandardAudit
|
|
2
|
+
module Generators
|
|
3
|
+
class AddChecksumsGenerator < Rails::Generators::Base
|
|
4
|
+
include Rails::Generators::Migration
|
|
5
|
+
source_root File.expand_path("templates", __dir__)
|
|
6
|
+
|
|
7
|
+
def self.next_migration_number(dirname)
|
|
8
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def copy_migration
|
|
12
|
+
migration_template "add_checksum_to_audit_logs.rb.erb", "db/migrate/add_checksum_to_audit_logs.rb"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -16,6 +16,7 @@ class CreateAuditLogs < ActiveRecord::Migration[<%= ActiveRecord::Migration.curr
|
|
|
16
16
|
t.string :session_id
|
|
17
17
|
t.jsonb :metadata, default: {}
|
|
18
18
|
t.datetime :occurred_at, null: false
|
|
19
|
+
t.string :checksum, limit: 64
|
|
19
20
|
t.timestamps
|
|
20
21
|
end
|
|
21
22
|
|
|
@@ -25,6 +26,7 @@ class CreateAuditLogs < ActiveRecord::Migration[<%= ActiveRecord::Migration.curr
|
|
|
25
26
|
add_index :audit_logs, [:scope_type, :scope_gid]
|
|
26
27
|
add_index :audit_logs, :request_id
|
|
27
28
|
add_index :audit_logs, [:occurred_at, :created_at]
|
|
29
|
+
add_index :audit_logs, :created_at
|
|
28
30
|
add_index :audit_logs, :session_id
|
|
29
31
|
# GIN index requires PostgreSQL; remove if using another database
|
|
30
32
|
add_index :audit_logs, :metadata, using: :gin
|
|
@@ -5,6 +5,12 @@ module StandardAudit
|
|
|
5
5
|
initializer "standard_audit.subscriber" do
|
|
6
6
|
ActiveSupport.on_load(:active_record) do
|
|
7
7
|
StandardAudit.subscriber.setup!
|
|
8
|
+
|
|
9
|
+
# Rails 8.1+ structured event reporter. Feature-detected so the gem
|
|
10
|
+
# still works on older Rails versions that only have AS::Notifications.
|
|
11
|
+
if Rails.respond_to?(:event) && Rails.event.respond_to?(:subscribe)
|
|
12
|
+
Rails.event.subscribe(StandardAudit.event_subscriber)
|
|
13
|
+
end
|
|
8
14
|
end
|
|
9
15
|
end
|
|
10
16
|
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
module StandardAudit
|
|
2
|
+
# Subscriber for Rails 8.1+ structured event reporting (`Rails.event`).
|
|
3
|
+
#
|
|
4
|
+
# Registered with `Rails.event.subscribe(...)` so that every `Rails.event.notify`
|
|
5
|
+
# call flows through StandardAudit for persistence. Events whose name does not
|
|
6
|
+
# match any configured `subscribe_to` pattern are ignored.
|
|
7
|
+
#
|
|
8
|
+
# Payload is extracted with the same extractors used by the
|
|
9
|
+
# ActiveSupport::Notifications subscriber. Rails.event `context` supplies
|
|
10
|
+
# request_id/ip_address/user_agent/session_id and takes precedence over the
|
|
11
|
+
# Current.* resolvers. Tags and source_location are captured as metadata
|
|
12
|
+
# under the reserved keys `_tags` and `_source`.
|
|
13
|
+
class EventSubscriber
|
|
14
|
+
RESERVED_PAYLOAD_KEYS = %i[actor target scope request_id ip_address user_agent session_id].freeze
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@pattern_cache = {}
|
|
18
|
+
@pattern_cache_mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def emit(event)
|
|
22
|
+
return unless StandardAudit.config.enabled
|
|
23
|
+
|
|
24
|
+
name = event[:name]
|
|
25
|
+
return if name.nil?
|
|
26
|
+
return unless matches_subscription?(name)
|
|
27
|
+
|
|
28
|
+
config = StandardAudit.config
|
|
29
|
+
payload = event[:payload] || {}
|
|
30
|
+
context = event[:context] || {}
|
|
31
|
+
|
|
32
|
+
actor = config.actor_extractor.call(payload)
|
|
33
|
+
target = config.target_extractor.call(payload)
|
|
34
|
+
scope = config.scope_extractor.call(payload)
|
|
35
|
+
|
|
36
|
+
metadata = build_metadata(payload, event[:tags], event[:source_location], config)
|
|
37
|
+
|
|
38
|
+
StandardAudit.record(
|
|
39
|
+
name,
|
|
40
|
+
actor: actor,
|
|
41
|
+
target: target,
|
|
42
|
+
scope: scope,
|
|
43
|
+
metadata: metadata,
|
|
44
|
+
request_id: context[:request_id] || payload[:request_id],
|
|
45
|
+
ip_address: context[:ip_address] || payload[:ip_address],
|
|
46
|
+
user_agent: context[:user_agent] || payload[:user_agent],
|
|
47
|
+
session_id: context[:session_id] || payload[:session_id]
|
|
48
|
+
)
|
|
49
|
+
rescue => e
|
|
50
|
+
Rails.logger.error("[StandardAudit] Error handling Rails.event: #{e.class}: #{e.message}")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def matches_subscription?(name)
|
|
56
|
+
StandardAudit.config.subscriptions.any? { |pattern| pattern_match?(pattern, name) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Supports the same pattern shapes as ActiveSupport::Notifications.subscribe:
|
|
60
|
+
# a Regexp, or a String with `*` matching a single segment and `**` matching
|
|
61
|
+
# the remainder.
|
|
62
|
+
def pattern_match?(pattern, name)
|
|
63
|
+
case pattern
|
|
64
|
+
when Regexp
|
|
65
|
+
pattern.match?(name)
|
|
66
|
+
when String
|
|
67
|
+
compiled_pattern_for(pattern).match?(name)
|
|
68
|
+
else
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def compiled_pattern_for(pattern)
|
|
74
|
+
cached = @pattern_cache[pattern]
|
|
75
|
+
return cached if cached
|
|
76
|
+
|
|
77
|
+
@pattern_cache_mutex.synchronize do
|
|
78
|
+
@pattern_cache[pattern] ||= Regexp.new(
|
|
79
|
+
"\\A" + Regexp.escape(pattern).gsub('\\*\\*', ".*").gsub('\\*', "[^.]*") + "\\z"
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# `_tags` and `_source` are reserved metadata keys owned by this
|
|
85
|
+
# subscriber. Sensitive-key filtering is handled downstream by
|
|
86
|
+
# `StandardAudit.record`, so we don't re-run it here.
|
|
87
|
+
def build_metadata(payload, tags, source_location, config)
|
|
88
|
+
reserved = RESERVED_PAYLOAD_KEYS.map(&:to_s)
|
|
89
|
+
raw = payload.reject { |k, _| reserved.include?(k.to_s) }
|
|
90
|
+
raw = config.metadata_builder.call(raw) if config.metadata_builder
|
|
91
|
+
|
|
92
|
+
if tags.is_a?(Hash) && tags.any?
|
|
93
|
+
raw[:_tags] = tags
|
|
94
|
+
elsif tags && !tags.is_a?(Hash)
|
|
95
|
+
Rails.logger.warn("[StandardAudit] Dropping Rails.event tags of unexpected type: #{tags.class}")
|
|
96
|
+
end
|
|
97
|
+
raw[:_source] = source_location if source_location
|
|
98
|
+
raw
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -67,7 +67,7 @@ module StandardAudit
|
|
|
67
67
|
log.save!
|
|
68
68
|
end
|
|
69
69
|
rescue => e
|
|
70
|
-
Rails.logger.error("[StandardAudit] Error creating audit log: #{e.message}")
|
|
70
|
+
Rails.logger.error("[StandardAudit] Error creating audit log: #{e.class}: #{e.message}")
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
def extract_metadata(payload, config)
|
data/lib/standard_audit.rb
CHANGED
|
@@ -2,10 +2,15 @@ require "standard_audit/version"
|
|
|
2
2
|
require "standard_audit/engine"
|
|
3
3
|
require "standard_audit/configuration"
|
|
4
4
|
require "standard_audit/subscriber"
|
|
5
|
+
require "standard_audit/event_subscriber"
|
|
5
6
|
require "standard_audit/auditable"
|
|
6
7
|
require "standard_audit/audit_scope"
|
|
7
8
|
|
|
8
9
|
module StandardAudit
|
|
10
|
+
# Metadata keys owned internally by StandardAudit. Never filtered by
|
|
11
|
+
# `sensitive_keys` even if a user adds them there.
|
|
12
|
+
RESERVED_METADATA_KEYS = %w[_tags _source].freeze
|
|
13
|
+
|
|
9
14
|
class << self
|
|
10
15
|
def configure
|
|
11
16
|
yield(config) if block_given?
|
|
@@ -20,8 +25,9 @@ module StandardAudit
|
|
|
20
25
|
|
|
21
26
|
actor ||= config.current_actor_resolver.call
|
|
22
27
|
|
|
23
|
-
# Filter sensitive keys
|
|
24
|
-
|
|
28
|
+
# Filter sensitive keys. `_tags` and `_source` are reserved internal
|
|
29
|
+
# metadata keys owned by EventSubscriber and are never stripped.
|
|
30
|
+
sensitive = config.sensitive_keys.map(&:to_s) - RESERVED_METADATA_KEYS
|
|
25
31
|
filtered_metadata = metadata.reject { |k, _| sensitive.include?(k.to_s) }
|
|
26
32
|
|
|
27
33
|
attrs = {
|
|
@@ -90,6 +96,10 @@ module StandardAudit
|
|
|
90
96
|
@subscriber ||= Subscriber.new
|
|
91
97
|
end
|
|
92
98
|
|
|
99
|
+
def event_subscriber
|
|
100
|
+
@event_subscriber ||= EventSubscriber.new
|
|
101
|
+
end
|
|
102
|
+
|
|
93
103
|
def reset_configuration!
|
|
94
104
|
@configuration = nil
|
|
95
105
|
end
|
|
@@ -102,12 +112,31 @@ module StandardAudit
|
|
|
102
112
|
|
|
103
113
|
def flush_batch(buffer)
|
|
104
114
|
now = Time.current
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
previous_checksum = StandardAudit::AuditLog
|
|
116
|
+
.order(created_at: :desc, id: :desc)
|
|
117
|
+
.limit(1)
|
|
118
|
+
.pick(:checksum)
|
|
119
|
+
|
|
120
|
+
# Generate sorted UUIDs to ensure batch ordering matches id ordering.
|
|
121
|
+
# UUIDv7 within the same millisecond can have non-monotonic lower bits;
|
|
122
|
+
# sorting guarantees the chain order matches the id order used by
|
|
123
|
+
# verify_chain. Under very high throughput this is a best-effort
|
|
124
|
+
# guarantee — see compute_checksum's concurrency note.
|
|
125
|
+
ids = buffer.size.times.map { SecureRandom.uuid_v7 }.sort
|
|
126
|
+
|
|
127
|
+
rows = buffer.each_with_index.map do |attrs, i|
|
|
128
|
+
row = attrs.merge(
|
|
129
|
+
id: ids[i],
|
|
108
130
|
created_at: now,
|
|
109
131
|
updated_at: now
|
|
110
132
|
)
|
|
133
|
+
checksum = StandardAudit::AuditLog.compute_checksum_value(
|
|
134
|
+
row.stringify_keys,
|
|
135
|
+
previous_checksum: previous_checksum
|
|
136
|
+
)
|
|
137
|
+
row[:checksum] = checksum
|
|
138
|
+
previous_checksum = checksum
|
|
139
|
+
row
|
|
111
140
|
end
|
|
112
141
|
|
|
113
142
|
StandardAudit::AuditLog.insert_all!(rows)
|
|
@@ -55,6 +55,30 @@ namespace :standard_audit do
|
|
|
55
55
|
puts "Anonymized #{count} audit logs for #{args[:actor_gid]}"
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
desc "Verify audit log chain integrity (tamper detection)"
|
|
59
|
+
task verify: :environment do
|
|
60
|
+
result = StandardAudit::AuditLog.verify_chain
|
|
61
|
+
|
|
62
|
+
puts "Audit Log Chain Verification"
|
|
63
|
+
puts "============================="
|
|
64
|
+
puts "Records verified: #{result[:verified]}"
|
|
65
|
+
puts "Chain valid: #{result[:valid]}"
|
|
66
|
+
|
|
67
|
+
if result[:failures].any?
|
|
68
|
+
puts "\nTampered records detected: #{result[:failures].size}"
|
|
69
|
+
result[:failures].each do |failure|
|
|
70
|
+
puts " #{failure[:id]} (#{failure[:event_type]}) at #{failure[:created_at]}"
|
|
71
|
+
end
|
|
72
|
+
abort "Chain verification failed"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
desc "Backfill checksums for records that don't have them"
|
|
77
|
+
task backfill_checksums: :environment do
|
|
78
|
+
count = StandardAudit::AuditLog.backfill_checksums!
|
|
79
|
+
puts "Backfilled checksums for #{count} audit log records"
|
|
80
|
+
end
|
|
81
|
+
|
|
58
82
|
desc "Export audit logs for a specific actor (GDPR right to access)"
|
|
59
83
|
task :export_actor, [:actor_gid, :output] => :environment do |_t, args|
|
|
60
84
|
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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -65,8 +65,9 @@ dependencies:
|
|
|
65
65
|
- - ">="
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '1.0'
|
|
68
|
-
description: StandardAudit is a standalone Rails gem for database-backed audit logging
|
|
69
|
-
|
|
68
|
+
description: StandardAudit is a standalone Rails gem for database-backed audit logging.
|
|
69
|
+
On Rails 8.1+ it subscribes to Rails.event; on earlier versions it subscribes to
|
|
70
|
+
ActiveSupport::Notifications. Generic, flexible, and works with any Rails application.
|
|
70
71
|
email:
|
|
71
72
|
- code@jaryl.dev
|
|
72
73
|
executables: []
|
|
@@ -82,6 +83,8 @@ files:
|
|
|
82
83
|
- app/models/standard_audit/application_record.rb
|
|
83
84
|
- app/models/standard_audit/audit_log.rb
|
|
84
85
|
- config/routes.rb
|
|
86
|
+
- lib/generators/standard_audit/add_checksums/add_checksums_generator.rb
|
|
87
|
+
- lib/generators/standard_audit/add_checksums/templates/add_checksum_to_audit_logs.rb.erb
|
|
85
88
|
- lib/generators/standard_audit/install/install_generator.rb
|
|
86
89
|
- lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb
|
|
87
90
|
- lib/generators/standard_audit/install/templates/initializer.rb.erb
|
|
@@ -90,6 +93,7 @@ files:
|
|
|
90
93
|
- lib/standard_audit/auditable.rb
|
|
91
94
|
- lib/standard_audit/configuration.rb
|
|
92
95
|
- lib/standard_audit/engine.rb
|
|
96
|
+
- lib/standard_audit/event_subscriber.rb
|
|
93
97
|
- lib/standard_audit/presets/standard_id.rb
|
|
94
98
|
- lib/standard_audit/subscriber.rb
|
|
95
99
|
- lib/standard_audit/version.rb
|
|
@@ -118,5 +122,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
118
122
|
requirements: []
|
|
119
123
|
rubygems_version: 4.0.3
|
|
120
124
|
specification_version: 4
|
|
121
|
-
summary: Database-backed audit logging for Rails via ActiveSupport::Notifications.
|
|
125
|
+
summary: Database-backed audit logging for Rails via Rails.event and ActiveSupport::Notifications.
|
|
122
126
|
test_files: []
|