cafe_car 0.3.0 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8782de0874fe886849232cebef1d036e0cc43680e758a5bfdc2a0b027e26b06c
4
- data.tar.gz: 346735b4f4568e3c28a751e8dde44d5e85fa0b5d907c2ce95e0e004ff4277e1e
3
+ metadata.gz: f60b0fb2f1007109068b603e6b74d167017ae1e025752c86369f0c680a30fdfc
4
+ data.tar.gz: 9e9d9005160ef6cd09a3c2eda12083d2286d3d4353be85cb904e073d8e21ca89
5
5
  SHA512:
6
- metadata.gz: 84003696580e71078eced5706bf2a4d33858de2aee2a2b8ac3c2764838f53226d6fa5c1b6015a9bec4ec5f8049ef7cbd3188db462ad6500f29e79c6ff5b9778a
7
- data.tar.gz: 4ec99470b492701f531e2977d0fd88cc2f33fa3b4b35327b25a3b5b11f233b75fb91a5b2fdad3ff72a5398975c130cc8b6ffca4db5e159ebc6cfdbcd8c8c036b
6
+ metadata.gz: baac158f6e2a02c520faea65399352b56d6e54e238092fcee028411ffb4a36fdcae0ac76429dd4e090ecbfb109795dd47b247e5731a51b972e9bd25c87b389ff
7
+ data.tar.gz: 9f08a42247edd2bc6302e6372b40a1ecefaadb2ddcddedb385b84b918456aca5bf6e4ec922a1750e91e9d455180f178d0dd8b95445ff1db17d8c80226df8543f
data/README.md CHANGED
@@ -913,6 +913,14 @@ you write one view.** Its existence turns the dashboard on; delete it and there'
913
913
  no dashboard at all (a direct hit 404s, no nav link), so a CRUD-only app never
914
914
  inherits a blank page.
915
915
 
916
+ Authorize the page with an ordinary Pundit policy:
917
+
918
+ ```ruby
919
+ class DashboardPolicy < ApplicationPolicy
920
+ def show? = user.present?
921
+ end
922
+ ```
923
+
916
924
  Write `app/views/cafe_car/dashboard/show.html.haml`:
917
925
 
918
926
  ```haml
@@ -932,13 +940,14 @@ Three helpers compose the page:
932
940
  returns.
933
941
  - **`metrics(Model)`** — the tiles a **policy** declares. `Model`'s policy lists
934
942
  the scopes to surface in `permitted_metrics` (`:all` = the whole relation), and
935
- CafeCar renders a count tile for each — the same policy-is-source-of-truth rule
936
- as bulk actions.
943
+ CafeCar renders a count tile for each over `policy_scope(Model)` — the same
944
+ policy-is-source-of-truth rule as bulk actions.
937
945
  - **`chart "Title", model:, x:, by:`** — the same dependency-free inline-SVG bar
938
946
  chart as the index [Chart view](#advanced-usage), bucketing `model`'s records
939
947
  over the `x` date column at `by` granularity (`:day`/`:week`/`:month`, default
940
- `:month`). The `x` column is validated against the model's date-column allowlist
941
- and truncated with portable Arel, so it's never interpolated as raw SQL.
948
+ `:month`). It reads from `policy_scope(model)`; the `x` column is validated
949
+ against the model's date-column allowlist and truncated with portable Arel, so
950
+ it's never interpolated as raw SQL.
942
951
 
943
952
  ```ruby
944
953
  class UserPolicy < ApplicationPolicy
@@ -3,17 +3,25 @@ module CafeCar
3
3
  # template — `app/views/cafe_car/dashboard/show.html.haml` — that composes the
4
4
  # `metric`/`chart` helpers (and the policy-driven `metrics` helper). Its existence
5
5
  # IS the opt-in: the route always mounts, but with no host template this 404s, so
6
- # a CRUD-only host never inherits a blank page. It has no model of its own, so it
7
- # skips the CRUD policy/authorization pipeline like the components gallery does.
6
+ # a CRUD-only host never inherits a blank page. An opted-in dashboard authorizes
7
+ # the conventional `DashboardPolicy#show?`; its model helpers apply each model's
8
+ # policy scope independently.
8
9
  class DashboardsController < const(:ApplicationController)
9
10
  include Controller
10
11
  helper CafeCar::Helpers
11
12
 
13
+ rescue_from ::Pundit::NotAuthorizedError, ::Pundit::NotDefinedError, with: :render_unauthorized
14
+
12
15
  before_action :skip_policy_scope
