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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +6 -1
- data/CHANGELOG.md +82 -2
- data/CONTRIBUTING.md +9 -1
- data/README.md +118 -2
- data/docs/advanced-configuration.md +106 -0
- data/docs/rails8-authentication.md +24 -4
- data/docs/request-reproduction.md +242 -0
- data/lib/generators/karst/install/templates/karst_initializer.rb +6 -0
- data/lib/karst/access/approved_populations.rb +15 -6
- data/lib/karst/access/errors.rb +16 -0
- data/lib/karst/access/identity_probe.rb +151 -0
- data/lib/karst/access/local_path.rb +42 -0
- data/lib/karst/access/observed_endpoint.rb +46 -0
- data/lib/karst/access/outcome_groups.rb +93 -0
- data/lib/karst/access/population_discovery.rb +25 -1
- data/lib/karst/access/principal_source.rb +24 -3
- data/lib/karst/access/probe.rb +397 -0
- data/lib/karst/access/search.rb +14 -6
- data/lib/karst/access/sweep.rb +42 -112
- data/lib/karst/cli/identity_serialization.rb +82 -0
- data/lib/karst/cli/outcome_summary.rb +131 -0
- data/lib/karst/cli/principal_reference.rb +51 -0
- data/lib/karst/cli/reproduction.rb +267 -0
- data/lib/karst/cli/verification.rb +219 -55
- data/lib/karst/configuration.rb +52 -6
- data/lib/karst/identity/evidence.rb +110 -0
- data/lib/karst/identity/observation.rb +167 -0
- data/lib/karst/identity/warden_adapter.rb +26 -1
- data/lib/karst/identity.rb +157 -22
- data/lib/karst/mcp/compatibility.rb +24 -0
- data/lib/karst/mcp/reproduce_request_tool.rb +137 -0
- data/lib/karst/mcp/reproduction.rb +58 -0
- data/lib/karst/mcp/server.rb +27 -8
- data/lib/karst/mcp/verify_access_tool.rb +36 -2
- data/lib/karst/reproduction/curl.rb +73 -0
- data/lib/karst/reproduction/exercise.rb +246 -0
- data/lib/karst/reproduction/observation.rb +87 -0
- data/lib/karst/reproduction/sanitizer.rb +210 -0
- data/lib/karst/version.rb +1 -1
- data/lib/karst/web/middleware.rb +105 -16
- data/lib/karst/web/panel.rb +302 -47
- data/lib/karst.rb +2 -0
- data/lib/rails/commands/karst/mcp/mcp_command.rb +30 -3
- data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
- data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
- metadata +33 -6
data/lib/karst/web/middleware.rb
CHANGED
|
@@ -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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|