janela 0.7.0 → 0.8.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.
data/lib/janela/doctor.rb CHANGED
@@ -134,22 +134,43 @@ module Janela
134
134
 
135
135
  # Ransack's allowlist is per class, so a dimension read through an
136
136
  # association needs the associated model to allow the attribute too.
137
+ #
138
+ # Grouped by the allowlist that has to be written rather than by the
139
+ # dimension that led here, because that is what the finding is about and
140
+ # there is one of it. Two dimensions reading through the same
141
+ # association used to produce two findings whose suggested lines
142
+ # contradicted each other, so a host pasting both kept the second and
143
+ # silently lost the first; an STI family produced a copy of each per
144
+ # class, since a subclass shares its parent's associations (#44).
137
145
  def through_dimensions_without_an_allowlist
138
- janela_models.flat_map do |model|
139
- model.janela.dimensions.values.select(&:through).filter_map do |dimension|
146
+ missing_allowlist_entries.map do |klass, entry|
147
+ allowed = klass.ransackable_attributes.map(&:to_s)
148
+ Finding.new(severity: :error,
149
+ summary: "#{klass} does not allow filtering on #{entry[:columns].sort.to_sentence}",
150
+ detail: " Ransack's allowlist is per class, and these read through an association:\n" \
151
+ "#{entry[:sources].sort.map { |source| " #{source}" }.join("\n")}\n" \
152
+ " Add to #{klass}:\n" \
153
+ " def self.ransackable_attributes(_auth_object = nil) = " \
154
+ "%w[#{(allowed + entry[:columns].sort).uniq.join(' ')}]")
155
+ end
156
+ end
157
+
158
+ # Keyed on the associated class rather than on the declaration, because a
159
+ # subclass may reflect an association its parent does not: two classes
160
+ # sharing a declaration and an association share a fix, and two that do
161
+ # not have a fix each. The source list names declaring classes, so an STI
162
+ # family is one line rather than one per subclass.
163
+ def missing_allowlist_entries
164
+ janela_models.each_with_object({}) do |model, missing|
165
+ model.janela.dimensions.values.select(&:through).each do |dimension|
140
166
  association = model.reflect_on_association(dimension.through)
141
167
  next unless association
168
+ next if association.klass.ransackable_attributes.map(&:to_s).include?(dimension.column.to_s)
142
169
 
143
- allowed = association.klass.ransackable_attributes.map(&:to_s)
144
- next if allowed.include?(dimension.column.to_s)
145
-
146
- Finding.new(severity: :error,
147
- summary: "#{association.klass} does not allow filtering on #{dimension.column}",
148
- detail: " #{model}'s #{dimension.name.inspect} dimension reads it through " \
149
- "#{dimension.through.inspect}, and Ransack's allowlist is per class. Add to " \
150
- "#{association.klass}:\n" \
151
- " def self.ransackable_attributes(_auth_object = nil) = " \
152
- "%w[#{(allowed + [ dimension.column.to_s ]).uniq.join(' ')}]")
170
+ entry = missing[association.klass] ||= { columns: [], sources: [] }
171
+ entry[:columns] |= [ dimension.column.to_s ]
172
+ entry[:sources] |= [ "#{model.janela.declared_by}'s #{dimension.name.inspect} dimension, " \
173
+ "through #{dimension.through.inspect}" ]
153
174
  end
154
175
  end
155
176
  end
@@ -158,11 +179,23 @@ module Janela
158
179
  # but one written into the host's own Ruby is source like any other
159
180
  # identifier this doctor already greps for (ADR 015, ADR 021, ADR 025).
160
181
  def hardcoded_disallowed_predicates
161
- janela_models.flat_map do |model|
162
- model.janela.dimensions.values.flat_map { |dimension| disallowed_uses(model, dimension) }
182
+ janela_definitions.flat_map do |definition|
183
+ definition.dimensions.values.flat_map { |dimension| disallowed_uses(definition.declared_by, dimension) }
163
184
  end
164
185
  end
165
186
 