13
- before_action :skip_authorization
16
+ after_action :verify_authorized
14
17
 
15
18
  def show
16
- return head(:not_found) unless dashboard_template?
19
+ unless dashboard_template?
20
+ skip_authorization
21
+ return head(:not_found)
22
+ end
23
+
24
+ authorize :dashboard, :show?
17
25
  render "cafe_car/dashboard/show"
18
26
  end
19
27
 
@@ -8,11 +8,4 @@
8
8
  = Field label: "Query" do
9
9
  = p scope.to_sql, as: :code, lang: :sql
10
10
 
11
- - if session = current_session
12
- = Field label: "Session" do
13
- = p session.as_json
14
-
15
- = Field label: "Cookies" do
16
- = p cookies.signed.as_json.to_s, as: :code
17
-
18
11
  = yield :debug
@@ -2,6 +2,6 @@
2
2
  -# query DSL turns into a where_assoc_exists matching any of the chosen ids.
3
3
  = Field do
4
4
  = f.label info.method
5
- = f.collection_select info.method, info.collection, :id, -> { present(_1).title },
5
+ = f.collection_select info.method, f.association_collection(info), :id, -> { present(_1).title },
6
6
  { selected: f.object.value(info.method, :id) },
7
7
  { multiple: true, name: "#{f.field_name(info.method, :id)}[]", **f.searchable_select(info) }
@@ -0,0 +1,114 @@
1
+ module CafeCar::Controller::AssociationAuthorization
2
+ private
3
+
4
+ # A parent policy controls whether an association key is editable; the
5
+ # associated model's policy scope controls WHICH records may be assigned.
6
+ # Enforce both halves server-side so a crafted foreign key cannot select a
7
+ # row the association typeahead and initial options correctly hide.
8
+ def authorize_association_attributes!(record, policy, attributes)
9
+ checked = {}
10
+
11
+ attributes.each_key do |key|
12
+ info = CafeCar[:FieldInfo].new(model: record.class, method: key)
13
+ reflection = info.reflection
14
+ next unless reflection
15
+ next if info.attachment?
16
+
17
+ if info.type == :nested
18
+ authorize_nested_associations!(record, reflection, attributes[key])
19
+ elsif reflection.polymorphic?
20
+ next if checked[reflection.name]
21
+ checked[reflection.name] = true
22
+ authorize_polymorphic_association!(record, policy, reflection, attributes)
23
+ elsif reflection.macro.in?(%i[belongs_to has_many])
24
+ authorize_association_ids!(record, policy, reflection, attributes[key])
25
+ end
26
+ end
27
+ end
28
+
29
+ def authorize_nested_associations!(record, reflection, value)
30
+ nested_attributes(reflection, value).each do |attributes|
31
+ next if destroy_nested_record?(attributes)
32
+
33
+ nested = nested_record(record, reflection, attributes)
34
+ authorize_association_attributes!(nested, policy(nested), attributes)
35
+ end
36
+ end
37
+
38
+ def authorize_polymorphic_association!(record, policy, reflection, attributes)
39
+ id = attributes[reflection.foreign_key]
40
+ type = attributes[reflection.foreign_type]
41
+ return if id.blank? && type.blank?
42
+
43
+ deny_association!(record, policy, reflection) if id.blank? || type.blank?
44
+
45
+ klass = record.class.polymorphic_class_for(type)
46
+ deny_association!(record, policy, reflection) unless klass <= ActiveRecord::Base
47
+ authorize_association_ids!(record, policy, reflection, id, klass:)
48
+ rescue NameError, ArgumentError, Pundit::NotDefinedError
49
+ deny_association!(record, policy, reflection)
50
+ end
51
+
52
+ def authorize_association_ids!(record, policy, reflection, value, klass: reflection.klass)
53
+ ids = Array.wrap(value).compact_blank.map(&:to_s).uniq
54
+ ids -= existing_association_ids(record, reflection, klass, ids)
55
+ return if ids.empty?
56
+
57
+ authorize klass, :index?
58
+ scope = policy_scope(klass)
59
+ primary = klass.primary_key
60
+ allowed = scope.where(primary => ids).pluck(primary).map(&:to_s)
61
+ deny_association!(record, policy, reflection) unless (ids - allowed).empty?
62
+ rescue Pundit::NotDefinedError
63
+ deny_association!(record, policy, reflection)
64
+ end
65
+
66
+ def existing_association_ids(record, reflection, klass, ids)
67
+ return [] unless record.persisted?
68
+
69
+ if reflection.polymorphic?
70
+ type = record.public_send(reflection.foreign_type)
71
+ return [] unless type && record.class.polymorphic_class_for(type) == klass
72
+ end
73
+
74
+ if reflection.belongs_to?
75
+ [ record.public_send(reflection.foreign_key).to_s ]
76
+ else
77
+ primary = klass.primary_key
78
+ record.association(reflection.name).scope.where(primary => ids).pluck(primary).map(&:to_s)
79
+ end
80
+ rescue NameError, ArgumentError
81
+ []
82
+ end
83
+
84
+ def nested_attributes(reflection, value)
85
+ values = value.respond_to?(:to_unsafe_h) ? value.to_unsafe_h : value
86
+ return Array.wrap(values).compact unless reflection.collection?
87
+
88
+ values.is_a?(Hash) ? values.values.compact : Array.wrap(values).compact
89
+ end
90
+
91
+ def nested_record(record, reflection, attributes)
92
+ attributes = attributes.with_indifferent_access
93
+ id = attributes[reflection.klass.primary_key]
94
+ return reflection.klass.new unless id
95
+
96
+ association = record.association(reflection.name)
97
+ if reflection.collection?
98
+ association.scope.find_by(reflection.klass.primary_key => id)
99
+ else
100
+ association.target if association.target&.id.to_s == id.to_s
101
+ end || reflection.klass.new
102
+ end
103
+
104
+ def destroy_nested_record?(attributes)
105
+ value = attributes.respond_to?(:[]) && (attributes[:_destroy] || attributes["_destroy"])
106
+ ActiveModel::Type::Boolean.new.cast(value)
107
+ end
108
+
109
+ def deny_association!(record, policy, reflection)
110
+ raise Pundit::NotAuthorizedError.new(
111
+ query: "associate_#{reflection.name}?", record:, policy:
112
+ )
113
+ end
114
+ end
@@ -4,8 +4,10 @@ module CafeCar
4
4
  module Controller
