karst 0.1.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 +14 -12
- data/CHANGELOG.md +114 -47
- data/CONTRIBUTING.md +9 -1
- data/README.md +137 -9
- data/SECURITY.md +1 -1
- data/docs/advanced-configuration.md +112 -7
- data/docs/rails8-authentication.md +191 -0
- 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_approval.rb +67 -0
- data/lib/karst/access/population_discovery.rb +25 -1
- data/lib/karst/access/population_revocation.rb +34 -0
- 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 +57 -11
- data/lib/karst/execution_context.rb +6 -7
- 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/railtie.rb +4 -7
- 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/badge.rb +5 -31
- data/lib/karst/web/browser_identity.rb +10 -13
- data/lib/karst/web/csrf.rb +43 -0
- data/lib/karst/web/middleware.rb +145 -84
- data/lib/karst/web/panel.rb +343 -125
- data/lib/karst/web/populations_panel.rb +46 -336
- data/lib/karst.rb +3 -3
- data/lib/rails/commands/karst/mcp/mcp_command.rb +41 -1
- data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
- data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
- metadata +38 -18
- data/lib/karst/access/population_config_snippet.rb +0 -67
- data/lib/karst/access/population_preview.rb +0 -83
- data/lib/karst/access/resource_evidence.rb +0 -233
- data/lib/karst/spec/catalog.rb +0 -199
- data/lib/karst/spec/example_observation.rb +0 -31
- data/lib/karst/spec/observer.rb +0 -300
- data/lib/karst/spec/principal.rb +0 -12
- data/lib/karst/spec/reporter.rb +0 -83
- data/lib/karst/spec/request_observation.rb +0 -38
- data/lib/karst/spec/scenario.rb +0 -65
- data/lib/tasks/karst.rake +0 -34
data/lib/karst/web/panel.rb
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "cgi"
|
|
4
|
+
require "json"
|
|
4
5
|
require "rack/utils"
|
|
6
|
+
require "active_support"
|
|
5
7
|
require "active_support/number_helper"
|
|
6
8
|
require "base64"
|
|
7
9
|
require "digest"
|
|
10
|
+
require_relative "../access/outcome_groups"
|
|
8
11
|
|
|
9
12
|
module Karst
|
|
10
13
|
module Web
|
|
@@ -80,13 +83,9 @@ module Karst
|
|
|
80
83
|
.usable-principal button[type=submit]{background:#0f5132;border-color:#0f5132;color:#fff;font-weight:600}
|
|
81
84
|
.usable-principal button[type=submit]:hover{background:#0a3d25}
|
|
82
85
|
.usable-principal p{margin:.5rem 0 0;color:#444;font-size:.92rem}
|
|
83
|
-
.related-state{margin:.75rem 0 0;padding:.6rem .75rem;border-left:3px solid #ccc;background:#fafafa;font-size:.9rem}
|
|
84
|
-
.related-state ul{margin:.25rem 0 0;padding-left:1.1rem}
|
|
85
86
|
details{border:1px solid #e2e2e2;border-radius:.4rem;padding:.6rem .8rem;margin:.75rem 0}
|
|
86
87
|
details summary{cursor:pointer;font-weight:600}
|
|
87
88
|
details[open]>summary{margin-bottom:.5rem}
|
|
88
|
-
.scenario{border:1px solid #ddd;border-radius:.4rem;padding:.75rem;margin:.6rem 0}
|
|
89
|
-
.scenario h4{margin:.1rem 0}
|
|
90
89
|
.evidence{display:flex;gap:1rem;flex-wrap:wrap;font-size:.9rem}
|
|
91
90
|
.label{font-size:.72rem;font-weight:700;text-transform:uppercase;color:#666}
|
|
92
91
|
.failed,.pending{border-left:4px solid #b3261e}
|
|
@@ -97,10 +96,26 @@ module Karst
|
|
|
97
96
|
.population-attempt.hit .name{color:#0f5132}
|
|
98
97
|
.population-attempt.untried{color:#777}
|
|
99
98
|
.candidate-review{font-size:.9rem;color:#444;margin:.5rem 0 0}
|
|
99
|
+
.candidate-review label{display:flex;flex-direction:row;align-items:center;font-size:.9rem;margin:.35rem 0}
|
|
100
100
|
.ordinary-sample{border:1px solid #ddd;border-radius:.5rem;padding:.8rem 1rem;margin:1rem 0}
|
|
101
101
|
.ordinary-sample h2{margin:0 0 .45rem}
|
|
102
102
|
.ordinary-sample details{margin:.55rem 0}
|
|
103
103
|
.write-warning{border:2px solid #b3261e;border-radius:.4rem;padding:.7rem .85rem;background:#fff7f6}
|
|
104
|
+
section.reproduce textarea{font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;padding:.4rem .5rem;border:1px solid #ccc;border-radius:.3rem;width:100%;min-height:5rem;resize:vertical}
|
|
105
|
+
section.reproduce form{display:grid;gap:.75rem;margin:.6rem 0}
|
|
106
|
+
section.reproduce .row{display:flex;gap:.75rem;flex-wrap:wrap}
|
|
107
|
+
section.reproduce label{flex:1 1 10rem}
|
|
108
|
+
section.reproduce label input{width:100%}
|
|
109
|
+
.recipe{border:1px solid #ddd;border-radius:.5rem;padding:.9rem 1rem;margin:.9rem 0}
|
|
110
|
+
.recipe h3{font-size:.95rem;margin:.9rem 0 .3rem}
|
|
111
|
+
.recipe h3:first-child{margin-top:0}
|
|
112
|
+
.recipe p{margin:.2rem 0}
|
|
113
|
+
.recipe pre{overflow-x:auto;background:#f6f6f7;border:1px solid #e2e2e2;border-radius:.4rem;padding:.7rem .8rem;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;margin:.3rem 0 0}
|
|
114
|
+
.recipe dl{display:grid;grid-template-columns:auto 1fr;gap:.15rem .75rem;margin:.2rem 0;font-size:.9rem}
|
|
115
|
+
.recipe dt{font-weight:600;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
116
|
+
.recipe dd{margin:0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow-wrap:anywhere}
|
|
117
|
+
.redacted{color:#8a5b00;font-weight:600}
|
|
118
|
+
.unobserved{color:#666;font-size:.88rem}
|
|
104
119
|
small{color:#666}
|
|
105
120
|
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
|
106
121
|
@media (max-width:480px){
|
|
@@ -123,13 +138,16 @@ module Karst
|
|
|
123
138
|
.population-attempt.hit .name{color:#7fd8a4}
|
|
124
139
|
.population-attempt.untried{color:#8a8a90}
|
|
125
140
|
.usable-principal button[type=submit]{background:#2e7d52;border-color:#2e7d52;color:#0b1a12}
|
|
126
|
-
.related-state{background:#1c1d20;border-color:#3a3b3e}
|
|
127
141
|
details{border-color:#2c2d31}
|
|
128
|
-
.scenario{border-color:#33343a}
|
|
129
142
|
.testing-banner{background:#3a2f0d;border-color:#6b5423;color:#f0e4c0}
|
|
130
143
|
.hint{color:#d8a63d}
|
|
131
144
|
.failed,.pending{border-left-color:#e5534b}
|
|
132
145
|
.write-warning{background:#321b1a}
|
|
146
|
+
section.reproduce textarea{background:#1f2023;border-color:#3a3b3e;color:#e4e4e6}
|
|
147
|
+
.recipe{border-color:#33343a}
|
|
148
|
+
.recipe pre{background:#1f2023;border-color:#2c2d31}
|
|
149
|
+
.redacted{color:#d8a63d}
|
|
150
|
+
.unobserved{color:#9a9aa0}
|
|
133
151
|
}
|
|
134
152
|
CSS
|
|
135
153
|
private_constant :STYLE
|
|
@@ -147,15 +165,24 @@ module Karst
|
|
|
147
165
|
|
|
148
166
|
# rubocop:disable Metrics/ClassLength
|
|
149
167
|
class << self
|
|
168
|
+
# `identity_snapshot` is the request's one Karst::Identity::Snapshot;
|
|
169
|
+
# every identity-configuration question this page asks (principal
|
|
170
|
+
# sources, setup readiness, browser Test As support) reads it, never
|
|
171
|
+
# the live configuration, so rendering N users never resolves
|
|
172
|
+
# configuration N times. Taken here when the caller has none.
|
|
150
173
|
# rubocop:disable Metrics/ParameterLists
|
|
151
174
|
def render(params: {}, access_result: nil, csrf_token: nil, browser_identity_active: false,
|
|
152
|
-
route_lookup_limitation: nil,
|
|
153
|
-
principal_source_selection_saved: false, principal_source_selection_error: nil
|
|
175
|
+
route_lookup_limitation: nil, unapproved_candidates: [], population_approval_error: nil,
|
|
176
|
+
principal_source_selection_saved: false, principal_source_selection_error: nil,
|
|
177
|
+
reproduction: nil, identity_snapshot: nil)
|
|
154
178
|
state = { csrf_token: csrf_token, browser_identity_active: browser_identity_active,
|
|
179
|
+
identity: identity_snapshot || Identity.snapshot,
|
|
155
180
|
route_lookup_limitation: route_lookup_limitation,
|
|
156
|
-
|
|
181
|
+
unapproved_candidates: unapproved_candidates,
|
|
182
|
+
population_approval_error: population_approval_error,
|
|
157
183
|
principal_source_selection_saved: principal_source_selection_saved,
|
|
158
|
-
principal_source_selection_error: principal_source_selection_error
|
|
184
|
+
principal_source_selection_error: principal_source_selection_error,
|
|
185
|
+
reproduction: reproduction }
|
|
159
186
|
[200, HEADERS.dup, [document(params, access_result, state)]]
|
|
160
187
|
end
|
|
161
188
|
# rubocop:enable Metrics/ParameterLists
|
|
@@ -180,9 +207,10 @@ module Karst
|
|
|
180
207
|
action = string_param(params, "action")
|
|
181
208
|
http_method = string_param(params, "method")
|
|
182
209
|
path = string_param(params, "path")
|
|
183
|
-
"#{testing_banner(path, state
|
|
210
|
+
"#{testing_banner(path, state)}" \
|
|
184
211
|
"#{route_header(http_method, path, state[:route_lookup_limitation])}" \
|
|
185
|
-
"#{access_section(http_method, path, controller, action, access_result, state)}"
|
|
212
|
+
"#{access_section(http_method, path, controller, action, access_result, state)}" \
|
|
213
|
+
"#{reproduction_section(params, http_method, path, state)}"
|
|
186
214
|
end
|
|
187
215
|
|
|
188
216
|
def string_param(params, key)
|
|
@@ -192,15 +220,15 @@ module Karst
|
|
|
192
220
|
|
|
193
221
|
# -- Testing-as banner ------------------------------------------------
|
|
194
222
|
|
|
195
|
-
def testing_banner(path,
|
|
196
|
-
|
|
223
|
+
def testing_banner(path, state)
|
|
224
|
+
csrf_token = state[:csrf_token]
|
|
225
|
+
return "" unless state[:browser_identity_active] && state[:identity].browser_supported? && csrf_token
|
|
197
226
|
|
|
198
227
|
fields = hidden("operation", "stop_test_as") + hidden("csrf_token", csrf_token) + hidden("path", path)
|
|
199
228
|
<<~HTML
|
|
200
229
|
<div class="testing-banner" role="status">
|
|
201
|
-
<p><strong>Currently testing as an assumed
|
|
202
|
-
<p><small>Stopping
|
|
203
|
-
(commonly signed out) -- Karst does not restore a previous session.</small></p>
|
|
230
|
+
<p><strong>Currently testing as an assumed user.</strong></p>
|
|
231
|
+
<p><small>Stopping signs this browser out; Karst does not restore your previous session.</small></p>
|
|
204
232
|
<form action="/karst" method="post">#{fields}<button type="submit">Stop testing as</button></form>
|
|
205
233
|
</div>
|
|
206
234
|
HTML
|
|
@@ -243,7 +271,7 @@ module Karst
|
|
|
243
271
|
|
|
244
272
|
# -- Primary action: access analysis ------------------------------------
|
|
245
273
|
|
|
246
|
-
# rubocop:disable Metrics/ParameterLists
|
|
274
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
|
|
247
275
|
def access_section(http_method, path, controller, action, result, state)
|
|
248
276
|
return setup_notice_section(state) if path.empty? || state[:route_lookup_limitation]
|
|
249
277
|
|
|
@@ -252,12 +280,14 @@ module Karst
|
|
|
252
280
|
body = if http_method == "GET"
|
|
253
281
|
analyze_form(context, state)
|
|
254
282
|
else
|
|
255
|
-
"<p>Access analysis is available for GET routes only
|
|
283
|
+
"<p>Access analysis is available for GET routes only. " \
|
|
284
|
+
"Use <strong>Reproduce request</strong> below to issue this one request " \
|
|
285
|
+
"and get a recipe for it.</p>"
|
|
256
286
|
end
|
|
257
287
|
heading = "<h2 class=\"sr-only\">Access analysis</h2>"
|
|
258
288
|
"<section class=\"access\">#{heading}#{body}#{access_result(result, state)}</section>"
|
|
259
289
|
end
|
|
260
|
-
# rubocop:enable Metrics/ParameterLists
|
|
290
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
|
|
261
291
|
|
|
262
292
|
# A developer with several Devise models (or no automatic
|
|
263
293
|
# authentication integration at all) needs to know that before
|
|
@@ -273,19 +303,17 @@ module Karst
|
|
|
273
303
|
# away must still say so, rather than have the confirmation vanish
|
|
274
304
|
# the moment `sources` becomes truthy.
|
|
275
305
|
def setup_notice_section(state)
|
|
276
|
-
sources = principal_sources
|
|
306
|
+
sources = state[:identity].principal_sources
|
|
277
307
|
notice = principal_source_selection_notice(state)
|
|
278
308
|
return notice if sources
|
|
279
309
|
|
|
280
310
|
heading = "<h2 class=\"sr-only\">Access analysis</h2>"
|
|
281
|
-
body = "#{notice}#{principal_source_hint(
|
|
311
|
+
body = "#{notice}#{principal_source_hint(state[:identity])}"
|
|
282
312
|
"<section class=\"access\">#{heading}#{body}</section>"
|
|
283
313
|
end
|
|
284
314
|
|
|
285
315
|
def analyze_form(context, state)
|
|
286
|
-
|
|
287
|
-
kind = sources && any_representative?(sources) ? "representative " : ""
|
|
288
|
-
label = "Who can use this? (test #{Karst.config.access_sweep_limit} #{kind}users)"
|
|
316
|
+
label = "Who can use this? (test #{Karst.config.access_sweep_limit} users)"
|
|
289
317
|
operation = "<input type=\"hidden\" name=\"operation\" value=\"access_sweep\">"
|
|
290
318
|
button = "<button class=\"primary\" type=\"submit\">#{escape(label)}</button>"
|
|
291
319
|
form = "<form action=\"/karst\" method=\"post\">#{context}#{operation}#{button}</form>"
|
|
@@ -294,27 +322,13 @@ module Karst
|
|
|
294
322
|
# `sources` truthy must still tell the developer it worked, rather
|
|
295
323
|
# than have the confirmation vanish the moment it stops being
|
|
296
324
|
# needed.
|
|
297
|
-
"#{principal_source_selection_notice(state)}#{form}#{principal_source_hint(
|
|
298
|
-
end
|
|
299
|
-
|
|
300
|
-
# Only ever type-checks each configured source's evaluated records
|
|
301
|
-
# (see Access::PrincipalSampler.representative_capable?); it never
|
|
302
|
-
# queries or enumerates any of them, so this is safe to compute on
|
|
303
|
-
# every panel render.
|
|
304
|
-
def principal_sources
|
|
305
|
-
Identity.principal_sources
|
|
306
|
-
rescue Identity::Error
|
|
307
|
-
nil
|
|
308
|
-
end
|
|
309
|
-
|
|
310
|
-
def any_representative?(sources)
|
|
311
|
-
sources.values.any? { |source| Access::PrincipalSampler.representative_capable?(source.evaluate) }
|
|
325
|
+
"#{principal_source_selection_notice(state)}#{form}#{principal_source_hint(state[:identity])}"
|
|
312
326
|
end
|
|
313
327
|
|
|
314
|
-
def principal_source_hint(
|
|
315
|
-
return "" if
|
|
328
|
+
def principal_source_hint(identity)
|
|
329
|
+
return "" if identity.principal_sources
|
|
316
330
|
|
|
317
|
-
setup =
|
|
331
|
+
setup = identity.setup_state
|
|
318
332
|
return principal_source_selection_form(setup) if setup.status == :ambiguous
|
|
319
333
|
|
|
320
334
|
custom_auth_hint
|
|
@@ -381,6 +395,206 @@ module Karst
|
|
|
381
395
|
"<input type=\"hidden\" name=\"#{name}\" value=\"#{escape(value)}\">"
|
|
382
396
|
end
|
|
383
397
|
|
|
398
|
+
# -- Request reproduction -----------------------------------------------
|
|
399
|
+
|
|
400
|
+
# The one place /karst answers "something calls this endpoint -- what
|
|
401
|
+
# request do I send to exercise the same behavior?". Deliberately a
|
|
402
|
+
# collapsed <details> below the access analysis rather than a peer of
|
|
403
|
+
# it: reproduction issues a real request with real non-database side
|
|
404
|
+
# effects, so it is something a developer opens on purpose, never
|
|
405
|
+
# something the page does on arrival. Everything below renders the
|
|
406
|
+
# same evidence document Karst::CLI::Reproduction hands the MCP tool
|
|
407
|
+
# and the CLI, so the panel cannot show a recipe those two would not.
|
|
408
|
+
def reproduction_section(params, http_method, path, state)
|
|
409
|
+
document = state[:reproduction]
|
|
410
|
+
return "" if path.empty? && document.nil?
|
|
411
|
+
return "" unless state[:csrf_token]
|
|
412
|
+
|
|
413
|
+
open = document ? " open" : ""
|
|
414
|
+
"<section class=\"reproduce\"><details#{open}><summary><strong>Reproduce request</strong></summary>" \
|
|
415
|
+
"#{reproduction_intro}#{reproduction_form(params, http_method, path, state)}" \
|
|
416
|
+
"#{reproduction_result(document)}</details></section>"
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def reproduction_intro
|
|
420
|
+
"<p class=\"meta\">Karst issues this request once against your running application and shows what " \
|
|
421
|
+
"actually happened, plus a cURL command for exactly what it sent. Database writes are rolled " \
|
|
422
|
+
"back on the same connection; jobs, mail, outbound HTTP, files, and other connections are not. " \
|
|
423
|
+
"Secrets are replaced by placeholders, so a generated command may need a credential filled in.</p>"
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def reproduction_form(params, http_method, path, state)
|
|
427
|
+
<<~HTML
|
|
428
|
+
<form action="/karst" method="post">
|
|
429
|
+
#{hidden('operation', 'reproduce_request')}#{hidden('csrf_token', state[:csrf_token])}
|
|
430
|
+
#{hidden('controller', string_param(params, 'controller'))}#{hidden('action', string_param(params, 'action'))}
|
|
431
|
+
<div class="row">
|
|
432
|
+
<label>Method <input name="method" value="#{escape(http_method.empty? ? 'GET' : http_method)}" required></label>
|
|
433
|
+
<label>Path <input name="path" value="#{escape(path)}" required></label>
|
|
434
|
+
</div>
|
|
435
|
+
<div class="row">
|
|
436
|
+
<label>Content type <input name="content_type" value="#{escape(string_param(params, 'content_type'))}" placeholder="application/json"></label>
|
|
437
|
+
</div>
|
|
438
|
+
<label>Body <textarea name="body" placeholder='{"serial_number": "ABC123"}'>#{escape(string_param(params, 'body'))}</textarea></label>
|
|
439
|
+
<label>Headers <small>one per line, as <code>Name: value</code></small>
|
|
440
|
+
<textarea name="headers" placeholder="Authorization: Bearer ...">#{escape(string_param(params, 'headers'))}</textarea></label>
|
|
441
|
+
<div><button class="primary" type="submit">Send once and build request</button></div>
|
|
442
|
+
</form>
|
|
443
|
+
HTML
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# rubocop:disable Metrics/AbcSize
|
|
447
|
+
def reproduction_result(document)
|
|
448
|
+
return "" unless document
|
|
449
|
+
return "<p class=\"hint\" role=\"alert\">#{escape(document[:error][:message])}</p>" if document[:error]
|
|
450
|
+
|
|
451
|
+
sections = [recipe_request(document[:request]), recipe_identity(document[:identity]),
|
|
452
|
+
recipe_execution(document[:execution]), recipe_response(document[:response]),
|
|
453
|
+
recipe_effects(document[:execution], document[:isolation]),
|
|
454
|
+
recipe_unobserved(document[:unobserved]), recipe_curl(document[:reproduce][:curl])]
|
|
455
|
+
"<div class=\"recipe\">#{sections.join}</div>"
|
|
456
|
+
end
|
|
457
|
+
# rubocop:enable Metrics/AbcSize
|
|
458
|
+
|
|
459
|
+
def recipe_request(request)
|
|
460
|
+
"<h3>Request</h3><p class=\"route-path\">#{escape(request[:method])} #{escape(request[:path])}</p>" \
|
|
461
|
+
"#{recipe_pairs('Route parameters', request[:route_params])}" \
|
|
462
|
+
"#{recipe_pairs('Query parameters', request[:query_params])}" \
|
|
463
|
+
"#{recipe_pairs('Headers', request[:headers])}" \
|
|
464
|
+
"#{recipe_body(request)}"
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def recipe_body(request)
|
|
468
|
+
return "" unless request[:body]
|
|
469
|
+
if request[:body_format] == "opaque"
|
|
470
|
+
return "<h3>Body</h3><p class=\"unobserved\">Karst sent a body it could not parse as JSON or " \
|
|
471
|
+
"form data, so it is not shown.</p>"
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
"<h3>Body <small>(#{escape(request[:body_format])})</small></h3>" \
|
|
475
|
+
"<pre>#{escape(JSON.pretty_generate(request[:body]))}</pre>"
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# Redacted values are visibly marked rather than silently mixed in
|
|
479
|
+
# with observed ones, so a developer copying a value out of this
|
|
480
|
+
# panel can always tell which is which.
|
|
481
|
+
def recipe_pairs(title, pairs)
|
|
482
|
+
return "" if pairs.nil? || pairs.empty?
|
|
483
|
+
|
|
484
|
+
rows = pairs.map do |name, value|
|
|
485
|
+
"<dt>#{escape(name)}</dt><dd>#{recipe_value(value)}</dd>"
|
|
486
|
+
end.join
|
|
487
|
+
"<h3>#{escape(title)}</h3><dl>#{rows}</dl>"
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def recipe_value(value)
|
|
491
|
+
text = value.is_a?(String) ? value : JSON.generate(value)
|
|
492
|
+
return "<span class=\"redacted\">#{escape(text)}</span>" if placeholder?(text)
|
|
493
|
+
|
|
494
|
+
escape(text)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def placeholder?(text)
|
|
498
|
+
text.start_with?("<") && text.end_with?(">")
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# The same requested/observed/confirmation evidence verify_access
|
|
502
|
+
# reports, never a bare echo of the identity Karst assumed: a
|
|
503
|
+
# mismatch, an absence, or an unobservable outcome stays visible here
|
|
504
|
+
# exactly as it would in a "sent as User X" claim it is not entitled
|
|
505
|
+
# to make.
|
|
506
|
+
def recipe_identity(identity)
|
|
507
|
+
html = "<h3>Identity</h3><p>#{escape(identity_confirmation_label(identity))}</p>"
|
|
508
|
+
html += "<p class=\"unobserved\">#{escape(identity[:reason])}</p>" if identity[:reason]
|
|
509
|
+
html += identity_failure_detail(identity)
|
|
510
|
+
"#{html}<p class=\"unobserved\">Karst assumed this identity directly; it did not exercise how an " \
|
|
511
|
+
"external client would authenticate this endpoint.</p>"
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def identity_confirmation_label(identity)
|
|
515
|
+
requested = principal_summary(identity[:requested], "anonymous")
|
|
516
|
+
observed = principal_summary(identity[:observed], "no principal")
|
|
517
|
+
case identity[:confirmation]
|
|
518
|
+
when "confirmed" then "observed #{observed} (identity confirmed)"
|
|
519
|
+
when "confirmed_anonymous" then "observed no principal (anonymous confirmed)"
|
|
520
|
+
when "absent" then "requested #{requested}, observed no principal (NOT confirmed)"
|
|
521
|
+
when "mismatch" then "requested #{requested}, observed #{observed} (MISMATCH)"
|
|
522
|
+
when "contaminated" then "anonymous probe observed #{observed} (CONTAMINATED)"
|
|
523
|
+
else "identity unobservable#{": #{identity[:observation_error]}" if identity[:observation_error]}"
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def principal_summary(value, absent_label)
|
|
528
|
+
value ? "#{value[:model]} ##{value[:id]}" : absent_label
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def identity_failure_detail(identity)
|
|
532
|
+
detail = []
|
|
533
|
+
detail << "Identity setup: #{escape(identity[:establishment_error])}" if identity[:establishment_error]
|
|
534
|
+
detail << "Cleanup: #{escape(identity[:cleanup_error])}" if identity[:cleanup_error]
|
|
535
|
+
detail.map { |line| "<p class=\"unobserved\">#{line}</p>" }.join
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def recipe_execution(execution)
|
|
539
|
+
dispatched = if execution[:controller]
|
|
540
|
+
"#{escape(execution[:controller])}##{escape(execution[:action])}"
|
|
541
|
+
else
|
|
542
|
+
"<span class=\"unobserved\">no controller dispatched</span>"
|
|
543
|
+
end
|
|
544
|
+
"<h3>Observed execution</h3><p>#{dispatched}</p>#{recipe_controller_completed(execution)}" \
|
|
545
|
+
"#{recipe_execution_detail(execution)}#{recipe_rendered(execution[:rendered])}"
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def recipe_execution_detail(execution)
|
|
549
|
+
halted = execution[:halted_callback]
|
|
550
|
+
exception = execution[:exception_class]
|
|
551
|
+
"#{"<p>Halted at #{escape(halted)}</p>" if halted}" \
|
|
552
|
+
"#{"<p>Exception: #{escape(exception)} (during #{escape(execution[:exception_phase])})</p>" if exception}"
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def recipe_controller_completed(execution)
|
|
556
|
+
completed = execution[:controller_completed]
|
|
557
|
+
return "" if completed.nil?
|
|
558
|
+
|
|
559
|
+
"<p class=\"meta\">Controller completed: #{completed}</p>"
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def recipe_rendered(rendered)
|
|
563
|
+
return "" if rendered.nil? || rendered.empty?
|
|
564
|
+
|
|
565
|
+
items = rendered.map do |template|
|
|
566
|
+
state = template[:completed] ? "" : " <span class=\"unobserved\">(raised)</span>"
|
|
567
|
+
"<li>#{escape(template[:virtual_path])}#{state}</li>"
|
|
568
|
+
end.join
|
|
569
|
+
"<p>Rendered:</p><ul>#{items}</ul>"
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def recipe_response(response)
|
|
573
|
+
return "<h3>Observed response</h3><p class=\"unobserved\">No response observed.</p>" unless response[:status]
|
|
574
|
+
|
|
575
|
+
type = response[:content_type]
|
|
576
|
+
redirect = response[:redirect]
|
|
577
|
+
"<h3>Observed response</h3><p>#{status_title(response[:status])}" \
|
|
578
|
+
"#{" · #{escape(type)}" if type}#{" → #{escape(redirect)}" if redirect}</p>"
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def recipe_effects(execution, isolation)
|
|
582
|
+
count = execution[:write_count]
|
|
583
|
+
"<h3>Observed effects</h3><p>#{escape(count)} database #{count == 1 ? 'write' : 'writes'} " \
|
|
584
|
+
"· rollback attempted on the same connection</p>" \
|
|
585
|
+
"<p class=\"unobserved\">Not isolated: #{escape(isolation[:not_isolated].join(', '))}.</p>"
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def recipe_unobserved(unobserved)
|
|
589
|
+
return "" if unobserved.nil? || unobserved.empty?
|
|
590
|
+
|
|
591
|
+
"<h3>Not observed</h3><p class=\"unobserved\">#{escape(unobserved.join(', '))}</p>"
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def recipe_curl(curl)
|
|
595
|
+
"<h3>Reproduce</h3><pre><code>#{escape(curl)}</code></pre>"
|
|
596
|
+
end
|
|
597
|
+
|
|
384
598
|
def access_result(result, state)
|
|
385
599
|
return "" unless result
|
|
386
600
|
return "<p>Analysis unavailable: #{escape(result.message)}</p>" if result.is_a?(StandardError)
|
|
@@ -392,13 +606,17 @@ module Karst
|
|
|
392
606
|
# any automatic candidate-population retries as a single answer, so
|
|
393
607
|
# a usable user found through a population reads exactly like one
|
|
394
608
|
# found in the sample -- there is no second workflow to enter.
|
|
609
|
+
#
|
|
610
|
+
# Test As is offered per user, so whether it is available at all is
|
|
611
|
+
# decided once here: `test_as_token` is the CSRF token only when the
|
|
612
|
+
# snapshot says browser identity is supported, and nil otherwise.
|
|
395
613
|
def search_result(result, state)
|
|
396
|
-
|
|
614
|
+
test_as_token = state[:csrf_token] if state[:identity].browser_supported?
|
|
397
615
|
outcomes = result.all_outcomes
|
|
398
616
|
usable = outcomes.select { |outcome| usable_outcome?(outcome) }
|
|
399
617
|
write_count = outcomes.count(&:writes_observed)
|
|
400
|
-
"#{usable_outcomes(usable, result, state)}#{ordinary_sample(result,
|
|
401
|
-
"#{populations_section(result,
|
|
618
|
+
"#{usable_outcomes(usable, result, state, test_as_token)}#{ordinary_sample(result, nil)}" \
|
|
619
|
+
"#{populations_section(result, test_as_token)}#{write_evidence(write_count)}#{search_meta(result)}"
|
|
402
620
|
end
|
|
403
621
|
|
|
404
622
|
# -- Automatic candidate-population retries ----------------------------
|
|
@@ -406,8 +624,8 @@ module Karst
|
|
|
406
624
|
# Every approved population appears here, including the ones
|
|
407
625
|
# deliberately not run -- "not tried" is reported honestly rather
|
|
408
626
|
# than left to look like a failure. Only configured populations ever
|
|
409
|
-
# reach this list; a name merely discovered
|
|
410
|
-
# never executed automatically.
|
|
627
|
+
# reach this list; a name merely discovered after a failed analysis
|
|
628
|
+
# is never executed automatically.
|
|
411
629
|
def populations_section(result, csrf_token)
|
|
412
630
|
return "" if result.attempts.empty?
|
|
413
631
|
|
|
@@ -454,14 +672,6 @@ module Karst
|
|
|
454
672
|
"could not be resolved#{detail}"
|
|
455
673
|
end
|
|
456
674
|
|
|
457
|
-
def dominant_halted_callback(outcomes)
|
|
458
|
-
tally = Hash.new(0)
|
|
459
|
-
outcomes.filter_map(&:halted_callback).each { |callback| tally[callback] += 1 }
|
|
460
|
-
return nil if tally.empty?
|
|
461
|
-
|
|
462
|
-
tally.max_by { |_callback, count| count }.first
|
|
463
|
-
end
|
|
464
|
-
|
|
465
675
|
def users(count)
|
|
466
676
|
count == 1 ? "user" : "users"
|
|
467
677
|
end
|
|
@@ -470,7 +680,7 @@ module Karst
|
|
|
470
680
|
initial = result.initial.outcomes.size
|
|
471
681
|
population = result.population_request_count
|
|
472
682
|
total = initial + population
|
|
473
|
-
"<p class=\"meta\"><strong>
|
|
683
|
+
"<p class=\"meta\"><strong>Tested:</strong> #{escape(initial)} initial · " \
|
|
474
684
|
"#{escape(population)} candidate population · #{escape(total)} total " \
|
|
475
685
|
"#{users(total)}/requests · #{escape(total_seconds(result))}s elapsed.</p>"
|
|
476
686
|
end
|
|
@@ -501,16 +711,6 @@ module Karst
|
|
|
501
711
|
" from up to #{escape(size)} recent users"
|
|
502
712
|
end
|
|
503
713
|
|
|
504
|
-
# A bounded candidate pool is reported explicitly rather than left
|
|
505
|
-
# implicit, so this never reads as "every user was searched."
|
|
506
|
-
def candidate_pool_note(result)
|
|
507
|
-
size = result.candidate_pool_size
|
|
508
|
-
return "" unless size
|
|
509
|
-
|
|
510
|
-
delimited = ActiveSupport::NumberHelper.number_to_delimited(size)
|
|
511
|
-
" · candidate pool: up to #{escape(delimited)} most recent users"
|
|
512
|
-
end
|
|
513
|
-
|
|
514
714
|
def write_evidence(count)
|
|
515
715
|
return "<p class=\"meta\">Database writes observed: 0</p>" if count.zero?
|
|
516
716
|
|
|
@@ -527,42 +727,60 @@ module Karst
|
|
|
527
727
|
|
|
528
728
|
# -- Usable principals ---------------------------------------------------
|
|
529
729
|
|
|
530
|
-
def usable_outcomes(outcomes, result, state)
|
|
531
|
-
csrf_token = state[:csrf_token]
|
|
730
|
+
def usable_outcomes(outcomes, result, state, test_as_token)
|
|
532
731
|
body = if outcomes.empty?
|
|
533
|
-
candidate_review(state
|
|
732
|
+
candidate_review(state, result)
|
|
534
733
|
else
|
|
535
|
-
usable_cards(outcomes,
|
|
536
|
-
result, csrf_token)
|
|
734
|
+
usable_cards(outcomes, result, test_as_token)
|
|
537
735
|
end
|
|
538
736
|
heading = outcomes.empty? ? "No verified usable user found" : "Verified usable user"
|
|
539
737
|
"<section class=\"usable\"><h2>#{heading}</h2>" \
|
|
540
|
-
"#{test_as_hint(outcomes,
|
|
738
|
+
"#{test_as_hint(outcomes, state, test_as_token)}#{body}</section>"
|
|
541
739
|
end
|
|
542
740
|
|
|
543
741
|
# The one place /karst mentions candidate groups at all: a small
|
|
544
742
|
# contextual action, shown only when the analysis found nothing
|
|
545
743
|
# usable and unapproved application-defined groups actually exist on
|
|
546
744
|
# a configured user source. Deliberately not a configuration
|
|
547
|
-
# workflow -- it
|
|
548
|
-
#
|
|
549
|
-
def candidate_review(
|
|
550
|
-
|
|
745
|
+
# workflow -- it names only the candidates which may be explicitly
|
|
746
|
+
# approved in this result, and never executes them merely by rendering.
|
|
747
|
+
def candidate_review(state, result)
|
|
748
|
+
candidates = state[:unapproved_candidates]
|
|
749
|
+
return "" if candidates.empty? || !state[:csrf_token]
|
|
750
|
+
|
|
751
|
+
fields = candidates.map { |candidate| candidate_checkbox(candidate) }.join
|
|
752
|
+
"<form class=\"candidate-review\" method=\"post\" action=\"/karst\">#{approval_alert(state)}" \
|
|
753
|
+
"<p>Karst found application-defined user groups in your app. " \
|
|
754
|
+
"Select only the populations Karst may try, then rerun this analysis:</p>#{fields}" \
|
|
755
|
+
"#{candidate_form_fields(state, result)}" \
|
|
756
|
+
"<button type=\"submit\">Approve selected and retry</button></form>"
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def candidate_form_fields(state, result)
|
|
760
|
+
hidden("operation", "approve_populations") + hidden("csrf_token", state[:csrf_token]) +
|
|
761
|
+
hidden("path", result.path) + hidden("method", result.http_method)
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
def approval_alert(state)
|
|
765
|
+
error = state[:population_approval_error]
|
|
766
|
+
"<p class=\"hint\" role=\"alert\">#{escape(error)}</p>" if error
|
|
767
|
+
end
|
|
551
768
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
769
|
+
def candidate_checkbox(candidate)
|
|
770
|
+
key = [candidate.principal_source, candidate.model_name, candidate.method_name].join("::")
|
|
771
|
+
"<label><input type=\"checkbox\" name=\"population[]\" value=\"#{escape(key)}\">" \
|
|
772
|
+
"#{escape(candidate.method_name)} <small>(#{escape(candidate.model_name)})</small></label>"
|
|
555
773
|
end
|
|
556
774
|
|
|
557
775
|
def usable_cards(outcomes, result, csrf_token)
|
|
558
776
|
outcomes.map { |outcome| usable_principal(outcome, result, csrf_token) }.join
|
|
559
777
|
end
|
|
560
778
|
|
|
561
|
-
def test_as_hint(outcomes,
|
|
562
|
-
return "" if outcomes.empty? ||
|
|
779
|
+
def test_as_hint(outcomes, state, test_as_token)
|
|
780
|
+
return "" if outcomes.empty? || test_as_token
|
|
563
781
|
|
|
564
|
-
|
|
565
|
-
return "<p class=\"hint\">#{escape(
|
|
782
|
+
setup = state[:identity].setup_state
|
|
783
|
+
return "<p class=\"hint\">#{escape(setup.message)}</p>" if setup.status == :ambiguous
|
|
566
784
|
|
|
567
785
|
custom_auth_hint
|
|
568
786
|
end
|
|
@@ -570,66 +788,63 @@ module Karst
|
|
|
570
788
|
def usable_principal(outcome, result, csrf_token)
|
|
571
789
|
writes = outcome.writes_observed ? " — ⚠ #{escape(outcome.write_count)} database writes observed" : ""
|
|
572
790
|
action = test_as_form(outcome.principal, result.path, csrf_token)
|
|
573
|
-
evidence = resource_evidence(outcome, result)
|
|
574
791
|
"<article class=\"usable-principal\"><h4><span>#{principal_label(outcome.principal)}#{writes}</span>" \
|
|
575
792
|
"#{action}</h4><p>#{outcome_title(outcome, prefix: 'Observed ')} · " \
|
|
576
|
-
"#{escape(outcome.elapsed_ms)}ms</p>#{
|
|
793
|
+
"#{escape(outcome.elapsed_ms)}ms</p>#{identity_note(outcome)}#{sampled_for(outcome)}</article>"
|
|
577
794
|
end
|
|
578
795
|
|
|
579
|
-
#
|
|
580
|
-
#
|
|
581
|
-
#
|
|
582
|
-
#
|
|
583
|
-
#
|
|
584
|
-
def
|
|
585
|
-
|
|
586
|
-
return "" if
|
|
796
|
+
# Karst asking the application to run as a principal is not evidence
|
|
797
|
+
# that it did. Silence here means the application itself resolved
|
|
798
|
+
# exactly the requested principal while running this request;
|
|
799
|
+
# anything else says so out loud, because "this user reached the
|
|
800
|
+
# page" would otherwise be a claim Karst cannot support.
|
|
801
|
+
def identity_note(outcome)
|
|
802
|
+
evidence = outcome.identity
|
|
803
|
+
return "" if evidence.nil? || evidence.confirmation == :confirmed
|
|
587
804
|
|
|
588
|
-
"<p class=\"meta\"
|
|
805
|
+
"<p class=\"meta\">⚠ #{escape(identity_summary(evidence))}</p>"
|
|
589
806
|
end
|
|
590
807
|
|
|
591
|
-
def
|
|
592
|
-
evidence
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
""
|
|
808
|
+
def identity_summary(evidence)
|
|
809
|
+
case evidence.confirmation
|
|
810
|
+
when :confirmed_anonymous then "Ran anonymously: the application observed no principal."
|
|
811
|
+
when :absent then "The application observed no principal for this request — it did not run as this user."
|
|
812
|
+
when :mismatch then "The application observed #{observed_label(evidence)}, not the requested user."
|
|
813
|
+
when :contaminated then "Anonymous probe contaminated: the application observed #{observed_label(evidence)}."
|
|
814
|
+
else "Identity not runtime-confirmed: #{evidence.observation_error}"
|
|
815
|
+
end
|
|
600
816
|
end
|
|
601
817
|
|
|
602
|
-
def
|
|
603
|
-
|
|
604
|
-
model, id = key
|
|
605
|
-
"<p><strong>#{escape(model)} ##{escape(id)}</strong></p><ul>#{relationship_rows(items)}</ul>"
|
|
606
|
-
end.join
|
|
818
|
+
def observed_label(evidence)
|
|
819
|
+
evidence.observed ? "#{evidence.observed.model_name} ##{evidence.observed.id}" : "no principal"
|
|
607
820
|
end
|
|
608
821
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
822
|
+
# A compact, secondary line below the observed outcome, never a card
|
|
823
|
+
# of its own, so it augments a usable principal without competing with
|
|
824
|
+
# Test as or the observed outcome for attention. Sampling evidence,
|
|
825
|
+
# not an authorization claim: see PrincipalSampler.
|
|
826
|
+
def sampled_for(outcome)
|
|
827
|
+
reasons = outcome.sampling_reasons
|
|
828
|
+
return "" if reasons.nil? || reasons.empty?
|
|
829
|
+
|
|
830
|
+
"<p class=\"meta\">Sampled for: #{escape(reasons.join(' · '))}</p>"
|
|
613
831
|
end
|
|
614
832
|
|
|
615
833
|
# -- Other observed outcomes (collapsed) ---------------------------------
|
|
616
834
|
|
|
835
|
+
# Semantic outcome groups, largest first -- the same grouping the CLI
|
|
836
|
+
# and MCP evidence use (see Karst::Access::OutcomeGroups).
|
|
617
837
|
def observed_groups(outcomes, path, csrf_token)
|
|
618
|
-
outcomes.
|
|
619
|
-
.map { |_key, grouped| outcome_group(grouped, path, csrf_token) }.join
|
|
620
|
-
end
|
|
621
|
-
|
|
622
|
-
def outcome_group_key(item)
|
|
623
|
-
[item.status, item.redirect, item.exception_class, item.halted_callback,
|
|
624
|
-
item.writes_observed, item.write_count]
|
|
838
|
+
Access::OutcomeGroups.group(outcomes).map { |group| outcome_group(group, path, csrf_token) }.join
|
|
625
839
|
end
|
|
626
840
|
|
|
627
|
-
def outcome_group(
|
|
628
|
-
|
|
841
|
+
def outcome_group(group, path, csrf_token)
|
|
842
|
+
outcomes = group.outcomes
|
|
843
|
+
first = group.representative
|
|
629
844
|
title = outcome_title(first)
|
|
630
845
|
labels = outcomes.map { |item| outcome_principal(item, path, csrf_token) }.join
|
|
631
846
|
halt = halted_callback(first)
|
|
632
|
-
usability =
|
|
847
|
+
usability = group.usable ? "Verified usable" : "Not verified as usable"
|
|
633
848
|
"<details><summary>#{title}#{halt_summary(first)} — #{outcomes.size}</summary>" \
|
|
634
849
|
"#{halt}<p>#{usability}</p><ul>#{labels}</ul></details>"
|
|
635
850
|
end
|
|
@@ -658,11 +873,14 @@ module Karst
|
|
|
658
873
|
def outcome_principal(item, path, csrf_token)
|
|
659
874
|
writes = item.writes_observed ? " — ⚠ #{escape(item.write_count)} database writes observed" : ""
|
|
660
875
|
action = test_as_form(item.principal, path, csrf_token)
|
|
661
|
-
"<li>#{principal_label(item.principal)} — #{escape(item.elapsed_ms)}ms#{writes}
|
|
876
|
+
"<li>#{principal_label(item.principal)} — #{escape(item.elapsed_ms)}ms#{writes}" \
|
|
877
|
+
"#{identity_note(item)}#{action}</li>"
|
|
662
878
|
end
|
|
663
879
|
|
|
880
|
+
# `csrf_token` is the search's test-as token: nil whenever browser
|
|
881
|
+
# identity is unsupported (see #search_result).
|
|
664
882
|
def test_as_form(principal, path, csrf_token)
|
|
665
|
-
return "" unless
|
|
883
|
+
return "" unless csrf_token
|
|
666
884
|
|
|
667
885
|
fields = hidden("operation", "test_as") + hidden("csrf_token", csrf_token) + hidden("path", path) +
|
|
668
886
|
hidden("principal_type", principal.model_name) + hidden("principal_id", principal.id)
|