standard_audit 0.12.1 → 0.13.1

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.
@@ -0,0 +1,208 @@
1
+ require "json"
2
+ require "openssl"
3
+
4
+ module StandardAudit
5
+ # The row digest. There are two algorithms; which one a row uses is decided
6
+ # by its `created_at` against `config.canonical_checksum_since` (default
7
+ # StandardAudit::CANONICAL_CHECKSUM_CUTOVER), at write time and again at
8
+ # verification, from the same stored timestamp. Nothing extra is stored.
9
+ #
10
+ # == Legacy (rows created before the cutover)
11
+ #
12
+ # `SHA256("<parent>|field=value|field=value|…")`, where a Hash value was
13
+ # serialised with `to_json` in whatever key order the Ruby hash had. That is
14
+ # the bug in fundbright/delivery-ops#689: at write time the hash is the one
15
+ # the caller built (insertion order), but PostgreSQL `jsonb` — and MySQL
16
+ # `JSON` — store object keys in their own order (shortest first, then
17
+ # bytewise), so the value read back serialises differently and the digest
18
+ # cannot be reproduced. SQLite keeps JSON as text, so the gem's own suite
19
+ # never saw it. Kept byte-for-byte so legacy rows are verified exactly as
20
+ # they were signed.
21
+ #
22
+ # == Canonical (rows created at or after the cutover)
23
+ #
24
+ # `SHA256(canonical_json({"fields" => {…}, "previous_checksum" => …, "v" => 2}))`.
25
+ # Every input is reduced to a form the database cannot change:
26
+ #
27
+ # * Hash / Array values (the `metadata` jsonb column, or any other JSON
28
+ # column a host adds to the hashed set) are first round-tripped through
29
+ # `ActiveSupport::JSON.encode` + `JSON.parse` — the same encoding the
30
+ # column type applies on write — so symbol keys, Time values, BigDecimals
31
+ # and the like hash as the JSON the database actually stores. Object keys
32
+ # are then sorted bytewise at every depth; array order is kept (arrays are
33
+ # ordered in every JSON store).
34
+ # * Integral floats hash as integers (`1.0` → `1`, `1e20` →
35
+ # `100000000000000000000`), because a JSON store is free to hand back
36
+ # either spelling of the same number.
37
+ # * Times hash as UTC ISO 8601 with microseconds, as in the legacy digest.
38
+ # * Strings are escaped at the byte level (`"`, `\` and C0 controls only),
39
+ # so the output does not depend on the json gem's escaping options.
40
+ # * nil stays `null`, distinct from `""` — the legacy digest conflated them.
41
+ # * Fields are encoded as a JSON object rather than joined with `|`, so a
42
+ # value containing `|field=` can no longer move content between adjacent
43
+ # fields without changing the digest.
44
+ #
45
+ # Known limit: a host with `ActiveSupport.parse_json_times = true` reads
46
+ # ISO 8601 strings in JSON back as Time objects, which re-encode at
47
+ # millisecond precision in the app's zone. A metadata string that was not
48
+ # already in that exact form would then hash differently on read. The
49
+ # setting is off by default.
50
+ module Checksum
51
+ LEGACY = 1
52
+ CANONICAL = 2
53
+ ALGORITHMS = [LEGACY, CANONICAL].freeze
54
+
55
+ TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%6NZ".freeze
56
+ ESCAPE = /["\\\x00-\x1f]/n
57
+
58
+ module_function
59
+
60
+ # The algorithm for a row created at `created_at`: canonical at or after
61
+ # `config.canonical_checksum_since`, legacy before it. A row not yet
62
+ # timestamped is judged by the current time — what a write now would use.
63
+ def algorithm_for(created_at)
64
+ created_at ||= Time.current
65
+ created_at >= StandardAudit.config.canonical_checksum_since ? CANONICAL : LEGACY
66
+ end
67
+
68
+ def digest(attrs, fields:, previous_checksum: nil, version:)
69
+ digester(attrs, fields: fields, version: version).call(previous_checksum)
70
+ end
71
+
72
+ # A callable `parent -> digest` for one row. The row's own serialisation
73
+ # is built once, so trying many candidate parents (the recovery search)
74
+ # costs one SHA-256 each.
75
+ def digester(attrs, fields:, version:)
76
+ case version
77
+ when LEGACY then legacy_digester(attrs, fields: fields)
78
+ when CANONICAL then canonical_digester(attrs, fields: fields)
79
+ else raise ArgumentError, "unknown checksum algorithm #{version.inspect} (known: #{ALGORITHMS.join(", ")})"
80
+ end
81
+ end
82
+
83
+ # The legacy digest, unchanged since 0.3. Do not "fix" this: it is how
84
+ # every pre-cutover row is signed, and it must keep reproducing them.
85
+ def legacy_digest(attrs, fields:, previous_checksum: nil)
86
+ legacy_digester(attrs, fields: fields).call(previous_checksum)
87
+ end
88
+
89
+ def legacy_digester(attrs, fields:)
90
+ canonical = fields.map { |f|
91
+ value = attrs[f]
92
+ value = value.to_json if value.is_a?(Hash)
93
+ value = value.utc.strftime(TIME_FORMAT) if time_like?(value)
94
+ "#{f}=#{value}"
95
+ }.join("|")
96
+
97
+ lambda do |previous_checksum|
98
+ input = previous_checksum.present? ? "#{previous_checksum}|#{canonical}" : canonical
99
+ OpenSSL::Digest::SHA256.hexdigest(input)
100
+ end
101
+ end
102
+
103
+ def canonical_digest(attrs, fields:, previous_checksum: nil)
104
+ canonical_digester(attrs, fields: fields).call(previous_checksum)
105
+ end
106
+
107
+ # SHA-256 of canonical_json(canonical_payload(...)). The payload's keys
108
+ # sort as "fields" < "previous_checksum" < "v", so the row's part is
109
+ # serialised once and only the parent is spliced in per call — the
110
+ # result is byte-identical to serialising the whole payload.
111
+ def canonical_digester(attrs, fields:)
112
+ head = String.new("{\"fields\":", encoding: Encoding::BINARY)
113
+ canonical_json(fields.to_h { |f| [f, canonical_value(attrs[f])] }, head)
114
+ head << ",\"previous_checksum\":"
115
+ tail = ",\"v\":#{CANONICAL}}"
116
+
117
+ lambda do |previous_checksum|
118
+ parent = previous_checksum.presence
119
+ OpenSSL::Digest::SHA256.hexdigest(head + (parent ? json_string(parent.to_s, String.new(encoding: Encoding::BINARY)) : "null") + tail)
120
+ end
121
+ end
122
+
123
+ # The documented canonical payload for a row ("v" => 2 names the format), as a plain Hash.
124
+ def canonical_payload(attrs, fields:, previous_checksum: nil)
125
+ {
126
+ "fields" => fields.to_h { |f| [f, canonical_value(attrs[f])] },
127
+ "previous_checksum" => previous_checksum.presence,
128
+ "v" => CANONICAL
129
+ }
130
+ end
131
+
132
+ # The stored-form value of one field, before canonical encoding.
133
+ def canonical_value(value)
134
+ case value
135
+ when nil, String, Integer, Float, true, false then value
136
+ when Hash, Array then json_round_trip(value)
137
+ else time_like?(value) ? value.utc.strftime(TIME_FORMAT) : value.to_s
138
+ end
139
+ end
140
+
141
+ # What a JSON column hands back for `value`: encoded exactly as
142
+ # ActiveRecord's JSON type encodes on write, then parsed. Key ORDER is
143
+ # the one thing this does not settle — canonical_json does.
144
+ def json_round_trip(value)
145
+ JSON.parse(ActiveSupport::JSON.encode(value), max_nesting: false, create_additions: false)
146
+ end
147
+
148
+ # Deterministic JSON: object keys sorted bytewise at every depth, no
149
+ # whitespace, integral floats as integers, byte-level string escaping.
150
+ # Returns a binary String.
151
+ def canonical_json(value, out = String.new(encoding: Encoding::BINARY))
152
+ case value
153
+ when Hash
154
+ out << "{"
155
+ value.map { |k, v| [k.to_s.b, v] }.sort_by(&:first).each_with_index do |(k, v), i|
156
+ out << "," if i.positive?
157
+ json_string(k, out)
158
+ out << ":"
159
+ canonical_json(v, out)
160
+ end
161
+ out << "}"
162
+ when Array
163
+ out << "["
164
+ value.each_with_index do |v, i|
165
+ out << "," if i.positive?
166
+ canonical_json(v, out)
167
+ end
168
+ out << "]"
169
+ when nil then out << "null"
170
+ when true then out << "true"
171
+ when false then out << "false"
172
+ when Integer then out << value.to_s
173
+ when Float then out << (value.finite? && value == value.floor ? value.to_i.to_s : value.to_s)
174
+ else json_string(value.to_s, out)
175
+ end
176
+ out
177
+ end
178
+
179
+ def json_string(string, out)
180
+ out << '"'
181
+ out << string.b.gsub(ESCAPE) do |c|
182
+ case c
183
+ when '"' then '\\"'
184
+ when "\\" then "\\\\"
185
+ else format("\\u%04x", c.ord)
186
+ end
187
+ end
188
+ out << '"'
189
+ end
190
+
191
+ def time_like?(value)
192
+ value.respond_to?(:strftime) && value.respond_to?(:utc)
193
+ end
194
+
195
+ # True when some JSON object inside `value` has more than one key, i.e.
196
+ # a store that reorders keys could have changed how the legacy digest serialised
197
+ # it. A row with no such object cannot fail the legacy digest because of key order.
198
+ def key_order_ambiguous?(value)
199
+ case value
200
+ when Hash then value.size > 1 || value.each_value.any? { |v| key_order_ambiguous?(v) }
201
+ when Array then value.any? { |v| key_order_ambiguous?(v) }
202
+ else false
203
+ end
204
+ end
205
+ end
206
+ end
207
+
208
+ require "standard_audit/checksum/key_order_search"
@@ -13,7 +13,8 @@ module StandardAudit
13
13
  :anonymizable_metadata_keys, :retention_days,
14
14
  :audit_catalogue, :verify_audit_declarations,
15
15
  :raise_on_audit_write_error, :audit_write_error_handler,
16
- :audit_error_context_key
16
+ :audit_error_context_key, :error_reporter,
17
+ :canonical_checksum_since
17
18
 
18
19
  def initialize
19
20
  @subscriptions = []
@@ -128,6 +129,16 @@ module StandardAudit
128
129
  # computed. See Configuration#before_checksum.
129
130
  @before_checksum_hooks = []
130
131
 
132
+ # Rows whose `created_at` is at or after this time are signed with the
133
+ # canonical checksum (object keys sorted, independent of how jsonb
134
+ # stores them) and verified strictly with it; earlier rows keep the
135
+ # legacy algorithm. Defaults to StandardAudit::CANONICAL_CHECKSUM_CUTOVER.
136
+ # Override only if your rollout of this release slips past that date,
137
+ # set it BEFORE the new time arrives, and never change it afterwards:
138
+ # verification recomputes the same decision from each row's stored
139
+ # created_at, so moving it re-judges rows under the other algorithm.
140
+ @canonical_checksum_since = StandardAudit::CANONICAL_CHECKSUM_CUTOVER
141
+
131
142
  @anonymizable_metadata_keys = %i[email name ip_address]
132
143
 
133
144
  # ── StandardAudit::Operation (the operation-audit DSL) ────────────────
@@ -191,6 +202,27 @@ module StandardAudit
191
202
  # improvement to the built-in reporter. Default keeps existing behaviour.
192
203
  @audit_error_context_key = :audit_action
193
204
 
205
+ # Where the gem sends the errors it swallows: a failed subscriber write,
206
+ # a failed `record(raise: false)`, a raising `before_checksum` hook, and
207
+ # a failed `audit!` write under the default report-and-swallow policy.
208
+ # A callable taking `(error, context)`, where context is a Hash such as
209
+ # `{ audit_action: "orders.created", subscriber: "StandardAudit::Subscriber" }`
210
+ # (keyed by `audit_error_context_key`).
211
+ #
212
+ # nil (the default) means `Rails.error.report(error, handled: true,
213
+ # context: context)`. That reaches Sentry only when a `Rails.error`
214
+ # subscriber is registered (sentry-rails registers one); a host that
215
+ # never forwards `Rails.error` can point this straight at its tracker:
216
+ #
217
+ # config.error_reporter = ->(error, context) { Sentry.capture_exception(error, extra: context) }
218
+ #
219
+ # It replaces the built-in `Rails.error` call; call `Rails.error.report`
220
+ # from it too if you want both. A reporter that raises is logged and
221
+ # ignored, so it can never turn a swallowed audit failure into a raised
222
+ # one. `audit_write_error_handler`, when set, still takes precedence for
223
+ # `audit!` write failures.
224
+ @error_reporter = nil
225
+
194
226
  # Retention defaults from ENV so it can be set per-environment without a
195
227
  # code change. Unset/blank/non-positive => nil (infinite retention, the
196
228
  # compliance-safe default that never auto-deletes). A host app can still
@@ -1,7 +1,10 @@
1
1
  module StandardAudit
2
+ # A plain (non-isolated) engine: it contributes the AuditLog model, the two
3
+ # jobs and the subscriber wiring, and has no routes, controllers or views.
4
+ # 0.13.0 dropped `isolate_namespace` and the empty `config/routes.rb` it
5
+ # carried. Nothing depended on them: AuditLog sets its own `table_name`, and
6
+ # no host mounted the engine.
2
7
  class Engine < ::Rails::Engine
3
- isolate_namespace StandardAudit
4
-
5
8
  initializer "standard_audit.subscriber" do
6
9
  ActiveSupport.on_load(:active_record) do
7
10
  StandardAudit.subscriber.setup!
@@ -47,7 +47,8 @@ module StandardAudit
47
47
  ip_address: context[:ip_address] || payload[:ip_address],
48
48
  user_agent: context[:user_agent] || payload[:user_agent],
49
49
  session_id: context[:session_id] || payload[:session_id]
50
- }
50
+ },
51
+ via: :rails_event
51
52
  )