5
5
  extend ActiveSupport::Concern
6
6
 
7
+ INDEX_VIEWS = %w[table grid chart].freeze
8
+
7
9
  include Pundit::Authorization
8
- include Filtering, Authentication
10
+ include Filtering, AssociationAuthorization, Authentication
9
11
 
10
12
  class_methods do
11
13
  def model(model)
@@ -311,7 +313,9 @@ module CafeCar
311
313
  method = "permitted_attributes" unless policy.respond_to?(method)
312
314
  multi = multiple_attachments(record)
313
315
  keys = policy.public_send(method).map { |k| multi.include?(k) ? { k => [] } : k }
314
- pundit_params_for(record).permit(*keys)
316
+ pundit_params_for(record).permit(*keys).tap do |attributes|
317
+ authorize_association_attributes!(record, policy, attributes)
318
+ end
315
319
  end
316
320
 
317
321
  # Names of the record's `has_many_attached` attachments — the fields whose
@@ -399,7 +403,9 @@ module CafeCar
399
403
 
400
404
  def default_view = self.class.default_view
401
405
  def view
402
- params.fetch(:view) { default_view }
406
+ [ params[:view], default_view, "table" ].compact
407
+ .map(&:to_s)
408
+ .find { _1.in?(INDEX_VIEWS) }
403
409
  end
404
410
 
405
411
  def _render_with_renderer_json(obj, options)
@@ -410,11 +416,8 @@ module CafeCar
410
416
  # permitted_attributes is record-oriented, so ask a record for the column
411
417
  # list even when serializing a collection.
412
418
  record = obj.is_a?(CafeCar::Model) ? obj : obj.klass.new
413
- options[:only] ||= [ :id ] | policy(record).attributes.displayable
414
-
415
- if obj.is_a?(CafeCar::Model)
416
- options[:include] ||= policy(obj).displayable_associations
417
- end
419
+ displayable = [ :id ] | policy(record).attributes.displayable
420
+ options[:only] ||= displayable & record.attribute_names.map(&:to_sym)
418
421
 
419
422
  super obj, **options
420
423
  end
@@ -31,7 +31,7 @@ module CafeCar
31
31
  def rich_text? = reflection&.name =~ /^rich_text_(\w+)$/
32
32
  def attachment? = model.reflect_on_attachment(method)
33
33
  def multiple? = attachment?&.macro == :has_many_attached
