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,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../value"
|
|
4
|
+
require_relative "sweep"
|
|
5
|
+
require_relative "sensitive_attribute_names"
|
|
6
|
+
|
|
7
|
+
module Karst
|
|
8
|
+
module Access
|
|
9
|
+
# Selects a small candidate set without ever scanning the full principal
|
|
10
|
+
# relation: one bounded recent pool, stratified in memory by coarse
|
|
11
|
+
# states derived from the schema itself -- boolean and enum columns,
|
|
12
|
+
# nullable-foreign-key presence, and low-cardinality scalars, minus
|
|
13
|
+
# anything PII- or tenancy-shaped. Nothing here is configurable: an
|
|
14
|
+
# application that needs a specific rare user reaches it through a
|
|
15
|
+
# candidate population (Karst::Access::CandidatePopulation), which is
|
|
16
|
+
# named, reportable evidence, rather than by tuning how the ordinary
|
|
17
|
+
# sample spreads.
|
|
18
|
+
#
|
|
19
|
+
# Application-authored *populations* deliberately do not live here. They
|
|
20
|
+
# are a second search stage owned by Karst::Access::Search, which runs
|
|
21
|
+
# them only after an ordinary sample observes no usable outcome -- a
|
|
22
|
+
# population folded into this sample would silently become part of an
|
|
23
|
+
# ordinary-looking result, and Karst could never report the stronger,
|
|
24
|
+
# true story: "the ordinary sample failed; then system_admins reached
|
|
25
|
+
# it." This class only selects candidates. Access::Sweep remains the
|
|
26
|
+
# sole source of behavioral evidence.
|
|
27
|
+
# rubocop:disable Metrics/ClassLength
|
|
28
|
+
class PrincipalSampler
|
|
29
|
+
class UnsupportedPrimaryKey < Error; end
|
|
30
|
+
|
|
31
|
+
CARDINALITY_CUTOFF = 10
|
|
32
|
+
MAX_DIMENSIONS = 8
|
|
33
|
+
TENANCY_FK_TOKENS = %w[tenant account organization org company workspace team customer client shop].freeze
|
|
34
|
+
ALLOWED_SCALAR_TYPES = %i[integer bigint string].freeze
|
|
35
|
+
|
|
36
|
+
Candidate = Value.define(:principal, :reasons)
|
|
37
|
+
Result = Value.define(:principals, :candidates, :strategy, :queries, :candidate_pool_size)
|
|
38
|
+
DimensionValue = Struct.new(:reason, :matcher, keyword_init: true)
|
|
39
|
+
private_constant :DimensionValue
|
|
40
|
+
|
|
41
|
+
# Exactly one bounded recent-pool query. Every stratification decision
|
|
42
|
+
# after that is made in memory over that pool.
|
|
43
|
+
def self.query_budget(_limit = nil)
|
|
44
|
+
1
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize(source:, limit: Karst.config.access_sweep_limit,
|
|
48
|
+
pool_size: Karst.config.principal_candidate_pool_size)
|
|
49
|
+
@source = source
|
|
50
|
+
@limit = limit
|
|
51
|
+
@pool_size = pool_size
|
|
52
|
+
@queries = 0
|
|
53
|
+
@query_budget = self.class.query_budget
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def call
|
|
57
|
+
relation = active_record_relation
|
|
58
|
+
return fallback_sample unless relation
|
|
59
|
+
|
|
60
|
+
representative_sample(relation)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.representative_capable?(source)
|
|
64
|
+
return true if defined?(ActiveRecord::Relation) && source.is_a?(ActiveRecord::Relation)
|
|
65
|
+
|
|
66
|
+
defined?(ActiveRecord::Base) && source.is_a?(Class) && source < ActiveRecord::Base
|
|
67
|
+
rescue StandardError
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def active_record_relation
|
|
74
|
+
return @source if defined?(ActiveRecord::Relation) && @source.is_a?(ActiveRecord::Relation)
|
|
75
|
+
|
|
76
|
+
@source.all if defined?(ActiveRecord::Base) && @source.is_a?(Class) && @source < ActiveRecord::Base
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def fallback_sample
|
|
80
|
+
principals = @source.each.lazy.take(@limit).to_a
|
|
81
|
+
candidates = principals.map { |principal| Candidate.new(principal: principal, reasons: []) }
|
|
82
|
+
Result.new(principals: principals, candidates: candidates, strategy: :first_n, queries: 0,
|
|
83
|
+
candidate_pool_size: nil)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def representative_sample(relation)
|
|
87
|
+
klass = relation.klass
|
|
88
|
+
primary_key = single_primary_key!(klass)
|
|
89
|
+
pool = recent_pool(relation, klass, primary_key)
|
|
90
|
+
selected = {}
|
|
91
|
+
|
|
92
|
+
apply_dimensions(pool, primary_key, generic_dimensions(pool, klass), selected)
|
|
93
|
+
fill_remaining(pool, primary_key, selected)
|
|
94
|
+
|
|
95
|
+
candidates = selected.values
|
|
96
|
+
Result.new(principals: candidates.map(&:principal), candidates: candidates,
|
|
97
|
+
strategy: :representative, queries: @queries, candidate_pool_size: @pool_size)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def recent_pool(relation, klass, primary_key)
|
|
101
|
+
return [] unless query_allowed?
|
|
102
|
+
|
|
103
|
+
order = klass.columns_hash.key?("created_at") ? { created_at: :desc } : { primary_key => :desc }
|
|
104
|
+
@queries += 1
|
|
105
|
+
relation.reorder(order).limit(@pool_size).to_a
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def single_primary_key!(klass)
|
|
109
|
+
primary_key = klass.primary_key
|
|
110
|
+
return primary_key if primary_key.is_a?(String)
|
|
111
|
+
|
|
112
|
+
raise UnsupportedPrimaryKey,
|
|
113
|
+
"Karst::Access::PrincipalSampler requires #{klass.name} to have a single-column primary key " \
|
|
114
|
+
"(got #{primary_key.inspect}); pass an already-materialized Array/Enumerable of principals " \
|
|
115
|
+
"instead to use bounded-first sampling"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def query_allowed?
|
|
119
|
+
@queries < @query_budget
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def generic_dimensions(pool, klass)
|
|
123
|
+
candidate_columns(klass).first(MAX_DIMENSIONS).filter_map do |column|
|
|
124
|
+
values = generic_values(pool, klass, column)
|
|
125
|
+
next unless values
|
|
126
|
+
|
|
127
|
+
values
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
132
|
+
def generic_values(pool, klass, column)
|
|
133
|
+
if enum_mapping(klass, column)
|
|
134
|
+
enum_mapping(klass, column).keys.sort.map do |value|
|
|
135
|
+
equality_value(column, value, value)
|
|
136
|
+
end
|
|
137
|
+
elsif column.type == :boolean
|
|
138
|
+
[true, false].map { |value| equality_value(column, value, value.inspect) }
|
|
139
|
+
elsif nullable_foreign_key?(klass, column)
|
|
140
|
+
[true, false].map { |present| foreign_key_value(column, present) }
|
|
141
|
+
elsif ALLOWED_SCALAR_TYPES.include?(column.type)
|
|
142
|
+
scalar_values(pool, column)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
147
|
+
def scalar_values(pool, column)
|
|
148
|
+
values = pool.map { |record| record.public_send(column.name) }.uniq
|
|
149
|
+
return unless values.size.between?(2, CARDINALITY_CUTOFF)
|
|
150
|
+
|
|
151
|
+
values.sort_by(&:to_s).map { |value| equality_value(column, value, format_value(value)) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Booleans/nil render as `true`/`false`/`nil`; everything else (a role
|
|
155
|
+
# string, an enum key, a plan tier) renders plainly -- so a `role`
|
|
156
|
+
# column reads `role=local_admin`, not the quoted `role="local_admin"`
|
|
157
|
+
# a blind #inspect would produce.
|
|
158
|
+
def format_value(value)
|
|
159
|
+
case value
|
|
160
|
+
when true, false, nil then value.inspect
|
|
161
|
+
else value.to_s
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
166
|
+
def apply_dimensions(pool, primary_key, dimensions, selected)
|
|
167
|
+
queues = dimensions.map(&:dup)
|
|
168
|
+
loop do
|
|
169
|
+
progressed = false
|
|
170
|
+
queues.each do |queue|
|
|
171
|
+
value = queue.shift
|
|
172
|
+
next unless value
|
|
173
|
+
|
|
174
|
+
progressed = true
|
|
175
|
+
record = pool.find { |candidate| value.matcher.call(candidate) }
|
|
176
|
+
next unless record
|
|
177
|
+
|
|
178
|
+
id = record.public_send(primary_key)
|
|
179
|
+
existing = selected[id]
|
|
180
|
+
reasons = existing ? (existing.reasons + [value.reason]).uniq : [value.reason]
|
|
181
|
+
selected[id] = Candidate.new(principal: record, reasons: reasons)
|
|
182
|
+
break if selected.size >= @limit
|
|
183
|
+
end
|
|
184
|
+
break if selected.size >= @limit || !progressed
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
189
|
+
def fill_remaining(pool, primary_key, selected)
|
|
190
|
+
pool.each do |record|
|
|
191
|
+
break if selected.size >= @limit
|
|
192
|
+
|
|
193
|
+
id = record.public_send(primary_key)
|
|
194
|
+
selected[id] ||= Candidate.new(principal: record, reasons: [])
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def candidate_columns(klass)
|
|
199
|
+
klass.columns_hash.values.reject do |column|
|
|
200
|
+
column.name == klass.primary_key || SensitiveAttributeNames.match?(column.name) ||
|
|
201
|
+
encrypted_attribute?(klass, column) || tenancy_foreign_key?(column)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def encrypted_attribute?(klass, column)
|
|
206
|
+
klass.respond_to?(:encrypted_attributes) && klass.encrypted_attributes&.include?(column.name.to_sym)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def tenancy_foreign_key?(column)
|
|
210
|
+
column.name.end_with?("_id") &&
|
|
211
|
+
column.name.downcase.split("_").any? { |token| TENANCY_FK_TOKENS.include?(token) }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def nullable_foreign_key?(klass, column)
|
|
215
|
+
column.name.end_with?("_id") && column.name != klass.primary_key && column.null
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def enum_mapping(klass, column)
|
|
219
|
+
klass.defined_enums[column.name] if klass.respond_to?(:defined_enums)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def equality_value(column, value, label)
|
|
223
|
+
dimension_value("#{column.name}=#{label}") do |record|
|
|
224
|
+
record.public_send(column.name) == value
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def foreign_key_value(column, present)
|
|
229
|
+
label = present ? "present" : "absent"
|
|
230
|
+
dimension_value("#{column.name} #{label}") do |record|
|
|
231
|
+
record.public_send(column.name).nil? != present
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def dimension_value(reason, &matcher)
|
|
236
|
+
DimensionValue.new(reason: reason, matcher: matcher)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
# rubocop:enable Metrics/ClassLength
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../value"
|
|
4
|
+
require_relative "principal_sampler"
|
|
5
|
+
|
|
6
|
+
module Karst
|
|
7
|
+
module Access
|
|
8
|
+
# Runs Karst::Access::PrincipalSampler independently per configured
|
|
9
|
+
# Karst::Access::PrincipalSource -- never materializing multiple sources
|
|
10
|
+
# together (no `Author.all.to_a + Reader.all.to_a`) -- and allocates the
|
|
11
|
+
# combined candidates within one overall `limit`. The single-source case
|
|
12
|
+
# (including the implicit legacy `:default` source Configuration builds
|
|
13
|
+
# from a bare `config.principals`) behaves identically to calling
|
|
14
|
+
# PrincipalSampler directly: this class only changes behavior once more
|
|
15
|
+
# than one source is actually configured.
|
|
16
|
+
#
|
|
17
|
+
# Allocation policy (deliberately simple, not a general-purpose
|
|
18
|
+
# scheduler): every non-empty source is guaranteed at least one
|
|
19
|
+
# candidate, then remaining room is filled round-robin across sources,
|
|
20
|
+
# each contributing its own candidates in the order PrincipalSampler
|
|
21
|
+
# already prioritizes them (dimension-covering candidates before plain
|
|
22
|
+
# fill). One source running out never blocks another from filling the
|
|
23
|
+
# rest of the budget.
|
|
24
|
+
class PrincipalSelection
|
|
25
|
+
Result = Value.define(:principals, :candidates, :queries, :candidate_pool_size)
|
|
26
|
+
|
|
27
|
+
def initialize(sources:, limit: Karst.config.access_sweep_limit,
|
|
28
|
+
pool_size: Karst.config.principal_candidate_pool_size)
|
|
29
|
+
@sources = sources || {}
|
|
30
|
+
@limit = limit
|
|
31
|
+
@pool_size = pool_size
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call
|
|
35
|
+
return empty_result if @sources.empty?
|
|
36
|
+
|
|
37
|
+
per_source = sample_each_source
|
|
38
|
+
build_result(per_source, allocate(per_source))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def sample_each_source
|
|
44
|
+
@sources.each_with_object({}) do |(name, source), memo|
|
|
45
|
+
memo[name] = PrincipalSampler.new(source: source.evaluate, limit: @limit, pool_size: @pool_size).call
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def allocate(per_source)
|
|
50
|
+
names = per_source.keys
|
|
51
|
+
queues = per_source.transform_values { |result| result.candidates.dup }
|
|
52
|
+
selected = []
|
|
53
|
+
index = 0
|
|
54
|
+
until selected.size >= @limit || queues.values.all?(&:empty?)
|
|
55
|
+
take_one(queues, names[index % names.size], selected)
|
|
56
|
+
index += 1
|
|
57
|
+
end
|
|
58
|
+
selected
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def take_one(queues, name, selected)
|
|
62
|
+
queue = queues.fetch(name)
|
|
63
|
+
selected << [name, queue.shift] unless queue.empty?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_result(per_source, allocated)
|
|
67
|
+
multi_source = per_source.size > 1
|
|
68
|
+
candidates = allocated.map { |name, candidate| multi_source ? tag_source(candidate, name) : candidate }
|
|
69
|
+
|
|
70
|
+
Result.new(
|
|
71
|
+
principals: candidates.map(&:principal), candidates: candidates,
|
|
72
|
+
queries: per_source.values.sum(&:queries), candidate_pool_size: combined_pool_size(per_source)
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def tag_source(candidate, name)
|
|
77
|
+
PrincipalSampler::Candidate.new(principal: candidate.principal, reasons: candidate.reasons + ["source=#{name}"])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def combined_pool_size(per_source)
|
|
81
|
+
sizes = per_source.values.filter_map(&:candidate_pool_size)
|
|
82
|
+
sizes.empty? ? nil : sizes.sum
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def empty_result
|
|
86
|
+
Result.new(principals: [], candidates: [], queries: 0, candidate_pool_size: nil)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Karst
|
|
4
|
+
module Access
|
|
5
|
+
# One allowed principal population Karst may sample from or resolve
|
|
6
|
+
# into -- "which records may Karst consider at all," a different
|
|
7
|
+
# question from Karst::Access::CandidatePopulation ("which
|
|
8
|
+
# application-authored relation, within this source, is worth trying
|
|
9
|
+
# first").
|
|
10
|
+
#
|
|
11
|
+
# `records` is a callable Karst evaluates lazily, exactly like a bare
|
|
12
|
+
# `config.principals` -- never enumerated, sampled, or materialized just
|
|
13
|
+
# by building a PrincipalSource. A single config.principals (plus any
|
|
14
|
+
# config.principal_populations) is normalized into one implicit
|
|
15
|
+
# `:default` PrincipalSource internally (see
|
|
16
|
+
# Karst::Configuration#principal_sources), so every downstream consumer
|
|
17
|
+
# (PrincipalSelection, Identity.resolve, the panel) only ever has to
|
|
18
|
+
# handle "one or more sources," never a separate single-source case.
|
|
19
|
+
class PrincipalSource
|
|
20
|
+
attr_reader :name, :records, :populations
|
|
21
|
+
|
|
22
|
+
def initialize(name:, records:, populations: {})
|
|
23
|
+
raise ArgumentError, "principal source #{name.inspect} must be callable" unless records.respond_to?(:call)
|
|
24
|
+
|
|
25
|
+
@name = name.to_sym
|
|
26
|
+
@records = records
|
|
27
|
+
@populations = self.class.normalize_populations(@name, populations)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Evaluates the configured records callable. Never enumerates or
|
|
31
|
+
# queries on its own -- for an Active Record source this only builds a
|
|
32
|
+
# Relation, exactly like Karst::Identity.principals already did for
|
|
33
|
+
# the single-source case.
|
|
34
|
+
def evaluate
|
|
35
|
+
records.call
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The Active Record class this source's records ultimately belong to,
|
|
39
|
+
# or nil for a source whose evaluated records are not an
|
|
40
|
+
# ActiveRecord::Relation/Class (a plain Array/Enumerable source, or a
|
|
41
|
+
# callable that raises). Only ever builds a Relation to read its
|
|
42
|
+
# #klass -- never queries a row. Used to match a
|
|
43
|
+
# Karst::Access::PopulationDiscovery-discovered model against an
|
|
44
|
+
# already-configured principal source, and by the panel's guided
|
|
45
|
+
# population retry.
|
|
46
|
+
def record_klass
|
|
47
|
+
evaluated = evaluate
|
|
48
|
+
return evaluated if defined?(ActiveRecord::Base) && evaluated.is_a?(Class) && evaluated < ActiveRecord::Base
|
|
49
|
+
return evaluated.klass if defined?(ActiveRecord::Relation) && evaluated.is_a?(ActiveRecord::Relation)
|
|
50
|
+
|
|
51
|
+
nil
|
|
52
|
+
rescue StandardError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A copy of this source with `extra` populations appended after its own
|
|
57
|
+
# configured ones. Used by Karst::Access::ApprovedPopulations to fold
|
|
58
|
+
# locally approved discovered scopes into the effective configuration,
|
|
59
|
+
# so nothing downstream has to know an approval workflow exists.
|
|
60
|
+
# Explicit configuration wins outright: a name this source already
|
|
61
|
+
# configures keeps its configured callable, and configured populations
|
|
62
|
+
# keep their position ahead of appended ones.
|
|
63
|
+
def with_populations(extra)
|
|
64
|
+
return self if extra.nil? || extra.empty?
|
|
65
|
+
|
|
66
|
+
merged = self.class.normalize_populations(@name, extra).reject { |name, _| @populations.key?(name) }
|
|
67
|
+
return self if merged.empty?
|
|
68
|
+
|
|
69
|
+
self.class.new(name: @name, records: @records, populations: @populations.merge(merged))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Accepts a raw Hash of name => (callable, or {records:,
|
|
73
|
+
# populations:}) -- the shape config.principal_sources= receives.
|
|
74
|
+
def self.normalize(sources)
|
|
75
|
+
return nil if sources.nil?
|
|
76
|
+
raise ArgumentError, "principal_sources must be a Hash of name => records/{records:, populations:}" unless
|
|
77
|
+
sources.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
sources.each_with_object({}) do |(name, spec), normalized|
|
|
80
|
+
source = spec.is_a?(PrincipalSource) ? spec : from_spec(name, spec)
|
|
81
|
+
normalized[source.name] = source
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
SPEC_KEYS = %w[records populations].freeze
|
|
86
|
+
private_constant :SPEC_KEYS
|
|
87
|
+
|
|
88
|
+
def self.from_spec(name, spec)
|
|
89
|
+
return new(name: name, records: spec) if spec.respond_to?(:call)
|
|
90
|
+
|
|
91
|
+
unless spec.is_a?(Hash)
|
|
92
|
+
raise ArgumentError, "principal source #{name.inspect} must be callable or a Hash with :records"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
reject_unknown_keys!(name, spec)
|
|
96
|
+
new(name: name, records: fetch_any(spec, :records), populations: fetch_any(spec, :populations) || {})
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# An unrecognized key is refused rather than ignored. `dimensions:` in
|
|
100
|
+
# particular used to be meaningful here; a source spec that still
|
|
101
|
+
# carries one must fail loudly instead of quietly sampling differently
|
|
102
|
+
# than its author configured.
|
|
103
|
+
def self.reject_unknown_keys!(name, spec)
|
|
104
|
+
unknown = spec.keys.map(&:to_s) - SPEC_KEYS
|
|
105
|
+
return if unknown.empty?
|
|
106
|
+
|
|
107
|
+
detail = if unknown.include?("dimensions")
|
|
108
|
+
"; :dimensions was removed -- sampling states are derived from the schema automatically"
|
|
109
|
+
else
|
|
110
|
+
""
|
|
111
|
+
end
|
|
112
|
+
raise ArgumentError,
|
|
113
|
+
"principal source #{name.inspect} got unknown key(s) #{unknown.join(', ')}; " \
|
|
114
|
+
"supported keys are :records and :populations#{detail}"
|
|
115
|
+
end
|
|
116
|
+
private_class_method :reject_unknown_keys!
|
|
117
|
+
|
|
118
|
+
# A configured population is a Hash of name => zero-argument callable
|
|
119
|
+
# expected to return an ActiveRecord::Relation scoped to this same
|
|
120
|
+
# source -- see Karst::Access::CandidatePopulation. Deliberately kept
|
|
121
|
+
# as raw callables here, not wrapped into CandidatePopulation
|
|
122
|
+
# instances: a CandidatePopulation represents one already-*resolved*
|
|
123
|
+
# (queried) population, which only happens once PrincipalSampler
|
|
124
|
+
# actually runs, never at configuration time.
|
|
125
|
+
def self.normalize_populations(source_name, populations)
|
|
126
|
+
return {} if populations.nil?
|
|
127
|
+
|
|
128
|
+
valid = populations.is_a?(Hash) && populations.all? { |n, c| n.is_a?(Symbol) && c.respond_to?(:call) }
|
|
129
|
+
unless valid
|
|
130
|
+
raise ArgumentError,
|
|
131
|
+
"principal source #{source_name.inspect} populations must be a Hash of Symbol => callable"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
populations
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.fetch_any(hash, key)
|
|
138
|
+
hash.fetch(key) { hash[key.to_s] }
|
|
139
|
+
end
|
|
140
|
+
private_class_method :fetch_any
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "../value"
|
|
6
|
+
|
|
7
|
+
module Karst
|
|
8
|
+
module Access
|
|
9
|
+
# The local, machine-scoped record of which ambiguous Devise model(s) a
|
|
10
|
+
# developer has explicitly told Karst to test (see
|
|
11
|
+
# Karst::Access::SelectedPrincipalSources for how this is turned back
|
|
12
|
+
# into runnable Karst::Access::PrincipalSource objects, revalidated
|
|
13
|
+
# against Karst::Identity::DeviseSupport's own current metadata on every
|
|
14
|
+
# read).
|
|
15
|
+
#
|
|
16
|
+
# Deliberately *data*, never code: an entry is a bare model name and
|
|
17
|
+
# nothing else -- never a scope, a class, or any executable Ruby. Karst
|
|
18
|
+
# never constantizes a stored name; it is only ever compared, as a
|
|
19
|
+
# string, against what Devise.mappings currently reports. This is the
|
|
20
|
+
# same never-trust-the-file posture Karst::Access::PopulationApprovals
|
|
21
|
+
# uses for candidate populations, applied one layer earlier: to *which
|
|
22
|
+
# models* Karst may consider at all, not what it may sample from within
|
|
23
|
+
# one already-known model.
|
|
24
|
+
#
|
|
25
|
+
# Stored under the host application's `tmp/` (`tmp/karst/`) for the same
|
|
26
|
+
# reason approved populations are: machine-local, disposable,
|
|
27
|
+
# git-ignored, reset by deleting the file, and consulted only in
|
|
28
|
+
# development/test (see Karst::Access::ApprovedPopulations.local_environment?,
|
|
29
|
+
# reused as-is by Karst::Access::SelectedPrincipalSources -- this is the
|
|
30
|
+
# same local-preference mechanism, not a parallel one).
|
|
31
|
+
#
|
|
32
|
+
# Every read fails closed, exactly like PopulationApprovals: a file that
|
|
33
|
+
# is unreadable, is not JSON, is not the expected document shape, carries
|
|
34
|
+
# an unknown schema version, or holds a single entry that is not a
|
|
35
|
+
# plausible constant name selects nothing at all.
|
|
36
|
+
module PrincipalSourceSelection
|
|
37
|
+
SCHEMA_VERSION = 1
|
|
38
|
+
|
|
39
|
+
RELATIVE_PATH = File.join("tmp", "karst", "principal_source_selection.json")
|
|
40
|
+
|
|
41
|
+
# Matched only against Karst::Identity::DeviseSupport.mappings' own
|
|
42
|
+
# model names -- a stored name is never constantized and never used to
|
|
43
|
+
# look up an arbitrary constant.
|
|
44
|
+
MODEL_NAME = /\A[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*\z/
|
|
45
|
+
|
|
46
|
+
# A generous bound no realistic application approaches -- exists only
|
|
47
|
+
# so a corrupted or maliciously grown document cannot turn every
|
|
48
|
+
# principal source resolution into unbounded work.
|
|
49
|
+
MAX_ENTRIES = 50
|
|
50
|
+
|
|
51
|
+
Record = Value.define(:model_names, :error) do
|
|
52
|
+
def selected?(model_name)
|
|
53
|
+
model_names.include?(model_name.to_s)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class << self
|
|
58
|
+
def path
|
|
59
|
+
File.join(root, RELATIVE_PATH)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The path as a developer should see it: relative to the application
|
|
63
|
+
# root, since that is where they will go looking for (or delete) it.
|
|
64
|
+
def display_path
|
|
65
|
+
RELATIVE_PATH
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def load
|
|
69
|
+
document = JSON.parse(File.read(path))
|
|
70
|
+
parse(document)
|
|
71
|
+
rescue Errno::ENOENT
|
|
72
|
+
empty
|
|
73
|
+
rescue JSON::ParserError
|
|
74
|
+
failed("could not be read as JSON")
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
failed("could not be read (#{e.class})")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Replaces the whole selection with `model_names`, atomically:
|
|
80
|
+
# callers always submit the complete set they intend to keep, so
|
|
81
|
+
# deselecting a model is simply selecting a smaller set, and a
|
|
82
|
+
# partially written file can never be observed.
|
|
83
|
+
def replace(model_names)
|
|
84
|
+
normalized = normalize(model_names)
|
|
85
|
+
write(normalized)
|
|
86
|
+
Record.new(model_names: normalized, error: nil)
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
Record.new(model_names: normalized || [].freeze,
|
|
89
|
+
error: "selection could not be saved (#{e.class})")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def root
|
|
95
|
+
return Rails.root.to_s if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
96
|
+
|
|
97
|
+
Dir.pwd
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Rejects the whole document the moment any one entry is unusable,
|
|
101
|
+
# rather than silently dropping just that entry -- a hand-edited
|
|
102
|
+
# file that mostly looks right is exactly the case fail-closed
|
|
103
|
+
# exists for.
|
|
104
|
+
def rejection(document)
|
|
105
|
+
return "is not a Karst selection document" unless document.is_a?(Hash)
|
|
106
|
+
return "was written by an incompatible Karst version" unless document["version"] == SCHEMA_VERSION
|
|
107
|
+
|
|
108
|
+
selected = document["selected"]
|
|
109
|
+
return "is not a Karst selection document" unless selected.is_a?(Array)
|
|
110
|
+
|
|
111
|
+
"holds more than #{MAX_ENTRIES} entries" if selected.size > MAX_ENTRIES
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse(document)
|
|
115
|
+
reason = rejection(document)
|
|
116
|
+
return failed(reason) if reason
|
|
117
|
+
|
|
118
|
+
raw = document["selected"]
|
|
119
|
+
unless raw.all? { |name| name.is_a?(String) && MODEL_NAME.match?(name) }
|
|
120
|
+
return failed("holds an entry Karst does not recognize")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
Record.new(model_names: sort(raw.uniq).freeze, error: nil)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def normalize(model_names)
|
|
127
|
+
usable = model_names.map(&:to_s).grep(MODEL_NAME)
|
|
128
|
+
sort(usable.uniq).first(MAX_ENTRIES).freeze
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def sort(names)
|
|
132
|
+
names.sort
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def write(names)
|
|
136
|
+
target = path
|
|
137
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
138
|
+
temporary = "#{target}.#{Process.pid}.tmp"
|
|
139
|
+
File.write(temporary, "#{JSON.pretty_generate(document(names))}\n")
|
|
140
|
+
File.rename(temporary, target)
|
|
141
|
+
ensure
|
|
142
|
+
FileUtils.rm_f(temporary) if temporary
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def document(names)
|
|
146
|
+
{ "version" => SCHEMA_VERSION, "selected" => names }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def empty
|
|
150
|
+
Record.new(model_names: [].freeze, error: nil)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def failed(reason)
|
|
154
|
+
Record.new(model_names: [].freeze,
|
|
155
|
+
error: "#{display_path} #{reason}; Karst selected no principal sources from it. " \
|
|
156
|
+
"Delete the file and select again.")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|