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 +4 -4
- data/README.md +2 -2
- data/lib/rhino/concerns/has_rhino.rb +16 -0
- data/lib/rhino/context.rb +37 -7
- data/lib/rhino/controllers/resources_controller.rb +12 -1
- data/lib/rhino/invalid_scope_arguments_error.rb +14 -0
- data/lib/rhino/policies/resource_policy.rb +18 -0
- data/lib/rhino/query.rb +50 -19
- data/lib/rhino/query_builder.rb +161 -20
- data/lib/rhino/scope_spec.rb +124 -0
- data/lib/rhino/templates/rhino.rb +8 -2
- data/lib/rhino/version.rb +1 -1
- data/lib/rhino.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9e5e5a25c72952be8b915169fd2f5320476a1414b20aba43428c636e92513188
|
|
4
|
+
data.tar.gz: 48af3696d7a56d92fc1e9f7e270077f108579bf1e2701542e563d6a75f94dd4e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
34
|
-
#
|
|
35
|
-
#
|
|
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
|
-
|
|
41
|
+
value = store[:rhino_route_group]
|
|
42
|
+
value = RequestStore.store[:rhino_route_group] if value.nil? && defined?(RequestStore)
|
|
38
43
|
|
|
39
|
-
value
|
|
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
|
-
|
|
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
|
|
25
|
-
# (`tenant: false`),
|
|
26
|
-
#
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
100
|
+
target.call(relation, user, *args)
|
|
83
101
|
else
|
|
84
|
-
|
|
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
|
data/lib/rhino/query_builder.rb
CHANGED
|
@@ -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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
131
|
+
run_named_scope(name, entry, Rhino::ScopeSpec.bind(wire_name.to_s, entry, raw_arguments))
|
|
132
|
+
end
|
|
133
|
+
end
|
|
97
134
|
|
|
98
|
-
|
|
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
|
|
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(
|
|
146
|
+
@scope.merge(model_class.public_send(target, *args))
|
|
106
147
|
when Proc
|
|
107
|
-
|
|
148
|
+
target.call(@scope, user, *args)
|
|
108
149
|
else
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
return if
|
|
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
|
|
62
|
-
#
|
|
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
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.
|
|
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
|