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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0155b30eedf93061b38295ed96254194529de0abf03aad309becc312a512c5ba
4
- data.tar.gz: 5e1ce1624bab2a77f8603ebdf6984a43e4bd71d277f51900ae533465e4fd0881
3
+ metadata.gz: 3c800a5e70d40849b886fdd638cc4214fa4bd782965de5a94394a6fa0319cb38
4
+ data.tar.gz: e967104d44d640b9645f579022f14831d36150e763497e25ecfbd2b391ec76b1
5
5
  SHA512:
6
- metadata.gz: 975f1ee3efc83423ae775131eace5689a7b09269541848da21e286f2f55cedd71b265a8e3c5c448a85cc7b9c8ecdde2e27dd615ef1a26719732a7d958cbfb8da
7
- data.tar.gz: 3a32ccaf04ad8f351828752a83efac31503d53afbd4c3239d0f38e836c8e151dd4681f2a650a853e9d6d728e6477f34e72023680c6bbd36dc63369ad6ca6d10c
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
- def validate_options(options)
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.id}", "store" => "",
130
- "update" => "/\#{record.id}", "destroy" => "/\#{record.id}",
131
- "trashed" => "/trashed", "restore" => "/\#{record.id}/restore",
132
- "forceDelete" => "/\#{record.id}/force-delete"
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.id}\", headers: auth_headers(user)"
190
+ lines << " get \"/api/\#{org.#{org_identifier}}/#{slug}/\#{record.#{route_key}}\", headers: auth_headers(user)"
188
191
  else
189
- lines << " get \"/api/#{slug}/\#{record.id}\", headers: auth_headers(user)"
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
@@ -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
- builder.instance_variable_set(:@scope, model_class.where(id: record.id))
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.find(params[:id])
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.find(params[:id])
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
- # When the resource IS the Organization model
450
- if org.class == model_class
451
- builder.instance_variable_set(
452
- :@scope,
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
- # Results are cached per model class to avoid repeated reflection.
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
- return nil if max_depth <= 0 || visited.include?(klass.name)
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.find(params[:id])
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
  # =========================================================================
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rhino
4
- VERSION = "4.4.0"
4
+ VERSION = "4.6.0"
5
5
  end
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.0
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