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,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