karst 0.2.0 → 0.4.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +6 -1
  3. data/CHANGELOG.md +82 -2
  4. data/CONTRIBUTING.md +9 -1
  5. data/README.md +118 -2
  6. data/docs/advanced-configuration.md +106 -0
  7. data/docs/rails8-authentication.md +24 -4
  8. data/docs/request-reproduction.md +242 -0
  9. data/lib/generators/karst/install/templates/karst_initializer.rb +6 -0
  10. data/lib/karst/access/approved_populations.rb +15 -6
  11. data/lib/karst/access/errors.rb +16 -0
  12. data/lib/karst/access/identity_probe.rb +151 -0
  13. data/lib/karst/access/local_path.rb +42 -0
  14. data/lib/karst/access/observed_endpoint.rb +46 -0
  15. data/lib/karst/access/outcome_groups.rb +93 -0
  16. data/lib/karst/access/population_discovery.rb +25 -1
  17. data/lib/karst/access/principal_source.rb +24 -3
  18. data/lib/karst/access/probe.rb +397 -0
  19. data/lib/karst/access/search.rb +14 -6
  20. data/lib/karst/access/sweep.rb +42 -112
  21. data/lib/karst/cli/identity_serialization.rb +82 -0
  22. data/lib/karst/cli/outcome_summary.rb +131 -0
  23. data/lib/karst/cli/principal_reference.rb +51 -0
  24. data/lib/karst/cli/reproduction.rb +267 -0
  25. data/lib/karst/cli/verification.rb +219 -55
  26. data/lib/karst/configuration.rb +52 -6
  27. data/lib/karst/identity/evidence.rb +110 -0
  28. data/lib/karst/identity/observation.rb +167 -0
  29. data/lib/karst/identity/warden_adapter.rb +26 -1
  30. data/lib/karst/identity.rb +157 -22
  31. data/lib/karst/mcp/compatibility.rb +24 -0
  32. data/lib/karst/mcp/reproduce_request_tool.rb +137 -0
  33. data/lib/karst/mcp/reproduction.rb +58 -0
  34. data/lib/karst/mcp/server.rb +27 -8
  35. data/lib/karst/mcp/verify_access_tool.rb +36 -2
  36. data/lib/karst/reproduction/curl.rb +73 -0
  37. data/lib/karst/reproduction/exercise.rb +246 -0
  38. data/lib/karst/reproduction/observation.rb +87 -0
  39. data/lib/karst/reproduction/sanitizer.rb +210 -0
  40. data/lib/karst/version.rb +1 -1
  41. data/lib/karst/web/middleware.rb +105 -16
  42. data/lib/karst/web/panel.rb +302 -47
  43. data/lib/karst.rb +2 -0
  44. data/lib/rails/commands/karst/mcp/mcp_command.rb +30 -3
  45. data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
  46. data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
  47. metadata +33 -6
@@ -23,6 +23,7 @@ require_relative "../access/principal_source_selection"
23
23
  require_relative "../access/population_approval"
24
24
  require_relative "../access/population_revocation"
25
25
  require_relative "../identity/devise_support"
26
+ require_relative "../cli/reproduction"
26
27
 
27
28
  module Karst
28
29
  module Web
@@ -92,14 +93,39 @@ module Karst
92
93
  identity_response = mutate_browser_identity(env, params, browser_identity)
93
94
  return identity_response if identity_response
94
95
 
