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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43ae824cbc25eeacd6ce09fc6a9d5b218206d8877fc672a49b728dd6fbf10b1c
4
- data.tar.gz: 6590704092005ad3b6c0b959b0166332387b2503d8db19e57fc17be7734c2d0b
3
+ metadata.gz: ecffad187ec69c47659219d1eb766247a82ce666a662be9a32c3d88afe4bd48c
4
+ data.tar.gz: 1ccb038bc991c74d4338b84a20280b38660f1ec959552b3546625de855f37969
5
5
  SHA512:
6
- metadata.gz: a8007c1c7f90ceae1cb0aed88ca032009e417f0e8052897f5ed73e514d9f3c782c6304f92f9b16002179d6492f5510d7ef3a3e00f5902094a925c2dd74c3b781
7
- data.tar.gz: a58f92293f66ad18700a9ec39bf48e0696f64c72b7ac82c64fde715775821a5f00c3b90ce8e61dbf76b4edf4973eb520bc04b075e04ffb16c84fe7253c350e74
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
- # When the resource IS the Organization model
446
- if org.class == model_class
447
- builder.instance_variable_set(
448
- :@scope,
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
- # Results are cached per model class to avoid repeated reflection.
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
- return nil if max_depth <= 0 || visited.include?(klass.name)
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
  #
@@ -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
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ # Raised when a client requests a ?scope= name that is not whitelisted via
5
+ # rhino_scopes / rhino_default_scope. Rendered as 403 by ResourcesController.
6
+ class ScopeNotAllowedError < StandardError; end
7
+ 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
data/lib/rhino/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rhino
4
- VERSION = "4.3.0"
4
+ VERSION = "4.5.0"
5
5
  end
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.3.0
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