rhino-rails 4.7.3 → 4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7f50b9ced877e5d2fc1b304798f7c2c6372e89754ea1c7c9efd296c8f478c33
4
- data.tar.gz: cca1fbd5f280df7e94a1fa9a69c1ca338fdfff6b76d35158afdc16fd07ef4309
3
+ metadata.gz: 9e5e5a25c72952be8b915169fd2f5320476a1414b20aba43428c636e92513188
4
+ data.tar.gz: 48af3696d7a56d92fc1e9f7e270077f108579bf1e2701542e563d6a75f94dd4e
5
5
  SHA512:
6
- metadata.gz: 43e7ae4d3e346c984ab8452c8dbc2f3ceb7199f35f8e3eba3f971931aed8539e752f9744393f2429125cb94b6d092358c070d719b634fa95b0cd9e314ea362e1
7
- data.tar.gz: d03e920444bfcdfc0c42ddfa0d494ac180aae4fc499acd852aafc1ca6486aeccaecdff54decf4e64f65e21e5c7cb0f078ea81b3426518d7495b7e8bc3a4bc7de
6
+ metadata.gz: '099ea8dc9ecea149b1fd30cd56446fb107d474478cb669d39dbc1bb2d01a16a1669160f6912658da035fa9dc62e26a4639962ad9f661848919ca3b15178f59da'
7
+ data.tar.gz: d9423c888d912f451312d5c0b3243dd1a1b43493ee4fb60378fafa17127c9f22f445cd0161197492e08543ce6946d2202cfef1a8891f7847e5677374f96c7991
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 }
@@ -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,11 @@ module Rhino
12
12
  # - Fields: ?fields[posts]=id,title,status
13
13
  # - Includes: ?include=user,comments
14
14
  class QueryBuilder
15
+ # How many named scopes one request may combine. Scopes are arbitrary query
16
+ # fragments, so stacking many of them is a good way to build an accidental
17
+ # cross join; three covers every real listing.
18
+ MAX_SCOPES_PER_REQUEST = 3
19
+
15
20
  attr_reader :scope, :model_class, :params
16
21
 
17
22
  def initialize(model_class, params: {}, named_scopes: false)
@@ -83,33 +88,82 @@ module Rhino
83
88
  # +named_scopes: true+. `show` (including its ?include= build path) stays
84
89
  # unscoped so a record excluded by the default scope is still viewable.
85
90
  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
91
+ raw = params[:scope]
92
+ raw = raw.to_unsafe_h if raw.respond_to?(:to_unsafe_h)
93
+
94
+ declared = Rhino::ScopeSpec.normalize(model_class.try(:allowed_scopes))
95
+ default = model_class.try(:default_rhino_scope)
96
+
97
+ # Nothing requested: the model's default scope, which takes no arguments.
98
+ if raw.nil? || raw == "" || raw == {}
99
+ return if default.nil?
100
+
101
+ return run_named_scope(default.to_s, declared[default.to_s] || {}, [])
102
+ end
103
+
104
+ requested =
105
+ if raw.is_a?(Hash)
106
+ raw
107
+ else
108
+ # Legacy form — ?scope=name, one scope, no arguments.
109
+ { raw.to_s => "" }
110
+ end
111
+
112
+ raise Rhino::ScopeNotAllowedError, "Too many scopes requested" if requested.size > MAX_SCOPES_PER_REQUEST
113
+
114
+ permitted = permitted_scope_names
115
+
116
+ requested.each do |wire_name, raw_arguments|
117
+ raise Rhino::ScopeNotAllowedError, wire_name.to_s if wire_name.to_s.empty?
118
+
119
+ name = wire_name.to_s.underscore
120
+ entry = declared[name]
121
+ # The default scope is implicitly allowed when requested by name.
122
+ entry ||= { target: name.to_sym, params: [], optional: [] } if name == default
123
+
124
+ # Echo the client's wire name (not the underscored form) in the error.
125
+ raise Rhino::ScopeNotAllowedError, wire_name.to_s if entry.nil?
89
126
 
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)
127
+ if permitted != ["*"] && !permitted.include?(name)
128
+ raise Rhino::ScopeNotAllowedError, wire_name.to_s
129
+ end
94
130
 
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?
131
+ run_named_scope(name, entry, Rhino::ScopeSpec.bind(wire_name.to_s, entry, raw_arguments))
132
+ end
133
+ end
97
134
 
