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,699 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "rack/utils"
5
+ require "active_support/number_helper"
6
+ require "base64"
7
+ require "digest"
8
+
9
+ module Karst
10
+ module Web
11
+ # Read-only HTML presentation of route access results for existing users.
12
+ # All artifact and query-string values cross #escape before entering the
13
+ # document.
14
+ # rubocop:disable Metrics/ModuleLength
15
+ module Panel
16
+ SCRIPT = <<~JS
17
+ document.addEventListener("submit", function(event) {
18
+ var form = event.target;
19
+ if (!form.matches("form.test-as")) return;
20
+ event.preventDefault();
21
+ fetch(form.action, {
22
+ method: "POST", body: new FormData(form), credentials: "same-origin",
23
+ headers: { "Accept": "application/json" }
24
+ }).then(function(response) {
25
+ if (!response.ok) throw new Error("Test as failed");
26
+ return response.json();
27
+ }).then(function(result) {
28
+ window.location.assign(result.location);
29
+ }).catch(function() {
30
+ window.alert("Karst could not change the browser identity. Reload and try again.");
31
+ });
32
+ });
33
+ JS
34
+ private_constant :SCRIPT
35
+
36
+ SCRIPT_HASH = Base64.strict_encode64(Digest::SHA256.digest(SCRIPT))
37
+ private_constant :SCRIPT_HASH
38
+
39
+ CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; " \
40
+ "script-src 'sha256-#{SCRIPT_HASH}'; connect-src 'self'; frame-ancestors 'none'".freeze
41
+ private_constant :CONTENT_SECURITY_POLICY
42
+
43
+ HEADERS = {
44
+ "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store",
45
+ "x-robots-tag" => "noindex, nofollow", "x-frame-options" => "DENY",
46
+ "content-security-policy" => CONTENT_SECURITY_POLICY
47
+ }.freeze
48
+ private_constant :HEADERS
49
+
50
+ STYLE = <<~CSS
51
+ :root{color-scheme:light dark}
52
+ *{box-sizing:border-box}
53
+ body{font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;max-width:48rem;margin:2rem auto;padding:0 1.25rem;color:#1a1a1a;background:#fff}
54
+ h1{font-size:1.4rem;margin:0 0 1rem}
55
+ h2{font-size:.85rem;text-transform:uppercase;letter-spacing:.05em;color:#555;margin:1.75rem 0 .6rem;border-bottom:1px solid #e2e2e2;padding-bottom:.35rem}
56
+ h3{font-size:1rem;margin:0}
57
+ h4{margin:0}
58
+ .route-path{margin:0;color:#555;font-size:.95rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
59
+ .route-lookup{margin-top:.6rem;border:0;padding:0}
60
+ .route-lookup summary{cursor:pointer;color:#555;font-size:.85rem;font-weight:400}
61
+ .route-lookup form{margin-top:.6rem;display:flex;gap:.75rem;flex-wrap:wrap;align-items:flex-end}
62
+ label{display:flex;flex-direction:column;font-size:.78rem;font-weight:600;gap:.25rem;color:#444}
63
+ input{font:inherit;padding:.4rem .5rem;border:1px solid #ccc;border-radius:.3rem}
64
+ button{font:inherit;padding:.45rem .8rem;border:1px solid #ccc;border-radius:.35rem;background:#f4f4f4;cursor:pointer}
65
+ button:hover{background:#eaeaea}
66
+ button:focus-visible,input:focus-visible,summary:focus-visible{outline:2px solid #2563eb;outline-offset:2px}
67
+ button.primary{background:#202124;border-color:#202124;color:#fff;font-weight:600;padding:.65rem 1.15rem;font-size:.95rem}
68
+ button.primary:hover{background:#3a3b3e}
69
+ .hint{color:#8a5b00;font-size:.85rem;margin:.4rem 0}
70
+ .testing-banner{background:#fff7e0;border:1px solid #eacb6b;border-radius:.4rem;padding:.75rem 1rem;margin-bottom:1.25rem}
71
+ .testing-banner form{margin-top:.5rem}
72
+ .testing-banner p{margin:0}
73
+ .testing-banner p+p{margin-top:.35rem}
74
+ section.access{margin-bottom:1rem}
75
+ .meta{color:#555;font-size:.88rem}
76
+ section.usable{margin:1rem 0}
77
+ .usable-principal{border:1px solid #ddd;border-radius:.5rem;padding:.9rem 1rem;margin:.75rem 0}
78
+ .usable-principal h4{display:flex;justify-content:space-between;align-items:center;gap:.75rem;margin:0;flex-wrap:wrap}
79
+ .test-as{border:0;padding:0;margin:0;display:inline}
80
+ .usable-principal button[type=submit]{background:#0f5132;border-color:#0f5132;color:#fff;font-weight:600}
81
+ .usable-principal button[type=submit]:hover{background:#0a3d25}
82
+ .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
+ details{border:1px solid #e2e2e2;border-radius:.4rem;padding:.6rem .8rem;margin:.75rem 0}
86
+ details summary{cursor:pointer;font-weight:600}
87
+ 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
+ .evidence{display:flex;gap:1rem;flex-wrap:wrap;font-size:.9rem}
91
+ .label{font-size:.72rem;font-weight:700;text-transform:uppercase;color:#666}
92
+ .failed,.pending{border-left:4px solid #b3261e}
93
+ section.populations{margin:1rem 0}
94
+ .population-attempt{padding:.35rem 0;font-size:.92rem;border-bottom:1px solid #eee}
95
+ .population-attempt:last-child{border-bottom:0}
96
+ .population-attempt .name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:600}
97
+ .population-attempt.hit .name{color:#0f5132}
98
+ .population-attempt.untried{color:#777}
99
+ .candidate-review{font-size:.9rem;color:#444;margin:.5rem 0 0}
100
+ .ordinary-sample{border:1px solid #ddd;border-radius:.5rem;padding:.8rem 1rem;margin:1rem 0}
101
+ .ordinary-sample h2{margin:0 0 .45rem}
102
+ .ordinary-sample details{margin:.55rem 0}
103
+ .write-warning{border:2px solid #b3261e;border-radius:.4rem;padding:.7rem .85rem;background:#fff7f6}
104
+ small{color:#666}
105
+ .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
+ @media (max-width:480px){
107
+ body{margin:1rem auto;padding:0 .85rem}
108
+ .usable-principal h4{flex-direction:column;align-items:flex-start}
109
+ .route-lookup form{flex-direction:column;align-items:stretch}
110
+ }
111
+ @media (prefers-color-scheme:dark){
112
+ body{background:#16171a;color:#e4e4e6}
113
+ h2{color:#a7a7ad;border-color:#2c2d31}
114
+ .route-path{color:#a7a7ad}
115
+ .meta,small,.candidate-review{color:#9a9aa0}
116
+ input{background:#1f2023;border-color:#3a3b3e;color:#e4e4e6}
117
+ button{background:#26272b;border-color:#3a3b3e;color:#e4e4e6}
118
+ button:hover{background:#303136}
119
+ button.primary{background:#e4e4e6;border-color:#e4e4e6;color:#16171a}
120
+ button.primary:hover{background:#c9c9cc}
121
+ .usable-principal{border-color:#33343a}
122
+ .population-attempt{border-color:#2c2d31}
123
+ .population-attempt.hit .name{color:#7fd8a4}
124
+ .population-attempt.untried{color:#8a8a90}
125
+ .usable-principal button[type=submit]{background:#2e7d52;border-color:#2e7d52;color:#0b1a12}
126
+ .related-state{background:#1c1d20;border-color:#3a3b3e}
127
+ details{border-color:#2c2d31}
128
+ .scenario{border-color:#33343a}
129
+ .testing-banner{background:#3a2f0d;border-color:#6b5423;color:#f0e4c0}
130
+ .hint{color:#d8a63d}
131
+ .failed,.pending{border-left-color:#e5534b}
132
+ .write-warning{background:#321b1a}
133
+ }
134
+ CSS
135
+ private_constant :STYLE
136
+
137
+ # Population-attempt states with no observed result of their own to
138
+ # describe (see Karst::Access::Search). The three that do -- :usable,
139
+ # :no_match, :unresolved -- render from their own evidence instead.
140
+ STATIC_ATTEMPT_STATES = {
141
+ empty: "no matching records",
142
+ already_tried: "every candidate was already tested above",
143
+ skipped: "not tried — a usable user was already found",
144
+ budget_exhausted: "not tried — the retry request budget was reached"
145
+ }.freeze
146
+ private_constant :STATIC_ATTEMPT_STATES
147
+
148
+ # rubocop:disable Metrics/ClassLength
149
+ class << self
150
+ # rubocop:disable Metrics/ParameterLists
151
+ def render(params: {}, access_result: nil, csrf_token: nil, browser_identity_active: false,
152
+ route_lookup_limitation: nil, unapproved_candidate_count: nil,
153
+ principal_source_selection_saved: false, principal_source_selection_error: nil)
154
+ state = { csrf_token: csrf_token, browser_identity_active: browser_identity_active,
155
+ route_lookup_limitation: route_lookup_limitation,
156
+ unapproved_candidate_count: unapproved_candidate_count,
157
+ principal_source_selection_saved: principal_source_selection_saved,
158
+ principal_source_selection_error: principal_source_selection_error }
159
+ [200, HEADERS.dup, [document(params, access_result, state)]]
160
+ end
161
+ # rubocop:enable Metrics/ParameterLists
162
+
163
+ private
164
+
165
+ def document(params, access_result, state)
166
+ <<~HTML
167
+ <!DOCTYPE html>
168
+ <html lang="en"><head><meta charset="utf-8"><title>Karst</title>
169
+ <style>#{STYLE}</style>
170
+ <script>#{SCRIPT}</script>
171
+ </head><body>
172
+ <h1>Karst</h1>
173
+ #{page_body(params, access_result, state)}
174
+ </body></html>
175
+ HTML
176
+ end
177
+
178
+ def page_body(params, access_result, state)
179
+ controller = string_param(params, "controller")
180
+ action = string_param(params, "action")
181
+ http_method = string_param(params, "method")
182
+ path = string_param(params, "path")
183
+ "#{testing_banner(path, state[:csrf_token], state[:browser_identity_active])}" \
184
+ "#{route_header(http_method, path, state[:route_lookup_limitation])}" \
185
+ "#{access_section(http_method, path, controller, action, access_result, state)}"
186
+ end
187
+
188
+ def string_param(params, key)
189
+ value = params[key]
190
+ value.is_a?(String) ? value.strip : ""
191
+ end
192
+
193
+ # -- Testing-as banner ------------------------------------------------
194
+
195
+ def testing_banner(path, csrf_token, active)
196
+ return "" unless active && Identity.browser_supported? && csrf_token
197
+
198
+ fields = hidden("operation", "stop_test_as") + hidden("csrf_token", csrf_token) + hidden("path", path)
199
+ <<~HTML
200
+ <div class="testing-banner" role="status">
201
+ <p><strong>Currently testing as an assumed browser identity.</strong></p>
202
+ <p><small>Stopping clears to whatever identity your configured clear_browser_identity hook defines
203
+ (commonly signed out) -- Karst does not restore a previous session.</small></p>
204
+ <form action="/karst" method="post">#{fields}<button type="submit">Stop testing as</button></form>
205
+ </div>
206
+ HTML
207
+ end
208
+
209
+ # -- Compact route header ----------------------------------------------
210
+
211
+ def route_header(http_method, path, limitation)
212
+ identity = path.empty? ? "<p>No URL selected yet.</p>" : route_identity(http_method, path)
213
+ lookup = route_lookup(http_method, path, limitation)
214
+ "<header class=\"route\">#{identity}#{lookup}</header>"
215
+ end
216
+
217
+ def route_identity(http_method, path)
218
+ "<p class=\"route-path\">#{method_prefix(http_method)}#{escape(path)}</p>"
219
+ end
220
+
221
+ def method_prefix(http_method)
222
+ http_method.empty? ? "" : "#{escape(http_method)} "
223
+ end
224
+
225
+ # rubocop:disable Metrics/MethodLength
226
+ def route_lookup(http_method, path, limitation)
227
+ open = path.empty?
228
+ summary = open ? "What URL are you trying to test?" : "Test a different URL"
229
+ attr = open ? " open" : ""
230
+ message = limitation ? "<p class=\"hint\" role=\"alert\">#{escape(limitation)}</p>" : ""
231
+ <<~HTML
232
+ <details class="route-lookup"#{attr}><summary>#{summary}</summary>
233
+ #{message}
234
+ <form action="/karst" method="get">
235
+ <input type="hidden" name="operation" value="route_lookup">
236
+ <label>Path <input name="path" value="#{escape(path)}" placeholder="/organizations" required></label>
237
+ <label>Method <input name="method" value="#{escape(http_method.empty? ? 'GET' : http_method)}" placeholder="GET" required></label>
238
+ <button type="submit">Use this URL</button>
239
+ </form></details>
240
+ HTML
241
+ end
242
+ # rubocop:enable Metrics/MethodLength
243
+
244
+ # -- Primary action: access analysis ------------------------------------
245
+
246
+ # rubocop:disable Metrics/ParameterLists
247
+ def access_section(http_method, path, controller, action, result, state)
248
+ return setup_notice_section(state) if path.empty? || state[:route_lookup_limitation]
249
+
250
+ context = hidden("controller", controller) + hidden("action", action) +
251
+ hidden("method", http_method) + hidden("path", path)
252
+ body = if http_method == "GET"
253
+ analyze_form(context, state)
254
+ else
255
+ "<p>Access analysis is available for GET routes only.</p>"
256
+ end
257
+ heading = "<h2 class=\"sr-only\">Access analysis</h2>"
258
+ "<section class=\"access\">#{heading}#{body}#{access_result(result, state)}</section>"
259
+ end
260
+ # rubocop:enable Metrics/ParameterLists
261
+
262
+ # A developer with several Devise models (or no automatic
263
+ # authentication integration at all) needs to know that before
264
+ # picking a URL to analyze -- not only after submitting the route
265
+ # lookup form, which they would have no reason to do while /karst
266
+ # otherwise looks entirely blank. Mirrors analyze_form's own hint so
267
+ # selecting a model or reading the custom-auth pointer works
268
+ # identically whether or not a route is already selected.
269
+ #
270
+ # The save notice itself is rendered unconditionally, exactly like
271
+ # analyze_form's own -- a selection saved from the bare /karst page
272
+ # (no route picked yet) that happens to resolve the ambiguity right
273
+ # away must still say so, rather than have the confirmation vanish
274
+ # the moment `sources` becomes truthy.
275
+ def setup_notice_section(state)
276
+ sources = principal_sources
277
+ notice = principal_source_selection_notice(state)
278
+ return notice if sources
279
+
280
+ heading = "<h2 class=\"sr-only\">Access analysis</h2>"
281
+ body = "#{notice}#{principal_source_hint(sources)}"
282
+ "<section class=\"access\">#{heading}#{body}</section>"
283
+ end
284
+
285
+ def analyze_form(context, state)
286
+ sources = principal_sources
287
+ kind = sources && any_representative?(sources) ? "representative " : ""
288
+ label = "Who can use this? (test #{Karst.config.access_sweep_limit} #{kind}users)"
289
+ operation = "<input type=\"hidden\" name=\"operation\" value=\"access_sweep\">"
290
+ button = "<button class=\"primary\" type=\"submit\">#{escape(label)}</button>"
291
+ form = "<form action=\"/karst\" method=\"post\">#{context}#{operation}#{button}</form>"
292
+ # The save notice is independent of whether the save just resolved
293
+ # the ambiguity below: a save that succeeded and immediately made
294
+ # `sources` truthy must still tell the developer it worked, rather
295
+ # than have the confirmation vanish the moment it stops being
296
+ # needed.
297
+ "#{principal_source_selection_notice(state)}#{form}#{principal_source_hint(sources)}"
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) }
312
+ end
313
+
314
+ def principal_source_hint(sources)
315
+ return "" if sources
316
+
317
+ setup = Identity.setup_state
318
+ return principal_source_selection_form(setup) if setup.status == :ambiguous
319
+
320
+ custom_auth_hint
321
+ end
322
+
323
+ def custom_auth_hint
324
+ url = "https://github.com/SilenceDogood1984/karst/blob/main/docs/advanced-configuration.md#custom-or-non-devise-authentication"
325
+ "<p class=\"hint\" role=\"alert\">Karst couldn't determine how this app authenticates users. " \
326
+ "<a href=\"#{url}\">Set up custom authentication</a></p>"
327
+ end
328
+
329
+ # The one place Karst asks a developer to pick which ambiguous
330
+ # Devise model(s) to test, right where the old "configure
331
+ # config.principals" hint used to sit -- no initializer, no separate
332
+ # page. Only ever offers the models Devise.mappings itself currently
333
+ # reports (see Karst::Identity::DeviseSupport); saving is handled by
334
+ # Karst::Web::Middleware, which only ever persists a submitted name
335
+ # that matches one of those same mappings (see
336
+ # Karst::Access::PrincipalSourceSelection).
337
+ def principal_source_selection_form(setup)
338
+ candidates = Identity::DeviseSupport.mappings.sort_by { |mapping| mapping.model.name }
339
+ return "<p class=\"hint\" role=\"alert\">#{escape(setup.message)}</p>" if candidates.size < 2
340
+
341
+ <<~HTML
342
+ <div class="hint" role="alert">
343
+ #{principal_source_selection_intro(candidates)}
344
+ #{principal_source_selection_fields(candidates)}
345
+ </div>
346
+ HTML
347
+ end
348
+
349
+ def principal_source_selection_intro(candidates)
350
+ names = candidates.map { |mapping| mapping.model.name }
351
+ "<p>Karst found #{names.size} user types: #{escape(names.join(', '))}.</p>"
352
+ end
353
+
354
+ def principal_source_selection_fields(candidates)
355
+ selected = Access::SelectedPrincipalSources.mappings.map { |mapping| mapping.model.name }
356
+ rows = candidates.map { |mapping| principal_source_checkbox(mapping, selected) }.join
357
+ <<~HTML
358
+ <form action="/karst" method="post">
359
+ <input type="hidden" name="operation" value="select_principal_sources">
360
+ <p>Which should Karst test?</p>
361
+ #{rows}
362
+ <button type="submit">Save</button>
363
+ </form>
364
+ HTML
365
+ end
366
+
367
+ def principal_source_checkbox(mapping, selected)
368
+ name = mapping.model.name
369
+ box = "<input type=\"checkbox\" name=\"principal[]\" value=\"#{escape(name)}\"" \
370
+ "#{' checked' if selected.include?(name)}>"
371
+ "<label>#{box} #{escape(name)}</label><br>"
372
+ end
373
+
374
+ def principal_source_selection_notice(state)
375
+ saved = "<p class=\"hint\" role=\"status\">Selection saved.</p>" if state[:principal_source_selection_saved]
376
+ error = state[:principal_source_selection_error]
377
+ "#{saved}#{"<p class=\"hint\" role=\"alert\">#{escape(error)}</p>" if error}"
378
+ end
379
+
380
+ def hidden(name, value)
381
+ "<input type=\"hidden\" name=\"#{name}\" value=\"#{escape(value)}\">"
382
+ end
383
+
384
+ def access_result(result, state)
385
+ return "" unless result
386
+ return "<p>Analysis unavailable: #{escape(result.message)}</p>" if result.is_a?(StandardError)
387
+
388
+ search_result(result, state)
389
+ end
390
+
391
+ # Renders one Karst::Access::Search::Result: the ordinary sample and
392
+ # any automatic candidate-population retries as a single answer, so
393
+ # a usable user found through a population reads exactly like one
394
+ # found in the sample -- there is no second workflow to enter.
395
+ def search_result(result, state)
396
+ csrf_token = state[:csrf_token]
397
+ outcomes = result.all_outcomes
398
+ usable = outcomes.select { |outcome| usable_outcome?(outcome) }
399
+ write_count = outcomes.count(&:writes_observed)
400
+ "#{usable_outcomes(usable, result, state)}#{ordinary_sample(result, csrf_token)}" \
401
+ "#{populations_section(result, csrf_token)}#{write_evidence(write_count)}#{search_meta(result)}"
402
+ end
403
+
404
+ # -- Automatic candidate-population retries ----------------------------
405
+
406
+ # Every approved population appears here, including the ones
407
+ # deliberately not run -- "not tried" is reported honestly rather
408
+ # than left to look like a failure. Only configured populations ever
409
+ # reach this list; a name merely discovered at /karst/populations is
410
+ # never executed automatically.
411
+ def populations_section(result, csrf_token)
412
+ return "" if result.attempts.empty?
413
+
414
+ rows = result.attempts.map { |attempt| population_attempt(attempt, result.path, csrf_token) }.join
415
+ "<section class=\"populations\"><h2>Candidate populations</h2>#{rows}</section>"
416
+ end
417
+
418
+ def population_attempt(attempt, path, csrf_token)
419
+ details = attempt.result ? observed_groups(attempt.result.outcomes, path, csrf_token) : ""
420
+ "<div class=\"population-attempt #{attempt_class(attempt)}\">" \
421
+ "<span class=\"name\">#{escape(attempt.name)}</span><br>#{attempt_state(attempt)}#{details}</div>"
422
+ end
423
+
424
+ def attempt_class(attempt)
425
+ case attempt.state
426
+ when :usable then "hit"
427
+ when :skipped, :budget_exhausted then "untried"
428
+ else ""
429
+ end
430
+ end
431
+
432
+ def attempt_state(attempt)
433
+ case attempt.state
434
+ when :usable then population_hit(attempt)
435
+ when :no_match then population_miss(attempt)
436
+ when :unresolved then population_unresolved(attempt)
437
+ else STATIC_ATTEMPT_STATES.fetch(attempt.state, "not tried")
438
+ end
439
+ end
440
+
441
+ def population_hit(attempt)
442
+ outcome = attempt.result.outcomes.find { |item| usable_outcome?(item) }
443
+ "#{escape(attempt.result.outcomes.size)} #{users(attempt.result.outcomes.size)} tested<br>" \
444
+ "#{principal_label(outcome.principal)} → #{outcome_title(outcome)} ✓"
445
+ end
446
+
447
+ def population_miss(attempt)
448
+ outcomes = attempt.result.outcomes
449
+ "#{escape(outcomes.size)} #{users(outcomes.size)} tested<br>none verified usable"
450
+ end
451
+
452
+ def population_unresolved(attempt)
453
+ detail = attempt.error ? " (#{escape(attempt.error)})" : ""
454
+ "could not be resolved#{detail}"
455
+ end
456
+
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
+ def users(count)
466
+ count == 1 ? "user" : "users"
467
+ end
468
+
469
+ def search_meta(result)
470
+ initial = result.initial.outcomes.size
471
+ population = result.population_request_count
472
+ total = initial + population
473
+ "<p class=\"meta\"><strong>Request accounting:</strong> #{escape(initial)} initial · " \
474
+ "#{escape(population)} candidate population · #{escape(total)} total " \
475
+ "#{users(total)}/requests · #{escape(total_seconds(result))}s elapsed.</p>"
476
+ end
477
+
478
+ def total_seconds(result)
479
+ total = result.initial.elapsed_ms + result.attempted.sum { |attempt| attempt.result.elapsed_ms }
480
+ (total / 1000.0).round(2)
481
+ end
482
+
483
+ # The ordinary sample's own result stays visible even when a
484
+ # population later succeeded: "nothing recent worked, and here is
485
+ # what stopped them" is the evidence that explains why a population
486
+ # was tried at all.
487
+ def ordinary_sample(result, _csrf_token)
488
+ outcomes = result.initial.outcomes
489
+ usable = outcomes.count { |outcome| usable_outcome?(outcome) }
490
+ result_text = usable.positive? ? "#{usable} verified usable" : "No verified usable user"
491
+ pool = ordinary_pool(result.initial)
492
+ "<section class=\"ordinary-sample\"><h2>Ordinary sample</h2>" \
493
+ "<p>#{escape(outcomes.size)} #{users(outcomes.size)} tested#{pool}<br>#{result_text}</p>" \
494
+ "<strong>Observed:</strong>#{observed_groups(outcomes, result.path, nil)}</section>"
495
+ end
496
+
497
+ def ordinary_pool(initial)
498
+ return "" unless initial.candidate_pool_size
499
+
500
+ size = ActiveSupport::NumberHelper.number_to_delimited(initial.candidate_pool_size)
501
+ " from up to #{escape(size)} recent users"
502
+ end
503
+
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
+ def write_evidence(count)
515
+ return "<p class=\"meta\">Database writes observed: 0</p>" if count.zero?
516
+
517
+ "<div class=\"write-warning\" role=\"alert\"><strong>⚠ Database writes observed during " \
518
+ "#{escape(count)} #{count == 1 ? 'probe' : 'probes'}.</strong><br>" \
519
+ "Rollback was attempted on the same Active Record connection." \
520
+ "<br><small>Jobs, mail, external HTTP, files, Redis, and other database connections are not " \
521
+ "isolated by same-connection rollback.</small></div>"
522
+ end
523
+
524
+ def usable_outcome?(outcome)
525
+ Karst.config.usable_access_outcome.call(outcome)
526
+ end
527
+
528
+ # -- Usable principals ---------------------------------------------------
529
+
530
+ def usable_outcomes(outcomes, result, state)
531
+ csrf_token = state[:csrf_token]
532
+ body = if outcomes.empty?
533
+ candidate_review(state[:unapproved_candidate_count])
534
+ else
535
+ usable_cards(outcomes,
536
+ result, csrf_token)
537
+ end
538
+ heading = outcomes.empty? ? "No verified usable user found" : "Verified usable user"
539
+ "<section class=\"usable\"><h2>#{heading}</h2>" \
540
+ "#{test_as_hint(outcomes, csrf_token)}#{body}</section>"
541
+ end
542
+
543
+ # The one place /karst mentions candidate groups at all: a small
544
+ # contextual action, shown only when the analysis found nothing
545
+ # usable and unapproved application-defined groups actually exist on
546
+ # a configured user source. Deliberately not a configuration
547
+ # workflow -- it says what Karst found and offers to show it, and
548
+ # names nothing it has not been approved to run.
549
+ def candidate_review(count)
550
+ return "" unless count&.positive?
551
+
552
+ "<p class=\"candidate-review\">Karst found #{escape(count)} application-defined user " \
553
+ "#{count == 1 ? 'group' : 'groups'} that could be tried. " \
554
+ "<a href=\"/karst/populations\">Review candidate groups</a></p>"
555
+ end
556
+
557
+ def usable_cards(outcomes, result, csrf_token)
558
+ outcomes.map { |outcome| usable_principal(outcome, result, csrf_token) }.join
559
+ end
560
+
561
+ def test_as_hint(outcomes, csrf_token)
562
+ return "" if outcomes.empty? || (Identity.browser_supported? && csrf_token)
563
+
564
+ state = Identity.setup_state
565
+ return "<p class=\"hint\">#{escape(state.message)}</p>" if state.status == :ambiguous
566
+
567
+ custom_auth_hint
568
+ end
569
+
570
+ def usable_principal(outcome, result, csrf_token)
571
+ writes = outcome.writes_observed ? " — ⚠ #{escape(outcome.write_count)} database writes observed" : ""
572
+ action = test_as_form(outcome.principal, result.path, csrf_token)
573
+ evidence = resource_evidence(outcome, result)
574
+ "<article class=\"usable-principal\"><h4><span>#{principal_label(outcome.principal)}#{writes}</span>" \
575
+ "#{action}</h4><p>#{outcome_title(outcome, prefix: 'Observed ')} · " \
576
+ "#{escape(outcome.elapsed_ms)}ms</p>#{sampled_for(outcome)}#{evidence}</article>"
577
+ end
578
+
579
+ # A compact, secondary line -- deliberately below the observed
580
+ # outcome and above any resource evidence, never a card of its own --
581
+ # so it augments a usable principal without competing with Test as,
582
+ # the observed outcome, or resource evidence for attention. Sampling
583
+ # evidence, not an authorization claim: see PrincipalSampler.
584
+ def sampled_for(outcome)
585
+ reasons = outcome.sampling_reasons
586
+ return "" if reasons.nil? || reasons.empty?
587
+
588
+ "<p class=\"meta\">Sampled for: #{escape(reasons.join(' · '))}</p>"
589
+ end
590
+
591
+ def resource_evidence(outcome, result)
592
+ evidence = Access::ResourceEvidence.for_outcome(outcome: outcome, path: result.path,
593
+ http_method: result.http_method)
594
+ return "" if evidence.limitation || evidence.relationships.empty?
595
+
596
+ "<div class=\"related-state\"><strong>Related state</strong>" \
597
+ "#{relationship_groups(evidence.relationships)}</div>"
598
+ rescue StandardError
599
+ ""
600
+ end
601
+
602
+ def relationship_groups(relationships)
603
+ relationships.group_by { |item| [item.from_model, item.from_id] }.map do |key, items|
604
+ model, id = key
605
+ "<p><strong>#{escape(model)} ##{escape(id)}</strong></p><ul>#{relationship_rows(items)}</ul>"
606
+ end.join
607
+ end
608
+
609
+ def relationship_rows(relationships)
610
+ relationships.map do |item|
611
+ "<li>#{escape(item.column)} → #{escape(item.to_model)} ##{escape(item.to_id)}</li>"
612
+ end.join
613
+ end
614
+
615
+ # -- Other observed outcomes (collapsed) ---------------------------------
616
+
617
+ def observed_groups(outcomes, path, csrf_token)
618
+ outcomes.group_by { |item| outcome_group_key(item) }
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]
625
+ end
626
+
627
+ def outcome_group(outcomes, path, csrf_token)
628
+ first = outcomes.first
629
+ title = outcome_title(first)
630
+ labels = outcomes.map { |item| outcome_principal(item, path, csrf_token) }.join
631
+ halt = halted_callback(first)
632
+ usability = usable_outcome?(first) ? "Verified usable" : "Not verified as usable"
633
+ "<details><summary>#{title}#{halt_summary(first)} — #{outcomes.size}</summary>" \
634
+ "#{halt}<p>#{usability}</p><ul>#{labels}</ul></details>"
635
+ end
636
+
637
+ def halt_summary(outcome)
638
+ outcome.halted_callback ? " · halted at #{escape(outcome.halted_callback)}" : ""
639
+ end
640
+
641
+ def halted_callback(outcome)
642
+ return "" unless outcome.halted_callback
643
+
644
+ "<p>Halted callback: #{escape(outcome.halted_callback)}</p>"
645
+ end
646
+
647
+ def outcome_title(outcome, prefix: "")
648
+ title = if outcome.exception_class
649
+ "Exception: #{escape(outcome.exception_class)}"
650
+ elsif outcome.redirect
651
+ "#{escape(outcome.status)} → #{escape(outcome.redirect)}"
652
+ else
653
+ status_title(outcome.status)
654
+ end
655
+ "#{prefix}#{title}"
656
+ end
657
+
658
+ def outcome_principal(item, path, csrf_token)
659
+ writes = item.writes_observed ? " — ⚠ #{escape(item.write_count)} database writes observed" : ""
660
+ action = test_as_form(item.principal, path, csrf_token)
661
+ "<li>#{principal_label(item.principal)} — #{escape(item.elapsed_ms)}ms#{writes}#{action}</li>"
662
+ end
663
+
664
+ def test_as_form(principal, path, csrf_token)
665
+ return "" unless Identity.browser_supported? && csrf_token
666
+
667
+ fields = hidden("operation", "test_as") + hidden("csrf_token", csrf_token) + hidden("path", path) +
668
+ hidden("principal_type", principal.model_name) + hidden("principal_id", principal.id)
669
+ button = "<button type=\"submit\">Test as</button>"
670
+ " <form class=\"test-as\" action=\"/karst\" method=\"post\">#{fields}#{button}</form>"
671
+ end
672
+
673
+ def status_title(status)
674
+ phrase = Rack::Utils::HTTP_STATUS_CODES[status]
675
+ phrase ? "#{escape(status)} #{escape(phrase)}" : escape(status)
676
+ end
677
+
678
+ def principal_label(principal)
679
+ identifier = principal.respond_to?(:authentication_identifier) && principal.authentication_identifier
680
+ key = principal.respond_to?(:authentication_key) && principal.authentication_key
681
+ return escape(principal.display_label) unless identifier
682
+
683
+ identity = if key == :email
684
+ "<a href=\"mailto:#{escape(identifier)}\">#{escape(identifier)}</a>"
685
+ else
686
+ escape(identifier)
687
+ end
688
+ "#{identity} · #{escape(principal.model_name)} ##{escape(principal.id)}"
689
+ end
690
+
691
+ def escape(value)
692
+ CGI.escapeHTML(value.to_s)
693
+ end
694
+ end
695
+ # rubocop:enable Metrics/ClassLength
696
+ end
697
+ # rubocop:enable Metrics/ModuleLength
698
+ end
699
+ end