34
- def collection = reflection.klass.limit(CafeCar.max_collection_options)
34
+ def collection(scope = reflection.klass.all) = scope.limit(CafeCar.max_collection_options)
35
35
  def reflection
36
36
  return if @method.nil?
37
37
  # A nested-attributes permit names the key `<assoc>_attributes` (what Rails'
@@ -18,8 +18,9 @@ module CafeCar
18
18
 
19
19
  return show(info.input_key) if info.polymorphic? and object.persisted?
20
20
  return hidden(*info.polymorphic_methods) if info.polymorphic?
21
+ return preserved_association(info) unless association_accessible?(info)
21
22
 
22
- collection ||= with_selected(info)
23
+ collection ||= with_selected(info, association_collection(info))
23
24
  # A multi-select filters by a set (`author_id[]`), so no blank "any" option —
24
25
  # an empty selection already means "any". A single select keeps its prompt.
25
26
  options[:include_blank] ||= info.prompt unless multiple
@@ -31,6 +32,20 @@ module CafeCar
31
32
  -> { @template.present(_1).title }, options, html)
32
33
  end
33
34
 
35
+ # Association choices obey the associated model's Pundit scope. The remote
36
+ # typeahead endpoint already applies this boundary; using the same boundary
37
+ # for the initial HTML options keeps the progressive-enhancement path from
38
+ # leaking rows the user cannot reach through that endpoint.
39
+ def association_collection(info)
40
+ klass = info.reflection.klass
41
+ scope = association_accessible?(info) ? @template.policy_scope(klass) : klass.none
42
+ info.collection(scope)
43
+ end
44
+
45
+ def association_accessible?(info)
46
+ @template.policy(info.reflection.klass).index?
47
+ end
48
+
34
49
  # HTML options that flag an association <select> for Tom Select enhancement
35
50
  # (see cafe_car.js). When the associated model exposes an `options` typeahead
36
51
  # feed, its URL rides along so keystroke search can reach records past
@@ -53,15 +68,30 @@ module CafeCar
53
68
  # The capped option collection, guaranteeing the currently-associated record is
54
69
  # among the options even when it sorts past the cap — otherwise editing a record
55
70
  # whose association is beyond `max_collection_options` would silently drop the value.
56
- def with_selected(info)
57
- collection = info.collection
71
+ def with_selected(info, collection)
58
72
  return collection unless info.reflection&.belongs_to?
73
+ return collection unless association_accessible?(info)
59
74
 
60
75
  selected = object.try(info.reflection.name)
61
76
  return collection unless selected
62
77
 
63
78
  records = collection.to_a
64
- records.include?(selected) ? records : [ selected, *records ]
79
+ return records if records.include?(selected)
80
+
81
+ scope = @template.policy_scope(info.reflection.klass)
82
+ scope.where(id: selected.id).exists? ? [ selected, *records ] : records
83
+ end
84
+
85
+ # A persisted association can sit outside the viewer's current list boundary
86
+ # (or listing can be denied wholesale). Keep that foreign key through an
87
+ # unrelated edit without exposing the associated record as a labelled option.
88
+ # The controller accepts only this unchanged value; any reassignment still
89
+ # requires `index?` and membership in the associated policy scope.
90
+ def preserved_association(info)
91
+ return @template.safe_join([]) unless object.respond_to?(:persisted?) && object.persisted?
92
+ return @template.safe_join([]) unless info.reflection.belongs_to?
93
+
94
+ hidden(info.input_key)
65
95
  end
66
96
 
67
97
  # An ActiveRecord enum renders as a plain <select> of its declared values,
@@ -146,8 +146,9 @@ module CafeCar
146
146
  ids = Array.wrap(value)
147
147
  assoc = filter_association(klass, key)
148
148
  return ids.join(", ") unless assoc
149
+ return ids.join(", ") unless policy(assoc).index?
149
150
 
150
- titles = assoc.where(id: ids).index_by { _1.id.to_s }
151
+ titles = policy_scope(assoc).where(id: ids).index_by { _1.id.to_s }
151
152
  ids.map { |id| titles[id.to_s]&.then { present(_1).title } || id }.join(", ")
152
153
  end
153
154
 
@@ -299,9 +300,10 @@ module CafeCar
299
300
  # A dashboard chart tile: a title over the dependency-free inline-SVG bar chart,
300
301
  # built from `model`'s records bucketed over the `x` date column at `by`
301
302
  # granularity. `x` runs through ChartBuilder's date-column allowlist unchanged,
