karst 0.2.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 +6 -1
- data/CHANGELOG.md +82 -2
- data/CONTRIBUTING.md +9 -1
- data/README.md +118 -2
- data/docs/advanced-configuration.md +106 -0
- data/docs/rails8-authentication.md +24 -4
- 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_discovery.rb +25 -1
- 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 +52 -6
- 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/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/middleware.rb +105 -16
- data/lib/karst/web/panel.rb +302 -47
- data/lib/karst.rb +2 -0
- data/lib/rails/commands/karst/mcp/mcp_command.rb +30 -3
- data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
- data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
- metadata +33 -6
data/lib/karst/access/sweep.rb
CHANGED
|
@@ -1,28 +1,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
require_relative "
|
|
6
|
-
require_relative "
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "local_path"
|
|
5
|
+
require_relative "probe"
|
|
6
|
+
require_relative "outcome_groups"
|
|
7
7
|
require_relative "../identity"
|
|
8
8
|
require_relative "../value"
|
|
9
9
|
|
|
10
10
|
module Karst
|
|
11
11
|
module Access
|
|
12
|
-
class Error < StandardError; end
|
|
13
|
-
class UnsafeTarget < Error; end
|
|
14
|
-
class UnsupportedMethod < Error; end
|
|
15
|
-
class Unavailable < Error; end
|
|
16
|
-
|
|
17
12
|
# sampling_reasons is a frozen Array of short evidence strings (e.g.
|
|
18
13
|
# "role=local_admin", "source=authors") explaining why PrincipalSampler
|
|
19
14
|
# or PrincipalSelection deliberately included this principal, or an
|
|
20
15
|
# empty Array when the principal came from plain first-N/fill sampling
|
|
21
16
|
# or was supplied directly rather than through a sampler. This is
|
|
22
17
|
# sampling evidence, not an authorization claim.
|
|
18
|
+
#
|
|
19
|
+
# `principal` is the *requested* identity -- what Karst was asked to
|
|
20
|
+
# execute as, nil for a deliberate anonymous probe -- and is intent, not
|
|
21
|
+
# evidence. What the application actually resolved lives in `identity`
|
|
22
|
+
# (a Karst::Identity::Evidence), and only its #confirmation says whether
|
|
23
|
+
# the two agree. Nothing may conclude "this request ran as User#123" from
|
|
24
|
+
# `principal` alone.
|
|
25
|
+
#
|
|
26
|
+
# controller/action are the controller class name and action the request
|
|
27
|
+
# actually dispatched to, observed from the probe request's own env, or
|
|
28
|
+
# nil when it never reached a controller.
|
|
29
|
+
#
|
|
30
|
+
# Every evidence field is Access::Probe's observation of the target
|
|
31
|
+
# request alone: write_count never includes identity setup/teardown.
|
|
23
32
|
Outcome = Value.define(:principal, :status, :redirect, :exception_class,
|
|
24
33
|
:writes_observed, :write_count, :elapsed_ms, :database_rollback_attempted,
|
|
25
|
-
:sampling_reasons, :
|
|
34
|
+
:sampling_reasons, :halted_callback, :identity, :controller, :action)
|
|
26
35
|
|
|
27
36
|
# candidate_pool_size is nil unless the caller supplying `principals` (see
|
|
28
37
|
# Access::PrincipalSampler::Result) knows it sampled from a bounded
|
|
@@ -31,14 +40,20 @@ module Karst
|
|
|
31
40
|
# principal was considered.
|
|
32
41
|
Result = Value.define(:path, :http_method, :outcomes, :elapsed_ms, :aborted_reason, :database_isolation,
|
|
33
42
|
:candidate_pool_size) do
|
|
43
|
+
# Semantic outcome groups (see Access::OutcomeGroups), largest first,
|
|
44
|
+
# as semantic key => member outcomes.
|
|
34
45
|
def groups
|
|
35
|
-
outcomes.
|
|
46
|
+
OutcomeGroups.group(outcomes).to_h { |group| [group.key, group.outcomes] }
|
|
36
47
|
end
|
|
37
48
|
end
|
|
38
49
|
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
50
|
+
# Runs one concrete local GET as each of a bounded set of principals, in
|
|
51
|
+
# order, and collects what happened to each. Every request is one
|
|
52
|
+
# Access::Probe call -- a fresh session and a rollback-only transaction per
|
|
53
|
+
# principal -- so a sweep's per-principal evidence is exactly what
|
|
54
|
+
# reproducing that request as that principal observes. Sweep itself owns
|
|
55
|
+
# only the population: bounding it, and attaching why each principal was
|
|
56
|
+
# sampled.
|
|
42
57
|
class Sweep
|
|
43
58
|
# sampling_reasons optionally maps a principal (by Ruby equality, so
|
|
44
59
|
# the same Active Record identity even across separate instances) to
|
|
@@ -46,9 +61,8 @@ module Karst
|
|
|
46
61
|
# Access::PrincipalSampler::Candidate/PrincipalSelection. A principal
|
|
47
62
|
# with no entry simply gets an empty Array on its Outcome.
|
|
48
63
|
# rubocop:disable Metrics/ParameterLists
|
|
49
|
-
# rubocop:disable Metrics/MethodLength
|
|
50
64
|
def initialize(path:, principals:, http_method: "GET", limit: Karst.config.access_sweep_limit,
|
|
51
|
-
application: nil, candidate_pool_size: nil, sampling_reasons: {}
|
|
65
|
+
application: nil, candidate_pool_size: nil, sampling_reasons: {})
|
|
52
66
|
@path = normalize_path(path)
|
|
53
67
|
@http_method = http_method.to_s.upcase
|
|
54
68
|
raise UnsupportedMethod, "access sweeps support GET only" unless @http_method == "GET"
|
|
@@ -56,23 +70,20 @@ module Karst
|
|
|
56
70
|
|
|
57
71
|
@principals = principals
|
|
58
72
|
@limit = limit
|
|
59
|
-
@
|
|
60
|
-
@probe_application = build_probe_application
|
|
73
|
+
@probe = Probe.new(application || Rails.application)
|
|
61
74
|
@candidate_pool_size = candidate_pool_size
|
|
62
75
|
@sampling_reasons = sampling_reasons
|
|
63
|
-
@body_includes = body_includes
|
|
64
76
|
end
|
|
65
|
-
# rubocop:enable Metrics/MethodLength
|
|
66
77
|
# rubocop:enable Metrics/ParameterLists
|
|
67
78
|
|
|
68
79
|
def call
|
|
69
80
|
raise Unavailable, "access sweeps are development-only" unless Rails.env.development?
|
|
70
81
|
raise Unavailable, "Karst is disabled (config.enabled)" unless Karst.enabled?
|
|
71
82
|
|
|
72
|
-
require "action_dispatch/testing/integration" unless defined?(ActionDispatch::Integration::Session)
|
|
73
|
-
|
|
74
83
|
started = monotonic
|
|
75
|
-
outcomes = bounded_principals.map
|
|
84
|
+
outcomes = bounded_principals.map do |principal|
|
|
85
|
+
outcome(principal, @probe.call(principal: principal, http_method: @http_method, target: @path))
|
|
86
|
+
end
|
|
76
87
|
Result.new(path: @path, http_method: @http_method, outcomes: outcomes.freeze,
|
|
77
88
|
elapsed_ms: elapsed(started), aborted_reason: nil,
|
|
78
89
|
database_isolation: :same_connection_rollback_attempted,
|
|
@@ -82,14 +93,7 @@ module Karst
|
|
|
82
93
|
private
|
|
83
94
|
|
|
84
95
|
def normalize_path(value)
|
|
85
|
-
|
|
86
|
-
uri = URI.parse(raw)
|
|
87
|
-
local = uri.relative? && raw.start_with?("/") && !raw.start_with?("//")
|
|
88
|
-
raise UnsafeTarget, "target must be a local application path" unless local
|
|
89
|
-
|
|
90
|
-
raw
|
|
91
|
-
rescue URI::InvalidURIError
|
|
92
|
-
raise UnsafeTarget, "target must be a valid local application path"
|
|
96
|
+
LocalPath.path(value)
|
|
93
97
|
end
|
|
94
98
|
|
|
95
99
|
def valid_limit?(limit)
|
|
@@ -102,87 +106,14 @@ module Karst
|
|
|
102
106
|
source.each.lazy.take(@limit).to_a
|
|
103
107
|
end
|
|
104
108
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
status = redirect = exception_class = nil
|
|
111
|
-
body_marker_observed = nil
|
|
112
|
-
halted_callback = nil
|
|
113
|
-
writes = 0
|
|
114
|
-
callback = lambda do |_name, _start, _finish, _id, payload|
|
|
115
|
-
writes += 1 if DatabaseIsolation.mutation?(payload[:sql])
|
|
116
|
-
end
|
|
117
|
-
halt_observer = lambda do |_name, _start, _finish, _id, payload|
|
|
118
|
-
halted_callback = payload[:filter]
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
with_rollback do
|
|
122
|
-
ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
|
|
123
|
-
Karst::Identity.with(session, principal) do
|
|
124
|
-
ActiveSupport::Notifications.subscribed(halt_observer, "halted_callback.action_controller") do
|
|
125
|
-
session.get(@path)
|
|
126
|
-
end
|
|
127
|
-
rendered_exception = request_exception(session)
|
|
128
|
-
if rendered_exception
|
|
129
|
-
exception_class = rendered_exception.class.name
|
|
130
|
-
else
|
|
131
|
-
status = session.response.status
|
|
132
|
-
if @body_includes && session.response.respond_to?(:body)
|
|
133
|
-
body_marker_observed = session.response.body.to_s.include?(@body_includes.to_s)
|
|
134
|
-
end
|
|
135
|
-
redirect = clean_redirect(session.response.location) if status >= 300 && status < 400
|
|
136
|
-
end
|
|
137
|
-
end
|
|
138
|
-
end
|
|
139
|
-
rescue StandardError => e
|
|
140
|
-
exception_class = e.class.name
|
|
141
|
-
end
|
|
142
|
-
Outcome.new(principal: Karst::Identity.describe(principal), status: status, redirect: redirect,
|
|
143
|
-
exception_class: exception_class, writes_observed: writes.positive?, write_count: writes,
|
|
144
|
-
elapsed_ms: elapsed(started), database_rollback_attempted: true,
|
|
109
|
+
def outcome(principal, observed)
|
|
110
|
+
writes = observed.write_count
|
|
111
|
+
Outcome.new(principal: observed.identity.requested, status: observed.status, redirect: observed.redirect,
|
|
112
|
+
exception_class: observed.exception_class, writes_observed: writes.positive?,
|
|
113
|
+
write_count: writes, elapsed_ms: observed.elapsed_ms, database_rollback_attempted: true,
|
|
145
114
|
sampling_reasons: (@sampling_reasons[principal] || []).freeze,
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
149
|
-
|
|
150
|
-
def with_rollback
|
|
151
|
-
raise Unavailable, "Active Record rollback isolation is unavailable" unless defined?(ActiveRecord::Base)
|
|
152
|
-
|
|
153
|
-
ActiveRecord::Base.transaction(requires_new: true) do
|
|
154
|
-
yield
|
|
155
|
-
raise ActiveRecord::Rollback
|
|
156
|
-
end
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def build_probe_application
|
|
160
|
-
ProbeApplication.for(@application)
|
|
161
|
-
rescue ProbeApplication::ConstructionError => e
|
|
162
|
-
raise Unavailable, e.message, cause: e
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
def configure_host(session)
|
|
166
|
-
return unless @probe_application.respond_to?(:host) && @probe_application.host
|
|
167
|
-
|
|
168
|
-
session.host!(@probe_application.host)
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
# Rails may either re-raise an application exception or render it through
|
|
172
|
-
# ShowExceptions, depending on host and Rails-version configuration. The
|
|
173
|
-
# latter records the original exception in the integration request env.
|
|
174
|
-
def request_exception(session)
|
|
175
|
-
return unless session.respond_to?(:request) && session.request
|
|
176
|
-
|
|
177
|
-
session.request.get_header("action_dispatch.exception")
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def clean_redirect(location)
|
|
181
|
-
return nil if location.to_s.empty?
|
|
182
|
-
|
|
183
|
-
URI.parse(location).tap { |uri| uri.query = nil }.to_s
|
|
184
|
-
rescue URI::InvalidURIError
|
|
185
|
-
location.to_s.split("?", 2).first
|
|
115
|
+
halted_callback: observed.halted_callback, identity: observed.identity,
|
|
116
|
+
controller: observed.controller, action: observed.action)
|
|
186
117
|
end
|
|
187
118
|
|
|
188
119
|
def monotonic
|
|
@@ -193,6 +124,5 @@ module Karst
|
|
|
193
124
|
((monotonic - started) * 1000.0).round(1)
|
|
194
125
|
end
|
|
195
126
|
end
|
|
196
|
-
# rubocop:enable Metrics/ClassLength
|
|
197
127
|
end
|
|
198
128
|
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Karst
|
|
4
|
+
module CLI
|
|
5
|
+
# The one place a Karst::Identity::Evidence becomes the JSON/MCP-facing
|
|
6
|
+
# identity document, shared by Karst::CLI::Verification and
|
|
7
|
+
# Karst::CLI::Reproduction so the two schemas can never describe identity
|
|
8
|
+
# evidence differently -- a probe's `identity` document means the same
|
|
9
|
+
# thing regardless of which adapter produced it.
|
|
10
|
+
module IdentitySerialization
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# The whole point of this schema. `requested` is intent, `observed` is
|
|
14
|
+
# what the application resolved while running the request, and
|
|
15
|
+
# `confirmation` is the only field that says whether a principal claim
|
|
16
|
+
# about this request is true. Anything other than
|
|
17
|
+
# "confirmed"/"confirmed_anonymous" means it is not.
|
|
18
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
19
|
+
def identity_document(evidence)
|
|
20
|
+
return nil unless evidence
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
requested: evidence.requested && principal(evidence.requested),
|
|
24
|
+
observed: observed_principal(evidence.observed),
|
|
25
|
+
confirmation: evidence.confirmation.to_s,
|
|
26
|
+
observed_at: evidence.observed_at&.to_s,
|
|
27
|
+
observation_source: evidence.observation_source&.to_s,
|
|
28
|
+
observation_error: evidence.observation_error,
|
|
29
|
+
establishment: evidence.establishment&.to_s,
|
|
30
|
+
establishment_error: evidence.establishment_error,
|
|
31
|
+
cleanup_error: evidence.cleanup_error,
|
|
32
|
+
changed_during_request: evidence.changed_during_request
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
36
|
+
|
|
37
|
+
def observed_principal(value)
|
|
38
|
+
return nil unless value
|
|
39
|
+
|
|
40
|
+
{ model: value.model_name.to_s, id: primitive_id(value.id) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def principal(value)
|
|
44
|
+
# JSON is also the MCP contract. Framework-inferred login identifiers
|
|
45
|
+
# must never cross that machine-readable boundary. An application-
|
|
46
|
+
# authored principal_label remains explicit configuration and keeps
|
|
47
|
+
# its longstanding serialization behavior.
|
|
48
|
+
label = if Karst.config.principal_label
|
|
49
|
+
value.display_label.to_s
|
|
50
|
+
else
|
|
51
|
+
"#{value.model_name} ##{value.id}"
|
|
52
|
+
end
|
|
53
|
+
{ model: value.model_name.to_s, id: primitive_id(value.id), label: label }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def primitive_id(value)
|
|
57
|
+
value.is_a?(Integer) ? value : value.to_s
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Says what the application actually did with identity, never what was
|
|
61
|
+
# asked of it.
|
|
62
|
+
def identity_line(evidence)
|
|
63
|
+
case evidence.confirmation
|
|
64
|
+
when :confirmed then "observed #{observed_label(evidence)} (identity confirmed)"
|
|
65
|
+
when :confirmed_anonymous then "observed no principal (anonymous confirmed)"
|
|
66
|
+
when :absent then "requested #{requested_label(evidence)}, observed no principal (NOT confirmed)"
|
|
67
|
+
when :mismatch then "requested #{requested_label(evidence)}, observed #{observed_label(evidence)} (MISMATCH)"
|
|
68
|
+
when :contaminated then "anonymous probe observed #{observed_label(evidence)} (CONTAMINATED)"
|
|
69
|
+
else "identity unobservable: #{evidence.observation_error}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def observed_label(evidence)
|
|
74
|
+
evidence.observed ? "#{evidence.observed.model_name} ##{evidence.observed.id}" : "no principal"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def requested_label(evidence)
|
|
78
|
+
evidence.requested ? "#{evidence.requested.model_name} ##{evidence.requested.id}" : "anonymous"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/utils"
|
|
4
|
+
|
|
5
|
+
module Karst
|
|
6
|
+
module CLI
|
|
7
|
+
# The bounded, human-readable outcome lines for one probe stage (the
|
|
8
|
+
# ordinary sample, one population retry), built from already-computed
|
|
9
|
+
# Access::OutcomeGroups groups.
|
|
10
|
+
#
|
|
11
|
+
# Counts, not conclusions: each line is one semantic outcome group and
|
|
12
|
+
# the number of users that observed it, largest first. The largest group
|
|
13
|
+
# is only ever marked by position -- Karst never calls it "dominant",
|
|
14
|
+
# "most", or "typical", so a close split (12 / 11 / 9) reads as exactly
|
|
15
|
+
# that. When one group is strictly larger than every other, its users are
|
|
16
|
+
# not listed (it is the outcome the rest differ from); every other group
|
|
17
|
+
# names up to MAX_EXAMPLES of its users, which are the ones worth
|
|
18
|
+
# inspecting. When the largest count is tied, no group is singled out and
|
|
19
|
+
# every group names examples.
|
|
20
|
+
#
|
|
21
|
+
# Output stays bounded however many users were tested: at most
|
|
22
|
+
# MAX_OUTCOMES group lines (verified-usable groups are always shown -- they
|
|
23
|
+
# are the answer to the question the sweep asks), one examples line per
|
|
24
|
+
# listed group, and one line summarizing whatever else was observed. The
|
|
25
|
+
# complete per-user evidence stays in `--json`.
|
|
26
|
+
class OutcomeSummary
|
|
27
|
+
MAX_OUTCOMES = 5
|
|
28
|
+
MAX_EXAMPLES = 3
|
|
29
|
+
|
|
30
|
+
def initialize(groups, indent: " ")
|
|
31
|
+
@groups = groups
|
|
32
|
+
@indent = indent
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def lines
|
|
36
|
+
return [] if @groups.empty?
|
|
37
|
+
|
|
38
|
+
width = shown.map { |group| group.count.to_s.size }.max
|
|
39
|
+
rows = shown.flat_map { |group| group_lines(group, width) }
|
|
40
|
+
rows << hidden_line if hidden.any?
|
|
41
|
+
rows
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# One outcome group as a single line of observed facts, in Karst's own
|
|
45
|
+
# terms (see Web::Panel#outcome_title and CLI::Verification's
|
|
46
|
+
# response evidence) -- never "denied", "allowed", or any other
|
|
47
|
+
# authorization claim the application itself did not make.
|
|
48
|
+
def describe(group)
|
|
49
|
+
item = group.representative
|
|
50
|
+
parts = [response(item), (dispatch(item) if dispatch_varies?),
|
|
51
|
+
(writes(item.write_count) if item.writes_observed), ("verified usable" if group.usable)]
|
|
52
|
+
parts.insert(1, "halted at #{item.halted_callback}") if item.halted_callback
|
|
53
|
+
parts.compact.join(" · ")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def group_lines(group, width)
|
|
59
|
+
line = ["#{@indent}#{group.count.to_s.rjust(width)} #{describe(group)}"]
|
|
60
|
+
line << "#{@indent}#{' ' * width} #{examples(group)}" if examples?(group)
|
|
61
|
+
line
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def shown
|
|
65
|
+
@shown ||= @groups.each_with_index.select { |group, index| index < MAX_OUTCOMES || group.usable }
|
|
66
|
+
.map(&:first)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def hidden
|
|
70
|
+
@hidden ||= @groups - shown
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def hidden_line
|
|
74
|
+
users = hidden.sum(&:count)
|
|
75
|
+
"#{@indent}+#{hidden.size} more #{hidden.size == 1 ? 'outcome' : 'outcomes'} " \
|
|
76
|
+
"(#{users} #{users == 1 ? 'user' : 'users'}); --json lists every outcome"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def examples?(group)
|
|
80
|
+
return false if @groups.size == 1
|
|
81
|
+
|
|
82
|
+
!(unique_largest? && group.equal?(@groups.first))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def unique_largest?
|
|
86
|
+
@groups.size == 1 || @groups[0].count > @groups[1].count
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def examples(group)
|
|
90
|
+
labels = group.outcomes.first(MAX_EXAMPLES).map { |item| label(item.principal) }
|
|
91
|
+
remaining = group.count - labels.size
|
|
92
|
+
labels << "+#{remaining} more" if remaining.positive?
|
|
93
|
+
labels.join(", ")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def label(principal)
|
|
97
|
+
principal ? principal.display_label.to_s : "anonymous request"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def response(item)
|
|
101
|
+
return "exception #{item.exception_class}" if item.exception_class
|
|
102
|
+
return "no response observed" unless item.status
|
|
103
|
+
|
|
104
|
+
status = status_title(item.status)
|
|
105
|
+
item.redirect ? "#{status} → #{item.redirect}" : status
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def status_title(status)
|
|
109
|
+
phrase = Rack::Utils::HTTP_STATUS_CODES[status]
|
|
110
|
+
phrase ? "#{status} #{phrase}" : status.to_s
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Two groups must never read identically. Dispatch is part of the
|
|
114
|
+
# semantic key but normally uniform for one path, so it is printed only
|
|
115
|
+
# when groups in this stage actually disagree about it.
|
|
116
|
+
def dispatch_varies?
|
|
117
|
+
return @dispatch_varies if defined?(@dispatch_varies)
|
|
118
|
+
|
|
119
|
+
@dispatch_varies = @groups.map { |group| dispatch(group.representative) }.uniq.size > 1
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def dispatch(item)
|
|
123
|
+
item.controller ? "#{item.controller}##{item.action}" : "no controller dispatched"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def writes(count)
|
|
127
|
+
"#{count} database #{count == 1 ? 'write' : 'writes'}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../identity"
|
|
4
|
+
|
|
5
|
+
module Karst
|
|
6
|
+
module CLI
|
|
7
|
+
# Parses and resolves a human-supplied "--as MODEL:ID" reference (e.g.
|
|
8
|
+
# "User:72") into an existing principal, entirely through
|
|
9
|
+
# Karst::Identity.resolve -- the same configured-source-only lookup the
|
|
10
|
+
# browser's own Test As form already uses (see
|
|
11
|
+
# Karst::Web::BrowserIdentity#assume). There is no bypass here: a model
|
|
12
|
+
# name or id that Identity.resolve does not recognize resolves to
|
|
13
|
+
# nothing, exactly as it would for Test As, rather than falling back to
|
|
14
|
+
# Object#const_get/Model.find against an arbitrary class name.
|
|
15
|
+
module PrincipalReference
|
|
16
|
+
Reference = Struct.new(:model_name, :id)
|
|
17
|
+
|
|
18
|
+
# Format-only validation, with no dependency on a booted application or
|
|
19
|
+
# configured principal sources -- callers use this to reject a
|
|
20
|
+
# malformed --as value immediately, before Identity.resolve is even
|
|
21
|
+
# reachable.
|
|
22
|
+
def self.parse(value)
|
|
23
|
+
model_name, id = value.to_s.split(":", 2)
|
|
24
|
+
if model_name.to_s.empty? || id.to_s.empty?
|
|
25
|
+
raise ArgumentError, "--as must be MODEL:ID, e.g. \"User:72\" (got #{value.inspect})"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Reference.new(model_name, id)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Resolves `value` to an existing principal, or raises ArgumentError --
|
|
32
|
+
# never nil, and never a fallback to sampling or to anonymous. A
|
|
33
|
+
# caller who explicitly asked to run as one principal gets that
|
|
34
|
+
# principal or a clear failure, not a silent substitute.
|
|
35
|
+
#
|
|
36
|
+
# `sources:`, when given, is the operation's already-resolved principal
|
|
37
|
+
# source Hash (see Karst::Identity::Snapshot).
|
|
38
|
+
def self.resolve(value, sources: nil)
|
|
39
|
+
reference = parse(value)
|
|
40
|
+
lookup = { model_name: reference.model_name, id: reference.id }
|
|
41
|
+
lookup[:sources] = sources if sources
|
|
42
|
+
principal = Identity.resolve(**lookup)
|
|
43
|
+
return principal if principal
|
|
44
|
+
|
|
45
|
+
raise ArgumentError,
|
|
46
|
+
"--as #{value} did not resolve: Karst found no #{reference.model_name} ##{reference.id} " \
|
|
47
|
+
"in its configured principal source(s)"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|