karst 0.1.0 → 0.2.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 +8 -11
- data/CHANGELOG.md +37 -50
- data/README.md +20 -8
- data/SECURITY.md +1 -1
- data/docs/advanced-configuration.md +8 -9
- data/docs/rails8-authentication.md +171 -0
- data/lib/karst/access/population_approval.rb +67 -0
- data/lib/karst/access/population_revocation.rb +34 -0
- data/lib/karst/configuration.rb +5 -5
- data/lib/karst/execution_context.rb +6 -7
- data/lib/karst/railtie.rb +4 -7
- 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 +50 -78
- data/lib/karst/web/panel.rb +44 -81
- data/lib/karst/web/populations_panel.rb +46 -336
- data/lib/karst.rb +1 -3
- data/lib/rails/commands/karst/mcp/mcp_command.rb +14 -1
- metadata +7 -14
- 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
|
@@ -1,32 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "cgi"
|
|
4
|
-
require "securerandom"
|
|
5
|
-
require_relative "../access/population_preview"
|
|
6
4
|
|
|
7
5
|
module Karst
|
|
8
6
|
module Web
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
|
7
|
+
# Advanced, local-only management for approvals already granted from the
|
|
8
|
+
# contextual /karst workflow. New approvals deliberately cannot be made
|
|
9
|
+
# here: this surface exists only so persistent state can be inspected and
|
|
10
|
+
# safely revoked, including after its model or scope becomes stale.
|
|
23
11
|
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
12
|
STALE_REASONS = {
|
|
31
13
|
not_discovered: "no longer a discovered scope on this model — not used",
|
|
32
14
|
no_principal_source: "not part of a configured user source — not used"
|
|
@@ -36,356 +18,84 @@ module Karst
|
|
|
36
18
|
STYLE = <<~CSS
|
|
37
19
|
:root{color-scheme:light dark}
|
|
38
20
|
*{box-sizing:border-box}
|
|
39
|
-
body{font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;max-width:
|
|
40
|
-
h1{font-size:1.4rem;margin:0 0 1rem}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
.
|
|
45
|
-
.
|
|
46
|
-
.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
}
|
|
21
|
+
body{font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;max-width:42rem;margin:2rem auto;padding:0 1.25rem;color:#1a1a1a;background:#fff}
|
|
22
|
+
h1{font-size:1.4rem;margin:0 0 1rem} h2{font-size:1.1rem;margin:1.5rem 0 .5rem}
|
|
23
|
+
a{color:#2563eb} .lead,small{color:#666} ul{list-style:none;padding:0}
|
|
24
|
+
li{display:flex;justify-content:space-between;align-items:center;gap:1rem;border-top:1px solid #ddd;padding:.75rem 0}
|
|
25
|
+
code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
26
|
+
.stale{color:#8a5b00;font-size:.85rem;display:block}
|
|
27
|
+
.warning{color:#8a5b00;font-size:.85rem;border:1px solid #eacb6b;background:#fff7e0;border-radius:.4rem;padding:.6rem .8rem}
|
|
28
|
+
.saved{color:#0f5132;font-size:.9rem;border:1px solid #a6d9bb;background:#e6f4ea;border-radius:.4rem;padding:.6rem .8rem}
|
|
29
|
+
button{font:inherit;padding:.35rem .65rem;border:1px solid #b3261e;border-radius:.35rem;background:transparent;color:#b3261e;cursor:pointer}
|
|
30
|
+
button:focus-visible,a:focus-visible{outline:2px solid #2563eb;outline-offset:2px}
|
|
31
|
+
@media (prefers-color-scheme:dark){body{background:#16171a;color:#e4e4e6}.lead,small{color:#a7a7ad}li{border-color:#33343a}.stale,.warning{color:#d8a63d}.warning{background:#3a2f0d;border-color:#6b5423}.saved{background:#123822;border-color:#1f5c37;color:#7fd8a4}button{color:#ff817a;border-color:#ff817a}}
|
|
91
32
|
CSS
|
|
92
33
|
private_constant :STYLE
|
|
93
34
|
|
|
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
35
|
class << self
|
|
138
|
-
|
|
139
|
-
|
|
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)]]
|
|
36
|
+
def render(approved: [], stale: [], storage_path: nil, storage_error: nil, revoked: false)
|
|
37
|
+
[200, headers, [document(approved, stale, storage_path, storage_error, revoked)]]
|
|
148
38
|
end
|
|
149
|
-
# rubocop:enable Metrics/ParameterLists
|
|
150
39
|
|
|
151
40
|
private
|
|
152
41
|
|
|
153
|
-
def headers
|
|
154
|
-
csp = "default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-#{nonce}'; frame-ancestors 'none'"
|
|
42
|
+
def headers
|
|
155
43
|
{
|
|
156
44
|
"content-type" => "text/html; charset=utf-8", "cache-control" => "no-store",
|
|
157
|
-
"x-robots-tag" => "noindex, nofollow", "x-frame-options" => "DENY",
|
|
45
|
+
"x-robots-tag" => "noindex, nofollow", "x-frame-options" => "DENY",
|
|
46
|
+
"content-security-policy" => "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"
|
|
158
47
|
}
|
|
159
48
|
end
|
|
160
49
|
|
|
161
|
-
|
|
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)
|
|
50
|
+
def document(approved, stale, storage_path, storage_error, revoked)
|
|
168
51
|
<<~HTML
|
|
169
|
-
<!DOCTYPE html>
|
|
170
|
-
<
|
|
171
|
-
<style>#{STYLE}</style>
|
|
172
|
-
</head><body>
|
|
173
|
-
<h1>Karst</h1>
|
|
52
|
+
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Karst — Manage approvals</title>
|
|
53
|
+
<style>#{STYLE}</style></head><body><h1>Karst</h1>
|
|
174
54
|
<p><a href="/karst">← Back to route evidence</a></p>
|
|
175
|
-
<h2>
|
|
176
|
-
<p class="lead">
|
|
177
|
-
|
|
178
|
-
|
|
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>
|
|
55
|
+
<h2>Manage population approvals</h2>
|
|
56
|
+
<p class="lead">Advanced local-state management. Approve new candidate populations only when a failed route analysis offers them on <code>/karst</code>.</p>
|
|
57
|
+
#{notice(revoked)}#{storage_error_message(storage_error)}#{approval_list(approved, stale)}
|
|
58
|
+
#{storage_note(storage_path)}</body></html>
|
|
193
59
|
HTML
|
|
194
60
|
end
|
|
195
61
|
|
|
196
|
-
def
|
|
197
|
-
return ""
|
|
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 ------------------------------------------------------
|
|
62
|
+
def approval_list(approved, stale)
|
|
63
|
+
return "<p>No population approvals are stored.</p>" if approved.empty?
|
|
263
64
|
|
|
264
|
-
|
|
265
|
-
|
|
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>"
|
|
65
|
+
items = approved.map { |entry| approval_item(entry, stale) }.join
|
|
66
|
+
"<ul>#{items}</ul>"
|
|
272
67
|
end
|
|
273
68
|
|
|
274
|
-
def
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
open = approved_names.any? ? " open" : ""
|
|
278
|
-
rows = group.candidate_names.map { |name| candidate_row(group, name, approved_names, preview) }.join
|
|
69
|
+
def approval_item(entry, stale)
|
|
70
|
+
match = stale.find { |item, _reason| item.matches?(entry.model_name, entry.method_name) }
|
|
71
|
+
note = match ? "<span class=\"stale\">#{escape(STALE_REASONS.fetch(match.last, 'not used'))}</span>" : ""
|
|
279
72
|
<<~HTML
|
|
280
|
-
<
|
|
281
|
-
<
|
|
282
|
-
<
|
|
283
|
-
</
|
|
73
|
+
<li><span><code>#{escape(entry.display_label)}</code>#{note}</span>
|
|
74
|
+
<form method="post" action="/karst/populations"><input type="hidden" name="operation" value="revoke_population">
|
|
75
|
+
<input type="hidden" name="population" value="#{escape(entry.model_name)}::#{escape(entry.method_name)}">
|
|
76
|
+
<button type="submit">Revoke</button></form></li>
|
|
284
77
|
HTML
|
|
285
78
|
end
|
|
286
79
|
|
|
287
|
-
def
|
|
288
|
-
|
|
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>"
|
|
80
|
+
def notice(revoked)
|
|
81
|
+
revoked ? '<p class="saved" role="status">Approval revoked.</p>' : ""
|
|
327
82
|
end
|
|
328
83
|
|
|
329
|
-
|
|
330
|
-
|
|
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)}"
|
|
84
|
+
def storage_error_message(error)
|
|
85
|
+
error ? "<p class=\"warning\" role=\"alert\">#{escape(error)}</p>" : ""
|
|
337
86
|
end
|
|
338
87
|
|
|
339
|
-
|
|
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
|
|
88
|
+
def storage_note(path)
|
|
89
|
+
return "" unless path
|
|
378
90
|
|
|
379
|
-
|
|
380
|
-
|
|
91
|
+
"<p><small>Approvals are stored locally in <code>#{escape(path)}</code> " \
|
|
92
|
+
"as model and scope names only.</small></p>"
|
|
381
93
|
end
|
|
382
94
|
|
|
383
95
|
def escape(value)
|
|
384
96
|
CGI.escapeHTML(value.to_s)
|
|
385
97
|
end
|
|
386
98
|
end
|
|
387
|
-
# rubocop:enable Metrics/ClassLength
|
|
388
99
|
end
|
|
389
|
-
# rubocop:enable Metrics/ModuleLength
|
|
390
100
|
end
|
|
391
101
|
end
|
data/lib/karst.rb
CHANGED
|
@@ -17,13 +17,11 @@ require_relative "karst/access/principal_source"
|
|
|
17
17
|
require_relative "karst/access/principal_sampler"
|
|
18
18
|
require_relative "karst/access/principal_selection"
|
|
19
19
|
require_relative "karst/access/search"
|
|
20
|
-
require_relative "karst/access/resource_evidence"
|
|
21
20
|
require_relative "karst/access/candidate_population"
|
|
22
21
|
require_relative "karst/access/population_discovery"
|
|
23
22
|
require_relative "karst/access/population_approvals"
|
|
23
|
+
require_relative "karst/access/population_approval"
|
|
24
24
|
require_relative "karst/access/approved_populations"
|
|
25
|
-
require_relative "karst/access/population_preview"
|
|
26
|
-
require_relative "karst/access/population_config_snippet"
|
|
27
25
|
require_relative "karst/access/principal_source_selection"
|
|
28
26
|
require_relative "karst/access/selected_principal_sources"
|
|
29
27
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rails/command"
|
|
4
|
-
require "karst/mcp/server"
|
|
5
4
|
require_relative "../boot"
|
|
6
5
|
|
|
7
6
|
module Rails
|
|
@@ -17,9 +16,23 @@ module Rails
|
|
|
17
16
|
|
|
18
17
|
desc "Run Karst's MCP server over stdio, exposing the verify_access tool"
|
|
19
18
|
def perform(*)
|
|
19
|
+
load_mcp!
|
|
20
20
|
boot_karst_application!
|
|
21
21
|
::Karst::Mcp::Server.run!
|
|
22
22
|
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def load_mcp!
|
|
27
|
+
begin
|
|
28
|
+
gem "mcp", "~> 0.9.0"
|
|
29
|
+
rescue Gem::LoadError
|
|
30
|
+
abort "Karst MCP requires the optional dependency. " \
|
|
31
|
+
'Add gem "mcp", "~> 0.9.0" to your Gemfile and run bundle install.'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
require "karst/mcp/server"
|
|
35
|
+
end
|
|
23
36
|
end
|
|
24
37
|
end
|
|
25
38
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: karst
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Karst contributors
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -37,7 +37,7 @@ dependencies:
|
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: 0.9.0
|
|
40
|
-
type: :
|
|
40
|
+
type: :development
|
|
41
41
|
prerelease: false
|
|
42
42
|
version_requirements: !ruby/object:Gem::Requirement
|
|
43
43
|
requirements:
|
|
@@ -59,6 +59,7 @@ files:
|
|
|
59
59
|
- README.md
|
|
60
60
|
- SECURITY.md
|
|
61
61
|
- docs/advanced-configuration.md
|
|
62
|
+
- docs/rails8-authentication.md
|
|
62
63
|
- lib/generators/karst/install/install_generator.rb
|
|
63
64
|
- lib/generators/karst/install/templates/karst_identity_controller.rb
|
|
64
65
|
- lib/generators/karst/install/templates/karst_initializer.rb
|
|
@@ -66,16 +67,15 @@ files:
|
|
|
66
67
|
- lib/karst/access/approved_populations.rb
|
|
67
68
|
- lib/karst/access/candidate_population.rb
|
|
68
69
|
- lib/karst/access/database_isolation.rb
|
|
70
|
+
- lib/karst/access/population_approval.rb
|
|
69
71
|
- lib/karst/access/population_approvals.rb
|
|
70
|
-
- lib/karst/access/population_config_snippet.rb
|
|
71
72
|
- lib/karst/access/population_discovery.rb
|
|
72
|
-
- lib/karst/access/
|
|
73
|
+
- lib/karst/access/population_revocation.rb
|
|
73
74
|
- lib/karst/access/principal_sampler.rb
|
|
74
75
|
- lib/karst/access/principal_selection.rb
|
|
75
76
|
- lib/karst/access/principal_source.rb
|
|
76
77
|
- lib/karst/access/principal_source_selection.rb
|
|
77
78
|
- lib/karst/access/probe_application.rb
|
|
78
|
-
- lib/karst/access/resource_evidence.rb
|
|
79
79
|
- lib/karst/access/search.rb
|
|
80
80
|
- lib/karst/access/selected_principal_sources.rb
|
|
81
81
|
- lib/karst/access/sensitive_attribute_names.rb
|
|
@@ -89,17 +89,11 @@ files:
|
|
|
89
89
|
- lib/karst/mcp/server.rb
|
|
90
90
|
- lib/karst/mcp/verify_access_tool.rb
|
|
91
91
|
- lib/karst/railtie.rb
|
|
92
|
-
- lib/karst/spec/catalog.rb
|
|
93
|
-
- lib/karst/spec/example_observation.rb
|
|
94
|
-
- lib/karst/spec/observer.rb
|
|
95
|
-
- lib/karst/spec/principal.rb
|
|
96
|
-
- lib/karst/spec/reporter.rb
|
|
97
|
-
- lib/karst/spec/request_observation.rb
|
|
98
|
-
- lib/karst/spec/scenario.rb
|
|
99
92
|
- lib/karst/value.rb
|
|
100
93
|
- lib/karst/version.rb
|
|
101
94
|
- lib/karst/web/badge.rb
|
|
102
95
|
- lib/karst/web/browser_identity.rb
|
|
96
|
+
- lib/karst/web/csrf.rb
|
|
103
97
|
- lib/karst/web/locality.rb
|
|
104
98
|
- lib/karst/web/middleware.rb
|
|
105
99
|
- lib/karst/web/panel.rb
|
|
@@ -108,7 +102,6 @@ files:
|
|
|
108
102
|
- lib/rails/commands/karst/boot.rb
|
|
109
103
|
- lib/rails/commands/karst/mcp/mcp_command.rb
|
|
110
104
|
- lib/rails/commands/karst/verify/verify_command.rb
|
|
111
|
-
- lib/tasks/karst.rake
|
|
112
105
|
homepage: https://github.com/SilenceDogood1984/karst
|
|
113
106
|
licenses:
|
|
114
107
|
- MIT
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../value"
|
|
4
|
-
|
|
5
|
-
module Karst
|
|
6
|
-
module Access
|
|
7
|
-
# Renders a developer's curated selection of
|
|
8
|
-
# Karst::Access::PopulationDiscovery::Candidate into a Ruby config
|
|
9
|
-
# snippet they can copy into their own Karst.configure block. Karst
|
|
10
|
-
# never writes to the host application's files on its own -- the
|
|
11
|
-
# developer stays in control of what actually gets committed (see
|
|
12
|
-
# README "Curation / persistence").
|
|
13
|
-
module PopulationConfigSnippet
|
|
14
|
-
# wired is the subset actually rendered into `code` (every selected
|
|
15
|
-
# candidate whose model matches a configured principal source).
|
|
16
|
-
# unwired is every selected candidate that is not wired into
|
|
17
|
-
# anything yet -- present so the UI can say so honestly instead of
|
|
18
|
-
# silently dropping a selection (see README "Principal vs artifact
|
|
19
|
-
# populations": this release only wires principal populations).
|
|
20
|
-
Result = Value.define(:code, :wired, :unwired)
|
|
21
|
-
|
|
22
|
-
class << self
|
|
23
|
-
def generate(candidates)
|
|
24
|
-
unique = candidates.uniq { |candidate| [candidate.model_name, candidate.method_name] }
|
|
25
|
-
wired, unwired = unique.partition(&:principal_source)
|
|
26
|
-
Result.new(code: render(wired), wired: wired, unwired: unwired)
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
private
|
|
30
|
-
|
|
31
|
-
def render(wired)
|
|
32
|
-
return "# Select at least one population above to generate a configuration snippet.\n" if wired.empty?
|
|
33
|
-
|
|
34
|
-
by_source = wired.group_by(&:principal_source)
|
|
35
|
-
by_source.size == 1 && by_source.keys.first == :default ? flat(by_source.fetch(:default)) : nested(by_source)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def flat(candidates)
|
|
39
|
-
"config.principal_populations = {\n#{entries(candidates, indent: 2)}\n}\n"
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def nested(by_source)
|
|
43
|
-
sources = by_source.map do |source, group|
|
|
44
|
-
" #{source_key(source)}: {\n populations: {\n#{entries(group, indent: 6)}\n }\n }"
|
|
45
|
-
end.join(",\n")
|
|
46
|
-
"config.principal_sources = {\n#{sources}\n}\n"
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Sorted by model then method so the same selection always renders
|
|
50
|
-
# byte-identical output, regardless of checkbox submission order.
|
|
51
|
-
def entries(candidates, indent:)
|
|
52
|
-
pad = " " * indent
|
|
53
|
-
sorted = candidates.sort_by { |candidate| [candidate.model_name, candidate.method_name.to_s] }
|
|
54
|
-
sorted.map { |candidate| entry(candidate, pad) }.join(",\n")
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def entry(candidate, pad)
|
|
58
|
-
"#{pad}#{candidate.method_name}: -> { #{candidate.model_name}.#{candidate.method_name} }"
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def source_key(source)
|
|
62
|
-
source.to_s =~ /\A[a-zA-Z_][a-zA-Z0-9_]*\z/ ? source : source.inspect
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|