rhino-rails 4.6.1 → 4.7.2

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: ec046eac08bcbf895ec331091f9204e4abba36bf712dc2d283b2c91a02f36671
4
- data.tar.gz: 9b72423cd739ed788cf2554b08aa7ba5f5d1dcbc4739689058bf6566b940f0f4
3
+ metadata.gz: 632861555ee3e2508a90ec546b6cb0412368bc141a7d387b2f92698531605b86
4
+ data.tar.gz: 91b3ad79b1c0b0b492f1f3003e906ce11e9f3fa62e81b067ecfb9c0b7740a025
5
5
  SHA512:
6
- metadata.gz: adc4639f14af9208841959882a089595573f910a67168551eca6c1cd6eb9d73e3f29523ad8ec49b0ce91e95692ca43b08c29a7d2b121605193c2e51d13b9a686
7
- data.tar.gz: 7e6eb9a292a199dbf2f1c42b1e6970c3f99b7bc0df49d1b5318f5987ef1a5b5b070dc4cf01b613bbf035d40d001c8bfd8bf0c3dd1e27141f008da37b48689807
6
+ metadata.gz: 2c5fee208f22ae88b0531f4075e6183d6d0a17137c43423e7f491dbf341848764ce16418feca131b13c45c4295f6b4a3507eebb52e1e30b2f887dcbcfdd624c1
7
+ data.tar.gz: 83468b5afe671a2b0313778e57c2f053747129156d643bb85d5543513ea2d02aa6c2b63c4b70bb5f04613cb79bbb21544ba142bb4f127ebf56247581aab4c079
@@ -175,10 +175,26 @@ module Rhino
175
175
  allowed_fields: model_class.try(:allowed_fields) || [],
176
176
  allowed_includes: model_class.try(:allowed_includes) || [],
177
177
  allowed_search: model_class.try(:allowed_search) || [],
178
+ # Computed attributes: names only (the callables never leave the server).
179
+ collection_computed_attributes: computed_names(model_class.try(:rhino_collection_computed_attributes)),
180
+ record_computed_attributes: computed_names(record_computed_declaration(model_class)),
178
181
  default_sort: model_class.try(:default_sort_field)
179
182
  }
180
183
  end
181
184
 
185
+ def computed_names(declared)
186
+ declared.is_a?(Hash) ? declared.keys.map(&:to_s) : []
187
+ end
188
+
189
+ # Instantiating is safe (no DB round trip) and is the only way to read an
190
+ # instance-level declaration; a model that cannot be instantiated simply
191
+ # exports no record-level examples.
192
+ def record_computed_declaration(model_class)
193
+ model_class.new.try(:rhino_record_computed_attributes)
194
+ rescue StandardError
195
+ {}
196
+ end
197
+
182
198
  def build_action_folders(slug, meta, group_prefix)
183
199
  folders = []
184
200
  base = base_path(slug, group_prefix)
@@ -190,6 +206,10 @@ module Rhino
190
206
  folders << { name: "Update", item: build_update_requests(base) } unless except.include?("update")
191
207
  folders << { name: "Destroy", item: build_destroy_requests(base) } unless except.include?("destroy")
192
208
 
209
+ if Array(meta[:collection_computed_attributes]).any? && !except.include?("computed")
210
+ folders << { name: "Computed Attributes", item: build_computed_requests(base, meta) }
211
+ end
212
+
193
213
  if meta[:uses_soft_deletes]
194
214
  folders << { name: "Trashed", item: build_trashed_requests(base) } unless except.include?("trashed")
195
215
  folders << { name: "Restore", item: build_restore_requests(base) } unless except.include?("restore")
@@ -231,6 +251,11 @@ module Rhino
231
251
  { "fields[#{slug}]" => meta[:allowed_fields].first(5).join(",") }, headers)
232
252
  end
233
253
 
254
+ Array(meta[:record_computed_attributes]).each do |attribute|
255
+ requests << request_item("With computed attribute #{attribute}", "GET", base,
256
+ { computed_attributes: attribute }, headers)
257
+ end
258
+
234
259
  unless meta[:allowed_search].empty?
235
260
  requests << request_item("Search", "GET", base, { search: "example" }, headers)
236
261
  end