95
- result = analyze(env, params, approval: approval)
96
- candidates = inline_population_candidates(result)
96
+ reproduction, denied = reproduction_result(env, params, csrf)
97
+ return denied if denied
98
+
99
+ # Every local-state write this request may make (selection,
100
+ # approval) has happened by now, so one snapshot of the effective
101
+ # identity configuration serves the analysis, discovery, and the
102
+ # whole page render -- never one resolution per rendered user.
103
+ #
104
+ # `record` and `discovery` are this request's one approval-file read
105
+ # and one population-discovery pass, shared with whatever inside
106
+ # Identity.snapshot itself confirms already-approved populations
107
+ # (Access::ApprovedPopulations.merge) as well as with
108
+ # #inline_population_candidates below -- so a request that finds no
109
+ # usable outcome and goes on to look for candidate populations still
110
+ # reads the approval file, and parses model source, exactly once.
111
+ # `discovery`'s own principal_sources is a callable rather than a
112
+ # value because it is only ever actually needed by a *full* discovery
113
+ # pass (#inline_population_candidates), never by confirming one
114
+ # already-approved scope (which Identity.snapshot triggers first) --
115
+ # so by the time it is called, `identity` already holds this
116
+ # request's one resolution and nothing here re-resolves it.
117
+ record = Access::PopulationApprovals.load
118
+ identity = nil
119
+ discovery = Access::PopulationDiscovery.new(principal_sources: -> { identity.principal_sources })
120
+ identity = Identity.snapshot(record: record, discovery: discovery)
121
+ result = analyze(env, params, identity, approval: approval)
122
+ candidates = inline_population_candidates(result, record: record, discovery: discovery)
97
123
  Panel.render(params: params, access_result: result, route_lookup_limitation: lookup&.limitation,
98
- csrf_token: csrf_token(csrf),
99
- browser_identity_active: browser_identity_active?(browser_identity),
124
+ csrf_token: csrf_token(csrf), identity_snapshot: identity,
125
+ browser_identity_active: browser_identity_active?(browser_identity, identity),
100
126
  unapproved_candidates: candidates, population_approval_error: approval&.error,
101
127
  principal_source_selection_saved: !selection.nil? && selection.error.nil?,
102
- principal_source_selection_error: selection&.error)
128
+ principal_source_selection_error: selection&.error, reproduction: reproduction)
103
129
  end
104
130
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
105
131
 
@@ -123,19 +149,83 @@ module Karst
123
149
  env["REQUEST_METHOD"] == "POST" && params["operation"] == "select_principal_sources"
124
150
  end
125
151
 
152
+ # [evidence_document, nil], or [nil, forbidden_response]. Unlike the
153
+ # access sweep -- which only ever issues GET requests the developer
154
+ # could have made in their own browser -- reproduction issues one
155
+ # caller-specified request that may mutate, enqueue, and send mail, so
156
+ # it is protected exactly like the two operations that write local
157
+ # state: same-origin plus Karst's own CSRF token.
158
+ def reproduction_result(env, params, csrf)
159
+ return [nil, nil] unless env["REQUEST_METHOD"] == "POST" && params["operation"] == "reproduce_request"
160
+ return [nil, forbidden] unless approved_origin?(env)
161
+
162
+ csrf.verify!(params["csrf_token"])
163
+ [reproduce(env, params), nil]
164
+ rescue Csrf::InvalidToken
165
+ [nil, forbidden]
166
+ rescue ArgumentError => e
167
+ [{ schema_version: 1, error: { type: "input_error", message: e.message } }, nil]
168
+ end
169
+
170
+ def reproduce(env, params)
171
+ Karst::CLI::Reproduction.new(
172
+ path: params["path"], http_method: params["method"], body: params["body"],
173
+ content_type: params["content_type"], headers: request_headers(params["headers"]),
174
+ base_url: base_url(env)
175
+ ).evidence
176
+ end
177
+
178
+ # "Name: value" per line. A line Karst cannot read as a header is
179
+ # refused outright rather than silently dropped -- a header quietly
180
+ # missing from the request would make every observation below it wrong.
181
+ def request_headers(value)
182
+ value.to_s.split("\n").each_with_object({}) do |line, result|
183
+ next if line.strip.empty?
184
+
185
+ name, header_value = line.split(":", 2)
186
+ raise ArgumentError, "expected one header per line, as \"Name: value\"" if header_value.nil?
187
+
188
+ result[name.strip] = header_value.strip
189
+ end
190
+ end
191
+
192
+ # The developer's own origin, so a copied command runs against the
193
+ # server they are already looking at. Never a deployed host: Karst only
194
+ # ever knows about this local request.
195
+ def base_url(env)
196
+ request = Rack::Request.new(env)
197
+ "#{request.scheme}://#{request.host_with_port}"
198
+ end
199
+
200
+ # Validating and saving a submitted approval needs the effective
201
+ # principal sources *before* the write (to confirm the submitted
202
+ # candidates are currently real), so this necessarily resolves
203
+ # configuration once on its own -- separately from the fresh
204
+ # post-write resolution #call_owned takes afterward to render. `record`
205
+ # and `discovery` are still shared between the two config reads this
206
+ # one pre-write resolution would otherwise need (confirming
207
+ # already-approved populations, then discovering current candidates to
208
+ # validate the submission against), so this method itself reads the
209
+ # approval file and parses model source exactly once.
126
210
  def inline_population_approval_result(env, params, csrf)
