karst 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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +59 -0
  3. data/CHANGELOG.md +67 -0
  4. data/CODE_OF_CONDUCT.md +29 -0
  5. data/CONTRIBUTING.md +45 -0
  6. data/LICENSE +21 -0
  7. data/README.md +140 -0
  8. data/SECURITY.md +11 -0
  9. data/docs/advanced-configuration.md +188 -0
  10. data/lib/generators/karst/install/install_generator.rb +88 -0
  11. data/lib/generators/karst/install/templates/karst_identity_controller.rb +19 -0
  12. data/lib/generators/karst/install/templates/karst_initializer.rb +18 -0
  13. data/lib/karst/access/approved_populations.rb +128 -0
  14. data/lib/karst/access/candidate_population.rb +86 -0
  15. data/lib/karst/access/database_isolation.rb +62 -0
  16. data/lib/karst/access/population_approvals.rb +195 -0
  17. data/lib/karst/access/population_config_snippet.rb +67 -0
  18. data/lib/karst/access/population_discovery.rb +271 -0
  19. data/lib/karst/access/population_preview.rb +83 -0
  20. data/lib/karst/access/principal_sampler.rb +241 -0
  21. data/lib/karst/access/principal_selection.rb +90 -0
  22. data/lib/karst/access/principal_source.rb +143 -0
  23. data/lib/karst/access/principal_source_selection.rb +161 -0
  24. data/lib/karst/access/probe_application.rb +164 -0
  25. data/lib/karst/access/resource_evidence.rb +233 -0
  26. data/lib/karst/access/search.rb +265 -0
  27. data/lib/karst/access/selected_principal_sources.rb +65 -0
  28. data/lib/karst/access/sensitive_attribute_names.rb +26 -0
  29. data/lib/karst/access/sweep.rb +198 -0
  30. data/lib/karst/cli/verification.rb +182 -0
  31. data/lib/karst/configuration.rb +223 -0
  32. data/lib/karst/execution_context.rb +83 -0
  33. data/lib/karst/identity/devise_support.rb +90 -0
  34. data/lib/karst/identity/warden_adapter.rb +130 -0
  35. data/lib/karst/identity.rb +479 -0
  36. data/lib/karst/mcp/server.rb +63 -0
  37. data/lib/karst/mcp/verify_access_tool.rb +68 -0
  38. data/lib/karst/railtie.rb +30 -0
  39. data/lib/karst/spec/catalog.rb +199 -0
  40. data/lib/karst/spec/example_observation.rb +31 -0
  41. data/lib/karst/spec/observer.rb +300 -0
  42. data/lib/karst/spec/principal.rb +12 -0
  43. data/lib/karst/spec/reporter.rb +83 -0
  44. data/lib/karst/spec/request_observation.rb +38 -0
  45. data/lib/karst/spec/scenario.rb +65 -0
  46. data/lib/karst/value.rb +35 -0
  47. data/lib/karst/version.rb +5 -0
  48. data/lib/karst/web/badge.rb +183 -0
  49. data/lib/karst/web/browser_identity.rb +103 -0
  50. data/lib/karst/web/locality.rb +64 -0
  51. data/lib/karst/web/middleware.rb +377 -0
  52. data/lib/karst/web/panel.rb +699 -0
  53. data/lib/karst/web/populations_panel.rb +391 -0
  54. data/lib/karst/web/route_lookup.rb +65 -0
  55. data/lib/karst.rb +56 -0
  56. data/lib/rails/commands/karst/boot.rb +24 -0
  57. data/lib/rails/commands/karst/mcp/mcp_command.rb +26 -0
  58. data/lib/rails/commands/karst/verify/verify_command.rb +39 -0
  59. data/lib/tasks/karst.rake +34 -0
  60. metadata +138 -0
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karst
4
+ module Access
5
+ # Builds the smallest Rack endpoint which still gives a controller request
6
+ # Rails' cookie and session facilities. Calling Rails.application here would
7
+ # recursively enter every host middleware (including the middleware which
8
+ # initiated the sweep). RouteSet is the stable Rails dispatch boundary: it
9
+ # performs recognition and controller dispatch, but has no host middleware.
10
+ class ProbeApplication
11
+ class ConstructionError < StandardError; end
12
+
13
+ # Supplies the same Rails request environment as Rails::Application
14
+ # without calling its compiled middleware stack.
15
+ class Environment
16
+ attr_reader :host
17
+
18
+ def initialize(app, defaults, host)
19
+ @app = app
20
+ @defaults = defaults
21
+ @host = host
22
+ end
23
+
24
+ # Mutates the caller's own env in place (filling in only keys it
25
+ # doesn't already set -- @defaults never overrides an explicit
26
+ # incoming value) rather than building a new merged Hash. Downstream
27
+ # middleware Karst wraps this env with, in particular Warden::Manager
28
+ # setting env["warden"], must remain visible on the exact env object
29
+ # ActionDispatch::Integration::Session retains as #request.env after
30
+ # the call returns -- a fresh copy would silently discard every
31
+ # mutation the moment this method returned, leaving Karst unable to
32
+ # find that same Warden proxy again afterward (see WardenAdapter#clear).
33
+ def call(env)
34
+ @defaults.each { |key, value| env[key] = value unless env.key?(key) }
35
+ @app.call(env)
36
+ end
37
+ end
38
+ private_constant :Environment
39
+
40
+ class << self
41
+ def for(application)
42
+ return application unless rails_application?(application)
43
+
44
+ build(application)
45
+ rescue LoadError, StandardError => e
46
+ raise ConstructionError,
47
+ "Karst could not build the Rails probe endpoint; check the application's session store configuration",
48
+ cause: e
49
+ end
50
+
51
+ private
52
+
53
+ def build(application)
54
+ require_dependencies
55
+ endpoint = application.routes
56
+ endpoint = wrap_warden(endpoint)
57
+ endpoint = ActionDispatch::Flash.new(endpoint)
58
+ endpoint = session_middleware(application).new(endpoint, **session_options(application))
59
+ endpoint = ActionDispatch::Cookies.new(endpoint)
60
+ Environment.new(endpoint, application.env_config, probe_host(application))
61
+ end
62
+
63
+ def require_dependencies
64
+ require "action_dispatch/middleware/cookies"
65
+ require "action_dispatch/middleware/flash"
66
+ require "action_dispatch/middleware/session/cookie_store"
67
+ end
68
+
69
+ # Mirrors the host application's own middleware order (Warden::Manager
70
+ # sits directly in front of the router, inside session/flash/cookies)
71
+ # so `env["warden"]` exists for Karst::Identity::WardenAdapter -- the
72
+ # same object a real host request would see. Without this, a Devise
73
+ # (or otherwise Warden-based) application's probes would fail every
74
+ # single principal with Karst::Identity::Unavailable, since nothing
75
+ # else in this deliberately minimal Rack stack ever initializes a
76
+ # Warden proxy. Reuses Devise's own already-configured Warden::Config
77
+ # (scope defaults, session serializers, failure app) when Devise is
78
+ # present, rather than an empty default one, so a probe that reaches
79
+ # an unauthenticated 401/302 behaves exactly as it would for a real
80
+ # request instead of raising "No Failure App provided".
81
+ # Falls back to leaving `endpoint` unwrapped, rather than letting
82
+ # construction failure abort the whole probe, when Warden::Manager
83
+ # doesn't behave like the real middleware (for example a bare stand-in
84
+ # Class with Object's own zero-argument #initialize, as several specs
85
+ # use to exercise Devise-detection paths where explicit
86
+ # config.assume_identity/config.assume_browser_identity hooks already
87
+ # make WardenAdapter -- and so this wrapping -- unnecessary).
88
+ # Passed through the config block, never as Warden::Manager.new's own
89
+ # `options` argument: that constructor special-cases an
90
+ # options[:default_strategies] key by deleting it and re-adding it
91
+ # via `@config.default_strategies(*default_strategies)`, which
92
+ # assumes a flat Array of strategy names. Devise's own
93
+ # warden_config[:default_strategies] is already a Hash keyed by
94
+ # scope (e.g. {user: [...], admin: [...]}); splatting that Hash
95
+ # turns each [scope, strategies] pair into a positional argument, so
96
+ # every scope name (:admin, :user, ...) ends up misfiled into the
97
+ # :_all strategy list as if it were itself a strategy -- harmless
98
+ # for a single scope (nothing ever runs real strategies for a
99
+ # principal Karst just set_user'd into that exact scope), but a
100
+ # probed principal under one scope hitting a route gated on another
101
+ # then raises Warden's own "Invalid strategy admin" the moment
102
+ # multiple Devise models are involved. config.merge! after
103
+ # construction copies the same Hash in verbatim instead.
104
+ def wrap_warden(endpoint)
105
+ return endpoint unless defined?(Warden::Manager)
106
+
107
+ Warden::Manager.new(endpoint) { |config| config.merge!(devise_warden_config) }
108
+ rescue StandardError
109
+ endpoint
110
+ end
111
+
112
+ # A duplicate, never the live object: Warden::Manager#initialize
113
+ # destructively deletes :default_strategies from whatever options
114
+ # Hash it's given, and Devise's own warden_config is the one shared,
115
+ # mutable object every real host request also authenticates through
116
+ # -- corrupting it here would silently break the host application's
117
+ # own Devise authentication after a single Karst probe.
118
+ #
119
+ # Devise only populates warden_config (failure_app, scope_defaults,
120
+ # session serializers) the first time its routes finalize, which
121
+ # normally has already happened by the time a real /karst request
122
+ # reaches Sweep (Identity.setup_state/principal_sources already
123
+ # forced it via Devise.mappings). Calling Devise.mappings here too
124
+ # makes that a guarantee rather than an incidental ordering, so the
125
+ # very first sweep of a freshly booted process still gets a complete
126
+ # config instead of an empty one.
127
+ def devise_warden_config
128
+ return {} unless defined?(Devise) && Devise.respond_to?(:warden_config)
129
+
130
+ Devise.mappings if Devise.respond_to?(:mappings)
131
+ Devise.warden_config&.dup || {}
132
+ end
133
+
134
+ def rails_application?(application)
135
+ application.respond_to?(:routes) && application.respond_to?(:config)
136
+ end
137
+
138
+ def session_middleware(application)
139
+ application.config.session_store || ActionDispatch::Session::CookieStore
140
+ end
141
+
142
+ def session_options(application)
143
+ application.config.session_options.to_h
144
+ end
145
+
146
+ def probe_host(application)
147
+ candidates = [application.routes.default_url_options[:host]]
148
+ candidates.concat(Array(application.config.hosts).grep(String))
149
+ if defined?(ActionDispatch::HostAuthorization::ALLOWED_HOSTS_IN_DEVELOPMENT)
150
+ candidates.concat(ActionDispatch::HostAuthorization::ALLOWED_HOSTS_IN_DEVELOPMENT.grep(String))
151
+ end
152
+ candidates.filter_map { |candidate| safe_host(candidate) }.first
153
+ end
154
+
155
+ def safe_host(candidate)
156
+ host = candidate.to_s.sub(/\A\./, "")
157
+ return if host.empty? || host.match?(%r{[\s/:]})
158
+
159
+ host
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,233 @@
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
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../value"
4
+ require_relative "sweep"
5
+ require_relative "principal_selection"
6
+ require_relative "candidate_population"
7
+
8
+ module Karst
9
+ module Access
10
+ # Orchestrates the two-stage search for a user who can actually use a
11
+ # route: the ordinary bounded sample first, then -- only if that found
12
+ # nothing usable -- one bounded retry against each *approved* candidate
13
+ # population, stopping at the first verified success.
14
+ #
15
+ # This is deliberately an orchestrator built on top of the existing
16
+ # primitives rather than new behavior inside them. Access::Sweep still
17
+ # owns every actual request (and therefore every rollback, write
18
+ # observation, exception, and halted-callback observation), and
19
+ # CandidatePopulation still owns resolving one configured callable into
20
+ # bounded records. Search only decides what to run next and records what
21
+ # it chose not to run.
22
+ #
23
+ # Approval boundary: the populations considered here are exactly the
24
+ # ones on the Karst::Access::PrincipalSource objects handed to this
25
+ # class -- which means either explicit configuration
26
+ # (config.principal_populations, or a config.principal_sources[...]
27
+ # :populations entry) or a candidate a developer explicitly approved
28
+ # locally, folded into the same configuration by
29
+ # Karst::Access::ApprovedPopulations. A name merely *discovered* by
30
+ # Karst::Access::PopulationDiscovery, and never approved, is never
31
+ # executed. Search itself deliberately cannot tell the two apart and
32
+ # never reads approval state: whatever produced this source's
33
+ # populations already had to answer for them.
34
+ # rubocop:disable Metrics/ClassLength
35
+ class Search
36
+ # Why a population contributed nothing, kept explicit rather than
37
+ # inferred from an empty result so the panel can say what actually
38
+ # happened instead of implying every population was tested:
39
+ #
40
+ # :usable ran, and produced a usable outcome
41
+ # :no_match ran, and produced no usable outcome
42
+ # :empty resolved, but currently matches no records
43
+ # :already_tried resolved, but every candidate was already tested
44
+ # :unresolved the callable did not yield usable records
45
+ # :skipped not tried -- a usable user was already found
46
+ # :budget_exhausted not tried -- the retry request budget was reached
47
+ #
48
+ # Hard ceiling on the LIMIT any single population resolution may use,
49
+ # independent of how many users have already been tested. See
50
+ # #resolve_limit.
51
+ MAX_RESOLUTION_LIMIT = 50
52
+
53
+ PopulationAttempt = Value.define(:name, :source_name, :state, :result, :error) do
54
+ def ran?
55
+ !result.nil?
56
+ end
57
+ end
58
+
59
+ # `initial` is the ordinary Access::Sweep::Result; `attempts` is one
60
+ # PopulationAttempt per approved population, in configuration order,
61
+ # including the ones deliberately not run.
62
+ # rubocop:disable Metrics/BlockLength
63
+ Result = Value.define(:initial, :attempts) do
64
+ def path
65
+ initial.path
66
+ end
67
+
68
+ def http_method
69
+ initial.http_method
70
+ end
71
+
72
+ # Every outcome observed across both stages, initial sample first.
73
+ def all_outcomes
74
+ ([initial] + attempts.filter_map(&:result)).flat_map(&:outcomes)
75
+ end
76
+
77
+ def attempted
78
+ attempts.select(&:ran?)
79
+ end
80
+
81
+ def population_request_count
82
+ attempted.sum { |attempt| attempt.result.outcomes.size }
83
+ end
84
+
85
+ # The winning evidence and its origin are exposed by Search itself so
86
+ # adapters never need to invent another definition of usable access.
87
+ def verified_outcome
88
+ all_outcomes.find { |outcome| Karst.config.usable_access_outcome.call(outcome) }
89
+ end
90
+
91
+ def verified_source
92
+ return nil unless verified_outcome
93
+ return { type: :sample, name: nil } if initial.outcomes.include?(verified_outcome)
94
+
95
+ attempt = attempts.find { |item| item.result&.outcomes&.include?(verified_outcome) }
96
+ { type: :population, name: attempt.name }
97
+ end
98
+
99
+ def request_count
100
+ initial.outcomes.size + population_request_count
101
+ end
102
+
103
+ def elapsed_ms
104
+ ([initial] + attempted.map(&:result)).sum(&:elapsed_ms).round(1)
105
+ end
106
+ end
107
+ # rubocop:enable Metrics/BlockLength
108
+
109
+ def initialize(path:, http_method: "GET", sources: nil, application: nil)
110
+ @path = path
111
+ @http_method = http_method
112
+ @sources = sources || {}
113
+ @application = application
114
+ @requests_used = 0
115
+ @tried_keys = {}
116
+ end
117
+
118
+ def call
119
+ initial = initial_sweep
120
+ return Result.new(initial: initial, attempts: [].freeze) if usable?(initial.outcomes) || approved.empty?
121
+
122
+ Result.new(initial: initial, attempts: attempt_populations.freeze)
123
+ end
124
+
125
+ private
126
+
127
+ # PrincipalSampler is population-free by construction, so stage one
128
+ # cannot silently consume a population no matter what is configured;
129
+ # this class is the only thing that ever resolves or probes one.
130
+ def initial_sweep
131
+ sampled = PrincipalSelection.new(sources: @sources).call
132
+ record_tried(sampled.principals)
133
+ Sweep.new(path: @path, http_method: @http_method, principals: sampled.principals,
134
+ application: @application, candidate_pool_size: sampled.candidate_pool_size,
135
+ sampling_reasons: sampling_reasons(sampled)).call
136
+ end
137
+
138
+ def sampling_reasons(sampled)
139
+ sampled.candidates.to_h { |candidate| [candidate.principal, candidate.reasons] }
140
+ end
141
+
142
+ # Configuration order, across sources and then within each source --
143
+ # Ruby Hashes preserve insertion order, so this is exactly the order
144
+ # the application declared (explicitly configured populations first,
145
+ # then locally approved ones in their own stable model/scope order),
146
+ # never a heuristic ranking.
147
+ def approved
148
+ @approved ||= @sources.flat_map do |source_name, source|
149
+ source.populations.map { |name, callable| [source_name, name, callable, source] }
150
+ end
151
+ end
152
+
153
+ def attempt_populations
154
+ found = false
155
+ approved.map do |source_name, name, callable, source|
156
+ next skip(source_name, name, :skipped) if found
157
+
158
+ attempt = attempt_population(source_name, name, callable, source)
159
+ found = attempt.state == :usable
160
+ attempt
161
+ end
162
+ end
163
+
164
+ def attempt_population(source_name, name, callable, source)
165
+ return skip(source_name, name, :budget_exhausted) unless budget_remaining.positive?
166
+
167
+ resolved = candidate_records(source_name, name, callable, source)
168
+ return resolved if resolved.is_a?(PopulationAttempt)
169
+
170
+ sweep_population(source_name, name, resolved[:records], resolved[:population])
171
+ rescue StandardError => e
172
+ skip(source_name, name, :unresolved, error: "#{e.class}: #{e.message}")
173
+ end
174
+
175
+ # Returns either the records to probe or a terminal PopulationAttempt
176
+ # explaining why there are none. Resolution costs exactly one bounded
177
+ # LIMIT query -- never a COUNT, never full materialization.
178
+ def candidate_records(source_name, name, callable, source)
179
+ klass = source.record_klass
180
+ return skip(source_name, name, :unresolved, error: "source is not an Active Record model") unless klass
181
+
182
+ population = CandidatePopulation.resolve(name: name, callable: callable, source_klass: klass,
183
+ limit: resolve_limit)
184
+ return skip(source_name, name, :unresolved, error: "did not resolve to a usable relation") unless population
185
+ return skip(source_name, name, :empty) if population.records.empty?
186
+
187
+ fresh_records(source_name, name, population)
188
+ end
189
+
190
+ def fresh_records(source_name, name, population)
191
+ fresh = population.records.reject { |record| @tried_keys.key?(identity_key(record)) }
192
+ return skip(source_name, name, :already_tried) if fresh.empty?
193
+
194
+ { records: fresh.first(per_population_limit), population: population }
195
+ end
196
+
197
+ def sweep_population(source_name, name, records, population)
198
+ record_tried(records)
199
+ @requests_used += records.size
200
+ result = Sweep.new(path: @path, http_method: @http_method, principals: records, limit: records.size,
201
+ application: @application,
202
+ sampling_reasons: population_reasons(records, population)).call
203
+ state = usable?(result.outcomes) ? :usable : :no_match
204
+ PopulationAttempt.new(name: name, source_name: source_name, state: state, result: result, error: nil)
205
+ end
206
+
207
+ def population_reasons(records, population)
208
+ records.to_h { |record| [record, [population.provenance]] }
209
+ end
210
+
211
+ def skip(source_name, name, state, error: nil)
212
+ PopulationAttempt.new(name: name, source_name: source_name, state: state, result: nil, error: error)
213
+ end
214
+
215
+ # Resolving a few more rows than will actually be probed guarantees
216
+ # deduplication cannot produce a false ":already_tried": if a
217
+ # population holds at least (cap + already-tried) rows, at most
218
+ # already-tried of them can be duplicates, so at least `cap` fresh
219
+ # ones survive.
220
+ #
221
+ # MAX_RESOLUTION_LIMIT caps that explicitly rather than leaving it to
222
+ # an indirect argument about how large the already-tried set can get.
223
+ # Without the cap the limit is (cap + already-tried), and already-tried
224
+ # grows with both the initial sample and every prior population, so a
225
+ # late population would be resolved with a needlessly large LIMIT. The
226
+ # cap costs only the guarantee above, and only for a population whose
227
+ # rows are almost entirely duplicates -- which reports
228
+ # ":already_tried", still an honest answer.
229
+ def resolve_limit
230
+ [per_population_limit + @tried_keys.size, MAX_RESOLUTION_LIMIT].min
231
+ end
232
+
233
+ def per_population_limit
234
+ [Karst.config.population_retry_limit, budget_remaining].min
235
+ end
236
+
237
+ # The whole retry stage may never issue more requests than an ordinary
238
+ # sweep already may, so enabling populations at most doubles the cost
239
+ # of an analysis regardless of how many are configured.
240
+ def budget_remaining
241
+ Karst.config.access_sweep_limit - @requests_used
242
+ end
243
+
244
+ def usable?(outcomes)
245
+ policy = Karst.config.usable_access_outcome
246
+ outcomes.any? { |outcome| policy.call(outcome) }
247
+ end
248
+
249
+ def record_tried(records)
250
+ records.each { |record| @tried_keys[identity_key(record)] = true }
251
+ end
252
+
253
+ # Identity by model plus primary key, so the same row arriving as two
254
+ # separate Active Record instances (once from the sample, once from a
255
+ # population) is recognised as already tested. Falls back to object
256
+ # identity for anything without an id, which is never worse than the
257
+ # previous behaviour of always re-probing.
258
+ def identity_key(record)
259
+ id = record.respond_to?(:id) ? record.id : nil
260
+ id.nil? ? [nil, record.object_id] : [record.class.name, id]
261
+ end
262
+ end
263
+ # rubocop:enable Metrics/ClassLength
264
+ end
265
+ end