karst 0.1.0 → 0.4.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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +14 -12
  3. data/CHANGELOG.md +114 -47
  4. data/CONTRIBUTING.md +9 -1
  5. data/README.md +137 -9
  6. data/SECURITY.md +1 -1
  7. data/docs/advanced-configuration.md +112 -7
  8. data/docs/rails8-authentication.md +191 -0
  9. data/docs/request-reproduction.md +242 -0
  10. data/lib/generators/karst/install/templates/karst_initializer.rb +6 -0
  11. data/lib/karst/access/approved_populations.rb +15 -6
  12. data/lib/karst/access/errors.rb +16 -0
  13. data/lib/karst/access/identity_probe.rb +151 -0
  14. data/lib/karst/access/local_path.rb +42 -0
  15. data/lib/karst/access/observed_endpoint.rb +46 -0
  16. data/lib/karst/access/outcome_groups.rb +93 -0
  17. data/lib/karst/access/population_approval.rb +67 -0
  18. data/lib/karst/access/population_discovery.rb +25 -1
  19. data/lib/karst/access/population_revocation.rb +34 -0
  20. data/lib/karst/access/principal_source.rb +24 -3
  21. data/lib/karst/access/probe.rb +397 -0
  22. data/lib/karst/access/search.rb +14 -6
  23. data/lib/karst/access/sweep.rb +42 -112
  24. data/lib/karst/cli/identity_serialization.rb +82 -0
  25. data/lib/karst/cli/outcome_summary.rb +131 -0
  26. data/lib/karst/cli/principal_reference.rb +51 -0
  27. data/lib/karst/cli/reproduction.rb +267 -0
  28. data/lib/karst/cli/verification.rb +219 -55
  29. data/lib/karst/configuration.rb +57 -11
  30. data/lib/karst/execution_context.rb +6 -7
  31. data/lib/karst/identity/evidence.rb +110 -0
  32. data/lib/karst/identity/observation.rb +167 -0
  33. data/lib/karst/identity/warden_adapter.rb +26 -1
  34. data/lib/karst/identity.rb +157 -22
  35. data/lib/karst/mcp/compatibility.rb +24 -0
  36. data/lib/karst/mcp/reproduce_request_tool.rb +137 -0
  37. data/lib/karst/mcp/reproduction.rb +58 -0
  38. data/lib/karst/mcp/server.rb +27 -8
  39. data/lib/karst/mcp/verify_access_tool.rb +36 -2
  40. data/lib/karst/railtie.rb +4 -7
  41. data/lib/karst/reproduction/curl.rb +73 -0
  42. data/lib/karst/reproduction/exercise.rb +246 -0
  43. data/lib/karst/reproduction/observation.rb +87 -0
  44. data/lib/karst/reproduction/sanitizer.rb +210 -0
  45. data/lib/karst/version.rb +1 -1
  46. data/lib/karst/web/badge.rb +5 -31
  47. data/lib/karst/web/browser_identity.rb +10 -13
  48. data/lib/karst/web/csrf.rb +43 -0
  49. data/lib/karst/web/middleware.rb +145 -84
  50. data/lib/karst/web/panel.rb +343 -125
  51. data/lib/karst/web/populations_panel.rb +46 -336
  52. data/lib/karst.rb +3 -3
  53. data/lib/rails/commands/karst/mcp/mcp_command.rb +41 -1
  54. data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
  55. data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
  56. metadata +38 -18
  57. data/lib/karst/access/population_config_snippet.rb +0 -67
  58. data/lib/karst/access/population_preview.rb +0 -83
  59. data/lib/karst/access/resource_evidence.rb +0 -233
  60. data/lib/karst/spec/catalog.rb +0 -199
  61. data/lib/karst/spec/example_observation.rb +0 -31
  62. data/lib/karst/spec/observer.rb +0 -300
  63. data/lib/karst/spec/principal.rb +0 -12
  64. data/lib/karst/spec/reporter.rb +0 -83
  65. data/lib/karst/spec/request_observation.rb +0 -38
  66. data/lib/karst/spec/scenario.rb +0 -65
  67. data/lib/tasks/karst.rake +0 -34
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: karst
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Karst contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-12 00:00:00.000000000 Z
11
+ date: 2026-10-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -34,16 +34,22 @@ dependencies:
34
34
  name: mcp