52
53
  rescue => e
53
54
  StandardAudit.report_write_error(e, name, subscriber: self.class.name)
@@ -282,13 +282,9 @@ module StandardAudit
282
282
  message = "[StandardAudit] Failed to record #{action}: #{error.class} #{error.message}"
283
283
  Rails.logger&.error(message) if defined?(Rails) && Rails.respond_to?(:logger)
284
284
 
285
- return unless defined?(Rails) && Rails.respond_to?(:error) && Rails.error
286
-
287
- Rails.error.report(
285
+ StandardAudit.report_error(
288
286
  error,
289
- handled: true,
290
- context: { StandardAudit.config.audit_error_context_key => action,
291
- operation: operation.class.name }
287
+ { StandardAudit.config.audit_error_context_key => action, operation: operation.class.name }
292
288
  )
293
289
  end
294
290
  end
@@ -18,12 +18,19 @@ require "standard_audit"
18
18
  # catalogue: -> { AuditCatalogue::ACTIONS },
19
19
  # sensitive_keys: %i[source_payload],
20
20
  # sensitive_key_patterns: [/secret/i],
21
- # present: %i[metadata_builder before_write current_scope_resolver]
21
+ # present: %i[metadata_builder before_write current_scope_resolver],
22
+ # hooks: 2 # or %i[backfill_scope classify_actor]
22
23
  # end
23
24
  #
24
25
  # Every option is optional. Behaviour held in lambdas (resolvers, builders)
25
26
  # can't be compared by value, so list them under `present:` to assert they
26
27
  # survive a reset, and keep an app-specific example for what they return.
28
+ #
29
+ # `hooks:` covers `before_checksum` hooks, which live in a list rather than a
30
+ # named setting. Pass an Integer to assert exactly that many are registered,
31
+ # or an Array of the Symbol hook names (`config.before_checksum :name`) to
32
+ # assert each is registered. The mutation example also clears the hooks
33
+ # before the reset, so it fails if the baseline block doesn't re-add them.
27
34
  RSpec.shared_examples "a standard_audit baseline" do |options = {}|
28
35
  subscriptions = Array(options[:subscriptions])
29
36
  settings = options.fetch(:settings, {})
@@ -31,9 +38,14 @@ RSpec.shared_examples "a standard_audit baseline" do |options = {}|
31
38
  sensitive_keys = Array(options[:sensitive_keys])
32
39
  sensitive_key_patterns = Array(options[:sensitive_key_patterns])
33
40
  present = Array(options[:present])
41
+ hooks = options[:hooks]
42
+
43
+ unless hooks.nil? || hooks.is_a?(Integer) || (hooks.is_a?(Array) && hooks.all? { |h| h.is_a?(Symbol) || h.is_a?(String) })
44
+ raise ArgumentError, "hooks: must be an Integer (hook count) or an Array of Symbol hook names; got #{hooks.inspect}"
45
+ end
34
46
 
35
47
  def standard_audit_baseline_assertions(subscriptions:, settings:, catalogue:, sensitive_keys:,
36
- sensitive_key_patterns:, present:)
48
+ sensitive_key_patterns:, present:, hooks:)
37
49
  config = StandardAudit.config
