standard_audit 0.8.0 → 0.10.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: 7fca49bbca23bc24c89aa7249f0ea4718c2f4fa6bae03748ec93a45df17c4bd0
4
+ data.tar.gz: 7920932fea8af270498c7620d7cb4e18e0cfaab1c0deb7f299715ce578882dd2
5
5
  SHA512:
6
- metadata.gz: d1d0c3c3807faf64dc9e21c897da2e3317a6fee1ea202aae6b07a948f416bbb8d35c03f8977a3def8bdf5a2bca9944941e3fe1b3c860d7b2b3aa942a8f048e16
7
- data.tar.gz: 16278c66d30fe401947d8748e6b675a9dd5bc49dac1d0183635faf27df8e1b9e5a5f2d933aa8a606c1b66a01894dc030088a3813b7ca8eb2aee68ed7bd811e0d
6
+ metadata.gz: 7117529ed69a81588ea18b081e3b8a8047dc9441fe116bef9e594beacc692108a1c8d625e2d4c1a959ac01a1d03857d9d94e91a4dfe65c52c35ce894b980e797
7
+ data.tar.gz: bdb36b281136e0928c4bac601bbee408de0cf040d70222c8d3339153c53312e1bd1820f136488086184426f68ce9f930b75d521b75fd25950f4f7b3bb232c66d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.0] - 2026-07-31
11
+
12
+ ### Added
13
+
14
+ - **`config.audit_error_context_key`** (default `:audit_action`) — renames the `Rails.error.report` context key naming the audit action, without needing a whole handler. Two apps had overridden `audit_write_error_handler` for nothing but this: both tag every OTHER audit-error report site with `audit_event:` (four sites in one, twelve across ten files in the other), so adopting the gem name left the operations layer as the only place forking the convention. A handler written to rename one key also silently opts out of every future improvement to the built-in reporter.
15
+
16
+ ### Changed
17
+
18
+ - **The built-in reporter no longer fires when `raise_on_audit_write_error` re-raises.** The caller receives the error and owns it, so reporting as well produced two events for one failure in every host that both sets the flag and reports on what it catches — which is most hosts that set it at all, since the flag exists for hosts treating an unaudited write as a failure worth handling. Two of the five apps hit this independently and each wrote a log-only handler to work around it. A host that does NOT catch the error still gets a report, via its framework unhandled-error path. An explicitly configured `audit_write_error_handler` is unaffected and still runs under either policy — only the built-in reporter steps aside.
19
+
20
+ ### Fixed
21
+
22
+ - **The write-site scan no longer reads comments as calls.** An operation declaring `audit_none!` that explained itself in prose naming `audit!` was flagged by `unexpected_write_sites` — a false failure, which hosts worked around by backticking the token in their own comments. Source is now lexed with `Ripper` and comment tokens dropped before scanning; a file that cannot be lexed falls back to raw source (the old behaviour). Lexing rather than stripping `#` to end-of-line, because the naive form eats `"#{interpolation}"` and `%w[#]` and would trade a false failure for a false pass.
23
+ - **Corrected a docstring that was wrong for half its own surface.** It claimed the source-scanning predicates "err towards a false pass rather than a false failure". That held only for `missing_write_sites`; in the `unexpected_write_sites` direction the same match produced a false failure. Worse than the bug itself, since it told anyone hitting the failure not to suspect the scanner.
24
+
25
+ ## [0.9.0] - 2026-07-31
26
+
27
+ ### Added
28
+
29
+ - **`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`)
30
+ - 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.
31
+ - **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.
32
+ - `audit_abstract!` for an intermediate class the automatic rule can't see (typically one with no subclasses yet).
33
+ - **`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.
34
+ - **`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.
35
+ - **`config.verify_audit_declarations`** (callable or boolean, default: local environments only) — gates the dev/test guard.
36
+ - **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.
37
+ - **`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/`.
38
+
39
+ Everything is additive and nothing includes the module by default — existing hosts upgrade with no code change.
40
+
10
41
  ## [0.8.0] - 2026-07-30
11
42
 
12
43
  ### 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,10 @@ 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,
14
+ :audit_error_context_key
12
15
 
13
16
  def initialize