98
- user = defined?(RequestStore) ? RequestStore.store[:rhino_current_user] : nil
135
+ # Run one already-authorized named scope, passing the bound arguments in the
136
+ # order the model declared them.
137
+ def run_named_scope(name, entry, args)
138
+ target = entry[:target] || name.to_sym
139
+ user = current_user
99
140
 
100
141
  @scope =
101
- case entry
102
- when Symbol
142
+ case target
143
+ when Symbol, String
103
144
  # Whitelisted AR scope. Client input never reaches public_send unless the
104
145
  # developer declared it via rhino_scopes. .merge composes with default_scopes.
105
- @scope.merge(model_class.public_send(entry))
146
+ @scope.merge(model_class.public_send(target, *args))
106
147
  when Proc
107
- entry.call(@scope, user)
148
+ target.call(@scope, user, *args)
108
149
  else
109
- entry.new.apply(@scope) # Rhino::ResourceScope subclass (user/org/role helpers)
150
+ target.new.apply(@scope, *args) # Rhino::ResourceScope subclass (user/org/role helpers)
110
151
  end
111
152
  end
112
153
 
154
+ # Scope names this user may select, or ["*"] when the policy does not
155
+ # restrict them (the default, and the behavior of every policy written
156
+ # before permitted_scopes existed).
157
+ def permitted_scope_names
158
+ policy = policy_instance
159
+ return ["*"] unless policy.respond_to?(:permitted_scopes)
160
+
161
+ permitted = policy.permitted_scopes(current_user)
162
+ return ["*"] unless permitted.is_a?(Array)
163
+
164
+ permitted.map(&:to_s)
165
+ end
166
+
113
167
  # ------------------------------------------------------------------
114
168
  # Filtering: ?filter[status]=published&filter[user_id]=1
115
169
  # ------------------------------------------------------------------
@@ -125,6 +179,10 @@ module Rhino
125
179
  key = key.to_s
126
180
  next unless allowed.include?(key)
127
181
 
182
+ unless attribute_queryable?(key)
183
+ raise Rhino::QueryAttributeNotAllowedError, "Filter '#{key}' is not allowed"
184
+ end
185
+
128
186
  if value.to_s.include?(",")
129
187
  # Multiple values: OR condition
130
188
  values = value.to_s.split(",").map(&:strip)
@@ -146,7 +204,9 @@ module Rhino
146
204
  default = model_class.try(:default_sort_field)
147
205
  return unless default
148
206
 
149
- apply_sort_string(default)
207
+ # The default sort is the server's own choice, so it is not subject to the
208
+ # client allowlist or to the policy.
209
+ apply_sort_string(default, client_supplied: false)
150
210
  end
151
211
 
152
212
  def apply_sorts
@@ -156,7 +216,7 @@ module Rhino
156
216
  apply_sort_string(sort_param.to_s)
157
217
  end
158
218
 
159
- def apply_sort_string(sort_string)
219
+ def apply_sort_string(sort_string, client_supplied: true)
160
220
  allowed = model_class.try(:allowed_sorts) || []
161
221
 
162
222
  sort_string.split(",").each do |field|
@@ -169,12 +229,83 @@ module Rhino
169
229
  direction = :asc
170
230
  end
171
231
 
172
- next unless allowed.empty? || allowed.include?(column)
232
+ if client_supplied
233
+ # Deny by default: an undeclared column is ignored, never sorted by.
234
+ next unless allowed.include?(column)
235
+
236
+ unless attribute_queryable?(column)
237
+ raise Rhino::QueryAttributeNotAllowedError, "Sort '#{column}' is not allowed"
238
+ end
239
+ end
173
240
 