@@ -249,6 +274,11 @@ module Rhino
249
274
  requests << request_item("Show with include", "GET", path, { include: meta[:allowed_includes].first.to_s }, headers)
250
275
  end
251
276
 
277
+ Array(meta[:record_computed_attributes]).each do |attribute|
278
+ requests << request_item("Show with computed attribute #{attribute}", "GET", path,
279
+ { computed_attributes: attribute }, headers)
280
+ end
281
+
252
282
  requests
253
283
  end
254
284
 
@@ -268,6 +298,26 @@ module Rhino
268
298
  [request_item("Delete by ID", "DELETE", path, {}, default_headers)]
269
299
  end
270
300
 
301
+ # Requests for GET {resource}/computed — the collection-level aggregates.
302
+ def build_computed_requests(base, meta)
303
+ headers = default_headers
304
+ path = "#{base}/computed"
305
+ attributes = Array(meta[:collection_computed_attributes])
306
+
307
+ requests = [request_item("All computed attributes", "GET", path, {}, headers)]
308
+
309
+ attributes.each do |attribute|
310
+ requests << request_item("Computed: #{attribute}", "GET", path, { attributes: attribute }, headers)
311
+ end
312
+
313
+ if attributes.size > 1
314
+ requests << request_item("Computed: multiple attributes", "GET", path,
315
+ { attributes: attributes.join(",") }, headers)
316
+ end
317
+
318
+ requests
319
+ end
320
+
271
321
  def build_trashed_requests(base)
272
322
  [request_item("List trashed", "GET", "#{base}/trashed", {}, default_headers)]
273
323
  end
@@ -101,6 +101,9 @@ module Rhino
101
101
  self.rhino_middleware_actions_map = actions_hash.transform_keys(&:to_s)
102
102
  end
103
103
 
104
+ # CRUD actions to exclude from route registration.
105
+ # Valid values: :index, :show, :store, :update, :destroy, :trashed,
106
+ # :restore, :forceDelete, :computed.
104
107
  def rhino_except_actions(*actions)
105
108
  self.rhino_except_actions_list = actions.map(&:to_s)
106
109
  end
@@ -59,6 +59,31 @@ module Rhino
59
59
  def rhino_additional_hidden(*columns)
60
60
  self.additional_hidden_columns = columns.map(&:to_s)
61
61
  end
62
+
63
+ # Override this method to declare COLLECTION-level computed attributes,
64
+ # served by <tt>GET /api/{resource}/computed?attributes=a,b</tt>.
65
+ #
66
+ # Each callable receives the fully scoped relation (organization scope,
67
+ # default/global scopes, <tt>?scope=</tt>, <tt>?filter[]=</tt> and
68
+ # <tt>?search=</tt> already applied) plus the current user, and is
69
+ # evaluated ONCE for the whole collection — not once per row. This is the
70
+ # cheap way to expose aggregates such as counts.
71
+ #
72
+ # Declaring at least one attribute here is what registers the
73
+ # <tt>/computed</tt> route for the model.
74
+ #
75
+ # @example
76
+ # def self.rhino_collection_computed_attributes
77
+ # {
78
+ # 'active_users_count' => ->(scope, _user) { scope.where(status: 'active').count },
79
+ # 'blocked_users_count' => ->(scope, _user) { scope.where(status: 'blocked').count }
80
+ # }
81
+ # end
82
+ #
83
+ # @return [Hash{String => #call}]
84
+ def rhino_collection_computed_attributes
85
+ {}
86
+ end
62
87
  end
63
88
 
64
89
  # Get the list of columns to hide for the current user.
@@ -84,7 +109,7 @@ module Rhino
84
109
  # to add computed/virtual attributes to the JSON response.
85
110
  #
86
111
  # @return [Hash]
87
- def as_rhino_json
112
+ def as_rhino_json(computed_attributes: [])
88
113
  user = rhino_current_user
89
114
  hidden = hidden_columns_for(user)
90
115
  result = as_json(except: hidden)
@@ -93,6 +118,13 @@ module Rhino
93
118
  computed = rhino_computed_attributes
94
119
  result.merge!(computed) if computed.is_a?(Hash) && computed.any?
95
120
 