14
17
  @subscriptions = []
@@ -78,6 +81,67 @@ module StandardAudit
78
81
 
79
82
  @anonymizable_metadata_keys = %i[email name ip_address]
80
83
 
84
+ # ── StandardAudit::Operation (the operation-audit DSL) ────────────────
85
+
86
+ # The host's canonical action vocabulary, used by `audit!` to reject an
87
+ # action absent from it. Normally a CALLABLE, because referencing an
88
+ # autoloadable constant eagerly from an initializer pins the first-loaded
89
+ # copy and breaks Zeitwerk reloading:
90
+ #
91
+ # config.audit_catalogue = -> { AuditCatalogue::ACTIONS }
92
+ #
93
+ # A plain Array is accepted when the vocabulary really is a frozen
94
+ # literal. `nil` (the default) skips the membership check entirely, so
95
+ # the DSL is adoptable before an app has a catalogue.
96
+ #
97
+ # Membership is the ONLY rule applied — see StandardAudit::Operation.
98
+ @audit_catalogue = nil
99
+
100
+ # Whether `audit!` verifies the declaration before writing. A callable
101
+ # (or a plain boolean). Defaults to local environments only: in
102
+ # production `audit!` just writes, because a developer's declaration
103
+ # mistake must not 500 a user — the host's meta-spec is the real gate.
104
+ @verify_audit_declarations = -> {
105
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.respond_to?(:local?) && Rails.env.local?
106
+ }
107
+
108
+ # Whether a failed audit *write* aborts the operation. `false` (report
109
+ # and swallow) matches four of the five apps this DSL was extracted from.
110
+ # Set `true` where an unaudited state change is itself a compliance
111
+ # failure — one app deliberately does not rescue, and defaulting to
112
+ # swallow would have silently downgraded that posture.
113
+ #
114
+ # StandardAudit::Operation::DeclarationError is NEVER governed by this;
115
+ # it always propagates.
116
+ @raise_on_audit_write_error = false
117
+
118
+ # Optional callable invoked instead of the built-in logger/Rails.error
119
+ # reporting when an audit write fails:
120
+ #
121
+ # config.audit_write_error_handler =
122
+ # ->(error, action:, operation:) { ErrorReporting.notify(error, ...) }
123
+ #
124
+ # Runs before `raise_on_audit_write_error` is applied, so it sees every
125
+ # write failure under either policy.
126
+ @audit_write_error_handler = nil
127
+
128
+ # The `Rails.error.report` context key naming the audit action, for the
129
+ # built-in reporter. Exists because two apps independently overrode
130
+ # `audit_write_error_handler` for nothing but this key: both tag every
131
+ # OTHER audit-error report site with `audit_event:` (four sites in one
132
+ # app, twelve across ten files in the other), so adopting the gem's name
133
+ # left the operations layer as the only place forking the convention.
134
+ #
135
+ # That divergence fails silently and asymmetrically — a saved error-tracker
136
+ # search grouped on the host's key keeps working and simply stops
137
+ # containing operation write failures. Nothing errors, nothing goes red,
138
+ # and the search still looks healthy.
139
+ #
140
+ # A whole handler to rename one key is a lot of ceremony, and a handler
141
+ # written for that reason also silently opts out of every future
142
+ # improvement to the built-in reporter. Default keeps existing behaviour.
143
+ @audit_error_context_key = :audit_action
144
+
81
145
  # Retention defaults from ENV so it can be set per-environment without a
82
146
  # code change. Unset/blank/non-positive => nil (infinite retention, the
83
147
  # compliance-safe default that never auto-deletes). A host app can still