127
211
  return [nil, nil] unless env["REQUEST_METHOD"] == "POST" && params["operation"] == "approve_populations"
128
212
  return [nil, forbidden] unless approved_origin?(env)
129
213
 
130
214
  csrf.verify!(params["csrf_token"])
131
- discovery = Access::PopulationDiscovery.new.call
132
- approval = Access::PopulationApproval.new(discovery: discovery, principal_sources: Identity.principal_sources,
133
- submitted: params["population"]).call
134
- [approval, nil]
215
+ [validate_population_approval(params), nil]
135
216
  rescue Csrf::InvalidToken, Identity::Error
136
217
  [nil, forbidden]
137
218
  end
138
219
 
220
+ def validate_population_approval(params)
221
+ record = Access::PopulationApprovals.load
222
+ sources = nil
223
+ discovery = Access::PopulationDiscovery.new(principal_sources: -> { sources })
224
+ sources = Identity.principal_sources(record: record, discovery: discovery)
225
+ Access::PopulationApproval.new(discovery: discovery.call, principal_sources: sources,
226
+ submitted: params["population"], record: record).call
227
+ end
228
+
139
229
  # Only a model Devise itself currently maps can ever be written --
140
230
  # exactly like candidate-population approval only ever writes a
141
231
  # currently discovered scope -- so this form can never seed the file
@@ -236,12 +326,12 @@ module Karst
236
326
  # "try this population" operation for a developer to press --
237
327
  # and no path by which a merely discovered, unapproved population
238
328
  # name can be executed.
239
- def analyze(env, params, approval: nil)
329
+ def analyze(env, params, identity, approval: nil)
240
330
  return nil unless env["REQUEST_METHOD"] == "POST"
241
331
  return nil unless params["operation"] == "access_sweep" || approval
242
332
 
243
333
  Access::Search.new(path: params["path"], http_method: params["method"],
244
- sources: Identity.principal_sources).call
334
+ sources: identity.principal_sources!).call
245
335
  rescue Access::Error, Identity::Error, ArgumentError => e
246
336
  e
247
337
  end
@@ -252,11 +342,10 @@ module Karst
252
342
  # actionable -- so an ordinary panel render never parses model source,
253
343
  # and the main page stays a contextual approval step rather than a
254
344
  # population-management dashboard. Discovery executes nothing.
255
- def inline_population_candidates(result)
345
+ def inline_population_candidates(result, record:, discovery:)
256
346
  return [] unless result.is_a?(Access::Search::Result) && result.verified_outcome.nil?
257
347
 
258
- record = Access::PopulationApprovals.load
259
- Access::PopulationDiscovery.new.call.candidates.select do |candidate|
348
+ discovery.call.candidates.select do |candidate|
260
349
  candidate.principal_source && !record.approved?(candidate.model_name, candidate.method_name)
261
350
  end
262
351
  rescue StandardError
@@ -290,8 +379,8 @@ module Karst
290
379
  nil
291
380
  end
292
381
 
293
- def browser_identity_active?(browser_identity)
294
- Identity.browser_supported? && browser_identity.active?
382
+ def browser_identity_active?(browser_identity, identity)
383
+ identity.browser_supported? && browser_identity.active?
295
384
  rescue Identity::Error
296
385
  false
297
386
  end