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
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "population_approvals"
|
|
4
|
+
|
|
5
|
+
module Karst
|
|
6
|
+
module Access
|
|
7
|
+
# Adds explicitly selected candidates from one current discovery pass to
|
|
8
|
+
# the local approval record. Submitted values are treated only as keys:
|
|
9
|
+
# the Entry objects written below always come from discovery itself.
|
|
10
|
+
class PopulationApproval
|
|
11
|
+
Result = Value.define(:record, :error) do
|
|
12
|
+
def saved?
|
|
13
|
+
error.nil? && record.error.nil?
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
SEPARATOR = "::"
|
|
18
|
+
|
|
19
|
+
def initialize(discovery:, principal_sources:, submitted:, record: PopulationApprovals.load)
|
|
20
|
+
@discovery = discovery
|
|
21
|
+
@principal_sources = principal_sources
|
|
22
|
+
@submitted = Array(submitted).map(&:to_s)
|
|
23
|
+
@record = record
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call
|
|
27
|
+
selected = selected_candidates
|
|
28
|
+
return rejected("Select at least one current candidate population.") if @submitted.empty?
|
|
29
|
+
return rejected("A submitted population is no longer available for this principal source.") unless selected
|
|
30
|
+
|
|
31
|
+
entries = @record.entries + selected.map do |candidate|
|
|
32
|
+
PopulationApprovals::Entry.new(model_name: candidate.model_name,
|
|
33
|
+
method_name: candidate.method_name.to_s)
|
|
34
|
+
end
|
|
35
|
+
record = PopulationApprovals.replace(entries)
|
|
36
|
+
Result.new(record: record, error: record.error)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def selected_candidates
|
|
42
|
+
candidates = applicable_candidates
|
|
43
|
+
by_key = candidates.to_h { |candidate| [candidate_key(candidate), candidate] }
|
|
44
|
+
return unless @submitted.uniq.size == @submitted.size
|
|
45
|
+
return unless @submitted.all? { |key| by_key.key?(key) }
|
|
46
|
+
|
|
47
|
+
@submitted.map { |key| by_key.fetch(key) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def applicable_candidates
|
|
51
|
+
names = @principal_sources.keys.map(&:to_s)
|
|
52
|
+
@discovery.candidates.select do |candidate|
|
|
53
|
+
candidate.principal_source && names.include?(candidate.principal_source.to_s) &&
|
|
54
|
+
!@record.approved?(candidate.model_name, candidate.method_name)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def candidate_key(candidate)
|
|
59
|
+
[candidate.principal_source, candidate.model_name, candidate.method_name].join(SEPARATOR)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def rejected(message)
|
|
63
|
+
Result.new(record: @record, error: message)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "population_approvals"
|
|
4
|
+
|
|
5
|
+
module Karst
|
|
6
|
+
module Access
|
|
7
|
+
# Removes one exact, already-stored approval while preserving every other
|
|
8
|
+
# entry. Submitted values can never add or alter an approval.
|
|
9
|
+
class PopulationRevocation
|
|
10
|
+
Result = Value.define(:record, :revoked)
|
|
11
|
+
|
|
12
|
+
SEPARATOR = "::"
|
|
13
|
+
|
|
14
|
+
def initialize(submitted:, record: PopulationApprovals.load)
|
|
15
|
+
@submitted = submitted.to_s
|
|
16
|
+
@record = record
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
entry = @record.entries.find { |candidate| key(candidate) == @submitted }
|
|
21
|
+
return Result.new(record: @record, revoked: false) unless entry
|
|
22
|
+
|
|
23
|
+
record = PopulationApprovals.replace(@record.entries - [entry])
|
|
24
|
+
Result.new(record: record, revoked: record.error.nil?)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def key(entry)
|
|
30
|
+
[entry.model_name, entry.method_name].join(SEPARATOR)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/karst/configuration.rb
CHANGED
|
@@ -15,8 +15,8 @@ module Karst
|
|
|
15
15
|
# Process-level settings that control Karst's implemented behavior.
|
|
16
16
|
#
|
|
17
17
|
# An ordinary application sets none of this. A single-model Devise app is
|
|
18
|
-
# inferred outright, candidate populations are approved
|
|
19
|
-
#
|
|
18
|
+
# inferred outright, candidate populations are approved inline at /karst
|
|
19
|
+
# rather than written here, and every limit below
|
|
20
20
|
# already has a bounded default. `enabled` is not normal configuration
|
|
21
21
|
# either -- it is an off switch for exceptional cases (a shared
|
|
22
22
|
# development environment, a CI job that boots Rails but must never run
|
|
@@ -83,7 +83,7 @@ module Karst
|
|
|
83
83
|
"actually runs, and no longer keeps a process-wide sql.active_record buffer",
|
|
84
84
|
principal_dimensions: "sampling states are derived from the schema automatically; there is " \
|
|
85
85
|
"nothing to declare, and rare users are reached through candidate " \
|
|
86
|
-
"populations approved
|
|
86
|
+
"populations approved after a failed /karst analysis",
|
|
87
87
|
artifact_source: "artifact scenarios were removed; Karst analyzes routes, not record sweeps",
|
|
88
88
|
access_scenario: "artifact scenarios were removed; Karst analyzes routes, not record sweeps"
|
|
89
89
|
}.freeze
|
|
@@ -110,8 +110,8 @@ module Karst
|
|
|
110
110
|
# auditors: -> { User.auditors }
|
|
111
111
|
# }
|
|
112
112
|
#
|
|
113
|
-
# This is the committed-to-source form of what /karst
|
|
114
|
-
#
|
|
113
|
+
# This is the committed-to-source form of what /karst captures locally,
|
|
114
|
+
# and is needed only where machine-local approval state
|
|
115
115
|
# is deliberately not consulted (CI) or where populations should be
|
|
116
116
|
# reviewable code. Karst never infers that a population grants access or
|
|
117
117
|
# produces any UI state; it only tries records from it (see
|
|
@@ -9,11 +9,10 @@ rescue LoadError
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
module Karst
|
|
12
|
-
# Request-local correlation storage used by the page badge
|
|
13
|
-
#
|
|
14
|
-
# nested inside a Rack call or an RSpec example) back out to the code that
|
|
12
|
+
# Request-local correlation storage used by the page badge to carry evidence
|
|
13
|
+
# from a notification callback fired inside a Rack call back out to the code that
|
|
15
14
|
# reads it, without a global mutable variable that would let concurrent
|
|
16
|
-
# requests
|
|
15
|
+
# requests cross-contaminate each other.
|
|
17
16
|
#
|
|
18
17
|
# Modern Rails already solves exactly this with
|
|
19
18
|
# ActiveSupport::IsolatedExecutionState, so Karst simply delegates to it
|
|
@@ -23,9 +22,9 @@ module Karst
|
|
|
23
22
|
# fiber-local and would silently miss context under a Fiber scheduler.
|
|
24
23
|
# This mirrors ActiveSupport::IsolatedExecutionState's own default :thread
|
|
25
24
|
# isolation level: storage is shared by every Fiber running on one OS
|
|
26
|
-
# thread, not isolated per Fiber. Karst's own usage (one badge
|
|
27
|
-
#
|
|
28
|
-
#
|
|
25
|
+
# thread, not isolated per Fiber. Karst's own usage (one badge correlation
|
|
26
|
+
# captured and read back within a single synchronous request) never spans
|
|
27
|
+
# multiple concurrently-scheduled Fibers, so
|
|
29
28
|
# this fallback has no observable effect on Karst's supported behavior. It
|
|
30
29
|
# is documented here so a future caller does not assume Fiber isolation
|
|
31
30
|
# this fallback cannot provide.
|
data/lib/karst/railtie.rb
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
require "rails/railtie"
|
|
4
4
|
|
|
5
5
|
module Karst
|
|
6
|
-
# Inserts Karst's development-only web surface
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# Inserts Karst's development-only web surface. Karst installs no
|
|
7
|
+
# notification subscription, no eager-loaded state, no rake tasks (its CLI
|
|
8
|
+
# ships as Rails::Command classes under lib/rails/commands instead), and
|
|
9
|
+
# nothing that runs on an ordinary application request outside development.
|
|
9
10
|
class Railtie < Rails::Railtie
|
|
10
11
|
# Must run before the middleware stack is built (a later Finisher
|
|
11
12
|
# initializer), not in config.after_initialize, which runs after the stack
|
|
@@ -20,10 +21,6 @@ module Karst
|
|
|
20
21
|
|
|
21
22
|
Rails.logger&.info("Karst: evidence at /karst")
|
|
22
23
|
end
|
|
23
|
-
|
|
24
|
-
rake_tasks do
|
|
25
|
-
load File.expand_path("../tasks/karst.rake", __dir__)
|
|
26
|
-
end
|
|
27
24
|
end
|
|
28
25
|
|
|
29
26
|
private_constant :Railtie
|
data/lib/karst/version.rb
CHANGED
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,9 +19,9 @@ 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"
|
|
25
26
|
|
|
26
27
|
module Karst
|
|
@@ -52,9 +53,6 @@ module Karst
|
|
|
52
53
|
POPULATIONS_PATH = "/karst/populations"
|
|
53
54
|
private_constant :POPULATIONS_PATH
|
|
54
55
|
|
|
55
|
-
CANDIDATE_SEPARATOR = "::"
|
|
56
|
-
private_constant :CANDIDATE_SEPARATOR
|
|
57
|
-
|
|
58
56
|
CONTEXT_KEY = :karst_web_request_context
|
|
59
57
|
private_constant :CONTEXT_KEY
|
|
60
58
|
|
|
@@ -85,15 +83,21 @@ module Karst
|
|
|
85
83
|
selection, denied = principal_source_selection_result(env, params)
|
|
86
84
|
return denied if denied
|
|
87
85
|
|
|
88
|
-
|
|
86
|
+
request = Rack::Request.new(env)
|
|
87
|
+
csrf = Csrf.new(request)
|
|
88
|
+
browser_identity = BrowserIdentity.new(request, csrf: csrf)
|
|
89
|
+
approval, denied = inline_population_approval_result(env, params, csrf)
|
|
90
|
+
return denied if denied
|
|
91
|
+
|
|
89
92
|
identity_response = mutate_browser_identity(env, params, browser_identity)
|
|
90
93
|
return identity_response if identity_response
|
|
91
94
|
|
|
92
|
-
result = analyze(env, params)
|
|
95
|
+
result = analyze(env, params, approval: approval)
|
|
96
|
+
candidates = inline_population_candidates(result)
|
|
93
97
|
Panel.render(params: params, access_result: result, route_lookup_limitation: lookup&.limitation,
|
|
94
|
-
csrf_token:
|
|
98
|
+
csrf_token: csrf_token(csrf),
|
|
95
99
|
browser_identity_active: browser_identity_active?(browser_identity),
|
|
96
|
-
|
|
100
|
+
unapproved_candidates: candidates, population_approval_error: approval&.error,
|
|
97
101
|
principal_source_selection_saved: !selection.nil? && selection.error.nil?,
|
|
98
102
|
principal_source_selection_error: selection&.error)
|
|
99
103
|
end
|
|
@@ -119,6 +123,19 @@ module Karst
|
|
|
119
123
|
env["REQUEST_METHOD"] == "POST" && params["operation"] == "select_principal_sources"
|
|
120
124
|
end
|
|
121
125
|
|
|
126
|
+
def inline_population_approval_result(env, params, csrf)
|
|
127
|
+
return [nil, nil] unless env["REQUEST_METHOD"] == "POST" && params["operation"] == "approve_populations"
|
|
128
|
+
return [nil, forbidden] unless approved_origin?(env)
|
|
129
|
+
|
|
130
|
+
csrf.verify!(params["csrf_token"])
|
|
131
|
+
discovery = Access::PopulationDiscovery.new.call
|
|
132
|
+
approval = Access::PopulationApproval.new(discovery: discovery, principal_sources: Identity.principal_sources,
|
|
133
|
+
submitted: params["population"]).call
|
|
134
|
+
[approval, nil]
|
|
135
|
+
rescue Csrf::InvalidToken, Identity::Error
|
|
136
|
+
[nil, forbidden]
|
|
137
|
+
end
|
|
138
|
+
|
|
122
139
|
# Only a model Devise itself currently maps can ever be written --
|
|
123
140
|
# exactly like candidate-population approval only ever writes a
|
|
124
141
|
# currently discovered scope -- so this form can never seed the file
|
|
@@ -130,58 +147,30 @@ module Karst
|
|
|
130
147
|
Access::PrincipalSourceSelection.replace(names)
|
|
131
148
|
end
|
|
132
149
|
|
|
133
|
-
#
|
|
134
|
-
#
|
|
150
|
+
# Revocation changes local state that outlives the request, so every
|
|
151
|
+
# POST to this path must
|
|
135
152
|
# have come from Karst's own page. There is no Rack session to hang a
|
|
136
153
|
# CSRF token on here (this page deliberately touches none), so the
|
|
137
154
|
# check is same-origin rather than token-based; a cross-site form POST
|
|
138
|
-
# cannot forge Origin, so it cannot
|
|
155
|
+
# cannot forge Origin, so it cannot revoke anything. GET -- the page
|
|
139
156
|
# itself -- stays unauthenticated exactly like /karst.
|
|
140
157
|
def call_populations(env)
|
|
141
158
|
return forbidden unless approved_origin?(env)
|
|
142
159
|
|
|
143
160
|
params = owned_params(env)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
record =
|
|
147
|
-
render_populations(
|
|
161
|
+
revoking = env["REQUEST_METHOD"] == "POST" && params["operation"] == "revoke_population"
|
|
162
|
+
result = Access::PopulationRevocation.new(submitted: params["population"]).call if revoking
|
|
163
|
+
record = result&.record || Access::PopulationApprovals.load
|
|
164
|
+
render_populations(record, result&.revoked)
|
|
148
165
|
end
|
|
149
166
|
|
|
150
|
-
def render_populations(
|
|
151
|
-
approved = approved_candidates(discovery, record)
|
|
152
|
-
snippet = Access::PopulationConfigSnippet.generate(approved) if params["generate_snippet"]
|
|
167
|
+
def render_populations(record, revoked)
|
|
153
168
|
Web::PopulationsPanel.render(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
storage_path: Access::PopulationApprovals.display_path, storage_error: record.error, saved: saved
|
|
169
|
+
approved: record.entries, stale: stale_approvals(record),
|
|
170
|
+
storage_path: Access::PopulationApprovals.display_path, storage_error: record.error, revoked: revoked
|
|
157
171
|
)
|
|
158
172
|
end
|
|
159
173
|
|
|
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
174
|
def stale_approvals(record)
|
|
186
175
|
Access::ApprovedPopulations.stale(safe_principal_sources, record: record)
|
|
187
176
|
rescue StandardError
|
|
@@ -213,21 +202,6 @@ module Karst
|
|
|
213
202
|
[403, { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store" }, ["Forbidden"]]
|
|
214
203
|
end
|
|
215
204
|
|
|
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
205
|
def call_with_badge(env)
|
|
232
206
|
Karst::ExecutionContext[CONTEXT_KEY] = nil
|
|
233
207
|
status, headers, body = @app.call(env)
|
|
@@ -262,9 +236,9 @@ module Karst
|
|
|
262
236
|
# "try this population" operation for a developer to press --
|
|
263
237
|
# and no path by which a merely discovered, unapproved population
|
|
264
238
|
# name can be executed.
|
|
265
|
-
def analyze(env, params)
|
|
239
|
+
def analyze(env, params, approval: nil)
|
|
266
240
|
return nil unless env["REQUEST_METHOD"] == "POST"
|
|
267
|
-
return nil unless params["operation"] == "access_sweep"
|
|
241
|
+
return nil unless params["operation"] == "access_sweep" || approval
|
|
268
242
|
|
|
269
243
|
Access::Search.new(path: params["path"], http_method: params["method"],
|
|
270
244
|
sources: Identity.principal_sources).call
|
|
@@ -272,22 +246,21 @@ module Karst
|
|
|
272
246
|
e
|
|
273
247
|
end
|
|
274
248
|
|
|
275
|
-
#
|
|
276
|
-
#
|
|
249
|
+
# Application-defined groups on an already-configured user source a
|
|
250
|
+
# developer could still approve. Computed only after an
|
|
277
251
|
# analysis that found nothing usable -- the one moment the answer is
|
|
278
252
|
# actionable -- so an ordinary panel render never parses model source,
|
|
279
|
-
# and the main page
|
|
280
|
-
#
|
|
281
|
-
def
|
|
282
|
-
return
|
|
253
|
+
# and the main page stays a contextual approval step rather than a
|
|
254
|
+
# population-management dashboard. Discovery executes nothing.
|
|
255
|
+
def inline_population_candidates(result)
|
|
256
|
+
return [] unless result.is_a?(Access::Search::Result) && result.verified_outcome.nil?
|
|
283
257
|
|
|
284
258
|
record = Access::PopulationApprovals.load
|
|
285
|
-
|
|
259
|
+
Access::PopulationDiscovery.new.call.candidates.select do |candidate|
|
|
286
260
|
candidate.principal_source && !record.approved?(candidate.model_name, candidate.method_name)
|
|
287
261
|
end
|
|
288
|
-
count.positive? ? count : nil
|
|
289
262
|
rescue StandardError
|
|
290
|
-
|
|
263
|
+
[]
|
|
291
264
|
end
|
|
292
265
|
|
|
293
266
|
def mutate_browser_identity(env, params, browser_identity)
|
|
@@ -311,9 +284,9 @@ module Karst
|
|
|
311
284
|
end
|
|
312
285
|
end
|
|
313
286
|
|
|
314
|
-
def
|
|
315
|
-
|
|
316
|
-
rescue
|
|
287
|
+
def csrf_token(csrf)
|
|
288
|
+
csrf.token
|
|
289
|
+
rescue Csrf::InvalidToken
|
|
317
290
|
nil
|
|
318
291
|
end
|
|
319
292
|
|
|
@@ -363,8 +336,7 @@ module Karst
|
|
|
363
336
|
nil
|
|
364
337
|
end
|
|
365
338
|
|
|
366
|
-
#
|
|
367
|
-
# query string can carry a token (password reset, OAuth callback,
|
|
339
|
+
# A query string can carry a token (password reset, OAuth callback,
|
|
368
340
|
# signed URL), and this path is only ever contextual display evidence
|
|
369
341
|
# in the panel, never route identity, so it is never worth the risk.
|
|
370
342
|
def strip_query(value)
|