rhino-rails 4.7.0 → 4.7.3

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: 3da378b6f647733a88cb598ff3b23e06462f6c61494ebbc322fed024e9ba33a9
4
- data.tar.gz: e3dec4fcb82201e652e76e01ff7b5d8fa25c1ec61dde9d3b58f688f9a0d4ceb2
3
+ metadata.gz: c7f50b9ced877e5d2fc1b304798f7c2c6372e89754ea1c7c9efd296c8f478c33
4
+ data.tar.gz: cca1fbd5f280df7e94a1fa9a69c1ca338fdfff6b76d35158afdc16fd07ef4309
5
5
  SHA512:
6
- metadata.gz: a37303c3543f0e140bc5f00371c1d8d8d310a30645a0ef2c3b9bd218d5cebe74e83c3f500f413792ce7496b764832219044739422f4a13f7be1b3dd1fe63bdec
7
- data.tar.gz: fc60726be3e23b1da9ddcccc4fc884857608fb412125211b48cad5bfb51596d4986f6db13ed2728b70644f1d2c3a4be2e2e9a5fac5093ba6746783923fc01681
6
+ metadata.gz: 43e7ae4d3e346c984ab8452c8dbc2f3ceb7199f35f8e3eba3f971931aed8539e752f9744393f2429125cb94b6d092358c070d719b634fa95b0cd9e314ea362e1
7
+ data.tar.gz: d03e920444bfcdfc0c42ddfa0d494ac180aae4fc499acd852aafc1ca6486aeccaecdff54decf4e64f65e21e5c7cb0f078ea81b3426518d7495b7e8bc3a4bc7de
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ # Places a custom (non-Rhino) controller into a Rhino route group, so the
5
+ # resource-scope resolver knows whether the request has a tenant boundary.
6
+ #
7
+ # Rhino's own generated controllers already publish their group; a controller
8
+ # you write yourself is outside that chain, so include this concern in it:
9
+ #
10
+ # class Admin::DashboardController < ApplicationController
11
+ # include Rhino::RouteGroupContext
12
+ # rhino_route_group :admin
13
+ #
14
+ # def summary
15
+ # # :admin is declared `tenant: false`, so this spans every organization
16
+ # # instead of raising Rhino::MissingTenantContext.
17
+ # render json: { tasks: Rhino.query(Task).count }
18
+ # end
19
+ # end
20
+ #
21
+ # Without an explicit `rhino_route_group`, the group is read from the route's
22
+ # own `route_group` default:
23
+ #
24
+ # get "/api/admin/dashboard", to: "admin/dashboard#summary",
25
+ # defaults: { route_group: "admin" }
26
+ module RouteGroupContext
27
+ extend ActiveSupport::Concern
28
+
29
+ included do
30
+ # Inheritable, so a base controller can declare the group for a whole
31
+ # namespace and subclasses may override it.
32
+ class_attribute :rhino_route_group_name, instance_writer: false, default: nil
33
+
34
+ before_action :rhino_set_route_group
35
+ end
36
+
37
+ class_methods do
38
+ # Declare the route group this controller's actions belong to.
39
+ def rhino_route_group(name)
40
+ self.rhino_route_group_name = name.to_s
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ # Publish the group for Rhino::Context (and therefore for Rhino.query, the
47
+ # policies and the permission resolver) for the rest of this request.
48
+ def rhino_set_route_group
49
+ return unless defined?(RequestStore)
50
+
51
+ # An explicit declaration wins; otherwise fall back to the route's own
52
+ # `route_group` default, which only exists once a request is bound.
53
+ resolved = rhino_route_group_name.presence
54
+ resolved ||= params[:route_group].presence if request
55
+
56
+ RequestStore.store[:rhino_route_group] = resolved
57
+ end
58
+ end
59
+ end
@@ -62,7 +62,15 @@ module Rhino
62
62
  # captures the subdomain and feeds organization resolution exactly like the
63
63
  # path-prefix ":organization" does. Groups without a domain (nil/blank)
64
64
  # match any host (default, fully backward compatible).
