karst 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +14 -12
- data/CHANGELOG.md +114 -47
- data/CONTRIBUTING.md +9 -1
- data/README.md +137 -9
- data/SECURITY.md +1 -1
- data/docs/advanced-configuration.md +112 -7
- data/docs/rails8-authentication.md +191 -0
- 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_approval.rb +67 -0
- data/lib/karst/access/population_discovery.rb +25 -1
- data/lib/karst/access/population_revocation.rb +34 -0
- 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 +57 -11
- data/lib/karst/execution_context.rb +6 -7
- 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/railtie.rb +4 -7
- 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/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 +145 -84
- data/lib/karst/web/panel.rb +343 -125
- data/lib/karst/web/populations_panel.rb +46 -336
- data/lib/karst.rb +3 -3
- data/lib/rails/commands/karst/mcp/mcp_command.rb +41 -1
- data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
- data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
- metadata +38 -18
- 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
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: karst
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Karst contributors
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -34,16 +34,22 @@ dependencies:
|
|
|
34
34
|
name: mcp
|
|
35
35
|
requirement: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
|
-
- - "
|
|
37
|
+
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version:
|
|
40
|
-
|
|
39
|
+
version: 1.5.0
|
|
40
|
+
- - "<"
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '1.7'
|
|
43
|
+
type: :development
|
|
41
44
|
prerelease: false
|
|
42
45
|
version_requirements: !ruby/object:Gem::Requirement
|
|
43
46
|
requirements:
|
|
44
|
-
- - "
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: 1.5.0
|
|
50
|
+
- - "<"
|
|
45
51
|
- !ruby/object:Gem::Version
|
|
46
|
-
version:
|
|
52
|
+
version: '1.7'
|
|
47
53
|
description: Karst tests Rails routes as a bounded set of existing users and reports
|
|
48
54
|
runtime access evidence.
|
|
49
55
|
email:
|
|
@@ -59,6 +65,8 @@ files:
|
|
|
59
65
|
- README.md
|
|
60
66
|
- SECURITY.md
|
|
61
67
|
- docs/advanced-configuration.md
|
|
68
|
+
- docs/rails8-authentication.md
|
|
69
|
+
- docs/request-reproduction.md
|
|
62
70
|
- lib/generators/karst/install/install_generator.rb
|
|
63
71
|
- lib/generators/karst/install/templates/karst_identity_controller.rb
|
|
64
72
|
- lib/generators/karst/install/templates/karst_initializer.rb
|
|
@@ -66,40 +74,52 @@ files:
|
|
|
66
74
|
- lib/karst/access/approved_populations.rb
|
|
67
75
|
- lib/karst/access/candidate_population.rb
|
|
68
76
|
- lib/karst/access/database_isolation.rb
|
|
77
|
+
- lib/karst/access/errors.rb
|
|
78
|
+
- lib/karst/access/identity_probe.rb
|
|
79
|
+
- lib/karst/access/local_path.rb
|
|
80
|
+
- lib/karst/access/observed_endpoint.rb
|
|
81
|
+
- lib/karst/access/outcome_groups.rb
|
|
82
|
+
- lib/karst/access/population_approval.rb
|
|
69
83
|
- lib/karst/access/population_approvals.rb
|
|
70
|
-
- lib/karst/access/population_config_snippet.rb
|
|
71
84
|
- lib/karst/access/population_discovery.rb
|
|
72
|
-
- lib/karst/access/
|
|
85
|
+
- lib/karst/access/population_revocation.rb
|
|
73
86
|
- lib/karst/access/principal_sampler.rb
|
|
74
87
|
- lib/karst/access/principal_selection.rb
|
|
75
88
|
- lib/karst/access/principal_source.rb
|
|
76
89
|
- lib/karst/access/principal_source_selection.rb
|
|
90
|
+
- lib/karst/access/probe.rb
|
|
77
91
|
- lib/karst/access/probe_application.rb
|
|
78
|
-
- lib/karst/access/resource_evidence.rb
|
|
79
92
|
- lib/karst/access/search.rb
|
|
80
93
|
- lib/karst/access/selected_principal_sources.rb
|
|
81
94
|
- lib/karst/access/sensitive_attribute_names.rb
|
|
82
95
|
- lib/karst/access/sweep.rb
|
|
96
|
+
- lib/karst/cli/identity_serialization.rb
|
|
97
|
+
- lib/karst/cli/outcome_summary.rb
|
|
98
|
+
- lib/karst/cli/principal_reference.rb
|
|
99
|
+
- lib/karst/cli/reproduction.rb
|
|
83
100
|
- lib/karst/cli/verification.rb
|
|
84
101
|
- lib/karst/configuration.rb
|
|
85
102
|
- lib/karst/execution_context.rb
|
|
86
103
|
- lib/karst/identity.rb
|
|
87
104
|
- lib/karst/identity/devise_support.rb
|
|
105
|
+
- lib/karst/identity/evidence.rb
|
|
106
|
+
- lib/karst/identity/observation.rb
|
|
88
107
|
- lib/karst/identity/warden_adapter.rb
|
|
108
|
+
- lib/karst/mcp/compatibility.rb
|
|
109
|
+
- lib/karst/mcp/reproduce_request_tool.rb
|
|
110
|
+
- lib/karst/mcp/reproduction.rb
|
|
89
111
|
- lib/karst/mcp/server.rb
|
|
90
112
|
- lib/karst/mcp/verify_access_tool.rb
|
|
91
113
|
- lib/karst/railtie.rb
|
|
92
|
-
- lib/karst/
|
|
93
|
-
- lib/karst/
|
|
94
|
-
- lib/karst/
|
|
95
|
-
- lib/karst/
|
|
96
|
-
- lib/karst/spec/reporter.rb
|
|
97
|
-
- lib/karst/spec/request_observation.rb
|
|
98
|
-
- lib/karst/spec/scenario.rb
|
|
114
|
+
- lib/karst/reproduction/curl.rb
|
|
115
|
+
- lib/karst/reproduction/exercise.rb
|
|
116
|
+
- lib/karst/reproduction/observation.rb
|
|
117
|
+
- lib/karst/reproduction/sanitizer.rb
|
|
99
118
|
- lib/karst/value.rb
|
|
100
119
|
- lib/karst/version.rb
|
|
101
120
|
- lib/karst/web/badge.rb
|
|
102
121
|
- lib/karst/web/browser_identity.rb
|
|
122
|
+
- lib/karst/web/csrf.rb
|
|
103
123
|
- lib/karst/web/locality.rb
|
|
104
124
|
- lib/karst/web/middleware.rb
|
|
105
125
|
- lib/karst/web/panel.rb
|
|
@@ -107,8 +127,8 @@ files:
|
|
|
107
127
|
- lib/karst/web/route_lookup.rb
|
|
108
128
|
- lib/rails/commands/karst/boot.rb
|
|
109
129
|
- lib/rails/commands/karst/mcp/mcp_command.rb
|
|
130
|
+
- lib/rails/commands/karst/reproduce/reproduce_command.rb
|
|
110
131
|
- lib/rails/commands/karst/verify/verify_command.rb
|
|
111
|
-
- lib/tasks/karst.rake
|
|
112
132
|
homepage: https://github.com/SilenceDogood1984/karst
|
|
113
133
|
licenses:
|
|
114
134
|
- MIT
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../value"
|
|
4
|
-
|
|
5
|
-
module Karst
|
|
6
|
-
module Access
|
|
7
|
-
# Renders a developer's curated selection of
|
|
8
|
-
# Karst::Access::PopulationDiscovery::Candidate into a Ruby config
|
|
9
|
-
# snippet they can copy into their own Karst.configure block. Karst
|
|
10
|
-
# never writes to the host application's files on its own -- the
|
|
11
|
-
# developer stays in control of what actually gets committed (see
|
|
12
|
-
# README "Curation / persistence").
|
|
13
|
-
module PopulationConfigSnippet
|
|
14
|
-
# wired is the subset actually rendered into `code` (every selected
|
|
15
|
-
# candidate whose model matches a configured principal source).
|
|
16
|
-
# unwired is every selected candidate that is not wired into
|
|
17
|
-
# anything yet -- present so the UI can say so honestly instead of
|
|
18
|
-
# silently dropping a selection (see README "Principal vs artifact
|
|
19
|
-
# populations": this release only wires principal populations).
|
|
20
|
-
Result = Value.define(:code, :wired, :unwired)
|
|
21
|
-
|
|
22
|
-
class << self
|
|
23
|
-
def generate(candidates)
|
|
24
|
-
unique = candidates.uniq { |candidate| [candidate.model_name, candidate.method_name] }
|
|
25
|
-
wired, unwired = unique.partition(&:principal_source)
|
|
26
|
-
Result.new(code: render(wired), wired: wired, unwired: unwired)
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
private
|
|
30
|
-
|
|
31
|
-
def render(wired)
|
|
32
|
-
return "# Select at least one population above to generate a configuration snippet.\n" if wired.empty?
|
|
33
|
-
|
|
34
|
-
by_source = wired.group_by(&:principal_source)
|
|
35
|
-
by_source.size == 1 && by_source.keys.first == :default ? flat(by_source.fetch(:default)) : nested(by_source)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def flat(candidates)
|
|
39
|
-
"config.principal_populations = {\n#{entries(candidates, indent: 2)}\n}\n"
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def nested(by_source)
|
|
43
|
-
sources = by_source.map do |source, group|
|
|
44
|
-
" #{source_key(source)}: {\n populations: {\n#{entries(group, indent: 6)}\n }\n }"
|
|
45
|
-
end.join(",\n")
|
|
46
|
-
"config.principal_sources = {\n#{sources}\n}\n"
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Sorted by model then method so the same selection always renders
|
|
50
|
-
# byte-identical output, regardless of checkbox submission order.
|
|
51
|
-
def entries(candidates, indent:)
|
|
52
|
-
pad = " " * indent
|
|
53
|
-
sorted = candidates.sort_by { |candidate| [candidate.model_name, candidate.method_name.to_s] }
|
|
54
|
-
sorted.map { |candidate| entry(candidate, pad) }.join(",\n")
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def entry(candidate, pad)
|
|
58
|
-
"#{pad}#{candidate.method_name}: -> { #{candidate.model_name}.#{candidate.method_name} }"
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def source_key(source)
|
|
62
|
-
source.to_s =~ /\A[a-zA-Z_][a-zA-Z0-9_]*\z/ ? source : source.inspect
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../value"
|
|
4
|
-
require_relative "candidate_population"
|
|
5
|
-
|
|
6
|
-
module Karst
|
|
7
|
-
module Access
|
|
8
|
-
# The explicit, bounded "does this discovered candidate actually work"
|
|
9
|
-
# step -- deliberately separate from Karst::Access::PopulationDiscovery,
|
|
10
|
-
# which never executes anything. Only ever resolves a model/method pair
|
|
11
|
-
# that a given Karst::Access::PopulationDiscovery::Result itself already
|
|
12
|
-
# discovered; a submitted name that is not on that list is rejected
|
|
13
|
-
# without calling anything, so this can never become a general "call any
|
|
14
|
-
# class method by name" endpoint. Every resolution is bounded to
|
|
15
|
-
# PREVIEW_LIMIT rows via Karst::Access::CandidatePopulation.resolve --
|
|
16
|
-
# never a COUNT, never full materialization.
|
|
17
|
-
class PopulationPreview
|
|
18
|
-
PREVIEW_LIMIT = 3
|
|
19
|
-
|
|
20
|
-
Result = Value.define(:model_name, :method_name, :resolved, :records, :error)
|
|
21
|
-
|
|
22
|
-
def self.call(model_name:, method_name:, discovery_result:)
|
|
23
|
-
new(model_name: model_name, method_name: method_name, discovery_result: discovery_result).call
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def initialize(model_name:, method_name:, discovery_result:)
|
|
27
|
-
@model_name = model_name.to_s
|
|
28
|
-
@method_name = method_name.to_s
|
|
29
|
-
@discovery_result = discovery_result
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def call
|
|
33
|
-
return unresolved("this is not a discovered candidate") unless known_candidate?
|
|
34
|
-
|
|
35
|
-
klass = model_class
|
|
36
|
-
return unresolved("the model could not be resolved") unless klass
|
|
37
|
-
|
|
38
|
-
resolve(klass)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
private
|
|
42
|
-
|
|
43
|
-
def known_candidate?
|
|
44
|
-
@discovery_result.candidates.any? do |candidate|
|
|
45
|
-
candidate.model_name == @model_name && candidate.method_name.to_s == @method_name
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def model_class
|
|
50
|
-
return nil unless defined?(ActiveRecord::Base)
|
|
51
|
-
|
|
52
|
-
ActiveRecord::Base.descendants.find { |klass| klass.name == @model_name }
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def resolve(klass)
|
|
56
|
-
method_name = @method_name
|
|
57
|
-
return missing_scope unless scope_exists?(klass, method_name)
|
|
58
|
-
|
|
59
|
-
population = CandidatePopulation.resolve(
|
|
60
|
-
name: method_name.to_sym, callable: -> { klass.public_send(method_name) },
|
|
61
|
-
source_klass: klass, limit: PREVIEW_LIMIT
|
|
62
|
-
)
|
|
63
|
-
return unresolved("did not resolve to a usable ActiveRecord::Relation for #{@model_name}") unless population
|
|
64
|
-
|
|
65
|
-
Result.new(model_name: @model_name, method_name: @method_name, resolved: true, records: population.records,
|
|
66
|
-
error: nil)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def scope_exists?(klass, method_name)
|
|
70
|
-
klass.respond_to?(method_name)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def missing_scope
|
|
74
|
-
unresolved("the discovered scope no longer exists on #{@model_name}")
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def unresolved(error)
|
|
78
|
-
Result.new(model_name: @model_name, method_name: @method_name, resolved: false, records: [].freeze,
|
|
79
|
-
error: error)
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "active_support/core_ext/string/inflections"
|
|
4
|
-
require_relative "../value"
|
|
5
|
-
require_relative "../identity"
|
|
6
|
-
|
|
7
|
-
module Karst
|
|
8
|
-
module Access
|
|
9
|
-
# Given one exact resource (the specific record a route addresses by id)
|
|
10
|
-
# and one specific principal (typically the successful outcome from an
|
|
11
|
-
# Access::Sweep), reports simple, directly observed foreign-key
|
|
12
|
-
# relationships between those two records. This is evidence, not an
|
|
13
|
-
# authorization claim: it never states or implies *why* an outcome
|
|
14
|
-
# occurred, only which foreign-key columns, if any, point from one given
|
|
15
|
-
# record to the other's id.
|
|
16
|
-
#
|
|
17
|
-
# Deliberately narrow, matching the v1 scope this class was built for:
|
|
18
|
-
# - only foreign-key-shaped columns (ending in "_id") on the two given
|
|
19
|
-
# records are ever inspected -- never an arbitrary attribute, so no
|
|
20
|
-
# other column value (name, email, token, ...) is ever read or shown;
|
|
21
|
-
# - only a direct column-value comparison between the two given records,
|
|
22
|
-
# never a join, a has_many traversal, or any multi-hop graph walk;
|
|
23
|
-
# - resource resolution from a route path is attempted only through
|
|
24
|
-
# Rails' own route recognition plus its controller-to-model naming
|
|
25
|
-
# convention, and only trusted when every step succeeds unambiguously
|
|
26
|
-
# (a recognized route with an :id segment, a controller name that
|
|
27
|
-
# classifies to a real loaded Active Record class, and a record that
|
|
28
|
-
# actually exists for that id). Anything softer -- an unrecognized
|
|
29
|
-
# route, a controller with no conventional model, a missing record --
|
|
30
|
-
# is reported as a limitation rather than guessed at.
|
|
31
|
-
# rubocop:disable Metrics/ClassLength
|
|
32
|
-
class ResourceEvidence
|
|
33
|
-
class Error < StandardError; end
|
|
34
|
-
|
|
35
|
-
# The resource side never gets Identity::PrincipalDescriptor's
|
|
36
|
-
# configurable display_label hook -- there is no equivalent concept
|
|
37
|
-
# for "the current route's resource" -- so it gets its own minimal,
|
|
38
|
-
# equally attribute-free descriptor.
|
|
39
|
-
ResourceDescriptor = Value.define(:model_name, :id)
|
|
40
|
-
|
|
41
|
-
# from_model/from_id is whichever of the resource/principal actually
|
|
42
|
-
# holds the foreign-key column; to_model/to_id is the other side.
|
|
43
|
-
Relationship = Value.define(:column, :from_model, :from_id, :to_model, :to_id)
|
|
44
|
-
|
|
45
|
-
Result = Value.define(:principal, :resource, :relationships, :observed_status, :observed_redirect,
|
|
46
|
-
:limitation) do
|
|
47
|
-
# Plain-text rendering deliberately kept free of causal wording
|
|
48
|
-
# ("owns", "is authorized", "grants") -- see class comment above.
|
|
49
|
-
def to_text
|
|
50
|
-
lines = [principal.display_label]
|
|
51
|
-
lines << observed_line if observed_status || observed_redirect
|
|
52
|
-
lines << "" << "Related state:"
|
|
53
|
-
lines.concat(related_state_lines)
|
|
54
|
-
lines.join("\n")
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
private
|
|
58
|
-
|
|
59
|
-
def observed_line
|
|
60
|
-
observed_redirect ? "Observed #{observed_status} → #{observed_redirect}" : "Observed #{observed_status}"
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def related_state_lines
|
|
64
|
-
return [" Unavailable: #{limitation}"] if limitation
|
|
65
|
-
return [no_relationship_line] if relationships.empty?
|
|
66
|
-
|
|
67
|
-
grouped_relationship_lines
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def no_relationship_line
|
|
71
|
-
" No observed foreign-key relationship to #{resource.model_name} ##{resource.id}."
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def grouped_relationship_lines
|
|
75
|
-
relationships.group_by { |rel| [rel.from_model, rel.from_id] }.flat_map do |(model, id), grouped|
|
|
76
|
-
["#{model} ##{id}"] + grouped.map { |rel| " #{rel.column} → #{rel.to_model} ##{rel.to_id}" }
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
class << self
|
|
82
|
-
# Resolves the resource, resolves the principal (from a
|
|
83
|
-
# Sweep::Outcome's PrincipalDescriptor), and reports relationships in
|
|
84
|
-
# one call. Either resolution step may fail safely -- see
|
|
85
|
-
# #resolve_resource and #resolve_principal -- in which case the
|
|
86
|
-
# Result carries a limitation instead of relationships.
|
|
87
|
-
def for_outcome(outcome:, path:, http_method: "GET", application: nil)
|
|
88
|
-
resource, resource_limitation = resolve_resource(path: path, http_method: http_method,
|
|
89
|
-
application: application)
|
|
90
|
-
principal, principal_limitation = resolve_principal(outcome.principal)
|
|
91
|
-
limitation = [resource_limitation, principal_limitation].compact.join("; ")
|
|
92
|
-
|
|
93
|
-
return unresolved_result(outcome, limitation) if resource.nil? || principal.nil?
|
|
94
|
-
|
|
95
|
-
new(resource: resource, principal: principal).call(
|
|
96
|
-
observed_status: outcome.status, observed_redirect: outcome.redirect
|
|
97
|
-
)
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
# Attempts to resolve the exact record a route addresses, trusting
|
|
101
|
-
# only Rails' own route recognition and controller naming
|
|
102
|
-
# convention, and only when every step is unambiguous. Returns
|
|
103
|
-
# [record, nil] on success or [nil, reason] when any step is not
|
|
104
|
-
# reliable -- never a guessed record.
|
|
105
|
-
def resolve_resource(path:, http_method: "GET", application: nil)
|
|
106
|
-
app = application || rails_application
|
|
107
|
-
return [nil, "no Rails application is available to recognize the route"] unless app
|
|
108
|
-
|
|
109
|
-
params = recognize(app, path, http_method)
|
|
110
|
-
return [nil, "the route could not be recognized"] unless params
|
|
111
|
-
|
|
112
|
-
id = params[:id]
|
|
113
|
-
return [nil, "the recognized route has no :id segment addressing one specific resource"] unless id
|
|
114
|
-
|
|
115
|
-
find_by_controller(params[:controller], id)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
# Resolves the actual record behind a Karst::Identity::PrincipalDescriptor
|
|
119
|
-
# only through the configured principal source. A valid model/id outside
|
|
120
|
-
# that source is deliberately unresolved.
|
|
121
|
-
def resolve_principal(descriptor)
|
|
122
|
-
record = Identity.resolve(model_name: descriptor.model_name, id: descriptor.id)
|
|
123
|
-
return [nil, "principal is not available from the configured principal source"] unless record
|
|
124
|
-
|
|
125
|
-
[record, nil]
|
|
126
|
-
rescue Identity::Error
|
|
127
|
-
[nil, "the configured principal source is unavailable"]
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
private
|
|
131
|
-
|
|
132
|
-
def rails_application
|
|
133
|
-
defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def find_by_controller(controller, id)
|
|
137
|
-
klass = active_record_class(controller.to_s.classify)
|
|
138
|
-
return [nil, "the route's controller does not map to a loaded Active Record model by convention"] unless klass
|
|
139
|
-
|
|
140
|
-
record = klass.find_by(klass.primary_key => id)
|
|
141
|
-
return [nil, "no #{klass.name} record exists for id #{id.inspect}"] unless record
|
|
142
|
-
|
|
143
|
-
[record, nil]
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def unresolved_result(outcome, limitation)
|
|
147
|
-
Result.new(principal: outcome.principal, resource: nil, relationships: [].freeze,
|
|
148
|
-
observed_status: outcome.status, observed_redirect: outcome.redirect,
|
|
149
|
-
limitation: limitation.empty? ? "the resource or principal could not be resolved" : limitation)
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def recognize(app, path, http_method)
|
|
153
|
-
app.routes.recognize_path(path, method: http_method)
|
|
154
|
-
rescue StandardError
|
|
155
|
-
nil
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def active_record_class(name)
|
|
159
|
-
klass = name.to_s.safe_constantize
|
|
160
|
-
return nil unless defined?(ActiveRecord::Base) && klass.is_a?(Class) && klass < ActiveRecord::Base
|
|
161
|
-
|
|
162
|
-
klass
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def initialize(resource:, principal:)
|
|
167
|
-
@resource = resource
|
|
168
|
-
@principal = principal
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def call(observed_status: nil, observed_redirect: nil)
|
|
172
|
-
Result.new(
|
|
173
|
-
principal: Identity.describe(@principal),
|
|
174
|
-
resource: ResourceDescriptor.new(model_name: model_name(@resource), id: primary_key_value(@resource)),
|
|
175
|
-
relationships: relationships.freeze,
|
|
176
|
-
observed_status: observed_status,
|
|
177
|
-
observed_redirect: observed_redirect,
|
|
178
|
-
limitation: nil
|
|
179
|
-
)
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
private
|
|
183
|
-
|
|
184
|
-
def relationships
|
|
185
|
-
foreign_keys_from(@resource, @principal) + foreign_keys_from(@principal, @resource)
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def foreign_keys_from(source, target)
|
|
189
|
-
return [] unless active_record?(source) && active_record?(target)
|
|
190
|
-
|
|
191
|
-
source.class.columns_hash.values.filter_map { |column| relationship_for(source, target, column) }
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
def relationship_for(source, target, column)
|
|
195
|
-
return unless foreign_key_column?(source.class, column)
|
|
196
|
-
return unless targets?(column, target.class)
|
|
197
|
-
|
|
198
|
-
value = source.public_send(column.name)
|
|
199
|
-
return if value.nil? || value != primary_key_value(target)
|
|
200
|
-
|
|
201
|
-
Relationship.new(column: column.name, from_model: model_name(source), from_id: primary_key_value(source),
|
|
202
|
-
to_model: model_name(target), to_id: primary_key_value(target))
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
def foreign_key_column?(klass, column)
|
|
206
|
-
column.name.end_with?("_id") && column.name != klass.primary_key
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
# "Obvious" is judged purely from the column's own name against the
|
|
210
|
-
# target's actual class (including its Active Record ancestry, so a
|
|
211
|
-
# single-table-inherited subclass still matches its base class's
|
|
212
|
-
# conventional foreign key) -- never a declared association, never any
|
|
213
|
-
# other column's value, and never a second hop through another model.
|
|
214
|
-
def targets?(column, target_klass)
|
|
215
|
-
candidate = column.name.delete_suffix("_id").classify.safe_constantize
|
|
216
|
-
candidate.is_a?(Class) && (target_klass <= candidate || candidate <= target_klass)
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
def active_record?(record)
|
|
220
|
-
defined?(ActiveRecord::Base) && record.is_a?(ActiveRecord::Base)
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def model_name(record)
|
|
224
|
-
record.class.respond_to?(:model_name) ? record.class.model_name.name.to_s : record.class.name.to_s
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
def primary_key_value(record)
|
|
228
|
-
record.public_send(record.class.primary_key)
|
|
229
|
-
end
|
|
230
|
-
end
|
|
231
|
-
# rubocop:enable Metrics/ClassLength
|
|
232
|
-
end
|
|
233
|
-
end
|