187
+ # One finding per declaration rather than one per class that inherits it.
188
+ # An STI family shares its parent's dimensions (ADR 031), so iterating
189
+ # models multiplied the same finding by the size of the family (#44).
190
+ def janela_definitions
191
+ janela_models.filter_map(&:janela).uniq(&:declared_by)
192
+ end
193
+
194
+ # The match is a string in a file, and nothing here establishes that the
195
+ # file filters this model, or filters anything: a comment warning against
196
+ # the predicate reads the same as a call. So the finding says what was
197
+ # seen and admits the limit, and is a warning rather than an error
198
+ # (ADR 021, ADR 035, #51).
166
199
  def disallowed_uses(model, dimension)
167
200
  pattern = /\b#{Regexp.escape(dimension.ransack_name)}(_\w+)/
168
201
  source_files.flat_map do |file|
@@ -174,10 +207,12 @@ module Janela
174
207
 
175
208
  key = "#{dimension.ransack_name}_#{predicate}"
176
209
  allowed = dimension.allowed_predicates.map { |p| "#{dimension.ransack_name}_#{p}" }
177
- Finding.new(severity: :error,
210
+ Finding.new(severity: :warning,
178
211
  summary: "#{model} does not allow #{key}",
179
- detail: " #{file.relative_path_from(@root)} filters #{model} on #{key}, which Janela now " \
180
- "refuses (ADR 025). Allowed here: #{allowed.join(', ')}.")
212
+ detail: " #{file.relative_path_from(@root)} mentions #{key}, which Janela refuses on " \
213
+ "#{model} (ADR 025). Allowed here: #{allowed.join(', ')}.\n" \
214
+ " This reads your source for the name and cannot tell which model a match\n" \
215
+ " belongs to, so it may be another model's filter, or prose about one.")
181
216
  end
182
217
  end
183
218
  end
@@ -186,14 +221,35 @@ module Janela
186
221
  # and since ADR 032 every dashboard raises rather than answering with
187
222
  # every row. Reported so it is found here rather than by a visitor.
188
223
  #
189
- # Where unauthenticated_endpoints has to hedge, because what counts as
190
- # authentication cannot be determined by reading, this one is exact: the
191
- # method is defined or it is not, and that is the whole contract.
224
+ # ADR 032 called this check exact, on the reasoning that the method is
225
+ # defined or it is not. That was wrong, and ADR 035 supersedes it: Pundit
226
+ # defines policy_scope the moment it is included, whether or not the
227
+ # model has a policy, so for the commonest authorisation library defined
228
+ # and works are different questions. The check makes the call Janela
229
+ # makes rather than reading the method table (#51).
192
230
  def unscoped_reads
193
- parent = Janela.parent_controller.safe_constantize
231
+ parent = parent_controller
194
232
  return unless parent
195
- return if parent.private_method_defined?(:policy_scope) || parent.method_defined?(:policy_scope)
233
+ return undefined_policy_scope(parent) unless answers_policy_scope?(parent)
234
+ return unless Janela::Frame.table_exists? && Janela::Snapshot.table_exists?
235
+
236
+ [ Janela::Frame, Janela::Snapshot ].filter_map do |model|
237
+ outcome, answer = ask_for_scope(model)
238
+ next unless outcome == :raised
239
+
240
+ Finding.new(severity: :error,
241
+ summary: "#{parent}'s policy_scope raises for #{model}, so every pane will too",
242
+ detail: " Janela makes this exact call on every dashboard request, and got:\n" \
243
+ " #{answer.class}: #{answer.message.to_s.lines.first.to_s.strip.truncate(140)}\n" \
244
+ " Janela's own records go through your policy like any other model's, so\n" \
245
+ " they need whatever yours need: with Pundit that is a policy class for\n" \
246
+ " #{model}. docs/multi-tenancy.md has the wiring.")
247
+ end
248
+ rescue StandardError
249
+ nil # no database to ask yet
250
+ end
196
251
 
252
+ def undefined_policy_scope(parent)
197
253
  Finding.new(severity: :error,
198
254
  summary: "#{parent} defines no policy_scope, so every pane will raise",
199
255
  detail: " Janela asks your controller what may be read and refuses to guess.\n" \
@@ -204,15 +260,19 @@ module Janela
204
260
  " narrower; docs/multi-tenancy.md has the wiring for the usual libraries.")
