rhino-rails 4.3.0 → 4.5.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/concerns/has_rhino.rb +21 -0
- data/lib/rhino/context.rb +88 -0
- data/lib/rhino/controllers/resources_controller.rb +15 -119
- data/lib/rhino/missing_tenant_context.rb +8 -0
- data/lib/rhino/models/rhino_model.rb +30 -0
- data/lib/rhino/query.rb +123 -0
- data/lib/rhino/query_builder.rb +38 -1
- data/lib/rhino/scope_not_allowed_error.rb +7 -0
- data/lib/rhino/scopes_to_organization.rb +181 -0
- data/lib/rhino/version.rb +1 -1
- data/lib/rhino.rb +5 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ecffad187ec69c47659219d1eb766247a82ce666a662be9a32c3d88afe4bd48c
|
|
4
|
+
data.tar.gz: 1ccb038bc991c74d4338b84a20280b38660f1ec959552b3546625de855f37969
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 242a0d4672911f646bd9dc62dcfa6bd0641caed3ec88af8f62dea657f146e0d019f80d9cbe769fb9e7415c611e709946c1aeb01af4eeb1871ca590ea2533b866
|
|
7
|
+
data.tar.gz: ea611e79054413e6322dd44a2a141aa01a0791a8eb48d13e4dd8311c03821c8195fb7befd598840d63401355a3fd4819f7b4506afd6486d38d5a0fedef48a5e4
|
data/README.md
CHANGED
|
@@ -40,6 +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
44
|
|
|
44
45
|
## Quick Start
|
|
45
46
|
|
|
@@ -13,6 +13,8 @@ module Rhino
|
|
|
13
13
|
# rhino_includes :user, :comments
|
|
14
14
|
# rhino_fields :id, :title, :status, :created_at
|
|
15
15
|
# rhino_search :title, :content, 'user.name'
|
|
16
|
+
# rhino_scopes :active, available_for_drivers: Scopes::AvailableForDriversScope
|
|
17
|
+
# rhino_default_scope :active
|
|
16
18
|
# rhino_per_page 25
|
|
17
19
|
# rhino_pagination_enabled true
|
|
18
20
|
# rhino_middleware 'throttle:60,1'
|
|
@@ -24,6 +26,8 @@ module Rhino
|
|
|
24
26
|
|
|
25
27
|
included do
|
|
26
28
|
class_attribute :allowed_filters, default: []
|
|
29
|
+
class_attribute :allowed_scopes, default: {}
|
|
30
|
+
class_attribute :default_rhino_scope, default: nil
|
|
27
31
|
class_attribute :allowed_sorts, default: []
|
|
28
32
|
class_attribute :default_sort_field, default: nil
|
|
29
33
|
class_attribute :allowed_includes, default: []
|
|
@@ -42,6 +46,23 @@ module Rhino
|
|
|
42
46
|
self.allowed_filters = fields.map(&:to_s)
|
|
43
47
|
end
|
|
44
48
|
|
|
49
|
+
# Whitelist client-selectable named scopes for ?scope=.
|
|
50
|
+
# rhino_scopes :active, available_for_drivers: Scopes::AvailableForDriversScope
|
|
51
|
+
# Bare symbols must name an existing ActiveRecord scope/class method on the model.
|
|
52
|
+
# Hash values may be a Proc(relation, user) or a Rhino::ResourceScope subclass.
|
|
53
|
+
def rhino_scopes(*names, **named)
|
|
54
|
+
merged = allowed_scopes.dup
|
|
55
|
+
names.each { |n| merged[n.to_s] = n.to_sym }
|
|
56
|
+
named.each { |k, v| merged[k.to_s] = v }
|
|
57
|
+
self.allowed_scopes = merged
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Named scope applied when no ?scope param is sent. Convenience, not a
|
|
61
|
+
# security boundary. Value is the scope name (string/symbol).
|
|
62
|
+
def rhino_default_scope(name)
|
|
63
|
+
self.default_rhino_scope = name.to_s
|
|
64
|
+
end
|
|
65
|
+
|
|
45
66
|
def rhino_sorts(*fields)
|
|
46
67
|
self.allowed_sorts = fields.map(&:to_s)
|
|
47
68
|
end
|
|
@@ -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
|
|
@@ -16,6 +16,10 @@ module Rhino
|
|
|
16
16
|
render json: { message: "This action is unauthorized." }, status: :forbidden
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
rescue_from Rhino::ScopeNotAllowedError do |e|
|
|
20
|
+
render json: { message: "Scope '#{e.message}' is not allowed" }, status: :forbidden
|
|
21
|
+
end
|
|
22
|
+
|
|
19
23
|
# Cache for auto-detected organization paths (class-level, survives across requests)
|
|
20
24
|
@@organization_path_cache = {}
|
|
21
25
|
|
|
@@ -38,7 +42,7 @@ module Rhino
|
|
|
38
42
|
def index
|
|
39
43
|
authorize model_class, :index?, policy_class: policy_for(model_class)
|
|
40
44
|
|
|
41
|
-
builder = QueryBuilder.new(model_class, params: params)
|
|
45
|
+
builder = QueryBuilder.new(model_class, params: params, named_scopes: true)
|
|
42
46
|
apply_organization_scope(builder)
|
|
43
47
|
builder.build
|
|
44
48
|
|
|
@@ -170,7 +174,7 @@ module Rhino
|
|
|
170
174
|
def trashed
|
|
171
175
|
authorize model_class, :view_trashed?, policy_class: policy_for(model_class)
|
|
172
176
|
|
|
173
|
-
builder = QueryBuilder.new(model_class.discarded, params: params)
|
|
177
|
+
builder = QueryBuilder.new(model_class.discarded, params: params, named_scopes: true)
|
|
174
178
|
apply_organization_scope(builder)
|
|
175
179
|
builder.build
|
|
176
180
|
|
|
@@ -442,61 +446,10 @@ module Rhino
|
|
|
442
446
|
org = current_organization
|
|
443
447
|
return unless org
|
|
444
448
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
builder.
|
|
448
|
-
|
|
449
|
-
builder.scope.where(model_class.primary_key => org.send(model_class.primary_key))
|
|
450
|
-
)
|
|
451
|
-
return
|
|
452
|
-
end
|
|
453
|
-
|
|
454
|
-
# Check for scopeForOrganization
|
|
455
|
-
if model_class.respond_to?(:for_organization)
|
|
456
|
-
builder.instance_variable_set(:@scope, model_class.for_organization(org))
|
|
457
|
-
return
|
|
458
|
-
end
|
|
459
|
-
|
|
460
|
-
# Check for organization_id column
|
|
461
|
-
if model_class.column_names.include?("organization_id")
|
|
462
|
-
builder.instance_variable_set(
|
|
463
|
-
:@scope,
|
|
464
|
-
builder.scope.where(organization_id: org.id)
|
|
465
|
-
)
|
|
466
|
-
return
|
|
467
|
-
end
|
|
468
|
-
|
|
469
|
-
# Auto-detect from belongs_to relationships
|
|
470
|
-
detected_path = discover_organization_path(model_class)
|
|
471
|
-
if detected_path.present?
|
|
472
|
-
apply_organization_scope_through_relationship(builder, org, detected_path)
|
|
473
|
-
end
|
|
474
|
-
end
|
|
475
|
-
|
|
476
|
-
def apply_organization_scope_through_relationship(builder, organization, relationship_path)
|
|
477
|
-
if relationship_path.include?(".")
|
|
478
|
-
# Nested path: 'post.blog' -> joins(post: :blog).where(blogs: { organization_id: org.id })
|
|
479
|
-
parts = relationship_path.split(".")
|
|
480
|
-
join_chain = parts.reverse.inject(:organization) { |inner, outer| { outer.to_sym => inner } }
|
|
481
|
-
|
|
482
|
-
builder.instance_variable_set(
|
|
483
|
-
:@scope,
|
|
484
|
-
builder.scope.joins(join_chain.is_a?(Symbol) ? join_chain : parts.first.to_sym => join_chain)
|
|
485
|
-
.where(organizations: { id: organization.id })
|
|
486
|
-
)
|
|
487
|
-
else
|
|
488
|
-
# Single relationship
|
|
489
|
-
assoc = model_class.reflect_on_association(relationship_path.to_sym)
|
|
490
|
-
return unless assoc
|
|
491
|
-
|
|
492
|
-
if assoc.klass.column_names.include?("organization_id")
|
|
493
|
-
builder.instance_variable_set(
|
|
494
|
-
:@scope,
|
|
495
|
-
builder.scope.joins(relationship_path.to_sym)
|
|
496
|
-
.where(assoc.klass.table_name => { organization_id: organization.id })
|
|
497
|
-
)
|
|
498
|
-
end
|
|
499
|
-
end
|
|
449
|
+
builder.instance_variable_set(
|
|
450
|
+
:@scope,
|
|
451
|
+
Rhino::ScopesToOrganization.scope_to_organization(builder.scope, model_class, org)
|
|
452
|
+
)
|
|
500
453
|
end
|
|
501
454
|
|
|
502
455
|
def add_organization_to_data(data)
|
|
@@ -511,9 +464,10 @@ module Rhino
|
|
|
511
464
|
# Recursively discover the relationship path from a model to Organization
|
|
512
465
|
# by introspecting BelongsTo associations. Returns dot-notation path or nil.
|
|
513
466
|
#
|
|
514
|
-
#
|
|
467
|
+
# The recursion itself lives in Rhino::ScopesToOrganization (the extracted,
|
|
468
|
+
# pure implementation) so the controller and the custom-query resolver share
|
|
469
|
+
# one code path. The controller keeps its own per-class cache for back-compat.
|
|
515
470
|
def discover_organization_path(klass, visited = [], max_depth = 3)
|
|
516
|
-
# Return cached result (including nil)
|
|
517
471
|
if @@organization_path_cache.key?(klass.name)
|
|
518
472
|
return @@organization_path_cache[klass.name]
|
|
519
473
|
end
|
|
@@ -524,65 +478,7 @@ module Rhino
|
|
|
524
478
|
end
|
|
525
479
|
|
|
526
480
|
def _discover_organization_path_recursive(klass, visited, max_depth)
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
visited = visited + [klass.name]
|
|
530
|
-
|
|
531
|
-
begin
|
|
532
|
-
associations = klass.reflect_on_all_associations(:belongs_to)
|
|
533
|
-
rescue StandardError
|
|
534
|
-
return nil
|
|
535
|
-
end
|
|
536
|
-
|
|
537
|
-
matching_paths = []
|
|
538
|
-
|
|
539
|
-
associations.each do |assoc|
|
|
540
|
-
begin
|
|
541
|
-
related_class = assoc.klass
|
|
542
|
-
rescue StandardError
|
|
543
|
-
next
|
|
544
|
-
end
|
|
545
|
-
|
|
546
|
-
# Direct match: related model IS Organization
|
|
547
|
-
if related_class.name == "Organization"
|
|
548
|
-
matching_paths << assoc.name.to_s
|
|
549
|
-
next
|
|
550
|
-
end
|
|
551
|
-
|
|
552
|
-
# Related model has organization_id column
|
|
553
|
-
begin
|
|
554
|
-
if related_class.column_names.include?("organization_id")
|
|
555
|
-
matching_paths << assoc.name.to_s
|
|
556
|
-
next
|
|
557
|
-
end
|
|
558
|
-
rescue StandardError
|
|
559
|
-
# Table may not exist yet
|
|
560
|
-
end
|
|
561
|
-
|
|
562
|
-
# Related model includes BelongsToOrganization concern
|
|
563
|
-
if related_class.include?(Rhino::BelongsToOrganization)
|
|
564
|
-
matching_paths << assoc.name.to_s
|
|
565
|
-
next
|
|
566
|
-
end
|
|
567
|
-
|
|
568
|
-
# Recurse into related model's BelongsTo associations
|
|
569
|
-
sub_path = _discover_organization_path_recursive(related_class, visited, max_depth - 1)
|
|
570
|
-
if sub_path.present?
|
|
571
|
-
matching_paths << "#{assoc.name}.#{sub_path}"
|
|
572
|
-
end
|
|
573
|
-
end
|
|
574
|
-
|
|
575
|
-
return nil if matching_paths.empty?
|
|
576
|
-
|
|
577
|
-
if matching_paths.length > 1
|
|
578
|
-
Rails.logger&.debug(
|
|
579
|
-
"Rhino: Model #{klass.name} has multiple BelongsTo paths to Organization. " \
|
|
580
|
-
"Using '#{matching_paths[0]}'. " \
|
|
581
|
-
"Paths found: #{matching_paths.inspect}"
|
|
582
|
-
)
|
|
583
|
-
end
|
|
584
|
-
|
|
585
|
-
matching_paths[0]
|
|
481
|
+
Rhino::ScopesToOrganization._discover_organization_path_recursive(klass, visited, max_depth)
|
|
586
482
|
end
|
|
587
483
|
|
|
588
484
|
# ------------------------------------------------------------------
|
|
@@ -900,7 +796,7 @@ module Rhino
|
|
|
900
796
|
end
|
|
901
797
|
|
|
902
798
|
def params_hash
|
|
903
|
-
params.except(:controller, :action, :model_slug, :route_group, :organization, :id, :format).to_unsafe_h
|
|
799
|
+
params.except(:controller, :action, :model_slug, :route_group, :organization, :id, :format, :scope).to_unsafe_h
|
|
904
800
|
end
|
|
905
801
|
end
|
|
906
802
|
end
|
|
@@ -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
|
|
@@ -104,6 +104,36 @@ module Rhino
|
|
|
104
104
|
# self.allowed_filters = %w[status user_id category_id]
|
|
105
105
|
self.allowed_filters = []
|
|
106
106
|
|
|
107
|
+
# @!attribute [rw] allowed_scopes
|
|
108
|
+
# Client-selectable named scopes (whitelist for +?scope=+).
|
|
109
|
+
#
|
|
110
|
+
# Controls which named scopes can be requested via +?scope=name+
|
|
111
|
+
# (camelCase on the wire, underscored internally). Only whitelisted
|
|
112
|
+
# scopes are accepted — unknown/unlisted names return 403.
|
|
113
|
+
#
|
|
114
|
+
# Set via DSL: +rhino_scopes :active, available_for_drivers: Scopes::AvailableForDriversScope+
|
|
115
|
+
#
|
|
116
|
+
# Query: +GET /api/posts?scope=availableForDrivers+
|
|
117
|
+
#
|
|
118
|
+
# @return [Hash{String => Symbol, Proc, Class}]
|
|
119
|
+
# @example
|
|
120
|
+
# rhino_scopes :active, available_for_drivers: Scopes::AvailableForDriversScope
|
|
121
|
+
self.allowed_scopes = {}
|
|
122
|
+
|
|
123
|
+
# @!attribute [rw] default_rhino_scope
|
|
124
|
+
# Named scope applied automatically when no +?scope+ param is sent.
|
|
125
|
+
#
|
|
126
|
+
# This is a listing convenience, not a security boundary. The default
|
|
127
|
+
# scope is always requestable by name even if it is not otherwise
|
|
128
|
+
# whitelisted via +rhino_scopes+.
|
|
129
|
+
#
|
|
130
|
+
# Set via DSL: +rhino_default_scope :active+
|
|
131
|
+
#
|
|
132
|
+
# @return [String, nil]
|
|
133
|
+
# @example
|
|
134
|
+
# rhino_default_scope :active
|
|
135
|
+
self.default_rhino_scope = nil
|
|
136
|
+
|
|
107
137
|
# @!attribute [rw] allowed_sorts
|
|
108
138
|
# Sortable columns.
|
|
109
139
|
#
|
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
|
@@ -14,14 +14,16 @@ module Rhino
|
|
|
14
14
|
class QueryBuilder
|
|
15
15
|
attr_reader :scope, :model_class, :params
|
|
16
16
|
|
|
17
|
-
def initialize(model_class, params: {})
|
|
17
|
+
def initialize(model_class, params: {}, named_scopes: false)
|
|
18
18
|
@model_class = model_class
|
|
19
19
|
@scope = model_class.all
|
|
20
20
|
@params = params
|
|
21
|
+
@named_scopes = named_scopes
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
# Apply all query modifications based on params and model config.
|
|
24
25
|
def build
|
|
26
|
+
apply_named_scope if @named_scopes
|
|
25
27
|
apply_filters
|
|
26
28
|
apply_default_sort
|
|
27
29
|
apply_sorts
|
|
@@ -62,6 +64,41 @@ module Rhino
|
|
|
62
64
|
|
|
63
65
|
private
|
|
64
66
|
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
# Named scopes: ?scope=availableForDrivers
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
#
|
|
71
|
+
# Only runs for collection endpoints (index/trashed), which pass
|
|
72
|
+
# +named_scopes: true+. `show` (including its ?include= build path) stays
|
|
73
|
+
# unscoped so a record excluded by the default scope is still viewable.
|
|
74
|
+
def apply_named_scope
|
|
75
|
+
requested = params[:scope].presence
|
|
76
|
+
name = requested ? requested.to_s.underscore : model_class.try(:default_rhino_scope)
|
|
77
|
+
return unless name
|
|
78
|
+
|
|
79
|
+
allowed = model_class.try(:allowed_scopes) || {}
|
|
80
|
+
entry = allowed[name]
|
|
81
|
+
# The default scope is implicitly allowed when requested by name.
|
|
82
|
+
entry ||= name.to_sym if name == model_class.try(:default_rhino_scope)
|
|
83
|
+
|
|
84
|
+
# Echo the client's wire name (not the underscored form) in the error.
|
|
85
|
+
raise Rhino::ScopeNotAllowedError, (requested ? requested.to_s : name) if entry.nil?
|
|
86
|
+
|
|
87
|
+
user = defined?(RequestStore) ? RequestStore.store[:rhino_current_user] : nil
|
|
88
|
+
|
|
89
|
+
@scope =
|
|
90
|
+
case entry
|
|
91
|
+
when Symbol
|
|
92
|
+
# Whitelisted AR scope. Client input never reaches public_send unless the
|
|
93
|
+
# developer declared it via rhino_scopes. .merge composes with default_scopes.
|
|
94
|
+
@scope.merge(model_class.public_send(entry))
|
|
95
|
+
when Proc
|
|
96
|
+
entry.call(@scope, user)
|
|
97
|
+
else
|
|
98
|
+
entry.new.apply(@scope) # Rhino::ResourceScope subclass (user/org/role helpers)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
65
102
|
# ------------------------------------------------------------------
|
|
66
103
|
# Filtering: ?filter[status]=published&filter[user_id]=1
|
|
67
104
|
# ------------------------------------------------------------------
|
|
@@ -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
|
data/lib/rhino/version.rb
CHANGED
data/lib/rhino.rb
CHANGED
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
require "rhino/version"
|
|
4
4
|
require "rhino/configuration"
|
|
5
5
|
require "rhino/auth_rejected"
|
|
6
|
+
require "rhino/scope_not_allowed_error"
|
|
7
|
+
require "rhino/missing_tenant_context"
|
|
6
8
|
require "rhino/auth_hooks"
|
|
7
9
|
require "rhino/group_membership"
|
|
8
10
|
require "rhino/resource_scope"
|
|
11
|
+
require "rhino/scopes_to_organization"
|
|
12
|
+
require "rhino/context"
|
|
13
|
+
require "rhino/query"
|
|
9
14
|
require "rhino/routing/domain_constraint"
|
|
10
15
|
require "rhino/routing/route_group_validator"
|
|
11
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.5.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,18 +224,22 @@ 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
|
|
235
238
|
- lib/rhino/routes.rb
|
|
236
239
|
- lib/rhino/routing/domain_constraint.rb
|
|
237
240
|
- lib/rhino/routing/route_group_validator.rb
|
|
241
|
+
- lib/rhino/scope_not_allowed_error.rb
|
|
242
|
+
- lib/rhino/scopes_to_organization.rb
|
|
238
243
|
- lib/rhino/tasks/rhino.rake
|
|
239
244
|
- lib/rhino/templates/audit_trail/create_audit_logs.rb.erb
|
|
240
245
|
- lib/rhino/templates/generate/factory.rb.erb
|