174
241
  @scope = @scope.order(column => direction)
175
242
  end
176
243
  end
177
244
 
245
+ # ------------------------------------------------------------------
246
+ # Policy-aware attribute gate
247
+ # ------------------------------------------------------------------
248
+ #
249
+ # Attribute permissions used to apply only when serializing, so a hidden
250
+ # column stayed usable as a query predicate: ?filter[salary]=300000 never
251
+ # printed a salary but told the caller whose salary it was, and ?sort= leaked
252
+ # the whole ordering. Filters, sorts and search now go through the same gate
253
+ # as the response body.
254
+
255
+ def attribute_queryable?(name)
256
+ return true if name.nil? || name.to_s.empty?
257
+
258
+ attribute_path_allowed?(base_class, name.to_s)
259
+ end
260
+
261
+ def attribute_path_allowed?(klass, path)
262
+ if path.include?(".")
263
+ relation, rest = path.split(".", 2)
264
+ assoc = klass.respond_to?(:reflect_on_association) ? klass.reflect_on_association(relation.to_sym) : nil
265
+ # An unresolvable relation is left alone, so nothing that worked before
266
+ # starts failing for a reason nobody can find.
267
+ return true if assoc.nil?
268
+
269
+ begin
270
+ return attribute_path_allowed?(assoc.klass, rest)
271
+ rescue NoMethodError, NameError
272
+ return true
273
+ end
274
+ end
275
+
276
+ policy = policy_instance(klass)
277
+ user = current_user
278
+
279
+ if policy.respond_to?(:hidden_attributes_for_show)
280
+ return false if Array(policy.hidden_attributes_for_show(user)).map(&:to_s).include?(path)
281
+ end
282
+
283
+ return true unless policy.respond_to?(:permitted_attributes_for_show)
284
+
285
+ permitted = Array(policy.permitted_attributes_for_show(user)).map(&:to_s)
286
+ permitted == ["*"] || permitted.include?(path)
287
+ end
288
+
289
+ # The model class behind the builder: +model_class+ may be a relation, as it
290
+ # is for the trashed listing.
291
+ def base_class
292
+ @base_class ||= model_class.respond_to?(:klass) ? model_class.klass : model_class
293
+ end
294
+
295
+ def policy_instance(klass = base_class)
296
+ @policy_instances ||= {}
297
+ @policy_instances[klass] ||= begin
298
+ policy_class = "#{klass.name}Policy".safe_constantize || Rhino::ResourcePolicy
299
+ policy_class.new(current_user, klass)
300
+ rescue StandardError
301
+ Rhino::ResourcePolicy.new(current_user, klass)
302
+ end
303
+ end
304
+
305
+ def current_user
306
+ defined?(RequestStore) ? RequestStore.store[:rhino_current_user] : nil
307
+ end
308
+
178
309
  # ------------------------------------------------------------------
179
310
  # Search: ?search=term
180
311
  # ------------------------------------------------------------------
@@ -183,8 +314,18 @@ module Rhino
183
314
  search_term = params[:search]
184
315
  return unless search_term.present?
185
316
 
186
- columns = model_class.try(:allowed_search) || []
187
- return if columns.empty?
317
+ declared = model_class.try(:allowed_search) || []
318
+ return if declared.empty?
319
+
320
+ columns = declared.select { |column| attribute_queryable?(column.to_s) }
321
+
322
+ # Every searchable column is hidden from this user. Searching a hidden
323
+ # column tells the caller what is in it, so return nothing rather than
324
+ # silently returning the whole list the client asked to narrow.
325
+ if columns.empty?
326
+ @scope = @scope.none
327
+ return
328
+ end
188
329
 
189
330
  term = "%#{search_term.to_s.downcase}%"
190
331
  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.0"
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.0
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