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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c400c65330d8896079475becca2eedc20481027bd23a9e6c35d1aa6011e46762
4
- data.tar.gz: e5757a3734ccecaa266b0e17f537f53c51d26cebd6b6fdcc0022db758d31935e
3
+ metadata.gz: ae2015728333aa6f6549bf138de8c27017c78c23f0d930ab3df9cf73c99b14cf
4
+ data.tar.gz: 6e479ec25dcb88c2538a23efbec0796762b4274fd0baa2c5ad6707f57e90b65d
5
5
  SHA512:
6
- metadata.gz: f03510f5f6f9c6ba1e47e189da9d29e9c1a6d720886fb149c4938f4612581bd4049b87e61d1a67a36c7a27cb4ca1a147c3f9984c17ee090980d236fa632531c3
7
- data.tar.gz: faf25861c62875fb9e89b97c938a5ed1730982f3f8a48b40c694a3ee26f9941c9832085a1dc75420d4a7085c0cb03aff0b1474ea59e3a29a4386297d9fb6c879
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.uuid
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
@@ -0,0 +1,6 @@
1
+ class AddChecksumToAuditLogs < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ add_column :audit_logs, :checksum, :string, limit: 64
4
+ add_index :audit_logs, :created_at
5
+ end
6
+ 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
@@ -1,3 +1,3 @@
1
1
  module StandardAudit
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -102,12 +102,31 @@ module StandardAudit
102
102
 
103
103
  def flush_batch(buffer)
104
104
  now = Time.current
105
- rows = buffer.map do |attrs|
106
- attrs.merge(
107
- id: SecureRandom.uuid,
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.2.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