rhino-rails 4.7.2 → 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: 632861555ee3e2508a90ec546b6cb0412368bc141a7d387b2f92698531605b86
4
- data.tar.gz: 91b3ad79b1c0b0b492f1f3003e906ce11e9f3fa62e81b067ecfb9c0b7740a025
3
+ metadata.gz: 9e5e5a25c72952be8b915169fd2f5320476a1414b20aba43428c636e92513188
4
+ data.tar.gz: 48af3696d7a56d92fc1e9f7e270077f108579bf1e2701542e563d6a75f94dd4e
5
5
  SHA512:
6
- metadata.gz: 2c5fee208f22ae88b0531f4075e6183d6d0a17137c43423e7f491dbf341848764ce16418feca131b13c45c4295f6b4a3507eebb52e1e30b2f887dcbcfdd624c1
7
- data.tar.gz: 83468b5afe671a2b0313778e57c2f053747129156d643bb85d5543513ea2d02aa6c2b63c4b70bb5f04613cb79bbb21544ba142bb4f127ebf56247581aab4c079
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 }
data/lib/rhino/context.rb CHANGED
@@ -30,20 +30,24 @@ 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.
33
+ # The active route group: the explicit override if one is in effect (set by
34
+ # Rhino.in_route_group), else the group stashed into RequestStore by Rhino's
35
+ # own controllers and by Rhino::RouteGroupContext in custom ones.
36
+ #
37
+ # Unlike user/organization, an override never *erases* the request's group —
38
+ # +with+ only installs a non-nil one — so Rhino.for_user(u).query(M) inside a
39
+ # non-tenant request keeps that request's group.
36
40
  def route_group
37
- return nil unless defined?(RequestStore)
41
+ value = store[:rhino_route_group]
42
+ value = RequestStore.store[:rhino_route_group] if value.nil? && defined?(RequestStore)
38
43
 
39
- value = RequestStore.store[:rhino_route_group]
40
- value.nil? || value.to_s.empty? ? nil : value
44
+ value.nil? || value.to_s.empty? ? nil : value.to_s
41
45
  end
42
46
 
43
47
  # Run +block+ with the given user/organization installed into RequestStore.
44
48
  # Snapshots the prior RequestStore user+org, sets the new ones, yields, and
45
49
  # restores the snapshot in an ensure. Returns the block's value.
46
- def with(user:, organization:)
50
+ def with(user:, organization:, route_group: nil)
47
51
  return yield unless defined?(RequestStore)
48
52
 
49
53
  had_user = RequestStore.store.key?(:rhino_current_user)
@@ -51,6 +55,13 @@ module Rhino
51
55
  prev_user = RequestStore.store[:rhino_current_user]
52
56
  prev_org = RequestStore.store[:rhino_organization]
53
57
 
58
+ # A route group is only ever ADDED, never cleared: with no explicit group
59
+ # the request's own one (if any) stays in effect.
60
+ had_group = RequestStore.store.key?(:rhino_route_group)
61
+ prev_group = RequestStore.store[:rhino_route_group]
62
+ had_override_group = store.key?(:rhino_route_group)
63
+ prev_override_group = store[:rhino_route_group]
64
+
54
65
  # Track the active override so Context.user/organization prefer it even when
55
66
  # the passed value is nil (distinguishing "explicitly nil" from "absent").
56
67
  had_override_user = store.key?(:rhino_current_user)
@@ -63,6 +74,11 @@ module Rhino
63
74
  store[:rhino_current_user] = user
64
75
  store[:rhino_organization] = organization
65
76
 
77
+ if route_group
78
+ RequestStore.store[:rhino_route_group] = route_group.to_s
79
+ store[:rhino_route_group] = route_group.to_s
80
+ end
81
+
66
82
  begin
67
83
  yield
68
84
  ensure
@@ -87,6 +103,20 @@ module Rhino
87
103
  else
88
104
  store.delete(:rhino_organization)
89
105
  end
106
+
107
+ if route_group
108
+ if had_group
109
+ RequestStore.store[:rhino_route_group] = prev_group
110
+ else
111
+ RequestStore.store.delete(:rhino_route_group)
112
+ end
113
+
114
+ if had_override_group
115
+ store[:rhino_route_group] = prev_override_group
116
+ else
117
+ store.delete(:rhino_route_group)
118
+ end
119
+ end
90
120
  end
91
121
  end
92
122
 
@@ -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
@@ -21,9 +21,10 @@ module Rhino
21
21
  #
22
22
  # Fail closed: an org-scopable model with no org context RAISES
23
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
+ # unless the query belongs to a route group declared non-tenant
25
+ # (`tenant: false`), either because the request is served by that group or
26
+ # because the caller said so with Rhino.in_route_group(...). An explicit
27
+ # organization is always honored, in every group.
27
28
  def query(model_class)
28
29
  org = Rhino::Context.organization
29
30
 
@@ -44,8 +45,8 @@ module Rhino
44
45
  # Build a tenant-scoped relation and apply a whitelisted ?scope= named scope
45
46
  # on top of it. +scope_name+ is the wire name (camelCase accepted); nil falls
46
47
  # back to the model's rhino_default_scope.
47
- def scoped_query(model_class, scope_name = nil)
48
- 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)
49
50
  end
50
51
 
51
52
  # Begin the fluent explicit builder for +user+.
@@ -53,6 +54,20 @@ module Rhino
53
54
  Rhino::PendingScopedContext.new(user: user)
54
55
  end
55
56
 
