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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +59 -0
  3. data/CHANGELOG.md +67 -0
  4. data/CODE_OF_CONDUCT.md +29 -0
  5. data/CONTRIBUTING.md +45 -0
  6. data/LICENSE +21 -0
  7. data/README.md +140 -0
  8. data/SECURITY.md +11 -0
  9. data/docs/advanced-configuration.md +188 -0
  10. data/lib/generators/karst/install/install_generator.rb +88 -0
  11. data/lib/generators/karst/install/templates/karst_identity_controller.rb +19 -0
  12. data/lib/generators/karst/install/templates/karst_initializer.rb +18 -0
  13. data/lib/karst/access/approved_populations.rb +128 -0
  14. data/lib/karst/access/candidate_population.rb +86 -0
  15. data/lib/karst/access/database_isolation.rb +62 -0
  16. data/lib/karst/access/population_approvals.rb +195 -0
  17. data/lib/karst/access/population_config_snippet.rb +67 -0
  18. data/lib/karst/access/population_discovery.rb +271 -0
  19. data/lib/karst/access/population_preview.rb +83 -0
  20. data/lib/karst/access/principal_sampler.rb +241 -0
  21. data/lib/karst/access/principal_selection.rb +90 -0
  22. data/lib/karst/access/principal_source.rb +143 -0
  23. data/lib/karst/access/principal_source_selection.rb +161 -0
  24. data/lib/karst/access/probe_application.rb +164 -0
  25. data/lib/karst/access/resource_evidence.rb +233 -0
  26. data/lib/karst/access/search.rb +265 -0
  27. data/lib/karst/access/selected_principal_sources.rb +65 -0
  28. data/lib/karst/access/sensitive_attribute_names.rb +26 -0
  29. data/lib/karst/access/sweep.rb +198 -0
  30. data/lib/karst/cli/verification.rb +182 -0
  31. data/lib/karst/configuration.rb +223 -0
  32. data/lib/karst/execution_context.rb +83 -0
  33. data/lib/karst/identity/devise_support.rb +90 -0
  34. data/lib/karst/identity/warden_adapter.rb +130 -0
  35. data/lib/karst/identity.rb +479 -0
  36. data/lib/karst/mcp/server.rb +63 -0
  37. data/lib/karst/mcp/verify_access_tool.rb +68 -0
  38. data/lib/karst/railtie.rb +30 -0
  39. data/lib/karst/spec/catalog.rb +199 -0
  40. data/lib/karst/spec/example_observation.rb +31 -0
  41. data/lib/karst/spec/observer.rb +300 -0
  42. data/lib/karst/spec/principal.rb +12 -0
  43. data/lib/karst/spec/reporter.rb +83 -0
  44. data/lib/karst/spec/request_observation.rb +38 -0
  45. data/lib/karst/spec/scenario.rb +65 -0
  46. data/lib/karst/value.rb +35 -0
  47. data/lib/karst/version.rb +5 -0
  48. data/lib/karst/web/badge.rb +183 -0
  49. data/lib/karst/web/browser_identity.rb +103 -0
  50. data/lib/karst/web/locality.rb +64 -0
  51. data/lib/karst/web/middleware.rb +377 -0
  52. data/lib/karst/web/panel.rb +699 -0
  53. data/lib/karst/web/populations_panel.rb +391 -0
  54. data/lib/karst/web/route_lookup.rb +65 -0
  55. data/lib/karst.rb +56 -0
  56. data/lib/rails/commands/karst/boot.rb +24 -0
  57. data/lib/rails/commands/karst/mcp/mcp_command.rb +26 -0
  58. data/lib/rails/commands/karst/verify/verify_command.rb +39 -0
  59. data/lib/tasks/karst.rake +34 -0
  60. 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