205
261
  end
206
262
 
263
+ def answers_policy_scope?(parent)
264
+ parent.private_method_defined?(:policy_scope) || parent.method_defined?(:policy_scope)
265
+ end
266
+
207
267
  # A host whose policy filters frames by owner, but which never tells
208
268
  # Janela what owns a new one, creates frames its own scope then hides.
209
269
  # The failure is silent, and a typo in the method name looks the same as
210
270
  # not defining it, which is the cost of asking by duck typing (ADR 019).
211
271
  def frames_nobody_will_own
212
- parent = Janela.parent_controller.safe_constantize
213
- return unless parent&.private_method_defined?(:policy_scope) || parent&.method_defined?(:policy_scope)
272
+ parent = parent_controller
273
+ return unless parent && answers_policy_scope?(parent)
214
274
  return if parent.private_method_defined?(:janela_frame_owner) || parent.method_defined?(:janela_frame_owner)
215
- return unless Janela::Frame.table_exists? && scope_filters_by_owner?(parent, Janela::Frame)
275
+ return unless Janela::Frame.table_exists? && owner_filtering(Janela::Frame) == :filtered
216
276
 
217
277
  Finding.new(severity: :error,
218
278
  summary: "#{parent} scopes frames by owner but defines no janela_frame_owner",
@@ -236,9 +296,9 @@ module Janela
236
296
  # times in this repository's tests and once on the live demo within an
237
297
  # afternoon of the column landing (#49).
238
298
  def snapshots_nobody_will_see
239
- parent = Janela.parent_controller.safe_constantize
299
+ parent = parent_controller
240
300
  return unless parent && Janela::Snapshot.table_exists?
241
- return unless scope_filters_by_owner?(parent, Janela::Snapshot)
301
+ return unless owner_filtering(Janela::Snapshot) == :filtered
242
302
 
243
303
  unowned = Janela::Snapshot.where(owner_id: nil).count
244
304
  return if unowned.zero?
@@ -256,17 +316,46 @@ module Janela
256
316
  # Asking the policy rather than reading its source: a scope that narrows
257
317
  # an owned record is one that will hide an unowned one. Shared, because
258
318
  # a frame and a snapshot are the same question asked of two tables.
259
- def scope_filters_by_owner?(parent, model)
260
- parent.allocate.send(:policy_scope, model).to_sql.include?("owner")
319
+ #
320
+ # Three-valued on purpose. Collapsing a raise into false is how both
321
+ # owner checks came to be silent for every host whose policy reaches for
322
+ # the signed in user: a raise is a different fact from a scope that does
323
+ # not filter, and unscoped_reads is the check that reports it (ADR 035).
324
+ def owner_filtering(model)
325
+ outcome, answer = ask_for_scope(model)
326
+ return :raised if outcome == :raised
327
+
328
+ answer.to_sql.include?("owner") ? :filtered : :unfiltered
261
329
  rescue StandardError
262
- false
330
+ :raised
331
+ end
332
+
333
+ # What Janela's controllers actually inherit. Janela.parent_controller is
334
+ # what a host asked for, and the two differ when it was named too late,
335
+ # which is how a check came to print "Janela's controllers inherit X"
336
+ # about a class that was not in the chain (ADR 035, #38).
337
+ def parent_controller
338
+ Janela::ApplicationController.superclass
339
+ end
340
+
341
+ # Asks the host's policy the way a request does. On a controller with no
342
+ # request, session, params and current_user are all unreachable, so a
343
+ # policy that touches any of them raises for a reason that is not the
344
+ # host's fault. Given a request they are empty instead, which is an
345
+ # unauthenticated visitor: the right thing for a check to ask about.
346
+ def ask_for_scope(model)
347
+ controller = parent_controller.allocate
348
+ controller.set_request!(ActionDispatch::TestRequest.create) if controller.respond_to?(:set_request!)
349
+ [ :ok, controller.send(:policy_scope, model) ]
350
+ rescue StandardError => e
351
+ [ :raised, e ]
263
352
  end
264
353
 
265
354
  # Whether an endpoint is public depends on what the host's controller
266
355
  # does, which cannot be determined by reading, so this observes and says
267
356
  # so rather than declaring anything safe.
268
357
  def unauthenticated_endpoints
269
- parent = Janela.parent_controller.safe_constantize
358
+ parent = parent_controller
270
359
  return unless parent
271
360
 
272
361
  filters = parent._process_action_callbacks.map(&:filter).map(&:to_s)
@@ -281,7 +370,7 @@ module Janela
281
370
 
282
371
  def janela_models
283
372
  Rails.application.eager_load!
284
- ActiveRecord::Base.descendants.select { |model| model.respond_to?(:janela) && model.janela }
373
+ ActiveRecord::Base.descendants.select { |model| Janela.our_definition(model) }
285
374
  rescue StandardError
286
375
  []
287
376
  end
data/lib/janela/model.rb CHANGED
@@ -29,9 +29,14 @@ module Janela
29
29
  # does not exist yet, so it registers as it is created (ADR 031). An
30
30
  # anonymous class has no route key to be addressed by; naming it is the
31
31
  # host's move and declaring on it is the host's other one.
32
+ #
33
+ # A Definition rather than anything truthy: this is extended onto every
34
+ # model in the application, so `janela` may be a method the host wrote for
35
+ # its own reasons, and the host's own wins. Janela believes only what
36
+ # Janela built (#54).
32
37
  def inherited(subclass)
33
38
  super
34
- Janela.register_subclass(subclass) if subclass.name && janela
39
+ Janela.register_subclass(subclass) if subclass.name && janela.is_a?(Definition)
35
40
  end
36
41
 
37
42
  private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Janela
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/janela.rb CHANGED
@@ -27,7 +27,41 @@ module Janela
27
27
 
28
28
  # Janela's controllers inherit from the host's, so the host's authentication
29
29
  # and authorisation apply to dashboards with no configuration.
30
- mattr_accessor :parent_controller, default: "ApplicationController"
30
+ #
31
+ # Resolved once, when Janela::ApplicationController is first loaded. A host
32
+ # naming one after that point used to be ignored in silence: dashboards kept
33
+ # inheriting whatever was named first, so the host's authentication and its
34
+ # policy_scope were not the ones it had written, and nothing said so. Naming
35
+ # a different one too late raises instead (ADR 035, #38).
36
+ @parent_controller = "ApplicationController"
37
+
38
+ class << self
39
+ attr_reader :parent_controller
40
+
41
+ def parent_controller=(name)
42
+ inherited = inherited_controller
43
+ if inherited && inherited != name.to_s
44
+ raise Error, "Janela::ApplicationController already inherits #{inherited}, so naming " \
45
+ "#{name} now would do nothing: the superclass is resolved once, the first " \
46
+ "time the class is loaded. Set it earlier, in config/initializers/janela.rb, " \
47
+ "which runs before anything can load a Janela controller. config.to_prepare " \
48
+ "and config.after_initialize are both too late, and so is anything that runs " \
49
+ "once the application is serving."
50
+ end
51
+
52
+ @parent_controller = name
53
+ end
54
+
55
+ # What Janela::ApplicationController resolved to, or nil if it has not been
56
+ # loaded yet. Asked without forcing the autoload the question is about:
57
+ # const_defined? is true for a registered autoload, and autoload? stops
58
+ # being truthy only once the file has actually been loaded.
59
+ def inherited_controller
60
+ return nil if autoload?(:ApplicationController) || !const_defined?(:ApplicationController, false)
61
+
62
+ const_get(:ApplicationController, false).superclass.name
63
+ end
64
+ end
31
65
 
32
66
  # The stylesheet Janela's own pages load on top of janela.css. Nil means the
33
67
  # structural one only, which is what a host that has its own look wants. The
@@ -95,7 +129,7 @@ module Janela
95
129
  # here until a restart, and a form offering it would fail to draw at all.
96
130
  def self.definitions
97
131
  Rails.application.eager_load!
98
- registry.sort.filter_map { |_route_key, class_name| class_name.safe_constantize&.janela }
132
+ registry.sort.filter_map { |_route_key, class_name| our_definition(class_name.safe_constantize) }
99
133
  end
100
134
 
101
135
  def self.definition!(route_key)
@@ -104,7 +138,17 @@ module Janela
104
138
  Rails.application.eager_load! unless registry.key?(route_key)
105
139
  class_name = registry.fetch(route_key) { raise NotFound, "#{route_key.inspect} is not a janela model" }
106
140
 
107
- class_name.constantize.janela
141
+ our_definition(class_name.constantize) ||
142
+ raise(NotFound, "#{route_key.inspect} is not a janela model")
143
+ end
144
+
145
+ # Janela::Model is extended onto every model in the application, so `janela`
146
+ # is a question any of them can answer, and a host that answers it for its
147
+ # own reasons wins: its method is on its own singleton. Janela believes only
148
+ # what Janela built, rather than anything truthy that comes back (#54).
149
+ def self.our_definition(model)
150
+ definition = model.janela if model.respond_to?(:janela)
151
+ definition if definition.is_a?(Definition)
108
152
  end
109
153
 
110
154
  # What Janela can draw, so a gallery asks rather than reaching into
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: janela
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jay Killeen
@@ -171,9 +171,14 @@ files:
171
171
  - docs/decisions/032-janela-will-not-read-a-model-it-cannot-scope.md
172
172
  - docs/decisions/033-a-snapshot-is-told-who-owns-it.md
173
173
  - docs/decisions/034-janela-will-not-freeze-a-scope-the-host-has-not-named.md
174
+ - docs/decisions/035-a-check-does-what-janela-does-or-says-what-it-saw.md
175
+ - docs/decisions/036-janela-publishes-what-a-theme-may-target.md
176
+ - docs/decisions/037-what-1-0-means.md
174
177
  - docs/decisions/INDEX.md
175
178
  - docs/multi-tenancy.md
176
179
  - docs/naming.md
180
+ - docs/roadmap.md
181
+ - docs/theming.md
177
182
  - lib/janela.rb
178
183
  - lib/janela/definition.rb
179
184
  - lib/janela/dimension.rb
@@ -194,22 +199,24 @@ metadata:
194
199
  bug_tracker_uri: https://github.com/retail-tasker/janela/issues
195
200
  rubygems_mfa_required: 'true'
196
201
  post_install_message: |
197
- Janela 0.7.0 stops guessing what may be read. Two things now refuse
198
- rather than defaulting to every row, and both are quick to answer.
202
+ Janela 0.8.0 raises where it used to do nothing. One thing to act on,
203
+ and one to read.
199
204
 
200
- 1. If your ApplicationController defines no policy_scope, every pane
201
- raises Janela::Unscoped instead of totalling every row. Pundit
202
- hosts and anyone following docs/multi-tenancy.md: nothing to do.
203
- Everyone else writes one line (ADR 032).
205
+ 1. If you set Janela.parent_controller anywhere but an initializer, it
206
+ now raises instead of being ignored in silence. config.to_prepare
207
+ and config.after_initialize are both too late, and the README's own
208
+ layout recipe is a to_prepare block. Until now your dashboards kept
209
+ inheriting whatever was named first, so your authentication and
210
+ policy_scope were not the ones you wrote (ADR 035).
204
211
 
205
- 2. If you schedule Janela::SnapshotJob, it now needs to be told what
206
- rows to freeze: pass scope: :model_default, or subclass it and
207
- override scope_for. A job already on your queue was serialised
208
- without that argument and will raise when it performs, so drain it
209
- or re-enqueue (ADR 034).
212
+ 2. bin/rails janela:doctor reports findings it could not reach before,
213
+ including a Pundit host with no policy for Janela::Frame or
214
+ Janela::Snapshot, which used to pass the doctor and raise on every
215
+ request. Expect things that were always true and never printed.
210
216
 
211
- If you use snapshots, there is also a migration: janela_snapshots
212
- gains a nullable owner (ADR 033).
217
+ Also: the class names on Janela's own pages are no longer public API.
218
+ They are scoped under janela-page and may change in any release. What
219
+ your own markup contains is unchanged and is listed in docs/theming.md.
213
220
 
214
221
  Steps: UPGRADING.md in this gem, or
215
222
  https://github.com/retail-tasker/janela/blob/main/UPGRADING.md