38
50
 
39
51
  expect(config.subscriptions).to include(*subscriptions) if subscriptions.any?
@@ -49,11 +61,22 @@ RSpec.shared_examples "a standard_audit baseline" do |options = {}|
49
61
  present.each do |name|
50
62
  expect(config.public_send(name)).not_to be_nil, "expected config.#{name} to survive a reset"
51
63
  end
64
+ case hooks
65
+ when Integer
66
+ expect(config.before_checksum_hooks.size).to eq(hooks),
67
+ "expected #{hooks} before_checksum hook(s) after a reset, found #{config.before_checksum_hooks.size}"
68
+ when Array
69
+ registered = config.before_checksum_hooks.select { |h| h.is_a?(Symbol) || h.is_a?(String) }.map(&:to_sym)
70
+ hooks.map(&:to_sym).each do |name|
71
+ expect(registered).to include(name), "expected before_checksum :#{name} to survive a reset"
72
+ end
73
+ end
52
74
  end
53
75
 
54
76
  let(:standard_audit_baseline_options) do
55
77
  { subscriptions: subscriptions, settings: settings, catalogue: catalogue,
56
- sensitive_keys: sensitive_keys, sensitive_key_patterns: sensitive_key_patterns, present: present }
78
+ sensitive_keys: sensitive_keys, sensitive_key_patterns: sensitive_key_patterns, present: present,
79
+ hooks: hooks }
57
80
  end
