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