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.
@@ -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