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,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
# Replaces sensitive values with "[REDACTED]".
|
|
5
|
+
module Redactor
|
|
6
|
+
# Replacement for redacted values.
|
|
7
|
+
MASK = "[REDACTED]"
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# @return [Array<Symbol, String, Regexp, Proc>] configured redactions merged with Rails filter_parameters
|
|
12
|
+
def filters
|
|
13
|
+
rails = (defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.config&.filter_parameters) || []
|
|
14
|
+
Array(rails) + Array(Provenance.config.redact_attributes)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param name [String, Symbol]
|
|
18
|
+
# @param extra [Array<String>] exact attribute names to redact
|
|
19
|
+
# @return [Boolean]
|
|
20
|
+
def redact?(name, extra = [])
|
|
21
|
+
return true if extra.include?(name.to_s)
|
|
22
|
+
|
|
23
|
+
filtered = parameter_filter.filter(name.to_s => true)
|
|
24
|
+
filtered[name.to_s] != true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Redacts a diff, keeping its shape.
|
|
28
|
+
#
|
|
29
|
+
# @param diff [Hash, nil]
|
|
30
|
+
# @param extra [Array<String>]
|
|
31
|
+
# @return [Hash, nil]
|
|
32
|
+
def diff(diff, extra = [])
|
|
33
|
+
return diff unless diff.is_a?(Hash)
|
|
34
|
+
|
|
35
|
+
diff.to_h do |key, value|
|
|
36
|
+
next [key, value] unless redact?(key, extra)
|
|
37
|
+
|
|
38
|
+
[key, if value.is_a?(Array)
|
|
39
|
+
value.map { |v| v.nil? ? nil : MASK }
|
|
40
|
+
else
|
|
41
|
+
MASK
|
|
42
|
+
end]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Redacts nested hashes such as metadata.
|
|
47
|
+
#
|
|
48
|
+
# @param hash [Hash]
|
|
49
|
+
# @return [Hash]
|
|
50
|
+
def deep(hash)
|
|
51
|
+
parameter_filter.filter(hash)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @api private
|
|
55
|
+
def parameter_filter
|
|
56
|
+
current = filters
|
|
57
|
+
cached = @parameter_filter
|
|
58
|
+
return cached.last if cached && cached.first == current
|
|
59
|
+
|
|
60
|
+
ActiveSupport::ParameterFilter.new(current, mask: MASK).tap { |f| @parameter_filter = [current, f] }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
# Tracking options of every model declared with +has_provenance+.
|
|
5
|
+
class Registry
|
|
6
|
+
# Options of one tracked model.
|
|
7
|
+
Options = Struct.new(:only, :except, :redact, :associations, :ignore_if) do
|
|
8
|
+
# @param attribute [String]
|
|
9
|
+
# @return [Boolean] whether the attribute belongs in diffs
|
|
10
|
+
def track?(attribute)
|
|
11
|
+
return false if Provenance.config.ignored_attributes.map(&:to_s).include?(attribute)
|
|
12
|
+
return only.include?(attribute) if only.present?
|
|
13
|
+
|
|
14
|
+
!except.include?(attribute)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@models = Concurrent::Map.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param model [Class]
|
|
23
|
+
# @param options [Options]
|
|
24
|
+
# @return [Options]
|
|
25
|
+
def register(model, options)
|
|
26
|
+
@models[model.name] = options
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Options for the model or its closest tracked ancestor (STI).
|
|
30
|
+
#
|
|
31
|
+
# @param model [Class]
|
|
32
|
+
# @return [Options, nil]
|
|
33
|
+
def options_for(model)
|
|
34
|
+
model.ancestors.each do |klass|
|
|
35
|
+
next unless klass.is_a?(Class) && klass.name
|
|
36
|
+
|
|
37
|
+
options = @models[klass.name]
|
|
38
|
+
return options if options
|
|
39
|
+
end
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Array<String>] names of tracked models
|
|
44
|
+
def model_names
|
|
45
|
+
@models.keys
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
# Delivers pending outbox rows. Enqueued after every outbox write when
|
|
5
|
+
# +outbox_enqueue_relay+ is on; can also be scheduled periodically.
|
|
6
|
+
class RelayJob < ActiveJob::Base
|
|
7
|
+
skip_provenance if respond_to?(:skip_provenance)
|
|
8
|
+
|
|
9
|
+
# @param batch_size [Integer, nil]
|
|
10
|
+
# @return [Integer] delivered rows
|
|
11
|
+
def perform(batch_size: nil)
|
|
12
|
+
Outbox::Relay.run(batch_size: batch_size || Provenance.config.outbox_batch_size)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "provenance"
|
|
4
|
+
require "rspec/expectations"
|
|
5
|
+
|
|
6
|
+
module Provenance
|
|
7
|
+
module Testing
|
|
8
|
+
# Block matcher for emitted events.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# expect { patch invoice_path(invoice), params: {status: "sent"} }
|
|
12
|
+
# .to emit_provenance_event(action: "invoices#update", outcome: "success")
|
|
13
|
+
# .with_change(entity: "Invoice", operation: "update", diff: including(status: ["draft", "sent"]))
|
|
14
|
+
class EmitEventMatcher
|
|
15
|
+
include RSpec::Matchers::Composable
|
|
16
|
+
|
|
17
|
+
# Expected attribute name -> path in the native event.
|
|
18
|
+
PATHS = {
|
|
19
|
+
action: %w[action name], kind: %w[action kind], caused_by: %w[action caused_by],
|
|
20
|
+
outcome: %w[outcome result], error: %w[outcome error], actor: %w[actor], actor_id: %w[actor id],
|
|
21
|
+
request: %w[request], status: %w[request status], metadata: %w[metadata], app: %w[app]
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# @param expected [Hash] any of the keys in {PATHS}
|
|
25
|
+
def initialize(expected)
|
|
26
|
+
unknown = expected.keys - PATHS.keys
|
|
27
|
+
raise ArgumentError, "unknown keys #{unknown.inspect}" if unknown.any?
|
|
28
|
+
|
|
29
|
+
@expected = expected
|
|
30
|
+
@changes = []
|
|
31
|
+
@count = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Requires a change matching the given attributes in the same event.
|
|
35
|
+
#
|
|
36
|
+
# @param expected [Hash] entity, entity_id, operation, diff, association, count, ids
|
|
37
|
+
# @return [self]
|
|
38
|
+
def with_change(**expected)
|
|
39
|
+
@changes << expected
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Requires exactly +n+ matching events.
|
|
44
|
+
#
|
|
45
|
+
# @param n [Integer]
|
|
46
|
+
# @return [self]
|
|
47
|
+
def exactly(n)
|
|
48
|
+
@count = n
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Alias for +exactly(1)+.
|
|
53
|
+
#
|
|
54
|
+
# @return [self]
|
|
55
|
+
def once
|
|
56
|
+
exactly(1)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Boolean]
|
|
60
|
+
def supports_block_expectations?
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Boolean]
|
|
65
|
+
def supports_value_expectations?
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param block [Proc]
|
|
70
|
+
# @return [Boolean]
|
|
71
|
+
def matches?(block)
|
|
72
|
+
@events = Testing.capture(&block)
|
|
73
|
+
@matching = @events.select { |event| event_matches?(event) }
|
|
74
|
+
@count ? @matching.size == @count : @matching.any?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param block [Proc]
|
|
78
|
+
# @return [Boolean]
|
|
79
|
+
def does_not_match?(block)
|
|
80
|
+
raise ArgumentError, "exactly/once is not supported with not_to" if @count
|
|
81
|
+
|
|
82
|
+
@events = Testing.capture(&block)
|
|
83
|
+
@matching = @events.select { |event| event_matches?(event) }
|
|
84
|
+
@matching.empty?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [String]
|
|
88
|
+
def description
|
|
89
|
+
"emit provenance event #{description_of(@expected)}" +
|
|
90
|
+
@changes.map { |c| " with change #{description_of(c)}" }.join
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [String]
|
|
94
|
+
def failure_message
|
|
95
|
+
"expected block to #{description}#{" #{@count} time(s)" if @count}, " \
|
|
96
|
+
"but #{@matching.size} of #{@events.size} emitted event(s) matched:\n#{dump}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [String]
|
|
100
|
+
def failure_message_when_negated
|
|
101
|
+
"expected block not to #{description}, but it emitted:\n#{dump(@matching)}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def event_matches?(event)
|
|
107
|
+
@expected.all? { |key, value| values_match?(value, indifferent(event.dig(*PATHS.fetch(key)))) } &&
|
|
108
|
+
@changes.all? { |expected| event["changes"].any? { |change| change_matches?(expected, change) } }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def change_matches?(expected, change)
|
|
112
|
+
expected.all? { |key, value| values_match?(value, indifferent(change[key.to_s])) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def indifferent(value)
|
|
116
|
+
case value
|
|
117
|
+
when Hash then value.to_h { |k, v| [k, indifferent(v)] }.with_indifferent_access
|
|
118
|
+
when Array then value.map { |v| indifferent(v) }
|
|
119
|
+
else value
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def dump(events = @events)
|
|
124
|
+
events.map { |event| " #{JSON.generate(event)}" }.join("\n")
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# RSpec helpers, included into every example group.
|
|
129
|
+
module Matchers
|
|
130
|
+
# @param expected [Hash] see {EmitEventMatcher::PATHS}
|
|
131
|
+
# @return [EmitEventMatcher]
|
|
132
|
+
def emit_provenance_event(**expected)
|
|
133
|
+
EmitEventMatcher.new(expected)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
RSpec.configure { |config| config.include Provenance::Testing::Matchers } if defined?(RSpec.configure)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
module Serializers
|
|
5
|
+
# CloudEvents 1.0 structured-mode JSON with the native event as +data+.
|
|
6
|
+
module CloudEvents
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# @param event [Hash] native event
|
|
10
|
+
# @return [Array<Hash>]
|
|
11
|
+
def call(event)
|
|
12
|
+
[{
|
|
13
|
+
"specversion" => "1.0",
|
|
14
|
+
"id" => event["id"],
|
|
15
|
+
"source" => event["app"],
|
|
16
|
+
"type" => "provenance.action.#{event.dig("action", "kind")}",
|
|
17
|
+
"subject" => event.dig("action", "name"),
|
|
18
|
+
"time" => event["occurred_at"],
|
|
19
|
+
"datacontenttype" => "application/json",
|
|
20
|
+
"dataschema" => "urn:provenance:event:2",
|
|
21
|
+
"data" => event
|
|
22
|
+
}]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
module Serializers
|
|
5
|
+
# The native "provenance/event@2" format, as built by {Event.build}.
|
|
6
|
+
module Native
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# @param event [Hash] native event
|
|
10
|
+
# @return [Array<Hash>]
|
|
11
|
+
def call(event)
|
|
12
|
+
[event]
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
module Serializers
|
|
5
|
+
# OCSF 1.x: one API Activity (class_uid 6003) event per action plus one Entity
|
|
6
|
+
# Management (class_uid 3004) event per entity change. The README documents the
|
|
7
|
+
# field mapping.
|
|
8
|
+
module OCSF
|
|
9
|
+
# OCSF schema version the events declare.
|
|
10
|
+
VERSION = "1.3.0"
|
|
11
|
+
# API Activity class.
|
|
12
|
+
API_ACTIVITY = {class_uid: 6003, category_uid: 6, class_name: "API Activity", category_name: "Application Activity"}.freeze
|
|
13
|
+
# Entity Management class.
|
|
14
|
+
ENTITY_MANAGEMENT = {class_uid: 3004, category_uid: 3, class_name: "Entity Management", category_name: "Identity & Access Management"}.freeze
|
|
15
|
+
# activity_id by HTTP method for API Activity.
|
|
16
|
+
HTTP_ACTIVITIES = {"POST" => 1, "GET" => 2, "HEAD" => 2, "PUT" => 3, "PATCH" => 3, "DELETE" => 4}.freeze
|
|
17
|
+
# activity_id by change operation for Entity Management.
|
|
18
|
+
CHANGE_ACTIVITIES = {
|
|
19
|
+
"create" => 1, "bulk_insert" => 1, "update" => 3, "bulk_update" => 3, "destroy" => 4, "bulk_delete" => 4
|
|
20
|
+
}.freeze
|
|
21
|
+
# Activity names for ids used above.
|
|
22
|
+
ACTIVITY_NAMES = {1 => "Create", 2 => "Read", 3 => "Update", 4 => "Delete", 99 => "Other"}.freeze
|
|
23
|
+
# status_id, status and severity_id by outcome.
|
|
24
|
+
OUTCOMES = {
|
|
25
|
+
"success" => [1, "Success", 1],
|
|
26
|
+
"failure" => [2, "Failure", 2],
|
|
27
|
+
"denied" => [2, "Failure", 3]
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# @param event [Hash] native event
|
|
33
|
+
# @return [Array<Hash>] the API Activity event followed by Entity Management events
|
|
34
|
+
def call(event)
|
|
35
|
+
[api_activity(event)] + event["changes"].each_with_index.map { |change, i| entity_management(event, change, i) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param event [Hash]
|
|
39
|
+
# @return [Hash]
|
|
40
|
+
def api_activity(event)
|
|
41
|
+
request = event["request"] || {}
|
|
42
|
+
activity_id = HTTP_ACTIVITIES.fetch(request["method"].to_s.upcase) { activity_from_changes(event["changes"]) }
|
|
43
|
+
result = base(event, API_ACTIVITY, activity_id, event["id"])
|
|
44
|
+
result["api"] = {
|
|
45
|
+
"operation" => event.dig("action", "name"),
|
|
46
|
+
"request" => {"uid" => request["id"] || event["id"]},
|
|
47
|
+
"response" => response(event)
|
|
48
|
+
}.compact
|
|
49
|
+
result["src_endpoint"] = {"ip" => request["ip"]}.compact
|
|
50
|
+
if event["request"]
|
|
51
|
+
result["http_request"] = {
|
|
52
|
+
"http_method" => request["method"], "url" => {"path" => request["path"]},
|
|
53
|
+
"user_agent" => request["user_agent"], "uid" => request["id"]
|
|
54
|
+
}.compact
|
|
55
|
+
result["http_response"] = {"code" => request["status"]}.compact
|
|
56
|
+
end
|
|
57
|
+
result["resources"] = event["changes"].map { |c| {"type" => c["entity"], "uid" => c["entity_id"]}.compact }
|
|
58
|
+
result
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param event [Hash]
|
|
62
|
+
# @param change [Hash]
|
|
63
|
+
# @param index [Integer]
|
|
64
|
+
# @return [Hash]
|
|
65
|
+
def entity_management(event, change, index)
|
|
66
|
+
activity_id = CHANGE_ACTIVITIES.fetch(change["operation"], 99)
|
|
67
|
+
result = base(event, ENTITY_MANAGEMENT, activity_id, "#{event["id"]}/#{index}")
|
|
68
|
+
result["activity_name"] = change["operation"].capitalize if activity_id == 99
|
|
69
|
+
result["entity"] = {
|
|
70
|
+
"name" => change["entity"], "type" => change["entity"], "uid" => change["entity_id"], "data" => change["diff"]
|
|
71
|
+
}.compact
|
|
72
|
+
result["unmapped"]["change"] = change.except("entity", "entity_id", "diff")
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @api private
|
|
77
|
+
def base(event, klass, activity_id, uid)
|
|
78
|
+
status_id, status, severity_id = OUTCOMES.fetch(event.dig("outcome", "result"), [99, "Other", 1])
|
|
79
|
+
{
|
|
80
|
+
"category_uid" => klass[:category_uid], "category_name" => klass[:category_name],
|
|
81
|
+
"class_uid" => klass[:class_uid], "class_name" => klass[:class_name],
|
|
82
|
+
"activity_id" => activity_id, "activity_name" => ACTIVITY_NAMES[activity_id],
|
|
83
|
+
"type_uid" => (klass[:class_uid] * 100) + activity_id,
|
|
84
|
+
"time" => (Time.iso8601(event["occurred_at"]).to_r * 1000).to_i,
|
|
85
|
+
"severity_id" => severity_id,
|
|
86
|
+
"status_id" => status_id, "status" => status,
|
|
87
|
+
"status_detail" => event.dig("outcome", "result"),
|
|
88
|
+
"metadata" => {
|
|
89
|
+
"version" => VERSION, "uid" => uid, "correlation_uid" => event["id"], "log_name" => event["app"],
|
|
90
|
+
"product" => {"name" => "Provenance", "vendor_name" => "Provenance", "version" => Provenance::VERSION}
|
|
91
|
+
},
|
|
92
|
+
"actor" => actor(event["actor"], event["app"]),
|
|
93
|
+
"unmapped" => {
|
|
94
|
+
"action" => event["action"], "metadata" => event["metadata"],
|
|
95
|
+
"impersonator" => event.dig("actor", "impersonator"), "integrity" => event["integrity"]
|
|
96
|
+
}.compact
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @api private
|
|
101
|
+
def actor(actor, app)
|
|
102
|
+
result = {"app_name" => app}
|
|
103
|
+
if actor
|
|
104
|
+
result["user"] = {
|
|
105
|
+
"uid" => actor["id"], "name" => actor["display"], "type" => actor["type"],
|
|
106
|
+
"groups" => Array(actor["roles"]).map { |role| {"name" => role} }
|
|
107
|
+
}.compact
|
|
108
|
+
end
|
|
109
|
+
result
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @api private
|
|
113
|
+
def response(event)
|
|
114
|
+
error = event.dig("outcome", "error")
|
|
115
|
+
response = {"code" => event.dig("request", "status")}
|
|
116
|
+
response.merge!("error" => error["class"], "error_message" => error["message"]) if error
|
|
117
|
+
response.compact.presence
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @api private
|
|
121
|
+
def activity_from_changes(changes)
|
|
122
|
+
ids = changes.map { |c| CHANGE_ACTIVITIES.fetch(c["operation"], 99) }.uniq
|
|
123
|
+
(ids.size == 1) ? ids.first : 99
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "provenance/serializers/native"
|
|
4
|
+
require "provenance/serializers/ocsf"
|
|
5
|
+
require "provenance/serializers/cloud_events"
|
|
6
|
+
|
|
7
|
+
module Provenance
|
|
8
|
+
# Converts native events into the configured wire format.
|
|
9
|
+
module Serializers
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# @param format [Symbol] :native, :ocsf or :cloudevents
|
|
13
|
+
# @return [#call] serializer returning an Array of serialized events per native event
|
|
14
|
+
def for(format)
|
|
15
|
+
case format.to_sym
|
|
16
|
+
when :native then Native
|
|
17
|
+
when :ocsf then OCSF
|
|
18
|
+
when :cloudevents then CloudEvents
|
|
19
|
+
else raise ConfigurationError, "unknown format #{format.inspect}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param events [Array<Hash>] native events
|
|
24
|
+
# @param format [Symbol]
|
|
25
|
+
# @return [Array<Hash>] serialized events; OCSF may produce several per native event
|
|
26
|
+
def serialize_all(events, format)
|
|
27
|
+
serializer = self.for(format)
|
|
28
|
+
events.flat_map { |event| serializer.call(event) }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Provenance
|
|
7
|
+
module Sinks
|
|
8
|
+
# POSTs each batch as a JSON array. Any 2xx response is a success; other
|
|
9
|
+
# responses and network errors are retried with exponential backoff and jitter.
|
|
10
|
+
class HTTP
|
|
11
|
+
# Errors treated as retryable network failures.
|
|
12
|
+
NETWORK_ERRORS = [
|
|
13
|
+
IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError, Net::ProtocolError
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
# @return [URI::HTTP]
|
|
17
|
+
attr_reader :uri
|
|
18
|
+
|
|
19
|
+
# @param url [String]
|
|
20
|
+
# @param headers [Hash{String => String}]
|
|
21
|
+
# @param open_timeout [Numeric] seconds
|
|
22
|
+
# @param read_timeout [Numeric] seconds
|
|
23
|
+
# @param retries [Integer] retries after the first attempt
|
|
24
|
+
# @param backoff [Numeric] base delay in seconds, doubled per retry
|
|
25
|
+
# @param max_backoff [Numeric] delay cap in seconds
|
|
26
|
+
# @param sleeper [#call] receives the delay; replaceable in tests
|
|
27
|
+
def initialize(url:, headers: {}, open_timeout: 2, read_timeout: 5, retries: 3, backoff: 0.5,
|
|
28
|
+
max_backoff: 30, sleeper: ->(seconds) { sleep(seconds) })
|
|
29
|
+
@uri = URI.parse(url.to_s)
|
|
30
|
+
raise ConfigurationError, "http sink needs an http(s) url" unless @uri.is_a?(URI::HTTP)
|
|
31
|
+
|
|
32
|
+
@headers = {"Content-Type" => "application/json"}.merge(headers.transform_keys(&:to_s))
|
|
33
|
+
@open_timeout = open_timeout
|
|
34
|
+
@read_timeout = read_timeout
|
|
35
|
+
@retries = retries
|
|
36
|
+
@backoff = backoff
|
|
37
|
+
@max_backoff = max_backoff
|
|
38
|
+
@sleeper = sleeper
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param batch [Array<Hash>]
|
|
42
|
+
# @return [void]
|
|
43
|
+
# @raise [DeliveryError] when every attempt failed
|
|
44
|
+
def deliver(batch)
|
|
45
|
+
body = JSON.generate(batch)
|
|
46
|
+
attempt = 0
|
|
47
|
+
begin
|
|
48
|
+
post(body)
|
|
49
|
+
rescue DeliveryError, *NETWORK_ERRORS => e
|
|
50
|
+
raise DeliveryError, "#{uri.host}: #{e.message}" if attempt >= @retries
|
|
51
|
+
|
|
52
|
+
@sleeper.call(delay(attempt))
|
|
53
|
+
attempt += 1
|
|
54
|
+
retry
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @param attempt [Integer] zero-based retry number
|
|
59
|
+
# @return [Float] seconds to wait before the retry
|
|
60
|
+
def delay(attempt)
|
|
61
|
+
base = [@backoff * (2**attempt), @max_backoff].min
|
|
62
|
+
base / 2.0 + rand * base / 2.0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def post(body)
|
|
68
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
69
|
+
http.use_ssl = uri.scheme == "https"
|
|
70
|
+
http.open_timeout = @open_timeout
|
|
71
|
+
http.read_timeout = @read_timeout
|
|
72
|
+
request = Net::HTTP::Post.new(uri.request_uri, @headers)
|
|
73
|
+
request.body = body
|
|
74
|
+
response = http.request(request)
|
|
75
|
+
return response if response.code.to_i.between?(200, 299)
|
|
76
|
+
|
|
77
|
+
raise DeliveryError, "HTTP #{response.code}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
module Sinks
|
|
5
|
+
# Writes one JSON line per event to any IO.
|
|
6
|
+
class IO
|
|
7
|
+
# @param io [#write]
|
|
8
|
+
def initialize(io = $stdout)
|
|
9
|
+
@io = io
|
|
10
|
+
@lock = Mutex.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @param batch [Array<Hash>]
|
|
14
|
+
# @return [void]
|
|
15
|
+
def deliver(batch)
|
|
16
|
+
lines = batch.map { |event| "#{JSON.generate(event)}\n" }.join
|
|
17
|
+
@lock.synchronize do
|
|
18
|
+
@io.write(lines)
|
|
19
|
+
@io.flush if @io.respond_to?(:flush)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "rdkafka"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
raise LoadError, "the :kafka sink needs the rdkafka gem; add it to your Gemfile"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module Provenance
|
|
10
|
+
module Sinks
|
|
11
|
+
# Produces one Kafka message per event, keyed by event id. Optional: requires
|
|
12
|
+
# the +rdkafka+ gem.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# require "provenance/sinks/kafka"
|
|
16
|
+
# c.sink :kafka, topic: "audit", config: {"bootstrap.servers": "kafka:9092"}
|
|
17
|
+
class Kafka
|
|
18
|
+
# @param topic [String]
|
|
19
|
+
# @param config [Hash] rdkafka producer configuration
|
|
20
|
+
# @param producer [Object, nil] an existing rdkafka producer
|
|
21
|
+
# @param timeout [Numeric] seconds to wait for delivery reports
|
|
22
|
+
def initialize(topic:, config: {}, producer: nil, timeout: 10)
|
|
23
|
+
@topic = topic
|
|
24
|
+
@producer = producer || Rdkafka::Config.new(config).producer
|
|
25
|
+
@timeout = timeout
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param batch [Array<Hash>]
|
|
29
|
+
# @return [void]
|
|
30
|
+
def deliver(batch)
|
|
31
|
+
handles = batch.map do |event|
|
|
32
|
+
@producer.produce(topic: @topic, payload: JSON.generate(event), key: (event["id"] || event.dig("metadata", "uid")).to_s)
|
|
33
|
+
end
|
|
34
|
+
handles.each { |handle| handle.wait(max_wait_timeout: @timeout) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Closes the underlying producer.
|
|
38
|
+
#
|
|
39
|
+
# @return [void]
|
|
40
|
+
def close
|
|
41
|
+
@producer.close
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
module Sinks
|
|
5
|
+
# Writes one JSON line per event to a logger (Rails.logger by default).
|
|
6
|
+
class Logger
|
|
7
|
+
# @param logger [::Logger, nil] defaults to the configured Provenance logger
|
|
8
|
+
# @param level [Symbol] log level
|
|
9
|
+
def initialize(logger: nil, level: :info)
|
|
10
|
+
@logger = logger
|
|
11
|
+
@level = level
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @param batch [Array<Hash>]
|
|
15
|
+
# @return [void]
|
|
16
|
+
def deliver(batch)
|
|
17
|
+
logger = @logger || Provenance.config.logger
|
|
18
|
+
batch.each { |event| logger.public_send(@level, JSON.generate(event)) }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
module Sinks
|
|
5
|
+
# Keeps delivered events in memory. Intended for tests.
|
|
6
|
+
class Memory
|
|
7
|
+
def initialize
|
|
8
|
+
@events = []
|
|
9
|
+
@lock = Mutex.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @param batch [Array<Hash>]
|
|
13
|
+
# @return [void]
|
|
14
|
+
def deliver(batch)
|
|
15
|
+
@lock.synchronize { @events.concat(batch) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [Array<Hash>] a copy of the delivered events
|
|
19
|
+
def events
|
|
20
|
+
@lock.synchronize { @events.dup }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [void]
|
|
24
|
+
def clear
|
|
25
|
+
@lock.synchronize { @events.clear }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|