rhino-rails 4.7.3 → 4.8.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: c7f50b9ced877e5d2fc1b304798f7c2c6372e89754ea1c7c9efd296c8f478c33
4
- data.tar.gz: cca1fbd5f280df7e94a1fa9a69c1ca338fdfff6b76d35158afdc16fd07ef4309
3
+ metadata.gz: c77838e9b0818c083a66439097840287fdfadcd94fe17f7e403b9a1c00e08322
4
+ data.tar.gz: 698536e45e86aed2abd48ba646c0ebc4e821511838c79db07e6525413ace80c5
5
5
  SHA512:
6
- metadata.gz: 43e7ae4d3e346c984ab8452c8dbc2f3ceb7199f35f8e3eba3f971931aed8539e752f9744393f2429125cb94b6d092358c070d719b634fa95b0cd9e314ea362e1
7
- data.tar.gz: d03e920444bfcdfc0c42ddfa0d494ac180aae4fc499acd852aafc1ca6486aeccaecdff54decf4e64f65e21e5c7cb0f078ea81b3426518d7495b7e8bc3a4bc7de
6
+ metadata.gz: 52ec8339ccd944fcb4b3c071bc4829acaf37c1d401abfce18bec28648eac3005d7ca18c407ad3784bc541dcd16cf3238144c8ebd7bbc58ada65b8537f64e0515
7
+ data.tar.gz: '096db647db3f380fe23621e6fc31496321e857a80ce22911bc2133a7877a7e607a49be3e60cb08f88594030c784b9b3a6d3e8f9415969ed0188bfb11569e3ffc'
data/README.md CHANGED
@@ -16,7 +16,7 @@ Register a model, get a full REST API instantly.
16
16
  | 2 | **Authentication** | Login, logout, password recovery/reset, invitation-based registration. |
17
17
  | 3 | **Authorization & Policies** | Pundit-based permission checks (`{slug}.{action}`), wildcard support. |
18
18
  | 4 | **Role-Based Access Control** | Per-org roles via `user_roles` join table. |
19
- | 5 | **Attribute-Level Permissions** | Control which fields each role can read and write. |
19
+ | 5 | **Attribute-Level Permissions** | Control which fields each role can read and write. A field a role cannot read is also refused as a `?filter[]` or a `?sort`, and skipped by `?search=`. |
20
20
  | 6 | **Validation** | Dual-layer: format rules + field presence. Supports role-keyed rules. |
21
21
  | 7 | **Cross-Tenant FK Validation** | `exists:` rules auto-scoped to current org, even through indirect FK relationships. |
22
22
  | 8 | **Filtering** | `?filter[field]=value` with AND/OR logic. |
@@ -40,7 +40,7 @@ Register a model, get a full REST API instantly.
40
40
  | 26 | **Generator CLI** | `rhino:install`, `rhino:generate`, `rhino:blueprint`, `rhino:export_postman`. |
41
41
  | 27 | **Postman Export** | Auto-generated Postman Collection v2.1 with all endpoints. |
42
42
  | 28 | **Blueprint System** | YAML-to-code generation for models, migrations, factories, policies, tests, and seeders. |
43
- | 29 | **Named Scopes** | `?scope=availableForDrivers` client-selectable scopes (whitelisted via `rhino_scopes`), plus a `rhino_default_scope` applied when none is requested. Unknown scopes return 403. Applies to `index`/`trashed` only. |
43
+ | 29 | **Named Scopes** | `?scope=availableForDrivers` client-selectable scopes (whitelisted via `rhino_scopes`), plus a `rhino_default_scope` applied when none is requested. A scope may declare parameters the client fills in with `?scope[name][param]=value`, and up to three scopes may be combined. Unknown scopes, scopes the policy's `permitted_scopes` denies, and arguments that do not match the declared parameters return 403. Applies to `index`/`trashed` only. |
44
44
  | 30 | **Configurable Route Key** | Match the `:id` URL segment against any column (`rhino_route_key :hash_id` per model, or global `config.route_key`). Member endpoints only — payload FKs and nested-operation ids stay primary-key based. |
45
45
 
46
46
  ## Quick Start
@@ -52,6 +52,22 @@ module Rhino
52
52
  # rhino_scopes :active, available_for_drivers: Scopes::AvailableForDriversScope
53
53
  # Bare symbols must name an existing ActiveRecord scope/class method on the model.
