standard_audit 0.1.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: 44b12d653a138dc099f16e659e14cd139b612369f2279859b9fcae7eff57827c
4
- data.tar.gz: f36ceca0425ba35b8ad73c1ac91cf63e82eb6da21fdaf7f7117c73f9c09ecc4e
3
+ metadata.gz: ae2015728333aa6f6549bf138de8c27017c78c23f0d930ab3df9cf73c99b14cf
4
+ data.tar.gz: 6e479ec25dcb88c2538a23efbec0796762b4274fd0baa2c5ad6707f57e90b65d
5
5
  SHA512:
6
- metadata.gz: e10c0a45d0941ab4284f3f1fd0a8bd25b5b9c83b0052a1e354d3acb43be984eb1997e934ff8ea45beb9df772e12420300f41403bd018d3a75e2080b32df05cd0
7
- data.tar.gz: c0fcae8a3eee766f6a2caf7f01323788e4eeadf3de73ff3b37a7258e3a037b2771e557a7865adf559ebd98afc3d975b2e92bbdfc0b2735b1819fffaef80511e8
6
+ metadata.gz: d3ec930cf81109adf17c563d15dba60a0e82ab33bcb0cb6ce422009f5b157b8d1335c46da73269053ed3e09e8169bbada9c548409080871d6dd9e3c928b65ce8
7
+ data.tar.gz: 64906fb08b3d97c7e25626790c32fcf6eb08885cd4c0d5f7cf22a6acf9bd2516101f768fe90d93bfd3db4a3120f726b1e63eaa49036beffcf513d139f68d58f8
data/CHANGELOG.md CHANGED
@@ -1,8 +1,65 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0 (2026-03-03)
3
+ All notable changes to this project will be documented in this file.
4
4
 
5
- Initial release.
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
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
+
40
+ ## [0.2.0] - 2026-03-25
41
+
42
+ ### Added
43
+
44
+ - Batch insert mode via `StandardAudit.batch { }` for high-volume audit logging
45
+ - `StandardAudit::CleanupJob` for automated retention enforcement
46
+ - `config.use_preset(:standard_id)` to subscribe to StandardId auth events in one call
47
+ - GIN index on metadata JSONB column in install generator (PostgreSQL)
48
+ - CI-driven gem publishing via GitHub Actions trusted publisher
49
+
50
+ ### Changed
51
+
52
+ - Migration template uses `jsonb` instead of `json` for metadata column
53
+ - Expanded default `sensitive_keys` to include `api_key`, `access_token`, `refresh_token`, `private_key`, `certificate_chain`, `ssn`, `credit_card`, `authorization`
54
+
55
+ ### Breaking Changes
56
+
57
+ - AuditLog records are now immutable — `update`/`destroy` raises `ActiveRecord::ReadOnlyRecord`. Use `update_columns` for privileged operations like GDPR anonymization. `delete`/`delete_all` still work for bulk cleanup.
58
+ - Removed `auto_cleanup` config attribute. Schedule `StandardAudit::CleanupJob` directly instead.
59
+
60
+ ## [0.1.0] - 2026-03-03
61
+
62
+ ### Added
6
63
 
7
64
  - Core audit log model with UUID primary keys and GlobalID-based polymorphic references
8
65
  - Convenience API: `StandardAudit.record` with sync, async, and block forms
data/MIT-LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright TODO: Write your name
1
+ Copyright (c) 2026 Jaryl Sim
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
data/README.md CHANGED
@@ -161,7 +161,7 @@ StandardAudit.configure do |config|
161
161
 
162
162
  # -- Sensitive Data --
163
163
  # Keys automatically stripped from metadata.
164
- config.sensitive_keys = %i[password password_confirmation token secret]
164
+ config.sensitive_keys += %i[my_custom_secret] # added to built-in defaults
165
165
 
166
166
  # -- Metadata Builder --
167
167
  # Optional proc to transform metadata before storage.