58
81
 
59
82
  it "is registered with configure(baseline: true)" do
@@ -71,6 +94,7 @@ RSpec.shared_examples "a standard_audit baseline" do |options = {}|
71
94
  StandardAudit.config.subscribe_to "standard_audit.isolation_canary"
72
95
  settings.each_key { |name| StandardAudit.config.public_send(:"#{name}=", nil) }
73
96
  present.each { |name| StandardAudit.config.public_send(:"#{name}=", nil) }
97
+ StandardAudit.config.before_checksum_hooks = [] unless hooks.nil?
74
98
 
75
99
  StandardAudit.reset_configuration!
76
100
 
@@ -46,7 +46,8 @@ module StandardAudit
46
46
  target: config.target_extractor.call(payload),
47
47
  scope: config.scope_extractor.call(payload),
48
48
  metadata: payload.except(*EXCLUDED_PAYLOAD_KEYS),
49
- context: payload.slice(:request_id, :ip_address, :user_agent, :session_id)
49
+ context: payload.slice(:request_id, :ip_address, :user_agent, :session_id),
50
+ via: :notification
50
51
  )
51
52
  rescue => e
52
53
  StandardAudit.report_write_error(e, event.name, subscriber: self.class.name)
@@ -1,3 +1,3 @@
1
1
  module StandardAudit
