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,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
|