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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SealedTrail
4
+ VERSION = "0.1.0"
5
+ 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
@@ -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: []