54
54
  # Hash values may be a Proc(relation, user) or a Rhino::ResourceScope subclass.
55
+ #
56
+ # A scope may also declare parameters the client fills in, in the order the
57
+ # scope takes them. A parameter listed under :optional may be left out.
58
+ # rhino_scopes since: { params: [:date] },
59
+ # window: { params: %i[min max] },
60
+ # titled: { params: %i[title status], optional: [:status] },
61
+ # mine: { params: [:status], with: ->(rel, user, status) { ... } }
62
+ #
63
+ # Queries:
64
+ # GET /api/routes?scope=active
65
+ # GET /api/routes?scope[since]=2026-01-01
66
+ # GET /api/routes?scope[window][min]=1&scope[window][max]=9
67
+ #
68
+ # Up to three scopes may be combined in the bracket form, applied in the
69
+ # order the URL lists them. A scope with no declared parameters never
70
+ # receives client input: sending any is a 403.
55
71
  def rhino_scopes(*names, **named)
56
72
  merged = allowed_scopes.dup
57
73
  names.each { |n| merged[n.to_s] = n.to_sym }
@@ -9,6 +9,12 @@ module Rhino
9
9
  # model that does not declare its own +rhino_route_key+. Default nil =
10
10
  # primary key (today's behavior, fully backward compatible).
11
11
  attr_accessor :route_key
12
+ # How many client-selectable named scopes one request may combine with the
13
+ # bracket form (?scope[a]=&scope[b][x]=1). Each scope is an arbitrary query
14
+ # fragment that may add joins or subqueries, so the number is capped: a
15
+ # request over the cap is refused with 403 "Too many scopes requested".
16
+ # Default 3 — a base scope, a window, and one more predicate.
17
+ attr_reader :max_scopes_per_request
12
18
  attr_reader :auth
13
19
 
14
20
  def initialize
@@ -33,6 +39,14 @@ module Rhino
33
39
  @client_path = nil
34
40
  @mobile_path = nil
35
41
  @route_key = nil
42
+ @max_scopes_per_request = 3
43
+ end
44
+
45
+ # A non-numeric or non-positive value would lock every scope out of every
46
+ # request, so it falls back to the default instead.
47
+ def max_scopes_per_request=(value)
48
+ value = value.to_i if value.respond_to?(:to_i)
49
+ @max_scopes_per_request = value.is_a?(Integer) && value.positive? ? value : 3
36
50
  end
37
51
 
38
52
  # Auth configuration accessor. Merges supplied keys over defaults so a host
@@ -17,7 +17,18 @@ module Rhino
17
17
  end
18
18
 
19
19
  rescue_from Rhino::ScopeNotAllowedError do |e|
20
- render json: { message: "Scope '#{e.message}' is not allowed" }, status: :forbidden
20
+ # A message that already reads as a sentence is rendered as-is (the
21
+ # too-many-scopes case); a bare name becomes the standard refusal.
22
+ message = e.message.include?(" ") ? e.message : "Scope '#{e.message}' is not allowed"
23
+ render json: { message: message }, status: :forbidden
24
+ end
25
+
26
+ rescue_from Rhino::InvalidScopeArgumentsError do |e|
27
+ render json: { message: e.message }, status: :forbidden
28
+ end
29
+
30
+ rescue_from Rhino::QueryAttributeNotAllowedError do |e|
31
+ render json: { message: e.message }, status: :forbidden
21
32
  end
22
33
 
23
34
  # Cache for auto-detected organization paths (class-level, survives across requests)
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ # Raised when a client-requested named scope is given arguments that do not
5
+ # match the model's declared parameter spec. Rendered as 403 by
6
+ # ResourcesController, message and all: it is only ever raised after the scope
7
+ # name itself passed the whitelist and the policy, so naming the parameters
8
+ # reveals nothing about scopes the user may not use.
9
+ class InvalidScopeArgumentsError < StandardError; end
10
+
11
+ # Raised when a client filters or sorts by an attribute the policy hides from
12
+ # them. Rendered as 403 by ResourcesController.
13
+ class QueryAttributeNotAllowedError < StandardError; end
14
+ end
@@ -119,6 +119,24 @@ module Rhino
119
119
  []
120
120
  end
121
121
 
