karst 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +14 -12
- data/CHANGELOG.md +114 -47
- data/CONTRIBUTING.md +9 -1
- data/README.md +137 -9
- data/SECURITY.md +1 -1
- data/docs/advanced-configuration.md +112 -7
- data/docs/rails8-authentication.md +191 -0
- data/docs/request-reproduction.md +242 -0
- data/lib/generators/karst/install/templates/karst_initializer.rb +6 -0
- data/lib/karst/access/approved_populations.rb +15 -6
- data/lib/karst/access/errors.rb +16 -0
- data/lib/karst/access/identity_probe.rb +151 -0
- data/lib/karst/access/local_path.rb +42 -0
- data/lib/karst/access/observed_endpoint.rb +46 -0
- data/lib/karst/access/outcome_groups.rb +93 -0
- data/lib/karst/access/population_approval.rb +67 -0
- data/lib/karst/access/population_discovery.rb +25 -1
- data/lib/karst/access/population_revocation.rb +34 -0
- data/lib/karst/access/principal_source.rb +24 -3
- data/lib/karst/access/probe.rb +397 -0
- data/lib/karst/access/search.rb +14 -6
- data/lib/karst/access/sweep.rb +42 -112
- data/lib/karst/cli/identity_serialization.rb +82 -0
- data/lib/karst/cli/outcome_summary.rb +131 -0
- data/lib/karst/cli/principal_reference.rb +51 -0
- data/lib/karst/cli/reproduction.rb +267 -0
- data/lib/karst/cli/verification.rb +219 -55
- data/lib/karst/configuration.rb +57 -11
- data/lib/karst/execution_context.rb +6 -7
- data/lib/karst/identity/evidence.rb +110 -0
- data/lib/karst/identity/observation.rb +167 -0
- data/lib/karst/identity/warden_adapter.rb +26 -1
- data/lib/karst/identity.rb +157 -22
- data/lib/karst/mcp/compatibility.rb +24 -0
- data/lib/karst/mcp/reproduce_request_tool.rb +137 -0
- data/lib/karst/mcp/reproduction.rb +58 -0
- data/lib/karst/mcp/server.rb +27 -8
- data/lib/karst/mcp/verify_access_tool.rb +36 -2
- data/lib/karst/railtie.rb +4 -7
- data/lib/karst/reproduction/curl.rb +73 -0
- data/lib/karst/reproduction/exercise.rb +246 -0
- data/lib/karst/reproduction/observation.rb +87 -0
- data/lib/karst/reproduction/sanitizer.rb +210 -0
- data/lib/karst/version.rb +1 -1
- data/lib/karst/web/badge.rb +5 -31
- data/lib/karst/web/browser_identity.rb +10 -13
- data/lib/karst/web/csrf.rb +43 -0
- data/lib/karst/web/middleware.rb +145 -84
- data/lib/karst/web/panel.rb +343 -125
- data/lib/karst/web/populations_panel.rb +46 -336
- data/lib/karst.rb +3 -3
- data/lib/rails/commands/karst/mcp/mcp_command.rb +41 -1
- data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
- data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
- metadata +38 -18
- data/lib/karst/access/population_config_snippet.rb +0 -67
- data/lib/karst/access/population_preview.rb +0 -83
- data/lib/karst/access/resource_evidence.rb +0 -233
- data/lib/karst/spec/catalog.rb +0 -199
- data/lib/karst/spec/example_observation.rb +0 -31
- data/lib/karst/spec/observer.rb +0 -300
- data/lib/karst/spec/principal.rb +0 -12
- data/lib/karst/spec/reporter.rb +0 -83
- data/lib/karst/spec/request_observation.rb +0 -38
- data/lib/karst/spec/scenario.rb +0 -65
- data/lib/tasks/karst.rake +0 -34
data/lib/karst/web/badge.rb
CHANGED
|
@@ -3,20 +3,13 @@
|
|
|
3
3
|
require "cgi"
|
|
4
4
|
require "rack/utils"
|
|
5
5
|
|
|
6
|
-
begin
|
|
7
|
-
require_relative "../spec/catalog"
|
|
8
|
-
rescue LoadError
|
|
9
|
-
# Degrades to a countless badge rather than preventing the host response
|
|
10
|
-
# from being served, mirroring Panel's own posture toward a missing Catalog.
|
|
11
|
-
end
|
|
12
|
-
|
|
13
6
|
module Karst
|
|
14
7
|
module Web
|
|
15
8
|
# Rewrites an eligible host HTML response to add a small, page-local link
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
9
|
+
# to Karst's route access panel. Every check here exists to make injection
|
|
10
|
+
# safe to skip: Badge never guesses, never raises into the host application,
|
|
11
|
+
# and never touches a response it cannot confidently rewrite -- an untouched
|
|
12
|
+
# response is always the safe fallback.
|
|
20
13
|
#
|
|
21
14
|
# Route identity (controller/action/http_method) comes from
|
|
22
15
|
# Middleware, itself derived from a real process_action.action_controller
|
|
@@ -109,9 +102,8 @@ module Karst
|
|
|
109
102
|
|
|
110
103
|
def markup(headers:, context:)
|
|
111
104
|
href = escape("/karst?#{query_for(context)}")
|
|
112
|
-
label = escape(label_for(context))
|
|
113
105
|
style = inline_style_allowed?(headers) ? " style=\"#{BADGE_STYLE}\"" : ""
|
|
114
|
-
"<a href=\"#{href}\"#{style}
|
|
106
|
+
"<a href=\"#{href}\"#{style}>Karst</a>"
|
|
115
107
|
end
|
|
116
108
|
|
|
117
109
|
def query_for(context)
|
|
@@ -121,24 +113,6 @@ module Karst
|
|
|
121
113
|
)
|
|
122
114
|
end
|
|
123
115
|
|
|
124
|
-
def label_for(context)
|
|
125
|
-
catalog = load_catalog
|
|
126
|
-
return "Karst" unless catalog && catalog.status == :ready
|
|
127
|
-
|
|
128
|
-
count = catalog.scenarios_for(
|
|
129
|
-
controller: context.controller, action: context.action, http_method: context.http_method
|
|
130
|
-
).size
|
|
131
|
-
"Karst · #{count}"
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def load_catalog
|
|
135
|
-
return nil unless defined?(Karst::Spec::Catalog)
|
|
136
|
-
|
|
137
|
-
Karst::Spec::Catalog.load
|
|
138
|
-
rescue StandardError
|
|
139
|
-
nil
|
|
140
|
-
end
|
|
141
|
-
|
|
142
116
|
# A CSP the host response itself declares is the only signal Badge
|
|
143
117
|
# trusts: Karst never rewrites that header, only reads it, and falls
|
|
144
118
|
# back to an unstyled link rather than risk a silently-dropped style
|
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "securerandom"
|
|
4
3
|
require "uri"
|
|
5
|
-
|
|
4
|
+
require_relative "csrf"
|
|
6
5
|
|
|
7
6
|
module Karst
|
|
8
7
|
module Web
|
|
9
|
-
# State-changing browser identity operations
|
|
10
|
-
#
|
|
11
|
-
# the Rack boundary, before Action Controller dispatch.
|
|
8
|
+
# State-changing browser identity operations. Synchronizer-token behavior
|
|
9
|
+
# belongs to Web::Csrf and is shared with other Rack-boundary forms.
|
|
12
10
|
class BrowserIdentity
|
|
13
|
-
TOKEN_KEY = "karst.csrf_token"
|
|
14
11
|
ACTIVE_KEY = "karst.browser_identity_active"
|
|
15
12
|
|
|
16
13
|
# The exact Devise/Warden scope the currently assumed identity was
|
|
@@ -20,12 +17,13 @@ module Karst
|
|
|
20
17
|
# several selected sources produced the principal being cleared.
|
|
21
18
|
SCOPE_KEY = "karst.browser_identity_scope"
|
|
22
19
|
|
|
23
|
-
def initialize(request)
|
|
20
|
+
def initialize(request, csrf: Csrf.new(request))
|
|
24
21
|
@request = request
|
|
22
|
+
@csrf = csrf
|
|
25
23
|
end
|
|
26
24
|
|
|
27
25
|
def token
|
|
28
|
-
|
|
26
|
+
@csrf.token
|
|
29
27
|
end
|
|
30
28
|
|
|
31
29
|
def active?
|
|
@@ -68,14 +66,13 @@ module Karst
|
|
|
68
66
|
end
|
|
69
67
|
|
|
70
68
|
def verify_token!(submitted)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
raise Identity::Unavailable, "invalid Karst CSRF token" unless valid
|
|
69
|
+
@csrf.verify!(submitted)
|
|
70
|
+
rescue Csrf::InvalidToken => e
|
|
71
|
+
raise Identity::Unavailable, e.message
|
|
75
72
|
end
|
|
76
73
|
|
|
77
74
|
def rotate_token!
|
|
78
|
-
|
|
75
|
+
@csrf.rotate!
|
|
79
76
|
end
|
|
80
77
|
|
|
81
78
|
# A blank path is not a caller error: the panel renders this same
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "active_support/security_utils"
|
|
5
|
+
|
|
6
|
+
module Karst
|
|
7
|
+
module Web
|
|
8
|
+
# Synchronizer-token protection for state-changing forms served directly
|
|
9
|
+
# from Karst's Rack middleware, where Action Controller CSRF is unavailable.
|
|
10
|
+
class Csrf
|
|
11
|
+
TOKEN_KEY = "karst.csrf_token"
|
|
12
|
+
|
|
13
|
+
class InvalidToken < StandardError; end
|
|
14
|
+
|
|
15
|
+
def initialize(request)
|
|
16
|
+
@request = request
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def token
|
|
20
|
+
session[TOKEN_KEY] ||= SecureRandom.hex(32)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def verify!(submitted)
|
|
24
|
+
expected = session[TOKEN_KEY]
|
|
25
|
+
valid = expected && submitted && expected.bytesize == submitted.bytesize &&
|
|
26
|
+
ActiveSupport::SecurityUtils.secure_compare(expected, submitted)
|
|
27
|
+
raise InvalidToken, "invalid Karst CSRF token" unless valid
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def rotate!
|
|
31
|
+
session[TOKEN_KEY] = SecureRandom.hex(32)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def session
|
|
37
|
+
@request.session
|
|
38
|
+
rescue StandardError
|
|
39
|
+
raise InvalidToken, "a writable Rack session is required"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/karst/web/middleware.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "panel"
|
|
|
5
5
|
require_relative "populations_panel"
|
|
6
6
|
require_relative "badge"
|
|
7
7
|
require_relative "browser_identity"
|
|
8
|
+
require_relative "csrf"
|
|
8
9
|
require_relative "route_lookup"
|
|
9
10
|
require_relative "../execution_context"
|
|
10
11
|
require "rack/utils"
|
|
@@ -18,10 +19,11 @@ require_relative "../access/candidate_population"
|
|
|
18
19
|
require_relative "../access/population_discovery"
|
|
19
20
|
require_relative "../access/population_approvals"
|
|
20
21
|
require_relative "../access/approved_populations"
|
|
21
|
-
require_relative "../access/population_preview"
|
|
22
|
-
require_relative "../access/population_config_snippet"
|
|
23
22
|
require_relative "../access/principal_source_selection"
|
|
23
|
+
require_relative "../access/population_approval"
|
|
24
|
+
require_relative "../access/population_revocation"
|
|
24
25
|
require_relative "../identity/devise_support"
|
|
26
|
+
require_relative "../cli/reproduction"
|
|
25
27
|
|
|
26
28
|
module Karst
|
|
27
29
|
module Web
|
|
@@ -52,9 +54,6 @@ module Karst
|
|
|
52
54
|
POPULATIONS_PATH = "/karst/populations"
|
|
53
55
|
private_constant :POPULATIONS_PATH
|
|
54
56
|
|
|
55
|
-
CANDIDATE_SEPARATOR = "::"
|
|
56
|
-
private_constant :CANDIDATE_SEPARATOR
|
|
57
|
-
|
|
58
57
|
CONTEXT_KEY = :karst_web_request_context
|
|
59
58
|
private_constant :CONTEXT_KEY
|
|
60
59
|
|
|
@@ -85,17 +84,48 @@ module Karst
|
|
|
85
84
|
selection, denied = principal_source_selection_result(env, params)
|
|
86
85
|
return denied if denied
|
|
87
86
|
|
|
88
|
-
|
|
87
|
+
request = Rack::Request.new(env)
|
|
88
|
+
csrf = Csrf.new(request)
|
|
89
|
+
browser_identity = BrowserIdentity.new(request, csrf: csrf)
|
|
90
|
+
approval, denied = inline_population_approval_result(env, params, csrf)
|
|
91
|
+
return denied if denied
|
|
92
|
+
|
|
89
93
|
identity_response = mutate_browser_identity(env, params, browser_identity)
|
|
90
94
|
return identity_response if identity_response
|
|
91
95
|
|
|
92
|
-
|
|
96
|
+
reproduction, denied = reproduction_result(env, params, csrf)
|
|
97
|
+
return denied if denied
|
|
98
|
+
|
|
99
|
+
# Every local-state write this request may make (selection,
|
|
100
|
+
# approval) has happened by now, so one snapshot of the effective
|
|
101
|
+
# identity configuration serves the analysis, discovery, and the
|
|
102
|
+
# whole page render -- never one resolution per rendered user.
|
|
103
|
+
#
|
|
104
|
+
# `record` and `discovery` are this request's one approval-file read
|
|
105
|
+
# and one population-discovery pass, shared with whatever inside
|
|
106
|
+
# Identity.snapshot itself confirms already-approved populations
|
|
107
|
+
# (Access::ApprovedPopulations.merge) as well as with
|
|
108
|
+
# #inline_population_candidates below -- so a request that finds no
|
|
109
|
+
# usable outcome and goes on to look for candidate populations still
|
|
110
|
+
# reads the approval file, and parses model source, exactly once.
|
|
111
|
+
# `discovery`'s own principal_sources is a callable rather than a
|
|
112
|
+
# value because it is only ever actually needed by a *full* discovery
|
|
113
|
+
# pass (#inline_population_candidates), never by confirming one
|
|
114
|
+
# already-approved scope (which Identity.snapshot triggers first) --
|
|
115
|
+
# so by the time it is called, `identity` already holds this
|
|
116
|
+
# request's one resolution and nothing here re-resolves it.
|
|
117
|
+
record = Access::PopulationApprovals.load
|
|
118
|
+
identity = nil
|
|
119
|
+
discovery = Access::PopulationDiscovery.new(principal_sources: -> { identity.principal_sources })
|
|
120
|
+
identity = Identity.snapshot(record: record, discovery: discovery)
|
|
121
|
+
result = analyze(env, params, identity, approval: approval)
|
|
122
|
+
candidates = inline_population_candidates(result, record: record, discovery: discovery)
|
|
93
123
|
Panel.render(params: params, access_result: result, route_lookup_limitation: lookup&.limitation,
|
|
94
|
-
csrf_token:
|
|
95
|
-
browser_identity_active: browser_identity_active?(browser_identity),
|
|
96
|
-
|
|
124
|
+
csrf_token: csrf_token(csrf), identity_snapshot: identity,
|
|
125
|
+
browser_identity_active: browser_identity_active?(browser_identity, identity),
|
|
126
|
+
unapproved_candidates: candidates, population_approval_error: approval&.error,
|
|
97
127
|
principal_source_selection_saved: !selection.nil? && selection.error.nil?,
|
|
98
|
-
principal_source_selection_error: selection&.error)
|
|
128
|
+
principal_source_selection_error: selection&.error, reproduction: reproduction)
|
|
99
129
|
end
|
|
100
130
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
101
131
|
|
|
@@ -119,6 +149,83 @@ module Karst
|
|
|
119
149
|
env["REQUEST_METHOD"] == "POST" && params["operation"] == "select_principal_sources"
|
|
120
150
|
end
|
|
121
151
|
|
|
152
|
+
# [evidence_document, nil], or [nil, forbidden_response]. Unlike the
|
|
153
|
+
# access sweep -- which only ever issues GET requests the developer
|
|
154
|
+
# could have made in their own browser -- reproduction issues one
|
|
155
|
+
# caller-specified request that may mutate, enqueue, and send mail, so
|
|
156
|
+
# it is protected exactly like the two operations that write local
|
|
157
|
+
# state: same-origin plus Karst's own CSRF token.
|
|
158
|
+
def reproduction_result(env, params, csrf)
|
|
159
|
+
return [nil, nil] unless env["REQUEST_METHOD"] == "POST" && params["operation"] == "reproduce_request"
|
|
160
|
+
return [nil, forbidden] unless approved_origin?(env)
|
|
161
|
+
|
|
162
|
+
csrf.verify!(params["csrf_token"])
|
|
163
|
+
[reproduce(env, params), nil]
|
|
164
|
+
rescue Csrf::InvalidToken
|
|
165
|
+
[nil, forbidden]
|
|
166
|
+
rescue ArgumentError => e
|
|
167
|
+
[{ schema_version: 1, error: { type: "input_error", message: e.message } }, nil]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def reproduce(env, params)
|
|
171
|
+
Karst::CLI::Reproduction.new(
|
|
172
|
+
path: params["path"], http_method: params["method"], body: params["body"],
|
|
173
|
+
content_type: params["content_type"], headers: request_headers(params["headers"]),
|
|
174
|
+
base_url: base_url(env)
|
|
175
|
+
).evidence
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# "Name: value" per line. A line Karst cannot read as a header is
|
|
179
|
+
# refused outright rather than silently dropped -- a header quietly
|
|
180
|
+
# missing from the request would make every observation below it wrong.
|
|
181
|
+
def request_headers(value)
|
|
182
|
+
value.to_s.split("\n").each_with_object({}) do |line, result|
|
|
183
|
+
next if line.strip.empty?
|
|
184
|
+
|
|
185
|
+
name, header_value = line.split(":", 2)
|
|
186
|
+
raise ArgumentError, "expected one header per line, as \"Name: value\"" if header_value.nil?
|
|
187
|
+
|
|
188
|
+
result[name.strip] = header_value.strip
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The developer's own origin, so a copied command runs against the
|
|
193
|
+
# server they are already looking at. Never a deployed host: Karst only
|
|
194
|
+
# ever knows about this local request.
|
|
195
|
+
def base_url(env)
|
|
196
|
+
request = Rack::Request.new(env)
|
|
197
|
+
"#{request.scheme}://#{request.host_with_port}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Validating and saving a submitted approval needs the effective
|
|
201
|
+
# principal sources *before* the write (to confirm the submitted
|
|
202
|
+
# candidates are currently real), so this necessarily resolves
|
|
203
|
+
# configuration once on its own -- separately from the fresh
|
|
204
|
+
# post-write resolution #call_owned takes afterward to render. `record`
|
|
205
|
+
# and `discovery` are still shared between the two config reads this
|
|
206
|
+
# one pre-write resolution would otherwise need (confirming
|
|
207
|
+
# already-approved populations, then discovering current candidates to
|
|
208
|
+
# validate the submission against), so this method itself reads the
|
|
209
|
+
# approval file and parses model source exactly once.
|
|
210
|
+
def inline_population_approval_result(env, params, csrf)
|
|
211
|
+
return [nil, nil] unless env["REQUEST_METHOD"] == "POST" && params["operation"] == "approve_populations"
|
|
212
|
+
return [nil, forbidden] unless approved_origin?(env)
|
|
213
|
+
|
|
214
|
+
csrf.verify!(params["csrf_token"])
|
|
215
|
+
[validate_population_approval(params), nil]
|
|
216
|
+
rescue Csrf::InvalidToken, Identity::Error
|
|
217
|
+
[nil, forbidden]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def validate_population_approval(params)
|
|
221
|
+
record = Access::PopulationApprovals.load
|
|
222
|
+
sources = nil
|
|
223
|
+
discovery = Access::PopulationDiscovery.new(principal_sources: -> { sources })
|
|
224
|
+
sources = Identity.principal_sources(record: record, discovery: discovery)
|
|
225
|
+
Access::PopulationApproval.new(discovery: discovery.call, principal_sources: sources,
|
|
226
|
+
submitted: params["population"], record: record).call
|
|
227
|
+
end
|
|
228
|
+
|
|
122
229
|
# Only a model Devise itself currently maps can ever be written --
|
|
123
230
|
# exactly like candidate-population approval only ever writes a
|
|
124
231
|
# currently discovered scope -- so this form can never seed the file
|
|
@@ -130,58 +237,30 @@ module Karst
|
|
|
130
237
|
Access::PrincipalSourceSelection.replace(names)
|
|
131
238
|
end
|
|
132
239
|
|
|
133
|
-
#
|
|
134
|
-
#
|
|
240
|
+
# Revocation changes local state that outlives the request, so every
|
|
241
|
+
# POST to this path must
|
|
135
242
|
# have come from Karst's own page. There is no Rack session to hang a
|
|
136
243
|
# CSRF token on here (this page deliberately touches none), so the
|
|
137
244
|
# check is same-origin rather than token-based; a cross-site form POST
|
|
138
|
-
# cannot forge Origin, so it cannot
|
|
245
|
+
# cannot forge Origin, so it cannot revoke anything. GET -- the page
|
|
139
246
|
# itself -- stays unauthenticated exactly like /karst.
|
|
140
247
|
def call_populations(env)
|
|
141
248
|
return forbidden unless approved_origin?(env)
|
|
142
249
|
|
|
143
250
|
params = owned_params(env)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
record =
|
|
147
|
-
render_populations(
|
|
251
|
+
revoking = env["REQUEST_METHOD"] == "POST" && params["operation"] == "revoke_population"
|
|
252
|
+
result = Access::PopulationRevocation.new(submitted: params["population"]).call if revoking
|
|
253
|
+
record = result&.record || Access::PopulationApprovals.load
|
|
254
|
+
render_populations(record, result&.revoked)
|
|
148
255
|
end
|
|
149
256
|
|
|
150
|
-
def render_populations(
|
|
151
|
-
approved = approved_candidates(discovery, record)
|
|
152
|
-
snippet = Access::PopulationConfigSnippet.generate(approved) if params["generate_snippet"]
|
|
257
|
+
def render_populations(record, revoked)
|
|
153
258
|
Web::PopulationsPanel.render(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
storage_path: Access::PopulationApprovals.display_path, storage_error: record.error, saved: saved
|
|
259
|
+
approved: record.entries, stale: stale_approvals(record),
|
|
260
|
+
storage_path: Access::PopulationApprovals.display_path, storage_error: record.error, revoked: revoked
|
|
157
261
|
)
|
|
158
262
|
end
|
|
159
263
|
|
|
160
|
-
def saving_approvals?(env, params)
|
|
161
|
-
env["REQUEST_METHOD"] == "POST" && params.key?("save_approvals")
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
# Only a candidate the *current* discovery result actually lists can
|
|
165
|
-
# ever be written, so the file cannot be seeded through this form with
|
|
166
|
-
# a model/scope pair Karst would refuse to confirm later anyway.
|
|
167
|
-
def save_approvals(discovery, params)
|
|
168
|
-
raw = Array(params["population"]).map(&:to_s)
|
|
169
|
-
entries = discovery.candidates.filter_map do |candidate|
|
|
170
|
-
next unless raw.include?(candidate_key(candidate))
|
|
171
|
-
|
|
172
|
-
Access::PopulationApprovals::Entry.new(model_name: candidate.model_name,
|
|
173
|
-
method_name: candidate.method_name.to_s)
|
|
174
|
-
end
|
|
175
|
-
Access::PopulationApprovals.replace(entries)
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
# Discovery candidates (which carry principal-source metadata the
|
|
179
|
-
# snippet generator needs) for the approved entries that are still
|
|
180
|
-
# discovered at all.
|
|
181
|
-
def approved_candidates(discovery, record)
|
|
182
|
-
discovery.candidates.select { |candidate| record.approved?(candidate.model_name, candidate.method_name) }
|
|
183
|
-
end
|
|
184
|
-
|
|
185
264
|
def stale_approvals(record)
|
|
186
265
|
Access::ApprovedPopulations.stale(safe_principal_sources, record: record)
|
|
187
266
|
rescue StandardError
|
|
@@ -213,21 +292,6 @@ module Karst
|
|
|
213
292
|
[403, { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store" }, ["Forbidden"]]
|
|
214
293
|
end
|
|
215
294
|
|
|
216
|
-
def population_preview(discovery, params)
|
|
217
|
-
key = params["preview"].to_s
|
|
218
|
-
return nil if key.empty?
|
|
219
|
-
|
|
220
|
-
candidate = discovery.candidates.find { |item| candidate_key(item) == key }
|
|
221
|
-
return nil unless candidate
|
|
222
|
-
|
|
223
|
-
Access::PopulationPreview.call(model_name: candidate.model_name, method_name: candidate.method_name,
|
|
224
|
-
discovery_result: discovery)
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
def candidate_key(candidate)
|
|
228
|
-
"#{candidate.model_name}#{CANDIDATE_SEPARATOR}#{candidate.method_name}"
|
|
229
|
-
end
|
|
230
|
-
|
|
231
295
|
def call_with_badge(env)
|
|
232
296
|
Karst::ExecutionContext[CONTEXT_KEY] = nil
|
|
233
297
|
status, headers, body = @app.call(env)
|
|
@@ -262,32 +326,30 @@ module Karst
|
|
|
262
326
|
# "try this population" operation for a developer to press --
|
|
263
327
|
# and no path by which a merely discovered, unapproved population
|
|
264
328
|
# name can be executed.
|
|
265
|
-
def analyze(env, params)
|
|
329
|
+
def analyze(env, params, identity, approval: nil)
|
|
266
330
|
return nil unless env["REQUEST_METHOD"] == "POST"
|
|
267
|
-
return nil unless params["operation"] == "access_sweep"
|
|
331
|
+
return nil unless params["operation"] == "access_sweep" || approval
|
|
268
332
|
|
|
269
333
|
Access::Search.new(path: params["path"], http_method: params["method"],
|
|
270
|
-
sources:
|
|
334
|
+
sources: identity.principal_sources!).call
|
|
271
335
|
rescue Access::Error, Identity::Error, ArgumentError => e
|
|
272
336
|
e
|
|
273
337
|
end
|
|
274
338
|
|
|
275
|
-
#
|
|
276
|
-
#
|
|
339
|
+
# Application-defined groups on an already-configured user source a
|
|
340
|
+
# developer could still approve. Computed only after an
|
|
277
341
|
# analysis that found nothing usable -- the one moment the answer is
|
|
278
342
|
# actionable -- so an ordinary panel render never parses model source,
|
|
279
|
-
# and the main page
|
|
280
|
-
#
|
|
281
|
-
def
|
|
282
|
-
return
|
|
343
|
+
# and the main page stays a contextual approval step rather than a
|
|
344
|
+
# population-management dashboard. Discovery executes nothing.
|
|
345
|
+
def inline_population_candidates(result, record:, discovery:)
|
|
346
|
+
return [] unless result.is_a?(Access::Search::Result) && result.verified_outcome.nil?
|
|
283
347
|
|
|
284
|
-
|
|
285
|
-
count = Access::PopulationDiscovery.new.call.candidates.count do |candidate|
|
|
348
|
+
discovery.call.candidates.select do |candidate|
|
|
286
349
|
candidate.principal_source && !record.approved?(candidate.model_name, candidate.method_name)
|
|
287
350
|
end
|
|
288
|
-
count.positive? ? count : nil
|
|
289
351
|
rescue StandardError
|
|
290
|
-
|
|
352
|
+
[]
|
|
291
353
|
end
|
|
292
354
|
|
|
293
355
|
def mutate_browser_identity(env, params, browser_identity)
|
|
@@ -311,14 +373,14 @@ module Karst
|
|
|
311
373
|
end
|
|
312
374
|
end
|
|
313
375
|
|
|
314
|
-
def
|
|
315
|
-
|
|
316
|
-
rescue
|
|
376
|
+
def csrf_token(csrf)
|
|
377
|
+
csrf.token
|
|
378
|
+
rescue Csrf::InvalidToken
|
|
317
379
|
nil
|
|
318
380
|
end
|
|
319
381
|
|
|
320
|
-
def browser_identity_active?(browser_identity)
|
|
321
|
-
|
|
382
|
+
def browser_identity_active?(browser_identity, identity)
|
|
383
|
+
identity.browser_supported? && browser_identity.active?
|
|
322
384
|
rescue Identity::Error
|
|
323
385
|
false
|
|
324
386
|
end
|
|
@@ -363,8 +425,7 @@ module Karst
|
|
|
363
425
|
nil
|
|
364
426
|
end
|
|
365
427
|
|
|
366
|
-
#
|
|
367
|
-
# query string can carry a token (password reset, OAuth callback,
|
|
428
|
+
# A query string can carry a token (password reset, OAuth callback,
|
|
368
429
|
# signed URL), and this path is only ever contextual display evidence
|
|
369
430
|
# in the panel, never route identity, so it is never worth the risk.
|
|
370
431
|
def strip_query(value)
|