rhino-rails 4.4.0 → 4.6.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 +1 -0
- data/lib/rhino/blueprint/blueprint_parser.rb +2 -1
- data/lib/rhino/blueprint/blueprint_validator.rb +25 -3
- data/lib/rhino/blueprint/generators/test_generator.rb +16 -13
- data/lib/rhino/commands/blueprint_command.rb +1 -0
- data/lib/rhino/concerns/has_rhino.rb +32 -0
- data/lib/rhino/concerns/hidable_columns.rb +11 -0
- data/lib/rhino/configuration.rb +6 -0
- data/lib/rhino/context.rb +88 -0
- data/lib/rhino/controllers/resources_controller.rb +38 -120
- data/lib/rhino/missing_tenant_context.rb +8 -0
- data/lib/rhino/models/rhino_model.rb +24 -0
- data/lib/rhino/query.rb +123 -0
- data/lib/rhino/query_builder.rb +6 -0
- data/lib/rhino/scopes_to_organization.rb +181 -0
- data/lib/rhino/templates/generate/model.rb.erb +2 -0
- data/lib/rhino/templates/rhino.rb +11 -0
- data/lib/rhino/templates/rhino_model.rb +9 -0
- data/lib/rhino/version.rb +1 -1
- data/lib/rhino.rb +4 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3c800a5e70d40849b886fdd638cc4214fa4bd782965de5a94394a6fa0319cb38
|
|
4
|
+
data.tar.gz: e967104d44d640b9645f579022f14831d36150e763497e25ecfbd2b391ec76b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 717d3ad2ef05fe9ead1918baa1d52d61e865f0b3cd95925173df147ae41ba66786a88971eeed21ef5729103d07312c4b3fdb0ffd5ed2a398e140e3abeebca670
|
|
7
|
+
data.tar.gz: 0e20f9f6ee200c2643c691c4bac35a6acc6eb091103539b3271133c67f4093bae017bc5ce3b0955b2966ef1be1c688e30c4c17e28dd16566116e9beab5bee34e
|
data/README.md
CHANGED
|
@@ -41,6 +41,7 @@ Register a model, get a full REST API instantly.
|
|
|
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
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. |
|
|
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. |
|
|
44
45
|
|
|
45
46
|
## Quick Start
|
|
46
47
|
|
|
@@ -96,7 +96,8 @@ module Rhino
|
|
|
96
96
|
owner: options.fetch("owner", nil),
|
|
97
97
|
except_actions: options.fetch("except_actions", []),
|
|
98
98
|
pagination: options.fetch("pagination", false),
|
|
99
|
-
per_page: options.fetch("per_page", 25)
|
|
99
|
+
per_page: options.fetch("per_page", 25),
|
|
100
|
+
route_key: options.fetch("route_key", nil)
|
|
100
101
|
}
|
|
101
102
|
end
|
|
102
103
|
|
|
@@ -65,7 +65,8 @@ module Rhino
|
|
|
65
65
|
warnings.concat(perm_result[:warnings])
|
|
66
66
|
|
|
67
67
|
# Options
|
|
68
|
-
errors.concat(validate_options(blueprint[:options]))
|
|
68
|
+
errors.concat(validate_options(blueprint[:options], column_names))
|
|
69
|
+
warnings.concat(route_key_warnings(blueprint[:options], blueprint[:columns]))
|
|
69
70
|
|
|
70
71
|
# Relationships
|
|
71
72
|
errors.concat(validate_relationships(blueprint[:relationships]))
|
|
@@ -156,8 +157,9 @@ module Rhino
|
|
|
156
157
|
{ errors: errors, warnings: warnings }
|
|
157
158
|
end
|
|
158
159
|
|
|
159
|
-
# Validate options.
|
|
160
|
-
|
|
160
|
+
# Validate options. When +column_names+ is provided, also validates that
|
|
161
|
+
# a configured route_key references a declared column (or 'id').
|
|
162
|
+
def validate_options(options, column_names = nil)
|
|
161
163
|
errors = []
|
|
162
164
|
|
|
163
165
|
if options[:except_actions]
|
|
@@ -168,6 +170,11 @@ module Rhino
|
|
|
168
170
|
end
|
|
169
171
|
end
|
|
170
172
|
|
|
173
|
+
route_key = options[:route_key]
|
|
174
|
+
if route_key && column_names && !(["id"] + column_names).include?(route_key.to_s)
|
|
175
|
+
errors << "route_key '#{route_key}' does not match any declared column"
|
|
176
|
+
end
|
|
177
|
+
|
|
171
178
|
errors
|
|
172
179
|
end
|
|
173
180
|
|
|
@@ -193,6 +200,21 @@ module Rhino
|
|
|
193
200
|
errors
|
|
194
201
|
end
|
|
195
202
|
|
|
203
|
+
# Warn when the route_key column is not marked unique — non-unique route
|
|
204
|
+
# keys make member-endpoint lookups ambiguous.
|
|
205
|
+
def route_key_warnings(options, columns)
|
|
206
|
+
warnings = []
|
|
207
|
+
route_key = options[:route_key]
|
|
208
|
+
return warnings unless route_key
|
|
209
|
+
|
|
210
|
+
column = columns.find { |c| c[:name] == route_key.to_s }
|
|
211
|
+
if column && !column[:unique]
|
|
212
|
+
warnings << "route_key column '#{route_key}' is not marked unique — member lookups may be ambiguous"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
warnings
|
|
216
|
+
end
|
|
217
|
+
|
|
196
218
|
private
|
|
197
219
|
|
|
198
220
|
def check_field_references(fields, column_names, role, field_key, warnings)
|
|
@@ -23,9 +23,12 @@ module Rhino
|
|
|
23
23
|
slug = blueprint[:slug]
|
|
24
24
|
permissions = blueprint[:permissions]
|
|
25
25
|
columns = blueprint[:columns]
|
|
26
|
+
# URL segment attribute for member endpoints: the configured route
|
|
27
|
+
# key, or the primary key (id) when none is set.
|
|
28
|
+
route_key = (blueprint[:options] || {})[:route_key] || "id"
|
|
26
29
|
factory_name = model_to_factory(model)
|
|
27
30
|
|
|
28
|
-
role_contexts = build_role_contexts(slug, factory_name, permissions, columns, is_multi_tenant, org_identifier)
|
|
31
|
+
role_contexts = build_role_contexts(slug, factory_name, permissions, columns, is_multi_tenant, org_identifier, route_key)
|
|
29
32
|
|
|
30
33
|
if is_multi_tenant
|
|
31
34
|
wrap_multi_tenant(model, slug, role_contexts, org_identifier)
|
|
@@ -49,7 +52,7 @@ module Rhino
|
|
|
49
52
|
model.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
|
|
50
53
|
end
|
|
51
54
|
|
|
52
|
-
def build_role_contexts(slug, factory_name, permissions, columns, is_multi_tenant, org_identifier)
|
|
55
|
+
def build_role_contexts(slug, factory_name, permissions, columns, is_multi_tenant, org_identifier, route_key = "id")
|
|
53
56
|
return "" if permissions.empty?
|
|
54
57
|
|
|
55
58
|
all_defined_actions = permissions.values.flat_map { |p| p[:actions] }.uniq & ALL_ACTIONS
|
|
@@ -66,18 +69,18 @@ module Rhino
|
|
|
66
69
|
|
|
67
70
|
# Individual allowed action tests
|
|
68
71
|
allowed.each do |action|
|
|
69
|
-
lines << build_single_action_test(slug, action, is_multi_tenant, org_identifier, true)
|
|
72
|
+
lines << build_single_action_test(slug, action, is_multi_tenant, org_identifier, true, route_key)
|
|
70
73
|
lines << ""
|
|
71
74
|
end
|
|
72
75
|
|
|
73
76
|
# Individual blocked action tests
|
|
74
77
|
blocked.each do |action|
|
|
75
|
-
lines << build_single_action_test(slug, action, is_multi_tenant, org_identifier, false)
|
|
78
|
+
lines << build_single_action_test(slug, action, is_multi_tenant, org_identifier, false, route_key)
|
|
76
79
|
lines << ""
|
|
77
80
|
end
|
|
78
81
|
|
|
79
82
|
# Field visibility tests
|
|
80
|
-
field_test = build_field_visibility_test(slug, role, perm, columns, is_multi_tenant, org_identifier)
|
|
83
|
+
field_test = build_field_visibility_test(slug, role, perm, columns, is_multi_tenant, org_identifier, route_key)
|
|
81
84
|
if field_test
|
|
82
85
|
lines << field_test
|
|
83
86
|
lines << ""
|
|
@@ -114,7 +117,7 @@ module Rhino
|
|
|
114
117
|
end
|
|
115
118
|
end
|
|
116
119
|
|
|
117
|
-
def build_single_action_test(slug, action, is_multi_tenant, org_identifier, expect_success)
|
|
120
|
+
def build_single_action_test(slug, action, is_multi_tenant, org_identifier, expect_success, route_key = "id")
|
|
118
121
|
id_actions = %w[show update destroy restore forceDelete]
|
|
119
122
|
needs_id = id_actions.include?(action)
|
|
120
123
|
needs_discard = %w[restore forceDelete].include?(action)
|
|
@@ -126,10 +129,10 @@ module Rhino
|
|
|
126
129
|
}
|
|
127
130
|
|
|
128
131
|
action_path_suffix = {
|
|
129
|
-
"index" => "", "show" => "/\#{record
|
|
130
|
-
"update" => "/\#{record
|
|
131
|
-
"trashed" => "/trashed", "restore" => "/\#{record
|
|
132
|
-
"forceDelete" => "/\#{record
|
|
132
|
+
"index" => "", "show" => "/\#{record.#{route_key}}", "store" => "",
|
|
133
|
+
"update" => "/\#{record.#{route_key}}", "destroy" => "/\#{record.#{route_key}}",
|
|
134
|
+
"trashed" => "/trashed", "restore" => "/\#{record.#{route_key}}/restore",
|
|
135
|
+
"forceDelete" => "/\#{record.#{route_key}}/force-delete"
|
|
133
136
|
}
|
|
134
137
|
|
|
135
138
|
success_codes = { "store" => ":created", "destroy" => ":no_content", "forceDelete" => ":no_content" }
|
|
@@ -168,7 +171,7 @@ module Rhino
|
|
|
168
171
|
lines.join("\n")
|
|
169
172
|
end
|
|
170
173
|
|
|
171
|
-
def build_field_visibility_test(slug, role, perm, columns, is_multi_tenant, org_identifier)
|
|
174
|
+
def build_field_visibility_test(slug, role, perm, columns, is_multi_tenant, org_identifier, route_key = "id")
|
|
172
175
|
return nil unless perm[:actions].include?("show")
|
|
173
176
|
return nil if perm[:show_fields] == ["*"]
|
|
174
177
|
return nil if perm[:show_fields].empty?
|
|
@@ -184,9 +187,9 @@ module Rhino
|
|
|
184
187
|
lines << " it 'shows only permitted fields' do"
|
|
185
188
|
|
|
186
189
|
if is_multi_tenant
|
|
187
|
-
lines << " get \"/api/\#{org.#{org_identifier}}/#{slug}/\#{record
|
|
190
|
+
lines << " get \"/api/\#{org.#{org_identifier}}/#{slug}/\#{record.#{route_key}}\", headers: auth_headers(user)"
|
|
188
191
|
else
|
|
189
|
-
lines << " get \"/api/#{slug}/\#{record
|
|
192
|
+
lines << " get \"/api/#{slug}/\#{record.#{route_key}}\", headers: auth_headers(user)"
|
|
190
193
|
end
|
|
191
194
|
|
|
192
195
|
lines << " expect(response).to have_http_status(:ok)"
|
|
@@ -278,6 +278,7 @@ module Rhino
|
|
|
278
278
|
content += " rhino_sorts #{sort_cols.map { |c| ":#{c}" }.join(', ')}\n" unless sort_cols.empty?
|
|
279
279
|
content += " rhino_fields #{field_cols.map { |c| ":#{c}" }.join(', ')}\n" unless field_cols.empty?
|
|
280
280
|
content += " rhino_includes #{include_cols.map { |c| ":#{c}" }.join(', ')}\n" unless include_cols.empty?
|
|
281
|
+
content += " rhino_route_key :#{blueprint[:options][:route_key]}\n" if blueprint[:options][:route_key]
|
|
281
282
|
|
|
282
283
|
# Validations
|
|
283
284
|
|
|
@@ -20,6 +20,7 @@ module Rhino
|
|
|
20
20
|
# rhino_middleware 'throttle:60,1'
|
|
21
21
|
# rhino_middleware_actions store: ['verified'], update: ['verified']
|
|
22
22
|
# rhino_except_actions :destroy
|
|
23
|
+
# rhino_route_key :hash_id
|
|
23
24
|
# end
|
|
24
25
|
module HasRhino
|
|
25
26
|
extend ActiveSupport::Concern
|
|
@@ -39,6 +40,7 @@ module Rhino
|
|
|
39
40
|
class_attribute :rhino_middleware_actions_map, default: {}
|
|
40
41
|
class_attribute :rhino_except_actions_list, default: []
|
|
41
42
|
class_attribute :rhino_owner_path, default: nil
|
|
43
|
+
class_attribute :rhino_route_key_column, default: nil
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
class_methods do
|
|
@@ -103,6 +105,36 @@ module Rhino
|
|
|
103
105
|
self.rhino_except_actions_list = actions.map(&:to_s)
|
|
104
106
|
end
|
|
105
107
|
|
|
108
|
+
# Column matched against the :id URL segment on member endpoints
|
|
109
|
+
# (show/update/destroy/restore/force_delete).
|
|
110
|
+
# rhino_route_key :hash_id # GET /api/jobs/{hash_id}
|
|
111
|
+
# Affects ONLY the URL-segment lookup — FK values in payloads, nested
|
|
112
|
+
# operation ids and audit references stay primary-key based.
|
|
113
|
+
def rhino_route_key(column)
|
|
114
|
+
self.rhino_route_key_column = column.to_s
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Resolve the effective route key for this model. Precedence:
|
|
118
|
+
# model-level +rhino_route_key+ → global +Rhino.config.route_key+ →
|
|
119
|
+
# primary key. O(1): class_attribute + config read; the column presence
|
|
120
|
+
# check uses ActiveRecord's cached +column_names+ (no queries).
|
|
121
|
+
#
|
|
122
|
+
# Raises ArgumentError when a configured key names a column that does
|
|
123
|
+
# not exist on the model — a clear failure instead of a silent 404.
|
|
124
|
+
def rhino_resolved_route_key
|
|
125
|
+
key = rhino_route_key_column.presence || Rhino.config.route_key.presence || primary_key
|
|
126
|
+
key = key.to_s
|
|
127
|
+
|
|
128
|
+
if key != primary_key.to_s && !column_names.include?(key)
|
|
129
|
+
raise ArgumentError,
|
|
130
|
+
"Invalid route key for #{name}: column '#{key}' does not exist on table " \
|
|
131
|
+
"'#{table_name}'. Check `rhino_route_key` on the model or the global " \
|
|
132
|
+
"`Rhino.config.route_key` setting."
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
key
|
|
136
|
+
end
|
|
137
|
+
|
|
106
138
|
# Check if model uses soft deletes (Discard gem)
|
|
107
139
|
def uses_soft_deletes?
|
|
108
140
|
column_names.include?("discarded_at") || column_names.include?("deleted_at")
|
|
@@ -103,6 +103,10 @@ module Rhino
|
|
|
103
103
|
if permitted && permitted != ['*']
|
|
104
104
|
permitted_set = Set.new(permitted.map(&:to_s))
|
|
105
105
|
permitted_set.add('id') # id is always allowed
|
|
106
|
+
# The route key column is always allowed too — responses must stay
|
|
107
|
+
# routable even when a policy whitelist omits it.
|
|
108
|
+
route_key = self.class.try(:rhino_resolved_route_key)
|
|
109
|
+
permitted_set.add(route_key.to_s) if route_key
|
|
106
110
|
result.select! { |key, _| permitted_set.include?(key) }
|
|
107
111
|
end
|
|
108
112
|
|
|
@@ -168,6 +172,13 @@ module Rhino
|
|
|
168
172
|
if permitted != ['*']
|
|
169
173
|
all_columns = self.class.column_names
|
|
170
174
|
not_permitted = all_columns - permitted.map(&:to_s)
|
|
175
|
+
# A configured route key is never hidden by a policy whitelist —
|
|
176
|
+
# responses must stay routable. Default path (route key == primary
|
|
177
|
+
# key) is intentionally untouched for backward compatibility.
|
|
178
|
+
route_key = self.class.try(:rhino_resolved_route_key)
|
|
179
|
+
if route_key && route_key.to_s != self.class.primary_key.to_s
|
|
180
|
+
not_permitted -= [route_key.to_s]
|
|
181
|
+
end
|
|
171
182
|
hidden.concat(not_permitted)
|
|
172
183
|
end
|
|
173
184
|
end
|
data/lib/rhino/configuration.rb
CHANGED
|
@@ -4,6 +4,11 @@ module Rhino
|
|
|
4
4
|
class Configuration
|
|
5
5
|
attr_accessor :models, :route_groups, :multi_tenant, :invitations, :nested, :test_framework,
|
|
6
6
|
:client_path, :mobile_path
|
|
7
|
+
# Global default route key: the column matched against the :id URL segment
|
|
8
|
+
# on member endpoints (show/update/destroy/restore/force_delete) for every
|
|
9
|
+
# model that does not declare its own +rhino_route_key+. Default nil =
|
|
10
|
+
# primary key (today's behavior, fully backward compatible).
|
|
11
|
+
attr_accessor :route_key
|
|
7
12
|
attr_reader :auth
|
|
8
13
|
|
|
9
14
|
def initialize
|
|
@@ -27,6 +32,7 @@ module Rhino
|
|
|
27
32
|
@test_framework = "rspec"
|
|
28
33
|
@client_path = nil
|
|
29
34
|
@mobile_path = nil
|
|
35
|
+
@route_key = nil
|
|
30
36
|
end
|
|
31
37
|
|
|
32
38
|
# Auth configuration accessor. Merges supplied keys over defaults so a host
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rhino
|
|
4
|
+
# Ambient tenant context resolver.
|
|
5
|
+
#
|
|
6
|
+
# Reads the current user/organization from RequestStore by default (request-time
|
|
7
|
+
# context set by the controller before_actions), but can be overridden for the
|
|
8
|
+
# duration of a block via +with+ — used by the explicit query builder so jobs,
|
|
9
|
+
# rake tasks, and tests can query without a route.
|
|
10
|
+
module Context
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# The active organization: the explicit override if one is in effect, else the
|
|
14
|
+
# request-time organization from RequestStore.
|
|
15
|
+
def organization
|
|
16
|
+
if store.key?(:rhino_organization)
|
|
17
|
+
store[:rhino_organization]
|
|
18
|
+
elsif defined?(RequestStore)
|
|
19
|
+
RequestStore.store[:rhino_organization]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The active user: the explicit override if one is in effect, else the
|
|
24
|
+
# request-time user from RequestStore.
|
|
25
|
+
def user
|
|
26
|
+
if store.key?(:rhino_current_user)
|
|
27
|
+
store[:rhino_current_user]
|
|
28
|
+
elsif defined?(RequestStore)
|
|
29
|
+
RequestStore.store[:rhino_current_user]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Run +block+ with the given user/organization installed into RequestStore.
|
|
34
|
+
# Snapshots the prior RequestStore user+org, sets the new ones, yields, and
|
|
35
|
+
# restores the snapshot in an ensure. Returns the block's value.
|
|
36
|
+
def with(user:, organization:)
|
|
37
|
+
return yield unless defined?(RequestStore)
|
|
38
|
+
|
|
39
|
+
had_user = RequestStore.store.key?(:rhino_current_user)
|
|
40
|
+
had_org = RequestStore.store.key?(:rhino_organization)
|
|
41
|
+
prev_user = RequestStore.store[:rhino_current_user]
|
|
42
|
+
prev_org = RequestStore.store[:rhino_organization]
|
|
43
|
+
|
|
44
|
+
# Track the active override so Context.user/organization prefer it even when
|
|
45
|
+
# the passed value is nil (distinguishing "explicitly nil" from "absent").
|
|
46
|
+
had_override_user = store.key?(:rhino_current_user)
|
|
47
|
+
had_override_org = store.key?(:rhino_organization)
|
|
48
|
+
prev_override_user = store[:rhino_current_user]
|
|
49
|
+
prev_override_org = store[:rhino_organization]
|
|
50
|
+
|
|
51
|
+
RequestStore.store[:rhino_current_user] = user
|
|
52
|
+
RequestStore.store[:rhino_organization] = organization
|
|
53
|
+
store[:rhino_current_user] = user
|
|
54
|
+
store[:rhino_organization] = organization
|
|
55
|
+
|
|
56
|
+
begin
|
|
57
|
+
yield
|
|
58
|
+
ensure
|
|
59
|
+
if had_user
|
|
60
|
+
RequestStore.store[:rhino_current_user] = prev_user
|
|
61
|
+
else
|
|
62
|
+
RequestStore.store.delete(:rhino_current_user)
|
|
63
|
+
end
|
|
64
|
+
if had_org
|
|
65
|
+
RequestStore.store[:rhino_organization] = prev_org
|
|
66
|
+
else
|
|
67
|
+
RequestStore.store.delete(:rhino_organization)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if had_override_user
|
|
71
|
+
store[:rhino_current_user] = prev_override_user
|
|
72
|
+
else
|
|
73
|
+
store.delete(:rhino_current_user)
|
|
74
|
+
end
|
|
75
|
+
if had_override_org
|
|
76
|
+
store[:rhino_organization] = prev_override_org
|
|
77
|
+
else
|
|
78
|
+
store.delete(:rhino_organization)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Fiber-local override store for the active explicit context.
|
|
84
|
+
def store
|
|
85
|
+
Thread.current[:rhino_context_override] ||= {}
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -104,7 +104,8 @@ module Rhino
|
|
|
104
104
|
return auth_response if auth_response
|
|
105
105
|
|
|
106
106
|
builder = QueryBuilder.new(model_class, params: params)
|
|
107
|
-
|
|
107
|
+
# record is already resolved via the route key; re-query by primary key
|
|
108
|
+
builder.instance_variable_set(:@scope, model_class.where(model_class.primary_key => record.id))
|
|
108
109
|
apply_organization_scope(builder)
|
|
109
110
|
builder.build
|
|
110
111
|
record = builder.to_scope.first!
|
|
@@ -192,7 +193,7 @@ module Rhino
|
|
|
192
193
|
|
|
193
194
|
# POST /api/{slug}/:id/restore
|
|
194
195
|
def restore
|
|
195
|
-
record = model_class.discarded
|
|
196
|
+
record = find_by_route_key(model_class.discarded)
|
|
196
197
|
authorize record, :restore?, policy_class: policy_for(record)
|
|
197
198
|
|
|
198
199
|
record.undiscard!
|
|
@@ -203,7 +204,7 @@ module Rhino
|
|
|
203
204
|
|
|
204
205
|
# DELETE /api/{slug}/:id/force-delete
|
|
205
206
|
def force_delete
|
|
206
|
-
record = model_class.discarded
|
|
207
|
+
record = find_by_route_key(model_class.discarded)
|
|
207
208
|
authorize record, :force_delete?, policy_class: policy_for(record)
|
|
208
209
|
|
|
209
210
|
record.destroy!
|
|
@@ -446,61 +447,10 @@ module Rhino
|
|
|
446
447
|
org = current_organization
|
|
447
448
|
return unless org
|
|
448
449
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
builder.
|
|
452
|
-
|
|
453
|
-
builder.scope.where(model_class.primary_key => org.send(model_class.primary_key))
|
|
454
|
-
)
|
|
455
|
-
return
|
|
456
|
-
end
|
|
457
|
-
|
|
458
|
-
# Check for scopeForOrganization
|
|
459
|
-
if model_class.respond_to?(:for_organization)
|
|
460
|
-
builder.instance_variable_set(:@scope, model_class.for_organization(org))
|
|
461
|
-
return
|
|
462
|
-
end
|
|
463
|
-
|
|
464
|
-
# Check for organization_id column
|
|
465
|
-
if model_class.column_names.include?("organization_id")
|
|
466
|
-
builder.instance_variable_set(
|
|
467
|
-
:@scope,
|
|
468
|
-
builder.scope.where(organization_id: org.id)
|
|
469
|
-
)
|
|
470
|
-
return
|
|
471
|
-
end
|
|
472
|
-
|
|
473
|
-
# Auto-detect from belongs_to relationships
|
|
474
|
-
detected_path = discover_organization_path(model_class)
|
|
475
|
-
if detected_path.present?
|
|
476
|
-
apply_organization_scope_through_relationship(builder, org, detected_path)
|
|
477
|
-
end
|
|
478
|
-
end
|
|
479
|
-
|
|
480
|
-
def apply_organization_scope_through_relationship(builder, organization, relationship_path)
|
|
481
|
-
if relationship_path.include?(".")
|
|
482
|
-
# Nested path: 'post.blog' -> joins(post: :blog).where(blogs: { organization_id: org.id })
|
|
483
|
-
parts = relationship_path.split(".")
|
|
484
|
-
join_chain = parts.reverse.inject(:organization) { |inner, outer| { outer.to_sym => inner } }
|
|
485
|
-
|
|
486
|
-
builder.instance_variable_set(
|
|
487
|
-
:@scope,
|
|
488
|
-
builder.scope.joins(join_chain.is_a?(Symbol) ? join_chain : parts.first.to_sym => join_chain)
|
|
489
|
-
.where(organizations: { id: organization.id })
|
|
490
|
-
)
|
|
491
|
-
else
|
|
492
|
-
# Single relationship
|
|
493
|
-
assoc = model_class.reflect_on_association(relationship_path.to_sym)
|
|
494
|
-
return unless assoc
|
|
495
|
-
|
|
496
|
-
if assoc.klass.column_names.include?("organization_id")
|
|
497
|
-
builder.instance_variable_set(
|
|
498
|
-
:@scope,
|
|
499
|
-
builder.scope.joins(relationship_path.to_sym)
|
|
500
|
-
.where(assoc.klass.table_name => { organization_id: organization.id })
|
|
501
|
-
)
|
|
502
|
-
end
|
|
503
|
-
end
|
|
450
|
+
builder.instance_variable_set(
|
|
451
|
+
:@scope,
|
|
452
|
+
Rhino::ScopesToOrganization.scope_to_organization(builder.scope, model_class, org)
|
|
453
|
+
)
|
|
504
454
|
end
|
|
505
455
|
|
|
506
456
|
def add_organization_to_data(data)
|
|
@@ -515,9 +465,10 @@ module Rhino
|
|
|
515
465
|
# Recursively discover the relationship path from a model to Organization
|
|
516
466
|
# by introspecting BelongsTo associations. Returns dot-notation path or nil.
|
|
517
467
|
#
|
|
518
|
-
#
|
|
468
|
+
# The recursion itself lives in Rhino::ScopesToOrganization (the extracted,
|
|
469
|
+
# pure implementation) so the controller and the custom-query resolver share
|
|
470
|
+
# one code path. The controller keeps its own per-class cache for back-compat.
|
|
519
471
|
def discover_organization_path(klass, visited = [], max_depth = 3)
|
|
520
|
-
# Return cached result (including nil)
|
|
521
472
|
if @@organization_path_cache.key?(klass.name)
|
|
522
473
|
return @@organization_path_cache[klass.name]
|
|
523
474
|
end
|
|
@@ -528,65 +479,7 @@ module Rhino
|
|
|
528
479
|
end
|
|
529
480
|
|
|
530
481
|
def _discover_organization_path_recursive(klass, visited, max_depth)
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
visited = visited + [klass.name]
|
|
534
|
-
|
|
535
|
-
begin
|
|
536
|
-
associations = klass.reflect_on_all_associations(:belongs_to)
|
|
537
|
-
rescue StandardError
|
|
538
|
-
return nil
|
|
539
|
-
end
|
|
540
|
-
|
|
541
|
-
matching_paths = []
|
|
542
|
-
|
|
543
|
-
associations.each do |assoc|
|
|
544
|
-
begin
|
|
545
|
-
related_class = assoc.klass
|
|
546
|
-
rescue StandardError
|
|
547
|
-
next
|
|
548
|
-
end
|
|
549
|
-
|
|
550
|
-
# Direct match: related model IS Organization
|
|
551
|
-
if related_class.name == "Organization"
|
|
552
|
-
matching_paths << assoc.name.to_s
|
|
553
|
-
next
|
|
554
|
-
end
|
|
555
|
-
|
|
556
|
-
# Related model has organization_id column
|
|
557
|
-
begin
|
|
558
|
-
if related_class.column_names.include?("organization_id")
|
|
559
|
-
matching_paths << assoc.name.to_s
|
|
560
|
-
next
|
|
561
|
-
end
|
|
562
|
-
rescue StandardError
|
|
563
|
-
# Table may not exist yet
|
|
564
|
-
end
|
|
565
|
-
|
|
566
|
-
# Related model includes BelongsToOrganization concern
|
|
567
|
-
if related_class.include?(Rhino::BelongsToOrganization)
|
|
568
|
-
matching_paths << assoc.name.to_s
|
|
569
|
-
next
|
|
570
|
-
end
|
|
571
|
-
|
|
572
|
-
# Recurse into related model's BelongsTo associations
|
|
573
|
-
sub_path = _discover_organization_path_recursive(related_class, visited, max_depth - 1)
|
|
574
|
-
if sub_path.present?
|
|
575
|
-
matching_paths << "#{assoc.name}.#{sub_path}"
|
|
576
|
-
end
|
|
577
|
-
end
|
|
578
|
-
|
|
579
|
-
return nil if matching_paths.empty?
|
|
580
|
-
|
|
581
|
-
if matching_paths.length > 1
|
|
582
|
-
Rails.logger&.debug(
|
|
583
|
-
"Rhino: Model #{klass.name} has multiple BelongsTo paths to Organization. " \
|
|
584
|
-
"Using '#{matching_paths[0]}'. " \
|
|
585
|
-
"Paths found: #{matching_paths.inspect}"
|
|
586
|
-
)
|
|
587
|
-
end
|
|
588
|
-
|
|
589
|
-
matching_paths[0]
|
|
482
|
+
Rhino::ScopesToOrganization._discover_organization_path_recursive(klass, visited, max_depth)
|
|
590
483
|
end
|
|
591
484
|
|
|
592
485
|
# ------------------------------------------------------------------
|
|
@@ -601,7 +494,32 @@ module Rhino
|
|
|
601
494
|
scope = scope.where(organization_id: org.id)
|
|
602
495
|
end
|
|
603
496
|
|
|
604
|
-
scope
|
|
497
|
+
find_by_route_key(scope)
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Column matched against the :id URL segment. Resolution chain:
|
|
501
|
+
# model's rhino_route_key → Rhino.config.route_key → primary key.
|
|
502
|
+
# O(1) — class_attribute + config read, no queries.
|
|
503
|
+
def route_key_for(model_class)
|
|
504
|
+
if model_class.respond_to?(:rhino_resolved_route_key)
|
|
505
|
+
model_class.rhino_resolved_route_key
|
|
506
|
+
else
|
|
507
|
+
model_class.primary_key
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Look up params[:id] within +scope+ using the resolved route key.
|
|
512
|
+
# Default path (route key == primary key) uses .find — byte-identical to
|
|
513
|
+
# the historical behavior. Configured path uses .find_by!, which raises
|
|
514
|
+
# ActiveRecord::RecordNotFound with the same 404 semantics.
|
|
515
|
+
def find_by_route_key(scope)
|
|
516
|
+
key = route_key_for(model_class)
|
|
517
|
+
|
|
518
|
+
if key.to_s == model_class.primary_key.to_s
|
|
519
|
+
scope.find(params[:id])
|
|
520
|
+
else
|
|
521
|
+
scope.find_by!(key => params[:id])
|
|
522
|
+
end
|
|
605
523
|
end
|
|
606
524
|
|
|
607
525
|
# ------------------------------------------------------------------
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rhino
|
|
4
|
+
# Raised by Rhino.query (and the explicit builder) when an organization-scopable
|
|
5
|
+
# model is queried with no organization context available. Fail-closed: the
|
|
6
|
+
# resolver never returns an unscoped relation for a tenant-scopable model.
|
|
7
|
+
class MissingTenantContext < StandardError; end
|
|
8
|
+
end
|
|
@@ -293,6 +293,30 @@ module Rhino
|
|
|
293
293
|
# rhino_except_actions :store, :update, :destroy
|
|
294
294
|
self.rhino_except_actions_list = []
|
|
295
295
|
|
|
296
|
+
# =========================================================================
|
|
297
|
+
# ROUTE KEY
|
|
298
|
+
# =========================================================================
|
|
299
|
+
|
|
300
|
+
# @!attribute [rw] rhino_route_key_column
|
|
301
|
+
# Column matched against the +:id+ URL segment on member endpoints
|
|
302
|
+
# (show, update, destroy, restore, force-delete).
|
|
303
|
+
#
|
|
304
|
+
# Default +nil+ = fall back to the global +Rhino.config.route_key+, then
|
|
305
|
+
# the primary key (today's behavior). Affects ONLY the URL-segment
|
|
306
|
+
# lookup — foreign keys in payloads, nested-operation ids and audit
|
|
307
|
+
# references stay primary-key based.
|
|
308
|
+
#
|
|
309
|
+
# Set via DSL: +rhino_route_key :hash_id+
|
|
310
|
+
#
|
|
311
|
+
# Query: +GET /api/jobs/{hash_id}+
|
|
312
|
+
#
|
|
313
|
+
# @return [String, nil]
|
|
314
|
+
# @example
|
|
315
|
+
# rhino_route_key :hash_id
|
|
316
|
+
# @example Direct assignment
|
|
317
|
+
# self.rhino_route_key_column = 'hash_id'
|
|
318
|
+
self.rhino_route_key_column = nil
|
|
319
|
+
|
|
296
320
|
# =========================================================================
|
|
297
321
|
# OWNERSHIP / MULTI-TENANCY
|
|
298
322
|
# =========================================================================
|
data/lib/rhino/query.rb
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rhino
|
|
4
|
+
# Reusable tenant-safe query resolver for custom controllers (dashboards,
|
|
5
|
+
# reports, anything beyond CRUD). Mirrors the Laravel Rhino::query feature.
|
|
6
|
+
#
|
|
7
|
+
# Two ways to use it:
|
|
8
|
+
#
|
|
9
|
+
# # Direct / ambient — org + user from the request context (RequestStore)
|
|
10
|
+
# Rhino.query(Task)
|
|
11
|
+
#
|
|
12
|
+
# # Explicit — works in jobs/rake/tests with NO route; org scope comes from
|
|
13
|
+
# # the passed org, not the request:
|
|
14
|
+
# Rhino.for_user(user).in_organization(org).query(Task)
|
|
15
|
+
# Rhino.for_user(user).in_organization(org).run { ... }
|
|
16
|
+
class << self
|
|
17
|
+
# Build a tenant-scoped relation for +model_class+ using the ambient context.
|
|
18
|
+
#
|
|
19
|
+
# Applies the same org scoping as CRUD plus the model's default_scopes
|
|
20
|
+
# (BelongsToOrganization / HasAutoScope read RequestStore at BUILD time).
|
|
21
|
+
#
|
|
22
|
+
# Fail closed: an org-scopable model with no org context RAISES
|
|
23
|
+
# Rhino::MissingTenantContext rather than returning an unscoped relation.
|
|
24
|
+
def query(model_class)
|
|
25
|
+
org = Rhino::Context.organization
|
|
26
|
+
|
|
27
|
+
# default_scope (org via RequestStore) + auto-scope are baked here at build.
|
|
28
|
+
relation = model_class.all
|
|
29
|
+
|
|
30
|
+
if Rhino::ScopesToOrganization.organization_scoped?(model_class)
|
|
31
|
+
raise Rhino::MissingTenantContext, model_class.name unless org
|
|
32
|
+
|
|
33
|
+
relation = Rhino::ScopesToOrganization.scope_to_organization(relation, model_class, org, strict: true)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
relation
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Build a tenant-scoped relation and apply a whitelisted ?scope= named scope
|
|
40
|
+
# on top of it. +scope_name+ is the wire name (camelCase accepted); nil falls
|
|
41
|
+
# back to the model's rhino_default_scope.
|
|
42
|
+
def scoped_query(model_class, scope_name = nil)
|
|
43
|
+
apply_named_scope(query(model_class), model_class, scope_name)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Begin the fluent explicit builder for +user+.
|
|
47
|
+
def for_user(user)
|
|
48
|
+
Rhino::PendingScopedContext.new(user: user)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The ambient context resolver.
|
|
52
|
+
def context
|
|
53
|
+
Rhino::Context
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Apply a whitelisted named scope to +relation+ for +model_class+.
|
|
57
|
+
# Shared by Rhino.scoped_query and PendingScopedContext#scoped_query. Uses the
|
|
58
|
+
# same allowed_scopes / default_rhino_scope mechanism as the QueryBuilder.
|
|
59
|
+
# @api private
|
|
60
|
+
def apply_named_scope(relation, model_class, scope_name = nil)
|
|
61
|
+
requested = scope_name.to_s.presence
|
|
62
|
+
name = requested ? requested.underscore : model_class.try(:default_rhino_scope)
|
|
63
|
+
return relation unless name
|
|
64
|
+
|
|
65
|
+
allowed = model_class.try(:allowed_scopes) || {}
|
|
66
|
+
entry = allowed[name]
|
|
67
|
+
entry ||= name.to_sym if name == model_class.try(:default_rhino_scope)
|
|
68
|
+
|
|
69
|
+
raise Rhino::ScopeNotAllowedError, (requested || name) if entry.nil?
|
|
70
|
+
|
|
71
|
+
user = defined?(RequestStore) ? RequestStore.store[:rhino_current_user] : nil
|
|
72
|
+
|
|
73
|
+
case entry
|
|
74
|
+
when Symbol
|
|
75
|
+
relation.merge(model_class.public_send(entry))
|
|
76
|
+
when Proc
|
|
77
|
+
entry.call(relation, user)
|
|
78
|
+
else
|
|
79
|
+
entry.new.apply(relation)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Fluent explicit-context builder. Holds a user (and, once chained, an org) and
|
|
85
|
+
# resolves queries with that context installed into RequestStore at build time.
|
|
86
|
+
class PendingScopedContext
|
|
87
|
+
def initialize(user:, organization: nil)
|
|
88
|
+
@user = user
|
|
89
|
+
@organization = organization
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def in_organization(organization)
|
|
93
|
+
@organization = organization
|
|
94
|
+
self
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Build a fully-baked relation for +model_class+ under this explicit context.
|
|
98
|
+
#
|
|
99
|
+
# Because Rails default_scopes bake at BUILD time, we install the user+org into
|
|
100
|
+
# RequestStore, build the relation via Rhino.query, then restore RequestStore.
|
|
101
|
+
# The org+user are baked into the returned relation — no stickiness, fully
|
|
102
|
+
# isolated: a later Rhino.query with no context still fails closed.
|
|
103
|
+
def query(model_class)
|
|
104
|
+
Rhino::Context.with(user: @user, organization: @organization) do
|
|
105
|
+
Rhino.query(model_class)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Build a fully-baked, named-scoped relation under this explicit context.
|
|
110
|
+
def scoped_query(model_class, scope_name = nil)
|
|
111
|
+
Rhino::Context.with(user: @user, organization: @organization) do
|
|
112
|
+
Rhino.scoped_query(model_class, scope_name)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Run +block+ with the explicit context installed into RequestStore. Queries
|
|
117
|
+
# inside the block see the context; RequestStore is restored afterward.
|
|
118
|
+
# Returns the block's value.
|
|
119
|
+
def run(&block)
|
|
120
|
+
Rhino::Context.with(user: @user, organization: @organization, &block)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/rhino/query_builder.rb
CHANGED
|
@@ -232,6 +232,12 @@ module Rhino
|
|
|
232
232
|
if valid_fields.any?
|
|
233
233
|
# Always include the primary key
|
|
234
234
|
valid_fields.unshift(model_class.primary_key) unless valid_fields.include?(model_class.primary_key)
|
|
235
|
+
# Also include the configured route key — sparse responses must stay
|
|
236
|
+
# routable. No-op in the default path (route key == primary key).
|
|
237
|
+
route_key = model_class.try(:rhino_resolved_route_key)
|
|
238
|
+
if route_key && route_key.to_s != model_class.primary_key.to_s && !valid_fields.include?(route_key.to_s)
|
|
239
|
+
valid_fields << route_key.to_s
|
|
240
|
+
end
|
|
235
241
|
@scope = @scope.select(valid_fields.map { |f| "#{model_class.table_name}.#{f}" })
|
|
236
242
|
end
|
|
237
243
|
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rhino
|
|
4
|
+
# Pure organization-scoping logic, extracted from ResourcesController so it can
|
|
5
|
+
# be reused by the custom-query resolver (Rhino.query / Rhino.for_user...).
|
|
6
|
+
#
|
|
7
|
+
# Every method takes a RELATION and RETURNS a scoped relation — it never mutates
|
|
8
|
+
# a QueryBuilder's @scope. The scoping order MUST match the controller exactly:
|
|
9
|
+
# 1. Organization-is-self (the model IS the Organization)
|
|
10
|
+
# 2. for_organization (scopeForOrganization)
|
|
11
|
+
# 3. organization_id column
|
|
12
|
+
# 4. auto-detected belongs_to relationship path (incl. nested, e.g. post.blog)
|
|
13
|
+
module ScopesToOrganization
|
|
14
|
+
# Cache for auto-detected organization paths (survives across calls, keyed by
|
|
15
|
+
# model class name). Mirrors the controller's @@organization_path_cache.
|
|
16
|
+
@organization_path_cache = {}
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Apply organization scoping to +relation+ for +model_class+, returning the
|
|
21
|
+
# scoped relation. Behavior-equivalent to the controller's
|
|
22
|
+
# apply_organization_scope, but returning instead of mutating a builder.
|
|
23
|
+
def scope_to_organization(relation, model_class, organization, strict: false)
|
|
24
|
+
return relation unless organization
|
|
25
|
+
|
|
26
|
+
# When the resource IS the Organization model
|
|
27
|
+
if organization.class == model_class
|
|
28
|
+
return relation.where(
|
|
29
|
+
model_class.primary_key => organization.send(model_class.primary_key)
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Check for scopeForOrganization
|
|
34
|
+
if model_class.respond_to?(:for_organization)
|
|
35
|
+
return model_class.for_organization(organization)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Check for organization_id column
|
|
39
|
+
if model_class.column_names.include?("organization_id")
|
|
40
|
+
return relation.where(organization_id: organization.id)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Auto-detect from belongs_to relationships
|
|
44
|
+
detected_path = discover_organization_path(model_class)
|
|
45
|
+
if detected_path.present?
|
|
46
|
+
return scope_through_relationship(relation, model_class, organization, detected_path, strict: strict)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# No mechanism could be applied. In strict mode (the resolver) a model that
|
|
50
|
+
# reached here after being classified organization_scoped? must NOT return
|
|
51
|
+
# unscoped — fail closed instead of leaking across tenants.
|
|
52
|
+
raise Rhino::MissingTenantContext, model_class.name if strict && organization_scoped?(model_class)
|
|
53
|
+
|
|
54
|
+
relation
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Whether +model_class+ has any organization-scoping mechanism at all. Used by
|
|
58
|
+
# the resolver to decide whether missing org context must fail closed. On an
|
|
59
|
+
# unexpected classification error we fail CLOSED (treat as scopable) so the
|
|
60
|
+
# resolver raises rather than silently returning unscoped rows.
|
|
61
|
+
def organization_scoped?(model_class)
|
|
62
|
+
return true if model_class.respond_to?(:for_organization)
|
|
63
|
+
return true if model_class.column_names.include?("organization_id")
|
|
64
|
+
|
|
65
|
+
discover_organization_path(model_class).present?
|
|
66
|
+
rescue StandardError
|
|
67
|
+
true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def scope_through_relationship(relation, model_class, organization, relationship_path, strict: false)
|
|
71
|
+
if relationship_path.include?(".")
|
|
72
|
+
# Nested path: 'post.blog' -> joins(post: :blog).where(organizations: { id: org.id })
|
|
73
|
+
parts = relationship_path.split(".")
|
|
74
|
+
join_chain = parts.reverse.inject(:organization) { |inner, outer| { outer.to_sym => inner } }
|
|
75
|
+
|
|
76
|
+
relation.joins(join_chain.is_a?(Symbol) ? join_chain : parts.first.to_sym => join_chain)
|
|
77
|
+
.where(organizations: { id: organization.id })
|
|
78
|
+
else
|
|
79
|
+
# Single relationship
|
|
80
|
+
assoc = model_class.reflect_on_association(relationship_path.to_sym)
|
|
81
|
+
if assoc.nil?
|
|
82
|
+
# Classified scopable but the association vanished — fail closed for the
|
|
83
|
+
# resolver; stay lenient for the controller's legacy path.
|
|
84
|
+
raise Rhino::MissingTenantContext, model_class.name if strict
|
|
85
|
+
|
|
86
|
+
return relation
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if assoc.klass.column_names.include?("organization_id")
|
|
90
|
+
relation.joins(relationship_path.to_sym)
|
|
91
|
+
.where(assoc.klass.table_name => { organization_id: organization.id })
|
|
92
|
+
elsif strict
|
|
93
|
+
# Path leads somewhere without an organization_id column, so no filter
|
|
94
|
+
# can be applied — fail closed rather than return every tenant's rows.
|
|
95
|
+
raise Rhino::MissingTenantContext, model_class.name
|
|
96
|
+
else
|
|
97
|
+
relation
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Recursively discover the relationship path from a model to Organization by
|
|
103
|
+
# introspecting BelongsTo associations. Returns dot-notation path or nil.
|
|
104
|
+
# Results are cached per model class to avoid repeated reflection.
|
|
105
|
+
def discover_organization_path(klass, visited = [], max_depth = 3)
|
|
106
|
+
if @organization_path_cache.key?(klass.name)
|
|
107
|
+
return @organization_path_cache[klass.name]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
result = _discover_organization_path_recursive(klass, visited, max_depth)
|
|
111
|
+
# Only cache a positive result. Caching a transient nil (associations/tables
|
|
112
|
+
# not yet resolvable under Zeitwerk lazy-loading) would permanently
|
|
113
|
+
# misclassify a genuinely org-scoped model as global — a fail-open leak.
|
|
114
|
+
# Mirrors HasAutoScope's non-nil caching.
|
|
115
|
+
@organization_path_cache[klass.name] = result if result
|
|
116
|
+
result
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def _discover_organization_path_recursive(klass, visited, max_depth)
|
|
120
|
+
return nil if max_depth <= 0 || visited.include?(klass.name)
|
|
121
|
+
|
|
122
|
+
visited = visited + [klass.name]
|
|
123
|
+
|
|
124
|
+
begin
|
|
125
|
+
associations = klass.reflect_on_all_associations(:belongs_to)
|
|
126
|
+
rescue StandardError
|
|
127
|
+
return nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
matching_paths = []
|
|
131
|
+
|
|
132
|
+
associations.each do |assoc|
|
|
133
|
+
begin
|
|
134
|
+
related_class = assoc.klass
|
|
135
|
+
rescue StandardError
|
|
136
|
+
next
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Direct match: related model IS Organization
|
|
140
|
+
if related_class.name == "Organization"
|
|
141
|
+
matching_paths << assoc.name.to_s
|
|
142
|
+
next
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Related model has organization_id column
|
|
146
|
+
begin
|
|
147
|
+
if related_class.column_names.include?("organization_id")
|
|
148
|
+
matching_paths << assoc.name.to_s
|
|
149
|
+
next
|
|
150
|
+
end
|
|
151
|
+
rescue StandardError
|
|
152
|
+
# Table may not exist yet
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Related model includes BelongsToOrganization concern
|
|
156
|
+
if defined?(Rhino::BelongsToOrganization) && related_class.include?(Rhino::BelongsToOrganization)
|
|
157
|
+
matching_paths << assoc.name.to_s
|
|
158
|
+
next
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Recurse into related model's BelongsTo associations
|
|
162
|
+
sub_path = _discover_organization_path_recursive(related_class, visited, max_depth - 1)
|
|
163
|
+
if sub_path.present?
|
|
164
|
+
matching_paths << "#{assoc.name}.#{sub_path}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
return nil if matching_paths.empty?
|
|
169
|
+
|
|
170
|
+
if matching_paths.length > 1
|
|
171
|
+
Rails.logger&.debug(
|
|
172
|
+
"Rhino: Model #{klass.name} has multiple BelongsTo paths to Organization. " \
|
|
173
|
+
"Using '#{matching_paths[0]}'. " \
|
|
174
|
+
"Paths found: #{matching_paths.inspect}"
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
matching_paths[0]
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -27,6 +27,8 @@ class <%= name %> < Rhino::RhinoModel
|
|
|
27
27
|
<% unless include_cols.empty? -%>
|
|
28
28
|
rhino_includes <%= include_cols.map { |c| ":#{c}" }.join(", ") %>
|
|
29
29
|
<% end -%>
|
|
30
|
+
# Match the :id URL segment against another column (default: primary key):
|
|
31
|
+
# rhino_route_key :hash_id
|
|
30
32
|
|
|
31
33
|
# ---------------------------------------------------------------
|
|
32
34
|
# Validation (ActiveModel — use allow_nil: true for all validators)
|
|
@@ -79,6 +79,17 @@ Rhino.configure do |config|
|
|
|
79
79
|
# Permissions then resolve from that matching membership row.
|
|
80
80
|
# config.auth = { enforce_group_membership: false }
|
|
81
81
|
|
|
82
|
+
# ---------------------------------------------------------------
|
|
83
|
+
# Route Key
|
|
84
|
+
# ---------------------------------------------------------------
|
|
85
|
+
# Global default for which column matches the :id URL segment on member
|
|
86
|
+
# endpoints (show, update, destroy, restore, force-delete). Default nil =
|
|
87
|
+
# primary key. Individual models can override with `rhino_route_key :column`.
|
|
88
|
+
# Affects only URL lookups — foreign keys in payloads and nested-operation
|
|
89
|
+
# ids stay primary-key based.
|
|
90
|
+
#
|
|
91
|
+
# config.route_key = 'hash_id' # GET /api/jobs/{hash_id}
|
|
92
|
+
|
|
82
93
|
# ---------------------------------------------------------------
|
|
83
94
|
# Invitations
|
|
84
95
|
# ---------------------------------------------------------------
|
|
@@ -66,6 +66,15 @@ class RhinoModel < Rhino::RhinoModel
|
|
|
66
66
|
# rhino_includes :user, :comments, :tags
|
|
67
67
|
# rhino_search :title, :content, :excerpt
|
|
68
68
|
|
|
69
|
+
# -----------------------------------------------------------------
|
|
70
|
+
# Route Key
|
|
71
|
+
# -----------------------------------------------------------------
|
|
72
|
+
#
|
|
73
|
+
# Match the :id URL segment against a column other than the primary key
|
|
74
|
+
# on member endpoints (show/update/destroy/restore/force-delete):
|
|
75
|
+
#
|
|
76
|
+
# rhino_route_key :hash_id # GET /api/jobs/{hash_id}
|
|
77
|
+
|
|
69
78
|
# -----------------------------------------------------------------
|
|
70
79
|
# Pagination
|
|
71
80
|
# -----------------------------------------------------------------
|
data/lib/rhino/version.rb
CHANGED
data/lib/rhino.rb
CHANGED
|
@@ -4,9 +4,13 @@ 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/missing_tenant_context"
|
|
7
8
|
require "rhino/auth_hooks"
|
|
8
9
|
require "rhino/group_membership"
|
|
9
10
|
require "rhino/resource_scope"
|
|
11
|
+
require "rhino/scopes_to_organization"
|
|
12
|
+
require "rhino/context"
|
|
13
|
+
require "rhino/query"
|
|
10
14
|
require "rhino/routing/domain_constraint"
|
|
11
15
|
require "rhino/routing/route_group_validator"
|
|
12
16
|
require "rhino/middleware/resolve_organization_from_route"
|
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.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bruno Cipolla
|
|
@@ -216,6 +216,7 @@ files:
|
|
|
216
216
|
- lib/rhino/concerns/has_validation.rb
|
|
217
217
|
- lib/rhino/concerns/hidable_columns.rb
|
|
218
218
|
- lib/rhino/configuration.rb
|
|
219
|
+
- lib/rhino/context.rb
|
|
219
220
|
- lib/rhino/controllers/auth_controller.rb
|
|
220
221
|
- lib/rhino/controllers/invitations_controller.rb
|
|
221
222
|
- lib/rhino/controllers/resources_controller.rb
|
|
@@ -223,12 +224,14 @@ files:
|
|
|
223
224
|
- lib/rhino/group_membership.rb
|
|
224
225
|
- lib/rhino/mailers/invitation_mailer.rb
|
|
225
226
|
- lib/rhino/middleware/resolve_organization_from_route.rb
|
|
227
|
+
- lib/rhino/missing_tenant_context.rb
|
|
226
228
|
- lib/rhino/models/audit_log.rb
|
|
227
229
|
- lib/rhino/models/organization_invitation.rb
|
|
228
230
|
- lib/rhino/models/rhino_model.rb
|
|
229
231
|
- lib/rhino/permissions_migrator.rb
|
|
230
232
|
- lib/rhino/policies/invitation_policy.rb
|
|
231
233
|
- lib/rhino/policies/resource_policy.rb
|
|
234
|
+
- lib/rhino/query.rb
|
|
232
235
|
- lib/rhino/query_builder.rb
|
|
233
236
|
- lib/rhino/railtie.rb
|
|
234
237
|
- lib/rhino/resource_scope.rb
|
|
@@ -236,6 +239,7 @@ files:
|
|
|
236
239
|
- lib/rhino/routing/domain_constraint.rb
|
|
237
240
|
- lib/rhino/routing/route_group_validator.rb
|
|
238
241
|
- lib/rhino/scope_not_allowed_error.rb
|
|
242
|
+
- lib/rhino/scopes_to_organization.rb
|
|
239
243
|
- lib/rhino/tasks/rhino.rake
|
|
240
244
|
- lib/rhino/templates/audit_trail/create_audit_logs.rb.erb
|
|
241
245
|
- lib/rhino/templates/generate/factory.rb.erb
|