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,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Provenance
|
|
6
|
+
# Converts arbitrary Ruby values into JSON-safe primitives.
|
|
7
|
+
module JsonSafe
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# @param value [Object]
|
|
11
|
+
# @return [Object] a String, Numeric, true, false, nil, Array or Hash with String keys
|
|
12
|
+
def call(value)
|
|
13
|
+
case value
|
|
14
|
+
when nil, true, false, Integer then value
|
|
15
|
+
when Float then value.finite? ? value : value.to_s
|
|
16
|
+
when BigDecimal then value.to_s("F")
|
|
17
|
+
when String then string(value)
|
|
18
|
+
when Symbol then value.to_s
|
|
19
|
+
when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = call(v) }
|
|
20
|
+
when Array, Set then value.map { |v| call(v) }
|
|
21
|
+
when Time, DateTime, ActiveSupport::TimeWithZone then value.utc.iso8601(3)
|
|
22
|
+
when Date then value.iso8601
|
|
23
|
+
when ActiveSupport::Duration then value.iso8601
|
|
24
|
+
when Numeric then value.to_s
|
|
25
|
+
else value.respond_to?(:as_json) ? call(value.as_json) : value.to_s
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param value [String]
|
|
30
|
+
# @return [String]
|
|
31
|
+
def string(value)
|
|
32
|
+
if value.encoding == Encoding::BINARY && !(value.ascii_only? && value.valid_encoding?)
|
|
33
|
+
"[BINARY #{value.bytesize} bytes]"
|
|
34
|
+
elsif value.valid_encoding?
|
|
35
|
+
value
|
|
36
|
+
else
|
|
37
|
+
value.dup.force_encoding(Encoding::UTF_8).scrub
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
# Rack middleware that opens an +http+ action per request and closes it once the
|
|
5
|
+
# application has produced the response.
|
|
6
|
+
class Middleware
|
|
7
|
+
# @param app [#call]
|
|
8
|
+
def initialize(app)
|
|
9
|
+
@app = app
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @param env [Hash] Rack env
|
|
13
|
+
# @return [Array] Rack response
|
|
14
|
+
def call(env)
|
|
15
|
+
return @app.call(env) if !Provenance.enabled? || Provenance.current
|
|
16
|
+
|
|
17
|
+
action = Action.new(kind: :http, name: nil, context: Context.new(env: env))
|
|
18
|
+
Provenance.within(action) do
|
|
19
|
+
response = @app.call(env)
|
|
20
|
+
action.request["status"] = response[0].to_i
|
|
21
|
+
response
|
|
22
|
+
ensure
|
|
23
|
+
describe(action, env)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def describe(action, env)
|
|
30
|
+
request = ActionDispatch::Request.new(env)
|
|
31
|
+
action.request.merge!(
|
|
32
|
+
"id" => env["action_dispatch.request_id"],
|
|
33
|
+
"method" => request.request_method,
|
|
34
|
+
"path" => request.path,
|
|
35
|
+
"ip" => request.remote_ip,
|
|
36
|
+
"user_agent" => request.user_agent
|
|
37
|
+
)
|
|
38
|
+
action.request["status"] ||= nil
|
|
39
|
+
action.name ||= "#{request.request_method} #{request.path}"
|
|
40
|
+
rescue => e
|
|
41
|
+
Provenance.config.report_error(e)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
# Opt-in model tracking: +has_provenance+ and the hooks it installs.
|
|
5
|
+
module Model
|
|
6
|
+
# Bulk operations on relations of tracked models.
|
|
7
|
+
module BulkRelation
|
|
8
|
+
# @see ActiveRecord::Relation#update_all
|
|
9
|
+
def update_all(updates)
|
|
10
|
+
Provenance::Recorder.bulk(self, :bulk_update, updates: updates) { super }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @see ActiveRecord::Relation#delete_all
|
|
14
|
+
def delete_all
|
|
15
|
+
Provenance::Recorder.bulk(self, :bulk_delete) { super }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @see ActiveRecord::Relation#insert_all
|
|
19
|
+
def insert_all(attributes, **options)
|
|
20
|
+
Provenance::Recorder.bulk_insert(self, attributes) { super }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @see ActiveRecord::Relation#insert_all!
|
|
24
|
+
def insert_all!(attributes, **options)
|
|
25
|
+
Provenance::Recorder.bulk_insert(self, attributes) { super }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @see ActiveRecord::Relation#upsert_all
|
|
29
|
+
def upsert_all(attributes, **options)
|
|
30
|
+
Provenance::Recorder.bulk_insert(self, attributes) { super }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Bulk update through an association collection proxy.
|
|
35
|
+
module BulkCollectionProxy
|
|
36
|
+
# @see ActiveRecord::Relation#update_all
|
|
37
|
+
def update_all(updates)
|
|
38
|
+
Provenance::Recorder.bulk(self, :bulk_update, updates: updates) { super }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Keeps bulk tracking on STI subclasses defined after +has_provenance+.
|
|
43
|
+
module Inheritance
|
|
44
|
+
# @api private
|
|
45
|
+
def inherited(subclass)
|
|
46
|
+
super
|
|
47
|
+
Provenance::Model.install_bulk(subclass)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Class macros added to ActiveRecord::Base.
|
|
52
|
+
module ClassMethods
|
|
53
|
+
# Opts the model in to change tracking.
|
|
54
|
+
#
|
|
55
|
+
# @param only [Array<Symbol>] track only these attributes
|
|
56
|
+
# @param except [Array<Symbol>] track all attributes but these
|
|
57
|
+
# @param redact [Array<Symbol>] record these attributes as "[REDACTED]"
|
|
58
|
+
# @param associations [Array<Symbol>] collection associations producing link/unlink changes
|
|
59
|
+
# @param ignore_if [Proc, nil] +->(record) { ... }+; truthy skips the record
|
|
60
|
+
# @return [void]
|
|
61
|
+
def has_provenance(only: nil, except: nil, redact: [], associations: [], ignore_if: nil)
|
|
62
|
+
raise ArgumentError, "use either only: or except:, not both" if only && except
|
|
63
|
+
|
|
64
|
+
options = Registry::Options.new(
|
|
65
|
+
only: Array(only).map(&:to_s), except: Array(except).map(&:to_s),
|
|
66
|
+
redact: Array(redact).map(&:to_s), associations: Array(associations).map(&:to_sym),
|
|
67
|
+
ignore_if: ignore_if
|
|
68
|
+
)
|
|
69
|
+
Provenance.registry.register(self, options)
|
|
70
|
+
Provenance::Model.install(self, options)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
module_function
|
|
75
|
+
|
|
76
|
+
# @api private
|
|
77
|
+
def install(model, options)
|
|
78
|
+
unless model.instance_variable_get(:@provenance_installed)
|
|
79
|
+
model.instance_variable_set(:@provenance_installed, true)
|
|
80
|
+
model.after_create { Provenance::Recorder.record(self, :create) }
|
|
81
|
+
model.after_update { Provenance::Recorder.record(self, :update) }
|
|
82
|
+
model.after_destroy { Provenance::Recorder.record(self, :destroy) }
|
|
83
|
+
model.singleton_class.prepend(Inheritance)
|
|
84
|
+
([model] + model.descendants).each { |klass| install_bulk(klass) }
|
|
85
|
+
end
|
|
86
|
+
installed = model.instance_variable_get(:@provenance_associations) || []
|
|
87
|
+
(options.associations - installed).each { |name| install_association(model, name) }
|
|
88
|
+
model.instance_variable_set(:@provenance_associations, installed | options.associations)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @api private
|
|
92
|
+
def install_bulk(model)
|
|
93
|
+
prepend_once(model.relation_delegate_class(ActiveRecord::Relation), BulkRelation)
|
|
94
|
+
prepend_once(model.relation_delegate_class(ActiveRecord::AssociationRelation), BulkRelation)
|
|
95
|
+
prepend_once(model.relation_delegate_class(ActiveRecord::Associations::CollectionProxy), BulkCollectionProxy)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @api private
|
|
99
|
+
def prepend_once(klass, mod)
|
|
100
|
+
klass.prepend(mod) unless klass.ancestors.include?(mod)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Appends link/unlink recorders to the association's after_add/after_remove
|
|
104
|
+
# callback lists.
|
|
105
|
+
#
|
|
106
|
+
# @api private
|
|
107
|
+
def install_association(model, name)
|
|
108
|
+
reflection = model.reflect_on_association(name)
|
|
109
|
+
unless reflection&.collection?
|
|
110
|
+
raise ArgumentError, "#{model.name} has no collection association #{name.inspect}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
{after_add: :link, after_remove: :unlink}.each do |callback, operation|
|
|
114
|
+
accessor = "#{callback}_for_#{name}"
|
|
115
|
+
model.class_attribute(accessor, instance_accessor: false, instance_predicate: false) unless model.respond_to?(accessor)
|
|
116
|
+
recorder = ->(_method, owner, record) { Provenance::Recorder.association(owner, name, record, operation) }
|
|
117
|
+
model.public_send(:"#{accessor}=", Array(model.public_send(accessor)) + [recorder])
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
ActiveSupport.on_load(:active_record) { extend Provenance::Model::ClassMethods }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
module Outbox
|
|
5
|
+
# A stored event awaiting or past delivery.
|
|
6
|
+
class Record < ActiveRecord::Base
|
|
7
|
+
# Row awaiting delivery.
|
|
8
|
+
PENDING = "pending"
|
|
9
|
+
# Row delivered to every sink.
|
|
10
|
+
DELIVERED = "delivered"
|
|
11
|
+
|
|
12
|
+
self.table_name = "provenance_outbox"
|
|
13
|
+
|
|
14
|
+
scope :pending, -> { where(status: PENDING) }
|
|
15
|
+
scope :delivered, -> { where(status: DELIVERED) }
|
|
16
|
+
|
|
17
|
+
# @return [Hash] the stored native event
|
|
18
|
+
def event
|
|
19
|
+
JSON.parse(payload)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
module Outbox
|
|
5
|
+
# Drains pending outbox rows in id order and delivers them to the sinks.
|
|
6
|
+
module Relay
|
|
7
|
+
# Upper bound for the retry delay, in seconds.
|
|
8
|
+
MAX_BACKOFF = 3600
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Delivers due rows batch by batch until none are left or a batch fails, then
|
|
13
|
+
# prunes old delivered rows.
|
|
14
|
+
#
|
|
15
|
+
# @param batch_size [Integer]
|
|
16
|
+
# @return [Integer] number of delivered rows
|
|
17
|
+
def run(batch_size: Provenance.config.outbox_batch_size)
|
|
18
|
+
delivered = 0
|
|
19
|
+
loop do
|
|
20
|
+
count = Record.transaction { drain(batch_size) }
|
|
21
|
+
break unless count&.positive?
|
|
22
|
+
|
|
23
|
+
delivered += count
|
|
24
|
+
end
|
|
25
|
+
prune
|
|
26
|
+
delivered
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Locks and delivers one batch. Stops at the first row that is not due yet so
|
|
30
|
+
# rows are never delivered out of order.
|
|
31
|
+
#
|
|
32
|
+
# @api private
|
|
33
|
+
# @return [Integer, nil] delivered rows, or nil when the batch failed
|
|
34
|
+
def drain(batch_size)
|
|
35
|
+
scope = Record.pending.order(:id).limit(batch_size)
|
|
36
|
+
scope = scope.lock("FOR UPDATE SKIP LOCKED") if Outbox.postgresql?
|
|
37
|
+
now = Time.current
|
|
38
|
+
rows = scope.to_a.take_while { |row| row.next_attempt_at.nil? || row.next_attempt_at <= now }
|
|
39
|
+
return 0 if rows.empty?
|
|
40
|
+
|
|
41
|
+
begin
|
|
42
|
+
Emitter.deliver(rows.map(&:event), raise_errors: true)
|
|
43
|
+
rescue => e
|
|
44
|
+
fail_rows(rows, e, now)
|
|
45
|
+
return nil
|
|
46
|
+
end
|
|
47
|
+
Record.where(id: rows.map(&:id)).update_all(status: Record::DELIVERED, delivered_at: now, last_error: nil)
|
|
48
|
+
rows.size
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @api private
|
|
52
|
+
def fail_rows(rows, error, now)
|
|
53
|
+
rows.each do |row|
|
|
54
|
+
attempts = row.attempts.to_i + 1
|
|
55
|
+
row.update_columns(
|
|
56
|
+
attempts: attempts, next_attempt_at: now + backoff(attempts),
|
|
57
|
+
last_error: "#{error.class}: #{error.message}".truncate(1000)
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
Provenance.config.report_error(error)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param attempts [Integer]
|
|
64
|
+
# @return [Float] seconds until the next attempt
|
|
65
|
+
def backoff(attempts)
|
|
66
|
+
base = [5 * (2**(attempts - 1)), MAX_BACKOFF].min
|
|
67
|
+
base + rand * base * 0.2
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Deletes delivered rows older than +outbox_retention+, keeping the newest row
|
|
71
|
+
# of each app as the anchor of the integrity chain.
|
|
72
|
+
#
|
|
73
|
+
# @return [Integer] deleted rows
|
|
74
|
+
def prune
|
|
75
|
+
cutoff = Time.current - Provenance.config.outbox_retention
|
|
76
|
+
anchors = Record.group(:app).maximum(:id).values
|
|
77
|
+
Record.delivered.where(delivered_at: ...cutoff).where.not(id: anchors).delete_all
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
# Reliable delivery: events are stored in the +provenance_outbox+ table and
|
|
5
|
+
# delivered by {RelayJob}.
|
|
6
|
+
module Outbox
|
|
7
|
+
autoload :Record, "provenance/outbox/record"
|
|
8
|
+
autoload :Relay, "provenance/outbox/relay"
|
|
9
|
+
|
|
10
|
+
# Attempts made to allocate a sequence number before giving up.
|
|
11
|
+
WRITE_ATTEMPTS = 5
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Stores a native event, adding the integrity chain when a key is configured.
|
|
16
|
+
#
|
|
17
|
+
# @param event [Hash] native event
|
|
18
|
+
# @return [Record]
|
|
19
|
+
def write(event)
|
|
20
|
+
attempts = 0
|
|
21
|
+
begin
|
|
22
|
+
attempts += 1
|
|
23
|
+
record = Record.transaction(requires_new: true) { insert(event) }
|
|
24
|
+
rescue ActiveRecord::RecordNotUnique
|
|
25
|
+
retry if attempts < WRITE_ATTEMPTS
|
|
26
|
+
raise
|
|
27
|
+
end
|
|
28
|
+
RelayJob.perform_later if Provenance.config.outbox_enqueue_relay
|
|
29
|
+
record
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @api private
|
|
33
|
+
def insert(event)
|
|
34
|
+
app = event["app"]
|
|
35
|
+
key = Provenance.config.integrity.key
|
|
36
|
+
seq = mac = nil
|
|
37
|
+
if key.present?
|
|
38
|
+
lock(app)
|
|
39
|
+
last_seq, prev = Record.where(app: app).where.not(seq: nil).order(seq: :desc).pick(:seq, :mac)
|
|
40
|
+
seq = last_seq.to_i + 1
|
|
41
|
+
mac = Integrity.mac(key, prev, event)
|
|
42
|
+
event = event.merge("integrity" => {"seq" => seq, "prev" => prev, "mac" => mac})
|
|
43
|
+
end
|
|
44
|
+
Record.create!(
|
|
45
|
+
app: app, event_id: event["id"], seq: seq, mac: mac, payload: JSON.generate(event),
|
|
46
|
+
status: Record::PENDING, attempts: 0, next_attempt_at: Time.current
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Serializes sequence allocation per app on PostgreSQL. Other databases rely on
|
|
51
|
+
# the unique (app, seq) index and a retry.
|
|
52
|
+
#
|
|
53
|
+
# @api private
|
|
54
|
+
def lock(app)
|
|
55
|
+
return unless postgresql?
|
|
56
|
+
|
|
57
|
+
Record.connection.execute(
|
|
58
|
+
Record.sanitize_sql_array(["SELECT pg_advisory_xact_lock(hashtext(?))", "provenance:#{app}"])
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Boolean]
|
|
63
|
+
def postgresql?
|
|
64
|
+
Record.connection_db_config.adapter.to_s.match?(/postg/i)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module Provenance
|
|
6
|
+
# Wires Provenance into a Rails application.
|
|
7
|
+
class Railtie < Rails::Railtie
|
|
8
|
+
initializer "provenance.middleware" do |app|
|
|
9
|
+
app.middleware.insert_after ActionDispatch::RequestId, Provenance::Middleware
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
initializer "provenance.action_controller" do
|
|
13
|
+
ActiveSupport.on_load(:action_controller) { include Provenance::Controller }
|
|
14
|
+
ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*, payload|
|
|
15
|
+
Provenance::Controller::Subscriber.call(payload)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
initializer "provenance.active_job" do
|
|
20
|
+
ActiveSupport.on_load(:active_job) { include Provenance::Job }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
config.after_initialize do
|
|
24
|
+
Provenance.config.validate!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
rake_tasks do
|
|
28
|
+
load File.expand_path("../tasks/provenance.rake", __dir__)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
generators do
|
|
32
|
+
require "generators/provenance/install/install_generator"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
# Opt-in rake integration: every executed task becomes a +task+ action.
|
|
5
|
+
#
|
|
6
|
+
# @example Rakefile
|
|
7
|
+
# Provenance::Rake.install!
|
|
8
|
+
module Rake
|
|
9
|
+
# Prepended into Rake::Task.
|
|
10
|
+
module TaskExtension
|
|
11
|
+
# @api private
|
|
12
|
+
def execute(args = nil)
|
|
13
|
+
return super unless Provenance.enabled?
|
|
14
|
+
|
|
15
|
+
Provenance.track(kind: :task, name: name) { super }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Wraps rake task execution. Idempotent.
|
|
20
|
+
#
|
|
21
|
+
# @return [void]
|
|
22
|
+
def self.install!
|
|
23
|
+
require "rake"
|
|
24
|
+
::Rake::Task.prepend(TaskExtension) unless ::Rake::Task.ancestors.include?(TaskExtension)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Provenance
|
|
6
|
+
# Builds entity changes from model callbacks and bulk relation calls and attaches
|
|
7
|
+
# them to the current action once their transaction commits.
|
|
8
|
+
module Recorder
|
|
9
|
+
BULK_KEY = :provenance_bulk_depth
|
|
10
|
+
private_constant :BULK_KEY
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Records a create, update or destroy of a single record.
|
|
15
|
+
#
|
|
16
|
+
# @param record [ActiveRecord::Base]
|
|
17
|
+
# @param operation [Symbol] :create, :update or :destroy
|
|
18
|
+
# @return [void]
|
|
19
|
+
def record(record, operation)
|
|
20
|
+
options = options_for(record.class)
|
|
21
|
+
return unless options
|
|
22
|
+
return if options.ignore_if&.call(record)
|
|
23
|
+
|
|
24
|
+
raw = case operation
|
|
25
|
+
when :create, :destroy then record.attributes.compact
|
|
26
|
+
when :update then record.saved_changes
|
|
27
|
+
end
|
|
28
|
+
diff = filter(record.class, raw, options)
|
|
29
|
+
return if operation == :update && diff.empty?
|
|
30
|
+
|
|
31
|
+
change = EntityChange.new(entity: record.class.name, entity_id: record.id, operation: operation, diff: diff)
|
|
32
|
+
append(record.class, change, "#{record.class.name}.#{operation}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Records a link or unlink through a tracked association.
|
|
36
|
+
#
|
|
37
|
+
# @param owner [ActiveRecord::Base]
|
|
38
|
+
# @param association [Symbol]
|
|
39
|
+
# @param target [ActiveRecord::Base]
|
|
40
|
+
# @param operation [Symbol] :link or :unlink
|
|
41
|
+
# @return [void]
|
|
42
|
+
def association(owner, association, target, operation)
|
|
43
|
+
options = options_for(owner.class)
|
|
44
|
+
return unless options
|
|
45
|
+
return if options.ignore_if&.call(owner)
|
|
46
|
+
|
|
47
|
+
change = EntityChange.new(
|
|
48
|
+
entity: owner.class.name, entity_id: -> { owner.id }, operation: operation, association: association,
|
|
49
|
+
target: {"type" => target.class.name, "id" => -> { target.id }}
|
|
50
|
+
)
|
|
51
|
+
append(owner.class, change, "#{owner.class.name}.#{operation}")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Wraps update_all/delete_all on a relation of a tracked model.
|
|
55
|
+
#
|
|
56
|
+
# @param relation [ActiveRecord::Relation]
|
|
57
|
+
# @param operation [Symbol] :bulk_update or :bulk_delete
|
|
58
|
+
# @param updates [Hash, String, Array, nil] the update_all argument
|
|
59
|
+
# @yieldreturn [Integer] affected rows
|
|
60
|
+
# @return [Integer] the block's return value
|
|
61
|
+
def bulk(relation, operation, updates: nil)
|
|
62
|
+
model = relation.model
|
|
63
|
+
options = options_for(model)
|
|
64
|
+
return yield if options.nil? || bulk_depth.positive?
|
|
65
|
+
|
|
66
|
+
ids, truncated = bulk_ids(relation)
|
|
67
|
+
count = nested_bulk { yield }
|
|
68
|
+
diff = updates.is_a?(Hash) ? filter(model, updates.to_h { |k, v| [k.to_s, [nil, v]] }, options) : nil
|
|
69
|
+
change = EntityChange.new(
|
|
70
|
+
entity: model.name, operation: operation, diff: diff, count: count,
|
|
71
|
+
where: fingerprint(relation), ids: ids, truncated: truncated
|
|
72
|
+
)
|
|
73
|
+
append(model, change, "#{model.name}.#{operation}")
|
|
74
|
+
count
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Wraps insert_all/upsert_all on a tracked model.
|
|
78
|
+
#
|
|
79
|
+
# @param relation [ActiveRecord::Relation]
|
|
80
|
+
# @param attributes [Array<Hash>, Hash]
|
|
81
|
+
# @yieldreturn [ActiveRecord::Result]
|
|
82
|
+
# @return [ActiveRecord::Result] the block's return value
|
|
83
|
+
def bulk_insert(relation, attributes)
|
|
84
|
+
model = relation.model
|
|
85
|
+
options = options_for(model)
|
|
86
|
+
return yield if options.nil? || bulk_depth.positive?
|
|
87
|
+
|
|
88
|
+
result = nested_bulk { yield }
|
|
89
|
+
rows = attributes.is_a?(Hash) ? [attributes] : Array(attributes)
|
|
90
|
+
limit = Provenance.config.bulk_ids_limit
|
|
91
|
+
ids = inserted_ids(model, rows, result)
|
|
92
|
+
change = EntityChange.new(
|
|
93
|
+
entity: model.name, operation: :bulk_insert, count: rows.size,
|
|
94
|
+
ids: ids.first(limit), truncated: ids.size > limit
|
|
95
|
+
)
|
|
96
|
+
append(model, change, "#{model.name}.bulk_insert")
|
|
97
|
+
result
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Attaches a change to the current action after the surrounding transaction
|
|
101
|
+
# commits, or to an implicit +custom+ action when none is open.
|
|
102
|
+
#
|
|
103
|
+
# @param model [Class]
|
|
104
|
+
# @param change [EntityChange]
|
|
105
|
+
# @param implicit_name [String]
|
|
106
|
+
# @return [void]
|
|
107
|
+
def append(model, change, implicit_name)
|
|
108
|
+
action = Provenance.current
|
|
109
|
+
if action
|
|
110
|
+
after_commit(model) { action.add_change(change) }
|
|
111
|
+
else
|
|
112
|
+
action = Action.new(kind: :custom, name: implicit_name)
|
|
113
|
+
after_commit(model) { action.add_change(change) }
|
|
114
|
+
Emitter.finish(action)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Runs the block when the model's innermost joinable transaction commits, or
|
|
119
|
+
# immediately when none is open. Rolled-back transactions never run it.
|
|
120
|
+
#
|
|
121
|
+
# @param model [Class]
|
|
122
|
+
# @return [void]
|
|
123
|
+
def after_commit(model, &block)
|
|
124
|
+
transaction = model.connection_pool.active_connection&.current_transaction
|
|
125
|
+
if transaction&.open? && transaction.joinable?
|
|
126
|
+
transaction.after_commit(&block)
|
|
127
|
+
else
|
|
128
|
+
block.call
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @api private
|
|
133
|
+
def options_for(model)
|
|
134
|
+
return nil unless Provenance.enabled?
|
|
135
|
+
|
|
136
|
+
Provenance.registry.options_for(model)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @api private
|
|
140
|
+
def filter(model, attributes, options)
|
|
141
|
+
primary_keys = Array(model.primary_key).map(&:to_s)
|
|
142
|
+
diff = attributes.to_h { |k, v| [k.to_s, v] }
|
|
143
|
+
.select { |name, _| !primary_keys.include?(name) && options.track?(name) }
|
|
144
|
+
Redactor.diff(diff, options.redact + encrypted_attributes(model))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @api private
|
|
148
|
+
def encrypted_attributes(model)
|
|
149
|
+
model.respond_to?(:encrypted_attributes) ? Array(model.encrypted_attributes).map(&:to_s) : []
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @api private
|
|
153
|
+
def bulk_ids(relation)
|
|
154
|
+
limit = Provenance.config.bulk_ids_limit
|
|
155
|
+
ids = if relation.loaded?
|
|
156
|
+
relation.records.map(&:id)
|
|
157
|
+
else
|
|
158
|
+
fetch = limit + 1
|
|
159
|
+
fetch = [relation.limit_value, fetch].min if relation.limit_value
|
|
160
|
+
relation.limit(fetch).pluck(*Array(relation.model.primary_key))
|
|
161
|
+
end
|
|
162
|
+
[ids.first(limit).map(&:to_s), ids.size > limit]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# @api private
|
|
166
|
+
def inserted_ids(model, rows, result)
|
|
167
|
+
primary_key = model.primary_key
|
|
168
|
+
return [] unless primary_key.is_a?(String)
|
|
169
|
+
|
|
170
|
+
ids = if result.respond_to?(:columns) && result.columns.include?(primary_key)
|
|
171
|
+
result.rows.map { |row| row[result.columns.index(primary_key)] }
|
|
172
|
+
else
|
|
173
|
+
rows.filter_map { |row| row[primary_key] || row[primary_key.to_sym] }
|
|
174
|
+
end
|
|
175
|
+
ids.map(&:to_s)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Fingerprint of a relation's WHERE clause with bind values replaced by
|
|
179
|
+
# placeholders, so the same query shape yields the same fingerprint.
|
|
180
|
+
#
|
|
181
|
+
# @param relation [ActiveRecord::Relation]
|
|
182
|
+
# @return [String, nil] "sha256:<16 hex chars>"
|
|
183
|
+
def fingerprint(relation)
|
|
184
|
+
return nil if relation.where_clause.empty?
|
|
185
|
+
|
|
186
|
+
sql = relation.model.with_connection do |connection|
|
|
187
|
+
connection.visitor.compile(relation.where_clause.ast, Arel::Collectors::SQLString.new)
|
|
188
|
+
end
|
|
189
|
+
"sha256:#{Digest::SHA256.hexdigest(sql)[0, 16]}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# @api private
|
|
193
|
+
def bulk_depth
|
|
194
|
+
ActiveSupport::IsolatedExecutionState[BULK_KEY].to_i
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# @api private
|
|
198
|
+
def nested_bulk
|
|
199
|
+
state = ActiveSupport::IsolatedExecutionState
|
|
200
|
+
state[BULK_KEY] = bulk_depth + 1
|
|
201
|
+
yield
|
|
202
|
+
ensure
|
|
203
|
+
state[BULK_KEY] -= 1
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|