121
+ # Merge the OPT-IN record-level computed attributes the client selected via
122
+ # ?computed_attributes=. Nothing here is evaluated unless it was asked for
123
+ # by name, so declaring an expensive attribute costs nothing on requests
124
+ # that don't want it. Merged before policy filtering, so the blacklist and
125
+ # whitelist below still govern them.
126
+ result.merge!(rhino_resolve_record_computed_attributes(computed_attributes, user))
127
+
96
128
  # Apply blacklist to the final hash (covers DB columns from as_json
97
129
  # overrides AND computed attributes from rhino_computed_attributes)
98
130
  hidden_set = Set.new(hidden)
@@ -132,8 +164,62 @@ module Rhino
132
164
  {}
133
165
  end
134
166
 
167
+ # Override this method to declare OPT-IN record-level computed attributes.
168
+ #
169
+ # Unlike +rhino_computed_attributes+, nothing here is evaluated unless the
170
+ # client names it in <tt>?computed_attributes=a,b</tt> on index/show/trashed
171
+ # — so expensive per-row work is only paid for when it is actually wanted.
172
+ #
173
+ # Return a hash of attribute name => callable. The callable may accept
174
+ # zero, one (record) or two (record, user) arguments.
175
+ #
176
+ # @example
177
+ # def rhino_record_computed_attributes
178
+ # {
179
+ # 'open_tickets_count' => ->(record, _user) { record.tickets.where(closed_at: nil).count },
180
+ # 'full_name' => ->(record, _user) { "#{record.first_name} #{record.last_name}" }
181
+ # }
182
+ # end
183
+ #
184
+ # @return [Hash{String => #call}]
185
+ def rhino_record_computed_attributes
186
+ {}
187
+ end
188
+
135
189
  private
136
190
 
191
+ # Evaluate the selected opt-in record-level computed attributes.
192
+ #
193
+ # Names that are not declared are silently skipped — the controller has
194
+ # already rejected unknown/forbidden names with a 403, and a direct
195
+ # +as_rhino_json+ caller must not be able to force an arbitrary call.
196
+ def rhino_resolve_record_computed_attributes(names, user)
197
+ return {} if names.blank?
198
+
199
+ declared = rhino_record_computed_attributes
200
+ return {} unless declared.is_a?(Hash) && declared.any?
201
+
202
+ Array(names).each_with_object({}) do |name, memo|
203
+ key = name.to_s
204
+ next unless declared.key?(key)
205
+
206
+ memo[key] = rhino_call_computed(declared[key], self, user)
207
+ end
208
+ end
209
+
210
+ # Invoke a declared callable, tolerating lambdas of arity 0, 1 or 2.
211
+ # Ruby lambdas are strict about arity, so the arity is honoured rather than
212
+ # forcing every declaration to accept both arguments.
213
+ def rhino_call_computed(entry, record, user)
214
+ return entry unless entry.respond_to?(:call)
215
+
216
+ case entry.try(:arity)
217
+ when 0 then entry.call
218
+ when 1 then entry.call(record)
219
+ else entry.call(record, user)
220
+ end
221
+ end
222
+
137
223
  # Resolves the current user from RequestStore.
138
224
  # @return [Object, nil]