@@ -179,9 +179,8 @@ StandardAudit.configure do |config|
179
179
  # Metadata keys to strip during anonymization.
180
180
  config.anonymizable_metadata_keys = %i[email name ip_address]
181
181
 
182
- # -- Retention --
182
+ # -- Retention (schedule StandardAudit::CleanupJob to enforce) --
183
183
  config.retention_days = 90
184
- config.auto_cleanup = false
185
184
  end
186
185
  ```
187
186
 
@@ -353,7 +352,7 @@ t.jsonb :metadata, default: {}
353
352
  **Sensitive data**: Configure `sensitive_keys` to automatically strip passwords, tokens, and secrets from metadata. Add domain-specific keys as needed:
354
353
 
355
354
  ```ruby
356
- config.sensitive_keys = %i[password token secret ssn credit_card_number]
355
+ config.sensitive_keys += %i[medical_record_number] # extend the built-in defaults
357
356
  ```
358
357
 
359
358
  **Performance**: For high-volume applications, enable async processing and ensure your `audit_logs` table has appropriate indexes (the install generator adds them by default). Consider partitioning by `occurred_at` for very large tables.
@@ -0,0 +1,14 @@
1
+ module StandardAudit
2
+ class CleanupJob < ActiveJob::Base
3
+ queue_as { StandardAudit.config.queue_name }
4
+
5
+ def perform
6
+ days = StandardAudit.config.retention_days
7
+ return unless days
8
+
9
+ cutoff = days.days.ago
10
+ deleted = StandardAudit::AuditLog.before(cutoff).in_batches(of: 10_000).delete_all
11
+ Rails.logger.info("[StandardAudit] CleanupJob deleted #{deleted} audit logs older than #{days} days")
12
+ end
13
+ end
14
+ end
@@ -1,8 +1,25 @@
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? }
15
+ after_create_commit :emit_created_event
16
+
17
+ # Audit logs are append-only. Use update_columns for privileged
18
+ # operations like GDPR anonymization that must bypass this guard.
19
+ # Note: delete/delete_all bypass callbacks and are permitted for
20
+ # bulk cleanup operations (see CleanupJob, rake standard_audit:cleanup).
21
+ before_update { raise ActiveRecord::ReadOnlyRecord }
22
+ before_destroy { raise ActiveRecord::ReadOnlyRecord }
6
23
 
7
24
  validates :event_type, presence: true
8
25
  validates :occurred_at, presence: true
@@ -85,8 +102,8 @@ module StandardAudit
85
102
  scope :for_request, ->(request_id) { where(request_id: request_id) }
86
103
  scope :from_ip, ->(ip_address) { where(ip_address: ip_address) }
87
104
  scope :for_session, ->(session_id) { where(session_id: session_id) }
88
- scope :chronological, -> { order(occurred_at: :asc) }
89
- scope :reverse_chronological, -> { order(occurred_at: :desc) }
105
+ scope :chronological, -> { order(occurred_at: :asc, created_at: :asc) }
106
+ scope :reverse_chronological, -> { order(occurred_at: :desc, created_at: :desc) }
90
107
  scope :recent, ->(n = 10) { reverse_chronological.limit(n) }
91
108
 
92
109
  # -- GDPR methods --
@@ -148,10 +165,120 @@ module StandardAudit
148
165
  }
149
166
  end
150
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
+
151
257
  private
152
258
 
259
+ def emit_created_event
260
+ ActiveSupport::Notifications.instrument("standard_audit.audit_log.created", {
261
+ id: id,
262
+ event_type: event_type,
263
+ actor_type: actor_type,
264
+ target_type: target_type,
265
+ scope_type: scope_type
266
+ })
267
+ rescue StandardError => e
268
+ Rails.logger.warn("[StandardAudit] Failed to emit event: #{e.class}: #{e.message}")
269
+ end
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
+
153
280
  def assign_uuid
154
- self.id = SecureRandom.uuid
281
+ self.id = SecureRandom.uuid_v7
155
282
  end
156
283
  end
157
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
@@ -5,26 +5,30 @@ class CreateAuditLogs < ActiveRecord::Migration[<%= ActiveRecord::Migration.curr
5
5
  t.string :actor_type
6
6
  t.string :target_gid
7
7
  t.string :target_type
8
+ # Multi-tenancy: include StandardAudit::AuditScope in your tenant model
9
+ # (e.g., Organisation) to scope audit logs. Leave nil for single-tenant apps.
8
10
  t.string :scope_gid
9
11
  t.string :scope_type
10
12
  t.string :event_type, null: false
11
13
  t.string :request_id
12
14
  t.string :ip_address
13
- t.string :user_agent
15
+ t.text :user_agent
14
16
  t.string :session_id
15
- t.json :metadata, default: {}
17
+ t.jsonb :metadata, default: {}
16
18
  t.datetime :occurred_at, null: false
19
+ t.string :checksum, limit: 64
17
20
  t.timestamps
18
21
  end
19
22
 
20
23
  add_index :audit_logs, :event_type
21
- add_index :audit_logs, :actor_type
22
- add_index :audit_logs, :target_type
24
+ add_index :audit_logs, [:actor_gid, :occurred_at]
25
+ add_index :audit_logs, [:target_gid, :occurred_at]
23
26
  add_index :audit_logs, [:scope_type, :scope_gid]
24
- add_index :audit_logs, :scope_type
25
27
  add_index :audit_logs, :request_id
26
- add_index :audit_logs, :occurred_at
27
- add_index :audit_logs, :ip_address
28
+ add_index :audit_logs, [:occurred_at, :created_at]
29
+ add_index :audit_logs, :created_at
28
30
  add_index :audit_logs, :session_id
31
+ # GIN index requires PostgreSQL; remove if using another database
32
+ add_index :audit_logs, :metadata, using: :gin
29
33
  end
30
34
  end
@@ -1,5 +1,8 @@
1
1
  StandardAudit.configure do |config|
2
- # Subscribe to ActiveSupport::Notifications patterns
2
+ # Use a preset to subscribe to common event patterns
3
+ # config.use_preset(:standard_id)
4
+
5
+ # Or subscribe to ActiveSupport::Notifications patterns manually
3
6
  # config.subscribe_to "audit.**"
4
7
 
5
8
  # Actor extractor from notification payload
@@ -11,6 +14,9 @@ StandardAudit.configure do |config|
11
14
  # Scope extractor from notification payload
12
15
  # config.scope_extractor = ->(payload) { payload[:scope] }
13
16
 
17
+ # Multi-tenancy: include StandardAudit::AuditScope in your tenant model
18
+ # to add a scoped_audit_logs association.
19
+
14
20
  # Fallback resolvers (used when payload values are nil)
15
21
  # config.current_actor_resolver = -> { Current.user }
16
22
  # config.current_request_id_resolver = -> { Current.request_id }
@@ -18,8 +24,10 @@ StandardAudit.configure do |config|
18
24
  # config.current_user_agent_resolver = -> { Current.user_agent }
19
25
  # config.current_session_id_resolver = -> { Current.session_id }
20
26
 
21
- # Keys to strip from metadata
22
- # config.sensitive_keys = %i[password password_confirmation token secret]
27
+ # Keys to strip from metadata (defaults include: password, password_confirmation,
28
+ # token, secret, api_key, access_token, refresh_token, private_key,
29
+ # certificate_chain, ssn, credit_card, authorization)
30
+ # config.sensitive_keys += %i[my_custom_secret]
23
31
 
24
32
  # Run audit log creation in background job
25
33
  # config.async = false
@@ -31,7 +39,6 @@ StandardAudit.configure do |config|
31
39
  # GDPR: metadata keys to strip on anonymization
32
40
  # config.anonymizable_metadata_keys = %i[email name ip_address]
33
41
 
34
- # Data retention
42
+ # Data retention (schedule StandardAudit::CleanupJob to enforce)
35
43
  # config.retention_days = nil
36
- # config.auto_cleanup = false
37
44
  end
@@ -6,10 +6,11 @@ module StandardAudit
6
6
  :current_ip_address_resolver, :current_user_agent_resolver,
7
7
  :current_session_id_resolver,
8
8
  :sensitive_keys, :metadata_builder,
9
- :anonymizable_metadata_keys, :retention_days, :auto_cleanup
9
+ :anonymizable_metadata_keys, :retention_days
10
10
 
11
11
  def initialize
12
12
  @subscriptions = []
13
+ @applied_presets = []
13
14
  @async = false
14
15
  @queue_name = :default
15
16
  @enabled = true
@@ -34,11 +35,18 @@ module StandardAudit
34
35
  defined?(Current) && Current.respond_to?(:session_id) ? Current.session_id : nil
35
36
  }
36
37
 
37
- @sensitive_keys = %i[password password_confirmation token secret]
38
+ # Note: :authorization filters the HTTP Authorization header value.
39
+ # If you use "authorization" as a metadata key for policy decisions,
40
+ # rename it (e.g. :authorization_policy) to avoid accidental filtering.
41
+ @sensitive_keys = %i[
42
+ password password_confirmation token secret
43
+ api_key access_token refresh_token
44
+ private_key certificate_chain
45
+ ssn credit_card authorization
46
+ ]
38
47
  @metadata_builder = nil
39
48
  @anonymizable_metadata_keys = %i[email name ip_address]
40
49
  @retention_days = nil
41
- @auto_cleanup = false
42
50
  end
43
51
 
44
52
  def subscribe_to(pattern)
@@ -48,5 +56,22 @@ module StandardAudit
48
56
  def subscriptions
49
57
  @subscriptions.dup.freeze
50
58
  end
59
+
60
+ def use_preset(name)
61
+ key = name.to_sym
62
+ return self if @applied_presets.include?(key)
63
+
64
+ preset = case key
65
+ when :standard_id
66
+ require "standard_audit/presets/standard_id"
67
+ StandardAudit::Presets::StandardId
68
+ else
69
+ raise ArgumentError, "Unknown preset: #{name}. Available presets: :standard_id"
70
+ end
71
+
72
+ preset.apply(self)
73
+ @applied_presets << key
74
+ self
75
+ end
51
76
  end
52
77
  end
@@ -0,0 +1,22 @@
1
+ module StandardAudit
2
+ module Presets
3
+ module StandardId
4
+ # Regex wildcards capture all events in a namespace. Session uses
5
+ # explicit strings to exclude noisy events like session.validated
6
+ # that fire on every authenticated request.
7
+ SUBSCRIPTIONS = [
8
+ /\Astandard_id\.authentication\./,
9
+ "standard_id.session.created",
10
+ "standard_id.session.revoked",
11
+ "standard_id.session.expired",
12
+ /\Astandard_id\.account\./,
13
+ /\Astandard_id\.social\./,
14
+ /\Astandard_id\.passwordless\./
15
+ ].freeze
16
+
17
+ def self.apply(config)
18
+ SUBSCRIPTIONS.each { |pattern| config.subscribe_to(pattern) }
19
+ end
20
+ end
21
+ end
22
+ end
@@ -1,3 +1,3 @@
1
1
  module StandardAudit
2
- VERSION = "0.1.0"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -44,15 +44,20 @@ module StandardAudit
44
44
  return
45
45
  end
46
46
 
47
- if config.async
48
- job_attrs = attrs.dup
49
- job_attrs[:actor_gid] = actor&.to_global_id&.to_s
50
- job_attrs[:target_gid] = target&.to_global_id&.to_s
51
- job_attrs[:scope_gid] = scope&.to_global_id&.to_s
52
- job_attrs[:actor_type] = actor&.class&.name
53
- job_attrs[:target_type] = target&.class&.name
54
- job_attrs[:scope_type] = scope&.class&.name
55
- StandardAudit::CreateAuditLogJob.perform_later(job_attrs.stringify_keys)
47
+ gid_attrs = {
48
+ actor_gid: actor&.to_global_id&.to_s,
49
+ actor_type: actor&.class&.name,
50
+ target_gid: target&.to_global_id&.to_s,
51
+ target_type: target&.class&.name,
52
+ scope_gid: scope&.to_global_id&.to_s,
53
+ scope_type: scope&.class&.name
54
+ }
55
+
56
+ if batching?
57
+ Thread.current[:standard_audit_batch] << attrs.merge(gid_attrs)
58
+ nil
59
+ elsif config.async
60
+ StandardAudit::CreateAuditLogJob.perform_later(attrs.merge(gid_attrs).stringify_keys)
56
61
  else
57
62
  log = StandardAudit::AuditLog.new(attrs)
58
63
  log.actor = actor
@@ -63,6 +68,24 @@ module StandardAudit
63
68
  end
64
69
  end
65
70
 
71
+ # Buffers record calls and flushes them via insert_all! on block exit.
72
+ # If the block raises, buffered records are dropped — only successful
73
+ # batches are persisted. Nested batches flush independently.
74
+ # Block-form record calls (with AS::Notifications) bypass the buffer
75
+ # and are processed normally since they don't persist records directly.
76
+ # Note: uses Thread.current for storage, which is not fiber-safe.
77
+ # Apps using async adapters (Falcon) should avoid concurrent batches.
78
+ def batch
79
+ previous = Thread.current[:standard_audit_batch]
80
+ buffer = Thread.current[:standard_audit_batch] = []
81
+
82
+ yield
83
+
84
+ flush_batch(buffer) if buffer.any?
85
+ ensure
86
+ Thread.current[:standard_audit_batch] = previous
87
+ end
88
+
66
89
  def subscriber
67
90
  @subscriber ||= Subscriber.new
68
91
  end
@@ -70,5 +93,43 @@ module StandardAudit
70
93
  def reset_configuration!
71
94
  @configuration = nil
72
95
  end
96
+
97
+ private
98
+
99
+ def batching?
100
+ Thread.current[:standard_audit_batch].is_a?(Array)
101
+ end
102
+
103
+ def flush_batch(buffer)
104
+ now = Time.current
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],
120
+ created_at: now,
121
+ updated_at: now
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
130
+ end
131
+
132
+ StandardAudit::AuditLog.insert_all!(rows)
133
+ end
73
134
  end
74
135
  end
@@ -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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -77,10 +77,13 @@ files:
77
77
  - MIT-LICENSE
78
78
  - README.md
79
79
  - Rakefile
80
+ - app/jobs/standard_audit/cleanup_job.rb
80
81
  - app/jobs/standard_audit/create_audit_log_job.rb
81
82
  - app/models/standard_audit/application_record.rb
82
83
  - app/models/standard_audit/audit_log.rb
83
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
84
87
  - lib/generators/standard_audit/install/install_generator.rb
85
88
  - lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb
86
89
  - lib/generators/standard_audit/install/templates/initializer.rb.erb
@@ -89,6 +92,7 @@ files:
89
92
  - lib/standard_audit/auditable.rb
90
93
  - lib/standard_audit/configuration.rb
91
94
  - lib/standard_audit/engine.rb
95
+ - lib/standard_audit/presets/standard_id.rb
92
96
  - lib/standard_audit/subscriber.rb
93
97
  - lib/standard_audit/version.rb
94
98
  - lib/tasks/standard_audit_tasks.rake
@@ -99,6 +103,7 @@ metadata:
99
103
  homepage_uri: https://github.com/rarebit-one/standard_audit
100
104
  source_code_uri: https://github.com/rarebit-one/standard_audit
101
105
  changelog_uri: https://github.com/rarebit-one/standard_audit/blob/main/CHANGELOG.md
106
+ bug_tracker_uri: https://github.com/rarebit-one/standard_audit/issues
102
107
  rdoc_options: []
103
108
  require_paths:
104
109
  - lib