standard_audit 0.2.0 → 0.3.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 +30 -0
- 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/version.rb +1 -1
- data/lib/standard_audit.rb +22 -3
- data/lib/tasks/standard_audit_tasks.rake +24 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ae2015728333aa6f6549bf138de8c27017c78c23f0d930ab3df9cf73c99b14cf
|
|
4
|
+
data.tar.gz: 6e479ec25dcb88c2538a23efbec0796762b4274fd0baa2c5ad6707f57e90b65d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d3ec930cf81109adf17c563d15dba60a0e82ab33bcb0cb6ce422009f5b157b8d1335c46da73269053ed3e09e8169bbada9c548409080871d6dd9e3c928b65ce8
|
|
7
|
+
data.tar.gz: 64906fb08b3d97c7e25626790c32fcf6eb08885cd4c0d5f7cf22a6acf9bd2516101f768fe90d93bfd3db4a3120f726b1e63eaa49036beffcf513d139f68d58f8
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-03-31
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Tamper detection via chained SHA-256 checksums — each record's `checksum` column hashes its content plus the previous record's checksum
|
|
15
|
+
- `AuditLog.verify_chain` to walk the chain and detect modified records
|
|
16
|
+
- `AuditLog.backfill_checksums!` to retroactively checksum pre-existing records
|
|
17
|
+
- Rake tasks: `standard_audit:verify` (exits non-zero on failure) and `standard_audit:backfill_checksums`
|
|
18
|
+
- Upgrade generator: `rails g standard_audit:add_checksums` adds the checksum column and created_at index
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Primary keys now use UUIDv7 (time-ordered) instead of UUIDv4 for deterministic chain ordering
|
|
23
|
+
- Batch inserts (`StandardAudit.batch`) now compute chained checksums
|
|
24
|
+
|
|
25
|
+
### Upgrade
|
|
26
|
+
|
|
27
|
+
Run the upgrade generator to add the checksum column:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
rails generate standard_audit:add_checksums
|
|
31
|
+
rails db:migrate
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Optionally backfill checksums for existing records:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
rake standard_audit:backfill_checksums
|
|
38
|
+
```
|
|
39
|
+
|
|
10
40
|
## [0.2.0] - 2026-03-25
|
|
11
41
|
|
|
12
42
|
### Added
|
|
@@ -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
|
data/lib/standard_audit.rb
CHANGED
|
@@ -102,12 +102,31 @@ module StandardAudit
|
|
|
102
102
|
|
|
103
103
|
def flush_batch(buffer)
|
|
104
104
|
now = Time.current
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
previous_checksum = StandardAudit::AuditLog
|
|
106
|
+
.order(created_at: :desc, id: :desc)
|
|
107
|
+
.limit(1)
|
|
108
|
+
.pick(:checksum)
|
|
109
|
+
|
|
110
|
+
# Generate sorted UUIDs to ensure batch ordering matches id ordering.
|
|
111
|
+
# UUIDv7 within the same millisecond can have non-monotonic lower bits;
|
|
112
|
+
# sorting guarantees the chain order matches the id order used by
|
|
113
|
+
# verify_chain. Under very high throughput this is a best-effort
|
|
114
|
+
# guarantee — see compute_checksum's concurrency note.
|
|
115
|
+
ids = buffer.size.times.map { SecureRandom.uuid_v7 }.sort
|
|
116
|
+
|
|
117
|
+
rows = buffer.each_with_index.map do |attrs, i|
|
|
118
|
+
row = attrs.merge(
|
|
119
|
+
id: ids[i],
|
|
108
120
|
created_at: now,
|
|
109
121
|
updated_at: now
|
|
110
122
|
)
|
|
123
|
+
checksum = StandardAudit::AuditLog.compute_checksum_value(
|
|
124
|
+
row.stringify_keys,
|
|
125
|
+
previous_checksum: previous_checksum
|
|
126
|
+
)
|
|
127
|
+
row[:checksum] = checksum
|
|
128
|
+
previous_checksum = checksum
|
|
129
|
+
row
|
|
111
130
|
end
|
|
112
131
|
|
|
113
132
|
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -82,6 +82,8 @@ files:
|
|
|
82
82
|
- app/models/standard_audit/application_record.rb
|
|
83
83
|
- app/models/standard_audit/audit_log.rb
|
|
84
84
|
- config/routes.rb
|
|
85
|
+
- lib/generators/standard_audit/add_checksums/add_checksums_generator.rb
|
|
86
|
+
- lib/generators/standard_audit/add_checksums/templates/add_checksum_to_audit_logs.rb.erb
|
|
85
87
|
- lib/generators/standard_audit/install/install_generator.rb
|
|
86
88
|
- lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb
|
|
87
89
|
- lib/generators/standard_audit/install/templates/initializer.rb.erb
|