57
+ # Begin an explicit context that acts as the named route group, for use
58
+ # where no request resolves one — an Active Job, a rake task, the console,
59
+ # a test.
60
+ #
61
+ # The group's own configuration still decides the boundary: naming a group
62
+ # declared +tenant: false+ lets the query span every organization, while
63
+ # naming any other group keeps failing closed.
64
+ #
65
+ # Rhino.in_route_group(:admin).query(Task)
66
+ # Rhino.for_user(user).in_route_group(:admin).run { ... }
67
+ def in_route_group(route_group)
68
+ Rhino::PendingScopedContext.new(user: nil, route_group: route_group)
69
+ end
70
+
56
71
  # The ambient context resolver.
57
72
  def context
58
73
  Rhino::Context
@@ -62,26 +77,29 @@ module Rhino
62
77
  # Shared by Rhino.scoped_query and PendingScopedContext#scoped_query. Uses the
63
78
  # same allowed_scopes / default_rhino_scope mechanism as the QueryBuilder.
64
79
  # @api private
65
- def apply_named_scope(relation, model_class, scope_name = nil)
80
+ def apply_named_scope(relation, model_class, scope_name = nil, *args)
66
81
  requested = scope_name.to_s.presence
67
82
  name = requested ? requested.underscore : model_class.try(:default_rhino_scope)
68
83
  return relation unless name
69
84
 
70
- allowed = model_class.try(:allowed_scopes) || {}
85
+ allowed = Rhino::ScopeSpec.normalize(model_class.try(:allowed_scopes))
71
86
  entry = allowed[name]
72
- 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)
73
88
 
74
89
  raise Rhino::ScopeNotAllowedError, (requested || name) if entry.nil?
75
90
 
76
91
  user = defined?(RequestStore) ? RequestStore.store[:rhino_current_user] : nil
92
+ target = entry[:target] || name.to_sym
77
93
 
78
- case entry
79
- when Symbol
80
- 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))
81
99
  when Proc
82
- entry.call(relation, user)
100
+ target.call(relation, user, *args)
83
101
  else
84
- entry.new.apply(relation)
102
+ target.new.apply(relation, *args)
85
103
  end
86
104
  end
87
105
  end
@@ -89,9 +107,10 @@ module Rhino
89
107
  # Fluent explicit-context builder. Holds a user (and, once chained, an org) and
90
108
  # resolves queries with that context installed into RequestStore at build time.
91
109
  class PendingScopedContext
92
- def initialize(user:, organization: nil)
110
+ def initialize(user:, organization: nil, route_group: nil)
93
111
  @user = user
94
112
  @organization = organization
113
+ @route_group = route_group
95
114
  end
96
115
 
97
116
  def in_organization(organization)
@@ -99,6 +118,18 @@ module Rhino
99
118
  self
100
119
  end
101
120
 
121
+ # Act as the named route group for this context (see Rhino.in_route_group).
122
+ def in_route_group(route_group)
123
+ @route_group = route_group
124
+ self
125
+ end
126
+
127
+ # Set the explicit user for this context.
128
+ def for_user(user)
129
+ @user = user
130
+ self
131
+ end
132
+
102
133
  # Build a fully-baked relation for +model_class+ under this explicit context.
103
134
  #
104
135
  # Because Rails default_scopes bake at BUILD time, we install the user+org into
@@ -106,15 +137,15 @@ module Rhino
106
137
  # The org+user are baked into the returned relation — no stickiness, fully
107
138
  # isolated: a later Rhino.query with no context still fails closed.
108
139
  def query(model_class)
109
- Rhino::Context.with(user: @user, organization: @organization) do
140
+ Rhino::Context.with(user: @user, organization: @organization, route_group: @route_group) do
110
141
  Rhino.query(model_class)
111
142
  end
112
143
  end
113
144
 
114
145
  # Build a fully-baked, named-scoped relation under this explicit context.
115
- def scoped_query(model_class, scope_name = nil)
116
- Rhino::Context.with(user: @user, organization: @organization) do
117
- Rhino.scoped_query(model_class, scope_name)
146
+ def scoped_query(model_class, scope_name = nil, *args)
147
+ Rhino::Context.with(user: @user, organization: @organization, route_group: @route_group) do
148
+ Rhino.scoped_query(model_class, scope_name, *args)
118
149
  end
119
150
  end
120
151
 
@@ -122,7 +153,7 @@ module Rhino
122
153
  # inside the block see the context; RequestStore is restored afterward.
123
154
  # Returns the block's value.
124
155
  def run(&block)
125
- Rhino::Context.with(user: @user, organization: @organization, &block)
156
+ Rhino::Context.with(user: @user, organization: @organization, route_group: @route_group, &block)
126
157
  end
127
158
  end
128
159
  end
@@ -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
@@ -58,8 +58,14 @@ Rhino.configure do |config|
58
58
  # Custom (non-Rhino) controllers publish their group by including
59
59
  # Rhino::RouteGroupContext and declaring `rhino_route_group :admin`, or by
60
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.
61
+ # request (jobs, rake tasks, console) no group resolves, so the caller names
62
+ # the one it is acting as:
63
+ #
64
+ # Rhino.in_route_group(:admin).query(Task)
65
+ # Rhino.for_user(operator).in_route_group(:admin).run { ... }
66
+ #
67
+ # The group's own `tenant:` still decides: naming a tenant group there changes
68
+ # nothing, the query still fails closed.
63
69
 
64
70
  # config.route_group :default, prefix: '', middleware: [], models: :all
65
71
 
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.2"
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.2
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