rhino-rails 4.0.1 → 4.2.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/lib/rhino/auth_hooks.rb +28 -0
- data/lib/rhino/auth_rejected.rb +18 -0
- data/lib/rhino/concerns/has_permissions.rb +24 -1
- data/lib/rhino/configuration.rb +95 -2
- data/lib/rhino/controllers/auth_controller.rb +118 -2
- data/lib/rhino/controllers/invitations_controller.rb +37 -4
- data/lib/rhino/controllers/resources_controller.rb +94 -1
- data/lib/rhino/engine.rb +2 -1
- data/lib/rhino/group_membership.rb +93 -0
- data/lib/rhino/models/organization_invitation.rb +15 -4
- data/lib/rhino/policies/resource_policy.rb +36 -1
- data/lib/rhino/routes.rb +131 -10
- data/lib/rhino/routing/domain_constraint.rb +101 -0
- data/lib/rhino/routing/route_group_validator.rb +121 -0
- data/lib/rhino/templates/multi_tenant/migrations/add_group_membership.rb.erb +59 -0
- data/lib/rhino/templates/multi_tenant/migrations/create_user_roles.rb.erb +13 -2
- data/lib/rhino/templates/rhino.rb +32 -0
- data/lib/rhino/version.rb +1 -1
- data/lib/rhino.rb +5 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1168eadabff8797a003d07665dfb30f4c1581e78e5df400ac9d72116d323d413
|
|
4
|
+
data.tar.gz: 0ec4d0b8ce91cadecdc94af585261ecd148157eacf7e720494adeb566187010c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e79bfcd2a29520962874939ec5621c5dbb6242c19681515a36aac49f0ba158a95bbf631638055a342de181248c43e2ce7f9480d77ef79900ec216682f238e3bf
|
|
7
|
+
data.tar.gz: 264a1e3201782888918077db9e46ba4bea10bb7262d14b5d4a2eaa67ff843fd5b859579dfdfca57041d5a11babfc26f57bc23806359fcf7ca931f07c70d74d41
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rhino
|
|
4
|
+
# Base class for per-group auth lifecycle hooks. A group's optional `hooks:`
|
|
5
|
+
# class may subclass this (or simply respond to the event methods) and
|
|
6
|
+
# override any of the events; each defaults to a no-op.
|
|
7
|
+
#
|
|
8
|
+
# Each event receives the affected user and a context hash:
|
|
9
|
+
# { user:, route_group:, organization:, token:, request: }
|
|
10
|
+
#
|
|
11
|
+
# A hook rejects an action by raising Rhino::AuthRejected (optionally with a
|
|
12
|
+
# status). For token-issuing actions (login/register) the controller revokes
|
|
13
|
+
# the just-issued token and returns the status; for the others it returns the
|
|
14
|
+
# status without side effects.
|
|
15
|
+
#
|
|
16
|
+
# See GROUP_AUTH_DESIGN.md §7.
|
|
17
|
+
class AuthHooks
|
|
18
|
+
def after_login(user, context = {}); end
|
|
19
|
+
|
|
20
|
+
def after_logout(user, context = {}); end
|
|
21
|
+
|
|
22
|
+
def after_register(user, context = {}); end
|
|
23
|
+
|
|
24
|
+
def after_password_recover(user, context = {}); end
|
|
25
|
+
|
|
26
|
+
def after_password_reset(user, context = {}); end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rhino
|
|
4
|
+
# Raised by a lifecycle hook (or membership enforcement) to reject an auth
|
|
5
|
+
# action. Carries an HTTP status (default 403) and a message. For
|
|
6
|
+
# token-issuing actions (login/register) the controller revokes the
|
|
7
|
+
# just-issued token before returning the status.
|
|
8
|
+
#
|
|
9
|
+
# See GROUP_AUTH_DESIGN.md §7.
|
|
10
|
+
class AuthRejected < StandardError
|
|
11
|
+
attr_reader :status
|
|
12
|
+
|
|
13
|
+
def initialize(message = "Forbidden", status: 403)
|
|
14
|
+
@status = status
|
|
15
|
+
super(message)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -35,9 +35,28 @@ module Rhino
|
|
|
35
35
|
# @param permission [String] Permission string like 'posts.index'
|
|
36
36
|
# @param organization [Object, nil] Organization to check permissions for
|
|
37
37
|
# @return [Boolean]
|
|
38
|
-
def has_permission?(permission, organization = nil)
|
|
38
|
+
def has_permission?(permission, organization = nil, route_group: nil)
|
|
39
39
|
return false if permission.blank?
|
|
40
40
|
|
|
41
|
+
# Group-aware permission resolution (GROUP_AUTH_DESIGN.md §6). Only active
|
|
42
|
+
# when enforce_group_membership is on. Permissions then resolve from the
|
|
43
|
+
# membership row matching (route_group, organization), not the heuristic.
|
|
44
|
+
if group_membership_enforced?
|
|
45
|
+
membership = Rhino::GroupMembership.matching_membership(self, route_group, organization)
|
|
46
|
+
return false unless membership
|
|
47
|
+
|
|
48
|
+
ur_permissions = parse_permissions(membership.respond_to?(:permissions) ? membership.permissions : nil)
|
|
49
|
+
return matches_permission?(permission, ur_permissions) if ur_permissions.present?
|
|
50
|
+
|
|
51
|
+
role = membership.respond_to?(:role) ? membership.role : nil
|
|
52
|
+
if role
|
|
53
|
+
role_permissions = parse_permissions(role.respond_to?(:permissions) ? role.permissions : nil)
|
|
54
|
+
return matches_permission?(permission, role_permissions) if role_permissions.present?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
return false
|
|
58
|
+
end
|
|
59
|
+
|
|
41
60
|
if organization
|
|
42
61
|
# Tenant route group: check permissions for this organization
|
|
43
62
|
user_role = find_user_role(organization)
|
|
@@ -113,5 +132,9 @@ module Rhino
|
|
|
113
132
|
|
|
114
133
|
user_roles.find_by(organization_id: organization.id)
|
|
115
134
|
end
|
|
135
|
+
|
|
136
|
+
def group_membership_enforced?
|
|
137
|
+
Rhino.config.respond_to?(:enforce_group_membership?) && Rhino.config.enforce_group_membership?
|
|
138
|
+
end
|
|
116
139
|
end
|
|
117
140
|
end
|
data/lib/rhino/configuration.rb
CHANGED
|
@@ -4,6 +4,7 @@ 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
|
+
attr_reader :auth
|
|
7
8
|
|
|
8
9
|
def initialize
|
|
9
10
|
@models = {}
|
|
@@ -20,11 +21,26 @@ module Rhino
|
|
|
20
21
|
max_operations: 50,
|
|
21
22
|
allowed_models: nil
|
|
22
23
|
}
|
|
24
|
+
@auth = {
|
|
25
|
+
enforce_group_membership: false
|
|
26
|
+
}
|
|
23
27
|
@test_framework = "rspec"
|
|
24
28
|
@client_path = nil
|
|
25
29
|
@mobile_path = nil
|
|
26
30
|
end
|
|
27
31
|
|
|
32
|
+
# Auth configuration accessor. Merges supplied keys over defaults so a host
|
|
33
|
+
# app can set just `enforce_group_membership` without losing future keys.
|
|
34
|
+
def auth=(value)
|
|
35
|
+
@auth = { enforce_group_membership: false }.merge((value || {}).symbolize_keys)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Master flag (default off). When off, behavior is byte-for-byte today's:
|
|
39
|
+
# no group-membership enforcement.
|
|
40
|
+
def enforce_group_membership?
|
|
41
|
+
!!@auth[:enforce_group_membership]
|
|
42
|
+
end
|
|
43
|
+
|
|
28
44
|
# Register a model with its slug
|
|
29
45
|
# Usage: config.model :posts, 'Post'
|
|
30
46
|
def model(slug, klass_name)
|
|
@@ -33,11 +49,24 @@ module Rhino
|
|
|
33
49
|
|
|
34
50
|
# Register a route group with its configuration
|
|
35
51
|
# Usage: config.route_group :tenant, prefix: ':organization', middleware: [Rhino::Middleware::ResolveOrganizationFromRoute], models: :all
|
|
36
|
-
|
|
52
|
+
#
|
|
53
|
+
# The optional `domain:` keyword constrains the group's routes to a specific
|
|
54
|
+
# host. Two groups can then share the same `prefix:` but live on different
|
|
55
|
+
# domains. A parameterized domain such as "{organization}.example.com"
|
|
56
|
+
# captures the subdomain and feeds organization resolution exactly like the
|
|
57
|
+
# path-prefix ":organization" does. Groups without a domain (nil/blank)
|
|
58
|
+
# match any host (default, fully backward compatible).
|
|
59
|
+
def route_group(name, prefix: "", domain: nil, middleware: [], models: :all, auth: false, hooks: nil)
|
|
60
|
+
normalized_domain = domain.to_s.strip
|
|
61
|
+
normalized_domain = nil if normalized_domain.empty?
|
|
62
|
+
|
|
37
63
|
@route_groups[name.to_sym] = {
|
|
38
64
|
prefix: prefix.to_s,
|
|
65
|
+
domain: normalized_domain,
|
|
39
66
|
middleware: Array(middleware),
|
|
40
|
-
models: models
|
|
67
|
+
models: models,
|
|
68
|
+
auth: !!auth,
|
|
69
|
+
hooks: hooks
|
|
41
70
|
}
|
|
42
71
|
end
|
|
43
72
|
|
|
@@ -97,5 +126,69 @@ module Rhino
|
|
|
97
126
|
def model_in_group?(slug, group_name)
|
|
98
127
|
models_for_group(group_name).include?(slug.to_sym)
|
|
99
128
|
end
|
|
129
|
+
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
# Group-aware auth helpers (see GROUP_AUTH_DESIGN.md §5/§7)
|
|
132
|
+
# ------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
# Whether a group has per-group auth routes enabled (`auth: true`).
|
|
135
|
+
# The `public` group is never auth-enabled.
|
|
136
|
+
def group_auth_enabled?(group_name)
|
|
137
|
+
return false if group_name.to_s == "public"
|
|
138
|
+
|
|
139
|
+
group = @route_groups[group_name.to_sym]
|
|
140
|
+
!!(group && group[:auth])
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Names of all groups (except :public) that opted into per-group auth.
|
|
144
|
+
def auth_enabled_groups
|
|
145
|
+
@route_groups.keys.reject { |name| name.to_s == "public" }
|
|
146
|
+
.select { |name| group_auth_enabled?(name) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Names of auth-enabled groups that have an empty prefix AND no domain, i.e.
|
|
150
|
+
# groups whose auth routes would be byte-for-byte identical to the legacy
|
|
151
|
+
# unprefixed /api/auth/* set (GROUP_AUTH_DESIGN.md §11.1). Such a group IS
|
|
152
|
+
# the default/legacy auth: the legacy routes adopt its route_group/hooks
|
|
153
|
+
# instead of registering a colliding second set. Two or more is a conflict
|
|
154
|
+
# (raised by the route-group validator).
|
|
155
|
+
def auth_enabled_legacy_groups
|
|
156
|
+
auth_enabled_groups.select do |name|
|
|
157
|
+
group = @route_groups[name.to_sym]
|
|
158
|
+
prefix = group[:prefix].to_s
|
|
159
|
+
domain = group[:domain]
|
|
160
|
+
prefix.empty? && (domain.nil? || domain.to_s.strip.empty?)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Resolve the configured lifecycle-hooks class for a group, instantiated.
|
|
165
|
+
# Returns nil when the group has no hooks configured. Accepts a class, a
|
|
166
|
+
# class name string, or an instance.
|
|
167
|
+
def hooks_for_group(group_name)
|
|
168
|
+
return nil if group_name.nil?
|
|
169
|
+
|
|
170
|
+
group = @route_groups[group_name.to_sym]
|
|
171
|
+
return nil unless group
|
|
172
|
+
|
|
173
|
+
hooks = group[:hooks]
|
|
174
|
+
return nil if hooks.nil?
|
|
175
|
+
|
|
176
|
+
case hooks
|
|
177
|
+
when String
|
|
178
|
+
klass = hooks.safe_constantize
|
|
179
|
+
klass&.new
|
|
180
|
+
when Class
|
|
181
|
+
hooks.new
|
|
182
|
+
else
|
|
183
|
+
hooks
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Whether a group is a tenant group (organization-scoped). Only the
|
|
188
|
+
# reserved `:tenant` group is treated as a tenant group, matching
|
|
189
|
+
# has_tenant_group?.
|
|
190
|
+
def group_is_tenant?(group_name)
|
|
191
|
+
group_name.to_s == "tenant"
|
|
192
|
+
end
|
|
100
193
|
end
|
|
101
194
|
end
|
|
@@ -30,6 +30,12 @@ module Rhino
|
|
|
30
30
|
return render json: { message: "Invalid credentials" }, status: :unauthorized
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# Group membership is a coarse access gate (GROUP_AUTH_DESIGN.md §6).
|
|
34
|
+
# Gated entirely by the enforce_group_membership flag; off = unchanged.
|
|
35
|
+
if membership_enforced? && !group_member?(user)
|
|
36
|
+
return render json: { message: "You are not a member of this group" }, status: :forbidden
|
|
37
|
+
end
|
|
38
|
+
|
|
33
39
|
token = generate_api_token(user)
|
|
34
40
|
|
|
35
41
|
# Get the first organization the user belongs to
|
|
@@ -39,6 +45,10 @@ module Rhino
|
|
|
39
45
|
organization_slug = first_org&.slug
|
|
40
46
|
end
|
|
41
47
|
|
|
48
|
+
# Lifecycle hook (GROUP_AUTH_DESIGN.md §7). A reject revokes the token.
|
|
49
|
+
hook_response = run_hook(:after_login, user, token: token, revoke_on_reject: true)
|
|
50
|
+
return if hook_response
|
|
51
|
+
|
|
42
52
|
render json: {
|
|
43
53
|
token: token,
|
|
44
54
|
organization_slug: organization_slug
|
|
@@ -55,6 +65,10 @@ module Rhino
|
|
|
55
65
|
user.update_column(:api_token, SecureRandom.hex(32))
|
|
56
66
|
end
|
|
57
67
|
|
|
68
|
+
# Token is already gone; a rejecting hook only changes the status code.
|
|
69
|
+
hook_response = run_hook(:after_logout, user, revoke_on_reject: false)
|
|
70
|
+
return if hook_response
|
|
71
|
+
|
|
58
72
|
render json: { message: "Logged out successfully" }, status: :ok
|
|
59
73
|
end
|
|
60
74
|
|
|
@@ -83,9 +97,18 @@ module Rhino
|
|
|
83
97
|
# Send email via mailer if available
|
|
84
98
|
mailer_class = "Rhino::PasswordRecoveryMailer".safe_constantize
|
|
85
99
|
mailer_class&.recover(user, token)&.deliver_later
|
|
100
|
+
|
|
101
|
+
# Lifecycle hook fires only when a user actually exists, and its
|
|
102
|
+
# rejection is SWALLOWED here. recover_password must be an enumeration
|
|
103
|
+
# oracle-free endpoint: a rejecting hook would otherwise return a 403
|
|
104
|
+
# only for existing emails, letting a caller distinguish real accounts
|
|
105
|
+
# from fake ones. The hook still runs for its side effects (e.g.
|
|
106
|
+
# auditing, throttling), but its reject never changes the response.
|
|
107
|
+
run_hook(:after_password_recover, user, revoke_on_reject: false, swallow_reject: true)
|
|
86
108
|
end
|
|
87
109
|
|
|
88
|
-
# Always return
|
|
110
|
+
# Always return the same response (existing OR non-existing email) to
|
|
111
|
+
# prevent email enumeration — this is the documented contract.
|
|
89
112
|
render json: { message: "Password recovery email sent." }, status: :ok
|
|
90
113
|
end
|
|
91
114
|
|
|
@@ -132,6 +155,9 @@ module Rhino
|
|
|
132
155
|
user.reset_password_sent_at = nil
|
|
133
156
|
user.save!
|
|
134
157
|
|
|
158
|
+
hook_response = run_hook(:after_password_reset, user, revoke_on_reject: false)
|
|
159
|
+
return if hook_response
|
|
160
|
+
|
|
135
161
|
render json: { message: "Password has been reset." }, status: :ok
|
|
136
162
|
end
|
|
137
163
|
|
|
@@ -184,7 +210,7 @@ module Rhino
|
|
|
184
210
|
password: params[:password]
|
|
185
211
|
)
|
|
186
212
|
|
|
187
|
-
# Accept invitation (adds user to organization)
|
|
213
|
+
# Accept invitation (adds user to organization, carrying its route_group)
|
|
188
214
|
invitation.accept!(user)
|
|
189
215
|
|
|
190
216
|
# Generate token
|
|
@@ -194,6 +220,15 @@ module Rhino
|
|
|
194
220
|
organization = invitation.organization
|
|
195
221
|
organization_slug = organization&.slug
|
|
196
222
|
|
|
223
|
+
# Lifecycle hook for the group the invitee joined (from the invitation).
|
|
224
|
+
invite_group = invitation.respond_to?(:route_group) ? invitation.route_group : nil
|
|
225
|
+
hook_response = run_hook(
|
|
226
|
+
:after_register, user,
|
|
227
|
+
token: token, revoke_on_reject: true,
|
|
228
|
+
group_override: invite_group, organization_override: organization
|
|
229
|
+
)
|
|
230
|
+
return if hook_response
|
|
231
|
+
|
|
197
232
|
render json: {
|
|
198
233
|
message: "Registration successful",
|
|
199
234
|
token: token,
|
|
@@ -204,6 +239,87 @@ module Rhino
|
|
|
204
239
|
|
|
205
240
|
private
|
|
206
241
|
|
|
242
|
+
# ------------------------------------------------------------------
|
|
243
|
+
# Group-aware auth (GROUP_AUTH_DESIGN.md §5/§6/§7)
|
|
244
|
+
# ------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
# The route_group resolved from the matched route's defaults. nil for the
|
|
247
|
+
# legacy unprefixed auth routes with no :default group.
|
|
248
|
+
def current_route_group
|
|
249
|
+
params[:route_group].presence
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Resolve the organization for group-aware auth (tenant groups carry the
|
|
253
|
+
# :organization prefix param). Returns nil for non-tenant/legacy routes.
|
|
254
|
+
def current_organization
|
|
255
|
+
return @current_organization if defined?(@current_organization)
|
|
256
|
+
|
|
257
|
+
@current_organization = begin
|
|
258
|
+
org_identifier = params[:organization]
|
|
259
|
+
if org_identifier.present?
|
|
260
|
+
org_class = "Organization".safe_constantize
|
|
261
|
+
if org_class
|
|
262
|
+
column = Rhino.config.multi_tenant[:organization_identifier_column] || "id"
|
|
263
|
+
org_class.find_by(column => org_identifier)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def membership_enforced?
|
|
270
|
+
Rhino.config.respond_to?(:enforce_group_membership?) && Rhino.config.enforce_group_membership?
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Coarse membership gate for the resolved group (and org for tenant groups).
|
|
274
|
+
def group_member?(user)
|
|
275
|
+
Rhino::GroupMembership.member?(user, current_route_group, current_organization)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Run the configured lifecycle hook for the current (or overridden) group.
|
|
279
|
+
# Returns true when a response was rendered (rejection), false/nil otherwise.
|
|
280
|
+
#
|
|
281
|
+
# On Rhino::AuthRejected: for token-issuing actions (revoke_on_reject) the
|
|
282
|
+
# just-issued token is revoked, then the carried status is returned.
|
|
283
|
+
#
|
|
284
|
+
# When swallow_reject is true (password/recover only), a rejection is run
|
|
285
|
+
# for its side effects but NOT surfaced: the action proceeds to its uniform
|
|
286
|
+
# response so the endpoint cannot be used as an email-enumeration oracle.
|
|
287
|
+
def run_hook(event, user, token: nil, revoke_on_reject: false, swallow_reject: false, group_override: :__none__, organization_override: :__none__)
|
|
288
|
+
group = group_override == :__none__ ? current_route_group : group_override
|
|
289
|
+
org = organization_override == :__none__ ? current_organization : organization_override
|
|
290
|
+
|
|
291
|
+
hooks = Rhino.config.respond_to?(:hooks_for_group) ? Rhino.config.hooks_for_group(group) : nil
|
|
292
|
+
return false unless hooks
|
|
293
|
+
return false unless hooks.respond_to?(event)
|
|
294
|
+
|
|
295
|
+
context = {
|
|
296
|
+
user: user,
|
|
297
|
+
route_group: group,
|
|
298
|
+
organization: org,
|
|
299
|
+
token: token,
|
|
300
|
+
request: request
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
hooks.public_send(event, user, context)
|
|
304
|
+
false
|
|
305
|
+
rescue Rhino::AuthRejected => e
|
|
306
|
+
return false if swallow_reject
|
|
307
|
+
|
|
308
|
+
revoke_token(user) if revoke_on_reject
|
|
309
|
+
render json: { message: e.message }, status: e.status
|
|
310
|
+
true
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def revoke_token(user)
|
|
314
|
+
return unless user
|
|
315
|
+
|
|
316
|
+
if user.respond_to?(:regenerate_api_token)
|
|
317
|
+
user.regenerate_api_token
|
|
318
|
+
elsif user.respond_to?(:update_column) && user.class.column_names.include?("api_token")
|
|
319
|
+
user.update_column(:api_token, SecureRandom.hex(32))
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
207
323
|
def authenticate_user!
|
|
208
324
|
unless current_user
|
|
209
325
|
render json: { message: "Unauthenticated." }, status: :unauthorized
|
|
@@ -53,6 +53,20 @@ module Rhino
|
|
|
53
53
|
|
|
54
54
|
email = params[:email].to_s.strip
|
|
55
55
|
role_id = params[:role_id]
|
|
56
|
+
route_group = params[:route_group].presence
|
|
57
|
+
|
|
58
|
+
# The public group is never auth-enabled — it cannot be invited into.
|
|
59
|
+
if route_group.to_s == "public"
|
|
60
|
+
return render json: { message: "Cannot invite into the public group" }, status: :unprocessable_entity
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# When group-membership enforcement is on, the inviter must themselves be
|
|
64
|
+
# a member of the target group (GROUP_AUTH_DESIGN.md §8).
|
|
65
|
+
if route_group.present? && membership_enforced?
|
|
66
|
+
unless Rhino::GroupMembership.member?(current_user, route_group, current_organization)
|
|
67
|
+
return render json: { message: "You are not a member of this group" }, status: :forbidden
|
|
68
|
+
end
|
|
69
|
+
end
|
|
56
70
|
|
|
57
71
|
# Check if user already exists and is in organization
|
|
58
72
|
user_class = "User".safe_constantize
|
|
@@ -75,13 +89,28 @@ module Rhino
|
|
|
75
89
|
return render json: { message: "A pending invitation already exists for this email" }, status: :unprocessable_entity
|
|
76
90
|
end
|
|
77
91
|
|
|
78
|
-
# Create invitation
|
|
79
|
-
|
|
80
|
-
|
|
92
|
+
# Create invitation. Non-tenant groups (e.g. :admin, :driver) have no
|
|
93
|
+
# organization, so a non-tenant invite must store organization_id = nil —
|
|
94
|
+
# matching the nullable membership row that accept! will create. Only the
|
|
95
|
+
# tenant group (and the legacy no-group invite) carries the current org.
|
|
96
|
+
invitation_org_id =
|
|
97
|
+
if route_group.present? && !Rhino.config.group_is_tenant?(route_group)
|
|
98
|
+
nil
|
|
99
|
+
else
|
|
100
|
+
current_organization.id
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
invitation_attrs = {
|
|
104
|
+
organization_id: invitation_org_id,
|
|
81
105
|
email: email,
|
|
82
106
|
role_id: role_id,
|
|
83
107
|
invited_by: current_user.id
|
|
84
|
-
|
|
108
|
+
}
|
|
109
|
+
if route_group.present? && OrganizationInvitation.column_names.include?("route_group")
|
|
110
|
+
invitation_attrs[:route_group] = route_group
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
invitation = OrganizationInvitation.create!(invitation_attrs)
|
|
85
114
|
|
|
86
115
|
# Send notification email
|
|
87
116
|
send_invitation_email(invitation)
|
|
@@ -221,6 +250,10 @@ module Rhino
|
|
|
221
250
|
@organization
|
|
222
251
|
end
|
|
223
252
|
|
|
253
|
+
def membership_enforced?
|
|
254
|
+
Rhino.config.respond_to?(:enforce_group_membership?) && Rhino.config.enforce_group_membership?
|
|
255
|
+
end
|
|
256
|
+
|
|
224
257
|
def send_invitation_email(invitation)
|
|
225
258
|
mailer_class = "Rhino::InvitationMailer".safe_constantize
|
|
226
259
|
mailer_class&.invite(invitation)&.deliver_later
|
|
@@ -20,8 +20,19 @@ module Rhino
|
|
|
20
20
|
@@organization_path_cache = {}
|
|
21
21
|
|
|
22
22
|
before_action :set_model_class
|
|
23
|
+
before_action :set_route_group
|
|
24
|
+
# GROUP_AUTH_DESIGN.md §11.2: when enforce_group_membership is ON, the
|
|
25
|
+
# membership gate (403) must take precedence over the org-resolution 404, so
|
|
26
|
+
# an authenticated non-member of the requested group gets 403 rather than the
|
|
27
|
+
# info-hiding 404. We therefore authenticate and run the gate BEFORE resolving
|
|
28
|
+
# the organization when enforcement is on; the gate resolves the org itself as
|
|
29
|
+
# needed (a genuinely non-existent org still 404s inside the gate). When
|
|
30
|
+
# enforcement is OFF (default), the original order is preserved byte-for-byte:
|
|
31
|
+
# resolve_organization (404) runs first, then authenticate.
|
|
32
|
+
before_action :authenticate_user_before_org!, if: :authenticate_before_org?
|
|
33
|
+
before_action :enforce_group_membership, if: :authenticate_before_org?
|
|
23
34
|
before_action :resolve_organization
|
|
24
|
-
before_action :
|
|
35
|
+
before_action :authenticate_user_after_org!, if: :authenticate_after_org?
|
|
25
36
|
|
|
26
37
|
# GET /api/{slug}
|
|
27
38
|
def index
|
|
@@ -275,10 +286,92 @@ module Rhino
|
|
|
275
286
|
current_route_group == "public"
|
|
276
287
|
end
|
|
277
288
|
|
|
289
|
+
# Whether enforce_group_membership is on (GROUP_AUTH_DESIGN.md §6/§11.2).
|
|
290
|
+
def membership_enforced?
|
|
291
|
+
Rhino.config.respond_to?(:enforce_group_membership?) &&
|
|
292
|
+
Rhino.config.enforce_group_membership?
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# When enforcement is ON we authenticate + run the membership gate BEFORE
|
|
296
|
+
# resolving the org so a 403 (non-member) takes precedence over the org 404.
|
|
297
|
+
def authenticate_before_org?
|
|
298
|
+
!public_route_group? && membership_enforced?
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# When enforcement is OFF (default) we keep today's order: resolve the org
|
|
302
|
+
# (its 404) first, then authenticate.
|
|
303
|
+
def authenticate_after_org?
|
|
304
|
+
!public_route_group? && !membership_enforced?
|
|
305
|
+
end
|
|
306
|
+
|
|
278
307
|
def current_route_group
|
|
279
308
|
params[:route_group]
|
|
280
309
|
end
|
|
281
310
|
|
|
311
|
+
# Expose the resolved route_group to policies/permissions via RequestStore
|
|
312
|
+
# so group-aware permission resolution (when enforcement is on) can use it.
|
|
313
|
+
def set_route_group
|
|
314
|
+
return unless defined?(RequestStore)
|
|
315
|
+
|
|
316
|
+
RequestStore.store[:rhino_route_group] = params[:route_group].presence
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Coarse group-membership gate (GROUP_AUTH_DESIGN.md §6). Entirely gated by
|
|
320
|
+
# the enforce_group_membership flag; off = unchanged. Runs after auth, so an
|
|
321
|
+
# authenticated user without a matching membership row gets 403.
|
|
322
|
+
def enforce_group_membership
|
|
323
|
+
return unless membership_enforced?
|
|
324
|
+
|
|
325
|
+
user = current_user
|
|
326
|
+
return unless user # unauthenticated already handled by authenticate_user!
|
|
327
|
+
|
|
328
|
+
# §11.2: this gate runs BEFORE resolve_organization, so it resolves the org
|
|
329
|
+
# itself. A non-existent org identifier still 404s (info-hiding for a
|
|
330
|
+
# resource that cannot exist); an authenticated NON-MEMBER gets 403, taking
|
|
331
|
+
# precedence over the cross-org 404. resolve_organization re-runs afterward
|
|
332
|
+
# to set request.env/RequestStore for the rest of the request.
|
|
333
|
+
org = resolve_membership_organization
|
|
334
|
+
return if performed? # 404: org identifier supplied but no such org
|
|
335
|
+
|
|
336
|
+
unless Rhino::GroupMembership.member?(user, current_route_group, org)
|
|
337
|
+
render json: { message: "You are not a member of this group" }, status: :forbidden
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Resolve the organization for the membership gate from the route's
|
|
342
|
+
# :organization param. Renders 404 and returns nil when an identifier is
|
|
343
|
+
# supplied but matches no organization. Returns nil (no render) when no
|
|
344
|
+
# identifier is present (non-tenant groups).
|
|
345
|
+
def resolve_membership_organization
|
|
346
|
+
org_identifier = params[:organization]
|
|
347
|
+
return nil unless org_identifier.present?
|
|
348
|
+
|
|
349
|
+
org_class = "Organization".safe_constantize
|
|
350
|
+
return nil unless org_class
|
|
351
|
+
|
|
352
|
+
column = Rhino.config.multi_tenant[:organization_identifier_column] || "id"
|
|
353
|
+
organization = org_class.find_by(column => org_identifier)
|
|
354
|
+
|
|
355
|
+
unless organization
|
|
356
|
+
render json: { message: "Organization not found" }, status: :not_found
|
|
357
|
+
return nil
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
organization
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Two distinct callback names so the before_action chain registers BOTH
|
|
364
|
+
# entries (Rails de-duplicates callbacks by method name; reusing
|
|
365
|
+
# :authenticate_user! for both the pre- and post-org slots would collapse
|
|
366
|
+
# them into one with a single condition). Both delegate to authenticate_user!.
|
|
367
|
+
def authenticate_user_before_org!
|
|
368
|
+
authenticate_user!
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def authenticate_user_after_org!
|
|
372
|
+
authenticate_user!
|
|
373
|
+
end
|
|
374
|
+
|
|
282
375
|
def authenticate_user!
|
|
283
376
|
unless current_user
|
|
284
377
|
render json: { message: "Unauthenticated." }, status: :unauthorized
|
data/lib/rhino/engine.rb
CHANGED
|
@@ -27,8 +27,9 @@ module Rhino
|
|
|
27
27
|
require "rhino/policies/resource_policy"
|
|
28
28
|
require "rhino/policies/invitation_policy"
|
|
29
29
|
|
|
30
|
-
# Query builder and routes
|
|
30
|
+
# Query builder, routing constraints and routes
|
|
31
31
|
require "rhino/query_builder"
|
|
32
|
+
require "rhino/routing/domain_constraint"
|
|
32
33
|
require "rhino/routes"
|
|
33
34
|
|
|
34
35
|
# Controllers
|