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.
- checksums.yaml +7 -0
- data/ARCHITECTURE.md +59 -0
- data/CHANGELOG.md +67 -0
- data/CODE_OF_CONDUCT.md +29 -0
- data/CONTRIBUTING.md +45 -0
- data/LICENSE +21 -0
- data/README.md +140 -0
- data/SECURITY.md +11 -0
- data/docs/advanced-configuration.md +188 -0
- data/lib/generators/karst/install/install_generator.rb +88 -0
- data/lib/generators/karst/install/templates/karst_identity_controller.rb +19 -0
- data/lib/generators/karst/install/templates/karst_initializer.rb +18 -0
- data/lib/karst/access/approved_populations.rb +128 -0
- data/lib/karst/access/candidate_population.rb +86 -0
- data/lib/karst/access/database_isolation.rb +62 -0
- data/lib/karst/access/population_approvals.rb +195 -0
- data/lib/karst/access/population_config_snippet.rb +67 -0
- data/lib/karst/access/population_discovery.rb +271 -0
- data/lib/karst/access/population_preview.rb +83 -0
- data/lib/karst/access/principal_sampler.rb +241 -0
- data/lib/karst/access/principal_selection.rb +90 -0
- data/lib/karst/access/principal_source.rb +143 -0
- data/lib/karst/access/principal_source_selection.rb +161 -0
- data/lib/karst/access/probe_application.rb +164 -0
- data/lib/karst/access/resource_evidence.rb +233 -0
- data/lib/karst/access/search.rb +265 -0
- data/lib/karst/access/selected_principal_sources.rb +65 -0
- data/lib/karst/access/sensitive_attribute_names.rb +26 -0
- data/lib/karst/access/sweep.rb +198 -0
- data/lib/karst/cli/verification.rb +182 -0
- data/lib/karst/configuration.rb +223 -0
- data/lib/karst/execution_context.rb +83 -0
- data/lib/karst/identity/devise_support.rb +90 -0
- data/lib/karst/identity/warden_adapter.rb +130 -0
- data/lib/karst/identity.rb +479 -0
- data/lib/karst/mcp/server.rb +63 -0
- data/lib/karst/mcp/verify_access_tool.rb +68 -0
- data/lib/karst/railtie.rb +30 -0
- data/lib/karst/spec/catalog.rb +199 -0
- data/lib/karst/spec/example_observation.rb +31 -0
- data/lib/karst/spec/observer.rb +300 -0
- data/lib/karst/spec/principal.rb +12 -0
- data/lib/karst/spec/reporter.rb +83 -0
- data/lib/karst/spec/request_observation.rb +38 -0
- data/lib/karst/spec/scenario.rb +65 -0
- data/lib/karst/value.rb +35 -0
- data/lib/karst/version.rb +5 -0
- data/lib/karst/web/badge.rb +183 -0
- data/lib/karst/web/browser_identity.rb +103 -0
- data/lib/karst/web/locality.rb +64 -0
- data/lib/karst/web/middleware.rb +377 -0
- data/lib/karst/web/panel.rb +699 -0
- data/lib/karst/web/populations_panel.rb +391 -0
- data/lib/karst/web/route_lookup.rb +65 -0
- data/lib/karst.rb +56 -0
- data/lib/rails/commands/karst/boot.rb +24 -0
- data/lib/rails/commands/karst/mcp/mcp_command.rb +26 -0
- data/lib/rails/commands/karst/verify/verify_command.rb +39 -0
- data/lib/tasks/karst.rake +34 -0
- 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
|