35
35
  requirement: !ruby/object:Gem::Requirement
36
36
  requirements:
37
- - - "~>"
37
+ - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: 0.9.0
40
- type: :runtime
39
+ version: 1.5.0
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '1.7'
43
+ type: :development
41
44
  prerelease: false
42
45
  version_requirements: !ruby/object:Gem::Requirement
43
46
  requirements:
44
- - - "~>"
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: 1.5.0
50
+ - - "<"
45
51
  - !ruby/object:Gem::Version
46
- version: 0.9.0
52
+ version: '1.7'
47
53
  description: Karst tests Rails routes as a bounded set of existing users and reports
48
54
  runtime access evidence.
49
55
  email:
@@ -59,6 +65,8 @@ files:
59
65
  - README.md
60
66
  - SECURITY.md
61
67
  - docs/advanced-configuration.md
68
+ - docs/rails8-authentication.md
69
+ - docs/request-reproduction.md
62
70
  - lib/generators/karst/install/install_generator.rb
63
71
  - lib/generators/karst/install/templates/karst_identity_controller.rb
64
72
  - lib/generators/karst/install/templates/karst_initializer.rb
@@ -66,40 +74,52 @@ files:
66
74
  - lib/karst/access/approved_populations.rb
67
75
  - lib/karst/access/candidate_population.rb
68
76
  - lib/karst/access/database_isolation.rb
77
+ - lib/karst/access/errors.rb
78
+ - lib/karst/access/identity_probe.rb
79
+ - lib/karst/access/local_path.rb
80
+ - lib/karst/access/observed_endpoint.rb
81
+ - lib/karst/access/outcome_groups.rb
82
+ - lib/karst/access/population_approval.rb
69
83
  - lib/karst/access/population_approvals.rb
70
- - lib/karst/access/population_config_snippet.rb
71
84
  - lib/karst/access/population_discovery.rb
72
- - lib/karst/access/population_preview.rb
85
+ - lib/karst/access/population_revocation.rb
73
86
  - lib/karst/access/principal_sampler.rb
74
87
  - lib/karst/access/principal_selection.rb
75
88
  - lib/karst/access/principal_source.rb
76
89
  - lib/karst/access/principal_source_selection.rb
90
+ - lib/karst/access/probe.rb
77
91
  - lib/karst/access/probe_application.rb
78
- - lib/karst/access/resource_evidence.rb
79
92
  - lib/karst/access/search.rb
80
93
  - lib/karst/access/selected_principal_sources.rb
81
94
  - lib/karst/access/sensitive_attribute_names.rb
82
95
  - lib/karst/access/sweep.rb
96
+ - lib/karst/cli/identity_serialization.rb
97
+ - lib/karst/cli/outcome_summary.rb
98
+ - lib/karst/cli/principal_reference.rb
99
+ - lib/karst/cli/reproduction.rb
83
100
  - lib/karst/cli/verification.rb
84
101
  - lib/karst/configuration.rb
85
102
  - lib/karst/execution_context.rb
86
103
  - lib/karst/identity.rb
87
104
  - lib/karst/identity/devise_support.rb
105
+ - lib/karst/identity/evidence.rb
106
+ - lib/karst/identity/observation.rb
88
107
  - lib/karst/identity/warden_adapter.rb
108
+ - lib/karst/mcp/compatibility.rb
109
+ - lib/karst/mcp/reproduce_request_tool.rb
110
+ - lib/karst/mcp/reproduction.rb
89
111
  - lib/karst/mcp/server.rb
90
112
  - lib/karst/mcp/verify_access_tool.rb
91
113
  - lib/karst/railtie.rb
92
- - lib/karst/spec/catalog.rb
93
- - lib/karst/spec/example_observation.rb
94
- - lib/karst/spec/observer.rb
95
- - lib/karst/spec/principal.rb
96
- - lib/karst/spec/reporter.rb
97
- - lib/karst/spec/request_observation.rb
98
- - lib/karst/spec/scenario.rb
114
+ - lib/karst/reproduction/curl.rb
115
+ - lib/karst/reproduction/exercise.rb
116
+ - lib/karst/reproduction/observation.rb
117
+ - lib/karst/reproduction/sanitizer.rb
99
118
  - lib/karst/value.rb