122
+ # ------------------------------------------------------------------
123
+ # Scope Permissions
124
+ # ------------------------------------------------------------------
125
+
126
+ # Override to restrict which named scopes this user may select with ?scope=.
127
+ # Return ['*'] to allow every scope the model declares (default). The model's
128
+ # rhino_scopes declaration still applies: this can only narrow it.
129
+ #
130
+ # The model's rhino_default_scope is applied by the server when the client
131
+ # sends no scope at all, so it is not subject to this list. Requesting it by
132
+ # name is.
133
+ #
134
+ # @param user [Object, nil] The authenticated user
135
+ # @return [Array<String>]
136
+ def permitted_scopes(user)
137
+ ['*']
138
+ end
139
+
122
140
  # Override to whitelist which fields a user can submit on create.
123
141
  # Return ['*'] to allow all fields (default).
124
142
  #
data/lib/rhino/query.rb CHANGED
@@ -45,8 +45,8 @@ module Rhino
45
45
  # Build a tenant-scoped relation and apply a whitelisted ?scope= named scope
46
46
  # on top of it. +scope_name+ is the wire name (camelCase accepted); nil falls
47
47
  # back to the model's rhino_default_scope.
48
- def scoped_query(model_class, scope_name = nil)
49
- apply_named_scope(query(model_class), model_class, scope_name)
48
+ def scoped_query(model_class, scope_name = nil, *args)
49
+ apply_named_scope(query(model_class), model_class, scope_name, *args)
50
50
  end
51
51
 
52
52
  # Begin the fluent explicit builder for +user+.
@@ -77,26 +77,29 @@ module Rhino
77
77
  # Shared by Rhino.scoped_query and PendingScopedContext#scoped_query. Uses the
78
78
  # same allowed_scopes / default_rhino_scope mechanism as the QueryBuilder.
79
79
  # @api private
80
- def apply_named_scope(relation, model_class, scope_name = nil)
80
+ def apply_named_scope(relation, model_class, scope_name = nil, *args)
81
81
  requested = scope_name.to_s.presence
82
82
  name = requested ? requested.underscore : model_class.try(:default_rhino_scope)
83
83
  return relation unless name
84
84
 
85
- allowed = model_class.try(:allowed_scopes) || {}
85
+ allowed = Rhino::ScopeSpec.normalize(model_class.try(:allowed_scopes))
86
86
  entry = allowed[name]
87
- entry ||= name.to_sym if name == model_class.try(:default_rhino_scope)
87
+ entry ||= { target: name.to_sym, params: [], optional: [] } if name == model_class.try(:default_rhino_scope)
88
88
 
89
89
  raise Rhino::ScopeNotAllowedError, (requested || name) if entry.nil?
90
90
 
91
91
  user = defined?(RequestStore) ? RequestStore.store[:rhino_current_user] : nil
92
+ target = entry[:target] || name.to_sym
92
93
 
93
- case entry
94
- when Symbol
95
- relation.merge(model_class.public_send(entry))
94
+ # Server-side caller: the policy gate belongs to the request path, and any
95
+ # arguments here come from application code, not from a client.
96
+ case target
97
+ when Symbol, String
98
+ relation.merge(model_class.public_send(target, *args))
96
99
  when Proc
97
- entry.call(relation, user)
100
+ target.call(relation, user, *args)
98
101
  else
99
- entry.new.apply(relation)
102
+ target.new.apply(relation, *args)
100
103
  end
101
104
  end
102
105
  end
@@ -140,9 +143,9 @@ module Rhino
140
143
  end
141
144
 
142
145
  # Build a fully-baked, named-scoped relation under this explicit context.
143
- def scoped_query(model_class, scope_name = nil)
146
+ def scoped_query(model_class, scope_name = nil, *args)
144
147
  Rhino::Context.with(user: @user, organization: @organization, route_group: @route_group) do
145
- Rhino.scoped_query(model_class, scope_name)
148
+ Rhino.scoped_query(model_class, scope_name, *args)
146
149
  end
147
150
  end
148
151
 
@@ -12,6 +12,13 @@ module Rhino
12
12
  # - Fields: ?fields[posts]=id,title,status
13
13
  # - Includes: ?include=user,comments
14
14
  class QueryBuilder
15
+ # Fallback for how many named scopes one request may combine, used when no
16
+ # Rhino configuration is reachable. Apps set +config.max_scopes_per_request+.
17
+ #
18
+ # Scopes are arbitrary query fragments, so stacking many of them is a good
19
+ # way to build an accidental cross join; three covers every real listing.
20
+ DEFAULT_MAX_SCOPES_PER_REQUEST = 3
21
+
15
22
  attr_reader :scope, :model_class, :params
