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,479 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "value"
|
|
4
|
+
require_relative "identity/devise_support"
|
|
5
|
+
require_relative "identity/warden_adapter"
|
|
6
|
+
require_relative "access/selected_principal_sources"
|
|
7
|
+
|
|
8
|
+
module Karst
|
|
9
|
+
# Framework-neutral identity seam for controlled probes. It deliberately
|
|
10
|
+
# does not discover or enumerate principals; callers own that policy.
|
|
11
|
+
#
|
|
12
|
+
# For a conventional single-model Devise application, Karst can also infer
|
|
13
|
+
# everything below automatically from Devise's own routing metadata and
|
|
14
|
+
# Warden's public runtime API -- see DeviseSupport and WardenAdapter.
|
|
15
|
+
# Explicit configuration (config.principals/config.principal_sources,
|
|
16
|
+
# config.assume_identity/config.clear_identity,
|
|
17
|
+
# config.assume_browser_identity/config.clear_browser_identity) always
|
|
18
|
+
# overrides inference; inference never partially combines with explicit
|
|
19
|
+
# configuration for the same seam.
|
|
20
|
+
# rubocop:disable Metrics/ModuleLength
|
|
21
|
+
module Identity
|
|
22
|
+
class Error < StandardError; end
|
|
23
|
+
class Unavailable < Error; end
|
|
24
|
+
class ConfigurationError < Error; end
|
|
25
|
+
|
|
26
|
+
# authentication_* is presentation-only evidence. Machine serializers
|
|
27
|
+
# deliberately ignore it; it exists so local human interfaces can be
|
|
28
|
+
# useful without broadening JSON/MCP disclosure.
|
|
29
|
+
PrincipalDescriptor = Value.define(:model_name, :id, :display_label, :authentication_key,
|
|
30
|
+
:authentication_identifier)
|
|
31
|
+
|
|
32
|
+
# Compact, inspectable report of why Karst's zero-config Devise/Warden
|
|
33
|
+
# path is or isn't active. `status` is one of:
|
|
34
|
+
#
|
|
35
|
+
# :ready_automatic -- principal source, probe identity, and browser
|
|
36
|
+
# identity are all inferred; no configuration
|
|
37
|
+
# required.
|
|
38
|
+
# :ready_mixed -- at least one of principal source / probe
|
|
39
|
+
# identity / browser identity is explicitly
|
|
40
|
+
# configured and the rest are safely inferred
|
|
41
|
+
# (e.g. an explicit config.principals selecting
|
|
42
|
+
# one of several Devise models).
|
|
43
|
+
# :ready_explicit -- principal source, probe identity, and browser
|
|
44
|
+
# identity are all explicitly configured.
|
|
45
|
+
# :ambiguous -- more than one Devise model was detected and no
|
|
46
|
+
# explicit config.principals/principal_sources
|
|
47
|
+
# selects one.
|
|
48
|
+
# :unavailable -- Karst could not identify enough of an
|
|
49
|
+
# authentication integration to run the primary
|
|
50
|
+
# workflow without explicit configuration.
|
|
51
|
+
#
|
|
52
|
+
# `message` is nil whenever the caller's own local hint text already
|
|
53
|
+
# says everything Karst can usefully add (the two ready states, and
|
|
54
|
+
# :unavailable with no principal source at all -- every hint call site
|
|
55
|
+
# already has its own "nothing is configured" wording for that). It is
|
|
56
|
+
# populated only when Karst has something more specific to say: which
|
|
57
|
+
# Devise models are ambiguous, or that a principal source exists but
|
|
58
|
+
# probe/browser identity still couldn't be wired up automatically.
|
|
59
|
+
SetupState = Value.define(:status, :message)
|
|
60
|
+
|
|
61
|
+
# Delegates identity operations to the application's configured hooks.
|
|
62
|
+
class ConfiguredAdapter
|
|
63
|
+
def initialize(assume_hook, clear_hook)
|
|
64
|
+
@assume_hook = assume_hook
|
|
65
|
+
@clear_hook = clear_hook
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def assume(session, principal)
|
|
69
|
+
@assume_hook.call(session, principal)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def clear(session)
|
|
73
|
+
@clear_hook.call(session)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
private_constant :ConfiguredAdapter
|
|
77
|
+
|
|
78
|
+
# rubocop:disable Metrics/ClassLength
|
|
79
|
+
class << self
|
|
80
|
+
def principals
|
|
81
|
+
source = Karst.config.principals
|
|
82
|
+
return called_principal_source(source) if source
|
|
83
|
+
|
|
84
|
+
inferred = DeviseSupport.unambiguous_mapping
|
|
85
|
+
return inferred.model.all if inferred
|
|
86
|
+
|
|
87
|
+
raise Unavailable, "no principal source is configured"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The effective, normalized principal population(s): a Hash of Symbol
|
|
91
|
+
# => Karst::Access::PrincipalSource, covering an explicit
|
|
92
|
+
# config.principal_sources, a bare config.principals (wrapped as one
|
|
93
|
+
# implicit :default source), and -- when neither is configured -- one
|
|
94
|
+
# inferred Devise model (see Karst::Configuration#principal_sources).
|
|
95
|
+
# Every multi-source-aware caller (Identity.resolve,
|
|
96
|
+
# Access::PrincipalSelection, the panel) reads this instead of
|
|
97
|
+
# config.principals directly.
|
|
98
|
+
def principal_sources
|
|
99
|
+
sources = Karst.config.principal_sources
|
|
100
|
+
raise Unavailable, "no principal source is configured" unless sources
|
|
101
|
+
|
|
102
|
+
sources
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def with(session, principal)
|
|
106
|
+
active_adapter = adapter(principal)
|
|
107
|
+
# The hook may establish identity and then raise, so cleanup becomes
|
|
108
|
+
# mandatory before invoking it rather than only after it returns.
|
|
109
|
+
assumed = true
|
|
110
|
+
active_adapter.assume(session, principal)
|
|
111
|
+
yield
|
|
112
|
+
ensure
|
|
113
|
+
active_adapter.clear(session) if active_adapter && assumed
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def clear(session)
|
|
117
|
+
adapter.clear(session)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def describe(principal)
|
|
121
|
+
model_name = model_name_for(principal)
|
|
122
|
+
id = id_for(principal)
|
|
123
|
+
label_hook = Karst.config.principal_label
|
|
124
|
+
if label_hook && !label_hook.respond_to?(:call)
|
|
125
|
+
raise ConfigurationError, "config.principal_label must be callable"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
label, key, identifier = label_attributes(principal, model_name, id, label_hook)
|
|
129
|
+
PrincipalDescriptor.new(model_name: model_name, id: id, display_label: label,
|
|
130
|
+
authentication_key: key, authentication_identifier: identifier)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Resolves only principals exposed by a configured source. In
|
|
134
|
+
# particular, this never constantizes a submitted model name or
|
|
135
|
+
# performs an unrestricted model lookup.
|
|
136
|
+
#
|
|
137
|
+
# Tries each configured Karst::Access::PrincipalSource in order and
|
|
138
|
+
# returns the first match; a model name that does not belong to any
|
|
139
|
+
# configured source resolves nothing, without ever touching that
|
|
140
|
+
# source's records. For an Active Record relation/class source, a
|
|
141
|
+
# matching model name resolves through a scoped primary-key query
|
|
142
|
+
# against that exact relation instead of enumerating it -- a source
|
|
143
|
+
# may cover hundreds of thousands of rows, and this must stay a single
|
|
144
|
+
# bounded query regardless of table size. The relation's own WHERE
|
|
145
|
+
# clauses (tenant scoping, soft deletes, and so on) still apply, so a
|
|
146
|
+
# principal outside a configured relation is never resolved. A generic
|
|
147
|
+
# Enumerable source (no scoped-query capability) keeps the original
|
|
148
|
+
# enumerate-and-compare behavior, bounded to that one source.
|
|
149
|
+
def resolve(model_name:, id:)
|
|
150
|
+
principal_sources.each_value do |source|
|
|
151
|
+
resolved = resolve_within_source(source, model_name: model_name, id: id)
|
|
152
|
+
return resolved if resolved
|
|
153
|
+
end
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def browser_supported?
|
|
158
|
+
explicit_browser_hooks? || automatic_browser_identity_available?
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Returns the Devise/Warden scope this browser identity was actually
|
|
162
|
+
# assumed under (nil for explicit hooks, or for a non-Devise bare
|
|
163
|
+
# Warden proxy) -- see Karst::Web::BrowserIdentity, which retains it
|
|
164
|
+
# for the lifetime of the browser session so #clear_browser below never
|
|
165
|
+
# has to guess which of several selected sources produced the
|
|
166
|
+
# principal being cleared.
|
|
167
|
+
def assume_browser(request, principal)
|
|
168
|
+
raise Unavailable, "browser identity hooks are not configured" unless browser_supported?
|
|
169
|
+
|
|
170
|
+
if explicit_browser_hooks?
|
|
171
|
+
Karst.config.assume_browser_identity.call(request, principal)
|
|
172
|
+
nil
|
|
173
|
+
else
|
|
174
|
+
warden_adapter = inferred_adapter(principal)
|
|
175
|
+
warden_adapter.assume(request, principal)
|
|
176
|
+
warden_adapter.scope
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# `scope`, when given, is the exact scope #assume_browser returned for
|
|
181
|
+
# the identity actually being cleared (see
|
|
182
|
+
# Karst::Web::BrowserIdentity) -- used in preference to
|
|
183
|
+
# #scope_for_effective_source, which cannot always determine one on
|
|
184
|
+
# its own with no principal in hand and several selected sources. Karst
|
|
185
|
+
# still refuses to guess when neither is available.
|
|
186
|
+
def clear_browser(request, scope: nil)
|
|
187
|
+
raise Unavailable, "browser identity hooks are not configured" unless browser_supported?
|
|
188
|
+
|
|
189
|
+
if explicit_browser_hooks?
|
|
190
|
+
Karst.config.clear_browser_identity.call(request)
|
|
191
|
+
else
|
|
192
|
+
inferred_adapter(nil, scope: scope).clear(request)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# See SetupState above. Cheap and side-effect free: touches only
|
|
197
|
+
# already-established configuration/metadata plus, at most, calling a
|
|
198
|
+
# configured principals/principal_sources callable the same way the
|
|
199
|
+
# panel already does on every render to type-check its result (see
|
|
200
|
+
# Access::PrincipalSampler.representative_capable?) -- never to
|
|
201
|
+
# enumerate or query it.
|
|
202
|
+
def setup_state
|
|
203
|
+
return SetupState.new(status: :ambiguous, message: ambiguous_message) if ambiguous_principal_source?
|
|
204
|
+
return SetupState.new(status: :unavailable, message: nil) unless principal_source_ready?
|
|
205
|
+
return SetupState.new(status: :unavailable, message: unavailable_message) unless identity_channels_ready?
|
|
206
|
+
|
|
207
|
+
SetupState.new(status: ready_status, message: nil)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
def label_attributes(principal, model_name, id, label_hook)
|
|
213
|
+
return [label_hook.call(principal), nil, nil] if label_hook
|
|
214
|
+
|
|
215
|
+
key, identifier = authentication_identifier(principal)
|
|
216
|
+
default = "#{model_name} ##{id}"
|
|
217
|
+
[identifier ? "#{identifier} · #{default}" : default, key, identifier]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def authentication_identifier(principal)
|
|
221
|
+
key = DeviseSupport.authentication_key_for(principal.class)
|
|
222
|
+
return [nil, nil] unless key && principal.respond_to?(key)
|
|
223
|
+
|
|
224
|
+
value = principal.public_send(key)
|
|
225
|
+
return [nil, nil] if value.nil? || value.to_s.empty?
|
|
226
|
+
|
|
227
|
+
[key, value.to_s]
|
|
228
|
+
rescue StandardError
|
|
229
|
+
[nil, nil]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def resolve_within_source(source, model_name:, id:)
|
|
233
|
+
records = source.evaluate
|
|
234
|
+
relation = active_record_relation(records)
|
|
235
|
+
return resolve_scoped(relation, model_name: model_name, id: id) if relation
|
|
236
|
+
|
|
237
|
+
resolve_enumerated(records, model_name: model_name, id: id)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def active_record_relation(source)
|
|
241
|
+
return source if defined?(ActiveRecord::Relation) && source.is_a?(ActiveRecord::Relation)
|
|
242
|
+
return source.all if defined?(ActiveRecord::Base) && source.is_a?(Class) && source < ActiveRecord::Base
|
|
243
|
+
|
|
244
|
+
nil
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# A model-name mismatch is checked before ever touching the database:
|
|
248
|
+
# each source is trusted to name one authoritative model, so a request
|
|
249
|
+
# for a different model name is rejected without issuing a query
|
|
250
|
+
# rather than attempting (and failing) a primary-key lookup against
|
|
251
|
+
# the wrong table.
|
|
252
|
+
def resolve_scoped(relation, model_name:, id:)
|
|
253
|
+
klass = relation.klass
|
|
254
|
+
return nil unless model_name_for_klass(klass) == model_name.to_s
|
|
255
|
+
|
|
256
|
+
primary_key = klass.primary_key
|
|
257
|
+
return nil unless primary_key.is_a?(String)
|
|
258
|
+
|
|
259
|
+
relation.find_by(primary_key => id)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def resolve_enumerated(source, model_name:, id:)
|
|
263
|
+
source.each do |principal|
|
|
264
|
+
descriptor = describe(principal)
|
|
265
|
+
return principal if descriptor.model_name == model_name.to_s && descriptor.id.to_s == id.to_s
|
|
266
|
+
end
|
|
267
|
+
nil
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def identity_channels_ready?
|
|
271
|
+
(explicit_probe_hooks? || automatic_identity_available?) &&
|
|
272
|
+
(explicit_browser_hooks? || automatic_browser_identity_available?)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Only called once every channel is already known to be ready (see
|
|
276
|
+
# #identity_channels_ready? above), so this purely classifies *how*
|
|
277
|
+
# each one got there -- inferred, explicit, or a mix of both (see
|
|
278
|
+
# SetupState).
|
|
279
|
+
def ready_status
|
|
280
|
+
explicit = [!Karst.config.principals.nil?, explicit_probe_hooks?, explicit_browser_hooks?]
|
|
281
|
+
return :ready_automatic if explicit.none?
|
|
282
|
+
return :ready_explicit if explicit.all?
|
|
283
|
+
|
|
284
|
+
:ready_mixed
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def called_principal_source(source)
|
|
288
|
+
raise ConfigurationError, "config.principals must be callable" unless source.respond_to?(:call)
|
|
289
|
+
|
|
290
|
+
source.call
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def adapter(principal = nil)
|
|
294
|
+
assume_hook = Karst.config.assume_identity
|
|
295
|
+
clear_hook = Karst.config.clear_identity
|
|
296
|
+
return configured_adapter(assume_hook, clear_hook) if assume_hook || clear_hook
|
|
297
|
+
|
|
298
|
+
inferred_adapter(principal)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def configured_adapter(assume_hook, clear_hook)
|
|
302
|
+
unless assume_hook.respond_to?(:call) && clear_hook.respond_to?(:call)
|
|
303
|
+
raise ConfigurationError,
|
|
304
|
+
"config.assume_identity and config.clear_identity must both be callable"
|
|
305
|
+
end
|
|
306
|
+
ConfiguredAdapter.new(assume_hook, clear_hook)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Builds a Warden-backed adapter for the automatic Devise/Warden path.
|
|
310
|
+
# With a specific `principal`, scope comes straight from that
|
|
311
|
+
# principal's own class -- the most direct evidence available, and
|
|
312
|
+
# correct even when a source mixes multiple Devise-mapped subclasses,
|
|
313
|
+
# regardless of how many principal_sources are configured. Without one,
|
|
314
|
+
# `scope:` (when the caller already knows exactly which identity is
|
|
315
|
+
# being cleared -- see #clear_browser) wins; otherwise scope falls back
|
|
316
|
+
# to the single effective principal source's model -- see
|
|
317
|
+
# #scope_for_effective_source. Devise loaded with no resolvable scope
|
|
318
|
+
# refuses to guess rather than risk operating under the wrong Warden
|
|
319
|
+
# scope.
|
|
320
|
+
def inferred_adapter(principal, scope: nil)
|
|
321
|
+
raise Unavailable, "no identity hooks are configured and Warden is unavailable" unless warden_available?
|
|
322
|
+
return WardenAdapter.new unless DeviseSupport.available?
|
|
323
|
+
|
|
324
|
+
resolved = scope || (principal ? DeviseSupport.mapping_for(principal.class)&.scope : scope_for_effective_source)
|
|
325
|
+
unless resolved
|
|
326
|
+
raise Unavailable,
|
|
327
|
+
"Karst could not determine this principal's Devise/Warden scope automatically; " \
|
|
328
|
+
"configure config.assume_identity/config.clear_identity " \
|
|
329
|
+
"(or config.assume_browser_identity/config.clear_browser_identity)"
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
WardenAdapter.new(scope: resolved)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Probe-identity eligibility. Preserves the pre-existing bare-Warden
|
|
336
|
+
# fallback (no Devise, no scope) for a non-Devise application already
|
|
337
|
+
# relying on Karst's isolated integration session -- see WardenAdapter.
|
|
338
|
+
def automatic_identity_available?
|
|
339
|
+
return false unless warden_available?
|
|
340
|
+
return true unless DeviseSupport.available?
|
|
341
|
+
|
|
342
|
+
every_effective_source_scoped?
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Browser-identity eligibility. Deliberately stricter than probe
|
|
346
|
+
# eligibility above: automatically mutating the developer's *real*
|
|
347
|
+
# browser session is only safe once Karst can prove the Devise
|
|
348
|
+
# scope -- a bare bootstrapped Warden proxy with no scope is never
|
|
349
|
+
# enough here, unlike the isolated probe session.
|
|
350
|
+
def automatic_browser_identity_available?
|
|
351
|
+
return false unless warden_available? && DeviseSupport.available?
|
|
352
|
+
|
|
353
|
+
every_effective_source_scoped?
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# True once every currently effective principal source resolves to its
|
|
357
|
+
# own known Devise/Warden scope -- the single-source case this always
|
|
358
|
+
# covered (see #scope_for_effective_source), plus several sources when
|
|
359
|
+
# each is independently Devise-mapped, exactly what
|
|
360
|
+
# Access::SelectedPrincipalSources builds from a local multi-model
|
|
361
|
+
# selection. Per-principal scope resolution (#inferred_adapter with a
|
|
362
|
+
# principal already in hand) is already correct for any number of
|
|
363
|
+
# sources; this governs only "no principal yet" eligibility and the
|
|
364
|
+
# bare-clear fallback, so it still refuses to guess when a source is
|
|
365
|
+
# not provably Devise-scoped.
|
|
366
|
+
def every_effective_source_scoped?
|
|
367
|
+
sources = safe_principal_sources
|
|
368
|
+
return false unless sources&.any?
|
|
369
|
+
|
|
370
|
+
sources.values.all? { |source| devise_scope_for_source(source) }
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def devise_scope_for_source(source)
|
|
374
|
+
model = model_from_source(source)
|
|
375
|
+
model && DeviseSupport.mapping_for(model)&.scope
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def scope_for_effective_source
|
|
379
|
+
model = effective_principal_model
|
|
380
|
+
model && DeviseSupport.mapping_for(model)&.scope
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# The model backing the *single* effective principal source, when one
|
|
384
|
+
# can be determined without risk. Per-principal scope resolution (the
|
|
385
|
+
# common case, with an actual principal instance already in hand)
|
|
386
|
+
# never goes through this path at all, and is unaffected by how many
|
|
387
|
+
# sources are configured -- see #every_effective_source_scoped? above
|
|
388
|
+
# for the multi-source "no principal in hand" case.
|
|
389
|
+
def effective_principal_model
|
|
390
|
+
sources = safe_principal_sources
|
|
391
|
+
return nil unless sources && sources.size == 1
|
|
392
|
+
|
|
393
|
+
model_from_source(sources.values.first)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# A relation/class, exactly the same type-check the panel already
|
|
397
|
+
# relies on for representative sampling, or the class of the first
|
|
398
|
+
# element of an already materialized Array (safe to inspect without
|
|
399
|
+
# issuing a query; evaluating the source callable itself is the same
|
|
400
|
+
# already-accepted pattern the panel uses every render, see
|
|
401
|
+
# Access::PrincipalSampler.representative_capable?). An unmaterialized
|
|
402
|
+
# Enumerable/lazy source yields nil rather than guessing further --
|
|
403
|
+
# automatic Warden scope resolution is unavailable there, by design
|
|
404
|
+
# (see Identity::DeviseSupport and README).
|
|
405
|
+
def model_from_source(source)
|
|
406
|
+
records = source.evaluate
|
|
407
|
+
active_record_model_from(records) || (records.first.class if records.is_a?(Array) && !records.empty?)
|
|
408
|
+
rescue StandardError
|
|
409
|
+
nil
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def active_record_model_from(records)
|
|
413
|
+
return records.klass if defined?(ActiveRecord::Relation) && records.is_a?(ActiveRecord::Relation)
|
|
414
|
+
return records if defined?(ActiveRecord::Base) && records.is_a?(Class) && records < ActiveRecord::Base
|
|
415
|
+
|
|
416
|
+
nil
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def safe_principal_sources
|
|
420
|
+
principal_sources
|
|
421
|
+
rescue Error
|
|
422
|
+
nil
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def warden_available?
|
|
426
|
+
defined?(Warden::Manager)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def explicit_probe_hooks?
|
|
430
|
+
!!(Karst.config.assume_identity || Karst.config.clear_identity)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def explicit_browser_hooks?
|
|
434
|
+
Karst.config.assume_browser_identity.respond_to?(:call) &&
|
|
435
|
+
Karst.config.clear_browser_identity.respond_to?(:call)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def ambiguous_principal_source?
|
|
439
|
+
return false if Karst.config.principals || Karst.config.configured_principal_sources
|
|
440
|
+
return false if Access::SelectedPrincipalSources.mappings.any?
|
|
441
|
+
|
|
442
|
+
DeviseSupport.mappings.size > 1
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def principal_source_ready?
|
|
446
|
+
!Karst.config.principals.nil? || !Karst.config.configured_principal_sources.nil? ||
|
|
447
|
+
!DeviseSupport.unambiguous_mapping.nil? || Access::SelectedPrincipalSources.mappings.any?
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def ambiguous_message
|
|
451
|
+
names = DeviseSupport.mappings.map { |mapping| mapping.model.name }.sort.join(", ")
|
|
452
|
+
"Karst detected multiple Devise models (#{names}). Select which one(s) to test at /karst, " \
|
|
453
|
+
"or configure config.principals/config.principal_sources explicitly."
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def unavailable_message
|
|
457
|
+
"Karst found a principal source but could not automatically wire up probe/browser identity for it. " \
|
|
458
|
+
"Configure config.assume_identity/config.clear_identity and " \
|
|
459
|
+
"config.assume_browser_identity/config.clear_browser_identity."
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def model_name_for(principal)
|
|
463
|
+
model_name_for_klass(principal.class)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def model_name_for_klass(klass)
|
|
467
|
+
klass.respond_to?(:model_name) ? klass.model_name.name.to_s : klass.name.to_s
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def id_for(principal)
|
|
471
|
+
raise ConfigurationError, "principal must expose an id" unless principal.respond_to?(:id)
|
|
472
|
+
|
|
473
|
+
principal.id
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
# rubocop:enable Metrics/ClassLength
|
|
477
|
+
end
|
|
478
|
+
# rubocop:enable Metrics/ModuleLength
|
|
479
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require_relative "verify_access_tool"
|
|
5
|
+
require_relative "../version"
|
|
6
|
+
|
|
7
|
+
module Karst
|
|
8
|
+
module Mcp
|
|
9
|
+
# Builds and runs Karst's stdio MCP server: one tool (VerifyAccessTool),
|
|
10
|
+
# no prompts, no resources, no other transport. The host Rails
|
|
11
|
+
# application must already be booted (see
|
|
12
|
+
# Rails::Command::Karst::Boot#boot_karst_application!, used by
|
|
13
|
+
# `bin/rails karst:mcp`) before this is built -- the server itself never
|
|
14
|
+
# boots or reboots the application, so one process serves every tool call
|
|
15
|
+
# for its lifetime against the one already-running application.
|
|
16
|
+
module Server
|
|
17
|
+
INSTRUCTIONS = <<~TEXT.strip
|
|
18
|
+
Karst verifies bounded runtime access to local paths in this Rails application.
|
|
19
|
+
|
|
20
|
+
Agent proposes: a hypothesis about who can reach a path.
|
|
21
|
+
Karst proves: it executes the real application, under real existing
|
|
22
|
+
identities, and reports observed evidence -- HTTP status, redirect,
|
|
23
|
+
halted callback, exception, and whether a usable outcome was found.
|
|
24
|
+
|
|
25
|
+
Call verify_access(path:, method:) to check one path. Karst does not
|
|
26
|
+
infer authorization rules, explain application code, or choose which
|
|
27
|
+
principal to try on your behalf -- it only reports what actually
|
|
28
|
+
happened when a real request was made under the application's own
|
|
29
|
+
configured identities.
|
|
30
|
+
TEXT
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
def build
|
|
34
|
+
MCP::Server.new(
|
|
35
|
+
name: "karst",
|
|
36
|
+
title: "Karst",
|
|
37
|
+
version: Karst::VERSION,
|
|
38
|
+
instructions: INSTRUCTIONS,
|
|
39
|
+
tools: [VerifyAccessTool],
|
|
40
|
+
configuration: MCP::Configuration.new(exception_reporter: method(:report_exception))
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Runs until the client closes stdin (or the process receives
|
|
45
|
+
# SIGINT). stdout is reserved for MCP protocol frames; anything Karst
|
|
46
|
+
# or the host application needs to say for local debugging goes to
|
|
47
|
+
# stderr instead, matching how `rails server`/`rails console` behave.
|
|
48
|
+
def run!
|
|
49
|
+
server = build
|
|
50
|
+
transport = MCP::Server::Transports::StdioTransport.new(server)
|
|
51
|
+
server.transport = transport
|
|
52
|
+
transport.open
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def report_exception(exception, _context)
|
|
58
|
+
warn("karst-mcp: #{exception.class}: #{exception.message}")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "mcp"
|
|
5
|
+
require_relative "../cli/verification"
|
|
6
|
+
|
|
7
|
+
module Karst
|
|
8
|
+
module Mcp
|
|
9
|
+
# The one MCP tool Karst exposes: a thin transport adapter over
|
|
10
|
+
# Karst::CLI::Verification#evidence, itself a thin adapter over
|
|
11
|
+
# Access::Search. This class owns no verification behavior of its own --
|
|
12
|
+
# it only shapes one MCP request into a Verification call and returns
|
|
13
|
+
# exactly the evidence document `bin/rails karst:verify --json` would
|
|
14
|
+
# print, so an agent calling this tool sees the same bounded runtime
|
|
15
|
+
# evidence a developer sees at the terminal, never a separate MCP result
|
|
16
|
+
# model.
|
|
17
|
+
#
|
|
18
|
+
# Every safety boundary an agent might reach for -- which principal to
|
|
19
|
+
# run as, which population to try, whether to skip rollback, how many
|
|
20
|
+
# requests to issue -- is owned by the host application's Karst
|
|
21
|
+
# configuration and Access::Search itself; nothing about that is
|
|
22
|
+
# settable here. The only inputs are the request an agent is allowed to
|
|
23
|
+
# make: which path, and which HTTP method (GET only, currently).
|
|
24
|
+
class VerifyAccessTool < MCP::Tool
|
|
25
|
+
tool_name "verify_access"
|
|
26
|
+
description <<~DESCRIPTION.strip
|
|
27
|
+
Verify bounded GET access to a local path in the running Rails application.
|
|
28
|
+
|
|
29
|
+
Karst executes the real application, under real existing identities
|
|
30
|
+
drawn from the application's own configured principal source(s), inside
|
|
31
|
+
a rolled-back database transaction, and reports what actually happened:
|
|
32
|
+
HTTP status, redirect target, any halted callback, any raised
|
|
33
|
+
exception, and whether a usable outcome was found. This is observed
|
|
34
|
+
runtime evidence, not an inference about authorization rules -- Karst
|
|
35
|
+
never explains *why* an outcome occurred, and this tool never
|
|
36
|
+
impersonates the developer's browser or exposes Test As.
|
|
37
|
+
DESCRIPTION
|
|
38
|
+
|
|
39
|
+
input_schema(
|
|
40
|
+
properties: {
|
|
41
|
+
path: {
|
|
42
|
+
type: "string",
|
|
43
|
+
description: "Local application path to verify, e.g. \"/admin/imports/123\". " \
|
|
44
|
+
"Must be a local path (no scheme/host)."
|
|
45
|
+
},
|
|
46
|
+
method: {
|
|
47
|
+
type: "string",
|
|
48
|
+
description: "HTTP method to verify. Only GET is currently supported.",
|
|
49
|
+
default: "GET"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
required: ["path"]
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
class << self
|
|
56
|
+
# server_context is part of MCP::Tool's call signature (the server
|
|
57
|
+
# passes it by keyword whenever a tool's #call accepts it) but this
|
|
58
|
+
# tool needs no per-request context beyond its own arguments.
|
|
59
|
+
# rubocop:disable Lint/UnusedMethodArgument
|
|
60
|
+
def call(path:, method: "GET", server_context: nil)
|
|
61
|
+
document = ::Karst::CLI::Verification.new(path: path, http_method: method).evidence
|
|
62
|
+
MCP::Tool::Response.new([{ type: "text", text: JSON.generate(document) }], error: document.key?(:error))
|
|
63
|
+
end
|
|
64
|
+
# rubocop:enable Lint/UnusedMethodArgument
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module Karst
|
|
6
|
+
# Inserts Karst's development-only web surface and rake tasks. Karst
|
|
7
|
+
# installs no notification subscription, no eager-loaded state, and nothing
|
|
8
|
+
# that runs on an ordinary application request outside development.
|
|
9
|
+
class Railtie < Rails::Railtie
|
|
10
|
+
# Must run before the middleware stack is built (a later Finisher
|
|
11
|
+
# initializer), not in config.after_initialize, which runs after the stack
|
|
12
|
+
# already exists and would have no effect. Lazily requires the web surface
|
|
13
|
+
# so a plain `require "karst"` never loads Rack rendering concerns outside
|
|
14
|
+
# development.
|
|
15
|
+
initializer "karst.web_middleware", before: :build_middleware_stack do |app|
|
|
16
|
+
next unless Rails.env.development?
|
|
17
|
+
|
|
18
|
+
require_relative "web/middleware"
|
|
19
|
+
app.middleware.use(Web::Middleware)
|
|
20
|
+
|
|
21
|
+
Rails.logger&.info("Karst: evidence at /karst")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
rake_tasks do
|
|
25
|
+
load File.expand_path("../tasks/karst.rake", __dir__)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private_constant :Railtie
|
|
30
|
+
end
|