302
- # so a column name can never reach SQL raw.
303
+ # so a column name can never reach SQL raw. The model's policy scope is the
304
+ # chart's row boundary.
303
305
  def chart(title, model:, x:, by: nil)
304
- render "cafe_car/dashboard/chart", title:, objects: model.all, x:, by:
306
+ render "cafe_car/dashboard/chart", title:, objects: policy_scope(model), x:, by:
305
307
  end
306
308
 
307
309
  # The policy-driven metric tiles for `model`: one count tile per name in the
@@ -314,7 +316,8 @@ module CafeCar
314
316
  end
315
317
 
316
318
  def metric_for(model, name)
317
- scope = name.to_sym == :all ? model.all : model.public_send(name)
319
+ scope = policy_scope(model)
320
+ scope = scope.public_send(name) unless name.to_sym == :all
318
321
  metric(metric_label(model, name)) { scope.count }
319
322
  end
320
323
 
@@ -339,7 +342,7 @@ module CafeCar
339
342
  end
340
343
  end
341
344
 
342
- def debug? = params.key?(:debug)
345
+ def debug? = Rails.env.development? && request.local? && params.key?(:debug)
343
346
  def console? = params.key?(:console)
344
347
 
345
348
  def comment(text)
@@ -1,3 +1,3 @@
1
1
  module CafeCar
2
- VERSION = "0.3.0"
2
+ VERSION = "0.3.1"
3
3
  end
@@ -43,7 +43,16 @@ Override the partials like any other ([views.md](views.md)):
43
43
  ## The dashboard — opt in by writing one view
44
44
 
45
45
  No template, no dashboard: the route always exists, but it 404s and shows no nav
46
- link until the host writes `app/views/cafe_car/dashboard/show.html.haml`:
46
+ link until the host writes `app/views/cafe_car/dashboard/show.html.haml`. The page
47
+ uses ordinary Pundit authorization:
48
+
49
+ ```ruby
50
+ class DashboardPolicy < ApplicationPolicy
51
+ def show? = user.present?
52
+ end
53
+ ```
54
+
55
+ Then write the view:
47
56
 
48
57
  ```haml
49
58
  - title "Dashboard"
@@ -57,11 +66,13 @@ link until the host writes `app/views/cafe_car/dashboard/show.html.haml`:
57
66
  ```
58
67
 
59
68
  - `metrics(Model)` — one count tile per scope named in the model policy's
60
- `permitted_metrics` (`:all` = whole relation). Policy-driven; the default choice.
69
+ `permitted_metrics` (`:all` = the whole policy scope). Policy-driven; the default
70
+ choice.
61
71
  - `metric("Label") { … }` — one tile with whatever the block returns.
62
72
  - `chart "Title", model:, x:, by:` — the inline-SVG bar chart, bucketing over the
63
73
  `x` date column at `:day`/`:week`/`:month`. Column names are validated against
64
- the policy's date columns — never raw SQL.
74
+ the policy's date columns, and rows come from `policy_scope(model)` — never raw
75
+ SQL or unscoped data.
65
76
 
66
77
  It's a plain view: add headings, your own partials, anything between tiles. Once
67
78
  the file exists a Dashboard link appears at the top of the sidebar.
@@ -59,6 +59,12 @@ end
59
59
  def permitted_attributes_for_create = %i[name email owner_id] # per-action variant
60
60
  ```
61
61
 
62
+ For associations, the parent policy decides whether the foreign key is editable,
63
+ and the associated model's `index?` permission plus policy scope decide which
64
+ records may be selected. CafeCar applies that boundary to the initial select, the
65
+ remote typeahead, and submitted ids (including nested and polymorphic ids); a
66
+ crafted id outside it is denied server-side.
67
+
62
68
  Nested records permit `<assoc>_attributes` the Rails way (with `:id` + `:_destroy`
63
69
  for `allow_destroy`):
64
70
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cafe_car
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Peterson
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-11 00:00:00.000000000 Z
11
+ date: 2026-07-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -388,6 +388,7 @@ files:
388
388
  - lib/cafe_car/component.rb
389
389
  - lib/cafe_car/context.rb
390
390
  - lib/cafe_car/controller.rb
391
+ - lib/cafe_car/controller/association_authorization.rb
391
392
  - lib/cafe_car/controller/filtering.rb
392
393
  - lib/cafe_car/core_ext.rb
393
394
  - lib/cafe_car/core_ext/array.rb