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.
- checksums.yaml +7 -0
- data/ARCHITECTURE.md +59 -0
- data/CHANGELOG.md +67 -0
- data/CODE_OF_CONDUCT.md +29 -0
- data/CONTRIBUTING.md +45 -0
- data/LICENSE +21 -0
- data/README.md +140 -0
- data/SECURITY.md +11 -0
- data/docs/advanced-configuration.md +188 -0
- data/lib/generators/karst/install/install_generator.rb +88 -0
- data/lib/generators/karst/install/templates/karst_identity_controller.rb +19 -0
- data/lib/generators/karst/install/templates/karst_initializer.rb +18 -0
- data/lib/karst/access/approved_populations.rb +128 -0
- data/lib/karst/access/candidate_population.rb +86 -0
- data/lib/karst/access/database_isolation.rb +62 -0
- data/lib/karst/access/population_approvals.rb +195 -0
- data/lib/karst/access/population_config_snippet.rb +67 -0
- data/lib/karst/access/population_discovery.rb +271 -0
- data/lib/karst/access/population_preview.rb +83 -0
- data/lib/karst/access/principal_sampler.rb +241 -0
- data/lib/karst/access/principal_selection.rb +90 -0
- data/lib/karst/access/principal_source.rb +143 -0
- data/lib/karst/access/principal_source_selection.rb +161 -0
- data/lib/karst/access/probe_application.rb +164 -0
- data/lib/karst/access/resource_evidence.rb +233 -0
- data/lib/karst/access/search.rb +265 -0
- data/lib/karst/access/selected_principal_sources.rb +65 -0
- data/lib/karst/access/sensitive_attribute_names.rb +26 -0
- data/lib/karst/access/sweep.rb +198 -0
- data/lib/karst/cli/verification.rb +182 -0
- data/lib/karst/configuration.rb +223 -0
- data/lib/karst/execution_context.rb +83 -0
- data/lib/karst/identity/devise_support.rb +90 -0
- data/lib/karst/identity/warden_adapter.rb +130 -0
- data/lib/karst/identity.rb +479 -0
- data/lib/karst/mcp/server.rb +63 -0
- data/lib/karst/mcp/verify_access_tool.rb +68 -0
- data/lib/karst/railtie.rb +30 -0
- data/lib/karst/spec/catalog.rb +199 -0
- data/lib/karst/spec/example_observation.rb +31 -0
- data/lib/karst/spec/observer.rb +300 -0
- data/lib/karst/spec/principal.rb +12 -0
- data/lib/karst/spec/reporter.rb +83 -0
- data/lib/karst/spec/request_observation.rb +38 -0
- data/lib/karst/spec/scenario.rb +65 -0
- data/lib/karst/value.rb +35 -0
- data/lib/karst/version.rb +5 -0
- data/lib/karst/web/badge.rb +183 -0
- data/lib/karst/web/browser_identity.rb +103 -0
- data/lib/karst/web/locality.rb +64 -0
- data/lib/karst/web/middleware.rb +377 -0
- data/lib/karst/web/panel.rb +699 -0
- data/lib/karst/web/populations_panel.rb +391 -0
- data/lib/karst/web/route_lookup.rb +65 -0
- data/lib/karst.rb +56 -0
- data/lib/rails/commands/karst/boot.rb +24 -0
- data/lib/rails/commands/karst/mcp/mcp_command.rb +26 -0
- data/lib/rails/commands/karst/verify/verify_command.rb +39 -0
- data/lib/tasks/karst.rake +34 -0
- metadata +138 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require_relative "../access/population_preview"
|
|
6
|
+
|
|
7
|
+
module Karst
|
|
8
|
+
module Web
|
|
9
|
+
# The local approval surface for Karst::Access::PopulationDiscovery:
|
|
10
|
+
# browse the application-defined groups Karst found, approve the ones
|
|
11
|
+
# Karst may try, optionally preview one, and see which approvals are no
|
|
12
|
+
# longer doing anything. Approving is the whole workflow -- generating
|
|
13
|
+
# Ruby configuration is kept as an advanced export path, not the way a
|
|
14
|
+
# developer is expected to finish.
|
|
15
|
+
#
|
|
16
|
+
# Kept as a pure renderer, exactly like Karst::Web::Panel: every value
|
|
17
|
+
# this module needs (the discovery result, the approved entries, stale
|
|
18
|
+
# approvals, an optional storage error, an optional generated snippet, an
|
|
19
|
+
# optional single preview) is computed by Karst::Web::Middleware and
|
|
20
|
+
# passed in, so this file never touches Active Record, the filesystem, or
|
|
21
|
+
# Karst.config directly.
|
|
22
|
+
# rubocop:disable Metrics/ModuleLength
|
|
23
|
+
module PopulationsPanel
|
|
24
|
+
CANDIDATE_SEPARATOR = "::"
|
|
25
|
+
|
|
26
|
+
# Why an approved entry currently does nothing (see
|
|
27
|
+
# Karst::Access::ApprovedPopulations.stale). Reported rather than
|
|
28
|
+
# silently ignored -- and never repaired automatically, since the fix
|
|
29
|
+
# is always a decision about the application, not about Karst.
|
|
30
|
+
STALE_REASONS = {
|
|
31
|
+
not_discovered: "no longer a discovered scope on this model — not used",
|
|
32
|
+
no_principal_source: "not part of a configured user source — not used"
|
|
33
|
+
}.freeze
|
|
34
|
+
private_constant :STALE_REASONS
|
|
35
|
+
|
|
36
|
+
STYLE = <<~CSS
|
|
37
|
+
:root{color-scheme:light dark}
|
|
38
|
+
*{box-sizing:border-box}
|
|
39
|
+
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}
|
|
40
|
+
h1{font-size:1.4rem;margin:0 0 1rem}
|
|
41
|
+
h2{font-size:1.1rem;margin:1.5rem 0 .5rem}
|
|
42
|
+
h3{font-size:.85rem;text-transform:uppercase;letter-spacing:.05em;color:#555;margin:1.5rem 0 .6rem;border-bottom:1px solid #e2e2e2;padding-bottom:.35rem}
|
|
43
|
+
a{color:#2563eb}
|
|
44
|
+
.lead{margin:.4rem 0 1rem}
|
|
45
|
+
.hint{color:#8a5b00;font-size:.85rem;margin:.4rem 0}
|
|
46
|
+
.warning{color:#8a5b00;font-size:.85rem;margin:.4rem 0;border:1px solid #eacb6b;background:#fff7e0;border-radius:.4rem;padding:.6rem .8rem}
|
|
47
|
+
.saved{color:#0f5132;font-size:.9rem;margin:.4rem 0;border:1px solid #a6d9bb;background:#e6f4ea;border-radius:.4rem;padding:.6rem .8rem}
|
|
48
|
+
.search-box{margin:1rem 0}
|
|
49
|
+
.search-box label{display:flex;flex-direction:column;font-size:.78rem;font-weight:600;gap:.25rem;color:#444}
|
|
50
|
+
.search-box input{font:inherit;padding:.5rem .6rem;border:1px solid #ccc;border-radius:.3rem;max-width:24rem}
|
|
51
|
+
button{font:inherit;padding:.35rem .65rem;border:1px solid #ccc;border-radius:.35rem;background:#f4f4f4;cursor:pointer}
|
|
52
|
+
button:hover{background:#eaeaea}
|
|
53
|
+
button:focus-visible,input:focus-visible,summary:focus-visible,a:focus-visible{outline:2px solid #2563eb;outline-offset:2px}
|
|
54
|
+
button.primary{background:#202124;border-color:#202124;color:#fff;font-weight:600;padding:.65rem 1.15rem;font-size:.95rem;margin-top:1rem}
|
|
55
|
+
button.primary:hover{background:#3a3b3e}
|
|
56
|
+
.approved-summary{border:1px solid #ddd;border-radius:.5rem;padding:.75rem 1rem}
|
|
57
|
+
.approved-model{margin:.4rem 0}
|
|
58
|
+
.approved-model strong{display:block}
|
|
59
|
+
.approved-model ul{margin:.2rem 0 0;padding-left:1.2rem}
|
|
60
|
+
.approved-model .stale{color:#8a5b00;font-size:.85rem}
|
|
61
|
+
details.model-group{border:1px solid #e2e2e2;border-radius:.4rem;padding:.5rem .8rem;margin:.5rem 0}
|
|
62
|
+
details.model-group summary{cursor:pointer;font-weight:600;display:flex;gap:.6rem;align-items:baseline;flex-wrap:wrap}
|
|
63
|
+
details.model-group summary .count{font-weight:400;color:#666;font-size:.85rem}
|
|
64
|
+
details.model-group summary .badge{font-weight:400;font-size:.72rem;color:#0f5132;background:#e6f4ea;border-radius:.6rem;padding:.05rem .5rem}
|
|
65
|
+
.candidate-list{list-style:none;margin:.6rem 0 0;padding:0}
|
|
66
|
+
.candidate-row{display:flex;align-items:center;gap:.6rem;padding:.3rem 0;flex-wrap:wrap}
|
|
67
|
+
.candidate-row label{display:flex;align-items:center;gap:.4rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.92rem}
|
|
68
|
+
.preview{flex-basis:100%;margin:.2rem 0 .3rem 1.6rem;font-size:.85rem;color:#444}
|
|
69
|
+
.preview.error{color:#8a5b00}
|
|
70
|
+
.snippet{margin:.6rem 0}
|
|
71
|
+
.snippet textarea{width:100%;min-height:6rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.85rem;padding:.5rem;border:1px solid #ccc;border-radius:.3rem}
|
|
72
|
+
details.advanced{border:1px solid #e2e2e2;border-radius:.4rem;padding:.5rem .8rem;margin:1.5rem 0}
|
|
73
|
+
details.advanced summary{cursor:pointer;font-size:.9rem}
|
|
74
|
+
code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.9em}
|
|
75
|
+
small{color:#666}
|
|
76
|
+
@media (prefers-color-scheme:dark){
|
|
77
|
+
body{background:#16171a;color:#e4e4e6}
|
|
78
|
+
a{color:#7aa2f7}
|
|
79
|
+
h3{color:#a7a7ad;border-color:#2c2d31}
|
|
80
|
+
.search-box input,.snippet textarea{background:#1f2023;border-color:#3a3b3e;color:#e4e4e6}
|
|
81
|
+
button{background:#26272b;border-color:#3a3b3e;color:#e4e4e6}
|
|
82
|
+
button:hover{background:#303136}
|
|
83
|
+
button.primary{background:#e4e4e6;border-color:#e4e4e6;color:#16171a}
|
|
84
|
+
button.primary:hover{background:#c9c9cc}
|
|
85
|
+
.approved-summary,details.model-group,details.advanced{border-color:#33343a}
|
|
86
|
+
.hint,.warning,.approved-model .stale{color:#d8a63d}
|
|
87
|
+
.warning{background:#3a2f0d;border-color:#6b5423}
|
|
88
|
+
.saved{background:#123822;border-color:#1f5c37;color:#7fd8a4}
|
|
89
|
+
details.model-group summary .badge{background:#123822;color:#7fd8a4}
|
|
90
|
+
}
|
|
91
|
+
CSS
|
|
92
|
+
private_constant :STYLE
|
|
93
|
+
|
|
94
|
+
SEARCH_SCRIPT = <<~JS
|
|
95
|
+
(function () {
|
|
96
|
+
var input = document.getElementById("karst-population-search");
|
|
97
|
+
if (!input) return;
|
|
98
|
+
var groups = document.querySelectorAll(".model-group");
|
|
99
|
+
input.addEventListener("input", function () {
|
|
100
|
+
var query = input.value.trim().toLowerCase();
|
|
101
|
+
groups.forEach(function (details) {
|
|
102
|
+
var model = (details.getAttribute("data-model") || "").toLowerCase();
|
|
103
|
+
var modelMatches = query === "" || model.indexOf(query) !== -1;
|
|
104
|
+
var rows = details.querySelectorAll(".candidate-row");
|
|
105
|
+
var anyRowMatches = false;
|
|
106
|
+
rows.forEach(function (row) {
|
|
107
|
+
var name = (row.getAttribute("data-name") || "").toLowerCase();
|
|
108
|
+
var rowMatches = query === "" || modelMatches || name.indexOf(query) !== -1;
|
|
109
|
+
row.style.display = rowMatches ? "" : "none";
|
|
110
|
+
if (rowMatches) anyRowMatches = true;
|
|
111
|
+
});
|
|
112
|
+
var show = query === "" || modelMatches || anyRowMatches;
|
|
113
|
+
details.style.display = show ? "" : "none";
|
|
114
|
+
if (query !== "" && show) details.open = true;
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
})();
|
|
118
|
+
JS
|
|
119
|
+
private_constant :SEARCH_SCRIPT
|
|
120
|
+
|
|
121
|
+
COPY_SCRIPT = <<~JS
|
|
122
|
+
(function () {
|
|
123
|
+
var button = document.getElementById("karst-copy-snippet");
|
|
124
|
+
var field = document.getElementById("karst-snippet-code");
|
|
125
|
+
if (!button || !field || !navigator.clipboard) return;
|
|
126
|
+
button.addEventListener("click", function () {
|
|
127
|
+
navigator.clipboard.writeText(field.value).then(function () {
|
|
128
|
+
button.textContent = "Copied";
|
|
129
|
+
setTimeout(function () { button.textContent = "Copy"; }, 1500);
|
|
130
|
+
}, function () {});
|
|
131
|
+
});
|
|
132
|
+
})();
|
|
133
|
+
JS
|
|
134
|
+
private_constant :COPY_SCRIPT
|
|
135
|
+
|
|
136
|
+
# rubocop:disable Metrics/ClassLength
|
|
137
|
+
class << self
|
|
138
|
+
# `approved` and each entry of `stale` are anything exposing
|
|
139
|
+
# #model_name/#method_name -- in practice
|
|
140
|
+
# Karst::Access::PopulationApprovals::Entry.
|
|
141
|
+
# rubocop:disable Metrics/ParameterLists
|
|
142
|
+
def render(discovery:, approved: [], stale: [], snippet: nil, preview: nil, storage_path: nil,
|
|
143
|
+
storage_error: nil, saved: false)
|
|
144
|
+
nonce = SecureRandom.hex(16)
|
|
145
|
+
state = { approved: approved, stale: stale, snippet: snippet, preview: preview,
|
|
146
|
+
storage_path: storage_path, storage_error: storage_error, saved: saved }
|
|
147
|
+
[200, headers(nonce), [document(discovery, state, nonce)]]
|
|
148
|
+
end
|
|
149
|
+
# rubocop:enable Metrics/ParameterLists
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def headers(nonce)
|
|
154
|
+
csp = "default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-#{nonce}'; frame-ancestors 'none'"
|
|
155
|
+
{
|
|
156
|
+
"content-type" => "text/html; charset=utf-8", "cache-control" => "no-store",
|
|
157
|
+
"x-robots-tag" => "noindex, nofollow", "x-frame-options" => "DENY", "content-security-policy" => csp
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The per-candidate Preview and the advanced snippet export submit
|
|
162
|
+
# through their own form, referenced by `form=` rather than nested
|
|
163
|
+
# inside the approval form (HTML forbids nesting): pressing either
|
|
164
|
+
# therefore cannot smuggle checkbox state into a save, so the only
|
|
165
|
+
# way to change what Karst may execute is the explicit approve
|
|
166
|
+
# button.
|
|
167
|
+
def document(discovery, state, nonce)
|
|
168
|
+
<<~HTML
|
|
169
|
+
<!DOCTYPE html>
|
|
170
|
+
<html lang="en"><head><meta charset="utf-8"><title>Karst — Candidate groups</title>
|
|
171
|
+
<style>#{STYLE}</style>
|
|
172
|
+
</head><body>
|
|
173
|
+
<h1>Karst</h1>
|
|
174
|
+
<p><a href="/karst">← Back to route evidence</a></p>
|
|
175
|
+
<h2>Candidate groups</h2>
|
|
176
|
+
<p class="lead">Approving a group lets Karst try a few existing users from it when the ordinary sample
|
|
177
|
+
fails. Approving is not a claim that the group grants access — only running an analysis against a route
|
|
178
|
+
shows what actually happens.</p>
|
|
179
|
+
#{saved_notice(state[:saved])}
|
|
180
|
+
#{storage_error(state[:storage_error])}
|
|
181
|
+
#{load_warning(discovery)}
|
|
182
|
+
<form id="karst-secondary" method="post" action="/karst/populations"></form>
|
|
183
|
+
<form method="post" action="/karst/populations">
|
|
184
|
+
#{search_box}
|
|
185
|
+
#{approved_section(state[:approved], state[:stale])}
|
|
186
|
+
#{model_groups_section(discovery, state[:approved], state[:preview])}
|
|
187
|
+
<button type="submit" name="save_approvals" value="1" class="primary">Approve selected groups</button>
|
|
188
|
+
</form>
|
|
189
|
+
#{advanced_section(state[:snippet])}
|
|
190
|
+
#{storage_note(state[:storage_path])}
|
|
191
|
+
<script nonce="#{nonce}">#{SEARCH_SCRIPT}#{COPY_SCRIPT}</script>
|
|
192
|
+
</body></html>
|
|
193
|
+
HTML
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def saved_notice(saved)
|
|
197
|
+
return "" unless saved
|
|
198
|
+
|
|
199
|
+
"<p class=\"saved\" role=\"status\">Approvals saved.</p>"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def storage_error(error)
|
|
203
|
+
return "" unless error
|
|
204
|
+
|
|
205
|
+
"<p class=\"warning\" role=\"alert\">#{escape(error)}</p>"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def storage_note(path)
|
|
209
|
+
return "" unless path
|
|
210
|
+
|
|
211
|
+
"<p><small>Approvals are stored locally in <code>#{escape(path)}</code> as plain model and " \
|
|
212
|
+
"scope names — no user data, no Ruby. Delete that file to reset every approval.</small></p>"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def load_warning(discovery)
|
|
216
|
+
return "" unless discovery.load_warning
|
|
217
|
+
|
|
218
|
+
"<p class=\"warning\" role=\"alert\">#{escape(discovery.load_warning)}</p>"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def search_box
|
|
222
|
+
<<~HTML
|
|
223
|
+
<div class="search-box">
|
|
224
|
+
<label>Search groups or models
|
|
225
|
+
<input type="search" id="karst-population-search" placeholder="e.g. admin, User, subscription">
|
|
226
|
+
</label>
|
|
227
|
+
</div>
|
|
228
|
+
HTML
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# -- Approved summary --------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def approved_section(approved, stale)
|
|
234
|
+
body = if approved.empty?
|
|
235
|
+
"<p>No groups approved yet.</p>"
|
|
236
|
+
else
|
|
237
|
+
approved.group_by(&:model_name).sort.map do |model_name, group|
|
|
238
|
+
approved_model(model_name, group, stale)
|
|
239
|
+
end.join
|
|
240
|
+
end
|
|
241
|
+
"<section class=\"approved-summary\"><h3>Approved (#{approved.size})</h3>#{body}</section>"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def approved_model(model_name, entries, stale)
|
|
245
|
+
items = entries.sort_by { |entry| entry.method_name.to_s }.map do |entry|
|
|
246
|
+
"<li>#{escape(entry.method_name)}#{stale_note(entry, stale)}</li>"
|
|
247
|
+
end.join
|
|
248
|
+
"<div class=\"approved-model\"><strong>#{escape(model_name)}</strong><ul>#{items}</ul></div>"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def stale_note(entry, stale)
|
|
252
|
+
match = stale.find { |item, _reason| same_candidate?(item, entry) }
|
|
253
|
+
return "" unless match
|
|
254
|
+
|
|
255
|
+
" <span class=\"stale\">— #{escape(STALE_REASONS.fetch(match.last, 'not used'))}</span>"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def same_candidate?(left, right)
|
|
259
|
+
left.model_name.to_s == right.model_name.to_s && left.method_name.to_s == right.method_name.to_s
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# -- Model groups ------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
def model_groups_section(discovery, approved, preview)
|
|
265
|
+
groups = discovery.model_groups.reject { |group| group.candidate_names.empty? }
|
|
266
|
+
body = if groups.empty?
|
|
267
|
+
"<p>No candidate groups were discovered.</p>"
|
|
268
|
+
else
|
|
269
|
+
groups.map { |group| model_group(group, approved, preview) }.join
|
|
270
|
+
end
|
|
271
|
+
"<section class=\"model-groups\"><h3>Available models</h3>#{body}</section>"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def model_group(group, approved, preview)
|
|
275
|
+
approved_names = approved.select { |entry| entry.model_name == group.model_name }
|
|
276
|
+
.map { |entry| entry.method_name.to_s }
|
|
277
|
+
open = approved_names.any? ? " open" : ""
|
|
278
|
+
rows = group.candidate_names.map { |name| candidate_row(group, name, approved_names, preview) }.join
|
|
279
|
+
<<~HTML
|
|
280
|
+
<details class="model-group" data-model="#{escape(group.model_name)}"#{open}>
|
|
281
|
+
<summary>#{model_summary(group)}</summary>
|
|
282
|
+
<ul class="candidate-list">#{rows}</ul>
|
|
283
|
+
</details>
|
|
284
|
+
HTML
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def model_summary(group)
|
|
288
|
+
count = group.candidate_names.size
|
|
289
|
+
"#{escape(group.model_name)} <span class=\"count\">#{count} group#{'s' unless count == 1}</span>" \
|
|
290
|
+
"#{principal_badge(group)}"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def principal_badge(group)
|
|
294
|
+
return "" unless group.principal_source
|
|
295
|
+
|
|
296
|
+
"<span class=\"badge\">user source: #{escape(group.principal_source)}</span>"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def candidate_row(group, method_name, approved_names, preview)
|
|
300
|
+
key = candidate_key(group.model_name, method_name)
|
|
301
|
+
checked = approved_names.include?(method_name.to_s)
|
|
302
|
+
box = "<input type=\"checkbox\" name=\"population[]\" value=\"#{escape(key)}\"#{' checked' if checked}>"
|
|
303
|
+
preview_button = "<button type=\"submit\" form=\"karst-secondary\" name=\"preview\" " \
|
|
304
|
+
"value=\"#{escape(key)}\">Preview</button>"
|
|
305
|
+
label = "<label>#{box} #{escape(method_name)}</label>"
|
|
306
|
+
"<li class=\"candidate-row\" data-name=\"#{escape(method_name)}\">#{label}" \
|
|
307
|
+
"#{preview_button}#{preview_result(group, method_name, preview)}</li>"
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def preview_result(group, method_name, preview)
|
|
311
|
+
unless preview && preview.model_name == group.model_name && preview.method_name.to_s == method_name.to_s
|
|
312
|
+
return ""
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
preview.resolved ? preview_success(preview) : preview_failure(preview)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def preview_success(preview)
|
|
319
|
+
return "<p class=\"preview\">Preview: no matching records currently.</p>" if preview.records.empty?
|
|
320
|
+
|
|
321
|
+
labels = preview.records.map { |record| record_label(record) }.join(", ")
|
|
322
|
+
"<p class=\"preview\">Preview (up to #{Access::PopulationPreview::PREVIEW_LIMIT}): #{escape(labels)}</p>"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def preview_failure(preview)
|
|
326
|
+
"<p class=\"preview error\">Preview: #{escape(preview.error)}.</p>"
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Deliberately generic -- "ModelName #id" only, never an arbitrary
|
|
330
|
+
# attribute dump, regardless of whether the previewed model happens
|
|
331
|
+
# to be a configured principal source. A discovered candidate may
|
|
332
|
+
# belong to any application model, not only ones a developer has
|
|
333
|
+
# already reviewed for what is safe to display.
|
|
334
|
+
def record_label(record)
|
|
335
|
+
primary_key = record.class.respond_to?(:primary_key) ? record.class.primary_key : "id"
|
|
336
|
+
"#{record.class.name} ##{record.public_send(primary_key)}"
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# -- Advanced: Ruby export ---------------------------------------------
|
|
340
|
+
|
|
341
|
+
# Approving is the workflow; this stays available for an application
|
|
342
|
+
# that would rather commit its populations as reviewable Ruby (a
|
|
343
|
+
# shared or CI environment, where a machine-local approval file is
|
|
344
|
+
# deliberately not consulted).
|
|
345
|
+
def advanced_section(snippet)
|
|
346
|
+
<<~HTML
|
|
347
|
+
<details class="advanced"#{' open' if snippet}><summary>Advanced: export approvals as Ruby
|
|
348
|
+
configuration</summary>
|
|
349
|
+
<p><small>Explicit <code>config.principal_populations</code> keeps working exactly as before and always
|
|
350
|
+
takes precedence over an approval of the same name. Karst never writes to your application's files.
|
|
351
|
+
</small></p>
|
|
352
|
+
<button type="submit" form="karst-secondary" name="generate_snippet" value="1">Generate snippet from
|
|
353
|
+
approvals</button>
|
|
354
|
+
#{snippet_section(snippet)}
|
|
355
|
+
</details>
|
|
356
|
+
HTML
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def snippet_section(snippet)
|
|
360
|
+
return "" unless snippet
|
|
361
|
+
|
|
362
|
+
<<~HTML
|
|
363
|
+
<div class="snippet">
|
|
364
|
+
<textarea id="karst-snippet-code" readonly>#{escape(snippet.code)}</textarea>
|
|
365
|
+
<p><button type="button" id="karst-copy-snippet">Copy</button></p>
|
|
366
|
+
#{unwired_note(snippet)}
|
|
367
|
+
</div>
|
|
368
|
+
HTML
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def unwired_note(snippet)
|
|
372
|
+
return "" if snippet.unwired.empty?
|
|
373
|
+
|
|
374
|
+
names = snippet.unwired.map { |c| "#{c.model_name}.#{c.method_name}" }.join(", ")
|
|
375
|
+
"<p class=\"hint\">#{snippet.unwired.size} approved group(s) are not part of a configured " \
|
|
376
|
+
"user source yet and were left out of the snippet above: #{escape(names)}.</p>"
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def candidate_key(model_name, method_name)
|
|
380
|
+
"#{model_name}#{CANDIDATE_SEPARATOR}#{method_name}"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def escape(value)
|
|
384
|
+
CGI.escapeHTML(value.to_s)
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
# rubocop:enable Metrics/ClassLength
|
|
388
|
+
end
|
|
389
|
+
# rubocop:enable Metrics/ModuleLength
|
|
390
|
+
end
|
|
391
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "active_support/core_ext/string/inflections"
|
|
5
|
+
|
|
6
|
+
module Karst
|
|
7
|
+
module Web
|
|
8
|
+
# Establishes route context for a manually entered application path. Rails'
|
|
9
|
+
# router is the authority: Karst does not infer a controller or action from
|
|
10
|
+
# the shape of the URL.
|
|
11
|
+
class RouteLookup
|
|
12
|
+
Result = Struct.new(:params, :limitation, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
def initialize(path:, http_method:, application: Rails.application)
|
|
15
|
+
@path = path.to_s.strip
|
|
16
|
+
@http_method = http_method.to_s.strip.upcase
|
|
17
|
+
@application = application
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
path = local_path
|
|
22
|
+
recognized = @application.routes.recognize_path(path, method: method)
|
|
23
|
+
return limitation("the recognized route did not identify a controller and action") unless complete?(recognized)
|
|
24
|
+
|
|
25
|
+
Result.new(params: recognized_params(recognized, path))
|
|
26
|
+
rescue URI::InvalidURIError
|
|
27
|
+
limitation("Path must be a valid local application path.")
|
|
28
|
+
rescue ActionController::RoutingError
|
|
29
|
+
limitation("Rails could not recognize that path and method. Karst will not guess the route.")
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
limitation("Rails route recognition was unavailable (#{e.class}). Karst will not guess the route.")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def complete?(recognized)
|
|
37
|
+
!recognized[:controller].to_s.empty? && !recognized[:action].to_s.empty?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def recognized_params(recognized, path)
|
|
41
|
+
{
|
|
42
|
+
"controller" => "#{recognized[:controller].to_s.camelize}Controller",
|
|
43
|
+
"action" => recognized[:action].to_s, "method" => method, "path" => path
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def local_path
|
|
48
|
+
raw = @path.split("?", 2).first
|
|
49
|
+
uri = URI.parse(raw)
|
|
50
|
+
valid = uri.relative? && raw.start_with?("/") && !raw.start_with?("//")
|
|
51
|
+
raise URI::InvalidURIError unless valid
|
|
52
|
+
|
|
53
|
+
raw
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def method
|
|
57
|
+
@http_method.empty? ? "GET" : @http_method
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def limitation(message)
|
|
61
|
+
Result.new(params: { "method" => method, "path" => @path }, limitation: message)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/karst.rb
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# On Rails 6.1, active_support/logger_thread_safe_level.rb references the
|
|
4
|
+
# bare ::Logger constant before active_support/logger.rb gets around to
|
|
5
|
+
# requiring "logger" itself -- a load-order bug in that Rails series, not a
|
|
6
|
+
# version conflict (it reproduces with Ruby's own bundled logger release,
|
|
7
|
+
# not just newer ones). A full Rails boot usually papers over it by sheer
|
|
8
|
+
# luck of some other gem having required "logger" first; requiring it here
|
|
9
|
+
# up front means `require "karst"` never depends on that luck.
|
|
10
|
+
require "logger"
|
|
11
|
+
|
|
12
|
+
require_relative "karst/version"
|
|
13
|
+
require_relative "karst/configuration"
|
|
14
|
+
require_relative "karst/identity"
|
|
15
|
+
require_relative "karst/access/sweep"
|
|
16
|
+
require_relative "karst/access/principal_source"
|
|
17
|
+
require_relative "karst/access/principal_sampler"
|
|
18
|
+
require_relative "karst/access/principal_selection"
|
|
19
|
+
require_relative "karst/access/search"
|
|
20
|
+
require_relative "karst/access/resource_evidence"
|
|
21
|
+
require_relative "karst/access/candidate_population"
|
|
22
|
+
require_relative "karst/access/population_discovery"
|
|
23
|
+
require_relative "karst/access/population_approvals"
|
|
24
|
+
require_relative "karst/access/approved_populations"
|
|
25
|
+
require_relative "karst/access/population_preview"
|
|
26
|
+
require_relative "karst/access/population_config_snippet"
|
|
27
|
+
require_relative "karst/access/principal_source_selection"
|
|
28
|
+
require_relative "karst/access/selected_principal_sources"
|
|
29
|
+
|
|
30
|
+
# Public entry point for Karst configuration.
|
|
31
|
+
module Karst
|
|
32
|
+
@ownership_mutex = Mutex.new
|
|
33
|
+
|
|
34
|
+
private_constant :Configuration
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
def configure
|
|
38
|
+
yield config
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def config
|
|
42
|
+
@ownership_mutex.synchronize { @config ||= Configuration.new }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The single switch that turns Karst's whole development surface off:
|
|
46
|
+
# /karst, the page badge, `bin/rails karst:verify`, and the MCP
|
|
47
|
+
# verify_access tool all refuse to run when this is false. Defaults to
|
|
48
|
+
# development/test only, so an application that never configures Karst
|
|
49
|
+
# at all still cannot expose it in production.
|
|
50
|
+
def enabled?
|
|
51
|
+
config.enabled
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
require_relative "karst/railtie" if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Command
|
|
5
|
+
module Karst
|
|
6
|
+
# Shared by every Karst Rails::Command: boots the host application the
|
|
7
|
+
# same way Rails' own commands do (Rails::Command::Actions#boot_application!
|
|
8
|
+
# on Rails >= 7.1, #require_application_and_environment! before that),
|
|
9
|
+
# without depending on either method name existing across Karst's whole
|
|
10
|
+
# supported Rails range. `require_application!` (from
|
|
11
|
+
# Rails::Command::Actions, already included into Rails::Command::Base)
|
|
12
|
+
# plus Rails.application.require_environment! is exactly what both of
|
|
13
|
+
# those version-specific wrappers do internally.
|
|
14
|
+
module Boot
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def boot_karst_application!
|
|
18
|
+
require_application!
|
|
19
|
+
Rails.application.require_environment! if defined?(APP_PATH)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/command"
|
|
4
|
+
require "karst/mcp/server"
|
|
5
|
+
require_relative "../boot"
|
|
6
|
+
|
|
7
|
+
module Rails
|
|
8
|
+
module Command
|
|
9
|
+
module Karst
|
|
10
|
+
# Rails command entry point for Karst's MCP server: `bin/rails karst:mcp`.
|
|
11
|
+
# Boots the host application once, the same way any other Karst Rails
|
|
12
|
+
# command does (see Karst::Boot), then serves verify_access tool calls
|
|
13
|
+
# over stdio for the lifetime of the process -- never a second
|
|
14
|
+
# application copy per call.
|
|
15
|
+
class McpCommand < Base
|
|
16
|
+
include Karst::Boot
|
|
17
|
+
|
|
18
|
+
desc "Run Karst's MCP server over stdio, exposing the verify_access tool"
|
|
19
|
+
def perform(*)
|
|
20
|
+
boot_karst_application!
|
|
21
|
+
::Karst::Mcp::Server.run!
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/command"
|
|
4
|
+
require "karst/cli/verification"
|
|
5
|
+
require_relative "../boot"
|
|
6
|
+
|
|
7
|
+
module Rails
|
|
8
|
+
module Command
|
|
9
|
+
module Karst
|
|
10
|
+
# Rails command entry point for the shared Access::Search adapter.
|
|
11
|
+
class VerifyCommand < Base
|
|
12
|
+
include Karst::Boot
|
|
13
|
+
|
|
14
|
+
class_option :json, type: :boolean, default: false, desc: "Emit stable JSON evidence"
|
|
15
|
+
|
|
16
|
+
desc "Verify bounded GET access to a local application path"
|
|
17
|
+
def perform(*arguments)
|
|
18
|
+
method, path = parse(arguments)
|
|
19
|
+
boot_karst_application!
|
|
20
|
+
exit(::Karst::CLI::Verification.new(path: path, http_method: method, json: options[:json]).call)
|
|
21
|
+
rescue ArgumentError => e
|
|
22
|
+
document = { schema_version: 1, error: { type: "input_error", message: e.message } }
|
|
23
|
+
puts(options[:json] ? JSON.generate(document) : "Karst cannot verify this route:\n#{e.message}")
|
|
24
|
+
exit(2)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def parse(arguments)
|
|
30
|
+
raise ArgumentError, "a local application path is required" if arguments.empty?
|
|
31
|
+
return ["GET", arguments.first] if arguments.size == 1
|
|
32
|
+
raise ArgumentError, "expected METHOD PATH" unless arguments.size == 2
|
|
33
|
+
|
|
34
|
+
arguments
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :karst do
|
|
4
|
+
desc "List zero-argument scopes declared directly in application model source. Never executes them."
|
|
5
|
+
task populations: :environment do
|
|
6
|
+
require "karst/access/population_discovery"
|
|
7
|
+
require "karst/access/population_approvals"
|
|
8
|
+
|
|
9
|
+
result = Karst::Access::PopulationDiscovery.new.call
|
|
10
|
+
approvals = Karst::Access::PopulationApprovals.load
|
|
11
|
+
groups = result.model_groups.reject { |group| group.candidate_names.empty? }
|
|
12
|
+
|
|
13
|
+
puts "Warning: #{result.load_warning}\n\n" if result.load_warning
|
|
14
|
+
puts "Warning: #{approvals.error}\n\n" if approvals.error
|
|
15
|
+
puts "No candidate scopes were discovered." if groups.empty?
|
|
16
|
+
|
|
17
|
+
groups.each do |group|
|
|
18
|
+
label = group.principal_source ? " (principal source: #{group.principal_source})" : ""
|
|
19
|
+
puts "#{group.model_name}#{label} -- #{group.candidate_names.size} scope(s)"
|
|
20
|
+
group.candidate_names.each do |name|
|
|
21
|
+
puts " #{name}#{' [approved]' if approvals.approved?(group.model_name, name)}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
puts "", <<~NOTES
|
|
26
|
+
These are discovered scopes only -- Karst has not verified any of them return a usable relation,
|
|
27
|
+
and none of this grants access or is wired into sampling on its own.
|
|
28
|
+
Only scopes declared directly on application models are included; concern-defined scopes may not appear.
|
|
29
|
+
Approve the ones Karst may try at /karst/populations. Approvals are stored locally in
|
|
30
|
+
#{Karst::Access::PopulationApprovals.display_path}; delete that file to reset them.
|
|
31
|
+
config.principal_populations remains supported and always takes precedence.
|
|
32
|
+
NOTES
|
|
33
|
+
end
|
|
34
|
+
end
|