janela 0.6.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
@@ -10,8 +10,9 @@ module Janela
10
10
  # Run in this order, and each one names the finding it produces: a host
11
11
  # silences a check by that name (ADR 021).
12
12
  CHECKS = %i[stale_identifiers unmounted_engine unmigrated_tables unregistered_controllers
13
- through_dimensions_without_an_allowlist frames_nobody_will_own
14
- unauthenticated_endpoints hardcoded_disallowed_predicates].freeze
13
+ through_dimensions_without_an_allowlist unscoped_reads frames_nobody_will_own
14
+ snapshots_nobody_will_see unauthenticated_endpoints
15
+ hardcoded_disallowed_predicates].freeze
15
16
 
16
17
  # Identifiers a previous version of Janela used, and what replaced them.
17
18
  RENAMED = {
@@ -133,22 +134,43 @@ module Janela
133
134
 
134
135
  # Ransack's allowlist is per class, so a dimension read through an
135
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).
136
145
  def through_dimensions_without_an_allowlist
137
- janela_models.flat_map do |model|
138
- 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|
139
166
  association = model.reflect_on_association(dimension.through)
140
167
  next unless association
168
+ next if association.klass.ransackable_attributes.map(&:to_s).include?(dimension.column.to_s)
141
169
 
142
- allowed = association.klass.ransackable_attributes.map(&:to_s)
143
- next if allowed.include?(dimension.column.to_s)
144
-
145
- Finding.new(severity: :error,
146
- summary: "#{association.klass} does not allow filtering on #{dimension.column}",
147
- detail: " #{model}'s #{dimension.name.inspect} dimension reads it through " \
148
- "#{dimension.through.inspect}, and Ransack's allowlist is per class. Add to " \
149
- "#{association.klass}:\n" \
150
- " def self.ransackable_attributes(_auth_object = nil) = " \
151
- "%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}" ]
152
174
  end
153
175
  end
154
176
  end
@@ -157,11 +179,23 @@ module Janela
157
179
  # but one written into the host's own Ruby is source like any other
158
180
  # identifier this doctor already greps for (ADR 015, ADR 021, ADR 025).
159
181
  def hardcoded_disallowed_predicates
160
- janela_models.flat_map do |model|
161
- 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) }
162
184
  end
163
185
  end
164
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).
165
199
  def disallowed_uses(model, dimension)
166
200
  pattern = /\b#{Regexp.escape(dimension.ransack_name)}(_\w+)/
167
201
  source_files.flat_map do |file|
@@ -173,23 +207,72 @@ module Janela
173
207
 
174
208
  key = "#{dimension.ransack_name}_#{predicate}"
175
209
  allowed = dimension.allowed_predicates.map { |p| "#{dimension.ransack_name}_#{p}" }
176
- Finding.new(severity: :error,
210
+ Finding.new(severity: :warning,
177
211
  summary: "#{model} does not allow #{key}",
178
- detail: " #{file.relative_path_from(@root)} filters #{model} on #{key}, which Janela now " \
179
- "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.")
180
216
  end
181
217
  end
182
218
  end
183
219
 
220
+ # A host that has defined no policy_scope has not said what may be read,
221
+ # and since ADR 032 every dashboard raises rather than answering with
222
+ # every row. Reported so it is found here rather than by a visitor.
223
+ #
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).
230
+ def unscoped_reads
231
+ parent = parent_controller
232
+ return unless parent
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
251
+
252
+ def undefined_policy_scope(parent)
253
+ Finding.new(severity: :error,
254
+ summary: "#{parent} defines no policy_scope, so every pane will raise",
255
+ detail: " Janela asks your controller what may be read and refuses to guess.\n" \
256
+ " Define it on #{parent}:\n" \
257
+ " private def policy_scope(model) = model.all\n" \
258
+ " That line says every visitor may read every row of every model on a\n" \
259
+ " dashboard. If that is not true of this application, return something\n" \
260
+ " narrower; docs/multi-tenancy.md has the wiring for the usual libraries.")
261
+ end
262
+
263
+ def answers_policy_scope?(parent)
264
+ parent.private_method_defined?(:policy_scope) || parent.method_defined?(:policy_scope)
265
+ end
266
+
184
267
  # A host whose policy filters frames by owner, but which never tells
185
268
  # Janela what owns a new one, creates frames its own scope then hides.
186
269
  # The failure is silent, and a typo in the method name looks the same as
187
270
  # not defining it, which is the cost of asking by duck typing (ADR 019).
188
271
  def frames_nobody_will_own
