provenance 1.0.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 +7 -0
- data/CHANGELOG.md +28 -0
- data/LICENSE +21 -0
- data/README.md +333 -0
- data/lib/provenance/configuration.rb +34 -0
- data/lib/provenance/context.rb +58 -0
- data/lib/provenance/journal.rb +123 -0
- data/lib/provenance/trackers/auditable.rb +125 -0
- data/lib/provenance/trackers/bulk_operations.rb +72 -0
- data/lib/provenance/trackers/error_reporting.rb +48 -0
- data/lib/provenance/trackers/providers.rb +163 -0
- data/lib/provenance/trackers/trackable.rb +341 -0
- data/lib/provenance/transaction_key.rb +19 -0
- data/lib/provenance/version.rb +5 -0
- data/lib/provenance.rb +56 -0
- metadata +122 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "action_controller"
|
|
5
|
+
require "active_record"
|
|
6
|
+
|
|
7
|
+
module Provenance
|
|
8
|
+
# Controller-side concern. Wraps each action, opens a journal, collects model
|
|
9
|
+
# changes for the request and delivers the assembled audit event to the
|
|
10
|
+
# configured hooks once all transactions have completed.
|
|
11
|
+
module Auditable
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
include Provenance::Trackers::Providers
|
|
14
|
+
|
|
15
|
+
included do
|
|
16
|
+
around_action :track_model_changes
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def track_model_changes
|
|
22
|
+
return yield if skip_model_change_tracking? || !Provenance.config.enabled
|
|
23
|
+
|
|
24
|
+
Provenance::Context.journal = Provenance::Journal.new
|
|
25
|
+
|
|
26
|
+
Provenance::Context.pending_audit_log = -> do
|
|
27
|
+
next unless Provenance::Context.request_completed?
|
|
28
|
+
|
|
29
|
+
status = Provenance::Context.response_status
|
|
30
|
+
next if status && status >= 400
|
|
31
|
+
|
|
32
|
+
log_audit_event_and_clear_journal
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
request_obj = request
|
|
36
|
+
if request_obj.respond_to?(:uuid)
|
|
37
|
+
uuid_value = request_obj.uuid
|
|
38
|
+
Provenance::Context.request_id = uuid_value.to_s if uuid_value && !uuid_value.to_s.strip.empty?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Thread.current[:provenance_send_scheduled] = nil
|
|
42
|
+
|
|
43
|
+
yield
|
|
44
|
+
|
|
45
|
+
Provenance::Context.response_status = if response.respond_to?(:status)
|
|
46
|
+
response.status
|
|
47
|
+
else
|
|
48
|
+
200
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
send_audit_after_all_commits
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def send_audit_after_all_commits
|
|
55
|
+
return unless Provenance.config.enabled
|
|
56
|
+
|
|
57
|
+
journal = Provenance::Context.journal
|
|
58
|
+
return unless journal
|
|
59
|
+
|
|
60
|
+
active_transactions = journal.instance_variable_get(:@active_transactions)
|
|
61
|
+
|
|
62
|
+
return if active_transactions.any?
|
|
63
|
+
|
|
64
|
+
send_audit_if_ready
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def send_audit_if_ready
|
|
68
|
+
return unless Provenance.config.enabled
|
|
69
|
+
|
|
70
|
+
journal = Provenance::Context.journal
|
|
71
|
+
|
|
72
|
+
if request.respond_to?(:get?) && (request.get? || request.head?)
|
|
73
|
+
log_audit_event_and_clear_journal
|
|
74
|
+
return
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
return unless journal&.all_transactions_completed?
|
|
78
|
+
|
|
79
|
+
log_audit_event_and_clear_journal
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def log_audit_event_and_clear_journal
|
|
83
|
+
return if skip_audit_logging? || !Provenance.config.enabled
|
|
84
|
+
|
|
85
|
+
journal = Provenance::Context.journal
|
|
86
|
+
return unless journal
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
audit_data = audit_log_data
|
|
90
|
+
|
|
91
|
+
Provenance.config.audit_hooks.each do |hook|
|
|
92
|
+
hook.call(audit_data)
|
|
93
|
+
end
|
|
94
|
+
ensure
|
|
95
|
+
Provenance::Context.cleanup
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def audit_log_data
|
|
100
|
+
journal_data = Provenance::Context.journal&.to_h
|
|
101
|
+
|
|
102
|
+
response_status = response.respond_to?(:status) ? response.status : 200
|
|
103
|
+
|
|
104
|
+
message_data = if response_status < 400
|
|
105
|
+
(journal_data || {}).merge(params: filter_sensitive_params(params.to_unsafe_h))
|
|
106
|
+
else
|
|
107
|
+
filter_sensitive_params(params.to_unsafe_h)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
{
|
|
111
|
+
timestamp: Time.current.utc.iso8601(3),
|
|
112
|
+
event_type: generate_event_type,
|
|
113
|
+
status: response_status,
|
|
114
|
+
message: stringify_hash_values(message_data),
|
|
115
|
+
username: username_from_provider || "unauthenticated",
|
|
116
|
+
remote_ip: remote_ip_from_provider || "unknown",
|
|
117
|
+
origin_ip: origin_ip_from_provider || "unknown",
|
|
118
|
+
session_id: session_id_from_provider || "unauthenticated",
|
|
119
|
+
roles: roles_from_provider || [],
|
|
120
|
+
request_id: Provenance::Context.request_id,
|
|
121
|
+
source: Provenance.config.source_name
|
|
122
|
+
}.compact
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module Provenance
|
|
6
|
+
module Trackers
|
|
7
|
+
# Intercepts bulk operations (`update_all`/`delete_all`) which bypass
|
|
8
|
+
# ActiveRecord callbacks and therefore never reach the regular Trackable
|
|
9
|
+
# path. Captures the ids of the affected rows before execution and records
|
|
10
|
+
# the change in the journal.
|
|
11
|
+
module BulkOperations
|
|
12
|
+
def update_all(*args)
|
|
13
|
+
Provenance::Trackers::BulkOperations.track(self, :bulk_update, args.first) { super }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def delete_all(*args)
|
|
17
|
+
Provenance::Trackers::BulkOperations.track(self, :bulk_delete, nil) { super }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def track(relation, action, updates)
|
|
22
|
+
journal = Provenance::Context.journal
|
|
23
|
+
klass = relation.klass
|
|
24
|
+
|
|
25
|
+
return yield unless trackable?(journal, klass)
|
|
26
|
+
|
|
27
|
+
ids, truncated = safe_collect_ids(relation, klass)
|
|
28
|
+
transaction_id = safe_transaction_id(klass)
|
|
29
|
+
|
|
30
|
+
result = yield
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
journal.add_bulk_change(klass, action, ids, updates,
|
|
34
|
+
truncated: truncated, transaction_id: transaction_id)
|
|
35
|
+
rescue StandardError
|
|
36
|
+
# Auditing must never break the underlying operation.
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def trackable?(journal, klass)
|
|
45
|
+
journal &&
|
|
46
|
+
Provenance.config.track_bulk_operations &&
|
|
47
|
+
klass.respond_to?(:include?) &&
|
|
48
|
+
klass.include?(Provenance::Trackable)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def safe_collect_ids(relation, klass)
|
|
52
|
+
pk = klass.primary_key
|
|
53
|
+
return [[], false] unless pk
|
|
54
|
+
|
|
55
|
+
max = Provenance.config.bulk_operations_max_ids.to_i
|
|
56
|
+
ids = relation.unscope(:select).limit(max + 1).pluck(pk)
|
|
57
|
+
[ids.first(max), ids.size > max]
|
|
58
|
+
rescue StandardError
|
|
59
|
+
[[], false]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def safe_transaction_id(klass)
|
|
63
|
+
Provenance::TransactionKey.for_connection(klass.connection)
|
|
64
|
+
rescue StandardError
|
|
65
|
+
Provenance::Context.request_id
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
ActiveRecord::Relation.prepend(Provenance::Trackers::BulkOperations) if defined?(ActiveRecord::Relation)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "active_support/inflector"
|
|
5
|
+
|
|
6
|
+
module Provenance
|
|
7
|
+
# Controller-side concern that emits a dedicated audit event for failed
|
|
8
|
+
# requests. Call `audit_error(error, status)` from your error handlers.
|
|
9
|
+
module ErrorReporting
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
include Provenance::Trackers::Providers
|
|
12
|
+
|
|
13
|
+
STATUS_CODES = {
|
|
14
|
+
not_found: 404,
|
|
15
|
+
unauthorized: 401,
|
|
16
|
+
forbidden: 403,
|
|
17
|
+
unprocessable_entity: 422,
|
|
18
|
+
conflict: 409
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def audit_error(error_message, status)
|
|
22
|
+
return if skip_audit_logging? || !Provenance.config.enabled
|
|
23
|
+
|
|
24
|
+
numeric_status = status.is_a?(Symbol) ? STATUS_CODES[status] || 500 : status
|
|
25
|
+
|
|
26
|
+
audit_data = {
|
|
27
|
+
timestamp: Time.current.utc.iso8601(3),
|
|
28
|
+
event_type: generate_event_type,
|
|
29
|
+
status: numeric_status.to_s,
|
|
30
|
+
message: stringify_hash_values({
|
|
31
|
+
error_type: error_message.class.name,
|
|
32
|
+
error_message: error_message.is_a?(Exception) ? error_message.message : error_message.to_s,
|
|
33
|
+
params: filter_sensitive_params(params.to_unsafe_h)
|
|
34
|
+
}),
|
|
35
|
+
username: username_from_provider || "unauthenticated",
|
|
36
|
+
remote_ip: remote_ip_from_provider || "unknown",
|
|
37
|
+
origin_ip: origin_ip_from_provider || "unknown",
|
|
38
|
+
session_id: session_id_from_provider || "unauthenticated",
|
|
39
|
+
roles: roles_from_provider || [],
|
|
40
|
+
source: Provenance.config.source_name
|
|
41
|
+
}.compact
|
|
42
|
+
|
|
43
|
+
Provenance.config.audit_hooks.each do |hook|
|
|
44
|
+
hook.call(audit_data)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "active_support/inflector"
|
|
5
|
+
|
|
6
|
+
module Provenance
|
|
7
|
+
module Trackers
|
|
8
|
+
# Shared controller-side helpers: per-action opt-outs, custom event types,
|
|
9
|
+
# value providers and the recursive sanitizers used when serializing audit
|
|
10
|
+
# payloads.
|
|
11
|
+
module Providers
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
class_methods do
|
|
15
|
+
def skip_model_change_tracking(*actions)
|
|
16
|
+
@skip_model_change_tracking_actions ||= []
|
|
17
|
+
@skip_model_change_tracking_actions += actions.map(&:to_s)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def skip_model_change_tracking_actions
|
|
21
|
+
@skip_model_change_tracking_actions || []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def custom_audit_event_type(action, event_type)
|
|
25
|
+
@custom_audit_event_types ||= {}
|
|
26
|
+
@custom_audit_event_types[action] = event_type
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def skip_audit_logging(*actions)
|
|
30
|
+
@skip_audit_logging_actions ||= []
|
|
31
|
+
@skip_audit_logging_actions += actions.map(&:to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def skip_audit_logging_actions
|
|
35
|
+
@skip_audit_logging_actions || []
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def username_from_provider
|
|
42
|
+
return nil unless Provenance.config.username_provider
|
|
43
|
+
|
|
44
|
+
call_provider(Provenance.config.username_provider)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def roles_from_provider
|
|
48
|
+
return [] unless Provenance.config.roles_provider
|
|
49
|
+
|
|
50
|
+
call_provider(Provenance.config.roles_provider)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def remote_ip_from_provider
|
|
54
|
+
return nil unless Provenance.config.remote_ip_provider
|
|
55
|
+
|
|
56
|
+
call_provider(Provenance.config.remote_ip_provider)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def session_id_from_provider
|
|
60
|
+
return nil unless Provenance.config.session_id_provider
|
|
61
|
+
|
|
62
|
+
call_provider(Provenance.config.session_id_provider)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def origin_ip_from_provider
|
|
66
|
+
return nil unless Provenance.config.origin_ip_provider
|
|
67
|
+
|
|
68
|
+
call_provider(Provenance.config.origin_ip_provider)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def call_provider(provider)
|
|
72
|
+
if provider.respond_to?(:call) || provider.is_a?(Proc)
|
|
73
|
+
provider.call(self)
|
|
74
|
+
else
|
|
75
|
+
provider
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def filter_sensitive_params(params)
|
|
80
|
+
sensitive_attrs = Provenance.config.sensitive_attributes
|
|
81
|
+
return params if sensitive_attrs.empty?
|
|
82
|
+
|
|
83
|
+
case params
|
|
84
|
+
when Hash
|
|
85
|
+
filtered_data = {}
|
|
86
|
+
params.each do |key, value|
|
|
87
|
+
filtered_data[key] = if sensitive_attrs.include?(key.to_s)
|
|
88
|
+
"[FILTERED]"
|
|
89
|
+
else
|
|
90
|
+
filter_sensitive_params(value)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
filtered_data
|
|
94
|
+
when Array
|
|
95
|
+
params.map { |item| filter_sensitive_params(item) }
|
|
96
|
+
else
|
|
97
|
+
params
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def generate_event_type
|
|
102
|
+
controller_path = params[:controller].to_s.tr("/", "_")
|
|
103
|
+
action = action_name
|
|
104
|
+
|
|
105
|
+
custom_event_type = self.class.instance_variable_get(:@custom_audit_event_types)&.[](action.to_sym)
|
|
106
|
+
return custom_event_type if custom_event_type
|
|
107
|
+
|
|
108
|
+
singular_path = ActiveSupport::Inflector.singularize(controller_path)
|
|
109
|
+
|
|
110
|
+
case action.to_sym
|
|
111
|
+
when :index
|
|
112
|
+
"read_#{controller_path}"
|
|
113
|
+
when :show
|
|
114
|
+
"show_#{singular_path}"
|
|
115
|
+
when :create
|
|
116
|
+
"create_#{controller_path}"
|
|
117
|
+
when :update
|
|
118
|
+
"update_#{singular_path}"
|
|
119
|
+
when :destroy
|
|
120
|
+
"destroy_#{singular_path}"
|
|
121
|
+
else
|
|
122
|
+
"#{action}_#{controller_path}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def skip_model_change_tracking?
|
|
127
|
+
self.class.skip_model_change_tracking_actions.include?(action_name) ||
|
|
128
|
+
(respond_to?(:skip_audit_logging?) && skip_audit_logging?)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def skip_audit_logging?
|
|
132
|
+
self.class.skip_audit_logging_actions.include?(action_name)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def stringify_hash_values(data, seen = Set.new)
|
|
136
|
+
case data
|
|
137
|
+
when Hash
|
|
138
|
+
return data if seen.include?(data.object_id)
|
|
139
|
+
|
|
140
|
+
seen.add(data.object_id)
|
|
141
|
+
data.transform_values { |value| stringify_hash_values(value, seen) }
|
|
142
|
+
when Array
|
|
143
|
+
return data if seen.include?(data.object_id)
|
|
144
|
+
|
|
145
|
+
seen.add(data.object_id)
|
|
146
|
+
data.map { |item| stringify_hash_values(item, seen) }
|
|
147
|
+
when Numeric, TrueClass, FalseClass
|
|
148
|
+
data.to_s
|
|
149
|
+
when Date, Time, DateTime
|
|
150
|
+
data.iso8601
|
|
151
|
+
when NilClass, String
|
|
152
|
+
data
|
|
153
|
+
else
|
|
154
|
+
begin
|
|
155
|
+
stringify_hash_values(data.as_json, seen)
|
|
156
|
+
rescue StandardError
|
|
157
|
+
data.to_s
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|