standard_audit 0.7.0 → 0.9.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.
@@ -0,0 +1,17 @@
1
+ module StandardAudit
2
+ module Generators
3
+ class AddPreviousChecksumGenerator < 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_previous_checksum_to_audit_logs.rb.erb",
13
+ "db/migrate/add_previous_checksum_to_audit_logs.rb"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,23 @@
1
+ class AddPreviousChecksumToAuditLogs < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ # On PostgreSQL with a large audit_logs table, add
4
+ # `disable_ddl_transaction!` above and pass `algorithm: :concurrently` to
5
+ # the add_index calls — StrongMigrations will ask for it.
6
+ #
7
+ # Nullable and index-only: no data is rewritten. Existing rows keep a NULL
8
+ # parent and are verified by position and parent recovery, exactly as they
9
+ # are today. Run `rake standard_audit:relink_checksums` afterwards to
10
+ # record the parent each existing row was actually signed against.
11
+ add_column :audit_logs, :previous_checksum, :string, limit: 64
12
+ # Not used by the gem itself: verification reads previous_checksum off rows
13
+ # it has already loaded. It is here for forensics — "what forked off this
14
+ # row?" — which is the question you ask once verification flags something.
15
+ add_index :audit_logs, :previous_checksum
16
+ # verify_chain / backfill_checksums! walk the table with a keyset cursor
17
+ # ordered by (created_at, id).
18
+ add_index :audit_logs, [:created_at, :id] unless index_exists?(:audit_logs, [:created_at, :id])
19
+ # verify_chain looks a declared parent up by digest when it falls outside
20
+ # the in-memory recovery window — the path that reports a removed row.
21
+ add_index :audit_logs, :checksum unless index_exists?(:audit_logs, :checksum)
22
+ end
23
+ end
@@ -17,6 +17,10 @@ class CreateAuditLogs < ActiveRecord::Migration[<%= ActiveRecord::Migration.curr
17
17
  t.jsonb :metadata, default: {}
18
18
  t.datetime :occurred_at, null: false
19
19
  t.string :checksum, limit: 64
20
+ # The digest of the row this one was chained to. Concurrent writers can
21
+ # share a parent, so the log is a DAG rather than a strict line; storing
22
+ # the parent is what keeps every row verifiable when it forks.
23
+ t.string :previous_checksum, limit: 64
20
24
  t.timestamps
21
25
  end
22
26
 
@@ -27,6 +31,17 @@ class CreateAuditLogs < ActiveRecord::Migration[<%= ActiveRecord::Migration.curr
27
31
  add_index :audit_logs, :request_id
28
32
  add_index :audit_logs, [:occurred_at, :created_at]
29
33
  add_index :audit_logs, :created_at
34
+ # verify_chain / backfill_checksums! walk the table with a keyset cursor
35
+ # ordered by (created_at, id); the composite index makes each page an index
36
+ # range scan rather than a scan plus filter.
37
+ add_index :audit_logs, [:created_at, :id]
38
+ # verify_chain looks a declared parent up by digest when it falls outside
39
+ # the in-memory recovery window — the path that reports a removed row.
40
+ add_index :audit_logs, :checksum
41
+ # Not used by the gem itself: verification reads previous_checksum off rows
42
+ # it has already loaded. It is here for forensics — "what forked off this
43
+ # row?" — which is the question you ask once verification flags something.
44
+ add_index :audit_logs, :previous_checksum
30
45
  add_index :audit_logs, :session_id
31
46
  # GIN index requires PostgreSQL; remove if using another database
32
47
  add_index :audit_logs, :metadata, using: :gin
@@ -8,7 +8,9 @@ module StandardAudit
8
8
  :sensitive_keys, :sensitive_key_patterns,
9
9
  :sensitive_key_exceptions, :filter_nested_metadata,
10
10
  :metadata_builder, :before_checksum_hooks,
11
- :anonymizable_metadata_keys, :retention_days
11
+ :anonymizable_metadata_keys, :retention_days,
12
+ :audit_catalogue, :verify_audit_declarations,
13
+ :raise_on_audit_write_error, :audit_write_error_handler
12
14
 
13
15
  def initialize
14
16
  @subscriptions = []
@@ -78,6 +80,50 @@ module StandardAudit
78
80
 
79
81
  @anonymizable_metadata_keys = %i[email name ip_address]
80
82
 
83
+ # ── StandardAudit::Operation (the operation-audit DSL) ────────────────
84
+
85
+ # The host's canonical action vocabulary, used by `audit!` to reject an
86
+ # action absent from it. Normally a CALLABLE, because referencing an
87
+ # autoloadable constant eagerly from an initializer pins the first-loaded
88
+ # copy and breaks Zeitwerk reloading:
89
+ #
90
+ # config.audit_catalogue = -> { AuditCatalogue::ACTIONS }
91
+ #
92
+ # A plain Array is accepted when the vocabulary really is a frozen
93
+ # literal. `nil` (the default) skips the membership check entirely, so
94
+ # the DSL is adoptable before an app has a catalogue.
95
+ #
96
+ # Membership is the ONLY rule applied — see StandardAudit::Operation.
97
+ @audit_catalogue = nil
98
+
99
+ # Whether `audit!` verifies the declaration before writing. A callable
100
+ # (or a plain boolean). Defaults to local environments only: in
101
+ # production `audit!` just writes, because a developer's declaration
102
+ # mistake must not 500 a user — the host's meta-spec is the real gate.
103
+ @verify_audit_declarations = -> {
104
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.respond_to?(:local?) && Rails.env.local?
105
+ }
106
+
107
+ # Whether a failed audit *write* aborts the operation. `false` (report
108
+ # and swallow) matches four of the five apps this DSL was extracted from.
109
+ # Set `true` where an unaudited state change is itself a compliance
110
+ # failure — one app deliberately does not rescue, and defaulting to
111
+ # swallow would have silently downgraded that posture.
112
+ #
113
+ # StandardAudit::Operation::DeclarationError is NEVER governed by this;
114
+ # it always propagates.
115
+ @raise_on_audit_write_error = false
116
+
117
+ # Optional callable invoked instead of the built-in logger/Rails.error
118
+ # reporting when an audit write fails:
119
+ #
120
+ # config.audit_write_error_handler =
121
+ # ->(error, action:, operation:) { ErrorReporting.notify(error, ...) }
122
+ #
123
+ # Runs before `raise_on_audit_write_error` is applied, so it sees every
124
+ # write failure under either policy.
125
+ @audit_write_error_handler = nil
126
+
81
127
  # Retention defaults from ENV so it can be set per-environment without a
82
128
  # code change. Unset/blank/non-positive => nil (infinite retention, the
83
129
  # compliance-safe default that never auto-deletes). A host app can still
@@ -0,0 +1,253 @@
1
+ module StandardAudit
2
+ module Operation
3
+ # The registry, the catalogue resolver, the write-error policy, and the
4
+ # analysis predicates behind the operation-audit meta-spec.
5
+ #
6
+ # Everything here is PLAIN RUBY that returns data — no RSpec, no
7
+ # assertions. A host can call these from a bespoke spec, a rake task, or a
8
+ # CI script. `standard_audit/rspec/operation` is a thin shared-example
9
+ # layer over exactly these methods and adds no logic of its own.
10
+ #
11
+ # StandardAudit::Operation::Audit.undeclared # => [Class, ...]
12
+ # StandardAudit::Operation::Audit.unknown_actions # => { Class => ["x.y"] }
13
+ # StandardAudit::Operation::Audit.orphan_actions # => ["x.y"]
14
+ # StandardAudit::Operation::Audit.missing_write_sites # => [Class, ...]
15
+ module Audit
16
+ # Heuristic used by the source-scanning predicates: a call to the private
17
+ # `audit!` helper, with or without parentheses, not preceded by a receiver
18
+ # (so `foo.audit!` and `Something::audit!` don't count).
19
+ #
20
+ # The scan is FILE-scoped, not class-scoped — two operations defined in
21
+ # one file share a verdict. Zeitwerk requires one class per file in a
22
+ # real app, so this only matters for fixtures. A commented-out or
23
+ # documented `audit!` in the same file also counts as a write site; the
24
+ # predicates deliberately err towards a false pass rather than a false
25
+ # failure, since the runtime guard is the authoritative check.
26
+ WRITE_SITE_PATTERN = /(?<![\w.:])audit!\s*(?:\(|["':@$\w])/
27
+
28
+ class << self
29
+ # ── Registry ────────────────────────────────────────────────────────
30
+
31
+ # Every class that has gained the contract, in registration order.
32
+ # Includes bases, intermediates, and anonymous classes — use
33
+ # {.operations} for the filtered view a meta-spec should assert on.
34
+ def registered
35
+ @registered ||= []
36
+ end
37
+
38
+ def register(klass)
39
+ registered << klass unless registered.include?(klass)
40
+ klass
41
+ end
42
+
43
+ # Empties the registry. For the gem's own specs and for hosts that
44
+ # rebuild the constant graph mid-suite; a normal suite never needs it.
45
+ def reset_registry!
46
+ @registered = []
47
+ end
48
+
49
+ # The real operations a meta-spec should hold to the contract.
50
+ #
51
+ # Excluded:
52
+ # - anonymous classes (no name) — test doubles and `Class.new` fixtures
53
+ # - classes that declared `audit_abstract!`
54
+ # - classes that declare NOTHING and have subclasses — i.e. a shared
55
+ # base such as `ApplicationOperation`. This is what lets an app
56
+ # adopt the whole contract with one `include` on its base class and
57
+ # no further configuration. A class that DID declare is never
58
+ # excluded by this rule, even if it is subclassed, so subclassing a
59
+ # real operation cannot quietly drop it from the check.
60
+ #
61
+ # @param source [String, nil] keep only classes whose defining file path
62
+ # contains this fragment, e.g. `"/app/operations/"`. Recommended: it
63
+ # scopes the check to the host's own operations and drops anything
64
+ # defined in a spec file.
65
+ def operations(source: nil)
66
+ registered.select do |klass|
67
+ next false if klass.name.nil? || klass.name.empty?
68
+ next false if klass.audit_spec == :abstract
69
+ next false if klass.audit_spec.nil? && subclassed?(klass)
70
+ next true if source.nil?
71
+
72
+ source_path(klass)&.include?(source)
73
+ end
74
+ end
75
+
76
+ # Absolute path of the file that defines `klass`, or nil.
77
+ def source_path(klass)
78
+ return nil if klass.name.nil? || klass.name.empty?
79
+
80
+ Object.const_source_location(klass.name)&.first
81
+ rescue NameError
82
+ nil
83
+ end
84
+
85
+ # ── Catalogue ───────────────────────────────────────────────────────
86
+
87
+ # The host's action vocabulary as an Array of Strings, or nil when no
88
+ # catalogue is configured (in which case membership is not checked).
89
+ #
90
+ # `config.audit_catalogue` is normally a callable — see the note in
91
+ # StandardAudit::Operation on why an eagerly-referenced autoloadable
92
+ # constant breaks Zeitwerk reloading. A plain Array is accepted for the
93
+ # case where the vocabulary really is a frozen literal.
94
+ def catalogue
95
+ raw = StandardAudit.config.audit_catalogue
96
+ return nil if raw.nil?
97
+
98
+ resolved = raw.respond_to?(:call) ? raw.call : raw
99
+ return nil if resolved.nil?
100
+
101
+ Array(resolved).map(&:to_s)
102
+ end
103
+
104
+ # ── Policy ──────────────────────────────────────────────────────────
105
+
106
+ # Whether `audit!` verifies declarations before writing. Defaults to
107
+ # "local environments only" — production writes silently, because a
108
+ # developer's declaration mistake must not 500 a user.
109
+ def verify?
110
+ resolver = StandardAudit.config.verify_audit_declarations
111
+ resolver.respond_to?(:call) ? !!resolver.call : !!resolver
112
+ end
113
+
114
+ # Applies the configured policy to a failed audit *write* (never to a
115
+ # DeclarationError, which `audit!` re-raises first).
116
+ #
117
+ # @return [nil] when the error is swallowed
118
+ # @raise [StandardError] the original error when
119
+ # `config.raise_on_audit_write_error` is true
120
+ def handle_write_error(error, action:, operation:)
121
+ handler = StandardAudit.config.audit_write_error_handler
122
+
123
+ if handler
124
+ handler.call(error, action: action, operation: operation)
125
+ else
126
+ report_write_error(error, action: action, operation: operation)
127
+ end
128
+
129
+ raise error if StandardAudit.config.raise_on_audit_write_error
130
+
131
+ nil
132
+ end
133
+
134
+ # ── Predicates ──────────────────────────────────────────────────────
135
+
136
+ # Operations that declare neither `audits` nor `audit_none!`. A new
137
+ # mutating operation shipping with no audit trail shows up here.
138
+ #
139
+ # @return [Array<Class>]
140
+ def undeclared(operations: self.operations)
141
+ operations.select { |klass| klass.audit_spec.nil? }
142
+ end
143
+
144
+ # Declared actions that are absent from the configured catalogue.
145
+ # Empty when no catalogue is configured.
146
+ #
147
+ # @return [Hash{Class => Array<String>}]
148
+ def unknown_actions(operations: self.operations, catalogue: self.catalogue)
149
+ return {} if catalogue.nil?
150
+
151
+ operations.each_with_object({}) do |klass, acc|
152
+ unknown = declared_for(klass) - catalogue
153
+ acc[klass] = unknown if unknown.any?
154
+ end
155
+ end
156
+
157
+ # Catalogue entries no operation declares — dead vocabulary, which makes
158
+ # the catalogue a wish rather than a record.
159
+ #
160
+ # @param within [Array<String>, nil] check only this slice. Hosts whose
161
+ # catalogue also covers non-operation writers (a controller concern, a
162
+ # tool server, a notification-bus name) pass the operation-only slice
163
+ # here; the rest are written outside `app/operations/` and would
164
+ # always look orphaned.
165
+ # @return [Array<String>]
166
+ def orphan_actions(operations: self.operations, catalogue: self.catalogue, within: nil)
167
+ pool = within ? Array(within).map(&:to_s) : catalogue
168
+ return [] if pool.nil?
169
+
170
+ pool - declared_actions(operations: operations)
171
+ end
172
+
173
+ # Every action declared across the given operations, de-duplicated.
174
+ #
175
+ # @return [Array<String>]
176
+ def declared_actions(operations: self.operations)
177
+ operations.flat_map { |klass| declared_for(klass) }.uniq
178
+ end
179
+
180
+ # Catalogue entries listed more than once.
181
+ #
182
+ # @return [Array<String>]
183
+ def duplicate_catalogue_entries(catalogue: self.catalogue)
184
+ return [] if catalogue.nil?
185
+
186
+ catalogue.tally.select { |_action, count| count > 1 }.keys
187
+ end
188
+
189
+ # Operations that declare `audits` but whose source contains no `audit!`
190
+ # call — the declaration is aspirational and nothing writes the row.
191
+ #
192
+ # Source-based, therefore a heuristic: a class whose defining file can't
193
+ # be read is skipped rather than reported.
194
+ #
195
+ # @return [Array<Class>]
196
+ def missing_write_sites(operations: self.operations)
197
+ operations.select do |klass|
198
+ spec = klass.audit_spec
199
+ next false unless spec.is_a?(Array)
200
+
201
+ src = source_for(klass)
202
+ src && !src.match?(WRITE_SITE_PATTERN)
203
+ end
204
+ end
205
+
206
+ # Operations that declare `audit_none!` but whose source calls `audit!`
207
+ # anyway. The runtime guard catches this too, but only if that code path
208
+ # is exercised; this catches it statically.
209
+ #
210
+ # @return [Array<Class>]
211
+ def unexpected_write_sites(operations: self.operations)
212
+ operations.select do |klass|
213
+ next false unless klass.audit_spec == :none
214
+
215
+ src = source_for(klass)
216
+ src&.match?(WRITE_SITE_PATTERN)
217
+ end
218
+ end
219
+
220
+ private
221
+
222
+ def declared_for(klass)
223
+ spec = klass.audit_spec
224
+ spec.is_a?(Array) ? spec : []
225
+ end
226
+
227
+ def source_for(klass)
228
+ path = source_path(klass)
229
+ return nil unless path && File.exist?(path)
230
+
231
+ File.read(path)
232
+ end
233
+
234
+ def subclassed?(klass)
235
+ klass.respond_to?(:subclasses) && klass.subclasses.any?
236
+ end
237
+
238
+ def report_write_error(error, action:, operation:)
239
+ message = "[StandardAudit] Failed to record #{action}: #{error.class} #{error.message}"
240
+ Rails.logger&.error(message) if defined?(Rails) && Rails.respond_to?(:logger)
241
+
242
+ return unless defined?(Rails) && Rails.respond_to?(:error) && Rails.error
243
+
244
+ Rails.error.report(
245
+ error,
246
+ handled: true,
247
+ context: { audit_action: action, operation: operation.class.name }
248
+ )
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,205 @@
1
+ require "active_support/concern"
2
+ require "standard_audit/operation/audit"
3
+
4
+ module StandardAudit
5
+ # Operation-level audit contract, extracted from five independent copies of
6
+ # the same DSL (fundbright-web, jumpdrive-web, luminality-web, nutripod-web,
7
+ # sidekick-web).
8
+ #
9
+ # It is a MODULE, never a base class. The five host `ApplicationOperation`
10
+ # classes range from 61 to 303 lines and diverge deliberately — one of them
11
+ # explicitly refuses a `Result`/`execute` lifecycle, and one app has no shared
12
+ # operation base at all. This module contributes the audit contract and
13
+ # nothing else: no lifecycle, no `call`, no `Result`.
14
+ #
15
+ # It gives an including class:
16
+ #
17
+ # - a class-level declaration of audit intent — `audits "x.y"` (it records
18
+ # one or more audit events), `audit_none!` (it intentionally records
19
+ # none), or `audit_abstract!` (it is a base/intermediate class, not a real
20
+ # operation). The declaration is meant to be MANDATORY, enforced by the
21
+ # host's meta-spec — see StandardAudit::Operation::Audit and
22
+ # `standard_audit/rspec/operation`.
23
+ #
24
+ # - a single private instance-level write path, `audit!(action, **attrs)`,
25
+ # which verifies the declaration (dev/test only) and then writes through
26
+ # StandardAudit.record.
27
+ #
28
+ # ── ADOPTION SHAPES ─────────────────────────────────────────────────────────
29
+ # Both shapes found in the estate work with no host configuration:
30
+ #
31
+ # 1. A shared base includes the module once, and the real operations are its
32
+ # subclasses. `inherited` registers them. The base itself is excluded
33
+ # from `Audit.operations` automatically because it declares nothing and
34
+ # has subclasses.
35
+ #
36
+ # 2. Every operation is a leaf that includes the module directly (no shared
37
+ # base). `included` registers each one.
38
+ #
39
+ # ── ERROR POLICY ────────────────────────────────────────────────────────────
40
+ # `DeclarationError` ALWAYS propagates — it is a developer mistake caught in
41
+ # dev/test, and letting it decay into a swallowed write or a failure Result
42
+ # would hide drift from CI.
43
+ #
44
+ # A genuine *write* failure is governed by `config.raise_on_audit_write_error`
45
+ # (default `false`, i.e. report and swallow). One of the five apps
46
+ # deliberately lets a failed audit write abort the operation, because for it
47
+ # an unaudited state change is a compliance failure; it sets the flag to
48
+ # `true`. A swallow-only module would have silently downgraded that posture,
49
+ # which is why this is configurable rather than fixed.
50
+ #
51
+ # ── CATALOGUE ───────────────────────────────────────────────────────────────
52
+ # The gem has no knowledge of any host's action vocabulary. The host declares
53
+ # it:
54
+ #
55
+ # StandardAudit.configure(baseline: true) do |config|
56
+ # config.audit_catalogue = -> { AuditCatalogue::ACTIONS }
57
+ # end
58
+ #
59
+ # A CALLABLE, because referencing an autoloadable constant eagerly from an
60
+ # initializer pins the first-loaded copy and breaks Zeitwerk reloading. `nil`
61
+ # (the default) means the catalogue check is skipped entirely, so the DSL is
62
+ # adoptable before an app has a catalogue.
63
+ #
64
+ # Membership is the ONLY rule applied to an action string. There is
65
+ # deliberately no dot-count, case, prefix, or namespace validation: one app's
66
+ # catalogue carries notification-bus names verbatim
67
+ # (`jumpdrive-web.surface.audience_changed`) because the subscriber records
68
+ # the bus name as-is, and normalising them would orphan historical rows.
69
+ module Operation
70
+ extend ActiveSupport::Concern
71
+
72
+ # Raised when an operation writes an audit action it didn't declare, wrote
73
+ # despite `audit_none!`, or wrote an action absent from the configured
74
+ # catalogue. Raised in dev/test only (see `config.verify_audit_declarations`)
75
+ # and never rescued by this module.
76
+ class DeclarationError < StandardError; end
77
+
78
+ included do |base|
79
+ StandardAudit::Operation::Audit.register(base)
80
+ end
81
+
82
+ module ClassMethods
83
+ # Subclasses of an adopting base class are the real operations, so they
84
+ # register too — and they do NOT inherit `@audit_spec`, by design: each
85
+ # leaf states its own intent. A leaf reading `nil` through a declared
86
+ # parent would be the wrong (and silently passing) answer.
87
+ def inherited(subclass)
88
+ super
89
+ StandardAudit::Operation::Audit.register(subclass)
90
+ end
91
+
92
+ # The audit action(s) this operation is expected to emit.
93
+ #
94
+ # @return [nil, :none, :abstract, Array<String>]
95
+ # `nil` — undeclared (the meta-spec forbids this)
96
+ # `:none` — intentionally records no audit
97
+ # `:abstract` — not a real operation; excluded from the meta-spec
98
+ # Array — the catalogue action strings it may write
99
+ def audit_spec
100
+ @audit_spec
101
+ end
102
+
103
+ # Declare the audit action(s) this operation emits (primary first; an
104
+ # operation may emit several — conditional or per-item — and all must be
105
+ # listed). The matching write happens via #audit! inside the operation.
106
+ #
107
+ # audits "order.created"
108
+ # audits "order.created", "order.line_item_added"
109
+ #
110
+ # Values are coerced to Strings, so Symbols and frozen catalogue
111
+ # constants both work. Calling it twice REPLACES the declaration.
112
+ def audits(*actions)
113
+ actions = actions.flatten.map(&:to_s)
114
+ raise ArgumentError, "`audits` needs at least one action" if actions.empty?
115
+
116
+ @audit_spec = actions
117
+ end
118
+
119
+ # Declare that this operation mutates state but intentionally records no
120
+ # audit. The accepted reasons, all of which should be left as a comment
121
+ # on the call: delegators that audit downstream, projections/re-indexes
122
+ # derived from already-audited state, outcome-only writes on a record
123
+ # whose creation is already audited, and high-volume telemetry ingestion.
124
+ def audit_none!
125
+ @audit_spec = :none
126
+ end
127
+
128
+ # Declare that this class is a base or intermediate class rather than a
129
+ # real operation, so the meta-spec skips it.
130
+ #
131
+ # Usually unnecessary: a class that declares nothing and HAS subclasses is
132
+ # treated as a base automatically, which is what makes a one-line
133
+ # `include StandardAudit::Operation` on a shared `ApplicationOperation`
134
+ # work with no further configuration. Use this when the automatic rule is
135
+ # not enough — most often an intermediate class that has no subclasses
136
+ # *yet*, or one you want to state the intent of explicitly.
137
+ def audit_abstract!
138
+ @audit_spec = :abstract
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ # The single audit-write path for operations.
145
+ #
146
+ # Call it INSIDE the operation's own transaction where one is open, so the
147
+ # audit row commits atomically with the state change — this helper opens no
148
+ # transaction of its own. `action` is the StandardAudit `event_type`;
149
+ # keyword args are forwarded straight to StandardAudit.record, so `actor:` /
150
+ # `target:` / `scope:` must be model objects (the gem records their
151
+ # GlobalID), alongside `metadata:` and context overrides such as
152
+ # `ip_address:`.
153
+ #
154
+ # `actor:` defaults to whatever `config.current_actor_resolver` returns, so
155
+ # only operations acting on someone else's behalf need to pass it.
156
+ #
157
+ # In dev/test it raises DeclarationError on declaration↔write drift. In
158
+ # production it just writes: a developer mistake shouldn't 500 a user, and
159
+ # the meta-spec is the CI gate.
160
+ #
161
+ # @return [StandardAudit::AuditLog, nil]
162
+ def audit!(action, **attrs)
163
+ action = action.to_s
164
+ verify_audit_declared!(action) if StandardAudit::Operation::Audit.verify?
165
+
166
+ StandardAudit.record(action, **attrs)
167
+ rescue DeclarationError
168
+ # Always propagate a declaration mismatch, ahead of the generic rescue
169
+ # below and regardless of `raise_on_audit_write_error`. It can only be
170
+ # raised when verification is on (dev/test).
171
+ raise
172
+ rescue StandardError => e
173
+ StandardAudit::Operation::Audit.handle_write_error(e, action: action, operation: self)
174
+ end
175
+
176
+ # Raises DeclarationError when `action` contradicts the class's declaration
177
+ # or is absent from the configured catalogue. Public-ish by convention (it
178
+ # is private, like `audit!`) but documented because host meta-specs assert
179
+ # on its messages.
180
+ def verify_audit_declared!(action)
181
+ catalogue = StandardAudit::Operation::Audit.catalogue
182
+
183
+ case (spec = self.class.audit_spec)
184
+ when nil
185
+ raise DeclarationError,
186
+ "#{self.class} writes audit '#{action}' but declares neither `audits` nor `audit_none!`"
187
+ when :none
188
+ raise DeclarationError,
189
+ "#{self.class} declares `audit_none!` but writes audit '#{action}'"
190
+ when :abstract
191
+ raise DeclarationError,
192
+ "#{self.class} declares `audit_abstract!` but writes audit '#{action}'"
193
+ else
194
+ unless spec.include?(action)
195
+ raise DeclarationError,
196
+ "#{self.class} writes audit '#{action}' not in its declared actions #{spec.inspect}"
197
+ end
198
+ if catalogue && !catalogue.include?(action)
199
+ raise DeclarationError,
200
+ "audit action '#{action}' is not in the configured audit catalogue"
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end