139
225
  def rhino_current_user
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ # Places a custom (non-Rhino) controller into a Rhino route group, so the
5
+ # resource-scope resolver knows whether the request has a tenant boundary.
6
+ #
7
+ # Rhino's own generated controllers already publish their group; a controller
8
+ # you write yourself is outside that chain, so include this concern in it:
9
+ #
10
+ # class Admin::DashboardController < ApplicationController
11
+ # include Rhino::RouteGroupContext
12
+ # rhino_route_group :admin
13
+ #
14
+ # def summary
15
+ # # :admin is declared `tenant: false`, so this spans every organization
16
+ # # instead of raising Rhino::MissingTenantContext.
17
+ # render json: { tasks: Rhino.query(Task).count }
18
+ # end
19
+ # end
20
+ #
21
+ # Without an explicit `rhino_route_group`, the group is read from the route's
22
+ # own `route_group` default:
23
+ #
24
+ # get "/api/admin/dashboard", to: "admin/dashboard#summary",
25
+ # defaults: { route_group: "admin" }
26
+ module RouteGroupContext
27
+ extend ActiveSupport::Concern
28
+
29
+ included do
30
+ # Inheritable, so a base controller can declare the group for a whole
31
+ # namespace and subclasses may override it.
32
+ class_attribute :rhino_route_group_name, instance_writer: false, default: nil
33
+
34
+ before_action :rhino_set_route_group
35
+ end
36
+
37
+ class_methods do
38
+ # Declare the route group this controller's actions belong to.
39
+ def rhino_route_group(name)
40
+ self.rhino_route_group_name = name.to_s
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ # Publish the group for Rhino::Context (and therefore for Rhino.query, the
47
+ # policies and the permission resolver) for the rest of this request.
48
+ def rhino_set_route_group
49
+ return unless defined?(RequestStore)
50
+
51
+ # An explicit declaration wins; otherwise fall back to the route's own
52
+ # `route_group` default, which only exists once a request is bound.
53
+ resolved = rhino_route_group_name.presence
54
+ resolved ||= params[:route_group].presence if request
55
+
56
+ RequestStore.store[:rhino_route_group] = resolved
57
+ end
58
+ end
59
+ end
@@ -62,7 +62,15 @@ module Rhino
62
62
  # captures the subdomain and feeds organization resolution exactly like the
63
63
  # path-prefix ":organization" does. Groups without a domain (nil/blank)
64
64
  # match any host (default, fully backward compatible).
65
- def route_group(name, prefix: "", domain: nil, middleware: [], models: :all, auth: false, hooks: nil)
65
+ #
66
+ # The optional `tenant:` keyword declares whether the group has a tenant
67
+ # boundary. It defaults to true: Rhino.query inside the group fails closed,
68
+ # raising Rhino::MissingTenantContext when an organization-scopable model is
69
+ # queried with no organization resolved. Pass `tenant: false` for a group
70
+ # that legitimately spans every organization — a back office or admin group
71
+ # whose operators see all tenants' rows.
72
+ def route_group(name, prefix: "", domain: nil, middleware: [], models: :all, auth: false, hooks: nil,
73
+ tenant: true)
66
74
  normalized_domain = domain.to_s.strip
67
75
  normalized_domain = nil if normalized_domain.empty?
68
76
 
@@ -72,10 +80,26 @@ module Rhino
72
80
  middleware: Array(middleware),
73
81
  models: models,
74
82
  auth: !!auth,
75
- hooks: hooks
83
+ hooks: hooks,
84
+ tenant: tenant != false
76
85
  }
77
86
  end
78
87
 
88
+ # Whether the named route group has a tenant boundary, i.e. whether
89
+ # Rhino.query must fail closed inside it. Only a group that explicitly
90
+ # declares `tenant: false` does not; every other answer — an unknown group,
91
+ # an untagged route, or no group at all (jobs, rake tasks, console) — is
92
+ # true, so the resolver keeps failing closed wherever the group is not
93
+ # provably non-tenant.
94
+ def group_tenant?(name)
95
+ return true if name.nil? || name.to_s.empty?
96
+
97
+ group = @route_groups[name.to_sym]
98
+ return true unless group
99
+
100
+ group.fetch(:tenant, true) != false
101
+ end
102
+
79
103
  # Resolve a model class from its slug
80
104
  def resolve_model(slug)
81
105
  klass_name = @models[slug.to_sym]
data/lib/rhino/context.rb CHANGED
@@ -30,6 +30,16 @@ module Rhino
30
30
  end
31
31
  end
32
32
 
33
+ # The route group serving the current request, as stashed into RequestStore
34
+ # by Rhino's own controllers and by Rhino::RouteGroupContext in custom ones.
35
+ # Nil outside a request or when the route carries no group.
36
+ def route_group
37
+ return nil unless defined?(RequestStore)
38
+
39
+ value = RequestStore.store[:rhino_route_group]
40
+ value.nil? || value.to_s.empty? ? nil : value
41
+ end
42
+
33
43
  # Run +block+ with the given user/organization installed into RequestStore.
34
44
  # Snapshots the prior RequestStore user+org, sets the new ones, yields, and
35
45
  # restores the snapshot in an ensure. Returns the block's value.
