provenance 2.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 +19 -0
- data/LICENSE +21 -0
- data/README.md +358 -0
- data/lib/generators/provenance/install/install_generator.rb +34 -0
- data/lib/generators/provenance/install/templates/create_provenance_outbox.rb.tt +21 -0
- data/lib/generators/provenance/install/templates/initializer.rb.tt +12 -0
- data/lib/provenance/action.rb +160 -0
- data/lib/provenance/actor.rb +86 -0
- data/lib/provenance/configuration.rb +152 -0
- data/lib/provenance/context.rb +30 -0
- data/lib/provenance/controller.rb +93 -0
- data/lib/provenance/emitter.rb +66 -0
- data/lib/provenance/entity_change.rb +73 -0
- data/lib/provenance/errors.rb +12 -0
- data/lib/provenance/event.rb +42 -0
- data/lib/provenance/integrity.rb +75 -0
- data/lib/provenance/job.rb +62 -0
- data/lib/provenance/json_safe.rb +41 -0
- data/lib/provenance/middleware.rb +44 -0
- data/lib/provenance/model.rb +123 -0
- data/lib/provenance/outbox/record.rb +23 -0
- data/lib/provenance/outbox/relay.rb +81 -0
- data/lib/provenance/outbox.rb +67 -0
- data/lib/provenance/railtie.rb +35 -0
- data/lib/provenance/rake.rb +27 -0
- data/lib/provenance/recorder.rb +206 -0
- data/lib/provenance/redactor.rb +63 -0
- data/lib/provenance/registry.rb +48 -0
- data/lib/provenance/relay_job.rb +15 -0
- data/lib/provenance/rspec.rb +139 -0
- data/lib/provenance/serializers/cloud_events.rb +26 -0
- data/lib/provenance/serializers/native.rb +16 -0
- data/lib/provenance/serializers/ocsf.rb +127 -0
- data/lib/provenance/serializers.rb +31 -0
- data/lib/provenance/sinks/http.rb +81 -0
- data/lib/provenance/sinks/io.rb +24 -0
- data/lib/provenance/sinks/kafka.rb +45 -0
- data/lib/provenance/sinks/logger.rb +22 -0
- data/lib/provenance/sinks/memory.rb +29 -0
- data/lib/provenance/sinks/proc.rb +20 -0
- data/lib/provenance/sinks.rb +35 -0
- data/lib/provenance/testing.rb +36 -0
- data/lib/provenance/uuid.rb +20 -0
- data/lib/provenance/version.rb +6 -0
- data/lib/provenance.rb +166 -0
- data/lib/tasks/provenance.rake +22 -0
- data/schema/event-2.json +126 -0
- metadata +150 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
# Who performed an action.
|
|
5
|
+
class Actor
|
|
6
|
+
# @return [String, nil]
|
|
7
|
+
attr_reader :id
|
|
8
|
+
# @return [String, nil]
|
|
9
|
+
attr_reader :type
|
|
10
|
+
# @return [String, nil]
|
|
11
|
+
attr_reader :display
|
|
12
|
+
# @return [Array<String>]
|
|
13
|
+
attr_reader :roles
|
|
14
|
+
# @return [Actor, nil] the real user behind a "login as" session
|
|
15
|
+
attr_reader :impersonator
|
|
16
|
+
|
|
17
|
+
# Builds an actor from a user-like object, a Hash, an {Actor} or nil.
|
|
18
|
+
#
|
|
19
|
+
# Objects are read through +provenance_actor+ (returning a Hash) when defined,
|
|
20
|
+
# otherwise through +id+, +provenance_display+/+email+/+name+ and +provenance_roles+.
|
|
21
|
+
#
|
|
22
|
+
# @param value [Object, Hash, Actor, nil]
|
|
23
|
+
# @return [Actor, nil]
|
|
24
|
+
def self.wrap(value)
|
|
25
|
+
case value
|
|
26
|
+
when nil then nil
|
|
27
|
+
when Actor then value
|
|
28
|
+
when Hash then new(**value.symbolize_keys.slice(:id, :type, :display, :roles, :impersonator))
|
|
29
|
+
else
|
|
30
|
+
return wrap(value.provenance_actor) if value.respond_to?(:provenance_actor)
|
|
31
|
+
|
|
32
|
+
new(
|
|
33
|
+
id: value.respond_to?(:id) ? value.id : nil,
|
|
34
|
+
type: value.class.name,
|
|
35
|
+
display: display_for(value),
|
|
36
|
+
roles: value.respond_to?(:provenance_roles) ? value.provenance_roles : []
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @api private
|
|
42
|
+
def self.display_for(value)
|
|
43
|
+
%i[provenance_display email name].each do |m|
|
|
44
|
+
return value.public_send(m) if value.respond_to?(m)
|
|
45
|
+
end
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param id [Object]
|
|
50
|
+
# @param type [String, nil]
|
|
51
|
+
# @param display [String, nil]
|
|
52
|
+
# @param roles [Array]
|
|
53
|
+
# @param impersonator [Object, nil] anything {wrap} accepts
|
|
54
|
+
def initialize(id:, type: nil, display: nil, roles: [], impersonator: nil)
|
|
55
|
+
@id = id&.to_s
|
|
56
|
+
@type = type&.to_s
|
|
57
|
+
@display = display&.to_s
|
|
58
|
+
@roles = Array(roles).map(&:to_s)
|
|
59
|
+
@impersonator = Actor.wrap(impersonator)
|
|
60
|
+
freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [Hash{String => Object}]
|
|
64
|
+
def to_h
|
|
65
|
+
{
|
|
66
|
+
"id" => id,
|
|
67
|
+
"type" => type,
|
|
68
|
+
"display" => display,
|
|
69
|
+
"roles" => roles,
|
|
70
|
+
"impersonator" => impersonator&.to_h
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @param other [Object]
|
|
75
|
+
# @return [Boolean]
|
|
76
|
+
def ==(other)
|
|
77
|
+
other.is_a?(Actor) && other.to_h == to_h
|
|
78
|
+
end
|
|
79
|
+
alias_method :eql?, :==
|
|
80
|
+
|
|
81
|
+
# @return [Integer]
|
|
82
|
+
def hash
|
|
83
|
+
to_h.hash
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
# Global settings, see {Provenance.configure}.
|
|
5
|
+
class Configuration
|
|
6
|
+
# Accepted values for {#format}.
|
|
7
|
+
FORMATS = %i[native ocsf cloudevents].freeze
|
|
8
|
+
|
|
9
|
+
# Settings for tamper evidence (section 7 of the specification).
|
|
10
|
+
class Integrity
|
|
11
|
+
# @return [String, nil] HMAC-SHA256 key; +nil+ disables integrity
|
|
12
|
+
attr_accessor :key
|
|
13
|
+
|
|
14
|
+
# @return [Boolean]
|
|
15
|
+
def enabled?
|
|
16
|
+
key.present?
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Boolean] master switch; default +true+
|
|
21
|
+
attr_accessor :enabled
|
|
22
|
+
# @return [Array<Symbol, String, Regexp>] extra attributes to redact, merged with Rails filter_parameters
|
|
23
|
+
attr_accessor :redact_attributes
|
|
24
|
+
# @return [Array<String>] attributes never recorded in diffs; default +created_at+, +updated_at+
|
|
25
|
+
attr_accessor :ignored_attributes
|
|
26
|
+
# @return [Symbol] :native, :ocsf or :cloudevents
|
|
27
|
+
attr_accessor :format
|
|
28
|
+
# @return [Boolean] write events to the outbox table instead of delivering inline
|
|
29
|
+
attr_accessor :outbox
|
|
30
|
+
# @return [ActiveSupport::Duration] how long delivered outbox rows are kept
|
|
31
|
+
attr_accessor :outbox_retention
|
|
32
|
+
# @return [Integer] rows per relay batch
|
|
33
|
+
attr_accessor :outbox_batch_size
|
|
34
|
+
# @return [Boolean] enqueue {RelayJob} after each outbox write
|
|
35
|
+
attr_accessor :outbox_enqueue_relay
|
|
36
|
+
# @return [Boolean] emit successful GET/HEAD actions without changes
|
|
37
|
+
attr_accessor :audit_reads
|
|
38
|
+
# @return [Array<String>] exception class names that produce a +denied+ outcome
|
|
39
|
+
attr_accessor :denied_exceptions
|
|
40
|
+
# @return [Integer] maximum number of ids recorded for a bulk change
|
|
41
|
+
attr_accessor :bulk_ids_limit
|
|
42
|
+
# @return [Array<#deliver>] configured sinks
|
|
43
|
+
attr_reader :sinks
|
|
44
|
+
# @return [Integrity]
|
|
45
|
+
attr_reader :integrity
|
|
46
|
+
# @return [Proc, nil] actor resolver set with {#actor}
|
|
47
|
+
attr_reader :actor_resolver
|
|
48
|
+
# @return [Proc, nil] error handler set with {#on_error}
|
|
49
|
+
attr_reader :error_handler
|
|
50
|
+
# @return [Logger, nil] logger for diagnostics; defaults to Rails.logger
|
|
51
|
+
attr_writer :logger
|
|
52
|
+
# @return [String] application name
|
|
53
|
+
attr_writer :app_name
|
|
54
|
+
|
|
55
|
+
def initialize
|
|
56
|
+
@enabled = true
|
|
57
|
+
@redact_attributes = []
|
|
58
|
+
@ignored_attributes = %w[created_at updated_at]
|
|
59
|
+
@format = :native
|
|
60
|
+
@sinks = []
|
|
61
|
+
@outbox = false
|
|
62
|
+
@outbox_retention = 7.days
|
|
63
|
+
@outbox_batch_size = 100
|
|
64
|
+
@outbox_enqueue_relay = true
|
|
65
|
+
@integrity = Integrity.new
|
|
66
|
+
@audit_reads = false
|
|
67
|
+
@denied_exceptions = ["CanCan::AccessDenied", "Pundit::NotAuthorizedError"]
|
|
68
|
+
@bulk_ids_limit = 500
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Application name used as the event +app+ and CloudEvents +source+. Defaults to
|
|
72
|
+
# the Rails application module name, underscored.
|
|
73
|
+
#
|
|
74
|
+
# @return [String]
|
|
75
|
+
def app_name
|
|
76
|
+
@app_name ||= if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
77
|
+
Rails.application.class.module_parent_name.underscore
|
|
78
|
+
else
|
|
79
|
+
"app"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Logger]
|
|
84
|
+
def logger
|
|
85
|
+
@logger || (defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) || Logger.new($stderr)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Sets the actor resolver. The block receives a {Context} and returns a user-like
|
|
89
|
+
# object, a Hash or an {Actor}.
|
|
90
|
+
#
|
|
91
|
+
# @yieldparam ctx [Context]
|
|
92
|
+
# @return [void]
|
|
93
|
+
def actor(&block)
|
|
94
|
+
@actor_resolver = block
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Sets the handler for sink and emission failures.
|
|
98
|
+
#
|
|
99
|
+
# @yieldparam exception [Exception]
|
|
100
|
+
# @yieldparam event [Hash, nil]
|
|
101
|
+
# @return [void]
|
|
102
|
+
def on_error(&block)
|
|
103
|
+
@error_handler = block
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Adds a sink. Accepts a built-in type (:logger, :io, :http, :proc, :memory,
|
|
107
|
+
# :kafka) with its options, or any object responding to +#deliver(batch)+.
|
|
108
|
+
#
|
|
109
|
+
# @param type [Symbol, #deliver]
|
|
110
|
+
# @return [#deliver] the sink instance
|
|
111
|
+
def sink(type, *args, **options, &block)
|
|
112
|
+
Sinks.build(type, *args, **options, &block).tap { |s| @sinks << s }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Reports an error through the configured handler, falling back to the logger.
|
|
116
|
+
#
|
|
117
|
+
# @param exception [Exception]
|
|
118
|
+
# @param event [Hash, nil]
|
|
119
|
+
# @return [void]
|
|
120
|
+
def report_error(exception, event = nil)
|
|
121
|
+
if error_handler
|
|
122
|
+
error_handler.call(exception, event)
|
|
123
|
+
else
|
|
124
|
+
logger.error("[provenance] #{exception.class}: #{exception.message}")
|
|
125
|
+
end
|
|
126
|
+
rescue => e
|
|
127
|
+
logger.error("[provenance] error handler failed: #{e.class}: #{e.message}")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @param exception [Exception]
|
|
131
|
+
# @return [Boolean] whether the exception produces a +denied+ outcome
|
|
132
|
+
def denied?(exception)
|
|
133
|
+
names = Array(denied_exceptions).map(&:to_s)
|
|
134
|
+
exception.class.ancestors.any? { |mod| names.include?(mod.name) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @raise [ConfigurationError]
|
|
138
|
+
# @return [true]
|
|
139
|
+
def validate!
|
|
140
|
+
unless FORMATS.include?(format)
|
|
141
|
+
raise ConfigurationError, "format must be one of #{FORMATS.join(", ")}, got #{format.inspect}"
|
|
142
|
+
end
|
|
143
|
+
if integrity.enabled? && !outbox
|
|
144
|
+
raise ConfigurationError, "integrity requires the outbox (set c.outbox = true)"
|
|
145
|
+
end
|
|
146
|
+
unless bulk_ids_limit.is_a?(Integer) && bulk_ids_limit >= 0
|
|
147
|
+
raise ConfigurationError, "bulk_ids_limit must be a non-negative Integer"
|
|
148
|
+
end
|
|
149
|
+
true
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
# What the actor resolver sees: whichever of controller, job, request and Rack env
|
|
5
|
+
# are available for the current action.
|
|
6
|
+
class Context
|
|
7
|
+
# @return [ActionController::Metal, nil]
|
|
8
|
+
attr_accessor :controller
|
|
9
|
+
# @return [ActiveJob::Base, nil]
|
|
10
|
+
attr_accessor :job
|
|
11
|
+
# @return [Hash, nil] the Rack env
|
|
12
|
+
attr_accessor :env
|
|
13
|
+
|
|
14
|
+
# @param controller [ActionController::Metal, nil]
|
|
15
|
+
# @param job [ActiveJob::Base, nil]
|
|
16
|
+
# @param request [ActionDispatch::Request, nil]
|
|
17
|
+
# @param env [Hash, nil]
|
|
18
|
+
def initialize(controller: nil, job: nil, request: nil, env: nil)
|
|
19
|
+
@controller = controller
|
|
20
|
+
@job = job
|
|
21
|
+
@request = request
|
|
22
|
+
@env = env
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [ActionDispatch::Request, nil]
|
|
26
|
+
def request
|
|
27
|
+
@request ||= controller&.request || (env && ActionDispatch::Request.new(env))
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
# Controller integration, included into ActionController::Base and ::API.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# class InvoicesController < ApplicationController
|
|
8
|
+
# provenance_action_name ->(controller) { "billing.#{controller.action_name}" }
|
|
9
|
+
# skip_provenance only: :preview
|
|
10
|
+
# end
|
|
11
|
+
module Controller
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
class_attribute :_provenance_action_name, instance_accessor: false, default: nil
|
|
16
|
+
class_attribute :_provenance_skip, instance_accessor: false, default: nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class_methods do
|
|
20
|
+
# Overrides the action name for this controller.
|
|
21
|
+
#
|
|
22
|
+
# @param callable [#call] receives the controller, returns the name
|
|
23
|
+
# @return [void]
|
|
24
|
+
def provenance_action_name(callable = nil, &block)
|
|
25
|
+
self._provenance_action_name = callable || block
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Excludes actions of this controller from auditing.
|
|
29
|
+
#
|
|
30
|
+
# @param only [Array<Symbol>, Symbol, nil]
|
|
31
|
+
# @param except [Array<Symbol>, Symbol, nil]
|
|
32
|
+
# @return [void]
|
|
33
|
+
def skip_provenance(only: nil, except: nil)
|
|
34
|
+
self._provenance_skip = {only: only && Array(only).map(&:to_s), except: Array(except).map(&:to_s)}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @api private
|
|
39
|
+
def process_action(*)
|
|
40
|
+
Provenance.current&.context&.controller ||= self
|
|
41
|
+
super
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Name of the current action for Provenance.
|
|
45
|
+
#
|
|
46
|
+
# @return [String]
|
|
47
|
+
def provenance_action_name
|
|
48
|
+
custom = self.class._provenance_action_name
|
|
49
|
+
custom ? custom.call(self).to_s : "#{controller_path}##{action_name}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Boolean] whether this controller action is excluded
|
|
53
|
+
def skip_provenance?
|
|
54
|
+
rule = self.class._provenance_skip
|
|
55
|
+
return false unless rule
|
|
56
|
+
return false if rule[:except].include?(action_name.to_s)
|
|
57
|
+
|
|
58
|
+
rule[:only].nil? || rule[:only].include?(action_name.to_s)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def append_info_to_payload(payload)
|
|
64
|
+
super
|
|
65
|
+
payload[:provenance_action_name] = provenance_action_name
|
|
66
|
+
payload[:provenance_skip] = skip_provenance?
|
|
67
|
+
rescue => e
|
|
68
|
+
Provenance.config.report_error(e)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Applies the process_action.action_controller payload to the current action.
|
|
72
|
+
#
|
|
73
|
+
# @api private
|
|
74
|
+
module Subscriber
|
|
75
|
+
module_function
|
|
76
|
+
|
|
77
|
+
# @param payload [Hash]
|
|
78
|
+
# @return [void]
|
|
79
|
+
def call(payload)
|
|
80
|
+
action = Provenance.current
|
|
81
|
+
return unless action&.kind == "http"
|
|
82
|
+
|
|
83
|
+
if payload[:provenance_skip]
|
|
84
|
+
action.skip!
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
action.name = payload[:provenance_action_name] || "#{payload[:controller]}##{payload[:action]}"
|
|
88
|
+
action.request["status"] = payload[:status] if payload[:status]
|
|
89
|
+
action.fail!(payload[:exception_object]) if payload[:exception_object]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
# Turns closed actions into events and hands them to the outbox or the sinks.
|
|
5
|
+
# Never raises: failures are reported through +on_error+.
|
|
6
|
+
module Emitter
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Schedules emission of a closed action once every open transaction has committed.
|
|
10
|
+
#
|
|
11
|
+
# @param action [Action]
|
|
12
|
+
# @return [void]
|
|
13
|
+
def finish(action)
|
|
14
|
+
return if action.skipped? || !Provenance.enabled?
|
|
15
|
+
|
|
16
|
+
ActiveRecord.after_all_transactions_commit { emit(action) }
|
|
17
|
+
rescue => e
|
|
18
|
+
Provenance.config.report_error(e)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Builds and dispatches the event for an action if it is auditable.
|
|
22
|
+
#
|
|
23
|
+
# @param action [Action]
|
|
24
|
+
# @return [Hash, nil] the native event
|
|
25
|
+
def emit(action)
|
|
26
|
+
return unless action.auditable?
|
|
27
|
+
|
|
28
|
+
event = Event.build(action)
|
|
29
|
+
dispatch(event)
|
|
30
|
+
event
|
|
31
|
+
rescue => e
|
|
32
|
+
Provenance.config.report_error(e, event)
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param event [Hash] native event
|
|
37
|
+
# @return [void]
|
|
38
|
+
def dispatch(event)
|
|
39
|
+
if Testing.capturing?
|
|
40
|
+
Testing.record(event)
|
|
41
|
+
elsif Provenance.config.outbox
|
|
42
|
+
Outbox.write(event)
|
|
43
|
+
else
|
|
44
|
+
deliver([event])
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Serializes native events in the configured format and delivers them to every
|
|
49
|
+
# sink. A failing sink does not prevent delivery to the others.
|
|
50
|
+
#
|
|
51
|
+
# @param events [Array<Hash>] native events
|
|
52
|
+
# @param raise_errors [Boolean] re-raise the first sink failure after trying all sinks
|
|
53
|
+
# @return [void]
|
|
54
|
+
def deliver(events, raise_errors: false)
|
|
55
|
+
batch = Serializers.serialize_all(events, Provenance.config.format)
|
|
56
|
+
failure = nil
|
|
57
|
+
Provenance.config.sinks.each do |sink|
|
|
58
|
+
sink.deliver(batch)
|
|
59
|
+
rescue => e
|
|
60
|
+
failure ||= e
|
|
61
|
+
events.each { |event| Provenance.config.report_error(e, event) } unless raise_errors
|
|
62
|
+
end
|
|
63
|
+
raise failure if failure && raise_errors
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
# One record-level change attached to an action.
|
|
5
|
+
class EntityChange
|
|
6
|
+
# Accepted operations.
|
|
7
|
+
OPERATIONS = %w[create update destroy link unlink bulk_update bulk_delete bulk_insert].freeze
|
|
8
|
+
|
|
9
|
+
# @return [String]
|
|
10
|
+
attr_reader :entity
|
|
11
|
+
# @return [String]
|
|
12
|
+
attr_reader :operation
|
|
13
|
+
# @return [Hash, nil] {attr => [before, after]}; create holds after values, destroy before values
|
|
14
|
+
attr_reader :diff
|
|
15
|
+
# @return [String, nil] association name for link/unlink
|
|
16
|
+
attr_reader :association
|
|
17
|
+
# @return [Hash, nil] {"type", "id"} of the linked record for link/unlink
|
|
18
|
+
attr_reader :target
|
|
19
|
+
# @return [Integer, nil] affected rows for bulk operations
|
|
20
|
+
attr_reader :count
|
|
21
|
+
# @return [String, nil] fingerprint of the WHERE clause for bulk operations
|
|
22
|
+
attr_reader :where
|
|
23
|
+
# @return [Array<String>, nil] affected ids for bulk operations
|
|
24
|
+
attr_reader :ids
|
|
25
|
+
# @return [Boolean, nil] whether {#ids} was cut at +bulk_ids_limit+
|
|
26
|
+
attr_reader :truncated
|
|
27
|
+
|
|
28
|
+
# @param entity [String]
|
|
29
|
+
# @param operation [String, Symbol]
|
|
30
|
+
# @param entity_id [Object, Proc] a Proc is evaluated when the event is built
|
|
31
|
+
def initialize(entity:, operation:, entity_id: nil, diff: nil, association: nil, target: nil,
|
|
32
|
+
count: nil, where: nil, ids: nil, truncated: nil)
|
|
33
|
+
@entity = entity.to_s
|
|
34
|
+
@operation = operation.to_s
|
|
35
|
+
raise ArgumentError, "unknown operation #{operation.inspect}" unless OPERATIONS.include?(@operation)
|
|
36
|
+
|
|
37
|
+
@entity_id = entity_id
|
|
38
|
+
@diff = diff
|
|
39
|
+
@association = association&.to_s
|
|
40
|
+
@target = target
|
|
41
|
+
@count = count
|
|
42
|
+
@where = where
|
|
43
|
+
@ids = ids
|
|
44
|
+
@truncated = truncated
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [String, nil]
|
|
48
|
+
def entity_id
|
|
49
|
+
value = @entity_id.is_a?(Proc) ? @entity_id.call : @entity_id
|
|
50
|
+
value&.to_s
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Hash, nil] {#target} with lazily computed ids evaluated
|
|
54
|
+
def resolved_target
|
|
55
|
+
target&.transform_values { |v| v.is_a?(Proc) ? v.call&.to_s : v }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Boolean]
|
|
59
|
+
def bulk?
|
|
60
|
+
operation.start_with?("bulk_")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [Hash{String => Object}] JSON-safe representation
|
|
64
|
+
def to_h
|
|
65
|
+
h = {"entity" => entity, "entity_id" => entity_id, "operation" => operation, "diff" => JsonSafe.call(diff)}
|
|
66
|
+
h.merge!("association" => association, "target" => JsonSafe.call(resolved_target)) if association
|
|
67
|
+
if bulk?
|
|
68
|
+
h.merge!("count" => count, "where" => where, "ids" => Array(ids).map(&:to_s), "truncated" => truncated ? true : false)
|
|
69
|
+
end
|
|
70
|
+
h
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
# Base class for all Provenance errors.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when {Configuration#validate!} finds an invalid setting.
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised by sinks when an event batch could not be delivered.
|
|
11
|
+
class DeliveryError < Error; end
|
|
12
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
# Builds the immutable native event (schema "provenance/event@2") for an action.
|
|
5
|
+
module Event
|
|
6
|
+
# Value of the +schema+ field.
|
|
7
|
+
SCHEMA = "provenance/event@2"
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# @param action [Action]
|
|
12
|
+
# @return [Hash{String => Object}] deeply frozen, JSON-safe event
|
|
13
|
+
def build(action)
|
|
14
|
+
event = {
|
|
15
|
+
"schema" => SCHEMA,
|
|
16
|
+
"id" => action.id,
|
|
17
|
+
"occurred_at" => action.started_at.utc.iso8601(3),
|
|
18
|
+
"app" => Provenance.config.app_name,
|
|
19
|
+
"action" => {"kind" => action.kind, "name" => action.name.to_s, "caused_by" => action.caused_by},
|
|
20
|
+
"actor" => action.actor&.to_h,
|
|
21
|
+
"request" => (action.kind == "http") ? JsonSafe.call(action.request) : nil,
|
|
22
|
+
"outcome" => {"result" => action.result, "error" => action.error},
|
|
23
|
+
"changes" => action.changes.map(&:to_h),
|
|
24
|
+
"metadata" => JsonSafe.call(Redactor.deep(action.metadata))
|
|
25
|
+
}
|
|
26
|
+
deep_freeze(event)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns a frozen deep copy of a JSON-like structure.
|
|
30
|
+
#
|
|
31
|
+
# @param value [Object]
|
|
32
|
+
# @return [Object]
|
|
33
|
+
def deep_freeze(value)
|
|
34
|
+
case value
|
|
35
|
+
when Hash then value.to_h { |k, v| [k.to_s.freeze, deep_freeze(v)] }.freeze
|
|
36
|
+
when Array then value.map { |v| deep_freeze(v) }.freeze
|
|
37
|
+
when String then value.frozen? ? value : value.dup.freeze
|
|
38
|
+
else value
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Provenance
|
|
6
|
+
# Tamper evidence: a per-app HMAC-SHA256 chain over canonical JSON.
|
|
7
|
+
#
|
|
8
|
+
# mac = HMAC-SHA256(key, prev + canonical_json(event without "integrity"))
|
|
9
|
+
module Integrity
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Canonical JSON: object keys sorted, no insignificant whitespace.
|
|
13
|
+
#
|
|
14
|
+
# @param value [Object] JSON-like structure
|
|
15
|
+
# @return [String]
|
|
16
|
+
def canonical(value)
|
|
17
|
+
JSON.generate(sort(value))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param key [String]
|
|
21
|
+
# @param prev [String, nil] mac of the previous event
|
|
22
|
+
# @param event [Hash] native event; an "integrity" member is ignored
|
|
23
|
+
# @return [String] lowercase hex HMAC-SHA256
|
|
24
|
+
def mac(key, prev, event)
|
|
25
|
+
payload = stringify(event).except("integrity")
|
|
26
|
+
OpenSSL::HMAC.hexdigest("SHA256", key.to_s, "#{prev}#{canonical(payload)}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Verifies a contiguous, seq-ordered list of events from one app. The +prev+ of
|
|
30
|
+
# the first event is taken as given, so a pruned outbox still verifies.
|
|
31
|
+
#
|
|
32
|
+
# @param events [Array<Hash>] native events with an "integrity" member
|
|
33
|
+
# @param key [String]
|
|
34
|
+
# @return [Integer, nil] the first broken seq, or nil when the chain is intact
|
|
35
|
+
def verify(events, key:)
|
|
36
|
+
previous = nil
|
|
37
|
+
events.each do |raw|
|
|
38
|
+
event = stringify(raw)
|
|
39
|
+
integrity = event["integrity"]
|
|
40
|
+
expected_seq = previous ? previous["seq"] + 1 : nil
|
|
41
|
+
return expected_seq || 0 unless integrity.is_a?(Hash) && integrity["seq"].is_a?(Integer)
|
|
42
|
+
|
|
43
|
+
seq = integrity["seq"]
|
|
44
|
+
return seq if previous && (seq != expected_seq || integrity["prev"] != previous["mac"])
|
|
45
|
+
return seq unless secure_compare(mac(key, integrity["prev"], event), integrity["mac"].to_s)
|
|
46
|
+
|
|
47
|
+
previous = integrity
|
|
48
|
+
end
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @api private
|
|
53
|
+
def secure_compare(a, b)
|
|
54
|
+
a.bytesize == b.bytesize && OpenSSL.fixed_length_secure_compare(a, b)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @api private
|
|
58
|
+
def sort(value)
|
|
59
|
+
case value
|
|
60
|
+
when Hash then value.keys.map(&:to_s).sort.to_h { |k| [k, sort(value[k] || value[k.to_sym])] }
|
|
61
|
+
when Array then value.map { |v| sort(v) }
|
|
62
|
+
else value
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @api private
|
|
67
|
+
def stringify(value)
|
|
68
|
+
case value
|
|
69
|
+
when Hash then value.to_h { |k, v| [k.to_s, stringify(v)] }
|
|
70
|
+
when Array then value.map { |v| stringify(v) }
|
|
71
|
+
else value
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
# ActiveJob integration, included into ActiveJob::Base. Each performed job is a
|
|
5
|
+
# +job+ action; the enqueuing action's id and actor travel in the job payload
|
|
6
|
+
# under the "provenance" key.
|
|
7
|
+
module Job
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
# Job payload key.
|
|
11
|
+
KEY = "provenance"
|
|
12
|
+
|
|
13
|
+
included do
|
|
14
|
+
class_attribute :_provenance_skip, instance_accessor: false, default: false
|
|
15
|
+
around_perform { |job, block| Provenance::Job.perform(job, &block) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class_methods do
|
|
19
|
+
# Excludes this job class from auditing.
|
|
20
|
+
#
|
|
21
|
+
# @return [void]
|
|
22
|
+
def skip_provenance
|
|
23
|
+
self._provenance_skip = true
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Hash, nil] the deserialized "provenance" payload
|
|
28
|
+
attr_reader :provenance_parent
|
|
29
|
+
|
|
30
|
+
# @api private
|
|
31
|
+
def serialize
|
|
32
|
+
data = super
|
|
33
|
+
action = Provenance.current
|
|
34
|
+
if action && Provenance.enabled?
|
|
35
|
+
data[KEY] = {"action_id" => action.id, "actor" => action.actor&.to_h}
|
|
36
|
+
end
|
|
37
|
+
data
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @api private
|
|
41
|
+
def deserialize(job_data)
|
|
42
|
+
super
|
|
43
|
+
@provenance_parent = job_data[KEY]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Runs a job inside a +job+ action.
|
|
47
|
+
#
|
|
48
|
+
# @param job [ActiveJob::Base]
|
|
49
|
+
# @return [Object]
|
|
50
|
+
def self.perform(job, &block)
|
|
51
|
+
return Provenance.without(&block) if job.class._provenance_skip
|
|
52
|
+
return block.call unless Provenance.enabled?
|
|
53
|
+
|
|
54
|
+
parent = job.provenance_parent || {}
|
|
55
|
+
actor = parent["actor"] && Actor.wrap(parent["actor"])
|
|
56
|
+
Provenance.track(
|
|
57
|
+
kind: :job, name: job.class.name, actor: actor, caused_by: parent["action_id"],
|
|
58
|
+
context: Context.new(job: job)
|
|
59
|
+
) { block.call }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|