2
- VERSION = "0.12.1"
2
+ VERSION = "0.13.1"
3
3
  end
@@ -1,6 +1,17 @@
1
1
  require "standard_audit/version"
2
+
3
+ module StandardAudit
4
+ # From this instant (by each row's `created_at`) new rows get the canonical
5
+ # checksum and are verified strictly with it; earlier rows are legacy. See
6
+ # StandardAudit::Checksum and fundbright/delivery-ops#689. Every host must
7
+ # run 0.13.1+ BEFORE this time: an older gem still writing after it produces
8
+ # legacy-hashed rows that fail strict canonical verification.
9
+ # Overridable per host via `config.canonical_checksum_since`.
10
+ CANONICAL_CHECKSUM_CUTOVER = Time.utc(2026, 10, 1, 0, 0, 0).freeze
11
+ end
2
12
  require "standard_audit/engine"
3
13
  require "standard_audit/configuration"
14
+ require "standard_audit/checksum"
4
15
  require "standard_audit/metadata_filter"
5
16
  require "standard_audit/record_reference"
6
17
  require "standard_audit/sensitive_keys_dry_run"
@@ -17,6 +28,16 @@ module StandardAudit
17
28
  # `sensitive_keys` even if a user adds them there.
18
29
  RESERVED_METADATA_KEYS = %w[_tags _source].freeze
