standard_audit 0.11.1 → 0.12.1
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 +53 -0
- data/README.md +271 -11
- data/app/models/standard_audit/audit_log.rb +71 -7
- data/lib/generators/standard_audit/add_anonymized_at/add_anonymized_at_generator.rb +20 -0
- data/lib/generators/standard_audit/add_anonymized_at/templates/add_anonymized_at_to_audit_logs.rb.erb +25 -0
- data/lib/generators/standard_audit/add_checksums/add_checksums_generator.rb +14 -0
- data/lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb +3 -0
- data/lib/standard_audit/configuration.rb +43 -6
- data/lib/standard_audit/event_subscriber.rb +18 -34
- data/lib/standard_audit/rspec/baseline.rb +81 -0
- data/lib/standard_audit/rspec/matchers.rb +187 -0
- data/lib/standard_audit/rspec.rb +7 -0
- data/lib/standard_audit/subscriber.rb +20 -78
- data/lib/standard_audit/version.rb +1 -1
- data/lib/standard_audit.rb +132 -50
- data/lib/tasks/standard_audit_tasks.rake +1 -0
- metadata +5 -1
|
@@ -21,6 +21,9 @@ class CreateAuditLogs < ActiveRecord::Migration[<%= ActiveRecord::Migration.curr
|
|
|
21
21
|
# share a parent, so the log is a DAG rather than a strict line; storing
|
|
22
22
|
# the parent is what keeps every row verifiable when it forks.
|
|
23
23
|
t.string :previous_checksum, limit: 64
|
|
24
|
+
# Set by AuditLog.anonymize_actor! (GDPR erasure). verify_chain counts a
|
|
25
|
+
# stamped row as `redacted` instead of a digest failure.
|
|
26
|
+
t.datetime :anonymized_at
|
|
24
27
|
t.timestamps
|
|
25
28
|
end
|
|
26
29
|
|
|
@@ -4,7 +4,8 @@ module StandardAudit
|
|
|
4
4
|
:actor_extractor, :target_extractor, :scope_extractor,
|
|
5
5
|
:current_actor_resolver, :current_request_id_resolver,
|
|
6
6
|
:current_ip_address_resolver, :current_user_agent_resolver,
|
|
7
|
-
:current_session_id_resolver,
|
|
7
|
+
:current_session_id_resolver, :current_scope_resolver,
|
|
8
|
+
:before_write,
|
|
8
9
|
:sensitive_keys, :sensitive_key_patterns,
|
|
9
10
|
:sensitive_key_exceptions, :filter_nested_metadata,
|
|
10
11
|
:dereference_record_metadata,
|
|
@@ -24,8 +25,11 @@ module StandardAudit
|
|
|
24
25
|
@target_extractor = ->(payload) { payload[:target] }
|
|
25
26
|
@scope_extractor = ->(payload) { payload[:scope] }
|
|
26
27
|
|
|
28
|
+
# Tries StandardId's `Current.account` first, then the Rails-generator
|
|
29
|
+
# convention `Current.user`. Each is respond_to?-guarded, so an app that
|
|
30
|
+
# defines only one of them (or no `Current` at all) resolves cleanly.
|
|
27
31
|
@current_actor_resolver = -> {
|
|
28
|
-
|
|
32
|
+
Configuration.current_attribute(:account) || Configuration.current_attribute(:user)
|
|
29
33
|
}
|
|
30
34
|
@current_request_id_resolver = -> {
|
|
31
35
|
defined?(Current) && Current.respond_to?(:request_id) ? Current.request_id : nil
|
|
@@ -36,10 +40,35 @@ module StandardAudit
|
|
|
36
40
|
@current_user_agent_resolver = -> {
|
|
37
41
|
defined?(Current) && Current.respond_to?(:user_agent) ? Current.user_agent : nil
|
|
38
42
|
}
|
|
43
|
+
# Tries StandardId's `Current.session&.id` first, then a plain
|
|
44
|
+
# `Current.session_id` attribute.
|
|
39
45
|
@current_session_id_resolver = -> {
|
|
40
|
-
|
|
46
|
+
session = Configuration.current_attribute(:session)
|
|
47
|
+
session_id = session.id if session.respond_to?(:id)
|
|
48
|
+
session_id || Configuration.current_attribute(:session_id)
|
|
41
49
|
}
|
|
42
50
|
|
|
51
|
+
# Fallback for the audit scope (tenant) when a write names none —
|
|
52
|
+
# explicit `scope:` and `scope_extractor` results always win. Applied on
|
|
53
|
+
# every write path. nil (the default) leaves unscoped writes unscoped.
|
|
54
|
+
#
|
|
55
|
+
# config.current_scope_resolver = -> { Current.organisation }
|
|
56
|
+
@current_scope_resolver = nil
|
|
57
|
+
|
|
58
|
+
# A callable run on EVERY write path (direct `record`, `audit!`,
|
|
59
|
+
# `record_audit`, both subscribers; sync, async and batched) after the
|
|
60
|
+
# Current resolvers and `metadata_builder`, and BEFORE redaction. It
|
|
61
|
+
# receives the mutable entry Hash — :event_type, :actor, :target, :scope,
|
|
62
|
+
# :metadata, :request_id, :ip_address, :user_agent, :session_id — and may
|
|
63
|
+
# change it in place; the return value is ignored. Raising aborts the
|
|
64
|
+
# write (the subscribers rescue and report; direct callers see the error).
|
|
65
|
+
#
|
|
66
|
+
# config.before_write = ->(entry) {
|
|
67
|
+
# AuditMetadataPii.verify!(entry[:metadata]) if Rails.env.local?
|
|
68
|
+
# entry[:metadata] = entry[:metadata].merge("surface" => Current.surface) if Current.surface
|
|
69
|
+
# }
|
|
70
|
+
@before_write = nil
|
|
71
|
+
|
|
43
72
|
# Note: :authorization filters the HTTP Authorization header value.
|
|
44
73
|
# If you use "authorization" as a metadata key for policy decisions,
|
|
45
74
|
# rename it (e.g. :authorization_policy) to avoid accidental filtering.
|
|
@@ -169,6 +198,14 @@ module StandardAudit
|
|
|
169
198
|
@retention_days = self.class.retention_days_from_env
|
|
170
199
|
end
|
|
171
200
|
|
|
201
|
+
# Reads `::Current.<name>` when a top-level `Current` exists and responds
|
|
202
|
+
# to it; nil otherwise. The building block of the default resolvers.
|
|
203
|
+
def self.current_attribute(name)
|
|
204
|
+
return nil unless defined?(::Current) && ::Current.respond_to?(name)
|
|
205
|
+
|
|
206
|
+
::Current.public_send(name)
|
|
207
|
+
end
|
|
208
|
+
|
|
172
209
|
# Parses STANDARD_AUDIT_RETENTION_DAYS into a positive Integer, or nil when
|
|
173
210
|
# unset/blank/zero/negative/non-numeric (=> infinite retention).
|
|
174
211
|
def self.retention_days_from_env
|
|
@@ -199,9 +236,9 @@ module StandardAudit
|
|
|
199
236
|
# supplied by a concern mixed into the model). A callable is passed the
|
|
200
237
|
# instance.
|
|
201
238
|
#
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
#
|
|
239
|
+
# Batched writes (`StandardAudit.batch { … }` → `insert_all!`) run the same
|
|
240
|
+
# hooks at flush time, against an unsaved instance built from each
|
|
241
|
+
# buffered row, before that row's checksum is computed (since 0.12.0).
|
|
205
242
|
def before_checksum(hook = nil, &block)
|
|
206
243
|
hook ||= block
|
|
207
244
|
raise ArgumentError, "before_checksum needs a callable, a Symbol, or a block" if hook.nil?
|
|
@@ -33,42 +33,28 @@ module StandardAudit
|
|
|
33
33
|
target = config.target_extractor.call(payload)
|
|
34
34
|
scope = config.scope_extractor.call(payload)
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
reserved = RESERVED_PAYLOAD_KEYS.map(&:to_s)
|
|
37
37
|
|
|
38
|
-
StandardAudit.
|
|
38
|
+
StandardAudit.write_entry(
|
|
39
39
|
name,
|
|
40
40
|
actor: actor,
|
|
41
41
|
target: target,
|
|
42
42
|
scope: scope,
|
|
43
|
-
metadata:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
metadata: payload.reject { |k, _| reserved.include?(k.to_s) },
|
|
44
|
+
reserved: reserved_metadata(event[:tags], event[:source_location]),
|
|
45
|
+
context: {
|
|
46
|
+
request_id: context[:request_id] || payload[:request_id],
|
|
47
|
+
ip_address: context[:ip_address] || payload[:ip_address],
|
|
48
|
+
user_agent: context[:user_agent] || payload[:user_agent],
|
|
49
|
+
session_id: context[:session_id] || payload[:session_id]
|
|
50
|
+
}
|
|
48
51
|
)
|
|
49
52
|
rescue => e
|
|
50
|
-
|
|
51
|
-
report_error(e, name)
|
|
53
|
+
StandardAudit.report_write_error(e, name, subscriber: self.class.name)
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
private
|
|
55
57
|
|
|
56
|
-
# The event handler rescues so a failed audit write cannot break the
|
|
57
|
-
# instrumented code path, but a log line alone is invisible to error
|
|
58
|
-
# tracking — so report it as handled too.
|
|
59
|
-
def report_error(error, event_name)
|
|
60
|
-
return unless Rails.respond_to?(:error) && Rails.error
|
|
61
|
-
|
|
62
|
-
Rails.error.report(
|
|
63
|
-
error,
|
|
64
|
-
handled: true,
|
|
65
|
-
context: { StandardAudit.config.audit_error_context_key => event_name,
|
|
66
|
-
subscriber: self.class.name }
|
|
67
|
-
)
|
|
68
|
-
rescue => report_failure
|
|
69
|
-
Rails.logger.error("[StandardAudit] Error reporting audit failure: #{report_failure.class}: #{report_failure.message}")
|
|
70
|
-
end
|
|
71
|
-
|
|
72
58
|
def matches_subscription?(name)
|
|
73
59
|
StandardAudit.config.subscriptions.any? { |pattern| pattern_match?(pattern, name) }
|
|
74
60
|
end
|
|
@@ -99,20 +85,18 @@ module StandardAudit
|
|
|
99
85
|
end
|
|
100
86
|
|
|
101
87
|
# `_tags` and `_source` are reserved metadata keys owned by this
|
|
102
|
-
# subscriber.
|
|
103
|
-
#
|
|
104
|
-
def
|
|
105
|
-
reserved =
|
|
106
|
-
raw = payload.reject { |k, _| reserved.include?(k.to_s) }
|
|
107
|
-
raw = config.metadata_builder.call(raw) if config.metadata_builder
|
|
88
|
+
# subscriber. They are merged in AFTER `metadata_builder` runs (see
|
|
89
|
+
# StandardAudit.write_entry), so a builder never sees or strips them.
|
|
90
|
+
def reserved_metadata(tags, source_location)
|
|
91
|
+
reserved = {}
|
|
108
92
|
|
|
109
93
|
if tags.is_a?(Hash) && tags.any?
|
|
110
|
-
|
|
94
|
+
reserved[:_tags] = tags
|
|
111
95
|
elsif tags && !tags.is_a?(Hash)
|
|
112
96
|
Rails.logger.warn("[StandardAudit] Dropping Rails.event tags of unexpected type: #{tags.class}")
|
|
113
97
|
end
|
|
114
|
-
|
|
115
|
-
|
|
98
|
+
reserved[:_source] = source_location if source_location
|
|
99
|
+
reserved
|
|
116
100
|
end
|
|
117
101
|
end
|
|
118
102
|
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
require "standard_audit"
|
|
2
|
+
|
|
3
|
+
# Shared example guarding the `configure(baseline: true)` contract: that the
|
|
4
|
+
# app's StandardAudit configuration survives `StandardAudit.reset_configuration!`
|
|
5
|
+
# (which `require "standard_audit/rspec"` runs before every example).
|
|
6
|
+
#
|
|
7
|
+
# require "standard_audit/rspec/baseline" # or "standard_audit/rspec"
|
|
8
|
+
#
|
|
9
|
+
# RSpec.describe "StandardAudit configuration baseline" do
|
|
10
|
+
# it_behaves_like "a standard_audit baseline",
|
|
11
|
+
# subscriptions: [/\Astandard_id\./, /\Aauthorization\./],
|
|
12
|
+
# settings: {
|
|
13
|
+
# retention_days: 1826,
|
|
14
|
+
# raise_on_audit_write_error: true,
|
|
15
|
+
# filter_nested_metadata: true,
|
|
16
|
+
# queue_name: :maintenance
|
|
17
|
+
# },
|
|
18
|
+
# catalogue: -> { AuditCatalogue::ACTIONS },
|
|
19
|
+
# sensitive_keys: %i[source_payload],
|
|
20
|
+
# sensitive_key_patterns: [/secret/i],
|
|
21
|
+
# present: %i[metadata_builder before_write current_scope_resolver]
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# Every option is optional. Behaviour held in lambdas (resolvers, builders)
|
|
25
|
+
# can't be compared by value, so list them under `present:` to assert they
|
|
26
|
+
# survive a reset, and keep an app-specific example for what they return.
|
|
27
|
+
RSpec.shared_examples "a standard_audit baseline" do |options = {}|
|
|
28
|
+
subscriptions = Array(options[:subscriptions])
|
|
29
|
+
settings = options.fetch(:settings, {})
|
|
30
|
+
catalogue = options[:catalogue]
|
|
31
|
+
sensitive_keys = Array(options[:sensitive_keys])
|
|
32
|
+
sensitive_key_patterns = Array(options[:sensitive_key_patterns])
|
|
33
|
+
present = Array(options[:present])
|
|
34
|
+
|
|
35
|
+
def standard_audit_baseline_assertions(subscriptions:, settings:, catalogue:, sensitive_keys:,
|
|
36
|
+
sensitive_key_patterns:, present:)
|
|
37
|
+
config = StandardAudit.config
|
|
38
|
+
|
|
39
|
+
expect(config.subscriptions).to include(*subscriptions) if subscriptions.any?
|
|
40
|
+
settings.each do |name, value|
|
|
41
|
+
expect(config.public_send(name)).to eq(value), "expected config.#{name} to be #{value.inspect} after a reset"
|
|
42
|
+
end
|
|
43
|
+
if catalogue
|
|
44
|
+
resolved = config.audit_catalogue.respond_to?(:call) ? config.audit_catalogue.call : config.audit_catalogue
|
|
45
|
+
expect(Array(resolved).map(&:to_s)).to match_array(Array(catalogue.call).map(&:to_s))
|
|
46
|
+
end
|
|
47
|
+
expect(config.sensitive_keys).to include(*sensitive_keys) if sensitive_keys.any?
|
|
48
|
+
expect(config.sensitive_key_patterns).to include(*sensitive_key_patterns) if sensitive_key_patterns.any?
|
|
49
|
+
present.each do |name|
|
|
50
|
+
expect(config.public_send(name)).not_to be_nil, "expected config.#{name} to survive a reset"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
let(:standard_audit_baseline_options) do
|
|
55
|
+
{ subscriptions: subscriptions, settings: settings, catalogue: catalogue,
|
|
56
|
+
sensitive_keys: sensitive_keys, sensitive_key_patterns: sensitive_key_patterns, present: present }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "is registered with configure(baseline: true)" do
|
|
60
|
+
expect(StandardAudit.baseline_configured?).to be(true),
|
|
61
|
+
"config/initializers/standard_audit.rb must call StandardAudit.configure(baseline: true), " \
|
|
62
|
+
"or reset_configuration! silently reverts every example to gem defaults"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "carries the app's configuration" do
|
|
66
|
+
standard_audit_baseline_assertions(**standard_audit_baseline_options)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "restores it after a mutation and a reset" do
|
|
70
|
+
StandardAudit.config.sensitive_keys += %i[standard_audit_isolation_canary]
|
|
71
|
+
StandardAudit.config.subscribe_to "standard_audit.isolation_canary"
|
|
72
|
+
settings.each_key { |name| StandardAudit.config.public_send(:"#{name}=", nil) }
|
|
73
|
+
present.each { |name| StandardAudit.config.public_send(:"#{name}=", nil) }
|
|
74
|
+
|
|
75
|
+
StandardAudit.reset_configuration!
|
|
76
|
+
|
|
77
|
+
expect(StandardAudit.config.sensitive_keys).not_to include(:standard_audit_isolation_canary)
|
|
78
|
+
expect(StandardAudit.config.subscriptions).not_to include("standard_audit.isolation_canary")
|
|
79
|
+
standard_audit_baseline_assertions(**standard_audit_baseline_options)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
require "standard_audit"
|
|
2
|
+
|
|
3
|
+
# Block matcher asserting that a block wrote an audit row.
|
|
4
|
+
#
|
|
5
|
+
# require "standard_audit/rspec/matchers" # or "standard_audit/rspec"
|
|
6
|
+
#
|
|
7
|
+
# expect { operation.call }
|
|
8
|
+
# .to have_audited("order.created")
|
|
9
|
+
# .by(account) # actor: a record, an RSpec matcher, or no arg for "any actor"
|
|
10
|
+
# .on(order) # target: same forms
|
|
11
|
+
# .within(organisation) # scope: same forms
|
|
12
|
+
# .with_metadata(total: 100) # subset match; values may be RSpec matchers
|
|
13
|
+
# .once # or .times(n); default is "at least one"
|
|
14
|
+
#
|
|
15
|
+
# expect { noop }.not_to have_audited("order.created")
|
|
16
|
+
#
|
|
17
|
+
# Only rows written DURING the block are considered, and only persisted ones:
|
|
18
|
+
# under `config.async = true` run jobs inline (or use `perform_enqueued_jobs`),
|
|
19
|
+
# and a `StandardAudit.batch` must be flushed inside the block.
|
|
20
|
+
#
|
|
21
|
+
# `.by` / `.on` / `.within` with no argument assert presence — a well-formed
|
|
22
|
+
# row with a resolvable GlobalID — which is what per-app helpers such as
|
|
23
|
+
# `expect_well_formed_audit` checked by hand.
|
|
24
|
+
module StandardAudit
|
|
25
|
+
module RSpec
|
|
26
|
+
module Matchers
|
|
27
|
+
class HaveAudited
|
|
28
|
+
include ::RSpec::Matchers::Composable
|
|
29
|
+
|
|
30
|
+
ANY = Object.new.freeze
|
|
31
|
+
private_constant :ANY
|
|
32
|
+
|
|
33
|
+
def initialize(event_type)
|
|
34
|
+
@event_type = event_type.to_s
|
|
35
|
+
@references = {}
|
|
36
|
+
@metadata = nil
|
|
37
|
+
@count = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def by(actor = ANY)
|
|
41
|
+
@references[:actor] = actor
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def on(target = ANY)
|
|
46
|
+
@references[:target] = target
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def within(scope = ANY)
|
|
51
|
+
@references[:scope] = scope
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
alias_method :scoped_to, :within
|
|
55
|
+
|
|
56
|
+
def with_metadata(expected)
|
|
57
|
+
@metadata = expected.to_h.deep_stringify_keys
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def once
|
|
62
|
+
times(1)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def times(count)
|
|
66
|
+
@count = count
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
alias_method :exactly, :times
|
|
70
|
+
|
|
71
|
+
def supports_block_expectations?
|
|
72
|
+
true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def supports_value_expectations?
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def matches?(block)
|
|
80
|
+
capture(block)
|
|
81
|
+
@count ? matching.size == @count : matching.any?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def does_not_match?(block)
|
|
85
|
+
raise ArgumentError, "have_audited(...).times/once is not supported with not_to" if @count
|
|
86
|
+
|
|
87
|
+
capture(block)
|
|
88
|
+
matching.empty?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def description
|
|
92
|
+
parts = ["have audited #{@event_type.inspect}"]
|
|
93
|
+
@references.each { |role, value| parts << "#{preposition(role)} #{describe_value(value)}" }
|
|
94
|
+
parts << "with metadata including #{@metadata.inspect}" if @metadata
|
|
95
|
+
parts << (@count == 1 ? "once" : "#{@count} times") if @count
|
|
96
|
+
parts.join(" ")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def failure_message
|
|
100
|
+
"expected block to #{description}, but #{found_summary}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def failure_message_when_negated
|
|
104
|
+
"expected block not to #{description}, but it wrote:\n#{format_rows(matching)}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def capture(block)
|
|
110
|
+
before_ids = StandardAudit::AuditLog.pluck(:id)
|
|
111
|
+
block.call
|
|
112
|
+
@written = StandardAudit::AuditLog.where.not(id: before_ids).order(:created_at, :id).to_a
|
|
113
|
+
@candidates = @written.select { |log| log.event_type == @event_type }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def matching
|
|
117
|
+
@matching ||= @candidates.select { |log| row_matches?(log) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def row_matches?(log)
|
|
121
|
+
@references.all? { |role, expected| reference_matches?(log, role, expected) } &&
|
|
122
|
+
metadata_matches?(log.metadata || {})
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def metadata_matches?(actual)
|
|
126
|
+
return true if @metadata.nil?
|
|
127
|
+
|
|
128
|
+
@metadata.all? { |key, value| actual.key?(key) && values_match?(value, actual[key]) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def reference_matches?(log, role, expected)
|
|
132
|
+
gid = log.public_send(:"#{role}_gid")
|
|
133
|
+
|
|
134
|
+
if expected.equal?(ANY)
|
|
135
|
+
gid.present? && GlobalID.parse(gid)&.model_id.present?
|
|
136
|
+
elsif expected.respond_to?(:to_global_id) && !matcher?(expected)
|
|
137
|
+
gid == expected.to_global_id.to_s
|
|
138
|
+
else
|
|
139
|
+
values_match?(expected, log.public_send(role))
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def matcher?(object)
|
|
144
|
+
::RSpec::Matchers.is_a_matcher?(object)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def preposition(role)
|
|
148
|
+
{ actor: "by", target: "on", scope: "within" }.fetch(role)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def describe_value(value)
|
|
152
|
+
return "any record" if value.equal?(ANY)
|
|
153
|
+
return value.description if matcher?(value) && value.respond_to?(:description)
|
|
154
|
+
return value.to_global_id.to_s if value.respond_to?(:to_global_id)
|
|
155
|
+
|
|
156
|
+
value.inspect
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def found_summary
|
|
160
|
+
if @candidates.empty?
|
|
161
|
+
others = @written.map(&:event_type).uniq
|
|
162
|
+
others.empty? ? "no audit rows were written" : "it wrote only: #{others.join(", ")}"
|
|
163
|
+
elsif @count
|
|
164
|
+
"#{matching.size} matching row(s) were written:\n#{format_rows(@candidates)}"
|
|
165
|
+
else
|
|
166
|
+
"no #{@event_type.inspect} row matched. Rows written:\n#{format_rows(@candidates)}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def format_rows(rows)
|
|
171
|
+
rows.map { |log|
|
|
172
|
+
" #{log.event_type} actor=#{log.actor_gid.inspect} target=#{log.target_gid.inspect} " \
|
|
173
|
+
"scope=#{log.scope_gid.inspect} metadata=#{log.metadata.inspect}"
|
|
174
|
+
}.join("\n")
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def have_audited(event_type)
|
|
179
|
+
HaveAudited.new(event_type)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
RSpec.configure do |config|
|
|
186
|
+
config.include StandardAudit::RSpec::Matchers
|
|
187
|
+
end
|
data/lib/standard_audit/rspec.rb
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
require "standard_audit"
|
|
2
|
+
require "standard_audit/rspec/matchers"
|
|
3
|
+
require "standard_audit/rspec/baseline"
|
|
2
4
|
|
|
5
|
+
# StandardAudit test support. Loads the `have_audited` matcher
|
|
6
|
+
# (standard_audit/rspec/matchers) and the "a standard_audit baseline" shared
|
|
7
|
+
# example (standard_audit/rspec/baseline), and resets StandardAudit state
|
|
8
|
+
# between examples.
|
|
9
|
+
#
|
|
3
10
|
# StandardAudit state reset between examples.
|
|
4
11
|
#
|
|
5
12
|
# - Clears the thread-local batch buffer so a spec that exits inside a
|
|
@@ -27,90 +27,32 @@ module StandardAudit
|
|
|
27
27
|
|
|
28
28
|
# The event handler rescues so a failed audit write cannot break the
|
|
29
29
|
# instrumented code path, but a log line alone is invisible to error
|
|
30
|
-
# tracking — so
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
context: { StandardAudit.config.audit_error_context_key => event_name,
|
|
38
|
-
subscriber: self.class.name }
|
|
39
|
-
)
|
|
40
|
-
rescue => report_failure
|
|
41
|
-
Rails.logger.error("[StandardAudit] Error reporting audit failure: #{report_failure.class}: #{report_failure.message}")
|
|
42
|
-
end
|
|
43
|
-
|
|
30
|
+
# tracking — so it is reported as handled too.
|
|
31
|
+
#
|
|
32
|
+
# The write itself goes through StandardAudit.write_entry, the same path
|
|
33
|
+
# `StandardAudit.record` uses, so batching, `current_scope_resolver`,
|
|
34
|
+
# `metadata_builder` and `before_write` behave identically here. Until
|
|
35
|
+
# 0.12.0 this method carried its own copy of the write logic, which ignored
|
|
36
|
+
# `StandardAudit.batch`.
|
|
44
37
|
def handle_event(event)
|
|
45
|
-
return unless StandardAudit.config.enabled
|
|
46
|
-
|
|
47
38
|
config = StandardAudit.config
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
actor = config.actor_extractor.call(payload)
|
|
51
|
-
target = config.target_extractor.call(payload)
|
|
52
|
-
scope = config.scope_extractor.call(payload)
|
|
53
|
-
|
|
54
|
-
# Fall back to Current attributes when payload values are nil
|
|
55
|
-
actor ||= config.current_actor_resolver.call
|
|
56
|
-
|
|
57
|
-
metadata = extract_metadata(payload, config)
|
|
39
|
+
return unless config.enabled
|
|
58
40
|
|
|
59
|
-
|
|
60
|
-
event_type: event.name,
|
|
61
|
-
occurred_at: Time.current,
|
|
62
|
-
request_id: payload[:request_id] || config.current_request_id_resolver.call,
|
|
63
|
-
ip_address: payload[:ip_address] || config.current_ip_address_resolver.call,
|
|
64
|
-
user_agent: payload[:user_agent] || config.current_user_agent_resolver.call,
|
|
65
|
-
session_id: payload[:session_id] || config.current_session_id_resolver.call,
|
|
66
|
-
metadata: metadata
|
|
67
|
-
}
|
|
41
|
+
payload = event.payload
|
|
68
42
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
StandardAudit::CreateAuditLogJob.perform_later(job_attrs.stringify_keys)
|
|
78
|
-
else
|
|
79
|
-
log = StandardAudit::AuditLog.new(attrs)
|
|
80
|
-
log.actor = actor
|
|
81
|
-
log.target = target
|
|
82
|
-
log.scope = scope
|
|
83
|
-
log.save!
|
|
84
|
-
end
|
|
43
|
+
StandardAudit.write_entry(
|
|
44
|
+
event.name,
|
|
45
|
+
actor: config.actor_extractor.call(payload),
|
|
46
|
+
target: config.target_extractor.call(payload),
|
|
47
|
+
scope: config.scope_extractor.call(payload),
|
|
48
|
+
metadata: payload.except(*EXCLUDED_PAYLOAD_KEYS),
|
|
49
|
+
context: payload.slice(:request_id, :ip_address, :user_agent, :session_id)
|
|
50
|
+
)
|
|
85
51
|
rescue => e
|
|
86
|
-
|
|
87
|
-
report_error(e, event.name)
|
|
52
|
+
StandardAudit.report_write_error(e, event.name, subscriber: self.class.name)
|
|
88
53
|
end
|
|
89
54
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
excluded_keys = %i[actor target scope request_id ip_address user_agent session_id]
|
|
93
|
-
raw_metadata = payload.except(*excluded_keys)
|
|
94
|
-
|
|
95
|
-
if config.metadata_builder
|
|
96
|
-
raw_metadata = config.metadata_builder.call(raw_metadata)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
# ActiveRecord objects in the payload are replaced by a reference BEFORE
|
|
100
|
-
# any key-based redaction, because the leak they cause is not a
|
|
101
|
-
# sensitive *key* — it is a value that serialises as an entire database
|
|
102
|
-
# row (rarebit-one/rarebit-ops#296). Runs after `metadata_builder` so a
|
|
103
|
-
# host that derives fields from a record (`payload[:account].email`)
|
|
104
|
-
# still sees the record.
|
|
105
|
-
if config.dereference_record_metadata
|
|
106
|
-
raw_metadata = StandardAudit::RecordReference.call(raw_metadata)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# Redaction lives in MetadataFilter, shared with StandardAudit.record.
|
|
110
|
-
# This path previously carried its own copy that did *not* subtract
|
|
111
|
-
# RESERVED_METADATA_KEYS, so `_tags`/`_source` were strippable here and
|
|
112
|
-
# not there.
|
|
113
|
-
StandardAudit::MetadataFilter.call(raw_metadata, config: config)
|
|
114
|
-
end
|
|
55
|
+
EXCLUDED_PAYLOAD_KEYS = %i[actor target scope request_id ip_address user_agent session_id].freeze
|
|
56
|
+
private_constant :EXCLUDED_PAYLOAD_KEYS
|
|
115
57
|
end
|
|
116
58
|
end
|