standard_audit 0.8.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 55028cd60be7b29a81c8bd0eccb5cbccadfba4f7691f0d6576214de4367e53c9
4
- data.tar.gz: efcf7f59f891570b8076e870f64d632de6d707ed9a4aa1f4b6e35aa7116199d1
3
+ metadata.gz: a1d506a4bb5ad3811dd4a70b6be817374ec9a88ced8d1ffd9415bfddd28cc50b
4
+ data.tar.gz: 52c8aaa70f86810c6b308c86fa81e10c368ab5ddb011d16f422745e7681c88da
5
5
  SHA512:
6
- metadata.gz: d1d0c3c3807faf64dc9e21c897da2e3317a6fee1ea202aae6b07a948f416bbb8d35c03f8977a3def8bdf5a2bca9944941e3fe1b3c860d7b2b3aa942a8f048e16
7
- data.tar.gz: 16278c66d30fe401947d8748e6b675a9dd5bc49dac1d0183635faf27df8e1b9e5a5f2d933aa8a606c1b66a01894dc030088a3813b7ca8eb2aee68ed7bd811e0d
6
+ metadata.gz: 2db9ee0ef4983ce2bd9a94d195bbbf973016f32a3e9e2a53b45476df6c9cc6d30f7edd2bfb7e9442d6839e05cc491263b91bb644462946510740c187eb861e29
7
+ data.tar.gz: cce577cbc3f1b5829429ab30765c451f4a0fdca18b9f6283f075e1b3487b3fb763ceaeca846e39768c53707361bfc87024da65e0f8e662885a47766f814dd5c1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] - 2026-07-31
11
+
12
+ ### Added
13
+
14
+ - **`StandardAudit::Operation` — the operation-audit DSL, extracted from five independent copies.** Every consumer app had written the same contract by hand: `audits "x.y"` / `audit_none!` class declarations, a private `audit!(action, **attrs)` as the single write path, and a `verify_audit_declared!` guard that raises on declaration↔write drift in local environments and writes silently in production. The surface is unchanged from those copies, so operation files do not move — adopting apps swap one `include` line and delete their concern. (`rarebit-one/rarebit-ops#279`)
15
+ - It is a **module, never a base class**. The five host `ApplicationOperation`s range 61–303 lines and diverge deliberately (one refuses a `Result`/`execute` lifecycle; one app has no shared base at all), so the module contributes the audit contract and nothing else.
16
+ - **Both adoption shapes work with no configuration**: a shared base that includes it once, whose subclasses are the real operations, and standalone leaves that each include it directly. The unifier is that a class declaring nothing *and* having subclasses is treated as a base and excluded — so a shared base auto-excludes, while every leaf is retained. A class that did declare is never excluded that way.
17
+ - `audit_abstract!` for an intermediate class the automatic rule can't see (typically one with no subclasses yet).
18
+ - **`config.audit_catalogue`** — the host's action vocabulary, as a **callable** (`-> { AuditCatalogue::ACTIONS }`); referencing an autoloadable constant eagerly from an initializer breaks Zeitwerk reloading. A plain Array is accepted for a frozen literal. `nil` (the default) skips the membership check, so the DSL is adoptable before an app has a catalogue. **Membership is the only rule applied** — no dot-count, case, prefix, or namespace validation, because an action may legitimately carry a notification-bus namespace verbatim and normalising it would orphan historical rows.
19
+ - **`config.raise_on_audit_write_error`** (default `false`) and **`config.audit_write_error_handler`**. Four of the five apps report-and-swallow a failed audit write; one deliberately does not rescue, because for it an unaudited state change is itself a compliance failure. A swallow-only module would have silently downgraded that posture. `StandardAudit::Operation::DeclarationError` is never governed by either — it always re-raises, ahead of any generic rescue.
20
+ - **`config.verify_audit_declarations`** (callable or boolean, default: local environments only) — gates the dev/test guard.
21
+ - **Meta-spec logic as plain Ruby predicates** on `StandardAudit::Operation::Audit`: `.operations(source:)`, `.undeclared`, `.unknown_actions`, `.orphan_actions(within:)`, `.missing_write_sites`, `.unexpected_write_sites`, `.duplicate_catalogue_entries`, `.declared_actions`. Each takes an explicit `operations:` list, so hosts can write bespoke assertions.
22
+ - **`standard_audit/rspec/operation`** — a thin RSpec shared-example layer over those predicates, with no logic of its own. It carries a **`minimum:` registry floor**, the one example that catches "someone stopped including the module" or "eager loading stopped reaching the operations"; without it every other example passes vacuously against an empty set. Also `expected:`, `source:` scoping, and `orphans_within:` for hosts whose catalogue covers writers outside `app/operations/`.
23
+
24
+ Everything is additive and nothing includes the module by default — existing hosts upgrade with no code change.
25
+
10
26
  ## [0.8.0] - 2026-07-30