@@ -42,6 +42,9 @@ module Rhino
42
42
  def index
43
43
  authorize model_class, :index?, policy_class: policy_for(model_class)
44
44
 
45
+ computed = resolve_requested_computed_attributes
46
+ return if performed?
47
+
45
48
  builder = QueryBuilder.new(model_class, params: params, named_scopes: true)
46
49
  apply_organization_scope(builder)
47
50
  builder.build
@@ -52,9 +55,9 @@ module Rhino
52
55
  if per_page.present? || pagination_enabled
53
56
  result = builder.paginate
54
57
  set_pagination_headers(result[:pagination])
55
- render json: { data: serialize_collection(result[:items]) }
58
+ render json: { data: serialize_collection(result[:items], computed) }
56
59
  else
57
- render json: { data: serialize_collection(builder.to_scope) }
60
+ render json: { data: serialize_collection(builder.to_scope, computed) }
58
61
  end
59
62
  end
60
63
 
@@ -98,6 +101,9 @@ module Rhino
98
101
  record = find_record
99
102
  authorize record, :show?, policy_class: policy_for(record)
100
103
 
104
+ computed = resolve_requested_computed_attributes
105
+ return if performed?
106
+
101
107
  # Apply includes if requested
102
108
  if params[:include].present?
103
109
  auth_response = authorize_includes
@@ -111,7 +117,7 @@ module Rhino
111
117
  record = builder.to_scope.first!
112
118
  end
113
119
 
114
- render json: serialize_record(record)
120
+ render json: serialize_record(record, computed)
115
121
  end
116
122
 
117
123
  # PUT /api/{slug}/:id
@@ -175,6 +181,9 @@ module Rhino
175
181
  def trashed
176
182
  authorize model_class, :view_trashed?, policy_class: policy_for(model_class)
177
183
 
184
+ computed = resolve_requested_computed_attributes
185
+ return if performed?
186
+
178
187
  builder = QueryBuilder.new(model_class.discarded, params: params, named_scopes: true)
179
188
  apply_organization_scope(builder)
180
189
  builder.build
@@ -185,10 +194,47 @@ module Rhino
185
194
  if per_page.present? || pagination_enabled
186
195
  result = builder.paginate
187
196
  set_pagination_headers(result[:pagination])
188
- render json: { data: serialize_collection(result[:items]) }
197
+ render json: { data: serialize_collection(result[:items], computed) }
189
198
  else
190
- render json: { data: serialize_collection(builder.to_scope) }
199
+ render json: { data: serialize_collection(builder.to_scope, computed) }
200
+ end
201
+ end
202
+
203
+ # GET /api/{slug}/computed?attributes=a,b
204
+ #
205
+ # Collection-level computed attributes: each declared callable is evaluated
206
+ # ONCE over the whole (scoped + filtered) relation instead of once per row,
207
+ # which is what makes aggregates such as `active_users_count` cheap.
208
+ #
209
+ # The relation handed to each callable has the organization scope, the
210
+ # model's default scopes, `?scope=`, `?filter[]=` and `?search=` already
211
+ # applied — so the numbers describe exactly the set `index` would have
212
+ # listed. Sorting, sparse fieldsets, includes and pagination are
213
+ # deliberately NOT applied.
214
+ #
215
+ # Omitting `?attributes=` returns every declared attribute the policy allows.
216
+ def computed
217
+ authorize model_class, :index?, policy_class: policy_for(model_class)
218
+
219
+ declared = collection_computed_attributes
220
+ names = resolve_requested_collection_attributes(declared)
221
+ return if performed?
222
+
223
+ builder = QueryBuilder.new(model_class, params: params, named_scopes: true)
224
+ apply_organization_scope(builder)
225
+ builder.build_for_computed
226
+
227
+ scope = builder.to_scope
228
+ user = current_user
229
+
230
+ data = names.each_with_object({}) do |name, memo|
231
+ # Each attribute gets the base relation; ActiveRecord relations are
232
+ # immutable under chaining, so one callable's constraints can never
233
+ # leak into the next one's result.
234
+ memo[name] = call_computed_attribute(declared[name], scope, user)
191
235
  end
236
+
237
+ render json: { data: data }
192
238
  end