16
23
 
17
24
  def initialize(model_class, params: {}, named_scopes: false)
@@ -83,33 +90,90 @@ module Rhino
83
90
  # +named_scopes: true+. `show` (including its ?include= build path) stays
84
91
  # unscoped so a record excluded by the default scope is still viewable.
85
92
  def apply_named_scope
86
- requested = params[:scope].presence
87
- name = requested ? requested.to_s.underscore : model_class.try(:default_rhino_scope)
88
- return unless name
93
+ raw = params[:scope]
94
+ raw = raw.to_unsafe_h if raw.respond_to?(:to_unsafe_h)
95
+
96
+ declared = Rhino::ScopeSpec.normalize(model_class.try(:allowed_scopes))
97
+ default = model_class.try(:default_rhino_scope)
98
+
99
+ # Nothing requested: the model's default scope, which takes no arguments.
100
+ if raw.nil? || raw == "" || raw == {}
101
+ return if default.nil?
102
+
103
+ return run_named_scope(default.to_s, declared[default.to_s] || {}, [])
104
+ end
105
+
106
+ requested =
107
+ if raw.is_a?(Hash)
108
+ raw
109
+ else
110
+ # Legacy form — ?scope=name, one scope, no arguments.
111
+ { raw.to_s => "" }
112
+ end
113
+
114
+ raise Rhino::ScopeNotAllowedError, "Too many scopes requested" if requested.size > max_scopes_per_request
115
+
116
+ permitted = permitted_scope_names
89
117
 
90
- allowed = model_class.try(:allowed_scopes) || {}
91
- entry = allowed[name]
92
- # The default scope is implicitly allowed when requested by name.
93
- entry ||= name.to_sym if name == model_class.try(:default_rhino_scope)
118
+ requested.each do |wire_name, raw_arguments|
119
+ raise Rhino::ScopeNotAllowedError, wire_name.to_s if wire_name.to_s.empty?
94
120
 
95
- # Echo the client's wire name (not the underscored form) in the error.
96
- raise Rhino::ScopeNotAllowedError, (requested ? requested.to_s : name) if entry.nil?
121
+ name = wire_name.to_s.underscore
122
+ entry = declared[name]
123
+ # The default scope is implicitly allowed when requested by name.
124
+ entry ||= { target: name.to_sym, params: [], optional: [] } if name == default
97
125
 
98
- user = defined?(RequestStore) ? RequestStore.store[:rhino_current_user] : nil
126
+ # Echo the client's wire name (not the underscored form) in the error.
127
+ raise Rhino::ScopeNotAllowedError, wire_name.to_s if entry.nil?
128
+
129
+ if permitted != ["*"] && !permitted.include?(name)
130
+ raise Rhino::ScopeNotAllowedError, wire_name.to_s
131
+ end
132
+
133
+ run_named_scope(name, entry, Rhino::ScopeSpec.bind(wire_name.to_s, entry, raw_arguments))
134
+ end
135
+ end
136
+
137
+ # How many named scopes this app allows in one request.
138
+ def max_scopes_per_request
139
+ configured = Rhino.config.try(:max_scopes_per_request)
140
+ configured.is_a?(Integer) && configured.positive? ? configured : DEFAULT_MAX_SCOPES_PER_REQUEST
141
+ rescue StandardError
142
+ DEFAULT_MAX_SCOPES_PER_REQUEST
143
+ end
144
+
145
+ # Run one already-authorized named scope, passing the bound arguments in the
146
+ # order the model declared them.
147
+ def run_named_scope(name, entry, args)
148
+ target = entry[:target] || name.to_sym
149
+ user = current_user
99
150
 
100
151
  @scope =
101
- case entry
102
- when Symbol
152
+ case target
153
+ when Symbol, String
103
154
  # Whitelisted AR scope. Client input never reaches public_send unless the
104
155
  # developer declared it via rhino_scopes. .merge composes with default_scopes.
105
- @scope.merge(model_class.public_send(entry))
156
+ @scope.merge(model_class.public_send(target, *args))
106
157
  when Proc
107
- entry.call(@scope, user)
158
+ target.call(@scope, user, *args)
108
159
  else
109
- entry.new.apply(@scope) # Rhino::ResourceScope subclass (user/org/role helpers)
160
+ target.new.apply(@scope, *args) # Rhino::ResourceScope subclass (user/org/role helpers)
110
161
  end