11
27
 
12
28
  ### Added
data/README.md CHANGED
@@ -162,6 +162,128 @@ This provides:
162
162
  organisation.scoped_audit_logs # all logs scoped to this organisation
163
163
  ```
164
164
 
165
+ ## Operation Audit Contract
166
+
167
+ `StandardAudit::Operation` makes an operation *declare* what it audits, so a new
168
+ mutating operation cannot silently ship with no audit trail. It is a **module,
169
+ not a base class** — it contributes the audit contract and nothing else (no
170
+ `call`, no `Result`, no `execute`), so it drops into any operation style.
171
+
172
+ ```ruby
173
+ class ApplicationOperation
174
+ include StandardAudit::Operation # ← the whole adoption, for a shared base
175
+ end
176
+
177
+ class Orders::CreateOperation < ApplicationOperation
178
+ audits "order.created"
179
+
180
+ def execute
181
+ order = Order.create!(**attrs)
182
+ audit!("order.created", target: order, metadata: { total: order.total })
183
+ end
184
+ end
185
+
186
+ class Orders::ReindexOperation < ApplicationOperation
187
+ audit_none! # projection of already-audited state
188
+ end
189
+ ```
190
+
191
+ - `audits "x.y"` — the action(s) this operation may write. Repeatable args; the
192
+ declaration is **not inherited**, so every leaf states its own intent.
193
+ - `audit_none!` — mutates state but deliberately records nothing. Leave the
194
+ reason as a comment.
195
+ - `audit_abstract!` — a base or intermediate class, not a real operation.
196
+ - `audit!(action, **attrs)` — private; the single write path. Args forward
197
+ straight to `StandardAudit.record`. Call it inside the operation's own
198
+ transaction so the row commits with the state change.
199
+
200
+ ### Adoption shapes
201
+
202
+ Both work with no configuration:
203
+
204
+ 1. **A shared base includes the module once**, and the real operations are its
205
+ subclasses (registered via `inherited`). The base declares nothing and has
206
+ subclasses, so it is excluded from the checks automatically. A class that
207
+ *did* declare is never excluded this way, so subclassing a real operation
208
+ cannot quietly drop it.
209
+ 2. **Every operation includes the module directly**, with no shared base
210
+ (registered via `included`).
211
+
212
+ Both land in one registry, so a single meta-spec covers a codebase mixing them.
213
+
214
+ ### The catalogue
215
+
216
+ The gem has no knowledge of your action vocabulary. Declare it as a **callable** —
217
+ referencing an autoloadable constant eagerly from an initializer pins the
218
+ first-loaded copy and breaks Zeitwerk reloading:
219
+
220
+ ```ruby
221
+ StandardAudit.configure(baseline: true) do |config|
222
+ config.audit_catalogue = -> { AuditCatalogue::ACTIONS }
223
+ end
224
+ ```
225
+
226
+ `nil` (the default) skips the membership check entirely, so the DSL is adoptable
227
+ before you have a catalogue. **Membership is the only rule** — there is no
228
+ dot-count, case, prefix, or namespace validation, because an action may
229
+ legitimately carry a notification-bus namespace verbatim.
230
+
231
+ ### Error policy
232
+
233
+ `StandardAudit::Operation::DeclarationError` — a declaration↔write mismatch —
234
+ **always propagates**, ahead of any generic rescue. It is raised in local
235
+ environments only (`config.verify_audit_declarations`); in production `audit!`
236
+ just writes, because a developer's mistake must not 500 a user. The meta-spec is
237
+ the real gate.
238
+
239
+ A genuine **write** failure is governed by config:
240
+
241
+ ```ruby
242
+ config.raise_on_audit_write_error = true # default false: report and swallow
243
+ config.audit_write_error_handler = ->(error, action:, operation:) { ... }
244
+ ```
245
+
246
+ Default `false` matches most apps, but set it `true` where an unaudited state
247
+ change is itself a compliance failure — the audit write then aborts the
248
+ operation.
249
+
250
+ ### The meta-spec
251
+
252
+ The analysis is **plain Ruby**, so you can assert on it however you like:
253
+
254
+ ```ruby
255
+ StandardAudit::Operation::Audit.operations(source: "/app/operations/")
256
+ StandardAudit::Operation::Audit.undeclared # => [Class, ...]
257
+ StandardAudit::Operation::Audit.unknown_actions # => { Class => ["x.y"] }
258
+ StandardAudit::Operation::Audit.orphan_actions(within: ...)
259
+ StandardAudit::Operation::Audit.missing_write_sites # declares but never writes
260
+ StandardAudit::Operation::Audit.unexpected_write_sites
261
+ StandardAudit::Operation::Audit.duplicate_catalogue_entries
262
+ ```
263
+
264
+ A thin RSpec layer over exactly those predicates:
265
+
266
+ ```ruby
267
+ require "standard_audit/rspec/operation"
268
+
269
+ RSpec.describe "Operation audit declarations" do
270
+ it_behaves_like "standard_audit operation declarations",
271
+ source: "/app/operations/",
272
+ minimum: 100,
273
+ expected: %w[Orders::CreateOperation],
274
+ orphans_within: -> { AuditCatalogue::OPERATION_ACTIONS }
275
+ end
276
+ ```
277
+
278
+ **Set `minimum:`.** It is the only example that fails when someone stops
279
+ including the module or eager loading stops reaching your operations — every
280
+ other example passes vacuously against an empty set. `orphans_within:` is the
281
+ catalogue slice operations own; pass it when your catalogue also covers writers
282
+ outside `app/operations/`, which would otherwise always look orphaned.
283
+
284
+ The registry can only see loaded classes, so the shared example calls
285
+ `Rails.application.eager_load!` by default (`eager_load: false` to opt out).
286
+
165
287
  ## Configuration Reference
166
288
 
167
289
  Use `configure(baseline: true)` in your initializer. It remembers the block so
@@ -218,6 +340,23 @@ StandardAudit.configure(baseline: true) do |config|
218
340
  config.before_checksum { |log| log.scope = MyApp.derive_scope(log) }
219
341
  config.before_checksum :backfill_scope # an AuditLog instance method
220
342
 
343
+ # -- Operation audit contract (StandardAudit::Operation) --
344
+ # Your action vocabulary, as a CALLABLE so Zeitwerk can reload it. nil (the
345
+ # default) skips the membership check entirely.
346
+ config.audit_catalogue = -> { AuditCatalogue::ACTIONS }
347
+
348
+ # Whether `audit!` verifies declarations before writing. Callable or boolean;
349
+ # defaults to local environments only.
350
+ config.verify_audit_declarations = -> { Rails.env.local? }
351
+
352
+ # Whether a failed audit WRITE aborts the operation. Default false (report and
353
+ # swallow). Set true where an unaudited state change is a compliance failure.
354
+ # DeclarationError always propagates regardless.
355
+ config.raise_on_audit_write_error = false
356
+ config.audit_write_error_handler = ->(error, action:, operation:) {
357
+ ErrorReporting.notify(error, component: "operation_audit", audit_action: action)
358
+ }
359
+
221
360
  # -- Metadata Builder --
222
361
  # Optional proc to transform metadata before storage.
223
362
  config.metadata_builder = ->(metadata) { metadata.slice(:relevant_key) }
@@ -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
@@ -0,0 +1,136 @@
1
+ require "standard_audit"
2
+
3
+ # RSpec shared examples for the operation-audit contract.
4
+ #
5
+ # A THIN layer over StandardAudit::Operation::Audit — every example below is a
6
+ # one-line call to a plain-Ruby predicate plus a failure message. If the shape
7
+ # of these examples doesn't fit your app, call the predicates directly from a
8
+ # bespoke spec and skip this file; nothing here is load-bearing.
9
+ #
10
+ # require "standard_audit/rspec/operation"
11
+ #
12
+ # RSpec.describe "Operation audit declarations" do
13
+ # it_behaves_like "standard_audit operation declarations",
14
+ # source: "/app/operations/",
15
+ # minimum: 100,
16
+ # orphans_within: -> { AuditCatalogue::OPERATION_ACTIONS }
17
+ # end
18
+ #
19
+ # Options (all optional):
20
+ #
21
+ # source: path fragment scoping the check to your own operations.
22
+ # Strongly recommended — without it, any class anywhere that
23
+ # includes the module is held to the contract.
24
+ # minimum: registry floor. THE MOST IMPORTANT OPTION. Without it, an
25
+ # app where someone stopped including the module — or where
26
+ # eager loading silently stopped reaching `app/operations/` —
27
+ # passes every other example vacuously against an empty set.
28
+ # Set it just below your real count.
29
+ # expected: Array of class names that must be registered. A second,
30
+ # sharper form of the same protection.
31
+ # orphans_within: the catalogue slice operations are responsible for, as an
32
+ # Array or a callable. Use it when your catalogue also covers
33
+ # writers outside `app/operations/` (a controller concern, a
34
+ # tool server, a notification-bus name), which would otherwise
35
+ # always look orphaned. Omit to skip the orphan check;
36
+ # pass `:catalogue` to check the whole catalogue.
37
+ # eager_load: force `Rails.application.eager_load!` first so the registry
38
+ # is complete regardless of the test env's setting.
39
+ # Defaults to true.
40
+ RSpec.shared_examples "standard_audit operation declarations" do |options = {}|
41
+ source = options[:source]
42
+ minimum = options[:minimum]
43
+ expected = Array(options[:expected])
44
+ orphans_within = options[:orphans_within]
45
+ eager_load = options.fetch(:eager_load, true)
46
+
47
+ audit = StandardAudit::Operation::Audit
48
+
49
+ before(:all) do
50
+ Rails.application.eager_load! if eager_load && defined?(Rails) && Rails.application
51
+ end
52
+
53
+ let(:operations) { audit.operations(source: source) }
54
+
55
+ it "sees the operations it is supposed to check" do
56
+ if expected.any?
57
+ expect(operations.map(&:name)).to include(*expected)
58
+ end
59
+
60
+ if minimum
61
+ expect(operations.size).to be >= minimum,
62
+ "Expected at least #{minimum} registered operations#{source ? " under #{source}" : ""}, " \
63
+ "found #{operations.size}. Every other example in this group passes vacuously against " \
64
+ "an empty set, so this is almost certainly a wiring failure — an operation that stopped " \
65
+ "including StandardAudit::Operation, or eager loading no longer reaching them."
66
+ end
67
+
68
+ skip("no `minimum:` or `expected:` given — nothing to assert") if minimum.nil? && expected.empty?
69
+ end
70
+
71
+ it "requires every operation to declare `audits` or `audit_none!`" do
72
+ undeclared = audit.undeclared(operations: operations)
73
+
74
+ expect(undeclared).to be_empty,
75
+ "These operations declare neither `audits` nor `audit_none!`:\n " \
76
+ "#{undeclared.map(&:name).sort.join("\n ")}\n" \
77
+ "Add `audits \"some.action\"` if it records an audit, or `audit_none!` " \
78
+ "(with the reason in a comment) if it deliberately doesn't."
79
+ end
80
+
81
+ it "only declares actions present in the audit catalogue" do
82
+ unknown = audit.unknown_actions(operations: operations)
83
+
84
+ skip("no `config.audit_catalogue` configured") if audit.catalogue.nil?
85
+
86
+ expect(unknown).to be_empty,
87
+ "These operations declare audit actions missing from the catalogue " \
88
+ "(add them to it):\n " \
89
+ "#{unknown.map { |klass, actions| "#{klass.name}: #{actions.inspect}" }.join("\n ")}"
90
+ end
91
+
92
+ it "has no duplicate catalogue entries" do
93
+ skip("no `config.audit_catalogue` configured") if audit.catalogue.nil?
94
+
95
+ dupes = audit.duplicate_catalogue_entries
96
+
97
+ expect(dupes).to be_empty, "duplicate audit catalogue entries: #{dupes.inspect}"
98
+ end
99
+
100
+ it "operations that declare `audits` call `audit!`" do
101
+ missing = audit.missing_write_sites(operations: operations)
102
+
103
+ expect(missing).to be_empty,
104
+ "These operations declare `audits` but never call `audit!`:\n " \
105
+ "#{missing.map(&:name).sort.join("\n ")}"
106
+ end
107
+
108
+ it "operations that declare `audit_none!` do not call `audit!`" do
109
+ writing = audit.unexpected_write_sites(operations: operations)
110
+
111
+ expect(writing).to be_empty,
112
+ "These operations declare `audit_none!` but call `audit!`:\n " \
113
+ "#{writing.map(&:name).sort.join("\n ")}"
114
+ end
115
+
116
+ it "has no catalogued action that no operation writes" do
117
+ skip("no `orphans_within:` given") if orphans_within.nil?
118
+
119
+ within =
120
+ if orphans_within == :catalogue
121
+ nil
122
+ elsif orphans_within.respond_to?(:call)
123
+ orphans_within.call
124
+ else
125
+ orphans_within
126
+ end
127
+
128
+ skip("no `config.audit_catalogue` configured") if within.nil? && audit.catalogue.nil?
129
+
130
+ orphans = audit.orphan_actions(operations: operations, within: within)
131
+
132
+ expect(orphans).to be_empty,
133
+ "These catalogued actions are declared by no operation — wire them or " \
134
+ "drop them: #{orphans.inspect}"
135
+ end
136
+ end
@@ -1,3 +1,3 @@
1
1
  module StandardAudit
2
- VERSION = "0.8.0"
2
+ VERSION = "0.9.0"
3
3
  end
@@ -6,6 +6,7 @@ require "standard_audit/sensitive_keys_dry_run"
6
6
  require "standard_audit/subscriber"
7
7
  require "standard_audit/event_subscriber"
8
8
  require "standard_audit/reference_preloading"
9
+ require "standard_audit/operation"
9
10
  require "standard_audit/auditable"
10
11
  require "standard_audit/audit_scope"
11
12
  require "standard_audit/checks/retention"
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.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -112,8 +112,11 @@ files:
112
112
  - lib/standard_audit/engine.rb
113
113
  - lib/standard_audit/event_subscriber.rb
114
114
  - lib/standard_audit/metadata_filter.rb
115
+ - lib/standard_audit/operation.rb
116
+ - lib/standard_audit/operation/audit.rb
115
117
  - lib/standard_audit/reference_preloading.rb
116
118
  - lib/standard_audit/rspec.rb
119
+ - lib/standard_audit/rspec/operation.rb
117
120
  - lib/standard_audit/sensitive_keys_dry_run.rb
118
121
  - lib/standard_audit/subscriber.rb
119
122
  - lib/standard_audit/version.rb