sealed_trail 0.1.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 +57 -0
- data/LICENSE +21 -0
- data/README.md +457 -0
- data/lib/generators/sealed_trail/install/install_generator.rb +48 -0
- data/lib/generators/sealed_trail/install/templates/create_sealed_trail_entries.rb.tt +21 -0
- data/lib/generators/sealed_trail/install/templates/sealed_trail.rb.tt +71 -0
- data/lib/sealed_trail/bulk.rb +177 -0
- data/lib/sealed_trail/configuration.rb +139 -0
- data/lib/sealed_trail/controller.rb +49 -0
- data/lib/sealed_trail/current.rb +27 -0
- data/lib/sealed_trail/entry.rb +127 -0
- data/lib/sealed_trail/job_context.rb +76 -0
- data/lib/sealed_trail/model.rb +124 -0
- data/lib/sealed_trail/railtie.rb +38 -0
- data/lib/sealed_trail/rake_context.rb +25 -0
- data/lib/sealed_trail/recorder.rb +95 -0
- data/lib/sealed_trail/redactor.rb +92 -0
- data/lib/sealed_trail/version.rb +5 -0
- data/lib/sealed_trail/writer.rb +79 -0
- data/lib/sealed_trail.rb +112 -0
- metadata +112 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SealedTrail
|
|
4
|
+
# Adds the +has_sealed_trail+ macro to Active Record models.
|
|
5
|
+
module Model
|
|
6
|
+
EVENTS = %i[create update destroy].freeze
|
|
7
|
+
OPTIONAL_EVENTS = %i[touch].freeze
|
|
8
|
+
BULK_EVENTS = %w[bulk_update bulk_delete bulk_insert bulk_upsert].freeze
|
|
9
|
+
ALL_EVENTS = (EVENTS + OPTIONAL_EVENTS).map(&:to_s) + BULK_EVENTS
|
|
10
|
+
|
|
11
|
+
# Per-model settings built by has_sealed_trail.
|
|
12
|
+
Options = Struct.new(:only, :ignore, :redact, :events, :meta, :bulk, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
module ClassMethods
|
|
15
|
+
# Records an audit entry for every create, update and destroy.
|
|
16
|
+
#
|
|
17
|
+
# class Patient < ApplicationRecord
|
|
18
|
+
# has_sealed_trail ignore: [:last_seen_at], redact: { ssn: :mask, email: :fingerprint }
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# Options:
|
|
22
|
+
# only:: record changes to these attributes only
|
|
23
|
+
# ignore:: never record these attributes (an update touching only
|
|
24
|
+
# ignored attributes records nothing)
|
|
25
|
+
# redact:: attribute names/Regexps (Array), or a Hash of name/Regexp =>
|
|
26
|
+
# strategy (:redact, :fingerprint, :mask, a callable, or false
|
|
27
|
+
# to opt out of a global rule). Checked before the global rules.
|
|
28
|
+
# on:: events to record: any of :create, :update, :destroy (default:
|
|
29
|
+
# all three) and :touch (opt-in)
|
|
30
|
+
# meta:: Hash of extra metadata stored with each entry; values may be
|
|
31
|
+
# static, a Symbol (method called on the record) or a Proc
|
|
32
|
+
# (called with the record)
|
|
33
|
+
# bulk:: record update_all/delete_all/insert_all/upsert_all summaries
|
|
34
|
+
# (default: true)
|
|
35
|
+
# if/unless:: standard Active Record callback conditions (callbacks only)
|
|
36
|
+
def has_sealed_trail(only: nil, ignore: [], redact: [], on: EVENTS, meta: {}, bulk: true, **callback_options)
|
|
37
|
+
if respond_to?(:sealed_trail_options) && sealed_trail_options
|
|
38
|
+
raise ArgumentError, "has_sealed_trail is already declared on #{name}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
events = Array(on).map(&:to_sym)
|
|
42
|
+
unknown = events - EVENTS - OPTIONAL_EVENTS
|
|
43
|
+
raise ArgumentError, "Unknown has_sealed_trail event(s): #{unknown.join(", ")}" if unknown.any?
|
|
44
|
+
|
|
45
|
+
unknown_options = callback_options.keys - %i[if unless]
|
|
46
|
+
raise ArgumentError, "Unknown has_sealed_trail option(s): #{unknown_options.join(", ")}" if unknown_options.any?
|
|
47
|
+
|
|
48
|
+
Redactor.validate_rules!(redact)
|
|
49
|
+
|
|
50
|
+
class_attribute :sealed_trail_options, instance_writer: false
|
|
51
|
+
self.sealed_trail_options = Options.new(
|
|
52
|
+
only: only && Array(only).map(&:to_s),
|
|
53
|
+
ignore: Array(ignore).map(&:to_s),
|
|
54
|
+
redact: redact.is_a?(Hash) ? redact : Array(redact),
|
|
55
|
+
events: events,
|
|
56
|
+
meta: meta.to_h,
|
|
57
|
+
bulk: bulk
|
|
58
|
+
).freeze
|
|
59
|
+
|
|
60
|
+
has_many :audit_entries,
|
|
61
|
+
-> { chronological },
|
|
62
|
+
as: :item,
|
|
63
|
+
class_name: "SealedTrail::Entry",
|
|
64
|
+
inverse_of: false
|
|
65
|
+
|
|
66
|
+
prepend SealedTrail::Model::InstanceHooks
|
|
67
|
+
singleton_class.prepend(SealedTrail::Bulk::InsertHooks) unless SealedTrail::Bulk.relation_inserts?
|
|
68
|
+
|
|
69
|
+
after_create(**callback_options) { SealedTrail::Recorder.new(self, :create).record } if events.include?(:create)
|
|
70
|
+
after_update(**callback_options) { SealedTrail::Recorder.new(self, :update).record } if events.include?(:update)
|
|
71
|
+
after_destroy(**callback_options) { SealedTrail::Recorder.new(self, :destroy).record } if events.include?(:destroy)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def sealed_trail?
|
|
75
|
+
respond_to?(:sealed_trail_options) && sealed_trail_options.present?
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Records the persistence methods that skip callbacks: update_column(s)
|
|
80
|
+
# and delete, plus touch when opted in with on: [:touch].
|
|
81
|
+
module InstanceHooks
|
|
82
|
+
def update_columns(attributes)
|
|
83
|
+
return super unless sealed_trail_event?(:update)
|
|
84
|
+
|
|
85
|
+
before = self.attributes
|
|
86
|
+
result = super
|
|
87
|
+
record_sealed_trail_diff(:update, before, "update_columns")
|
|
88
|
+
result
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def touch(*names, time: nil)
|
|
92
|
+
return super unless sealed_trail_event?(:touch)
|
|
93
|
+
|
|
94
|
+
before = attributes
|
|
95
|
+
result = super
|
|
96
|
+
record_sealed_trail_diff(:touch, before, "touch") if result
|
|
97
|
+
result
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def delete
|
|
101
|
+
return super unless persisted? && sealed_trail_event?(:destroy)
|
|
102
|
+
|
|
103
|
+
snapshot = attributes.compact.transform_values { |value| [ value, nil ] }
|
|
104
|
+
result = super
|
|
105
|
+
SealedTrail::Recorder.new(self, :destroy, changes: snapshot, via: "delete").record
|
|
106
|
+
result
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def sealed_trail_event?(event)
|
|
112
|
+
SealedTrail.tracking? && sealed_trail_options.events.include?(event)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def record_sealed_trail_diff(event, before, via)
|
|
116
|
+
after = attributes
|
|
117
|
+
changes = after.each_with_object({}) do |(name, value), result|
|
|
118
|
+
result[name] = [ before[name], value ] unless before[name] == value
|
|
119
|
+
end
|
|
120
|
+
SealedTrail::Recorder.new(self, event, changes: changes, via: via).record
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SealedTrail
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
initializer "sealed_trail.active_record" do
|
|
6
|
+
ActiveSupport.on_load(:active_record) do
|
|
7
|
+
extend SealedTrail::Model::ClassMethods
|
|
8
|
+
ActiveRecord::Relation.prepend(SealedTrail::Bulk::RelationHooks)
|
|
9
|
+
ActiveRecord::Relation.prepend(SealedTrail::Bulk::InsertHooks) if SealedTrail::Bulk.relation_inserts?
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
initializer "sealed_trail.action_controller" do
|
|
14
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
15
|
+
include SealedTrail::Controller if SealedTrail.config.auto_include_controller
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
ActiveSupport.on_load(:action_controller_api) do
|
|
19
|
+
include SealedTrail::Controller if SealedTrail.config.auto_include_controller
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
initializer "sealed_trail.active_job" do
|
|
24
|
+
ActiveSupport.on_load(:active_job) do
|
|
25
|
+
require "sealed_trail/job_context"
|
|
26
|
+
include SealedTrail::JobContext
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
console do
|
|
31
|
+
SealedTrail.process_actor_label ||= "console"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
rake_tasks do
|
|
35
|
+
SealedTrail::RakeContext.install!
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SealedTrail
|
|
4
|
+
# Labels entries recorded while a rake task runs as "rake:<task name>".
|
|
5
|
+
module RakeContext
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def install!
|
|
9
|
+
return unless defined?(::Rake::Task)
|
|
10
|
+
return if ::Rake::Task.ancestors.include?(TaskHook)
|
|
11
|
+
|
|
12
|
+
::Rake::Task.prepend(TaskHook)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
module TaskHook
|
|
16
|
+
def execute(*args)
|
|
17
|
+
previous = SealedTrail.process_actor_label
|
|
18
|
+
SealedTrail.process_actor_label = "rake:#{name}"
|
|
19
|
+
super
|
|
20
|
+
ensure
|
|
21
|
+
SealedTrail.process_actor_label = previous
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SealedTrail
|
|
4
|
+
# Builds and saves one Entry for one record event.
|
|
5
|
+
class Recorder
|
|
6
|
+
# +changes+ overrides the changes read from the record (used for
|
|
7
|
+
# update_columns, touch and delete); +via+ is stored in the metadata.
|
|
8
|
+
def initialize(record, event, changes: nil, via: nil)
|
|
9
|
+
@record = record
|
|
10
|
+
@event = event.to_sym
|
|
11
|
+
@options = record.class.sealed_trail_options
|
|
12
|
+
@changes = changes
|
|
13
|
+
@via = via
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Returns the saved Entry, or nil when nothing was recorded.
|
|
17
|
+
def record
|
|
18
|
+
return unless SealedTrail.tracking?
|
|
19
|
+
|
|
20
|
+
changes = AttributeFilter.new(@record.class).call(raw_changes)
|
|
21
|
+
return if %i[update touch].include?(@event) && changes.empty?
|
|
22
|
+
|
|
23
|
+
Writer.write(
|
|
24
|
+
item_type: @record.class.polymorphic_name,
|
|
25
|
+
item_id: @record.id,
|
|
26
|
+
event: @event,
|
|
27
|
+
object_changes: changes,
|
|
28
|
+
metadata: metadata
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def raw_changes
|
|
35
|
+
return @changes if @changes
|
|
36
|
+
|
|
37
|
+
case @event
|
|
38
|
+
when :create then @record.attributes.compact.transform_values { |value| [ nil, value ] }
|
|
39
|
+
when :update then @record.saved_changes
|
|
40
|
+
when :destroy then @record.attributes.compact.transform_values { |value| [ value, nil ] }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def metadata
|
|
45
|
+
model_meta = @options.meta.to_h do |key, value|
|
|
46
|
+
[ key.to_s, resolve_meta(value) ]
|
|
47
|
+
end
|
|
48
|
+
model_meta["via"] = @via if @via
|
|
49
|
+
model_meta
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resolve_meta(value)
|
|
53
|
+
case value
|
|
54
|
+
when Proc then value.arity.zero? ? @record.instance_exec(&value) : value.call(@record)
|
|
55
|
+
when Symbol then @record.public_send(value)
|
|
56
|
+
else value
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Applies a model's only/ignore and redaction rules to a changes hash of
|
|
62
|
+
# {"attribute" => [before, after]}.
|
|
63
|
+
class AttributeFilter
|
|
64
|
+
def initialize(model)
|
|
65
|
+
@model = model
|
|
66
|
+
@options = model.sealed_trail_options
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def call(changes)
|
|
70
|
+
redactor = self.redactor
|
|
71
|
+
changes.each_with_object({}) do |(name, values), result|
|
|
72
|
+
name = name.to_s
|
|
73
|
+
next unless tracked?(name)
|
|
74
|
+
|
|
75
|
+
result[name] = values.map { |value| redactor.apply(name, value) }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def tracked?(name)
|
|
80
|
+
name = name.to_s
|
|
81
|
+
return false if @options.only && !@options.only.include?(name)
|
|
82
|
+
return false if @options.ignore.include?(name)
|
|
83
|
+
|
|
84
|
+
!SealedTrail.config.ignore_attributes.map(&:to_s).include?(name)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def redactor
|
|
88
|
+
global = Array(SealedTrail.config.redact_attributes)
|
|
89
|
+
if SealedTrail.config.redact_encrypted_attributes && @model.respond_to?(:encrypted_attributes)
|
|
90
|
+
global += Array(@model.encrypted_attributes).map(&:to_s)
|
|
91
|
+
end
|
|
92
|
+
Redactor.new(@options.redact, global)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module SealedTrail
|
|
6
|
+
# Decides whether an attribute is redacted, and how.
|
|
7
|
+
#
|
|
8
|
+
# Rules are attribute names (exact match), Regexps, or Hashes mapping either
|
|
9
|
+
# to a strategy. A strategy of +false+ means "do not redact", which lets a
|
|
10
|
+
# model opt out of a global rule. Earlier rule sets win.
|
|
11
|
+
class Redactor
|
|
12
|
+
STRATEGIES = %i[redact fingerprint mask].freeze
|
|
13
|
+
|
|
14
|
+
def self.validate_rules!(rules)
|
|
15
|
+
normalize(rules).each do |_, strategy|
|
|
16
|
+
next if strategy.nil? || strategy == false || STRATEGIES.include?(strategy) || strategy.respond_to?(:call)
|
|
17
|
+
|
|
18
|
+
raise ArgumentError, "Unknown redaction strategy: #{strategy.inspect} (use #{STRATEGIES.map(&:inspect).join(", ")}, false or a callable)"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.normalize(rules)
|
|
23
|
+
case rules
|
|
24
|
+
when Hash then rules.to_a
|
|
25
|
+
when nil then []
|
|
26
|
+
else Array(rules).flat_map { |rule| rule.is_a?(Hash) ? rule.to_a : [ [ rule, nil ] ] }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(*rule_sets)
|
|
31
|
+
@rules = rule_sets.flat_map { |rules| self.class.normalize(rules) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The strategy for +name+, or nil when it is not redacted.
|
|
35
|
+
def strategy_for(name)
|
|
36
|
+
name = name.to_s
|
|
37
|
+
rule = @rules.find { |matcher, _| matches?(matcher, name) }
|
|
38
|
+
return nil if rule.nil? || rule.last == false
|
|
39
|
+
|
|
40
|
+
rule.last || SealedTrail.config.redaction_strategy
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def redact?(name)
|
|
44
|
+
!strategy_for(name).nil?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The value to store for +name+.
|
|
48
|
+
def apply(name, value)
|
|
49
|
+
strategy = strategy_for(name)
|
|
50
|
+
strategy ? self.class.transform(strategy, value, name) : value
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.transform(strategy, value, name = nil)
|
|
54
|
+
return nil if value.nil?
|
|
55
|
+
|
|
56
|
+
case strategy
|
|
57
|
+
when :redact then REDACTED
|
|
58
|
+
when :fingerprint then SealedTrail.fingerprint(value)
|
|
59
|
+
when :mask then mask(value)
|
|
60
|
+
else
|
|
61
|
+
raise ArgumentError, "Unknown redaction strategy: #{strategy.inspect}" unless strategy.respond_to?(:call)
|
|
62
|
+
|
|
63
|
+
strategy.arity == 1 ? strategy.call(value) : strategy.call(value, name.to_s)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
EMAIL = /\A([^@\s]+)@([^@\s]+)\z/
|
|
68
|
+
|
|
69
|
+
# "jane@example.com" => "j***@example.com"; "+1 555 0100" => "*******0100";
|
|
70
|
+
# values shorter than 8 characters are fully masked.
|
|
71
|
+
def self.mask(value)
|
|
72
|
+
string = stringify(value)
|
|
73
|
+
if (match = EMAIL.match(string))
|
|
74
|
+
"#{match[1][0]}***@#{match[2]}"
|
|
75
|
+
elsif string.length < 8
|
|
76
|
+
"*" * string.length
|
|
77
|
+
else
|
|
78
|
+
("*" * (string.length - 4)) + string[-4..]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.stringify(value)
|
|
83
|
+
value.is_a?(String) ? value : value.to_json
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def matches?(matcher, name)
|
|
89
|
+
matcher.is_a?(Regexp) ? matcher.match?(name) : matcher.to_s == name
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SealedTrail
|
|
4
|
+
# Saves entries, adding the actor and request context, and applies
|
|
5
|
+
# config.on_failure when a write fails.
|
|
6
|
+
module Writer
|
|
7
|
+
FAILURE_EVENT = "write_failed.sealed_trail"
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Returns the saved Entry, or nil when the write failed and the failure was
|
|
12
|
+
# handled (:log or a callable).
|
|
13
|
+
def write(item_type:, item_id:, event:, object_changes: nil, metadata: nil)
|
|
14
|
+
attributes = { item_type: item_type, item_id: item_id.to_s, event: event.to_s }
|
|
15
|
+
|
|
16
|
+
begin
|
|
17
|
+
actor_type, actor_id = actor_columns(SealedTrail.actor)
|
|
18
|
+
attributes.merge!(
|
|
19
|
+
object_changes: object_changes.presence&.as_json,
|
|
20
|
+
metadata: (Current.metadata || {}).merge(metadata || {}).presence&.as_json,
|
|
21
|
+
actor_type: actor_type,
|
|
22
|
+
actor_id: actor_id,
|
|
23
|
+
request_id: Current.request_id,
|
|
24
|
+
remote_ip: Current.remote_ip
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if SealedTrail.config.on_failure == :raise
|
|
28
|
+
SealedTrail::Entry.create!(attributes)
|
|
29
|
+
else
|
|
30
|
+
# A savepoint, so a failed INSERT does not abort the caller's
|
|
31
|
+
# transaction (PostgreSQL); still rolled back with it.
|
|
32
|
+
SealedTrail::Entry.transaction(requires_new: true) { SealedTrail::Entry.create!(attributes) }
|
|
33
|
+
end
|
|
34
|
+
rescue StandardError => error
|
|
35
|
+
handle_failure(error, attributes)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def handle_failure(error, attributes)
|
|
40
|
+
payload = { error: error, entry: attributes }
|
|
41
|
+
ActiveSupport::Notifications.instrument(FAILURE_EVENT, payload)
|
|
42
|
+
|
|
43
|
+
mode = SealedTrail.config.on_failure
|
|
44
|
+
case mode
|
|
45
|
+
when :raise
|
|
46
|
+
raise error
|
|
47
|
+
when :log
|
|
48
|
+
log(error, attributes)
|
|
49
|
+
notify_error_handler(error, payload)
|
|
50
|
+
else
|
|
51
|
+
raise ConfigurationError, "config.on_failure must be :log, :raise or a callable" unless mode.respond_to?(:call)
|
|
52
|
+
|
|
53
|
+
mode.call(error, payload)
|
|
54
|
+
end
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def log(error, attributes)
|
|
59
|
+
SealedTrail.config.logger&.error(
|
|
60
|
+
"[SealedTrail] Failed to write audit entry for #{attributes[:item_type]}##{attributes[:item_id]} " \
|
|
61
|
+
"(#{attributes[:event]}): #{error.class}: #{error.message}"
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def notify_error_handler(error, payload)
|
|
66
|
+
SealedTrail.config.error_handler&.call(error, payload)
|
|
67
|
+
rescue StandardError => handler_error
|
|
68
|
+
SealedTrail.config.logger&.error("[SealedTrail] error_handler raised #{handler_error.class}: #{handler_error.message}")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def actor_columns(actor)
|
|
72
|
+
case actor
|
|
73
|
+
when nil then [ nil, nil ]
|
|
74
|
+
when ActiveRecord::Base then [ actor.class.polymorphic_name, actor.id.to_s ]
|
|
75
|
+
else [ nil, actor.to_s ]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/sealed_trail.rb
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/core_ext/module/attribute_accessors"
|
|
5
|
+
require "active_record"
|
|
6
|
+
|
|
7
|
+
require "sealed_trail/version"
|
|
8
|
+
require "sealed_trail/configuration"
|
|
9
|
+
require "sealed_trail/current"
|
|
10
|
+
require "sealed_trail/redactor"
|
|
11
|
+
require "sealed_trail/writer"
|
|
12
|
+
require "sealed_trail/recorder"
|
|
13
|
+
require "sealed_trail/model"
|
|
14
|
+
require "sealed_trail/bulk"
|
|
15
|
+
require "sealed_trail/controller"
|
|
16
|
+
require "sealed_trail/rake_context"
|
|
17
|
+
|
|
18
|
+
# SealedTrail records an audit trail of changes to Active Record models: what
|
|
19
|
+
# changed, who changed it, when, and from which request.
|
|
20
|
+
module SealedTrail
|
|
21
|
+
REDACTED = "[REDACTED]"
|
|
22
|
+
|
|
23
|
+
class Error < StandardError; end
|
|
24
|
+
class ConfigurationError < Error; end
|
|
25
|
+
|
|
26
|
+
autoload :Entry, "sealed_trail/entry"
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# Global configuration. See SealedTrail::Configuration.
|
|
30
|
+
def config
|
|
31
|
+
@config ||= Configuration.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Yields the global configuration, typically from
|
|
35
|
+
# config/initializers/sealed_trail.rb.
|
|
36
|
+
def configure
|
|
37
|
+
yield config
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Restores the default configuration. Mostly useful in tests.
|
|
41
|
+
def reset_config!
|
|
42
|
+
@config = Configuration.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Process-wide fallback actor label, set to "console" in rails console and
|
|
46
|
+
# "rake:<task>" while a rake task runs.
|
|
47
|
+
attr_accessor :process_actor_label
|
|
48
|
+
|
|
49
|
+
# True unless tracking is switched off globally (config.enabled = false)
|
|
50
|
+
# or for the current block (SealedTrail.without_tracking).
|
|
51
|
+
def tracking?
|
|
52
|
+
config.enabled && !Current.tracking_disabled
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Runs the block without recording any audit entries on the current
|
|
56
|
+
# thread/fiber. Returns the block's value.
|
|
57
|
+
def without_tracking(&)
|
|
58
|
+
Current.set(tracking_disabled: true, &)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Runs the block with tracking forced back on, even inside
|
|
62
|
+
# without_tracking. Returns the block's value.
|
|
63
|
+
def with_tracking(&)
|
|
64
|
+
Current.set(tracking_disabled: false, &)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Attributes every entry recorded inside the block to +actor+. The actor
|
|
68
|
+
# can be an Active Record object (stored polymorphically) or a String or
|
|
69
|
+
# Symbol label such as "system" or :nightly_import. Returns the block's
|
|
70
|
+
# value.
|
|
71
|
+
def with_actor(actor, &)
|
|
72
|
+
Current.set(actor: actor, &)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Merges +metadata+ into the metadata of every entry recorded inside the
|
|
76
|
+
# block. Nested calls are merged. Returns the block's value.
|
|
77
|
+
def with_metadata(metadata, &)
|
|
78
|
+
merged = (Current.metadata || {}).merge(metadata.to_h.stringify_keys)
|
|
79
|
+
Current.set(metadata: merged, &)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The actor that will be recorded right now. In order: the explicit actor
|
|
83
|
+
# (with_actor / Current.actor), the request's actor, the request or job
|
|
84
|
+
# label ("anonymous", "job:<JobClass>"), the console/rake label, then
|
|
85
|
+
# config.default_actor.
|
|
86
|
+
def actor
|
|
87
|
+
identified = Current.identified_actor
|
|
88
|
+
return identified unless identified.nil?
|
|
89
|
+
return Current.context_label if Current.context_label.present?
|
|
90
|
+
return process_actor_label if config.detect_process_context && process_actor_label.present?
|
|
91
|
+
|
|
92
|
+
default = config.default_actor
|
|
93
|
+
default.respond_to?(:call) ? default.call : default
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The HMAC-SHA256 fingerprint stored by the :fingerprint redaction
|
|
97
|
+
# strategy, e.g. "hmac:3f2a...". Use it to find entries for a value
|
|
98
|
+
# without storing the value:
|
|
99
|
+
#
|
|
100
|
+
# SealedTrail::Entry.with_fingerprint("jane@example.com")
|
|
101
|
+
def fingerprint(value)
|
|
102
|
+
return nil if value.nil?
|
|
103
|
+
|
|
104
|
+
digest = OpenSSL::HMAC.hexdigest("SHA256", config.fingerprint_secret, Redactor.stringify(value))
|
|
105
|
+
"hmac:#{digest}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if defined?(Rails::Railtie)
|
|
111
|
+
require "sealed_trail/railtie"
|
|
112
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sealed_trail
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Steadybyte
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-25 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activerecord
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.1'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '9'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '7.1'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '9'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: activesupport
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.1'
|
|
40
|
+
- - "<"
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '9'
|
|
43
|
+
type: :runtime
|
|
44
|
+
prerelease: false
|
|
45
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '7.1'
|
|
50
|
+
- - "<"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '9'
|
|
53
|
+
description: SealedTrail records create, update and destroy events on Active Record
|
|
54
|
+
models with before/after values, the acting user, request id and IP address, and
|
|
55
|
+
redacts sensitive attributes by default. It helps Rails teams produce audit evidence
|
|
56
|
+
for security reviews.
|
|
57
|
+
email:
|
|
58
|
+
- steadybyte.hq@gmail.com
|
|
59
|
+
executables: []
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- CHANGELOG.md
|
|
64
|
+
- LICENSE
|
|
65
|
+
- README.md
|
|
66
|
+
- lib/generators/sealed_trail/install/install_generator.rb
|
|
67
|
+
- lib/generators/sealed_trail/install/templates/create_sealed_trail_entries.rb.tt
|
|
68
|
+
- lib/generators/sealed_trail/install/templates/sealed_trail.rb.tt
|
|
69
|
+
- lib/sealed_trail.rb
|
|
70
|
+
- lib/sealed_trail/bulk.rb
|
|
71
|
+
- lib/sealed_trail/configuration.rb
|
|
72
|
+
- lib/sealed_trail/controller.rb
|
|
73
|
+
- lib/sealed_trail/current.rb
|
|
74
|
+
- lib/sealed_trail/entry.rb
|
|
75
|
+
- lib/sealed_trail/job_context.rb
|
|
76
|
+
- lib/sealed_trail/model.rb
|
|
77
|
+
- lib/sealed_trail/railtie.rb
|
|
78
|
+
- lib/sealed_trail/rake_context.rb
|
|
79
|
+
- lib/sealed_trail/recorder.rb
|
|
80
|
+
- lib/sealed_trail/redactor.rb
|
|
81
|
+
- lib/sealed_trail/version.rb
|
|
82
|
+
- lib/sealed_trail/writer.rb
|
|
83
|
+
homepage: https://steadybytehq.github.io/sealedtrail/
|
|
84
|
+
licenses:
|
|
85
|
+
- MIT
|
|
86
|
+
metadata:
|
|
87
|
+
homepage_uri: https://steadybytehq.github.io/sealedtrail/
|
|
88
|
+
source_code_uri: https://github.com/steadybytehq/sealed_trail
|
|
89
|
+
changelog_uri: https://github.com/steadybytehq/sealed_trail/blob/main/CHANGELOG.md
|
|
90
|
+
bug_tracker_uri: https://github.com/steadybytehq/sealed_trail/issues
|
|
91
|
+
documentation_uri: https://github.com/steadybytehq/sealed_trail#readme
|
|
92
|
+
rubygems_mfa_required: 'true'
|
|
93
|
+
post_install_message:
|
|
94
|
+
rdoc_options: []
|
|
95
|
+
require_paths:
|
|
96
|
+
- lib
|
|
97
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
98
|
+
requirements:
|
|
99
|
+
- - ">="
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: '3.2'
|
|
102
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
103
|
+
requirements:
|
|
104
|
+
- - ">="
|
|
105
|
+
- !ruby/object:Gem::Version
|
|
106
|
+
version: '0'
|
|
107
|
+
requirements: []
|
|
108
|
+
rubygems_version: 3.5.22
|
|
109
|
+
signing_key:
|
|
110
|
+
specification_version: 4
|
|
111
|
+
summary: Audit trail of who changed what, when, for Rails models.
|
|
112
|
+
test_files: []
|