111
162
  end
112
163
 
164
+ # Scope names this user may select, or ["*"] when the policy does not
165
+ # restrict them (the default, and the behavior of every policy written
166
+ # before permitted_scopes existed).
167
+ def permitted_scope_names
168
+ policy = policy_instance
169
+ return ["*"] unless policy.respond_to?(:permitted_scopes)
170
+
171
+ permitted = policy.permitted_scopes(current_user)
172
+ return ["*"] unless permitted.is_a?(Array)
173
+
174
+ permitted.map(&:to_s)
175
+ end
176
+
113
177
  # ------------------------------------------------------------------
114
178
  # Filtering: ?filter[status]=published&filter[user_id]=1
115
179
  # ------------------------------------------------------------------
@@ -125,6 +189,10 @@ module Rhino
125
189
  key = key.to_s
126
190
  next unless allowed.include?(key)
127
191
 
192
+ unless attribute_queryable?(key)
193
+ raise Rhino::QueryAttributeNotAllowedError, "Filter '#{key}' is not allowed"
194
+ end
195
+
128
196
  if value.to_s.include?(",")
129
197
  # Multiple values: OR condition
130
198
  values = value.to_s.split(",").map(&:strip)
@@ -146,7 +214,9 @@ module Rhino
146
214
  default = model_class.try(:default_sort_field)
147
215
  return unless default
148
216
 
149
- apply_sort_string(default)
217
+ # The default sort is the server's own choice, so it is not subject to the
218
+ # client allowlist or to the policy.
219
+ apply_sort_string(default, client_supplied: false)
150
220
  end
151
221
 
152
222
  def apply_sorts
@@ -156,7 +226,7 @@ module Rhino
156
226
  apply_sort_string(sort_param.to_s)
157
227
  end
158
228
 
159
- def apply_sort_string(sort_string)
229
+ def apply_sort_string(sort_string, client_supplied: true)
160
230
  allowed = model_class.try(:allowed_sorts) || []
161
231
 
162
232
  sort_string.split(",").each do |field|
@@ -169,12 +239,83 @@ module Rhino
169
239
  direction = :asc
170
240
  end
171
241
 
172
- next unless allowed.empty? || allowed.include?(column)
242
+ if client_supplied
243
+ # Deny by default: an undeclared column is ignored, never sorted by.
244
+ next unless allowed.include?(column)
245
+
246
+ unless attribute_queryable?(column)
247
+ raise Rhino::QueryAttributeNotAllowedError, "Sort '#{column}' is not allowed"
248
+ end
249
+ end
173
250
 
174
251
  @scope = @scope.order(column => direction)
175
252
  end
176
253
  end
177
254
 
255
+ # ------------------------------------------------------------------
256
+ # Policy-aware attribute gate
257
+ # ------------------------------------------------------------------
258
+ #
259
+ # Attribute permissions used to apply only when serializing, so a hidden
260
+ # column stayed usable as a query predicate: ?filter[salary]=300000 never
261
+ # printed a salary but told the caller whose salary it was, and ?sort= leaked
262
+ # the whole ordering. Filters, sorts and search now go through the same gate
263
+ # as the response body.
264
+
265
+ def attribute_queryable?(name)
266
+ return true if name.nil? || name.to_s.empty?
267
+
268
+ attribute_path_allowed?(base_class, name.to_s)
269
+ end
270
+
271
+ def attribute_path_allowed?(klass, path)
272
+ if path.include?(".")
273
+ relation, rest = path.split(".", 2)
274
+ assoc = klass.respond_to?(:reflect_on_association) ? klass.reflect_on_association(relation.to_sym) : nil
275
+ # An unresolvable relation is left alone, so nothing that worked before
276
+ # starts failing for a reason nobody can find.
277
+ return true if assoc.nil?
278
+
279
+ begin
280
+ return attribute_path_allowed?(assoc.klass, rest)
281
+ rescue NoMethodError, NameError
282
+ return true
283
+ end
284
+ end
285
+
286
+ policy = policy_instance(klass)
287
+ user = current_user
288
+
289
+ if policy.respond_to?(:hidden_attributes_for_show)
290
+ return false if Array(policy.hidden_attributes_for_show(user)).map(&:to_s).include?(path)
291
+ end
292
+
293
+ return true unless policy.respond_to?(:permitted_attributes_for_show)
294
+
295
+ permitted = Array(policy.permitted_attributes_for_show(user)).map(&:to_s)
296
+ permitted == ["*"] || permitted.include?(path)
297
+ end
298
+
299
+ # The model class behind the builder: +model_class+ may be a relation, as it
300
+ # is for the trashed listing.
301
+ def base_class
302
+ @base_class ||= model_class.respond_to?(:klass) ? model_class.klass : model_class
303
+ end
304
+
305
+ def policy_instance(klass = base_class)
306
+ @policy_instances ||= {}
307
+ @policy_instances[klass] ||= begin
308
+ policy_class = "#{klass.name}Policy".safe_constantize || Rhino::ResourcePolicy
309
+ policy_class.new(current_user, klass)
310
+ rescue StandardError
311
+ Rhino::ResourcePolicy.new(current_user, klass)
312
+ end
313
+ end
314
+
315
+ def current_user
316
+ defined?(RequestStore) ? RequestStore.store[:rhino_current_user] : nil
317
+ end
318
+
178
319
  # ------------------------------------------------------------------