@@ -0,0 +1,297 @@
1
+ require "ripper"
2
+
3
+ module StandardAudit
4
+ module Operation
5
+ # The registry, the catalogue resolver, the write-error policy, and the
6
+ # analysis predicates behind the operation-audit meta-spec.
7
+ #
8
+ # Everything here is PLAIN RUBY that returns data — no RSpec, no
9
+ # assertions. A host can call these from a bespoke spec, a rake task, or a
10
+ # CI script. `standard_audit/rspec/operation` is a thin shared-example
11
+ # layer over exactly these methods and adds no logic of its own.
12
+ #
13
+ # StandardAudit::Operation::Audit.undeclared # => [Class, ...]
14
+ # StandardAudit::Operation::Audit.unknown_actions # => { Class => ["x.y"] }
15
+ # StandardAudit::Operation::Audit.orphan_actions # => ["x.y"]
16
+ # StandardAudit::Operation::Audit.missing_write_sites # => [Class, ...]
17
+ module Audit
18
+ # Heuristic used by the source-scanning predicates: a call to the private
19
+ # `audit!` helper, with or without parentheses, not preceded by a receiver
20
+ # (so `foo.audit!` and `Something::audit!` don't count).
21
+ #
22
+ # The scan is FILE-scoped, not class-scoped — two operations defined in
23
+ # one file share a verdict. Zeitwerk requires one class per file in a
24
+ # real app, so this only matters for fixtures.
25
+ #
26
+ # Comments are stripped before scanning (see .strip_comments), so a
27
+ # documented or commented-out `audit!` is not a write site. Until 0.9.1
28
+ # it was, and the docstring here claimed the predicates "err towards a
29
+ # false pass rather than a false failure" — which was only ever true of
30
+ # {.missing_write_sites}. In the {.unexpected_write_sites} direction the
31
+ # same match produces a false FAILURE, so an `audit_none!` class that
32
+ # explained itself in prose failed the check. The claim was wrong for
33
+ # half its own surface, which is worse than the bug: it told anyone
34
+ # hitting the failure not to suspect the scanner.
35
+ #
36
+ # What remains genuinely heuristic: a string literal containing `audit!`
37
+ # still counts, and the runtime guard is still the authoritative check.
38
+ WRITE_SITE_PATTERN = /(?<![\w.:])audit!\s*(?:\(|["':@$\w])/
39
+
40
+ class << self
41
+ # ── Registry ────────────────────────────────────────────────────────
42
+
43
+ # Every class that has gained the contract, in registration order.
44
+ # Includes bases, intermediates, and anonymous classes — use
45
+ # {.operations} for the filtered view a meta-spec should assert on.
46
+ def registered
47
+ @registered ||= []
48
+ end
49
+
50
+ def register(klass)
51
+ registered << klass unless registered.include?(klass)
52
+ klass
53
+ end
54
+
55
+ # Empties the registry. For the gem's own specs and for hosts that
56
+ # rebuild the constant graph mid-suite; a normal suite never needs it.
57
+ def reset_registry!
58
+ @registered = []
59
+ end
60
+
61
+ # The real operations a meta-spec should hold to the contract.
62
+ #
63
+ # Excluded:
64
+ # - anonymous classes (no name) — test doubles and `Class.new` fixtures
65
+ # - classes that declared `audit_abstract!`
66
+ # - classes that declare NOTHING and have subclasses — i.e. a shared
67
+ # base such as `ApplicationOperation`. This is what lets an app
68
+ # adopt the whole contract with one `include` on its base class and
69
+ # no further configuration. A class that DID declare is never
70
+ # excluded by this rule, even if it is subclassed, so subclassing a
71
+ # real operation cannot quietly drop it from the check.
72
+ #
73
+ # @param source [String, nil] keep only classes whose defining file path
74
+ # contains this fragment, e.g. `"/app/operations/"`. Recommended: it
75
+ # scopes the check to the host's own operations and drops anything
76
+ # defined in a spec file.
77
+ def operations(source: nil)
78
+ registered.select do |klass|
79
+ next false if klass.name.nil? || klass.name.empty?
80
+ next false if klass.audit_spec == :abstract
81
+ next false if klass.audit_spec.nil? && subclassed?(klass)
82
+ next true if source.nil?
83
+
84
+ source_path(klass)&.include?(source)
85
+ end
86
+ end
87
+
88
+ # Absolute path of the file that defines `klass`, or nil.
89
+ def source_path(klass)
90
+ return nil if klass.name.nil? || klass.name.empty?
91
+
92
+ Object.const_source_location(klass.name)&.first
93
+ rescue NameError
94
+ nil
95
+ end
96
+
97
+ # ── Catalogue ───────────────────────────────────────────────────────
98
+
99
+ # The host's action vocabulary as an Array of Strings, or nil when no
100
+ # catalogue is configured (in which case membership is not checked).
101
+ #
102
+ # `config.audit_catalogue` is normally a callable — see the note in
103
+ # StandardAudit::Operation on why an eagerly-referenced autoloadable
104
+ # constant breaks Zeitwerk reloading. A plain Array is accepted for the
105
+ # case where the vocabulary really is a frozen literal.
106
+ def catalogue
107
+ raw = StandardAudit.config.audit_catalogue
108
+ return nil if raw.nil?
109
+
110
+ resolved = raw.respond_to?(:call) ? raw.call : raw
111
+ return nil if resolved.nil?
112
+
113
+ Array(resolved).map(&:to_s)
114
+ end
115
+
116
+ # ── Policy ──────────────────────────────────────────────────────────
117
+
118
+ # Whether `audit!` verifies declarations before writing. Defaults to
119
+ # "local environments only" — production writes silently, because a
120
+ # developer's declaration mistake must not 500 a user.
121
+ def verify?
122
+ resolver = StandardAudit.config.verify_audit_declarations
123
+ resolver.respond_to?(:call) ? !!resolver.call : !!resolver
124
+ end
125
+
126
+ # Applies the configured policy to a failed audit *write* (never to a
127
+ # DeclarationError, which `audit!` re-raises first).
128
+ #
129
+ # @return [nil] when the error is swallowed
130
+ # @raise [StandardError] the original error when
131
+ # `config.raise_on_audit_write_error` is true
132
+ def handle_write_error(error, action:, operation:)
133
+ raising = StandardAudit.config.raise_on_audit_write_error
134
+ handler = StandardAudit.config.audit_write_error_handler
135
+
136
+ if handler
137
+ handler.call(error, action: action, operation: operation)
138
+ elsif !raising
139
+ # Deliberately NOT reported when re-raising: the caller receives the
140
+ # error and owns it from here. Reporting as well produced two events
141
+ # for one failure in every host that both sets
142
+ # `raise_on_audit_write_error` and reports on the error it catches —
143
+ # which is most hosts that set the flag at all, since the flag exists
144
+ # for hosts that treat an unaudited write as a failure worth
145
+ # handling. And a host that does NOT catch it still gets the report,
146
+ # via its framework's own unhandled-error path.
147
+ report_write_error(error, action: action, operation: operation)
148
+ end
149
+
150
+ raise error if raising
151
+
152
+ nil
153
+ end
154
+
155
+ # ── Predicates ──────────────────────────────────────────────────────
156
+
157
+ # Operations that declare neither `audits` nor `audit_none!`. A new
158
+ # mutating operation shipping with no audit trail shows up here.
159
+ #
160
+ # @return [Array<Class>]
161
+ def undeclared(operations: self.operations)
162
+ operations.select { |klass| klass.audit_spec.nil? }
163
+ end
164
+
165
+ # Declared actions that are absent from the configured catalogue.
166
+ # Empty when no catalogue is configured.
167
+ #
168
+ # @return [Hash{Class => Array<String>}]
169
+ def unknown_actions(operations: self.operations, catalogue: self.catalogue)
170
+ return {} if catalogue.nil?
171
+
172
+ operations.each_with_object({}) do |klass, acc|
173
+ unknown = declared_for(klass) - catalogue
174
+ acc[klass] = unknown if unknown.any?
175
+ end
176
+ end
177
+
178
+ # Catalogue entries no operation declares — dead vocabulary, which makes
179
+ # the catalogue a wish rather than a record.
180
+ #
181
+ # @param within [Array<String>, nil] check only this slice. Hosts whose
182
+ # catalogue also covers non-operation writers (a controller concern, a
183
+ # tool server, a notification-bus name) pass the operation-only slice
184
+ # here; the rest are written outside `app/operations/` and would
185
+ # always look orphaned.
186
+ # @return [Array<String>]
187
+ def orphan_actions(operations: self.operations, catalogue: self.catalogue, within: nil)
188
+ pool = within ? Array(within).map(&:to_s) : catalogue
189
+ return [] if pool.nil?
190
+
191
+ pool - declared_actions(operations: operations)
192
+ end
193
+
194
+ # Every action declared across the given operations, de-duplicated.
195
+ #
196
+ # @return [Array<String>]
197
+ def declared_actions(operations: self.operations)
198
+ operations.flat_map { |klass| declared_for(klass) }.uniq
199
+ end
200
+
201
+ # Catalogue entries listed more than once.
202
+ #
203
+ # @return [Array<String>]
204
+ def duplicate_catalogue_entries(catalogue: self.catalogue)
205
+ return [] if catalogue.nil?
206
+
207
+ catalogue.tally.select { |_action, count| count > 1 }.keys
208
+ end
209
+
210
+ # Operations that declare `audits` but whose source contains no `audit!`
211
+ # call — the declaration is aspirational and nothing writes the row.
212
+ #
213
+ # Source-based, therefore a heuristic: a class whose defining file can't
214
+ # be read is skipped rather than reported.
215
+ #
216
+ # @return [Array<Class>]
217
+ def missing_write_sites(operations: self.operations)
218
+ operations.select do |klass|
219
+ spec = klass.audit_spec
220
+ next false unless spec.is_a?(Array)
221
+
222
+ src = source_for(klass)
223
+ src && !src.match?(WRITE_SITE_PATTERN)
224
+ end
225
+ end
226
+
227
+ # Operations that declare `audit_none!` but whose source calls `audit!`
228
+ # anyway. The runtime guard catches this too, but only if that code path
229
+ # is exercised; this catches it statically.
230
+ #
231
+ # @return [Array<Class>]
232
+ def unexpected_write_sites(operations: self.operations)
233
+ operations.select do |klass|
234
+ next false unless klass.audit_spec == :none
235
+
236
+ src = source_for(klass)
237
+ src&.match?(WRITE_SITE_PATTERN)
238
+ end
239
+ end
240
+
241
+ private
242
+
243
+ def declared_for(klass)
244
+ spec = klass.audit_spec
245
+ spec.is_a?(Array) ? spec : []
246
+ end
247
+
248
+ def source_for(klass)
249
+ path = source_path(klass)
250
+ return nil unless path && File.exist?(path)
251
+
252
+ strip_comments(File.read(path))
253
+ end
254
+
255
+ # Comments are not write sites. Scanning raw source made a class that
256
+ # declares `audit_none!` and *explains why* in prose ("No direct audit!
257
+ # here") fail `unexpected_write_sites` — the scanner matched the word in
258
+ # the comment. That is a false failure, and it forced hosts to backtick
259
+ # the token in their own comments to appease a static check.
260
+ #
261
+ # Lexing rather than regex-stripping `#` to end-of-line, because the
262
+ # naive form eats `"#{interpolation}"` and `%w[#]`, which would trade a
263
+ # false failure for a false pass. A file Ripper cannot lex (syntax the
264
+ # running Ruby doesn't accept) falls back to the raw source: the old
265
+ # behaviour, which is conservative in the `audits` direction.
266
+ def strip_comments(src)
267
+ tokens = Ripper.lex(src)
268
+ return src if tokens.nil?
269
+
270
+ tokens.reject { |(_pos, type, _tok, _state)| type == :on_comment }
271
+ .map { |(_pos, _type, tok, _state)| tok }
272
+ .join
273
+ rescue StandardError
274
+ src
275
+ end
276
+
277
+ def subclassed?(klass)
278
+ klass.respond_to?(:subclasses) && klass.subclasses.any?
279
+ end
280
+
281
+ def report_write_error(error, action:, operation:)
282
+ message = "[StandardAudit] Failed to record #{action}: #{error.class} #{error.message}"
283
+ Rails.logger&.error(message) if defined?(Rails) && Rails.respond_to?(:logger)
284
+
285
+ return unless defined?(Rails) && Rails.respond_to?(:error) && Rails.error
286
+
287
+ Rails.error.report(
288
+ error,
289
+ handled: true,
290
+ context: { StandardAudit.config.audit_error_context_key => action,
291
+ operation: operation.class.name }
292
+ )
293
+ end
294
+ end
295
+ end
296
+ end
297
+ 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.10.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.10.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