65
- def route_group(name, prefix: "", domain: nil, middleware: [], models: :all, auth: false, hooks: nil)
65
+ #
66
+ # The optional `tenant:` keyword declares whether the group has a tenant
67
+ # boundary. It defaults to true: Rhino.query inside the group fails closed,
68
+ # raising Rhino::MissingTenantContext when an organization-scopable model is
69
+ # queried with no organization resolved. Pass `tenant: false` for a group
70
+ # that legitimately spans every organization — a back office or admin group
71
+ # whose operators see all tenants' rows.
72
+ def route_group(name, prefix: "", domain: nil, middleware: [], models: :all, auth: false, hooks: nil,
73
+ tenant: true)
66
74
  normalized_domain = domain.to_s.strip
67
75
  normalized_domain = nil if normalized_domain.empty?
68
76
 
@@ -72,10 +80,26 @@ module Rhino
72
80
  middleware: Array(middleware),
73
81
  models: models,
74
82
  auth: !!auth,
75
- hooks: hooks
83
+ hooks: hooks,
84
+ tenant: tenant != false
76
85
  }
77
86
  end
78
87
 
88
+ # Whether the named route group has a tenant boundary, i.e. whether
89
+ # Rhino.query must fail closed inside it. Only a group that explicitly
90
+ # declares `tenant: false` does not; every other answer — an unknown group,
91
+ # an untagged route, or no group at all (jobs, rake tasks, console) — is
92
+ # true, so the resolver keeps failing closed wherever the group is not
93
+ # provably non-tenant.
94
+ def group_tenant?(name)
95
+ return true if name.nil? || name.to_s.empty?
96
+
97
+ group = @route_groups[name.to_sym]
98
+ return true unless group
99
+
100
+ group.fetch(:tenant, true) != false
101
+ end
102
+
79
103
  # Resolve a model class from its slug
80
104
  def resolve_model(slug)
81
105
  klass_name = @models[slug.to_sym]
data/lib/rhino/context.rb CHANGED
@@ -30,10 +30,24 @@ module Rhino
30
30
  end
31
31
  end
32
32
 
33
+ # The active route group: the explicit override if one is in effect (set by
34
+ # Rhino.in_route_group), else the group stashed into RequestStore by Rhino's
35
+ # own controllers and by Rhino::RouteGroupContext in custom ones.
36
+ #
37
+ # Unlike user/organization, an override never *erases* the request's group —
38
+ # +with+ only installs a non-nil one — so Rhino.for_user(u).query(M) inside a
39
+ # non-tenant request keeps that request's group.
40
+ def route_group
41
+ value = store[:rhino_route_group]
42
+ value = RequestStore.store[:rhino_route_group] if value.nil? && defined?(RequestStore)
43
+
44
+ value.nil? || value.to_s.empty? ? nil : value.to_s
45
+ end
46
+
33
47
  # Run +block+ with the given user/organization installed into RequestStore.
34
48
  # Snapshots the prior RequestStore user+org, sets the new ones, yields, and
35
49
  # restores the snapshot in an ensure. Returns the block's value.
36
- def with(user:, organization:)
50
+ def with(user:, organization:, route_group: nil)
37
51
  return yield unless defined?(RequestStore)
38
52
 
39
53
  had_user = RequestStore.store.key?(:rhino_current_user)
@@ -41,6 +55,13 @@ module Rhino
41
55
  prev_user = RequestStore.store[:rhino_current_user]
42
56
  prev_org = RequestStore.store[:rhino_organization]
43
57
 
58
+ # A route group is only ever ADDED, never cleared: with no explicit group
59
+ # the request's own one (if any) stays in effect.
60
+ had_group = RequestStore.store.key?(:rhino_route_group)
61
+ prev_group = RequestStore.store[:rhino_route_group]
62
+ had_override_group = store.key?(:rhino_route_group)
63
+ prev_override_group = store[:rhino_route_group]
64
+
44
65
  # Track the active override so Context.user/organization prefer it even when
45
66
  # the passed value is nil (distinguishing "explicitly nil" from "absent").
46
67
  had_override_user = store.key?(:rhino_current_user)
@@ -53,6 +74,11 @@ module Rhino
53
74
  store[:rhino_current_user] = user
54
75
  store[:rhino_organization] = organization
55
76
 
77
+ if route_group
78
+ RequestStore.store[:rhino_route_group] = route_group.to_s
79
+ store[:rhino_route_group] = route_group.to_s
80
+ end
81
+
56
82
  begin
57
83
  yield
58
84
  ensure
@@ -77,6 +103,20 @@ module Rhino
77
103
  else
78
104
  store.delete(:rhino_organization)
