karst 0.1.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 +7 -0
- data/ARCHITECTURE.md +59 -0
- data/CHANGELOG.md +67 -0
- data/CODE_OF_CONDUCT.md +29 -0
- data/CONTRIBUTING.md +45 -0
- data/LICENSE +21 -0
- data/README.md +140 -0
- data/SECURITY.md +11 -0
- data/docs/advanced-configuration.md +188 -0
- data/lib/generators/karst/install/install_generator.rb +88 -0
- data/lib/generators/karst/install/templates/karst_identity_controller.rb +19 -0
- data/lib/generators/karst/install/templates/karst_initializer.rb +18 -0
- data/lib/karst/access/approved_populations.rb +128 -0
- data/lib/karst/access/candidate_population.rb +86 -0
- data/lib/karst/access/database_isolation.rb +62 -0
- data/lib/karst/access/population_approvals.rb +195 -0
- data/lib/karst/access/population_config_snippet.rb +67 -0
- data/lib/karst/access/population_discovery.rb +271 -0
- data/lib/karst/access/population_preview.rb +83 -0
- data/lib/karst/access/principal_sampler.rb +241 -0
- data/lib/karst/access/principal_selection.rb +90 -0
- data/lib/karst/access/principal_source.rb +143 -0
- data/lib/karst/access/principal_source_selection.rb +161 -0
- data/lib/karst/access/probe_application.rb +164 -0
- data/lib/karst/access/resource_evidence.rb +233 -0
- data/lib/karst/access/search.rb +265 -0
- data/lib/karst/access/selected_principal_sources.rb +65 -0
- data/lib/karst/access/sensitive_attribute_names.rb +26 -0
- data/lib/karst/access/sweep.rb +198 -0
- data/lib/karst/cli/verification.rb +182 -0
- data/lib/karst/configuration.rb +223 -0
- data/lib/karst/execution_context.rb +83 -0
- data/lib/karst/identity/devise_support.rb +90 -0
- data/lib/karst/identity/warden_adapter.rb +130 -0
- data/lib/karst/identity.rb +479 -0
- data/lib/karst/mcp/server.rb +63 -0
- data/lib/karst/mcp/verify_access_tool.rb +68 -0
- data/lib/karst/railtie.rb +30 -0
- data/lib/karst/spec/catalog.rb +199 -0
- data/lib/karst/spec/example_observation.rb +31 -0
- data/lib/karst/spec/observer.rb +300 -0
- data/lib/karst/spec/principal.rb +12 -0
- data/lib/karst/spec/reporter.rb +83 -0
- data/lib/karst/spec/request_observation.rb +38 -0
- data/lib/karst/spec/scenario.rb +65 -0
- data/lib/karst/value.rb +35 -0
- data/lib/karst/version.rb +5 -0
- data/lib/karst/web/badge.rb +183 -0
- data/lib/karst/web/browser_identity.rb +103 -0
- data/lib/karst/web/locality.rb +64 -0
- data/lib/karst/web/middleware.rb +377 -0
- data/lib/karst/web/panel.rb +699 -0
- data/lib/karst/web/populations_panel.rb +391 -0
- data/lib/karst/web/route_lookup.rb +65 -0
- data/lib/karst.rb +56 -0
- data/lib/rails/commands/karst/boot.rb +24 -0
- data/lib/rails/commands/karst/mcp/mcp_command.rb +26 -0
- data/lib/rails/commands/karst/verify/verify_command.rb +39 -0
- data/lib/tasks/karst.rake +34 -0
- metadata +138 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "principal_source"
|
|
4
|
+
require_relative "principal_source_selection"
|
|
5
|
+
require_relative "approved_populations"
|
|
6
|
+
require_relative "../identity/devise_support"
|
|
7
|
+
|
|
8
|
+
module Karst
|
|
9
|
+
module Access
|
|
10
|
+
# Turns a locally selected set of ambiguous Devise models (see
|
|
11
|
+
# Karst::Access::PrincipalSourceSelection) into ordinary
|
|
12
|
+
# Karst::Access::PrincipalSource objects, one per selected model, each
|
|
13
|
+
# keyed by that model's own Devise/Warden scope -- so selecting both
|
|
14
|
+
# User and Admin produces two independently queryable sources
|
|
15
|
+
# (:user => ..., :admin => ...) exactly like a hand-written
|
|
16
|
+
# config.principal_sources would, never collapsed into one combined
|
|
17
|
+
# source.
|
|
18
|
+
#
|
|
19
|
+
# Every selected name is revalidated against
|
|
20
|
+
# Karst::Identity::DeviseSupport's *current* Devise.mappings on every
|
|
21
|
+
# call: a name the file stores but Devise no longer maps (removed,
|
|
22
|
+
# renamed) is silently dropped here, never constantized, and never
|
|
23
|
+
# trusted on the strength of the file alone. If that drops every
|
|
24
|
+
# selection, Karst is ambiguous again -- the developer is asked to
|
|
25
|
+
# select once more, exactly like the first time.
|
|
26
|
+
module SelectedPrincipalSources
|
|
27
|
+
class << self
|
|
28
|
+
# A Hash of Symbol(Devise scope) => PrincipalSource for every
|
|
29
|
+
# currently valid selected mapping, or nil when local selection does
|
|
30
|
+
# not apply at all (production, nothing selected, or every selected
|
|
31
|
+
# mapping is now stale).
|
|
32
|
+
def sources
|
|
33
|
+
valid = mappings
|
|
34
|
+
return nil if valid.empty?
|
|
35
|
+
|
|
36
|
+
valid.to_h do |mapping|
|
|
37
|
+
[mapping.scope, PrincipalSource.new(name: mapping.scope, records: -> { mapping.model.all })]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The subset of Karst::Identity::DeviseSupport.mappings a developer
|
|
42
|
+
# has locally selected and that Devise still confirms right now --
|
|
43
|
+
# used both to build #sources above and by Karst::Identity's own
|
|
44
|
+
# ambiguous/ready checks, so neither has to know the storage format.
|
|
45
|
+
# Always [] outside development/test (see
|
|
46
|
+
# Karst::Access::ApprovedPopulations.local_environment?, the same
|
|
47
|
+
# local-preference gate approved populations already use) and on any
|
|
48
|
+
# failure -- selection is an optional convenience layered over
|
|
49
|
+
# Devise's own metadata, never something whose breakage should take
|
|
50
|
+
# down the panel, CLI, or MCP tool.
|
|
51
|
+
def mappings
|
|
52
|
+
return [] unless ApprovedPopulations.local_environment?
|
|
53
|
+
|
|
54
|
+
record = PrincipalSourceSelection.load
|
|
55
|
+
return [] if record.model_names.empty?
|
|
56
|
+
|
|
57
|
+
current = Identity::DeviseSupport.mappings
|
|
58
|
+
record.model_names.filter_map { |name| current.find { |mapping| mapping.model.name == name } }
|
|
59
|
+
rescue StandardError
|
|
60
|
+
[]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Karst
|
|
4
|
+
module Access
|
|
5
|
+
# Shared, deliberately conservative name-based PII filter used everywhere
|
|
6
|
+
# Karst decides whether an attribute name is safe to inspect or display --
|
|
7
|
+
# schema-derived sampling states (PrincipalSampler) above all.
|
|
8
|
+
# Column/attribute names are
|
|
9
|
+
# never PII-inspected, only compared (case-insensitive, underscore-
|
|
10
|
+
# tokenized) against this list. False positives (skipping a safe name)
|
|
11
|
+
# are free; false negatives are not, so this stays a single source of
|
|
12
|
+
# truth rather than being duplicated per caller.
|
|
13
|
+
module SensitiveAttributeNames
|
|
14
|
+
TOKENS = %w[
|
|
15
|
+
email name first last full phone mobile fax address street city zip
|
|
16
|
+
postal country ssn social security password secret salt encrypted
|
|
17
|
+
token key api credential auth login username url website dob birth
|
|
18
|
+
card cvv iban passport license
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
def self.match?(name)
|
|
22
|
+
name.to_s.downcase.split("_").any? { |token| TOKENS.include?(token) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/notifications"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "probe_application"
|
|
6
|
+
require_relative "database_isolation"
|
|
7
|
+
require_relative "../identity"
|
|
8
|
+
require_relative "../value"
|
|
9
|
+
|
|
10
|
+
module Karst
|
|
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
|
+
# sampling_reasons is a frozen Array of short evidence strings (e.g.
|
|
18
|
+
# "role=local_admin", "source=authors") explaining why PrincipalSampler
|
|
19
|
+
# or PrincipalSelection deliberately included this principal, or an
|
|
20
|
+
# empty Array when the principal came from plain first-N/fill sampling
|
|
21
|
+
# or was supplied directly rather than through a sampler. This is
|
|
22
|
+
# sampling evidence, not an authorization claim.
|
|
23
|
+
Outcome = Value.define(:principal, :status, :redirect, :exception_class,
|
|
24
|
+
:writes_observed, :write_count, :elapsed_ms, :database_rollback_attempted,
|
|
25
|
+
:sampling_reasons, :body_marker_observed, :halted_callback)
|
|
26
|
+
|
|
27
|
+
# candidate_pool_size is nil unless the caller supplying `principals` (see
|
|
28
|
+
# Access::PrincipalSampler::Result) knows it sampled from a bounded
|
|
29
|
+
# recent-N pool rather than the full principal source -- callers use it
|
|
30
|
+
# to report the sampling scope truthfully rather than implying every
|
|
31
|
+
# principal was considered.
|
|
32
|
+
Result = Value.define(:path, :http_method, :outcomes, :elapsed_ms, :aborted_reason, :database_isolation,
|
|
33
|
+
:candidate_pool_size) do
|
|
34
|
+
def groups
|
|
35
|
+
outcomes.group_by { |item| [item.status, item.redirect, item.exception_class, item.halted_callback] }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Sequentially observes one concrete local GET using a fresh integration
|
|
40
|
+
# session and a rollback-only transaction for every bounded principal.
|
|
41
|
+
# rubocop:disable Metrics/ClassLength
|
|
42
|
+
class Sweep
|
|
43
|
+
# sampling_reasons optionally maps a principal (by Ruby equality, so
|
|
44
|
+
# the same Active Record identity even across separate instances) to
|
|
45
|
+
# the Array of reasons it was selected for -- see
|
|
46
|
+
# Access::PrincipalSampler::Candidate/PrincipalSelection. A principal
|
|
47
|
+
# with no entry simply gets an empty Array on its Outcome.
|
|
48
|
+
# rubocop:disable Metrics/ParameterLists
|
|
49
|
+
# rubocop:disable Metrics/MethodLength
|
|
50
|
+
def initialize(path:, principals:, http_method: "GET", limit: Karst.config.access_sweep_limit,
|
|
51
|
+
application: nil, candidate_pool_size: nil, sampling_reasons: {}, body_includes: nil)
|
|
52
|
+
@path = normalize_path(path)
|
|
53
|
+
@http_method = http_method.to_s.upcase
|
|
54
|
+
raise UnsupportedMethod, "access sweeps support GET only" unless @http_method == "GET"
|
|
55
|
+
raise ArgumentError, "limit exceeds configured access_sweep_limit" unless valid_limit?(limit)
|
|
56
|
+
|
|
57
|
+
@principals = principals
|
|
58
|
+
@limit = limit
|
|
59
|
+
@application = application || Rails.application
|
|
60
|
+
@probe_application = build_probe_application
|
|
61
|
+
@candidate_pool_size = candidate_pool_size
|
|
62
|
+
@sampling_reasons = sampling_reasons
|
|
63
|
+
@body_includes = body_includes
|
|
64
|
+
end
|
|
65
|
+
# rubocop:enable Metrics/MethodLength
|
|
66
|
+
# rubocop:enable Metrics/ParameterLists
|
|
67
|
+
|
|
68
|
+
def call
|
|
69
|
+
raise Unavailable, "access sweeps are development-only" unless Rails.env.development?
|
|
70
|
+
raise Unavailable, "Karst is disabled (config.enabled)" unless Karst.enabled?
|
|
71
|
+
|
|
72
|
+
require "action_dispatch/testing/integration" unless defined?(ActionDispatch::Integration::Session)
|
|
73
|
+
|
|
74
|
+
started = monotonic
|
|
75
|
+
outcomes = bounded_principals.map { |principal| probe(principal) }
|
|
76
|
+
Result.new(path: @path, http_method: @http_method, outcomes: outcomes.freeze,
|
|
77
|
+
elapsed_ms: elapsed(started), aborted_reason: nil,
|
|
78
|
+
database_isolation: :same_connection_rollback_attempted,
|
|
79
|
+
candidate_pool_size: @candidate_pool_size)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def normalize_path(value)
|
|
85
|
+
raw = value.to_s.split("?", 2).first
|
|
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"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def valid_limit?(limit)
|
|
96
|
+
limit.is_a?(Integer) && limit.positive? && limit <= Karst.config.access_sweep_limit
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def bounded_principals
|
|
100
|
+
source = @principals
|
|
101
|
+
source = source.limit(@limit) if source.respond_to?(:limit)
|
|
102
|
+
source.each.lazy.take(@limit).to_a
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
106
|
+
def probe(principal)
|
|
107
|
+
session = ActionDispatch::Integration::Session.new(@probe_application)
|
|
108
|
+
configure_host(session)
|
|
109
|
+
started = monotonic
|
|
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,
|
|
145
|
+
sampling_reasons: (@sampling_reasons[principal] || []).freeze,
|
|
146
|
+
body_marker_observed: body_marker_observed, halted_callback: halted_callback)
|
|
147
|
+
end
|
|
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
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def monotonic
|
|
189
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def elapsed(started)
|
|
193
|
+
((monotonic - started) * 1000.0).round(1)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
# rubocop:enable Metrics/ClassLength
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../../karst"
|
|
5
|
+
|
|
6
|
+
module Karst
|
|
7
|
+
module CLI
|
|
8
|
+
# Presentation-only adapter for Access::Search. It deliberately receives
|
|
9
|
+
# Search's result and converts only its public evidence values to stable,
|
|
10
|
+
# privacy-bounded primitives.
|
|
11
|
+
# Formatting necessarily enumerates the complete public schema in one
|
|
12
|
+
# place, keeping the versioned contract auditable.
|
|
13
|
+
# rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize
|
|
14
|
+
class Verification
|
|
15
|
+
SCHEMA_VERSION = 1
|
|
16
|
+
|
|
17
|
+
def initialize(path:, http_method: "GET", output: $stdout, json: false)
|
|
18
|
+
@path = path
|
|
19
|
+
@http_method = http_method
|
|
20
|
+
@output = output
|
|
21
|
+
@json = json
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call
|
|
25
|
+
result = run_search
|
|
26
|
+
@output.puts(@json ? JSON.generate(document(result)) : human(result))
|
|
27
|
+
result.verified_outcome ? 0 : 1
|
|
28
|
+
rescue Access::Error, Identity::Error, ArgumentError => e
|
|
29
|
+
@output.puts(@json ? JSON.generate(error_document(e)) : "Karst cannot verify this route:\n#{e.message}")
|
|
30
|
+
2
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The same schema-versioned evidence document --json prints, without
|
|
34
|
+
# any dependency on @output/stdout -- the shared entry point every
|
|
35
|
+
# other adapter (currently only the MCP server) calls instead of
|
|
36
|
+
# duplicating Access::Search invocation or result serialization. Always
|
|
37
|
+
# returns a Hash: either the success document or, on any of the same
|
|
38
|
+
# errors #call rescues, error_document(e) -- never raises.
|
|
39
|
+
def evidence
|
|
40
|
+
document(run_search)
|
|
41
|
+
rescue Access::Error, Identity::Error, ArgumentError => e
|
|
42
|
+
error_document(e)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def run_search
|
|
48
|
+
validate_setup!
|
|
49
|
+
Access::Search.new(path: @path, http_method: @http_method, sources: Identity.principal_sources).call
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate_setup!
|
|
53
|
+
state = Identity.setup_state
|
|
54
|
+
return if state.status.to_s.start_with?("ready_")
|
|
55
|
+
|
|
56
|
+
message = state.message || "no principal source is configured"
|
|
57
|
+
raise Identity::ConfigurationError, message
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def document(result)
|
|
61
|
+
winner = result.verified_outcome
|
|
62
|
+
{
|
|
63
|
+
schema_version: SCHEMA_VERSION,
|
|
64
|
+
request: { method: result.http_method, path: result.path },
|
|
65
|
+
verified_usable: !winner.nil?,
|
|
66
|
+
verified_principal: winner && principal(winner.principal),
|
|
67
|
+
verified_outcome: winner && outcome(winner, include_principal: false),
|
|
68
|
+
source: result.verified_source,
|
|
69
|
+
sample: sweep(result.initial),
|
|
70
|
+
populations: result.attempts.map { |attempt| population(attempt) },
|
|
71
|
+
summary: { request_count: result.request_count, elapsed_ms: result.elapsed_ms }
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def sweep(result)
|
|
76
|
+
{
|
|
77
|
+
candidate_pool_size: result.candidate_pool_size,
|
|
78
|
+
users_tested: result.outcomes.size,
|
|
79
|
+
verified_usable: result.outcomes.any? { |item| Karst.config.usable_access_outcome.call(item) },
|
|
80
|
+
database_isolation: result.database_isolation.to_s,
|
|
81
|
+
outcomes: grouped_outcomes(result.outcomes)
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def population(attempt)
|
|
86
|
+
data = { name: attempt.name.to_s, source: attempt.source_name.to_s, state: attempt.state.to_s }
|
|
87
|
+
data[:reason] = attempt.error if attempt.error
|
|
88
|
+
return data unless attempt.result
|
|
89
|
+
|
|
90
|
+
data.merge(users_tested: attempt.result.outcomes.size, outcomes: grouped_outcomes(attempt.result.outcomes))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def grouped_outcomes(outcomes)
|
|
94
|
+
outcomes.group_by { |item| outcome(item, include_principal: false) }.map do |evidence, items|
|
|
95
|
+
evidence.merge(count: items.size, principals: items.map { |item| principal(item.principal) })
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def outcome(item, include_principal: true)
|
|
100
|
+
data = {
|
|
101
|
+
status: item.status, redirect: item.redirect, exception_class: item.exception_class,
|
|
102
|
+
halted_callback: item.halted_callback&.to_s, writes_observed: item.writes_observed,
|
|
103
|
+
write_count: item.write_count, database_rollback_attempted: item.database_rollback_attempted,
|
|
104
|
+
elapsed_ms: item.elapsed_ms
|
|
105
|
+
}
|
|
106
|
+
data[:principal] = principal(item.principal) if include_principal
|
|
107
|
+
data
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def principal(value)
|
|
111
|
+
# JSON is also the MCP contract. Framework-inferred login identifiers
|
|
112
|
+
# must never cross that machine-readable boundary. An application-
|
|
113
|
+
# authored principal_label remains explicit configuration and keeps
|
|
114
|
+
# its longstanding serialization behavior.
|
|
115
|
+
label = if Karst.config.principal_label
|
|
116
|
+
value.display_label.to_s
|
|
117
|
+
else
|
|
118
|
+
"#{value.model_name} ##{value.id}"
|
|
119
|
+
end
|
|
120
|
+
{ model: value.model_name.to_s, id: primitive_id(value.id), label: label }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def primitive_id(value)
|
|
124
|
+
value.is_a?(Integer) ? value : value.to_s
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def error_document(error)
|
|
128
|
+
type = error.is_a?(Identity::Error) ? "configuration_error" : "input_error"
|
|
129
|
+
{ schema_version: SCHEMA_VERSION, error: { type: type, message: error.message } }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def human(result)
|
|
133
|
+
lines = ["Karst verification", "", "#{result.http_method} #{result.path}", "", "Sample",
|
|
134
|
+
" #{result.initial.outcomes.size} users tested",
|
|
135
|
+
" #{sample_usable_count(result)} verified usable"]
|
|
136
|
+
append_key_evidence(lines, result.initial.outcomes)
|
|
137
|
+
append_populations(lines, result)
|
|
138
|
+
append_result(lines, result)
|
|
139
|
+
lines.join("\n")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def sample_usable_count(result)
|
|
143
|
+
result.initial.outcomes.count { |item| Karst.config.usable_access_outcome.call(item) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def append_key_evidence(lines, outcomes)
|
|
147
|
+
evidence = outcomes.first
|
|
148
|
+
return unless evidence
|
|
149
|
+
|
|
150
|
+
lines << " status #{evidence.status}" if evidence.status
|
|
151
|
+
lines << " redirect #{evidence.redirect}" if evidence.redirect
|
|
152
|
+
lines << " halted at #{evidence.halted_callback}" if evidence.halted_callback
|
|
153
|
+
lines << " exception #{evidence.exception_class}" if evidence.exception_class
|
|
154
|
+
lines << " WARNING: #{evidence.write_count} writes observed" if evidence.writes_observed
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def append_populations(lines, result)
|
|
158
|
+
return if result.attempts.empty?
|
|
159
|
+
|
|
160
|
+
lines.push("", "Candidate populations")
|
|
161
|
+
result.attempts.each do |attempt|
|
|
162
|
+
count = attempt.result&.outcomes&.size || 0
|
|
163
|
+
lines << " #{attempt.name}: #{attempt.state} (#{count} users tested)"
|
|
164
|
+
append_key_evidence(lines, attempt.result.outcomes) if attempt.result
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def append_result(lines, result)
|
|
169
|
+
lines.push("", "Result")
|
|
170
|
+
if result.verified_outcome
|
|
171
|
+
lines << " verified usable user: #{result.verified_outcome.principal.display_label}"
|
|
172
|
+
source = result.verified_source
|
|
173
|
+
lines << " source: #{source[:type]}#{"=#{source[:name]}" if source[:name]}"
|
|
174
|
+
else
|
|
175
|
+
lines << " no verified usable user found"
|
|
176
|
+
end
|
|
177
|
+
lines << " #{result.request_count} requests in #{result.elapsed_ms} ms"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
# rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize
|
|
181
|
+
end
|
|
182
|
+
end
|