19
30
 
31
+ # Values of `entry[:via]` as `before_write` sees it:
32
+ #
33
+ # * `:direct` — `StandardAudit.record` without a block, and therefore
34
+ # `Auditable#record_audit` and `Operation#audit!`.
35
+ # * `:notification` — the ActiveSupport::Notifications subscriber
36
+ # (`subscribe_to` patterns, and `StandardAudit.record` WITH a block, which
37
+ # instruments the event and lets this subscriber write it).
38
+ # * `:rails_event` — the `Rails.event` subscriber (Rails 8.1+).
39
+ VIA = %i[direct notification rails_event].freeze
40
+
20
41
  class << self
21
42
  # Applies configuration to the single mutable Configuration instance.
22
43
  #
@@ -58,7 +79,8 @@ module StandardAudit
58
79
  # here), so it records only when the event is subscribed to.
59
80
  #
60
81
  # `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
82
+ # reported through `config.error_reporter` (default: `Rails.error`, as
83
+ # handled), and nil is returned. For call
62
84
  # sites where a missing audit row must never break the request (auth
63
85
  # failure logging, say). It governs only the audit write — in block form
64
86
  # the subscriber already rescues, and the block's own errors always
@@ -80,7 +102,7 @@ module StandardAudit
80
102
 
81
103
  begin
82
104
  write_entry(event_type, actor: actor, target: target, scope: scope,
83
- metadata: metadata, context: options)
105
+ metadata: metadata, context: options, via: :direct)
84
106
  rescue => e
85
107
  raise if raise_errors
86
108
 
@@ -96,7 +118,11 @@ module StandardAudit
96
118
  # values; nil entries fall back to the Current resolvers. `reserved` is
97
119
  # merged into metadata AFTER `metadata_builder` (the Rails.event subscriber
98
120
  # 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: {})
121
+ # `via` names the entry point (see VIA) and is handed to `before_write`
122
+ # as `entry[:via]`; it is not persisted.
123
+ def write_entry(event_type, actor:, target:, scope:, metadata:, context: {}, reserved: {}, via: :direct)
124
+ raise ArgumentError, "via must be one of #{VIA.inspect}; got #{via.inspect}" unless VIA.include?(via)
125
+
100
126
  actor ||= config.current_actor_resolver.call
101
127
  scope ||= config.current_scope_resolver&.call
102
128
 
@@ -113,14 +139,16 @@ module StandardAudit
113
139
  request_id: context[:request_id] || config.current_request_id_resolver.call,
114
140
  ip_address: context[:ip_address] || config.current_ip_address_resolver.call,
115
141
  user_agent: context[:user_agent] || config.current_user_agent_resolver.call,