79
105
  end
106
+
107
+ if route_group
108
+ if had_group
109
+ RequestStore.store[:rhino_route_group] = prev_group
110
+ else
111
+ RequestStore.store.delete(:rhino_route_group)
112
+ end
113
+
114
+ if had_override_group
115
+ store[:rhino_route_group] = prev_override_group
116
+ else
117
+ store.delete(:rhino_route_group)
118
+ end
119
+ end
80
120
  end
81
121
  end
82
122
 
data/lib/rhino/engine.rb CHANGED
@@ -22,6 +22,7 @@ module Rhino
22
22
  require "rhino/concerns/hidable_columns"
23
23
  require "rhino/concerns/has_uuid"
24
24
  require "rhino/concerns/has_auto_scope"
25
+ require "rhino/concerns/route_group_context"
25
26
 
26
27
  # Policies
27
28
  require "rhino/policies/resource_policy"
@@ -4,5 +4,14 @@ module Rhino
4
4
  # Raised by Rhino.query (and the explicit builder) when an organization-scopable
5
5
  # model is queried with no organization context available. Fail-closed: the
6
6
  # resolver never returns an unscoped relation for a tenant-scopable model.
7
- class MissingTenantContext < StandardError; end
7
+ class MissingTenantContext < StandardError
8
+ def initialize(model_class = nil)
9
+ super(
10
+ "Rhino.query(#{model_class}) requires an organization context but none is set. " \
11
+ "Use Rhino.for_user(...).in_organization(...) outside a tenant request, or declare " \
12
+ "the route group serving this request non-tenant with `tenant: false` if it " \
13
+ "legitimately spans every organization."
14
+ )
15
+ end
16
+ end
8
17
  end
data/lib/rhino/query.rb CHANGED
@@ -20,7 +20,11 @@ module Rhino
20
20
  # (BelongsToOrganization / HasAutoScope read RequestStore at BUILD time).
21
21
  #
22
22
  # Fail closed: an org-scopable model with no org context RAISES
23
- # Rhino::MissingTenantContext rather than returning an unscoped relation.
23
+ # Rhino::MissingTenantContext rather than returning an unscoped relation
24
+ # unless the query belongs to a route group declared non-tenant
25
+ # (`tenant: false`), either because the request is served by that group or
26
+ # because the caller said so with Rhino.in_route_group(...). An explicit
27
+ # organization is always honored, in every group.
24
28
  def query(model_class)
25
29
  org = Rhino::Context.organization
26
30
 
@@ -28,9 +32,11 @@ module Rhino
28
32
  relation = model_class.all
29
33
 
30
34
  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)
35
+ if org
36
+ relation = Rhino::ScopesToOrganization.scope_to_organization(relation, model_class, org, strict: true)
37
+ elsif Rhino.config.group_tenant?(Rhino::Context.route_group)
38
+ raise Rhino::MissingTenantContext, model_class.name
39
+ end
34
40
  end
35
41
 
36
42
  relation
@@ -48,6 +54,20 @@ module Rhino
48
54
  Rhino::PendingScopedContext.new(user: user)
49
55
  end
50
56
 
57
+ # Begin an explicit context that acts as the named route group, for use
58
+ # where no request resolves one — an Active Job, a rake task, the console,
59
+ # a test.
60
+ #
61
+ # The group's own configuration still decides the boundary: naming a group
62
+ # declared +tenant: false+ lets the query span every organization, while
63
+ # naming any other group keeps failing closed.
64
+ #
65
+ # Rhino.in_route_group(:admin).query(Task)
66
+ # Rhino.for_user(user).in_route_group(:admin).run { ... }
67
+ def in_route_group(route_group)
68
+ Rhino::PendingScopedContext.new(user: nil, route_group: route_group)
69
+ end
70
+
51
71
  # The ambient context resolver.
52
72
  def context
53
73
  Rhino::Context
@@ -84,9 +104,10 @@ module Rhino
84
104
  # Fluent explicit-context builder. Holds a user (and, once chained, an org) and
85
105
  # resolves queries with that context installed into RequestStore at build time.
86
106
  class PendingScopedContext
87
- def initialize(user:, organization: nil)
107
+ def initialize(user:, organization: nil, route_group: nil)
88
108
  @user = user
89
109
  @organization = organization
110
+ @route_group = route_group
90
111
  end
91
112
 
