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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +139 -0
- data/lib/standard_audit/configuration.rb +47 -1
- data/lib/standard_audit/operation/audit.rb +253 -0
- data/lib/standard_audit/operation.rb +205 -0
- data/lib/standard_audit/rspec/operation.rb +136 -0
- data/lib/standard_audit/version.rb +1 -1
- data/lib/standard_audit.rb +1 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a1d506a4bb5ad3811dd4a70b6be817374ec9a88ced8d1ffd9415bfddd28cc50b
|
|
4
|
+
data.tar.gz: 52c8aaa70f86810c6b308c86fa81e10c368ab5ddb011d16f422745e7681c88da
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/standard_audit.rb
CHANGED
|
@@ -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.
|
|
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
|