193
239
 
194
240
  # POST /api/{slug}/:id/restore
@@ -599,16 +645,130 @@ module Rhino
599
645
  # Serialization
600
646
  # ------------------------------------------------------------------
601
647
 
602
- def serialize_record(record)
648
+ # ------------------------------------------------------------------
649
+ # Computed attributes
650
+ # ------------------------------------------------------------------
651
+
652
+ # The model's declared collection-level computed attributes (name => callable).
653
+ def collection_computed_attributes
654
+ declared = model_class.try(:rhino_collection_computed_attributes)
655
+ declared.is_a?(Hash) ? declared.transform_keys(&:to_s) : {}
656
+ end
657
+
658
+ # Parse and authorize `?attributes=a,b` for the /computed endpoint.
659
+ #
660
+ # Renders a 403 and returns [] on a bad/denied name. An undeclared name and
661
+ # a policy-denied name produce the SAME error, so the endpoint never reveals
662
+ # which attributes a model declares.
663
+ def resolve_requested_collection_attributes(declared)
664
+ raw = params[:attributes]
665
+
666
+ # Reject non-scalar input (?attributes[]=x) before any lookup.
667
+ if raw.present? && !raw.is_a?(String)
668
+ render json: { message: "Computed attributes are not allowed" }, status: :forbidden
669
+ return []
670
+ end
671
+
672
+ user = current_user
673
+
674
+ if raw.blank?
675
+ # No selection: every declared attribute the policy allows.
676
+ return declared.keys.select { |name| computed_attribute_allowed?(name, user) }
677
+ end
678
+
679
+ names = parse_attribute_list(raw)
680
+
681
+ names.each do |name|
682
+ next if declared.key?(name) && computed_attribute_allowed?(name, user)
683
+
684
+ render json: { message: "Computed attribute '#{name}' is not allowed" }, status: :forbidden
685
+ return []
686
+ end
687
+
688
+ names
689
+ end
690
+
691
+ # Parse and authorize `?computed_attributes=a,b` for index/show/trashed —
692
+ # the OPT-IN record-level computed attributes. Absent or blank means "none",
693
+ # which is byte-for-byte the pre-feature behavior.
694
+ def resolve_requested_computed_attributes
695
+ raw = params[:computed_attributes]
696
+ return [] if raw.nil? || raw == ""
697
+
698
+ unless raw.is_a?(String)
699
+ render json: { message: "Computed attributes are not allowed" }, status: :forbidden
700
+ return []
701
+ end
702
+
703
+ names = parse_attribute_list(raw)
704
+ return [] if names.empty?
705
+
706
+ declared = model_class.new.try(:rhino_record_computed_attributes)
707
+ declared = declared.is_a?(Hash) ? declared.transform_keys(&:to_s) : {}
708
+
709
+ user = current_user
710
+
711
+ names.each do |name|
712
+ next if declared.key?(name) && computed_attribute_allowed?(name, user)
713
+
714
+ render json: { message: "Computed attribute '#{name}' is not allowed" }, status: :forbidden
715
+ return []
716
+ end
717
+
718
+ names
719
+ end
720
+
721
+ # Split a comma-separated attribute list, dropping blanks and duplicates.
722
+ def parse_attribute_list(raw)
723
+ raw.to_s.split(",").map(&:strip).reject(&:empty?).uniq
724
+ end
725
+
726
+ # Whether the policy lets this user see a computed attribute.
727
+ #
728
+ # Computed attributes go through the SAME gate as columns:
729
+ # +hidden_attributes_for_show+ blacklists, and +permitted_attributes_for_show+
730
+ # whitelists unless it returns the ['*'] default.
731
+ def computed_attribute_allowed?(name, user)
732
+ policy = policy_for(model_class).new(user, model_class)
733
+
734
+ if policy.respond_to?(:hidden_attributes_for_show)
735
+ hidden = Array(policy.hidden_attributes_for_show(user)).map(&:to_s)
736
+ return false if hidden.include?(name)
737
+ end
738
+
739
+ return true unless policy.respond_to?(:permitted_attributes_for_show)
740
+
741
+ permitted = policy.permitted_attributes_for_show(user)
742
+ return true if permitted.nil? || permitted == ["*"]
743
+
744
+ Array(permitted).map(&:to_s).include?(name)
745
+ rescue StandardError
746
+ # Policy resolution failed — serialization applies the same filters again
747
+ # downstream, so nothing can leak through this fallback.
748
+ true
749
+ end
750
+
751
+ # Invoke a declared callable, tolerating lambdas of arity 0, 1 or 2.
752
+ def call_computed_attribute(entry, scope, user)
753
+ return entry unless entry.respond_to?(:call)
754
+
755
+ case entry.try(:arity)
756
+ when 0 then entry.call
757
+ when 1 then entry.call(scope)
758
+ else entry.call(scope, user)
759
+ end
760
+ end
761
+
762
+ def serialize_record(record, computed_attributes = [])
603
763
  if record.respond_to?(:as_rhino_json)
