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,71 @@
1
+ # SealedTrail records an audit trail of changes to your models.
2
+ # Add `has_sealed_trail` to any model you want to track.
3
+ SealedTrail.configure do |config|
4
+ # Turn all tracking off, e.g. in a one-off data migration.
5
+ # config.enabled = true
6
+
7
+ # --- Redaction -------------------------------------------------------------
8
+ # Attributes whose values are never stored in plain text. Strings match the
9
+ # exact attribute name; Regexps match any part of it; a Hash picks a strategy
10
+ # per rule. The defaults cover passwords, tokens, secrets, API/private keys,
11
+ # OTP fields, salts, encrypted_*, email, *_email, phone, *_phone and mobile.
12
+ # config.redact_attributes += [:ssn, /\Abank_account/, { date_of_birth: :mask }]
13
+
14
+ # How redacted values are stored unless a rule says otherwise:
15
+ # :redact => "[REDACTED]"
16
+ # :fingerprint => "hmac:<hex>" (HMAC-SHA256; equal values match, see
17
+ # SealedTrail::Entry.with_fingerprint)
18
+ # :mask => "j***@example.com", "******1234"
19
+ # or a callable ->(value, attribute_name) { ... }
20
+ # config.redaction_strategy = :redact
21
+
22
+ # Secret for :fingerprint. Defaults to a key derived from secret_key_base;
23
+ # changing it (or secret_key_base) changes all future fingerprints.
24
+ # config.fingerprint_secret = Rails.application.credentials.sealed_trail_fingerprint_secret
25
+
26
+ # Also redact attributes declared with `encrypts` (default: true).
27
+ # config.redact_encrypted_attributes = true
28
+
29
+ # Attributes never recorded in any model.
30
+ # config.ignore_attributes = [:updated_at]
31
+
32
+ # --- When an audit entry cannot be written ---------------------------------
33
+ # :log (default) logs the error, instruments "write_failed.sealed_trail" and
34
+ # lets the user's save succeed. :raise rolls the change back instead.
35
+ # A callable ->(error, payload) { ... } replaces the default handling.
36
+ # config.on_failure = :log
37
+
38
+ # Extra reporting in :log mode, e.g. to Sentry or Rails.error.
39
+ # config.error_handler = ->(error, payload) { Rails.error.report(error, context: payload.except(:error)) }
40
+
41
+ # --- Who made the change -----------------------------------------------------
42
+ # Controller method that returns the current actor (default: :current_user).
43
+ # config.actor_method = :current_user
44
+
45
+ # Recorded in a request when nobody is signed in (default: "anonymous").
46
+ # config.anonymous_actor = "anonymous"
47
+
48
+ # Recorded when nothing else identifies the actor (default: "system").
49
+ # Rake tasks are labelled "rake:<task>", the console "console", and jobs
50
+ # "job:<JobClass>" unless they inherited an actor from where they were enqueued.
51
+ # config.default_actor = "system"
52
+ # config.detect_process_context = true
53
+
54
+ # Carry the actor, request id and metadata into Active Job jobs (default: true).
55
+ # config.active_job_integration = true
56
+
57
+ # Include SealedTrail::Controller in all controllers automatically.
58
+ # config.auto_include_controller = true
59
+
60
+ # --- Bulk operations ---------------------------------------------------------
61
+ # Record update_all, delete_all, insert_all, insert_all! and upsert_all on
62
+ # tracked models as one summary entry each (default: true).
63
+ # config.track_bulk_operations = true
64
+
65
+ # Maximum affected ids stored per bulk entry (default: 1000).
66
+ # config.bulk_id_limit = 1000
67
+
68
+ # WHERE/SET SQL of bulk operations: :masked (string literals replaced with
69
+ # '?', default), :full or :none.
70
+ # config.bulk_sql = :masked
71
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SealedTrail
4
+ # Records one summary entry per bulk operation (update_all, delete_all,
5
+ # insert_all, insert_all!, upsert_all) on tracked models.
6
+ #
7
+ # The summary is stored in the entry's metadata under "bulk":
8
+ #
9
+ # "operation" => "update_all"
10
+ # "row_count" => rows affected (nil when the database does not say)
11
+ # "ids" => affected primary keys as strings (capped by
12
+ # config.bulk_id_limit)
13
+ # "ids_truncated" => true when more ids matched than were stored
14
+ # "where_sql" => the WHERE clause (per config.bulk_sql)
15
+ # "set_sql" => for update_all with a SQL string
16
+ # "columns" => for inserts, the columns written
17
+ #
18
+ # For update_all with a Hash, the values set are stored in object_changes as
19
+ # [nil, value], redacted like any other change.
20
+ module Bulk
21
+ ITEM_ID = "*"
22
+
23
+ module_function
24
+
25
+ # Rails 7.2+ defines insert_all and friends on Relation; Rails 7.1 only on
26
+ # the model class.
27
+ def relation_inserts?
28
+ ActiveRecord::Relation.method_defined?(:insert_all)
29
+ end
30
+
31
+ def track?(model)
32
+ SealedTrail.config.track_bulk_operations &&
33
+ SealedTrail.tracking? &&
34
+ model.respond_to?(:sealed_trail?) && model.sealed_trail? &&
35
+ model.sealed_trail_options.bulk
36
+ end
37
+
38
+ def update_all(relation, updates)
39
+ model = relation.klass
40
+ changes = nil
41
+ set_sql = nil
42
+
43
+ if updates.is_a?(Hash)
44
+ changes = AttributeFilter.new(model).call(
45
+ updates.to_h { |name, value| [ name.to_s.split(".").last, [ nil, sql_value(value) ] ] }
46
+ )
47
+ return yield if changes.empty? && updates.any?
48
+ else
49
+ set_sql = format_sql(model.sanitize_sql_for_assignment(updates))
50
+ end
51
+
52
+ ids, truncated = capture_ids(relation)
53
+ where_sql = where_sql(relation)
54
+ row_count = yield
55
+
56
+ summary = { "operation" => "update_all", "row_count" => row_count, "ids" => ids, "ids_truncated" => truncated, "where_sql" => where_sql }
57
+ summary["set_sql"] = set_sql if set_sql
58
+ write(model, "bulk_update", changes, summary)
59
+ row_count
60
+ end
61
+
62
+ def delete_all(relation)
63
+ model = relation.klass
64
+ ids, truncated = capture_ids(relation)
65
+ where_sql = where_sql(relation)
66
+ row_count = yield
67
+
68
+ write(model, "bulk_delete", nil,
69
+ "operation" => "delete_all", "row_count" => row_count, "ids" => ids, "ids_truncated" => truncated, "where_sql" => where_sql)
70
+ row_count
71
+ end
72
+
73
+ def insert(model, operation, rows)
74
+ result = yield
75
+ rows = Array(rows)
76
+ filter = AttributeFilter.new(model)
77
+ columns = rows.flat_map { |row| row.to_h.keys.map(&:to_s) }.uniq.select { |name| filter.tracked?(name) }.sort
78
+
79
+ pk = model.primary_key
80
+ returned_ids = (result.columns.include?(pk) if pk.is_a?(String) && result.respond_to?(:columns))
81
+ ids = returned_ids ? result.rows.map { |row| row[result.columns.index(pk)].to_s } : nil
82
+ limit = SealedTrail.config.bulk_id_limit
83
+ truncated = !!(ids && limit && ids.size > limit)
84
+ ids = ids.first(limit) if ids && limit
85
+
86
+ write(model, operation == :upsert_all ? "bulk_upsert" : "bulk_insert", nil,
87
+ "operation" => operation.to_s,
88
+ "row_count" => returned_ids ? result.rows.size : nil,
89
+ "attempted_count" => rows.size,
90
+ "ids" => ids,
91
+ "ids_truncated" => truncated,
92
+ "columns" => columns)
93
+ result
94
+ end
95
+
96
+ def write(model, event, changes, summary)
97
+ Writer.write(
98
+ item_type: model.polymorphic_name,
99
+ item_id: ITEM_ID,
100
+ event: event,
101
+ object_changes: changes,
102
+ metadata: { "bulk" => summary.compact }
103
+ )
104
+ end
105
+
106
+ def capture_ids(relation)
107
+ pk = relation.klass.primary_key
108
+ return [ nil, false ] unless pk.is_a?(String)
109
+
110
+ cap = SealedTrail.config.bulk_id_limit
111
+ scope = relation.except(:select, :includes, :preload, :eager_load)
112
+ fetch = cap && cap + 1
113
+ fetch = [ fetch, relation.limit_value ].compact.min if relation.limit_value
114
+ scope = scope.limit(fetch) if fetch
115
+ ids = scope.pluck(relation.klass.arel_table[pk]).map(&:to_s)
116
+ truncated = !!(cap && ids.size > cap)
117
+ [ cap ? ids.first(cap) : ids, truncated ]
118
+ end
119
+
120
+ def where_sql(relation)
121
+ return nil if SealedTrail.config.bulk_sql == :none
122
+
123
+ return nil if relation.where_clause.empty?
124
+
125
+ connection = relation.klass.connection
126
+ # Compile only the WHERE conditions, with bind values inlined.
127
+ sql = connection.unprepared_statement do
128
+ connection.visitor.compile(relation.where_clause.ast, connection.send(:collector))
129
+ end
130
+ format_sql(sql)
131
+ rescue StandardError
132
+ nil
133
+ end
134
+
135
+ def format_sql(sql)
136
+ return nil if sql.blank?
137
+
138
+ case SealedTrail.config.bulk_sql
139
+ when :none then nil
140
+ when :full then sql
141
+ else sql.gsub(/'(?:[^'\\]|''|\\.)*'/, "'?'")
142
+ end
143
+ end
144
+
145
+ def sql_value(value)
146
+ value.respond_to?(:to_sql) ? value.to_sql : value
147
+ end
148
+
149
+ # Prepended to ActiveRecord::Relation.
150
+ module RelationHooks
151
+ def update_all(updates)
152
+ return super unless SealedTrail::Bulk.track?(klass)
153
+
154
+ SealedTrail::Bulk.update_all(self, updates) { super }
155
+ end
156
+
157
+ def delete_all
158
+ return super unless SealedTrail::Bulk.track?(klass)
159
+
160
+ SealedTrail::Bulk.delete_all(self) { super }
161
+ end
162
+ end
163
+
164
+ # Prepended to ActiveRecord::Relation (Rails 7.2+) or to each tracked
165
+ # model's singleton class (Rails 7.1).
166
+ module InsertHooks
167
+ %i[insert_all insert_all! upsert_all].each do |operation|
168
+ define_method(operation) do |attributes, **options|
169
+ model = is_a?(ActiveRecord::Relation) ? klass : self
170
+ return super(attributes, **options) unless SealedTrail::Bulk.track?(model)
171
+
172
+ SealedTrail::Bulk.insert(model, operation, attributes) { super(attributes, **options) }
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SealedTrail
4
+ # Global settings. Change them with:
5
+ #
6
+ # SealedTrail.configure do |config|
7
+ # config.redact_attributes += [:ssn, { email: :fingerprint }]
8
+ # end
9
+ class Configuration
10
+ # Attribute names (Strings/Symbols, matched exactly) or Regexps whose
11
+ # values are redacted with config.redaction_strategy. A Hash entry maps a
12
+ # name or Regexp to its own strategy, e.g. { email: :fingerprint }.
13
+ DEFAULT_REDACT_ATTRIBUTES = [
14
+ /passw(or)?d/i,
15
+ /token/i,
16
+ /secret/i,
17
+ /\Aencrypted_/i,
18
+ /api_?key/i,
19
+ /private_?key/i,
20
+ /\Aotp_/i,
21
+ /salt\z/i,
22
+ /(\A|_)e?mail(_address)?\z/i,
23
+ /(\A|_)phone(_number)?\z/i,
24
+ /(\A|_)mobile(_number)?\z/i
25
+ ].freeze
26
+
27
+ ON_FAILURE_MODES = %i[log raise].freeze
28
+ BULK_SQL_MODES = %i[masked full none].freeze
29
+
30
+ # Switch all tracking off (for example in a seed task). Default: true.
31
+ attr_accessor :enabled
32
+
33
+ # Redaction rules applied to every tracked model, after the per-model
34
+ # +redact:+ rules. Default: DEFAULT_REDACT_ATTRIBUTES.
35
+ attr_accessor :redact_attributes
36
+
37
+ # How redacted values are stored unless a rule names its own strategy:
38
+ # :redact ("[REDACTED]"), :fingerprint ("hmac:<hex>"), :mask
39
+ # ("j***@example.com", "******1234") or a callable(value, attribute_name).
40
+ # Default: :redact.
41
+ attr_accessor :redaction_strategy
42
+
43
+ # Secret for the :fingerprint strategy and SealedTrail.fingerprint.
44
+ # Default: derived from Rails' secret_key_base.
45
+ attr_writer :fingerprint_secret
46
+
47
+ # Also redact attributes declared with Rails' +encrypts+. Default: true.
48
+ attr_accessor :redact_encrypted_attributes
49
+
50
+ # Attributes never recorded in any model. Default: none.
51
+ attr_accessor :ignore_attributes
52
+
53
+ # What to do when an audit entry cannot be written:
54
+ # :log (default) logs it, instruments "write_failed.sealed_trail", calls
55
+ # error_handler and lets the user's save succeed; :raise re-raises so the
56
+ # change is rolled back; a callable(error, payload) is called instead.
57
+ attr_accessor :on_failure
58
+
59
+ # Called with (error, payload) when a write fails in :log mode, e.g.
60
+ # config.error_handler = ->(error, payload) { Rails.error.report(error, context: payload) }
61
+ attr_accessor :error_handler
62
+
63
+ # Logger for write failures. Default: Rails.logger.
64
+ attr_writer :logger
65
+
66
+ # The controller method used to find the actor for a request. It is called
67
+ # lazily, the first time an entry is recorded in that request.
68
+ # Default: :current_user.
69
+ attr_accessor :actor_method
70
+
71
+ # Actor recorded in a request when the actor method returns nil.
72
+ # Default: "anonymous". Set to nil to fall back to default_actor.
73
+ attr_accessor :anonymous_actor
74
+
75
+ # Actor recorded when nothing else identifies one (after the explicit
76
+ # actor, the request, the job, and the console/rake context). A label, a
77
+ # record, or a callable returning one. Default: "system".
78
+ attr_accessor :default_actor
79
+
80
+ # Label rake tasks ("rake:<task>") and the console ("console").
81
+ # Default: true.
82
+ attr_accessor :detect_process_context
83
+
84
+ # Carry the actor, request id and metadata from where a job is enqueued
85
+ # into the job, and label jobs "job:<JobClass>". Default: true.
86
+ attr_accessor :active_job_integration
87
+
88
+ # Automatically include SealedTrail::Controller into ActionController::Base
89
+ # and ActionController::API. Default: true.
90
+ attr_accessor :auto_include_controller
91
+
92
+ # Record update_all, delete_all, insert_all, insert_all! and upsert_all on
93
+ # tracked models as one summary entry per operation. Default: true.
94
+ attr_accessor :track_bulk_operations
95
+
96
+ # Maximum number of affected ids stored in a bulk entry (nil: no limit).
97
+ # Default: 1000.
98
+ attr_accessor :bulk_id_limit
99
+
100
+ # How the WHERE/SET SQL of bulk operations is stored: :masked (quoted string
101
+ # literals replaced with '?'), :full, or :none. Default: :masked.
102
+ attr_accessor :bulk_sql
103
+
104
+ def initialize
105
+ @enabled = true
106
+ @redact_attributes = DEFAULT_REDACT_ATTRIBUTES.dup
107
+ @redaction_strategy = :redact
108
+ @fingerprint_secret = nil
109
+ @redact_encrypted_attributes = true
110
+ @ignore_attributes = []
111
+ @on_failure = :log
112
+ @error_handler = nil
113
+ @logger = nil
114
+ @actor_method = :current_user
115
+ @anonymous_actor = "anonymous"
116
+ @default_actor = "system"
117
+ @detect_process_context = true
118
+ @active_job_integration = true
119
+ @auto_include_controller = true
120
+ @track_bulk_operations = true
121
+ @bulk_id_limit = 1000
122
+ @bulk_sql = :masked
123
+ end
124
+
125
+ def logger
126
+ @logger || (defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) || ActiveRecord::Base.logger
127
+ end
128
+
129
+ def fingerprint_secret
130
+ return @fingerprint_secret if @fingerprint_secret.present?
131
+
132
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
133
+ @derived_fingerprint_secret ||= Rails.application.key_generator.generate_key("sealed_trail/fingerprint", 32)
134
+ else
135
+ raise ConfigurationError, "Set SealedTrail.config.fingerprint_secret to use the :fingerprint strategy"
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SealedTrail
4
+ # Captures the actor, request id and remote IP for every request.
5
+ #
6
+ # Included automatically into ActionController::Base and ActionController::API
7
+ # (set config.auto_include_controller = false to include it yourself).
8
+ #
9
+ # The actor is resolved lazily by calling #sealed_trail_actor the first time
10
+ # an entry is recorded, so it works with authentication that runs in a later
11
+ # before_action. When it returns nil, config.anonymous_actor ("anonymous")
12
+ # is recorded. Override #sealed_trail_actor to customise it:
13
+ #
14
+ # def sealed_trail_actor
15
+ # current_admin || current_user
16
+ # end
17
+ module Controller
18
+ extend ActiveSupport::Concern
19
+
20
+ included do
21
+ before_action :set_sealed_trail_context
22
+ end
23
+
24
+ private
25
+
26
+ # The actor recorded for this request. By default calls the method named by
27
+ # SealedTrail.config.actor_method (:current_user) when the controller has it.
28
+ def sealed_trail_actor
29
+ method_name = SealedTrail.config.actor_method
30
+ send(method_name) if method_name && respond_to?(method_name, true)
31
+ end
32
+
33
+ # Extra metadata stored with every entry recorded in this request.
34
+ # Override to add, e.g., { "user_agent" => request.user_agent }.
35
+ def sealed_trail_metadata
36
+ {}
37
+ end
38
+
39
+ def set_sealed_trail_context
40
+ current = SealedTrail::Current
41
+ current.request_id = request.request_id
42
+ current.remote_ip = request.remote_ip
43
+ current.actor_resolver = -> { sealed_trail_actor }
44
+ current.context_label = SealedTrail.config.anonymous_actor
45
+ metadata = sealed_trail_metadata
46
+ current.metadata = (current.metadata || {}).merge(metadata.to_h.stringify_keys) if metadata.present?
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SealedTrail
4
+ # Per-request (and per-job) state, isolated per thread or fiber according to
5
+ # Rails' isolation level and reset automatically by the Rails executor.
6
+ class Current < ActiveSupport::CurrentAttributes
7
+ # Explicit actor: an Active Record object, or a String/Symbol label.
8
+ attribute :actor
9
+ # A callable that returns the actor; used when +actor+ is not set. The
10
+ # controller concern stores one so authentication can run later.
11
+ attribute :actor_resolver
12
+ # Fallback label for this unit of work, e.g. "anonymous" in a request or
13
+ # "job:ImportJob" in a job.
14
+ attribute :context_label
15
+ attribute :request_id
16
+ attribute :remote_ip
17
+ attribute :metadata
18
+ attribute :tracking_disabled
19
+
20
+ # The explicit or request actor, without fallbacks.
21
+ def identified_actor
22
+ return actor unless actor.nil?
23
+
24
+ actor_resolver&.call
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SealedTrail
4
+ # One audit entry: a create, update, destroy or touch of one record, or a
5
+ # summary of one bulk operation (bulk_update, bulk_delete, bulk_insert,
6
+ # bulk_upsert) on a model.
7
+ #
8
+ # Columns: item_type, item_id ("*" for bulk entries), event, object_changes
9
+ # ({"attr" => [before, after]}), metadata, actor_type, actor_id, request_id,
10
+ # remote_ip, created_at.
11
+ #
12
+ # Entries are append-only through Active Record: once saved they are
13
+ # read-only, so +update+ and +destroy+ raise ActiveRecord::ReadOnlyRecord.
14
+ # (This does not stop raw SQL; tamper evidence is a SealedTrail Pro feature.)
15
+ class Entry < ActiveRecord::Base
16
+ self.table_name = "sealed_trail_entries"
17
+
18
+ belongs_to :item, polymorphic: true, optional: true
19
+ belongs_to :actor, polymorphic: true, optional: true
20
+
21
+ validates :item_type, :item_id, :event, presence: true
22
+ validates :event, inclusion: { in: SealedTrail::Model::ALL_EVENTS }
23
+
24
+ scope :chronological, -> { order(:created_at, :id) }
25
+ scope :reverse_chronological, -> { order(created_at: :desc, id: :desc) }
26
+
27
+ # Entries attributed to +actor+: an Active Record object or a label such
28
+ # as "system", "console", "rake:db:seed" or "job:ImportJob".
29
+ scope :for_actor, lambda { |actor|
30
+ if actor.is_a?(ActiveRecord::Base)
31
+ where(actor_type: actor.class.polymorphic_name, actor_id: actor.id.to_s)
32
+ else
33
+ where(actor_type: nil, actor_id: actor.to_s)
34
+ end
35
+ }
36
+
37
+ # Per-record entries about one record. Accepts a record, or a class (or
38
+ # class name) and an id, which also works after the record was destroyed:
39
+ #
40
+ # SealedTrail::Entry.for_item(post)
41
+ # SealedTrail::Entry.for_item(Post, 42)
42
+ scope :for_item, lambda { |item_or_class, id = nil|
43
+ type, id = item_key(item_or_class, id)
44
+ where(item_type: type, item_id: id)
45
+ }
46
+
47
+ # Per-record entries plus bulk entries whose stored ids include the record.
48
+ # Bulk matching searches the JSON text, so it misses ids beyond
49
+ # config.bulk_id_limit and operations without ids (e.g. inserts on MySQL).
50
+ scope :affecting, lambda { |item_or_class, id = nil|
51
+ type, id = item_key(item_or_class, id)
52
+ bulk_matches = where(item_type: type, event: SealedTrail::Model::BULK_EVENTS)
53
+ .where("#{json_text_sql(:metadata)} LIKE ?", "%#{sanitize_sql_like(id.to_json)}%")
54
+ where(item_type: type, item_id: id).or(bulk_matches)
55
+ }
56
+
57
+ # Entries for any record of a model class.
58
+ scope :for_item_type, lambda { |klass|
59
+ klass = klass.to_s.constantize unless klass.is_a?(Class)
60
+ where(item_type: klass.polymorphic_name)
61
+ }
62
+
63
+ # Entries created in a time range. Either end may be nil for an open range.
64
+ scope :between, ->(from, to) { where(created_at: from..to) }
65
+
66
+ # Entries for the given events: events(:update), events(:create, :destroy).
67
+ scope :events, ->(*names) { where(event: names.flatten.map(&:to_s)) }
68
+
69
+ # Bulk operation summaries.
70
+ scope :bulk, -> { where(event: SealedTrail::Model::BULK_EVENTS) }
71
+
72
+ scope :for_request, ->(request_id) { where(request_id: request_id) }
73
+
74
+ # Entries that stored +value+ with the :fingerprint strategy.
75
+ scope :with_fingerprint, lambda { |value|
76
+ where("#{json_text_sql(:object_changes)} LIKE ?", "%#{sanitize_sql_like(SealedTrail.fingerprint(value))}%")
77
+ }
78
+
79
+ def self.item_key(item_or_class, id)
80
+ if item_or_class.is_a?(ActiveRecord::Base)
81
+ [ item_or_class.class.polymorphic_name, item_or_class.id.to_s ]
82
+ else
83
+ klass = item_or_class.is_a?(Class) ? item_or_class : item_or_class.to_s.constantize
84
+ [ klass.polymorphic_name, id.to_s ]
85
+ end
86
+ end
87
+
88
+ # SQL for a JSON column as text, for LIKE searches.
89
+ def self.json_text_sql(column)
90
+ quoted = "#{connection.quote_table_name(table_name)}.#{connection.quote_column_name(column)}"
91
+ case connection.adapter_name
92
+ when /postg/i then "CAST(#{quoted} AS text)"
93
+ when /mysql|trilogy/i then "CAST(#{quoted} AS CHAR)"
94
+ else quoted
95
+ end
96
+ end
97
+
98
+ def readonly?
99
+ persisted? || super
100
+ end
101
+
102
+ # Names of the attributes this entry recorded.
103
+ def changed_attribute_names
104
+ (object_changes || {}).keys
105
+ end
106
+
107
+ # [before, after] for one attribute, or nil if it was not recorded.
108
+ def change_for(attribute)
109
+ (object_changes || {})[attribute.to_s]
110
+ end
111
+
112
+ def create? = event == "create"
113
+ def update? = event == "update"
114
+ def destroy? = event == "destroy"
115
+ def bulk? = event.to_s.start_with?("bulk_")
116
+
117
+ # The bulk summary hash ("operation", "row_count", "ids", ...), or nil.
118
+ def bulk_summary
119
+ (metadata || {})["bulk"] if bulk?
120
+ end
121
+
122
+ # Affected ids stored for a bulk entry (strings), or nil.
123
+ def bulk_ids
124
+ bulk_summary&.fetch("ids", nil)
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SealedTrail
4
+ # Carries the actor, request id, remote IP and metadata from where a job is
5
+ # enqueued into the job, and labels entries recorded by a job without an
6
+ # actor as "job:<JobClass>". Included into ActiveJob::Base by the Railtie.
7
+ module JobContext
8
+ KEY = "sealed_trail"
9
+
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ around_perform :with_sealed_trail_context
14
+ end
15
+
16
+ def serialize
17
+ data = super
18
+ context = SealedTrail::JobContext.capture
19
+ data[KEY] = context if context
20
+ data
21
+ end
22
+
23
+ def deserialize(job_data)
24
+ super
25
+ @sealed_trail_context = job_data[KEY]
26
+ end
27
+
28
+ def self.capture
29
+ return unless SealedTrail.config.active_job_integration
30
+
31
+ context = {}
32
+ actor = SealedTrail::Current.identified_actor
33
+ case actor
34
+ when nil then nil
35
+ when ActiveRecord::Base
36
+ if actor.respond_to?(:to_global_id) && actor.persisted?
37
+ context["actor_gid"] = actor.to_global_id.to_s
38
+ else
39
+ context["actor"] = "#{actor.class.polymorphic_name}##{actor.id}"
40
+ end
41
+ else
42
+ context["actor"] = actor.to_s
43
+ end
44
+ context["request_id"] = SealedTrail::Current.request_id if SealedTrail::Current.request_id
45
+ context["remote_ip"] = SealedTrail::Current.remote_ip if SealedTrail::Current.remote_ip
46
+ context["metadata"] = SealedTrail::Current.metadata.as_json if SealedTrail::Current.metadata.present?
47
+ context.presence
48
+ end
49
+
50
+ def self.locate_actor(context)
51
+ if (gid = context["actor_gid"])
52
+ GlobalID::Locator.locate(gid) || gid
53
+ else
54
+ context["actor"]
55
+ end
56
+ rescue ActiveRecord::RecordNotFound
57
+ gid
58
+ end
59
+
60
+ private
61
+
62
+ def with_sealed_trail_context(&)
63
+ return yield unless SealedTrail.config.active_job_integration
64
+
65
+ attributes = { context_label: "job:#{self.class.name}" }
66
+ if (context = @sealed_trail_context)
67
+ actor = SealedTrail::JobContext.locate_actor(context)
68
+ attributes[:actor] = actor unless actor.nil?
69
+ attributes[:request_id] = context["request_id"] if context["request_id"]
70
+ attributes[:remote_ip] = context["remote_ip"] if context["remote_ip"]
71
+ attributes[:metadata] = (SealedTrail::Current.metadata || {}).merge(context["metadata"]) if context["metadata"]
72
+ end
73
+ SealedTrail::Current.set(attributes, &)
74
+ end
75
+ end
76
+ end