189
- parent = Janela.parent_controller.safe_constantize
190
- 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)
191
274
  return if parent.private_method_defined?(:janela_frame_owner) || parent.method_defined?(:janela_frame_owner)
192
- return unless Janela::Frame.table_exists? && scope_filters_frames_by_owner?(parent)
275
+ return unless Janela::Frame.table_exists? && owner_filtering(Janela::Frame) == :filtered
193
276
 
194
277
  Finding.new(severity: :error,
195
278
  summary: "#{parent} scopes frames by owner but defines no janela_frame_owner",
@@ -202,20 +285,77 @@ module Janela
202
285
  nil # no database or no policy to ask; nothing can be concluded
203
286
  end
204
287
 
288
+ # A snapshot with no owner is stored and unreachable to a policy that
289
+ # filters on one: the row is there, a link to it is a 404, and nothing
290
+ # says why. Most often these were taken before the column existed, which
291
+ # is what an upgrade produces.
292
+ #
293
+ # ADR 033 judged this a thinner case than the frame's, because a caller
294
+ # assigns a snapshot's owner in its own Ruby rather than the engine doing
295
+ # it silently, and said it was worth revisiting if it bit. It bit four
296
+ # times in this repository's tests and once on the live demo within an
297
+ # afternoon of the column landing (#49).
298
+ def snapshots_nobody_will_see
299
+ parent = parent_controller
300
+ return unless parent && Janela::Snapshot.table_exists?
301
+ return unless owner_filtering(Janela::Snapshot) == :filtered
302
+
303
+ unowned = Janela::Snapshot.where(owner_id: nil).count
304
+ return if unowned.zero?
305
+
306
+ Finding.new(severity: :warning,
307
+ summary: "#{unowned} #{'snapshot'.pluralize(unowned)} with no owner, which your policy filters on",
308
+ detail: " Your policy narrows snapshots by owner, so one with none is stored and\n" \
309
+ " unreachable: a link to it answers 404 and nothing says why. Usually these\n" \
310
+ " were taken before the owner column existed. Assign an owner to them, or\n" \
311
+ " delete them, and pass owner: to Janela::Snapshot.take from now on.")
312
+ rescue StandardError
313
+ nil # no database or no policy to ask; nothing can be concluded
314
+ end
315
+
205
316
  # Asking the policy rather than reading its source: a scope that narrows
206
- # frames is one that will hide an unowned one.
207
- def scope_filters_frames_by_owner?(parent)
208
- scope = parent.allocate.send(:policy_scope, Janela::Frame)
209
- scope.to_sql.include?("owner")
317
+ # an owned record is one that will hide an unowned one. Shared, because
318
+ # a frame and a snapshot are the same question asked of two tables.
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
210
329
  rescue StandardError
211
- 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 ]
212
352
  end
213
353
 
214
354
  # Whether an endpoint is public depends on what the host's controller
215
355
  # does, which cannot be determined by reading, so this observes and says
216
356
  # so rather than declaring anything safe.
217
357
  def unauthenticated_endpoints
218
- parent = Janela.parent_controller.safe_constantize
358
+ parent = parent_controller
219
359
  return unless parent
220
360
 
221
361
  filters = parent._process_action_callbacks.map(&:filter).map(&:to_s)
@@ -230,7 +370,7 @@ module Janela
230
370
 
231
371
  def janela_models
232
372
  Rails.application.eager_load!
233
- ActiveRecord::Base.descendants.select { |model| model.respond_to?(:janela) && model.janela }
373
+ ActiveRecord::Base.descendants.select { |model| Janela.our_definition(model) }
234
374
  rescue StandardError
235
375
  []
236
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.6.0"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/janela.rb CHANGED
@@ -20,10 +20,48 @@ module Janela
20
20
  # Something the request asked for is not allowed here: a renderer, a
21
21
  # granularity, a limit or a filter. Rendered as 400.
22
22
  class BadRequest < Error; end
23
+ # The host has not said what may be read. Not rendered as anything: it is a
24
+ # setup mistake rather than a data condition, and dressing it as a 404 would
25
+ # hide the one line that fixes it (ADR 032).
26
+ class Unscoped < Error; end
23
27
 
24
28
  # Janela's controllers inherit from the host's, so the host's authentication
25
29
  # and authorisation apply to dashboards with no configuration.
26
- 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
27
65
 
28
66
  # The stylesheet Janela's own pages load on top of janela.css. Nil means the
29
67
  # structural one only, which is what a host that has its own look wants. The
