rhino-rails 4.0.1 → 4.2.1
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/MIT-LICENSE +21 -0
- data/lib/rhino/auth_hooks.rb +28 -0
- data/lib/rhino/auth_rejected.rb +18 -0
- data/lib/rhino/blueprint/sorter.rb +118 -0
- data/lib/rhino/commands/blueprint_command.rb +22 -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 +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c15dd271d0b613f2effa9bb7c8d050b3214c74884e3021461150017f0f159af6
|
|
4
|
+
data.tar.gz: 5b4e5dcb736acc54674ad24e9d689cd93582e7ba0195554ef99e7735f2c33050
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 93c2de702533d23edcfe6b334aa0ffaf0f327efe6518a8c608e8072b4fe27862430d6591a651e730f198f9e9e8cf39456005464490a12137dadce641613ef268
|
|
7
|
+
data.tar.gz: 7fe767badf982f99dd3fca7b72c441929ee0eb336b72b117ed4a5296cdcaafacda6582a338025e6e611f1a0e87117d6d906469c8b01abc9339109d9da32c711a
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rhino
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -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
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rhino
|
|
4
|
+
module Blueprint
|
|
5
|
+
# Orders blueprints so that a referenced model's table is created before any
|
|
6
|
+
# model whose migration adds a foreign key to it (parents before children).
|
|
7
|
+
#
|
|
8
|
+
# Foreign keys are taken from +foreignId+ columns that carry a +foreign_model+
|
|
9
|
+
# mapping to another model in the same generation set. References that impose
|
|
10
|
+
# no ordering are ignored:
|
|
11
|
+
# - self-references (a model's FK to its own table — one migration),
|
|
12
|
+
# - references to models NOT in this set (e.g. Organization/User, whose
|
|
13
|
+
# tables are created by +rhino:install+, not the blueprint run).
|
|
14
|
+
#
|
|
15
|
+
# Uses Kahn's algorithm with a stable tie-break: among models with no
|
|
16
|
+
# remaining unmet dependency, the one earliest in the input order wins. The
|
|
17
|
+
# input is already alphabetical (by file name), so the output stays
|
|
18
|
+
# alphabetical wherever relationships don't force a reorder.
|
|
19
|
+
#
|
|
20
|
+
# A circular FK dependency (A -> B -> A) has no linear migration order. Such
|
|
21
|
+
# models are emitted in a deterministic best-effort order and reported via
|
|
22
|
+
# {#cycles} so the caller can warn (one side should be a nullable/deferred FK).
|
|
23
|
+
class Sorter
|
|
24
|
+
# Model names involved in a circular foreign-key dependency during the last
|
|
25
|
+
# {#sort} (empty when the dependency graph is acyclic).
|
|
26
|
+
attr_reader :cycles
|
|
27
|
+
|
|
28
|
+
def initialize
|
|
29
|
+
@cycles = []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Re-order blueprints into a valid migration sequence (parents first).
|
|
33
|
+
#
|
|
34
|
+
# @param blueprints [Array<Hash>] normalized blueprints (each with a
|
|
35
|
+
# +:model+ name and +:columns+).
|
|
36
|
+
# @return [Array<Hash>] the blueprints, re-ordered.
|
|
37
|
+
def sort(blueprints)
|
|
38
|
+
@cycles = []
|
|
39
|
+
return blueprints.dup if blueprints.length < 2
|
|
40
|
+
|
|
41
|
+
by_model = {}
|
|
42
|
+
blueprints.each do |bp|
|
|
43
|
+
model = bp[:model]
|
|
44
|
+
by_model[model] ||= bp if model
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
dependents = {}
|
|
48
|
+
indegree = {}
|
|
49
|
+
by_model.each_key do |m|
|
|
50
|
+
dependents[m] = []
|
|
51
|
+
indegree[m] = 0
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
by_model.each do |model, bp|
|
|
55
|
+
seen = {}
|
|
56
|
+
dependency_models(bp).each do |ref|
|
|
57
|
+
next if ref == model || !by_model.key?(ref) || seen[ref]
|
|
58
|
+
|
|
59
|
+
seen[ref] = true
|
|
60
|
+
dependents[ref] << model
|
|
61
|
+
indegree[model] += 1
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
input_order = by_model.keys
|
|
66
|
+
|
|
67
|
+
# Record the models that actually participate in a cycle (reachable from
|
|
68
|
+
# themselves), in input order, so the caller can warn about the full cycle.
|
|
69
|
+
input_order.each do |model|
|
|
70
|
+
@cycles << model if reachable_from_self?(model, dependents)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
ordered = []
|
|
74
|
+
resolved = {}
|
|
75
|
+
while ordered.length < by_model.length
|
|
76
|
+
# Earliest-input model with all dependencies already emitted...
|
|
77
|
+
pick = input_order.find { |m| !resolved[m] && indegree[m].zero? }
|
|
78
|
+
# ...or, when a cycle blocks the graph, the earliest unresolved model
|
|
79
|
+
# (deterministic cycle-break; the cycle itself is reported via #cycles).
|
|
80
|
+
pick ||= input_order.find { |m| !resolved[m] }
|
|
81
|
+
|
|
82
|
+
ordered << by_model[pick]
|
|
83
|
+
resolved[pick] = true
|
|
84
|
+
dependents[pick].each { |child| indegree[child] -= 1 }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
ordered
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# Whether +start+ can reach itself by following dependency edges — i.e. it
|
|
93
|
+
# participates in a circular foreign-key dependency. +adj+ maps a model to
|
|
94
|
+
# the models that reference it (its dependents).
|
|
95
|
+
def reachable_from_self?(start, adj)
|
|
96
|
+
stack = (adj[start] || []).dup
|
|
97
|
+
visited = {}
|
|
98
|
+
until stack.empty?
|
|
99
|
+
node = stack.pop
|
|
100
|
+
return true if node == start
|
|
101
|
+
next if visited[node]
|
|
102
|
+
|
|
103
|
+
visited[node] = true
|
|
104
|
+
(adj[node] || []).each { |n| stack << n }
|
|
105
|
+
end
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The model names this blueprint's migration adds foreign keys to, taken
|
|
110
|
+
# from its +foreignId+ columns that carry a +foreign_model+.
|
|
111
|
+
def dependency_models(blueprint)
|
|
112
|
+
(blueprint[:columns] || [])
|
|
113
|
+
.select { |c| c[:type] == "foreignId" && c[:foreign_model] }
|
|
114
|
+
.map { |c| c[:foreign_model] }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -4,6 +4,7 @@ require "rhino/commands/base_command"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "rhino/blueprint/blueprint_parser"
|
|
6
6
|
require "rhino/blueprint/blueprint_validator"
|
|
7
|
+
require "rhino/blueprint/sorter"
|
|
7
8
|
require "rhino/blueprint/manifest_manager"
|
|
8
9
|
require "rhino/blueprint/generators/policy_generator"
|
|
9
10
|
require "rhino/blueprint/generators/test_generator"
|
|
@@ -85,6 +86,27 @@ module Rhino
|
|
|
85
86
|
|
|
86
87
|
say " Found #{yaml_files.length} blueprint(s)", :cyan
|
|
87
88
|
|
|
89
|
+
# Order so a referenced model's table is migrated before any model that
|
|
90
|
+
# foreign-keys to it (parents before children). Migration timestamps are
|
|
91
|
+
# assigned in iteration order, so this is what makes the set runnable.
|
|
92
|
+
parsed_for_order = []
|
|
93
|
+
unparseable = []
|
|
94
|
+
yaml_files.each do |f|
|
|
95
|
+
parsed_for_order << { file: f, blueprint: parser.parse_model(f) }
|
|
96
|
+
rescue StandardError
|
|
97
|
+
unparseable << f
|
|
98
|
+
end
|
|
99
|
+
sorter = Rhino::Blueprint::Sorter.new
|
|
100
|
+
ordered = sorter.sort(parsed_for_order.map { |p| p[:blueprint] })
|
|
101
|
+
file_by_model = parsed_for_order.each_with_object({}) do |p, h|
|
|
102
|
+
h[p[:blueprint][:model]] ||= p[:file]
|
|
103
|
+
end
|
|
104
|
+
yaml_files = ordered.map { |bp| file_by_model[bp[:model]] }.compact + unparseable
|
|
105
|
+
if sorter.cycles.any?
|
|
106
|
+
say " ⚠ Circular foreign-key dependency among: #{sorter.cycles.join(', ')}. " \
|
|
107
|
+
"Migration order is best-effort — make one side nullable or add the FK in a later migration.", :yellow
|
|
108
|
+
end
|
|
109
|
+
|
|
88
110
|
# 3. Process each blueprint
|
|
89
111
|
is_multi_tenant = multi_tenant_enabled?
|
|
90
112
|
org_identifier = detect_org_identifier
|
|
@@ -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
|