179
320
  # Search: ?search=term
180
321
  # ------------------------------------------------------------------
@@ -183,8 +324,18 @@ module Rhino
183
324
  search_term = params[:search]
184
325
  return unless search_term.present?
185
326
 
186
- columns = model_class.try(:allowed_search) || []
187
- return if columns.empty?
327
+ declared = model_class.try(:allowed_search) || []
328
+ return if declared.empty?
329
+
330
+ columns = declared.select { |column| attribute_queryable?(column.to_s) }
331
+
332
+ # Every searchable column is hidden from this user. Searching a hidden
333
+ # column tells the caller what is in it, so return nothing rather than
334
+ # silently returning the whole list the client asked to narrow.
335
+ if columns.empty?
336
+ @scope = @scope.none
337
+ return
338
+ end
188
339
 
189
340
  term = "%#{search_term.to_s.downcase}%"
190
341
  conditions = []
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ # Parses the +rhino_scopes+ declaration and binds the arguments a client sent
5
+ # for <tt>?scope[name][param]=value</tt> to the scope's parameters.
6
+ #
7
+ # Declaration forms (all may be mixed in one call):
8
+ #
9
+ # rhino_scopes :archived, # no parameters
10
+ # since: { params: [:date] }, # one parameter
11
+ # window: { params: %i[min max] }, # two, both required
12
+ # titled: { params: %i[title status], optional: [:status] },
13
+ # mine: ->(relation, user) { ... }, # legacy proc
14
+ # fresh: Scopes::FreshScope # legacy scope class
15
+ #
16
+ # A scope with no declared parameters never receives arguments: sending any
17
+ # is a 403, so a scope written without client input can never be handed some.
18
+ module ScopeSpec
19
+ module_function
20
+
21
+ # Normalize a raw +allowed_scopes+ hash into
22
+ # <tt>name => { target:, params:, optional: }</tt>.
23
+ def normalize(declared)
24
+ (declared || {}).each_with_object({}) do |(name, value), out|
25
+ key = name.to_s
26
+ out[key] = normalize_entry(key, value)
27
+ end
28
+ end
29
+
30
+ def normalize_entry(name, value)
31
+ if value.is_a?(Hash) || value.is_a?(ActiveSupport::HashWithIndifferentAccess)
32
+ spec = value.symbolize_keys
33
+ params = Array(spec[:params]).map(&:to_s)
34
+ optional = Array(spec[:optional]).map(&:to_s) & params
35
+
36
+ { target: spec[:with] || name.to_sym, params: params, optional: optional }
37
+ else
38
+ { target: value, params: [], optional: [] }
39
+ end
40
+ end
41
+
42
+ # Bind the raw value a client sent for one scope to positional arguments,
43
+ # in the order the model declared them.
44
+ #
45
+ # +raw+ is whatever the query string produced for <tt>scope[<name>]</tt>:
46
+ # nil or "" (no arguments), a scalar (the single parameter), or a hash of
47
+ # parameter name => value.
48
+ #
49
+ # Raises Rhino::InvalidScopeArgumentsError.
50
+ def bind(name, spec, raw)
51
+ params = spec[:params]
52
+ given = normalize_raw_arguments(name, params, raw)
53
+
54
+ given.each_key do |key|
55
+ unless params.include?(key)
56
+ raise Rhino::InvalidScopeArgumentsError, "Scope '#{name}' does not accept parameter '#{key}'"
57
+ end
58
+ end
59
+
60
+ args = params.map do |param|
61
+ if given.key?(param)
62
+ coerce(given[param])
63
+ elsif spec[:optional].include?(param)
64
+ nil
65
+ else
66
+ raise Rhino::InvalidScopeArgumentsError, "Scope '#{name}' requires parameter '#{param}'"
67
+ end
68
+ end
69
+
70
+ # Drop trailing nils so an omitted optional parameter falls back to the
71
+ # default in the scope's own signature.
72
+ args.pop while args.any? && args.last.nil?
73
+ args
74
+ end
75
+
76
+ def normalize_raw_arguments(name, params, raw)
77
+ # ?scope[archived]= (or a bare ?scope[archived]): no arguments. A scope
78
+ # with required parameters still fails, in bind, naming them.
79
+ return {} if raw.nil? || raw == ""
80
+
81
+ raw = raw.to_unsafe_h if raw.respond_to?(:to_unsafe_h)
82
+
83
+ if raw.is_a?(Array)
84
+ # A positional list (scope[between][]=a) names nothing.
85
+ raise Rhino::InvalidScopeArgumentsError, "Scope '#{name}' requires named parameters"
86
+ end
87
+
88
+ unless raw.is_a?(Hash)
89
+ raise Rhino::InvalidScopeArgumentsError, "Scope '#{name}' does not accept arguments" if params.empty?
90
+
91
+ # A bare value binds to the single declared parameter. Two parameters can
92
+ # never be guessed at from one value.
93
+ if params.length > 1
94
+ raise Rhino::InvalidScopeArgumentsError, "Scope '#{name}' requires named parameters"
95
+ end
96
+
97
+ return { params.first => raw }
98
+ end
99
+
100
+ raise Rhino::InvalidScopeArgumentsError, "Scope '#{name}' does not accept arguments" if params.empty?
101
+
102
+ raw.each_with_object({}) do |(key, value), out|
103
+ unless value.is_a?(String) || value.is_a?(Numeric) || value.is_a?(TrueClass) ||
104
+ value.is_a?(FalseClass) || value.nil?
105
+ raise Rhino::InvalidScopeArgumentsError, "Scope '#{name}' requires named parameters"
106
+ end
107
+
108
+ out[key.to_s.underscore] = value
109
+ end
110
+ end
111
+
112
+ # Query-string values always arrive as strings; hand scope bodies real
113
+ # booleans so a check cannot be fooled by the string "false".
114
+ def coerce(value)
115
+ return value unless value.is_a?(String)
116
+
117
+ case value.downcase
118
+ when "true" then true
119
+ when "false" then false
120
+ else value
121
+ end
122
+ end
123
+ end
124
+ end
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.7.3"
4
+ VERSION = "4.8.1"
5
5
  end
