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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +14 -12
  3. data/CHANGELOG.md +114 -47
  4. data/CONTRIBUTING.md +9 -1
  5. data/README.md +137 -9
  6. data/SECURITY.md +1 -1
  7. data/docs/advanced-configuration.md +112 -7
  8. data/docs/rails8-authentication.md +191 -0
  9. data/docs/request-reproduction.md +242 -0
  10. data/lib/generators/karst/install/templates/karst_initializer.rb +6 -0
  11. data/lib/karst/access/approved_populations.rb +15 -6
  12. data/lib/karst/access/errors.rb +16 -0
  13. data/lib/karst/access/identity_probe.rb +151 -0
  14. data/lib/karst/access/local_path.rb +42 -0
  15. data/lib/karst/access/observed_endpoint.rb +46 -0
  16. data/lib/karst/access/outcome_groups.rb +93 -0
  17. data/lib/karst/access/population_approval.rb +67 -0
  18. data/lib/karst/access/population_discovery.rb +25 -1
  19. data/lib/karst/access/population_revocation.rb +34 -0
  20. data/lib/karst/access/principal_source.rb +24 -3
  21. data/lib/karst/access/probe.rb +397 -0
  22. data/lib/karst/access/search.rb +14 -6
  23. data/lib/karst/access/sweep.rb +42 -112
  24. data/lib/karst/cli/identity_serialization.rb +82 -0
  25. data/lib/karst/cli/outcome_summary.rb +131 -0
  26. data/lib/karst/cli/principal_reference.rb +51 -0
  27. data/lib/karst/cli/reproduction.rb +267 -0
  28. data/lib/karst/cli/verification.rb +219 -55
  29. data/lib/karst/configuration.rb +57 -11
  30. data/lib/karst/execution_context.rb +6 -7
  31. data/lib/karst/identity/evidence.rb +110 -0
  32. data/lib/karst/identity/observation.rb +167 -0
  33. data/lib/karst/identity/warden_adapter.rb +26 -1
  34. data/lib/karst/identity.rb +157 -22
  35. data/lib/karst/mcp/compatibility.rb +24 -0
  36. data/lib/karst/mcp/reproduce_request_tool.rb +137 -0
  37. data/lib/karst/mcp/reproduction.rb +58 -0
  38. data/lib/karst/mcp/server.rb +27 -8
  39. data/lib/karst/mcp/verify_access_tool.rb +36 -2
  40. data/lib/karst/railtie.rb +4 -7
  41. data/lib/karst/reproduction/curl.rb +73 -0
  42. data/lib/karst/reproduction/exercise.rb +246 -0
  43. data/lib/karst/reproduction/observation.rb +87 -0
  44. data/lib/karst/reproduction/sanitizer.rb +210 -0
  45. data/lib/karst/version.rb +1 -1
  46. data/lib/karst/web/badge.rb +5 -31
  47. data/lib/karst/web/browser_identity.rb +10 -13
  48. data/lib/karst/web/csrf.rb +43 -0
  49. data/lib/karst/web/middleware.rb +145 -84
  50. data/lib/karst/web/panel.rb +343 -125
  51. data/lib/karst/web/populations_panel.rb +46 -336
  52. data/lib/karst.rb +3 -3
  53. data/lib/rails/commands/karst/mcp/mcp_command.rb +41 -1
  54. data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
  55. data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
  56. metadata +38 -18
  57. data/lib/karst/access/population_config_snippet.rb +0 -67
  58. data/lib/karst/access/population_preview.rb +0 -83
  59. data/lib/karst/access/resource_evidence.rb +0 -233
  60. data/lib/karst/spec/catalog.rb +0 -199
  61. data/lib/karst/spec/example_observation.rb +0 -31
  62. data/lib/karst/spec/observer.rb +0 -300
  63. data/lib/karst/spec/principal.rb +0 -12
  64. data/lib/karst/spec/reporter.rb +0 -83
  65. data/lib/karst/spec/request_observation.rb +0 -38
  66. data/lib/karst/spec/scenario.rb +0 -65
  67. data/lib/tasks/karst.rake +0 -34
@@ -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
- # into Karst's route-scoped scenario catalog. Every check here exists to
17
- # make injection safe to skip: Badge never guesses, never raises into the
18
- # host application, and never touches a response it cannot confidently
19
- # rewrite -- an untouched response is always the safe fallback.
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}>#{label}</a>"
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
- require "active_support/security_utils"
4
+ require_relative "csrf"
6
5
 
7
6
  module Karst
8
7
  module Web
9
- # State-changing browser identity operations and their same-session CSRF
10
- # token. Rails controller CSRF is unavailable because /karst is served at
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
- session[TOKEN_KEY] ||= SecureRandom.hex(32)
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
- expected = session[TOKEN_KEY]
72
- valid = expected && submitted && expected.bytesize == submitted.bytesize &&
73
- ActiveSupport::SecurityUtils.secure_compare(expected, submitted)
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
- session[TOKEN_KEY] = SecureRandom.hex(32)
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
@@ -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
- browser_identity = BrowserIdentity.new(Rack::Request.new(env))
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
- result = analyze(env, params)
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: browser_token(browser_identity),
95
- browser_identity_active: browser_identity_active?(browser_identity),
96
- unapproved_candidate_count: unapproved_candidate_count(result),
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
- # Unlike every other operation Karst serves, approving writes local
134
- # state that outlives the request, so every POST to this path must
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 approve anything. GET -- the page
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
- discovery = Access::PopulationDiscovery.new.call
145
- saving = saving_approvals?(env, params)
146
- record = saving ? save_approvals(discovery, params) : Access::PopulationApprovals.load
147
- render_populations(discovery, params, record, saving)
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(discovery, params, record, saved)
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
- discovery: discovery, approved: record.entries, stale: stale_approvals(record),
155
- snippet: snippet, preview: population_preview(discovery, params),
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: Identity.principal_sources).call
334
+ sources: identity.principal_sources!).call
271
335
  rescue Access::Error, Identity::Error, ArgumentError => e
272
336
  e
273
337
  end
274
338
 
275
- # How many application-defined groups on an already-configured user
276
- # source a developer could still approve. Computed only after an
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 never turns into a population-configuration
280
- # workflow. Discovery executes nothing; see PopulationDiscovery.
281
- def unapproved_candidate_count(result)
282
- return nil unless result.is_a?(Access::Search::Result) && result.verified_outcome.nil?
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
- record = Access::PopulationApprovals.load
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
- nil
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 browser_token(browser_identity)
315
- browser_identity.token if Identity.browser_supported?
316
- rescue Identity::Error
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
- Identity.browser_supported? && browser_identity.active?
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
- # Mirrors Karst::Spec::Observer's own treatment of request paths: a
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)