@@ -37,6 +75,29 @@ module Janela
37
75
  # is for (ADR 021).
38
76
  mattr_accessor :silenced_checks, default: []
39
77
 
78
+ # What the host permits to be read, asked of its controller and never
79
+ # assumed. Janela refuses rather than reading everything, because a
80
+ # dashboard that quietly totals rows its reader may not see is the worst
81
+ # thing this library can do (ADR 032).
82
+ #
83
+ # Asked of the controller and never of self, because a helper runs on the
84
+ # view and a view cannot see a private controller method: when there were
85
+ # two copies of this question they disagreed about whether a host had
86
+ # answered it (#46). One copy, for that reason.
87
+ def self.scope(controller, model)
88
+ unless controller.respond_to?(:policy_scope, true)
89
+ raise Unscoped, "#{controller.class} defines no policy_scope, so Janela has not been " \
90
+ "told what may be read of #{model.name}. Define it on " \
91
+ "#{Janela.parent_controller}, returning the rows this visitor may see:\n\n" \
92
+ " private def policy_scope(model) = model.all\n\n" \
93
+ "That line says every visitor may read every row of every model on a " \
94
+ "dashboard. If that is not true here, return something narrower. " \
95
+ "UPGRADING.md has the steps and docs/multi-tenancy.md has the wiring."
96
+ end
97
+
98
+ controller.send(:policy_scope, model)
99
+ end
100
+
40
101
  # A model that declares a janela block is addressable over HTTP, and so is a
41
102
  # subclass of one, keyed by the route key that appears in pane URLs (orders,
42
103
  # sales_orders). Names are stored rather than classes so a reloaded model
@@ -68,7 +129,7 @@ module Janela
68
129
  # here until a restart, and a form offering it would fail to draw at all.
69
130
  def self.definitions
70
131
  Rails.application.eager_load!
71
- 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) }
72
133
  end
73
134
 
74
135
  def self.definition!(route_key)
@@ -77,7 +138,17 @@ module Janela
77
138
  Rails.application.eager_load! unless registry.key?(route_key)
78
139
  class_name = registry.fetch(route_key) { raise NotFound, "#{route_key.inspect} is not a janela model" }
79
140
 
80
- 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)
81
152
  end
82
153
 
83
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.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jay Killeen
@@ -136,6 +136,7 @@ files:
136
136
  - db/migrate/20260915000001_create_janela_snapshots.rb
137
137
  - db/migrate/20260916000001_create_janela_frames.rb
138
138
  - db/migrate/20260916000002_create_janela_panes.rb
139
+ - db/migrate/20260921000001_add_owner_to_janela_snapshots.rb
139
140
  - docs/decisions/001-built-to-be-forked.md
140
141
  - docs/decisions/002-measures-and-dimensions-over-ransack.md
141
142
  - docs/decisions/003-cross-filtering-with-turbo-frames.md
@@ -167,9 +168,17 @@ files:
167
168
  - docs/decisions/029-a-panes-frame-is-identified-by-who-it-is.md
168
169
  - docs/decisions/030-a-panes-src-belongs-to-turbo.md
169
170
  - docs/decisions/031-a-subclass-inherits-the-dashboard.md
171
+ - docs/decisions/032-janela-will-not-read-a-model-it-cannot-scope.md
172
+ - docs/decisions/033-a-snapshot-is-told-who-owns-it.md
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
170
177
  - docs/decisions/INDEX.md
171
178
  - docs/multi-tenancy.md
172
179
  - docs/naming.md
180
+ - docs/roadmap.md
181
+ - docs/theming.md
173
182
  - lib/janela.rb
174
183
  - lib/janela/definition.rb
175
184
  - lib/janela/dimension.rb
@@ -190,14 +199,24 @@ metadata:
190
199
  bug_tracker_uri: https://github.com/retail-tasker/janela/issues
191
200
  rubygems_mfa_required: 'true'
192
201
  post_install_message: |
193
- Janela 0.6.0 changes how single table inheritance is handled (ADR 031).
194
- You need to act only if your application has STI subclasses under a model
195
- that declares a janela block. Every named subclass of one is now
196
- registered and addressable on its own route key, and Janela.definitions
197
- returns one entry per subclass where a form offering a choice of model
198
- previously showed one.
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
- Everyone else: nothing to do.
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).
211
+
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.
216
+
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.
201
220
 
202
221
  Steps: UPGRADING.md in this gem, or
203
222
  https://github.com/retail-tasker/janela/blob/main/UPGRADING.md