92
113
  def in_organization(organization)
@@ -94,6 +115,18 @@ module Rhino
94
115
  self
95
116
  end
96
117
 
118
+ # Act as the named route group for this context (see Rhino.in_route_group).
119
+ def in_route_group(route_group)
120
+ @route_group = route_group
121
+ self
122
+ end
123
+
124
+ # Set the explicit user for this context.
125
+ def for_user(user)
126
+ @user = user
127
+ self
128
+ end
129
+
97
130
  # Build a fully-baked relation for +model_class+ under this explicit context.
98
131
  #
99
132
  # Because Rails default_scopes bake at BUILD time, we install the user+org into
@@ -101,14 +134,14 @@ module Rhino
101
134
  # The org+user are baked into the returned relation — no stickiness, fully
102
135
  # isolated: a later Rhino.query with no context still fails closed.
103
136
  def query(model_class)
104
- Rhino::Context.with(user: @user, organization: @organization) do
137
+ Rhino::Context.with(user: @user, organization: @organization, route_group: @route_group) do
105
138
  Rhino.query(model_class)
106
139
  end
107
140
  end
108
141
 
109
142
  # Build a fully-baked, named-scoped relation under this explicit context.
110
143
  def scoped_query(model_class, scope_name = nil)
111
- Rhino::Context.with(user: @user, organization: @organization) do
144
+ Rhino::Context.with(user: @user, organization: @organization, route_group: @route_group) do
112
145
  Rhino.scoped_query(model_class, scope_name)
113
146
  end
114
147
  end
@@ -117,7 +150,7 @@ module Rhino
117
150
  # inside the block see the context; RequestStore is restored afterward.
118
151
  # Returns the block's value.
119
152
  def run(&block)
120
- Rhino::Context.with(user: @user, organization: @organization, &block)
153
+ Rhino::Context.with(user: @user, organization: @organization, route_group: @route_group, &block)
121
154
  end
122
155
  end
123
156
  end
@@ -44,6 +44,28 @@ Rhino.configure do |config|
44
44
  # the path prefix ':organization' does. Groups without a domain match any host.
45
45
  # config.route_group :admin, prefix: '', domain: 'admin.example.com', models: :all
46
46
  # config.route_group :tenant, prefix: '', domain: '{organization}.example.com', models: :all
47
+ #
48
+ # The optional `tenant:` keyword declares whether a group has a tenant
49
+ # boundary. It defaults to true: Rhino.query inside the group fails closed,
50
+ # raising Rhino::MissingTenantContext when an organization-scopable model is
51
+ # queried with no organization resolved. Pass `tenant: false` for a group that
52
+ # legitimately spans every organization — a back office whose operators see all
53
+ # tenants' rows. There, Rhino.query applies no organization filter and does not
54
+ # raise; the models' own scopes and the policies still apply, and an explicit
55
+ # Rhino.for_user(u).in_organization(org) still scopes.
56
+ # config.route_group :admin, prefix: 'admin', tenant: false, models: :all
57
+ #
58
+ # Custom (non-Rhino) controllers publish their group by including
59
+ # Rhino::RouteGroupContext and declaring `rhino_route_group :admin`, or by
60
+ # tagging the route with `defaults: { route_group: 'admin' }`. Outside a
61
+ # request (jobs, rake tasks, console) no group resolves, so the caller names
62
+ # the one it is acting as:
63
+ #
64
+ # Rhino.in_route_group(:admin).query(Task)
65
+ # Rhino.for_user(operator).in_route_group(:admin).run { ... }
66
+ #
67
+ # The group's own `tenant:` still decides: naming a tenant group there changes
68
+ # nothing, the query still fails closed.
47
69
 
48
70
  # config.route_group :default, prefix: '', middleware: [], models: :all
49
71
 
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.7.0"
4
+ VERSION = "4.7.3"
5
5
  end
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.7.0
4
+ version: 4.7.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Cipolla
@@ -215,6 +215,7 @@ files:
215
215
  - lib/rhino/concerns/has_uuid.rb
216
216
  - lib/rhino/concerns/has_validation.rb
217
217
  - lib/rhino/concerns/hidable_columns.rb
218
+ - lib/rhino/concerns/route_group_context.rb
218
219
  - lib/rhino/configuration.rb
219
220
  - lib/rhino/context.rb
220
221
  - lib/rhino/controllers/auth_controller.rb