116
- session_id: context[:session_id] || config.current_session_id_resolver.call
142
+ session_id: context[:session_id] || config.current_session_id_resolver.call,
143
+ via: via
117
144
  }
118
145
 
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.
146
+ # Runs on EVERY path, AFTER `metadata_builder` and before redaction, so
147
+ # it sees the builder's output and anything it injects into metadata is
148
+ # still subject to `sensitive_keys` and dereferencing. It may mutate
149
+ # `entry` in place; its return value is ignored. It may raise — that is
150
+ # how a host guard rejects a write — and the error propagates exactly as
151
+ # a failed save would. `entry[:via]` says which entry point wrote it.
124
152
  config.before_write&.call(entry)
125
153
 
126
154
  persist(entry)
@@ -147,20 +175,29 @@ module StandardAudit
147
175
  Thread.current[:standard_audit_batch] = previous
148
176
  end
149
177
 
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.
178
+ # @api private — logs a failed audit write and reports it through
179
+ # `report_error`. Used by the subscribers, which must never let an audit
180
+ # failure break the instrumented code path, and by `record(raise: false)`.
153
181
  def report_write_error(error, event_type, **context)
154
182
  Rails.logger.error("[StandardAudit] Error creating audit log for #{event_type}: #{error.class}: #{error.message}")
155
- return unless Rails.respond_to?(:error) && Rails.error
183
+ report_error(error, { config.audit_error_context_key => event_type, **context })
184
+ end
156
185
 
157
- Rails.error.report(
158
- error,
159
- handled: true,
160
- context: { config.audit_error_context_key => event_type, **context }
161
- )
186
+ # @api private — the one place the gem reports an error it swallows.
187
+ # Calls `config.error_reporter` when set, otherwise
188
+ # `Rails.error.report(error, handled: true, context:)`. A reporter that
189
+ # raises is logged and ignored.
190
+ def report_error(error, context)
191
+ if config.error_reporter
192
+ config.error_reporter.call(error, context)
193
+ elsif defined?(Rails) && Rails.respond_to?(:error) && Rails.error
194
+ Rails.error.report(error, handled: true, context: context)
195
+ end
196
+ nil
162
197
  rescue => report_failure
163
- Rails.logger.error("[StandardAudit] Error reporting audit failure: #{report_failure.class}: #{report_failure.message}")
198
+ message = "[StandardAudit] Error reporting audit failure: #{report_failure.class}: #{report_failure.message}"
199
+ Rails.logger&.error(message) if defined?(Rails) && Rails.respond_to?(:logger)
200
+ nil
164
201
  end
165
202
 
166
203
  def subscriber
@@ -260,6 +297,8 @@ module StandardAudit
260
297
  created_at: now,
261
298
  updated_at: now
262
299
  ))