data/lib/rhino.rb CHANGED
@@ -4,6 +4,8 @@ require "rhino/version"
4
4
  require "rhino/configuration"
5
5
  require "rhino/auth_rejected"
6
6
  require "rhino/scope_not_allowed_error"
7
+ require "rhino/invalid_scope_arguments_error"
8
+ require "rhino/scope_spec"
7
9
  require "rhino/missing_tenant_context"
8
10
  require "rhino/auth_hooks"
9
11
  require "rhino/group_membership"
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.7.3
4
+ version: 4.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Cipolla
@@ -223,6 +223,7 @@ files:
223
223
  - lib/rhino/controllers/resources_controller.rb
224
224
  - lib/rhino/engine.rb
225
225
  - lib/rhino/group_membership.rb
226
+ - lib/rhino/invalid_scope_arguments_error.rb
226
227
  - lib/rhino/mailers/invitation_mailer.rb
227
228
  - lib/rhino/middleware/resolve_organization_from_route.rb
228
229
  - lib/rhino/missing_tenant_context.rb
@@ -240,6 +241,7 @@ files:
240
241
  - lib/rhino/routing/domain_constraint.rb
241
242
  - lib/rhino/routing/route_group_validator.rb
242
243
  - lib/rhino/scope_not_allowed_error.rb
244
+ - lib/rhino/scope_spec.rb
243
245
  - lib/rhino/scopes_to_organization.rb
244
246
  - lib/rhino/tasks/rhino.rake
245
247
  - lib/rhino/templates/audit_trail/create_audit_logs.rb.erb