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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +19 -0
  3. data/LICENSE +21 -0
  4. data/README.md +358 -0
  5. data/lib/generators/provenance/install/install_generator.rb +34 -0
  6. data/lib/generators/provenance/install/templates/create_provenance_outbox.rb.tt +21 -0
  7. data/lib/generators/provenance/install/templates/initializer.rb.tt +12 -0
  8. data/lib/provenance/action.rb +160 -0
  9. data/lib/provenance/actor.rb +86 -0
  10. data/lib/provenance/configuration.rb +152 -0
  11. data/lib/provenance/context.rb +30 -0
  12. data/lib/provenance/controller.rb +93 -0
  13. data/lib/provenance/emitter.rb +66 -0
  14. data/lib/provenance/entity_change.rb +73 -0
  15. data/lib/provenance/errors.rb +12 -0
  16. data/lib/provenance/event.rb +42 -0
  17. data/lib/provenance/integrity.rb +75 -0
  18. data/lib/provenance/job.rb +62 -0
  19. data/lib/provenance/json_safe.rb +41 -0
  20. data/lib/provenance/middleware.rb +44 -0
  21. data/lib/provenance/model.rb +123 -0
  22. data/lib/provenance/outbox/record.rb +23 -0
  23. data/lib/provenance/outbox/relay.rb +81 -0
  24. data/lib/provenance/outbox.rb +67 -0
  25. data/lib/provenance/railtie.rb +35 -0
  26. data/lib/provenance/rake.rb +27 -0
  27. data/lib/provenance/recorder.rb +206 -0
  28. data/lib/provenance/redactor.rb +63 -0
  29. data/lib/provenance/registry.rb +48 -0
  30. data/lib/provenance/relay_job.rb +15 -0
  31. data/lib/provenance/rspec.rb +139 -0
  32. data/lib/provenance/serializers/cloud_events.rb +26 -0
  33. data/lib/provenance/serializers/native.rb +16 -0
  34. data/lib/provenance/serializers/ocsf.rb +127 -0
  35. data/lib/provenance/serializers.rb +31 -0
  36. data/lib/provenance/sinks/http.rb +81 -0
  37. data/lib/provenance/sinks/io.rb +24 -0
  38. data/lib/provenance/sinks/kafka.rb +45 -0
  39. data/lib/provenance/sinks/logger.rb +22 -0
  40. data/lib/provenance/sinks/memory.rb +29 -0
  41. data/lib/provenance/sinks/proc.rb +20 -0
  42. data/lib/provenance/sinks.rb +35 -0
  43. data/lib/provenance/testing.rb +36 -0
  44. data/lib/provenance/uuid.rb +20 -0
  45. data/lib/provenance/version.rb +6 -0
  46. data/lib/provenance.rb +166 -0
  47. data/lib/tasks/provenance.rake +22 -0
  48. data/schema/event-2.json +126 -0
  49. 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