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