604
- record.as_rhino_json
764
+ record.as_rhino_json(computed_attributes: computed_attributes)
605
765
  else
606
766
  record.as_json
607
767
  end
608
768
  end
609
769
 
610
- def serialize_collection(records)
611
- records.map { |r| serialize_record(r) }
770
+ def serialize_collection(records, computed_attributes = [])
771
+ records.map { |r| serialize_record(r, computed_attributes) }
612
772
  end
613
773
 
614
774
  # ------------------------------------------------------------------
data/lib/rhino/engine.rb CHANGED
@@ -22,6 +22,7 @@ module Rhino
22
22
  require "rhino/concerns/hidable_columns"
23
23
  require "rhino/concerns/has_uuid"
24
24
  require "rhino/concerns/has_auto_scope"
25
+ require "rhino/concerns/route_group_context"
25
26
 
26
27
  # Policies
27
28
  require "rhino/policies/resource_policy"
@@ -4,5 +4,14 @@ module Rhino
4
4
  # Raised by Rhino.query (and the explicit builder) when an organization-scopable
5
5
  # model is queried with no organization context available. Fail-closed: the
6
6
  # resolver never returns an unscoped relation for a tenant-scopable model.
7
- class MissingTenantContext < StandardError; end
7
+ class MissingTenantContext < StandardError
8
+ def initialize(model_class = nil)
9
+ super(
10
+ "Rhino.query(#{model_class}) requires an organization context but none is set. " \
11
+ "Use Rhino.for_user(...).in_organization(...) outside a tenant request, or declare " \
12
+ "the route group serving this request non-tenant with `tenant: false` if it " \
13
+ "legitimately spans every organization."
14
+ )
15
+ end
16
+ end
8
17
  end
data/lib/rhino/query.rb CHANGED
@@ -20,7 +20,10 @@ module Rhino
20
20
  # (BelongsToOrganization / HasAutoScope read RequestStore at BUILD time).
21
21
  #
22
22
  # Fail closed: an org-scopable model with no org context RAISES
23
- # Rhino::MissingTenantContext rather than returning an unscoped relation.
23
+ # Rhino::MissingTenantContext rather than returning an unscoped relation
24
+ # unless the request is served by a route group declared non-tenant
25
+ # (`tenant: false`), where a query legitimately spans every organization. An
26
+ # explicit organization is always honored, in every group.
24
27
  def query(model_class)
25
28
  org = Rhino::Context.organization
26
29
 
@@ -28,9 +31,11 @@ module Rhino
28
31
  relation = model_class.all
29
32
 
30
33
  if Rhino::ScopesToOrganization.organization_scoped?(model_class)
31
- raise Rhino::MissingTenantContext, model_class.name unless org
32
-
33
- relation = Rhino::ScopesToOrganization.scope_to_organization(relation, model_class, org, strict: true)
34
+ if org
35
+ relation = Rhino::ScopesToOrganization.scope_to_organization(relation, model_class, org, strict: true)
36
+ elsif Rhino.config.group_tenant?(Rhino::Context.route_group)
37
+ raise Rhino::MissingTenantContext, model_class.name
38
+ end
34
39
  end
35
40
 
36
41
  relation
@@ -33,6 +33,17 @@ module Rhino
33
33
  self
34
34
  end
35
35
 
