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,377 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "locality"
4
+ require_relative "panel"
5
+ require_relative "populations_panel"
6
+ require_relative "badge"
7
+ require_relative "browser_identity"
8
+ require_relative "route_lookup"
9
+ require_relative "../execution_context"
10
+ require "rack/utils"
11
+ require "rack/request"
12
+ require "json"
13
+ require "active_support/notifications"
14
+ require_relative "../access/sweep"
15
+ require_relative "../access/search"
16
+ require_relative "../access/principal_selection"
17
+ require_relative "../access/candidate_population"
18
+ require_relative "../access/population_discovery"
19
+ require_relative "../access/population_approvals"
20
+ require_relative "../access/approved_populations"
21
+ require_relative "../access/population_preview"
22
+ require_relative "../access/population_config_snippet"
23
+ require_relative "../access/principal_source_selection"
24
+ require_relative "../identity/devise_support"
25
+
26
+ module Karst
27
+ module Web
28
+ # Owns Karst's development-only HTTP evidence surface directly at the Rack
29
+ # boundary, rather than through a Rails engine. An engine would mount routes,
30
+ # load ActionView, and integrate into the host application's controller
31
+ # stack; Karst needs none of that to answer "what evidence does Karst
32
+ # currently hold," and all of it would blur the line between Karst's own
33
+ # page and the host application it is inspecting.
34
+ #
35
+ # Beyond serving /karst itself, this middleware also gives every other
36
+ # development HTML response a tiny link back into /karst, already scoped
37
+ # to the controller/action that produced it (see Badge). Karst sees a
38
+ # request before Rails routes it, so the controller/action that will
39
+ # eventually handle it is not yet known -- that evidence only exists once
40
+ # ActionController has actually dispatched the request, and is captured
41
+ # here via a real process_action.action_controller notification rather
42
+ # than guessed from the request path. Request-local state, not global
43
+ # mutable state, carries that evidence from the notification callback
44
+ # (which fires nested inside @app.call, on whatever thread or fiber is
45
+ # serving this request) back out to the code injecting the badge, so
46
+ # concurrent Puma requests never cross-contaminate each other's context.
47
+ # rubocop:disable Metrics/ClassLength
48
+ class Middleware
49
+ OWNED_PATH = "/karst"
50
+ private_constant :OWNED_PATH
51
+
52
+ POPULATIONS_PATH = "/karst/populations"
53
+ private_constant :POPULATIONS_PATH
54
+
55
+ CANDIDATE_SEPARATOR = "::"
56
+ private_constant :CANDIDATE_SEPARATOR
57
+
58
+ CONTEXT_KEY = :karst_web_request_context
59
+ private_constant :CONTEXT_KEY
60
+
61
+ def initialize(app)
62
+ @app = app
63
+ @locality = Locality.new
64
+ self.class.ensure_context_capture!
65
+ end
66
+
67
+ def call(env)
68
+ return call_owned(env) if owned?(env)
69
+
70
+ return @app.call(env) unless development? && @locality.local?(env["REMOTE_ADDR"])
71
+
72
+ call_with_badge(env)
73
+ end
74
+
75
+ private
76
+
77
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
78
+ def call_owned(env)
79
+ return @app.call(env) unless development? && @locality.local?(env["REMOTE_ADDR"])
80
+ return call_populations(env) if env["PATH_INFO"] == POPULATIONS_PATH
81
+
82
+ params = owned_params(env)
83
+ lookup = recognize_manual_route(env, params)
84
+ params = lookup.params if lookup
85
+ selection, denied = principal_source_selection_result(env, params)
86
+ return denied if denied
87
+
88
+ browser_identity = BrowserIdentity.new(Rack::Request.new(env))
89
+ identity_response = mutate_browser_identity(env, params, browser_identity)
90
+ return identity_response if identity_response
91
+
92
+ result = analyze(env, params)
93
+ Panel.render(params: params, access_result: result, route_lookup_limitation: lookup&.limitation,
94
+ csrf_token: browser_token(browser_identity),
95
+ browser_identity_active: browser_identity_active?(browser_identity),
96
+ unapproved_candidate_count: unapproved_candidate_count(result),
97
+ principal_source_selection_saved: !selection.nil? && selection.error.nil?,
98
+ principal_source_selection_error: selection&.error)
99
+ end
100
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
101
+
102
+ # [selection_record, nil] once saved, [nil, forbidden_response] when an
103
+ # attempted save was refused, or [nil, nil] for any other operation.
104
+ # Unlike every other operation Karst serves at /karst, saving a
105
+ # selection writes local state that outlives the request -- exactly
106
+ # like approving a candidate population -- and this page issues no
107
+ # CSRF token of its own precisely while ambiguous
108
+ # (Identity.browser_supported? is false until a selection resolves the
109
+ # ambiguity), so the check is same-origin rather than token-based; see
110
+ # #approved_origin?.
111
+ def principal_source_selection_result(env, params)
112
+ return [nil, nil] unless saving_principal_source_selection?(env, params)
113
+ return [nil, forbidden] unless approved_origin?(env)
114
+
115
+ [save_principal_source_selection(params), nil]
116
+ end
117
+
118
+ def saving_principal_source_selection?(env, params)
119
+ env["REQUEST_METHOD"] == "POST" && params["operation"] == "select_principal_sources"
120
+ end
121
+
122
+ # Only a model Devise itself currently maps can ever be written --
123
+ # exactly like candidate-population approval only ever writes a
124
+ # currently discovered scope -- so this form can never seed the file
125
+ # with an arbitrary submitted class name.
126
+ def save_principal_source_selection(params)
127
+ submitted = Array(params["principal"]).map(&:to_s)
128
+ names = Identity::DeviseSupport.mappings.map { |mapping| mapping.model.name }
129
+ .select { |name| submitted.include?(name) }
130
+ Access::PrincipalSourceSelection.replace(names)
131
+ end
132
+
133
+ # Unlike every other operation Karst serves, approving writes local
134
+ # state that outlives the request, so every POST to this path must
135
+ # have come from Karst's own page. There is no Rack session to hang a
136
+ # CSRF token on here (this page deliberately touches none), so the
137
+ # check is same-origin rather than token-based; a cross-site form POST
138
+ # cannot forge Origin, so it cannot approve anything. GET -- the page
139
+ # itself -- stays unauthenticated exactly like /karst.
140
+ def call_populations(env)
141
+ return forbidden unless approved_origin?(env)
142
+
143
+ params = owned_params(env)
144
+ discovery = Access::PopulationDiscovery.new.call
145
+ saving = saving_approvals?(env, params)
146
+ record = saving ? save_approvals(discovery, params) : Access::PopulationApprovals.load
147
+ render_populations(discovery, params, record, saving)
148
+ end
149
+
150
+ def render_populations(discovery, params, record, saved)
151
+ approved = approved_candidates(discovery, record)
152
+ snippet = Access::PopulationConfigSnippet.generate(approved) if params["generate_snippet"]
153
+ Web::PopulationsPanel.render(
154
+ discovery: discovery, approved: record.entries, stale: stale_approvals(record),
155
+ snippet: snippet, preview: population_preview(discovery, params),
156
+ storage_path: Access::PopulationApprovals.display_path, storage_error: record.error, saved: saved
157
+ )
158
+ end
159
+
160
+ def saving_approvals?(env, params)
161
+ env["REQUEST_METHOD"] == "POST" && params.key?("save_approvals")
162
+ end
163
+
164
+ # Only a candidate the *current* discovery result actually lists can
165
+ # ever be written, so the file cannot be seeded through this form with
166
+ # a model/scope pair Karst would refuse to confirm later anyway.
167
+ def save_approvals(discovery, params)
168
+ raw = Array(params["population"]).map(&:to_s)
169
+ entries = discovery.candidates.filter_map do |candidate|
170
+ next unless raw.include?(candidate_key(candidate))
171
+
172
+ Access::PopulationApprovals::Entry.new(model_name: candidate.model_name,
173
+ method_name: candidate.method_name.to_s)
174
+ end
175
+ Access::PopulationApprovals.replace(entries)
176
+ end
177
+
178
+ # Discovery candidates (which carry principal-source metadata the
179
+ # snippet generator needs) for the approved entries that are still
180
+ # discovered at all.
181
+ def approved_candidates(discovery, record)
182
+ discovery.candidates.select { |candidate| record.approved?(candidate.model_name, candidate.method_name) }
183
+ end
184
+
185
+ def stale_approvals(record)
186
+ Access::ApprovedPopulations.stale(safe_principal_sources, record: record)
187
+ rescue StandardError
188
+ [].freeze
189
+ end
190
+
191
+ def safe_principal_sources
192
+ Identity.principal_sources
193
+ rescue Identity::Error
194
+ {}
195
+ end
196
+
197
+ # An absent Origin (a non-browser client, or an older browser that
198
+ # only sends Referer) falls back to Referer; a POST carrying neither
199
+ # is refused rather than trusted.
200
+ def approved_origin?(env)
201
+ return true unless env["REQUEST_METHOD"] == "POST"
202
+
203
+ request = Rack::Request.new(env)
204
+ expected = "#{request.scheme}://#{request.host_with_port}"
205
+ origin = env["HTTP_ORIGIN"]
206
+ return origin == expected if origin
207
+
208
+ referer = env["HTTP_REFERER"].to_s
209
+ referer == expected || referer.start_with?("#{expected}/")
210
+ end
211
+
212
+ def forbidden
213
+ [403, { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store" }, ["Forbidden"]]
214
+ end
215
+
216
+ def population_preview(discovery, params)
217
+ key = params["preview"].to_s
218
+ return nil if key.empty?
219
+
220
+ candidate = discovery.candidates.find { |item| candidate_key(item) == key }
221
+ return nil unless candidate
222
+
223
+ Access::PopulationPreview.call(model_name: candidate.model_name, method_name: candidate.method_name,
224
+ discovery_result: discovery)
225
+ end
226
+
227
+ def candidate_key(candidate)
228
+ "#{candidate.model_name}#{CANDIDATE_SEPARATOR}#{candidate.method_name}"
229
+ end
230
+
231
+ def call_with_badge(env)
232
+ Karst::ExecutionContext[CONTEXT_KEY] = nil
233
+ status, headers, body = @app.call(env)
234
+ context = Karst::ExecutionContext[CONTEXT_KEY]
235
+
236
+ Badge.apply(status: status, headers: headers, body: body, context: context) || [status, headers, body]
237
+ ensure
238
+ Karst::ExecutionContext.delete(CONTEXT_KEY)
239
+ end
240
+
241
+ def owned?(env)
242
+ [OWNED_PATH, POPULATIONS_PATH].include?(env["PATH_INFO"])
243
+ end
244
+
245
+ def owned_params(env)
246
+ query = Rack::Utils.parse_nested_query(env["QUERY_STRING"].to_s)
247
+ return query unless env["REQUEST_METHOD"] == "POST"
248
+
249
+ query.merge(Rack::Request.new(env).POST)
250
+ end
251
+
252
+ def recognize_manual_route(env, params)
253
+ return unless env["REQUEST_METHOD"] == "GET" && params["operation"] == "route_lookup"
254
+
255
+ RouteLookup.new(path: params["path"], http_method: params["method"]).call
256
+ end
257
+
258
+ # One analysis operation, not two: Access::Search runs the ordinary
259
+ # bounded sample and, only if that finds nothing usable, automatically
260
+ # retries each *approved* candidate population in configuration order
261
+ # (see Karst::Access::Search). There is deliberately no separate
262
+ # "try this population" operation for a developer to press --
263
+ # and no path by which a merely discovered, unapproved population
264
+ # name can be executed.
265
+ def analyze(env, params)
266
+ return nil unless env["REQUEST_METHOD"] == "POST"
267
+ return nil unless params["operation"] == "access_sweep"
268
+
269
+ Access::Search.new(path: params["path"], http_method: params["method"],
270
+ sources: Identity.principal_sources).call
271
+ rescue Access::Error, Identity::Error, ArgumentError => e
272
+ e
273
+ end
274
+
275
+ # How many application-defined groups on an already-configured user
276
+ # source a developer could still approve. Computed only after an
277
+ # analysis that found nothing usable -- the one moment the answer is
278
+ # actionable -- so an ordinary panel render never parses model source,
279
+ # and the main page never turns into a population-configuration
280
+ # workflow. Discovery executes nothing; see PopulationDiscovery.
281
+ def unapproved_candidate_count(result)
282
+ return nil unless result.is_a?(Access::Search::Result) && result.verified_outcome.nil?
283
+
284
+ record = Access::PopulationApprovals.load
285
+ count = Access::PopulationDiscovery.new.call.candidates.count do |candidate|
286
+ candidate.principal_source && !record.approved?(candidate.model_name, candidate.method_name)
287
+ end
288
+ count.positive? ? count : nil
289
+ rescue StandardError
290
+ nil
291
+ end
292
+
293
+ def mutate_browser_identity(env, params, browser_identity)
294
+ return unless env["REQUEST_METHOD"] == "POST"
295
+
296
+ path = case params["operation"]
297
+ when "test_as" then browser_identity.assume(params)
298
+ when "stop_test_as" then browser_identity.clear(params)
299
+ end
300
+ path && identity_navigation_response(env, params, path)
301
+ rescue Identity::Error
302
+ forbidden
303
+ end
304
+
305
+ def identity_navigation_response(env, params, path)
306
+ if params["operation"] == "test_as" && env["HTTP_ACCEPT"].to_s.include?("application/json")
307
+ body = JSON.generate(location: path)
308
+ [200, { "content-type" => "application/json; charset=utf-8", "cache-control" => "no-store" }, [body]]
309
+ else
310
+ [303, { "location" => path, "cache-control" => "no-store" }, []]
311
+ end
312
+ end
313
+
314
+ def browser_token(browser_identity)
315
+ browser_identity.token if Identity.browser_supported?
316
+ rescue Identity::Error
317
+ nil
318
+ end
319
+
320
+ def browser_identity_active?(browser_identity)
321
+ Identity.browser_supported? && browser_identity.active?
322
+ rescue Identity::Error
323
+ false
324
+ end
325
+
326
+ # Re-checked per request as defense in depth: the middleware is only
327
+ # inserted into the stack in development (see Railtie), but this keeps
328
+ # that guarantee independent of how or when the middleware was inserted.
329
+ # config.enabled is read live rather than at insertion time, so turning
330
+ # Karst off never depends on initializer ordering.
331
+ def development?
332
+ Rails.env.development? && Karst.enabled?
333
+ end
334
+
335
+ class << self
336
+ # One subscription for the process lifetime of this middleware class,
337
+ # regardless of how many instances Rack::Builder creates: the
338
+ # notification is process-wide by nature, and its callback only ever
339
+ # writes into the current thread/fiber's own IsolatedExecutionState
340
+ # slot, so a single subscription safely serves every request.
341
+ def ensure_context_capture!
342
+ @context_capture_mutex ||= Mutex.new
343
+ @context_capture_mutex.synchronize do
344
+ next if @context_capture_installed
345
+
346
+ ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
347
+ capture_context(args.last)
348
+ end
349
+ @context_capture_installed = true
350
+ end
351
+ end
352
+
353
+ private
354
+
355
+ def capture_context(payload)
356
+ return unless payload.respond_to?(:[])
357
+
358
+ Karst::ExecutionContext[CONTEXT_KEY] = Badge::Context.new(
359
+ controller: payload[:controller], action: payload[:action],
360
+ http_method: payload[:method], path: strip_query(payload[:path])
361
+ )
362
+ rescue StandardError
363
+ nil
364
+ end
365
+
366
+ # Mirrors Karst::Spec::Observer's own treatment of request paths: a
367
+ # query string can carry a token (password reset, OAuth callback,
368
+ # signed URL), and this path is only ever contextual display evidence
369
+ # in the panel, never route identity, so it is never worth the risk.
370
+ def strip_query(value)
371
+ value.to_s.split("?").first
372
+ end
373
+ end
374
+ end
375
+ # rubocop:enable Metrics/ClassLength
376
+ end
377
+ end