100
119
  - lib/karst/version.rb
101
120
  - lib/karst/web/badge.rb
102
121
  - lib/karst/web/browser_identity.rb
122
+ - lib/karst/web/csrf.rb
103
123
  - lib/karst/web/locality.rb
104
124
  - lib/karst/web/middleware.rb
105
125
  - lib/karst/web/panel.rb
@@ -107,8 +127,8 @@ files:
107
127
  - lib/karst/web/route_lookup.rb
108
128
  - lib/rails/commands/karst/boot.rb
109
129
  - lib/rails/commands/karst/mcp/mcp_command.rb
130
+ - lib/rails/commands/karst/reproduce/reproduce_command.rb
110
131
  - lib/rails/commands/karst/verify/verify_command.rb
111
- - lib/tasks/karst.rake
112
132
  homepage: https://github.com/SilenceDogood1984/karst
113
133
  licenses:
114
134
  - MIT
@@ -1,67 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../value"
4
-
5
- module Karst
6
- module Access
7
- # Renders a developer's curated selection of
8
- # Karst::Access::PopulationDiscovery::Candidate into a Ruby config
9
- # snippet they can copy into their own Karst.configure block. Karst
10
- # never writes to the host application's files on its own -- the
11
- # developer stays in control of what actually gets committed (see
12
- # README "Curation / persistence").
13
- module PopulationConfigSnippet
14
- # wired is the subset actually rendered into `code` (every selected
15
- # candidate whose model matches a configured principal source).
16
- # unwired is every selected candidate that is not wired into
17
- # anything yet -- present so the UI can say so honestly instead of
18
- # silently dropping a selection (see README "Principal vs artifact
19
- # populations": this release only wires principal populations).
20
- Result = Value.define(:code, :wired, :unwired)
21
-
22
- class << self
23
- def generate(candidates)
24
- unique = candidates.uniq { |candidate| [candidate.model_name, candidate.method_name] }
25
- wired, unwired = unique.partition(&:principal_source)
26
- Result.new(code: render(wired), wired: wired, unwired: unwired)
27
- end
28
-
29
- private
30
-
31
- def render(wired)
32
- return "# Select at least one population above to generate a configuration snippet.\n" if wired.empty?
33
-
34
- by_source = wired.group_by(&:principal_source)
35
- by_source.size == 1 && by_source.keys.first == :default ? flat(by_source.fetch(:default)) : nested(by_source)
36
- end
37
-
38
- def flat(candidates)
39
- "config.principal_populations = {\n#{entries(candidates, indent: 2)}\n}\n"
40
- end
41
-
42
- def nested(by_source)
43
- sources = by_source.map do |source, group|
44
- " #{source_key(source)}: {\n populations: {\n#{entries(group, indent: 6)}\n }\n }"
45
- end.join(",\n")
46
- "config.principal_sources = {\n#{sources}\n}\n"
47
- end
48
-
49
- # Sorted by model then method so the same selection always renders
50
- # byte-identical output, regardless of checkbox submission order.
51
- def entries(candidates, indent:)
52
- pad = " " * indent
53
- sorted = candidates.sort_by { |candidate| [candidate.model_name, candidate.method_name.to_s] }
54
- sorted.map { |candidate| entry(candidate, pad) }.join(",\n")
55
- end
56
-
57
- def entry(candidate, pad)
58
- "#{pad}#{candidate.method_name}: -> { #{candidate.model_name}.#{candidate.method_name} }"
59
- end
60
-
61
- def source_key(source)
62
- source.to_s =~ /\A[a-zA-Z_][a-zA-Z0-9_]*\z/ ? source : source.inspect
63
- end
64
- end
65
- end
66
- end
67
- end
@@ -1,83 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../value"
4
- require_relative "candidate_population"
5
-
6
- module Karst
7
- module Access
8
- # The explicit, bounded "does this discovered candidate actually work"
9
- # step -- deliberately separate from Karst::Access::PopulationDiscovery,
10
- # which never executes anything. Only ever resolves a model/method pair
11
- # that a given Karst::Access::PopulationDiscovery::Result itself already
12
- # discovered; a submitted name that is not on that list is rejected
13
- # without calling anything, so this can never become a general "call any
14
- # class method by name" endpoint. Every resolution is bounded to
15
- # PREVIEW_LIMIT rows via Karst::Access::CandidatePopulation.resolve --
16
- # never a COUNT, never full materialization.
17
- class PopulationPreview
18
- PREVIEW_LIMIT = 3
19
-
20
- Result = Value.define(:model_name, :method_name, :resolved, :records, :error)
21
-
22
- def self.call(model_name:, method_name:, discovery_result:)
23
- new(model_name: model_name, method_name: method_name, discovery_result: discovery_result).call
24
- end
25
-
26
- def initialize(model_name:, method_name:, discovery_result:)
27
- @model_name = model_name.to_s
28
- @method_name = method_name.to_s
29
- @discovery_result = discovery_result
30
- end
31
-
32
- def call
33
- return unresolved("this is not a discovered candidate") unless known_candidate?
34
-
35
- klass = model_class
36
- return unresolved("the model could not be resolved") unless klass
37
-
38
- resolve(klass)
39
- end
40
-
41
- private
42
-
43
- def known_candidate?
44
- @discovery_result.candidates.any? do |candidate|
45
- candidate.model_name == @model_name && candidate.method_name.to_s == @method_name
46
- end
47
- end
48
-
49
- def model_class
50
- return nil unless defined?(ActiveRecord::Base)
51
-
52
- ActiveRecord::Base.descendants.find { |klass| klass.name == @model_name }
53
- end
54
-
55
- def resolve(klass)
56
- method_name = @method_name
57
- return missing_scope unless scope_exists?(klass, method_name)
58
-
59
- population = CandidatePopulation.resolve(
60
- name: method_name.to_sym, callable: -> { klass.public_send(method_name) },
61
- source_klass: klass, limit: PREVIEW_LIMIT
62
- )
63
- return unresolved("did not resolve to a usable ActiveRecord::Relation for #{@model_name}") unless population
64
-
65
- Result.new(model_name: @model_name, method_name: @method_name, resolved: true, records: population.records,
66
- error: nil)
67
- end
68
-
69
- def scope_exists?(klass, method_name)
70
- klass.respond_to?(method_name)
71
- end
72
-
73
- def missing_scope
74
- unresolved("the discovered scope no longer exists on #{@model_name}")
75
- end
76
-
77
- def unresolved(error)
78
- Result.new(model_name: @model_name, method_name: @method_name, resolved: false, records: [].freeze,
79
- error: error)
80
- end
81
- end
82
- end
83
- end
@@ -1,233 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/core_ext/string/inflections"
4
- require_relative "../value"
5
- require_relative "../identity"
6
-
7
- module Karst
8
- module Access
9
- # Given one exact resource (the specific record a route addresses by id)
10
- # and one specific principal (typically the successful outcome from an
11
- # Access::Sweep), reports simple, directly observed foreign-key
12
- # relationships between those two records. This is evidence, not an
13
- # authorization claim: it never states or implies *why* an outcome
14
- # occurred, only which foreign-key columns, if any, point from one given
15
- # record to the other's id.
16
- #
17
- # Deliberately narrow, matching the v1 scope this class was built for:
18
- # - only foreign-key-shaped columns (ending in "_id") on the two given
19
- # records are ever inspected -- never an arbitrary attribute, so no
20
- # other column value (name, email, token, ...) is ever read or shown;
21
- # - only a direct column-value comparison between the two given records,
22
- # never a join, a has_many traversal, or any multi-hop graph walk;
23
- # - resource resolution from a route path is attempted only through
24
- # Rails' own route recognition plus its controller-to-model naming
25
- # convention, and only trusted when every step succeeds unambiguously
26
- # (a recognized route with an :id segment, a controller name that
27
- # classifies to a real loaded Active Record class, and a record that
28
- # actually exists for that id). Anything softer -- an unrecognized
29
- # route, a controller with no conventional model, a missing record --
30
- # is reported as a limitation rather than guessed at.
31
- # rubocop:disable Metrics/ClassLength
32
- class ResourceEvidence
33
- class Error < StandardError; end
34
-
35
- # The resource side never gets Identity::PrincipalDescriptor's
36
- # configurable display_label hook -- there is no equivalent concept
37
- # for "the current route's resource" -- so it gets its own minimal,
38
- # equally attribute-free descriptor.
39
- ResourceDescriptor = Value.define(:model_name, :id)
40
-
41
- # from_model/from_id is whichever of the resource/principal actually
42
- # holds the foreign-key column; to_model/to_id is the other side.
43
- Relationship = Value.define(:column, :from_model, :from_id, :to_model, :to_id)
44
-
45
- Result = Value.define(:principal, :resource, :relationships, :observed_status, :observed_redirect,
46
- :limitation) do
47
- # Plain-text rendering deliberately kept free of causal wording
48
- # ("owns", "is authorized", "grants") -- see class comment above.
49
- def to_text
50
- lines = [principal.display_label]
51
- lines << observed_line if observed_status || observed_redirect
52
- lines << "" << "Related state:"
53
- lines.concat(related_state_lines)
54
- lines.join("\n")
55
- end
56
-
57
- private
58
-
59
- def observed_line
60
- observed_redirect ? "Observed #{observed_status} → #{observed_redirect}" : "Observed #{observed_status}"
61
- end
62
-
63
- def related_state_lines
64
- return [" Unavailable: #{limitation}"] if limitation
65
- return [no_relationship_line] if relationships.empty?
66
-
67
- grouped_relationship_lines
68
- end
69
-
70
- def no_relationship_line
71
- " No observed foreign-key relationship to #{resource.model_name} ##{resource.id}."
72
- end
73
-
74
- def grouped_relationship_lines
75
- relationships.group_by { |rel| [rel.from_model, rel.from_id] }.flat_map do |(model, id), grouped|
76
- ["#{model} ##{id}"] + grouped.map { |rel| " #{rel.column} → #{rel.to_model} ##{rel.to_id}" }
77
- end
78
- end
79
- end
80
-
81
- class << self
82
- # Resolves the resource, resolves the principal (from a
83
- # Sweep::Outcome's PrincipalDescriptor), and reports relationships in
84
- # one call. Either resolution step may fail safely -- see
85
- # #resolve_resource and #resolve_principal -- in which case the
86
- # Result carries a limitation instead of relationships.
87
- def for_outcome(outcome:, path:, http_method: "GET", application: nil)
88
- resource, resource_limitation = resolve_resource(path: path, http_method: http_method,
89
- application: application)
90
- principal, principal_limitation = resolve_principal(outcome.principal)
91
- limitation = [resource_limitation, principal_limitation].compact.join("; ")
92
-
93
- return unresolved_result(outcome, limitation) if resource.nil? || principal.nil?
94
-
95
- new(resource: resource, principal: principal).call(
96
- observed_status: outcome.status, observed_redirect: outcome.redirect
97
- )
98
- end
99
-
100
- # Attempts to resolve the exact record a route addresses, trusting
101
- # only Rails' own route recognition and controller naming
102
- # convention, and only when every step is unambiguous. Returns
103
- # [record, nil] on success or [nil, reason] when any step is not
104
- # reliable -- never a guessed record.
105
- def resolve_resource(path:, http_method: "GET", application: nil)
106
- app = application || rails_application
107
- return [nil, "no Rails application is available to recognize the route"] unless app
108
-
109
- params = recognize(app, path, http_method)
110
- return [nil, "the route could not be recognized"] unless params
111
-
112
- id = params[:id]
113
- return [nil, "the recognized route has no :id segment addressing one specific resource"] unless id
114
-
115
- find_by_controller(params[:controller], id)
116
- end
117
-
118
- # Resolves the actual record behind a Karst::Identity::PrincipalDescriptor
119
- # only through the configured principal source. A valid model/id outside
120
- # that source is deliberately unresolved.
121
- def resolve_principal(descriptor)
122
- record = Identity.resolve(model_name: descriptor.model_name, id: descriptor.id)
123
- return [nil, "principal is not available from the configured principal source"] unless record
124
-
125
- [record, nil]
126
- rescue Identity::Error
127
- [nil, "the configured principal source is unavailable"]
128
- end
129
-
130
- private
131
-
132
- def rails_application
133
- defined?(Rails) && Rails.respond_to?(:application) && Rails.application
134
- end
135
-
136
- def find_by_controller(controller, id)
137
- klass = active_record_class(controller.to_s.classify)
138
- return [nil, "the route's controller does not map to a loaded Active Record model by convention"] unless klass
139
-
140
- record = klass.find_by(klass.primary_key => id)
141
- return [nil, "no #{klass.name} record exists for id #{id.inspect}"] unless record
142
-
143
- [record, nil]
144
- end
145
-
146
- def unresolved_result(outcome, limitation)
147
- Result.new(principal: outcome.principal, resource: nil, relationships: [].freeze,
148
- observed_status: outcome.status, observed_redirect: outcome.redirect,
149
- limitation: limitation.empty? ? "the resource or principal could not be resolved" : limitation)
150
- end
151
-
152
- def recognize(app, path, http_method)
153
- app.routes.recognize_path(path, method: http_method)
154
- rescue StandardError
155
- nil
156
- end
157
-
158
- def active_record_class(name)
159
- klass = name.to_s.safe_constantize
160
- return nil unless defined?(ActiveRecord::Base) && klass.is_a?(Class) && klass < ActiveRecord::Base
161
-
162
- klass
163
- end
164
- end
165
-
166
- def initialize(resource:, principal:)
167
- @resource = resource
168
- @principal = principal
169
- end
170
-
171
- def call(observed_status: nil, observed_redirect: nil)
172
- Result.new(
173
- principal: Identity.describe(@principal),
174
- resource: ResourceDescriptor.new(model_name: model_name(@resource), id: primary_key_value(@resource)),
175
- relationships: relationships.freeze,
176
- observed_status: observed_status,
177
- observed_redirect: observed_redirect,
178
- limitation: nil
179
- )
180
- end
181
-
182
- private
183
-
184
- def relationships
185
- foreign_keys_from(@resource, @principal) + foreign_keys_from(@principal, @resource)
186
- end
187
-
188
- def foreign_keys_from(source, target)
189
- return [] unless active_record?(source) && active_record?(target)
190
-
191
- source.class.columns_hash.values.filter_map { |column| relationship_for(source, target, column) }
192
- end
193
-
194
- def relationship_for(source, target, column)
195
- return unless foreign_key_column?(source.class, column)
196
- return unless targets?(column, target.class)
197
-
198
- value = source.public_send(column.name)
199
- return if value.nil? || value != primary_key_value(target)
200
-
201
- Relationship.new(column: column.name, from_model: model_name(source), from_id: primary_key_value(source),
202
- to_model: model_name(target), to_id: primary_key_value(target))
203
- end
204
-
205
- def foreign_key_column?(klass, column)
206
- column.name.end_with?("_id") && column.name != klass.primary_key
207
- end
208
-
209
- # "Obvious" is judged purely from the column's own name against the
210
- # target's actual class (including its Active Record ancestry, so a
211
- # single-table-inherited subclass still matches its base class's
212
- # conventional foreign key) -- never a declared association, never any
213
- # other column's value, and never a second hop through another model.
214
- def targets?(column, target_klass)
215
- candidate = column.name.delete_suffix("_id").classify.safe_constantize
216
- candidate.is_a?(Class) && (target_klass <= candidate || candidate <= target_klass)
217
- end
218
-
219
- def active_record?(record)
220
- defined?(ActiveRecord::Base) && record.is_a?(ActiveRecord::Base)
221
- end
222
-
223
- def model_name(record)
224
- record.class.respond_to?(:model_name) ? record.class.model_name.name.to_s : record.class.name.to_s
225
- end
226
-
227
- def primary_key_value(record)
228
- record.public_send(record.class.primary_key)
229
- end
230
- end
231
- # rubocop:enable Metrics/ClassLength
232
- end
233
- end