300
+ # created_at is set above, so the algorithm is decided by the same
301
+ # stored timestamp verification will read.
263
302
  checksum = StandardAudit::AuditLog.compute_checksum_value(
264
303
  row.stringify_keys,
265
304
  previous_checksum: previous_checksum
@@ -85,17 +85,34 @@ namespace :standard_audit do
85
85
 
86
86
  desc "Verify audit log chain integrity (tamper detection)"
87
87
  task verify: :environment do
88
- result = StandardAudit::AuditLog.verify_chain
88
+ # FAIL_ON_LEGACY_UNVERIFIABLE=1 — treat pre-cutover rows whose metadata
89
+ # key order cannot be reconstructed as failures (see
90
+ # AuditLog.verify_chain). KEY_ORDER_SEARCH_LIMIT=5040 — orderings tried
91
+ # per legacy row.
92
+ options = {}
93
+ options[:fail_on_legacy_unverifiable] = true if %w[1 true].include?(ENV["FAIL_ON_LEGACY_UNVERIFIABLE"])
94
+ if (limit = ENV["KEY_ORDER_SEARCH_LIMIT"].presence)
95
+ options[:key_order_search_limit] = Integer(limit, 10)
96
+ end
97
+
98
+ result = StandardAudit::AuditLog.verify_chain(**options)
89
99
 
90
100
  puts "Audit Log Chain Verification"
91
101
  puts "============================="
102
+ puts "Canonical checksums since: #{StandardAudit.config.canonical_checksum_since.utc.iso8601}"
92
103
  puts "Records verified: #{result[:verified]}"
93
104
  puts "Chain valid: #{result[:valid]}"
94
105
  puts "Forked links recovered: #{result[:recovered]}"
106
+ puts "Legacy rows verified by key-order reconstruction: #{result[:reordered]}" if result[:reordered].to_i.positive?
95
107
  puts "Anonymized (redacted) records: #{result[:redacted]}" if result[:redacted].to_i.positive?
108
+ if result[:legacy_unverifiable].to_i.positive?
109
+ puts "Legacy rows unverifiable (metadata key order lost): #{result[:legacy_unverifiable]} " \
110
+ "— cannot be proven either way; this count must not grow after the cutover"
111
+ end
96
112
 
97
113
  if result[:failures].any?
98
114
  puts "\nUnverifiable records detected: #{result[:failures].size}"
115
+ result[:failures].map { |failure| failure[:reason] }.tally.each { |reason, n| puts " #{reason}: #{n}" }
99
116
  result[:failures].each do |failure|
100
117
  puts " #{failure[:id]} (#{failure[:event_type]}) at #{failure[:created_at]} — #{failure[:reason]}"
101
118
  end
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.12.1
4
+ version: 0.13.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -15,42 +15,42 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '8.0'
18
+ version: '8.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '8.0'
25
+ version: '8.1'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: activejob
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '8.0'
32
+ version: '8.1'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: '8.0'
39
+ version: '8.1'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: activesupport
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: '8.0'
46
+ version: '8.1'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
- version: '8.0'
53
+ version: '8.1'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: globalid
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -96,20 +96,20 @@ files:
96
96
  - app/jobs/standard_audit/create_audit_log_job.rb
97
97
  - app/models/standard_audit/application_record.rb
98
98
  - app/models/standard_audit/audit_log.rb
99
- - config/routes.rb
100
99
  - lib/generators/standard_audit/add_anonymized_at/add_anonymized_at_generator.rb
101
100
  - lib/generators/standard_audit/add_anonymized_at/templates/add_anonymized_at_to_audit_logs.rb.erb
102
- - lib/generators/standard_audit/add_checksums/add_checksums_generator.rb
103
- - lib/generators/standard_audit/add_checksums/templates/add_checksum_to_audit_logs.rb.erb
104
101
  - lib/generators/standard_audit/add_previous_checksum/add_previous_checksum_generator.rb
105
102
  - lib/generators/standard_audit/add_previous_checksum/templates/add_previous_checksum_to_audit_logs.rb.erb
106
103
  - lib/generators/standard_audit/install/install_generator.rb
107
104
  - lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb
108
105
  - lib/generators/standard_audit/install/templates/initializer.rb.erb
106
+ - lib/generators/standard_audit/migration_number.rb
109
107
  - lib/standard_audit.rb
110
108
  - lib/standard_audit/audit_scope.rb
111
109
  - lib/standard_audit/auditable.rb
112
110
  - lib/standard_audit/checks/retention.rb
111
+ - lib/standard_audit/checksum.rb
112
+ - lib/standard_audit/checksum/key_order_search.rb
113
113
  - lib/standard_audit/configuration.rb
114
114
  - lib/standard_audit/engine.rb
115
115
  - lib/standard_audit/event_subscriber.rb
@@ -148,7 +148,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
148
148
  - !ruby/object:Gem::Version
149
149
  version: '0'
150
150
  requirements: []
151
- rubygems_version: 4.0.3
151
+ rubygems_version: 4.0.10
152
152
  specification_version: 4
153
153
  summary: Database-backed audit logging for Rails via Rails.event and ActiveSupport::Notifications.
154
154
  test_files: []
data/config/routes.rb DELETED
@@ -1,2 +0,0 @@
1
- StandardAudit::Engine.routes.draw do
2
- end