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,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "access/principal_source"
4
+ require_relative "access/approved_populations"
5
+ require_relative "access/selected_principal_sources"
6
+ require_relative "identity/devise_support"
7
+
8
+ module Karst
9
+ # Raised when an initializer sets a configuration option Karst has removed.
10
+ # A NoMethodError subclass, so a removed option fails exactly like an
11
+ # ordinary typo would -- but with a message naming the removal and what
12
+ # replaced it. Karst never reinterprets a removed option as something else.
13
+ class RemovedConfiguration < NoMethodError; end
14
+
15
+ # Process-level settings that control Karst's implemented behavior.
16
+ #
17
+ # An ordinary application sets none of this. A single-model Devise app is
18
+ # inferred outright, candidate populations are approved locally at
19
+ # /karst/populations rather than written here, and every limit below
20
+ # already has a bounded default. `enabled` is not normal configuration
21
+ # either -- it is an off switch for exceptional cases (a shared
22
+ # development environment, a CI job that boots Rails but must never run
23
+ # Karst), already true by default wherever it should be. What remains
24
+ # below is for exceptional applications: custom (non-Devise)
25
+ # authentication, identity spread across several models, and a handful of
26
+ # deliberately unprominent bounds. See docs/advanced-configuration.md.
27
+ class Configuration
28
+ # None of these are normal configuration. `enabled` is an off switch for
29
+ # the exceptional case where Karst's default (on in development/test) is
30
+ # wrong for this environment. The rest are escape hatches for custom
31
+ # (non-Devise) authentication -- principals, assume_identity,
32
+ # clear_identity, principal_label, and the browser Test-as pair -- and
33
+ # are documented as such.
34
+ attr_accessor :enabled, :principals, :assume_identity, :clear_identity, :principal_label,
35
+ :assume_browser_identity, :clear_browser_identity
36
+
37
+ # principal_populations is an escape hatch (committed, reviewable
38
+ # populations for CI); the four bounds after it are advanced tuning an
39
+ # ordinary developer should never need to see.
40
+ #
41
+ # configured_principal_sources is internal: what the application
42
+ # explicitly configured, before Devise inference or local approvals are
43
+ # folded in. Read only by Karst::Identity to distinguish "configured"
44
+ # from "inferred"; not part of the documented configuration surface.
45
+ attr_reader :principal_populations, :access_sweep_limit, :population_retry_limit,
46
+ :principal_candidate_pool_size, :usable_access_outcome, :configured_principal_sources
47
+
48
+ # What Karst treats as "this user can use the page": an observed 200 with
49
+ # no contrary evidence. Older/custom outcome-like values may expose only
50
+ # a status; an absent optional evidence field means Karst did not observe
51
+ # that evidence, just as an explicit nil does, and must not make the
52
+ # policy itself crash.
53
+ DEFAULT_USABLE_ACCESS_OUTCOME = lambda do |outcome|
54
+ unobserved = ->(attribute) { !outcome.respond_to?(attribute) || outcome.public_send(attribute).nil? }
55
+ outcome.status == 200 && unobserved.call(:exception_class) && unobserved.call(:halted_callback)
56
+ end
57
+
58
+ MAX_ACCESS_SWEEP_LIMIT = 100
59
+
60
+ # How many records a single candidate population may contribute when
61
+ # Karst automatically retries it after an ordinary sample found nothing
62
+ # usable (see Karst::Access::Search). Kept deliberately small: the point
63
+ # of a population retry is to answer "does this population reach the
64
+ # behavior at all," which one or two records already settle -- not to
65
+ # survey the population. The total number of extra requests a retry may
66
+ # issue is bounded separately, by access_sweep_limit, so adding
67
+ # populations can never make an analysis cost more than roughly twice an
68
+ # ordinary sweep.
69
+ MAX_POPULATION_RETRY_LIMIT = 10
70
+
71
+ # Conservative hard ceiling on how many recent principals a
72
+ # representative-sampling candidate pool may ever cover. This bounds the
73
+ # cost of the one bounded pool query the sampler issues, independent of
74
+ # how large the underlying table actually is.
75
+ MAX_PRINCIPAL_CANDIDATE_POOL_SIZE = 10_000
76
+
77
+ # Options Karst used to expose, mapped to what an application should do
78
+ # instead. Karst is pre-1.0 and prefers a clean surface to accumulated
79
+ # accidental complexity -- but a removed option must say so out loud
80
+ # rather than be silently ignored or quietly reinterpreted.
81
+ REMOVED = {
82
+ buffer_size: "runtime SQL capture was removed; Karst reports evidence from the requests it " \
83
+ "actually runs, and no longer keeps a process-wide sql.active_record buffer",
84
+ principal_dimensions: "sampling states are derived from the schema automatically; there is " \
85
+ "nothing to declare, and rare users are reached through candidate " \
86
+ "populations approved at /karst/populations",
87
+ artifact_source: "artifact scenarios were removed; Karst analyzes routes, not record sweeps",
88
+ access_scenario: "artifact scenarios were removed; Karst analyzes routes, not record sweeps"
89
+ }.freeze
90
+
91
+ def initialize
92
+ @enabled = defined?(Rails) && Rails.respond_to?(:env) ? Rails.env.development? || Rails.env.test? : false
93
+ @access_sweep_limit = 25
94
+ @principal_candidate_pool_size = 1_000
95
+ @population_retry_limit = 3
96
+ @usable_access_outcome = DEFAULT_USABLE_ACCESS_OUTCOME
97
+ @principal_populations = {}
98
+ @configured_principal_sources = nil
99
+ %i[@principals @assume_identity @clear_identity @principal_label
100
+ @assume_browser_identity @clear_browser_identity].each { |hook| instance_variable_set(hook, nil) }
101
+ end
102
+
103
+ # An application-authored hint about meaningful candidate populations for
104
+ # whatever config.principals returns -- a Hash of name => zero-argument
105
+ # callable, each expected to return an ActiveRecord::Relation scoped to
106
+ # that same model, for example:
107
+ #
108
+ # config.principal_populations = {
109
+ # system_admins: -> { User.system_admins },
110
+ # auditors: -> { User.auditors }
111
+ # }
112
+ #
113
+ # This is the committed-to-source form of what /karst/populations already
114
+ # captures locally, and is needed only where machine-local approval state
115
+ # is deliberately not consulted (CI) or where populations should be
116
+ # reviewable code. Karst never infers that a population grants access or
117
+ # produces any UI state; it only tries records from it (see
118
+ # Karst::Access::PrincipalSampler/CandidatePopulation). nil/{} (the
119
+ # default) considers no configured populations at all. Karst does not
120
+ # attempt to verify that a callable's body is a "real" Rails named scope
121
+ # -- it only checks what calling it actually returns. Applications
122
+ # representing identity as more than one model configure populations per
123
+ # source instead (see config.principal_sources).
124
+ def principal_populations=(populations)
125
+ @principal_populations = Access::PrincipalSource.normalize_populations(:default, populations)
126
+ end
127
+
128
+ def principal_sources=(sources)
129
+ @configured_principal_sources = Access::PrincipalSource.normalize(sources)
130
+ end
131
+
132
+ # The effective, normalized principal population(s) Karst may sample
133
+ # from or resolve into: explicit config.principal_sources when
134
+ # configured, otherwise a bare config.principals (plus any
135
+ # config.principal_populations) wrapped as one implicit :default source,
136
+ # otherwise -- when neither is configured -- one inferred Devise model
137
+ # wrapped the same way (see Karst::Identity::DeviseSupport), otherwise --
138
+ # with several Devise models and nothing explicit -- whatever a
139
+ # developer has locally selected at /karst (see
140
+ # Access::SelectedPrincipalSources, one source per selected model, each
141
+ # keyed by its own Devise scope), or nil when none of those apply. A
142
+ # Hash of Symbol => PrincipalSource.
143
+ #
144
+ # This is also where locally approved discovered populations (see
145
+ # Karst::Access::ApprovedPopulations) join the effective configuration,
146
+ # after each source's own explicitly configured populations. Every
147
+ # adapter -- the panel, `bin/rails karst:verify`, the MCP verify_access
148
+ # tool -- reads this one method, so none of them knows or can diverge on
149
+ # what a "approved population" is. Resolved on every call rather than
150
+ # memoized: an approval revoked, or a scope deleted, must stop being
151
+ # executed on the next analysis without a server restart.
152
+ def principal_sources
153
+ Access::ApprovedPopulations.merge(configured_or_inferred_sources)
154
+ end
155
+
156
+ def access_sweep_limit=(value)
157
+ unless value.is_a?(Integer) && value.positive? && value <= MAX_ACCESS_SWEEP_LIMIT
158
+ raise ArgumentError, "access_sweep_limit must be between 1 and #{MAX_ACCESS_SWEEP_LIMIT}"
159
+ end
160
+
161
+ @access_sweep_limit = value
162
+ end
163
+
164
+ def principal_candidate_pool_size=(value)
165
+ unless value.is_a?(Integer) && value.positive? && value <= MAX_PRINCIPAL_CANDIDATE_POOL_SIZE
166
+ raise ArgumentError,
167
+ "principal_candidate_pool_size must be between 1 and #{MAX_PRINCIPAL_CANDIDATE_POOL_SIZE}"
168
+ end
169
+
170
+ @principal_candidate_pool_size = value
171
+ end
172
+
173
+ def population_retry_limit=(value)
174
+ unless value.is_a?(Integer) && value.positive? && value <= MAX_POPULATION_RETRY_LIMIT
175
+ raise ArgumentError, "population_retry_limit must be between 1 and #{MAX_POPULATION_RETRY_LIMIT}"
176
+ end
177
+
178
+ @population_retry_limit = value
179
+ end
180
+
181
+ def usable_access_outcome=(policy)
182
+ raise ArgumentError, "usable_access_outcome must be callable" unless policy.respond_to?(:call)
183
+
184
+ @usable_access_outcome = policy
185
+ end
186
+
187
+ private
188
+
189
+ # A removed option fails loudly and says what replaced it, rather than
190
+ # reaching Ruby's generic "undefined method" and leaving a developer to
191
+ # guess whether Karst renamed, moved, or silently ignored it.
192
+ def method_missing(name, *)
193
+ removal = REMOVED[name.to_s.delete_suffix("=").to_sym]
194
+ return super unless removal
195
+
196
+ raise RemovedConfiguration,
197
+ "config.#{name.to_s.delete_suffix('=')} was removed from Karst: #{removal}"
198
+ end
199
+
200
+ def respond_to_missing?(name, include_private = false)
201
+ REMOVED.key?(name.to_s.delete_suffix("=").to_sym) || super
202
+ end
203
+
204
+ def configured_or_inferred_sources
205
+ return @configured_principal_sources if @configured_principal_sources
206
+ return default_principal_source(@principals) if @principals
207
+
208
+ inferred = Identity::DeviseSupport.unambiguous_mapping
209
+ return default_principal_source(-> { inferred.model.all }) if inferred
210
+
211
+ # Several Devise models and nothing explicit: fall back to whatever a
212
+ # developer has locally selected at /karst (see
213
+ # Access::SelectedPrincipalSources), still nil when nothing has been
214
+ # selected or every selection has gone stale -- Karst never guesses.
215
+ Access::SelectedPrincipalSources.sources
216
+ end
217
+
218
+ def default_principal_source(records)
219
+ { default: Access::PrincipalSource.new(name: :default, records: records,
220
+ populations: @principal_populations) }
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "active_support/isolated_execution_state"
5
+ rescue LoadError
6
+ # Rails 6.1 does not ship this file (added in Rails 7.0). The thread-local
7
+ # fallback below (Karst::ExecutionContext::ThreadLocalStore) stands in for
8
+ # it on that series.
9
+ end
10
+
11
+ module Karst
12
+ # Request-local correlation storage used by the page badge and the RSpec
13
+ # scenario observer to carry evidence from a notification callback (fired
14
+ # nested inside a Rack call or an RSpec example) back out to the code that
15
+ # reads it, without a global mutable variable that would let concurrent
16
+ # requests or examples cross-contaminate each other.
17
+ #
18
+ # Modern Rails already solves exactly this with
19
+ # ActiveSupport::IsolatedExecutionState, so Karst simply delegates to it
20
+ # when present. Rails 6.1 does not provide that API, so Karst falls back to
21
+ # ThreadLocalStore, a plain per-Thread Hash reached through
22
+ # Thread#thread_variable_get/set -- never Thread#[]/[]=, which are
23
+ # fiber-local and would silently miss context under a Fiber scheduler.
24
+ # This mirrors ActiveSupport::IsolatedExecutionState's own default :thread
25
+ # isolation level: storage is shared by every Fiber running on one OS
26
+ # thread, not isolated per Fiber. Karst's own usage (one badge or spec
27
+ # correlation captured and read back within a single synchronous
28
+ # request/example) never spans multiple concurrently-scheduled Fibers, so
29
+ # this fallback has no observable effect on Karst's supported behavior. It
30
+ # is documented here so a future caller does not assume Fiber isolation
31
+ # this fallback cannot provide.
32
+ module ExecutionContext
33
+ # Deliberately its own class, rather than inlined into
34
+ # ExecutionContext's own singleton methods, so its behavior is directly
35
+ # testable on every supported Ruby regardless of which backend
36
+ # ExecutionContext itself selects in a given process.
37
+ class ThreadLocalStore
38
+ THREAD_VARIABLE = :karst_execution_context
39
+ private_constant :THREAD_VARIABLE
40
+
41
+ def [](key)
42
+ store[key]
43
+ end
44
+
45
+ def []=(key, value)
46
+ store[key] = value
47
+ end
48
+
49
+ def delete(key)
50
+ store.delete(key)
51
+ end
52
+
53
+ private
54
+
55
+ def store
56
+ Thread.current.thread_variable_get(THREAD_VARIABLE) ||
57
+ Thread.current.thread_variable_set(THREAD_VARIABLE, {})
58
+ end
59
+ end
60
+ private_constant :ThreadLocalStore
61
+
62
+ BACKEND = if defined?(ActiveSupport::IsolatedExecutionState)
63
+ ActiveSupport::IsolatedExecutionState
64
+ else
65
+ ThreadLocalStore.new
66
+ end
67
+ private_constant :BACKEND
68
+
69
+ class << self
70
+ def [](key)
71
+ BACKEND[key]
72
+ end
73
+
74
+ def []=(key, value)
75
+ BACKEND[key] = value
76
+ end
77
+
78
+ def delete(key)
79
+ BACKEND.delete(key)
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../value"
4
+
5
+ module Karst
6
+ module Identity
7
+ # Reads Devise's own routing-registered mapping metadata to determine
8
+ # which model(s) Devise protects and which Warden scope each one uses --
9
+ # never by scanning ObjectSpace, guessing from a model's name (User,
10
+ # Account, ...), or inferring from column names (encrypted_password,
11
+ # email, ...).
12
+ #
13
+ # `Devise.mappings` is populated by `devise_for` in config/routes.rb (the
14
+ # same metadata Devise itself relies on for `authenticate_user!`,
15
+ # `current_user`, and friends), so by the time a real request reaches
16
+ # Karst, every conventional Devise application already has it populated.
17
+ module DeviseSupport
18
+ # One Devise-registered model/Warden-scope pair, straight from
19
+ # Devise's own mapping -- never guessed.
20
+ Mapping = Value.define(:model, :scope)
21
+
22
+ class << self
23
+ # Returns the sole authentication key Devise declares for +model+.
24
+ # Multiple keys are intentionally treated as ambiguous: Karst cannot
25
+ # know which value is the useful login identity (or whether the
26
+ # combination itself is sensitive).
27
+ def authentication_key_for(model)
28
+ mapping = mapping_for(model)
29
+ return nil unless mapping && model.respond_to?(:authentication_keys)
30
+
31
+ keys = Array(model.authentication_keys).filter_map { |key| normalized_key(key) }.uniq
32
+ keys.first if keys.size == 1
33
+ rescue StandardError
34
+ nil
35
+ end
36
+
37
+ def available?
38
+ !!(defined?(Devise) && Devise.respond_to?(:mappings))
39
+ end
40
+
41
+ # Every Devise-registered mapping currently known to the framework.
42
+ # Empty when Devise is unavailable or has registered nothing yet.
43
+ def mappings
44
+ return [] unless available?
45
+
46
+ Devise.mappings.values.filter_map { |mapping| build(mapping) }
47
+ rescue StandardError
48
+ []
49
+ end
50
+
51
+ # The single unambiguous Devise mapping, or nil when there are zero
52
+ # or more than one -- multiple Devise models are a deliberate
53
+ # ambiguity boundary Karst never guesses across.
54
+ def unambiguous_mapping
55
+ candidates = mappings
56
+ candidates.first if candidates.size == 1
57
+ end
58
+
59
+ # The Devise mapping for one specific model class (including a
60
+ # subclass of a mapped model, e.g. STI), if Devise itself registers
61
+ # it. Used once a principal model is already known -- from an actual
62
+ # principal instance or an explicitly configured source -- so this
63
+ # never has to guess which of several Devise models is intended.
64
+ def mapping_for(model)
65
+ return nil unless model.is_a?(Class)
66
+
67
+ mappings.find { |mapping| model <= mapping.model }
68
+ end
69
+
70
+ private
71
+
72
+ def normalized_key(key)
73
+ key.to_sym
74
+ rescue StandardError
75
+ nil
76
+ end
77
+
78
+ def build(devise_mapping)
79
+ model = devise_mapping.to if devise_mapping.respond_to?(:to)
80
+ scope = devise_mapping.name if devise_mapping.respond_to?(:name)
81
+ return nil unless model.is_a?(Class) && scope
82
+
83
+ Mapping.new(model: model, scope: scope)
84
+ rescue StandardError
85
+ nil
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../execution_context"
4
+
5
+ module Karst
6
+ module Identity
7
+ # Optional convenience for a request-like object whose Rack environment
8
+ # already contains a Warden proxy. Warden cannot be bootstrapped safely
9
+ # from a bare ActionDispatch::Integration::Session, so applications using
10
+ # those sessions should configure explicit hooks (usually test endpoints).
11
+ #
12
+ # `scope:` is the Warden/Devise scope (e.g. `:user`, `:admin`) this
13
+ # adapter operates under. When known (see DeviseSupport), it is always
14
+ # passed explicitly rather than relying on Warden's own default scope --
15
+ # a Devise app almost always protects routes with a specific scope, and
16
+ # guessing wrong would silently authenticate under the wrong one. When
17
+ # nil, `set_user`/`logout` are called without a scope, matching Warden's
18
+ # own default behavior for a plain, non-Devise Warden setup.
19
+ class WardenAdapter
20
+ # Karst::Identity.with calls #assume before the caller's own block ever
21
+ # issues a request, so no env["warden"] proxy can exist yet -- Warden
22
+ # only builds one inside Warden::Manager#call, once a request actually
23
+ # reaches it (see Access::ProbeApplication). #assume therefore queues
24
+ # the principal here instead of reaching for a proxy that cannot exist,
25
+ # and .install_hook! below applies it the moment this exact thread's
26
+ # own probe request reaches Warden -- the same queue-for-next-request
27
+ # idiom Warden::Test::Helpers#login_as and
28
+ # Devise::Test::IntegrationHelpers#sign_in use for the identical
29
+ # problem in integration tests. Karst uses its own thread-local
30
+ # ExecutionContext instead of Warden's own global Test::Helpers queue,
31
+ # so this stays scoped to the exact thread that queued it -- a
32
+ # concurrent unrelated request on another thread of a real development
33
+ # server never observes it.
34
+ PENDING_PRINCIPAL_KEY = :karst_warden_pending_principal
35
+ private_constant :PENDING_PRINCIPAL_KEY
36
+
37
+ class << self
38
+ # Registered once per process (Warden::Hooks callbacks are
39
+ # class-level, so this necessarily runs for every Warden::Manager
40
+ # instance, including the host application's own) and is a no-op
41
+ # unless this exact thread has a principal queued. The real Warden
42
+ # gem always provides Warden::Manager.on_request (Warden::Hooks is
43
+ # core, not an extra); the guard exists only so a minimal
44
+ # Warden::Manager stand-in (a bare stub Class, as several existing
45
+ # specs use to exercise ambiguous-Devise-setup paths that never
46
+ # reach this code) can't crash Karst with a NoMethodError instead of
47
+ # deferral simply staying unavailable, same as before this existed.
48
+ def hook_installable?
49
+ defined?(Warden::Manager) && Warden::Manager.respond_to?(:on_request)
50
+ end
51
+
52
+ def install_hook!
53
+ return if @installed
54
+
55
+ @installed = true
56
+ Warden::Manager.on_request do |proxy|
57
+ pending = Karst::ExecutionContext.delete(PENDING_PRINCIPAL_KEY)
58
+ next unless pending
59
+
60
+ principal, scope = pending
61
+ scope ? proxy.set_user(principal, scope: scope) : proxy.set_user(principal)
62
+ end
63
+ end
64
+ end
65
+
66
+ attr_reader :scope
67
+
68
+ def initialize(scope: nil)
69
+ @scope = scope
70
+ end
71
+
72
+ def assume(session, principal)
73
+ proxy = existing_proxy(session)
74
+ return apply(proxy, principal) if proxy
75
+ return queue_for_next_request(principal) if deferrable?(session)
76
+
77
+ raise Unavailable, "the session has no initialized Warden proxy; configure identity hooks"
78
+ end
79
+
80
+ def clear(session)
81
+ proxy = proxy_for(session)
82
+ @scope ? proxy.logout(@scope) : proxy.logout
83
+ ensure
84
+ Karst::ExecutionContext.delete(PENDING_PRINCIPAL_KEY)
85
+ end
86
+
87
+ private
88
+
89
+ def apply(proxy, principal)
90
+ @scope ? proxy.set_user(principal, scope: @scope) : proxy.set_user(principal)
91
+ end
92
+
93
+ # Deferral only ever helps a real pre-dispatch integration session --
94
+ # something that will itself later be routed through Warden::Manager,
95
+ # letting .install_hook! consume the queued principal when that
96
+ # happens. A bare Hash (the shape every other caller in this codebase,
97
+ # and custom identity hooks, already use to carry an env["warden"] key
98
+ # directly) has no such future dispatch to defer to, so queuing for it
99
+ # would silently do nothing forever -- raising immediately, as before,
100
+ # is the correct and only honest outcome there.
101
+ def deferrable?(session)
102
+ !session.is_a?(Hash) && session.respond_to?(:request) && self.class.hook_installable?
103
+ end
104
+
105
+ def queue_for_next_request(principal)
106
+ self.class.install_hook!
107
+ Karst::ExecutionContext[PENDING_PRINCIPAL_KEY] = [principal, @scope]
108
+ end
109
+
110
+ def proxy_for(session)
111
+ existing_proxy(session) ||
112
+ (raise Unavailable, "the session has no initialized Warden proxy; configure identity hooks")
113
+ end
114
+
115
+ def existing_proxy(session)
116
+ env = rack_env(session)
117
+ proxy = env && env["warden"]
118
+ proxy if proxy.respond_to?(:set_user) && proxy.respond_to?(:logout)
119
+ end
120
+
121
+ def rack_env(session)
122
+ return session if session.is_a?(Hash)
123
+ return session.env if session.respond_to?(:env)
124
+ return session.request.env if session.respond_to?(:request) && session.request.respond_to?(:env)
125
+
126
+ nil
127
+ end
128
+ end
129
+ end
130
+ end