36
+ # Apply only the modifications that define WHICH rows are in the set:
37
+ # named scope, filters and search. Sorting, sparse fieldsets, includes and
38
+ # pagination are irrelevant to an aggregate over the collection and are
39
+ # deliberately skipped — a `select` in particular would break `count`.
40
+ def build_for_computed
41
+ apply_named_scope if @named_scopes
42
+ apply_filters
43
+ apply_search
44
+ self
45
+ end
46
+
36
47
  # Get the final ActiveRecord relation.
37
48
  def to_scope
38
49
  @scope
data/lib/rhino/routes.rb CHANGED
@@ -187,6 +187,14 @@ module Rhino
187
187
 
188
188
  except_actions = model_class.try(:rhino_except_actions_list) || []
189
189
 
190
+ # The /computed endpoint exists only for models that actually
191
+ # declare collection-level computed attributes. Models that
192
+ # don't are byte-for-byte unchanged — in particular
193
+ # `/{slug}/computed` keeps resolving to show() for a record
194
+ # whose route key is literally "computed".
195
+ declared_computed = model_class.try(:rhino_collection_computed_attributes)
196
+ has_computed = declared_computed.is_a?(Hash) && declared_computed.any?
197
+
190
198
  route_prefix = [group_prefix, slug.to_s].reject(&:blank?).join("/")
191
199
 
192
200
  # Each model's CRUD routes live inside a path scope. When the
@@ -202,6 +210,11 @@ module Rhino
202
210
  post "/", to: "rhino/resources#store", as: "rhino_#{group_name}_#{slug}_store"
203
211
  end
204
212
 
213
+ # Registered BEFORE the :id routes so the literal segment wins.
214
+ if has_computed && !except_actions.include?("computed")
215
+ get "computed", to: "rhino/resources#computed", as: "rhino_#{group_name}_#{slug}_computed"
216
+ end
217
+
205
218
  if model_class.try(:uses_soft_deletes?)
206
219
  unless except_actions.include?("trashed")
207
220
  get "trashed", to: "rhino/resources#trashed", as: "rhino_#{group_name}_#{slug}_trashed"
@@ -44,6 +44,22 @@ Rhino.configure do |config|
44
44
  # the path prefix ':organization' does. Groups without a domain match any host.
45
45
  # config.route_group :admin, prefix: '', domain: 'admin.example.com', models: :all
46
46
  # config.route_group :tenant, prefix: '', domain: '{organization}.example.com', models: :all
47
+ #
48
+ # The optional `tenant:` keyword declares whether a group has a tenant
49
+ # boundary. It defaults to true: Rhino.query inside the group fails closed,
50
+ # raising Rhino::MissingTenantContext when an organization-scopable model is
51
+ # queried with no organization resolved. Pass `tenant: false` for a group that
52
+ # legitimately spans every organization — a back office whose operators see all
53
+ # tenants' rows. There, Rhino.query applies no organization filter and does not
54
+ # raise; the models' own scopes and the policies still apply, and an explicit
55
+ # Rhino.for_user(u).in_organization(org) still scopes.
56
+ # config.route_group :admin, prefix: 'admin', tenant: false, models: :all
57
+ #
58
+ # Custom (non-Rhino) controllers publish their group by including
59
+ # Rhino::RouteGroupContext and declaring `rhino_route_group :admin`, or by
60
+ # tagging the route with `defaults: { route_group: 'admin' }`. Outside a
61
+ # request (jobs, rake tasks) no group resolves and the resolver keeps failing
62
+ # closed.
47
63
 
48
64
  # config.route_group :default, prefix: '', middleware: [], models: :all
49
65
 
data/lib/rhino/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rhino
4
- VERSION = "4.6.1"
4
+ VERSION = "4.7.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rhino-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.6.1
4
+ version: 4.7.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Cipolla
@@ -215,6 +215,7 @@ files:
215
215
  - lib/rhino/concerns/has_uuid.rb
216
216
  - lib/rhino/concerns/has_validation.rb
217
217
  - lib/rhino/concerns/hidable_columns.rb
218
+ - lib/rhino/concerns/route_group_context.rb
218
219
  - lib/rhino/configuration.rb
219
220
  - lib/rhino/context.rb
220
221
  - lib/rhino/controllers/auth_controller.rb