rhino-rails 4.2.1 → 4.4.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/README.md +1 -0
- data/lib/rhino/commands/install_command.rb +3 -2
- data/lib/rhino/concerns/has_permissions.rb +142 -46
- data/lib/rhino/concerns/has_rhino.rb +21 -0
- data/lib/rhino/controllers/resources_controller.rb +7 -3
- data/lib/rhino/models/rhino_model.rb +30 -0
- data/lib/rhino/permissions_migrator.rb +124 -0
- data/lib/rhino/query_builder.rb +38 -1
- data/lib/rhino/scope_not_allowed_error.rb +7 -0
- data/lib/rhino/tasks/rhino.rake +13 -0
- data/lib/rhino/templates/multi_tenant/migrations/create_org_role_permissions.rb.erb +21 -0
- data/lib/rhino/templates/multi_tenant/migrations/create_user_roles.rb.erb +7 -0
- data/lib/rhino/templates/multi_tenant/models/org_role_permission.rb.erb +14 -0
- data/lib/rhino/version.rb +1 -1
- data/lib/rhino.rb +1 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0155b30eedf93061b38295ed96254194529de0abf03aad309becc312a512c5ba
|
|
4
|
+
data.tar.gz: 5e1ce1624bab2a77f8603ebdf6984a43e4bd71d277f51900ae533465e4fd0881
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 975f1ee3efc83423ae775131eace5689a7b09269541848da21e286f2f55cedd71b265a8e3c5c448a85cc7b9c8ecdde2e27dd615ef1a26719732a7d958cbfb8da
|
|
7
|
+
data.tar.gz: 3a32ccaf04ad8f351828752a83efac31503d53afbd4c3239d0f38e836c8e151dd4681f2a650a853e9d6d728e6477f34e72023680c6bbd36dc63369ad6ca6d10c
|
data/README.md
CHANGED
|
@@ -40,6 +40,7 @@ Register a model, get a full REST API instantly.
|
|
|
40
40
|
| 26 | **Generator CLI** | `rhino:install`, `rhino:generate`, `rhino:blueprint`, `rhino:export_postman`. |
|
|
41
41
|
| 27 | **Postman Export** | Auto-generated Postman Collection v2.1 with all endpoints. |
|
|
42
42
|
| 28 | **Blueprint System** | YAML-to-code generation for models, migrations, factories, policies, tests, and seeders. |
|
|
43
|
+
| 29 | **Named Scopes** | `?scope=availableForDrivers` client-selectable scopes (whitelisted via `rhino_scopes`), plus a `rhino_default_scope` applied when none is requested. Unknown scopes return 403. Applies to `index`/`trashed` only. |
|
|
43
44
|
|
|
44
45
|
## Quick Start
|
|
45
46
|
|
|
@@ -109,7 +109,8 @@ module Rhino
|
|
|
109
109
|
"create_users" => "#{timestamp}00",
|
|
110
110
|
"create_organizations" => "#{timestamp}01",
|
|
111
111
|
"create_roles" => "#{timestamp}02",
|
|
112
|
-
"create_user_roles" => "#{timestamp}03"
|
|
112
|
+
"create_user_roles" => "#{timestamp}03",
|
|
113
|
+
"create_org_role_permissions" => "#{timestamp}04"
|
|
113
114
|
}.each do |name, ts|
|
|
114
115
|
template = File.join(templates_dir, "#{name}.rb.erb")
|
|
115
116
|
next unless File.exist?(template)
|
|
@@ -125,7 +126,7 @@ module Rhino
|
|
|
125
126
|
|
|
126
127
|
templates_dir = File.expand_path("../../templates/multi_tenant/models", __FILE__)
|
|
127
128
|
|
|
128
|
-
%w[user organization role user_role].each do |model|
|
|
129
|
+
%w[user organization role user_role org_role_permission].each do |model|
|
|
129
130
|
template = File.join(templates_dir, "#{model}.rb.erb")
|
|
130
131
|
next unless File.exist?(template)
|
|
131
132
|
|
|
@@ -12,21 +12,36 @@ module Rhino
|
|
|
12
12
|
# end
|
|
13
13
|
#
|
|
14
14
|
# Permission format: '{slug}.{action}' (e.g., 'posts.index', 'blogs.store')
|
|
15
|
-
# Wildcard support:
|
|
16
|
-
# - '*' grants
|
|
17
|
-
# - 'posts.*' grants
|
|
15
|
+
# Wildcard support on every layer:
|
|
16
|
+
# - '*' grants/denies everything
|
|
17
|
+
# - 'posts.*' grants/denies all actions on posts
|
|
18
18
|
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
# Stored as a JSON array directly on the user model.
|
|
22
|
-
# - role.permissions (via user_roles): used for tenant route groups (organization context present).
|
|
23
|
-
# Resolved per-organization via the user_roles → role association.
|
|
19
|
+
# ── Layered resolution (organization context) ────────────────────────────
|
|
20
|
+
# The effective decision for an org-scoped check is:
|
|
24
21
|
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
22
|
+
# effective = (role ∪ granted) − denied (deny always wins)
|
|
23
|
+
#
|
|
24
|
+
# - role → org_role_permissions[(organization, role)].permissions
|
|
25
|
+
# The shared "role layer" an org manages once per role.
|
|
26
|
+
# - granted → user_roles.granted_permissions (per-user additive delta)
|
|
27
|
+
# - denied → user_roles.denied_permissions (per-user subtractive delta)
|
|
28
|
+
# - legacy → user_roles.permissions (kept in the allow set)
|
|
29
|
+
#
|
|
30
|
+
# Deny is checked first and overrides everything — even a role '*'. This is
|
|
31
|
+
# intentionally deny-overrides (not most-specific-wins).
|
|
32
|
+
#
|
|
33
|
+
# Backward compatibility:
|
|
34
|
+
# - The global roles.permissions column is preserved as a FALLBACK: it is
|
|
35
|
+
# consulted only when the primary union (legacy ∪ granted ∪ org role layer)
|
|
36
|
+
# is empty — exactly the pre-layer "fall back to role.permissions when
|
|
37
|
+
# user_role.permissions is empty" behavior.
|
|
38
|
+
# - When org_role_permissions has no row and the granted/denied columns are
|
|
39
|
+
# absent, resolution reduces to the previous behavior byte-for-byte.
|
|
40
|
+
#
|
|
41
|
+
# Sources:
|
|
42
|
+
# 1. organization provided (tenant route group) → user_roles layers above.
|
|
43
|
+
# 2. no organization (non-tenant route group) → users.permissions (with
|
|
44
|
+
# optional user-level granted/denied if those columns exist; deny wins).
|
|
30
45
|
module HasPermissions
|
|
31
46
|
extend ActiveSupport::Concern
|
|
32
47
|
|
|
@@ -34,54 +49,58 @@ module Rhino
|
|
|
34
49
|
#
|
|
35
50
|
# @param permission [String] Permission string like 'posts.index'
|
|
36
51
|
# @param organization [Object, nil] Organization to check permissions for
|
|
52
|
+
# @param route_group [String, nil] Resolved route group (group enforcement only)
|
|
37
53
|
# @return [Boolean]
|
|
38
54
|
def has_permission?(permission, organization = nil, route_group: nil)
|
|
39
55
|
return false if permission.blank?
|
|
40
56
|
|
|
41
57
|
# Group-aware permission resolution (GROUP_AUTH_DESIGN.md §6). Only active
|
|
42
58
|
# when enforce_group_membership is on. Permissions then resolve from the
|
|
43
|
-
# membership row matching (route_group, organization)
|
|
59
|
+
# membership row matching (route_group, organization).
|
|
44
60
|
if group_membership_enforced?
|
|
45
61
|
membership = Rhino::GroupMembership.matching_membership(self, route_group, organization)
|
|
46
|
-
return
|
|
62
|
+
return decide_for_record(permission, membership, organization)
|
|
63
|
+
end
|
|
47
64
|
|
|
48
|
-
|
|
49
|
-
|
|
65
|
+
if organization
|
|
66
|
+
# Tenant route group: layered resolution from the user_role for this org.
|
|
67
|
+
return decide_for_record(permission, find_user_role(organization), organization)
|
|
68
|
+
end
|
|
50
69
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
70
|
+
# Non-tenant route group: users.permissions (+ optional user-level deltas).
|
|
71
|
+
deny = parse_permissions(safe_attr(self, :denied_permissions))
|
|
72
|
+
return false if matches_permission?(permission, deny)
|
|
73
|
+
|
|
74
|
+
allow = parse_permissions(safe_attr(self, :permissions)) +
|
|
75
|
+
parse_permissions(safe_attr(self, :granted_permissions))
|
|
76
|
+
matches_permission?(permission, allow)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Explain a permission decision — returns the deciding layer.
|
|
80
|
+
#
|
|
81
|
+
# @return [Hash] { granted: Boolean, reason: String }
|
|
82
|
+
# reason ∈ { 'denied', 'role', 'granted', 'legacy', 'user', 'default-deny' }
|
|
83
|
+
def explain_permission(permission, organization = nil, route_group: nil)
|
|
84
|
+
return { granted: false, reason: "default-deny" } if permission.blank?
|
|
56
85
|
|
|
57
|
-
|
|
86
|
+
if group_membership_enforced?
|
|
87
|
+
membership = Rhino::GroupMembership.matching_membership(self, route_group, organization)
|
|
88
|
+
return decide_for_record(permission, membership, organization, explain: true)
|
|
58
89
|
end
|
|
59
90
|
|
|
60
91
|
if organization
|
|
61
|
-
|
|
62
|
-
user_role = find_user_role(organization)
|
|
63
|
-
|
|
64
|
-
if user_role
|
|
65
|
-
# Check user_role.permissions first (per-user-org permissions on the pivot)
|
|
66
|
-
ur_permissions = parse_permissions(user_role.respond_to?(:permissions) ? user_role.permissions : nil)
|
|
67
|
-
if ur_permissions.present?
|
|
68
|
-
return matches_permission?(permission, ur_permissions)
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# Fallback to role.permissions (shared role-level permissions)
|
|
72
|
-
role = user_role.respond_to?(:role) ? user_role.role : nil
|
|
73
|
-
if role
|
|
74
|
-
role_permissions = parse_permissions(role.respond_to?(:permissions) ? role.permissions : nil)
|
|
75
|
-
return matches_permission?(permission, role_permissions) if role_permissions.present?
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
return false
|
|
92
|
+
return decide_for_record(permission, find_user_role(organization), organization, explain: true)
|
|
80
93
|
end
|
|
81
94
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
95
|
+
deny = parse_permissions(safe_attr(self, :denied_permissions))
|
|
96
|
+
return { granted: false, reason: "denied" } if matches_permission?(permission, deny)
|
|
97
|
+
|
|
98
|
+
user = parse_permissions(safe_attr(self, :permissions))
|
|
99
|
+
granted = parse_permissions(safe_attr(self, :granted_permissions))
|
|
100
|
+
return { granted: true, reason: "granted" } if matches_permission?(permission, granted)
|
|
101
|
+
return { granted: true, reason: "user" } if matches_permission?(permission, user)
|
|
102
|
+
|
|
103
|
+
{ granted: false, reason: "default-deny" }
|
|
85
104
|
end
|
|
86
105
|
|
|
87
106
|
# Get the role slug for validation purposes.
|
|
@@ -100,7 +119,77 @@ module Rhino
|
|
|
100
119
|
|
|
101
120
|
private
|
|
102
121
|
|
|
122
|
+
# Resolve a decision from a single membership/user_role record, applying
|
|
123
|
+
# deny-overrides over the layered allow set.
|
|
124
|
+
def decide_for_record(permission, record, organization, explain: false)
|
|
125
|
+
return explain ? { granted: false, reason: "default-deny" } : false unless record
|
|
126
|
+
|
|
127
|
+
# Deny always wins.
|
|
128
|
+
deny = parse_permissions(safe_attr(record, :denied_permissions))
|
|
129
|
+
if matches_permission?(permission, deny)
|
|
130
|
+
return explain ? { granted: false, reason: "denied" } : false
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
role_id = record.respond_to?(:role_id) ? record.role_id : nil
|
|
134
|
+
role_layer = org_role_permissions(organization, role_id)
|
|
135
|
+
granted = parse_permissions(safe_attr(record, :granted_permissions))
|
|
136
|
+
legacy = parse_permissions(safe_attr(record, :permissions))
|
|
137
|
+
|
|
138
|
+
primary = legacy + granted + role_layer
|
|
139
|
+
|
|
140
|
+
if primary.present?
|
|
141
|
+
unless explain
|
|
142
|
+
return matches_permission?(permission, primary)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
return { granted: true, reason: "role" } if matches_permission?(permission, role_layer)
|
|
146
|
+
return { granted: true, reason: "granted" } if matches_permission?(permission, granted)
|
|
147
|
+
return { granted: true, reason: "legacy" } if matches_permission?(permission, legacy)
|
|
148
|
+
return { granted: false, reason: "default-deny" }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Legacy fallback: the global roles.permissions column, consulted only when
|
|
152
|
+
# the primary union is empty (preserving pre-layer behavior).
|
|
153
|
+
role = record.respond_to?(:role) ? record.role : nil
|
|
154
|
+
global = role ? parse_permissions(safe_attr(role, :permissions)) : []
|
|
155
|
+
allowed = matches_permission?(permission, global)
|
|
156
|
+
|
|
157
|
+
return { granted: allowed, reason: allowed ? "role" : "default-deny" } if explain
|
|
158
|
+
|
|
159
|
+
allowed
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Resolve the shared role-layer permissions for (organization, role) from the
|
|
163
|
+
# org_role_permissions table. Memoized per instance; tolerant of the table not
|
|
164
|
+
# existing (un-migrated apps) so it degrades to "no role layer".
|
|
165
|
+
def org_role_permissions(organization, role_id)
|
|
166
|
+
return [] unless organization && role_id
|
|
167
|
+
|
|
168
|
+
org_id = organization.respond_to?(:id) ? organization.id : organization
|
|
169
|
+
return [] if org_id.nil?
|
|
170
|
+
|
|
171
|
+
@_org_role_permissions_cache ||= {}
|
|
172
|
+
key = "#{org_id}:#{role_id}"
|
|
173
|
+
return @_org_role_permissions_cache[key] if @_org_role_permissions_cache.key?(key)
|
|
174
|
+
|
|
175
|
+
perms =
|
|
176
|
+
begin
|
|
177
|
+
sql = ActiveRecord::Base.sanitize_sql_array(
|
|
178
|
+
["SELECT permissions FROM org_role_permissions WHERE organization_id = ? AND role_id = ? LIMIT 1",
|
|
179
|
+
org_id, role_id]
|
|
180
|
+
)
|
|
181
|
+
row = ActiveRecord::Base.connection.select_one(sql)
|
|
182
|
+
row ? parse_permissions(row["permissions"]) : []
|
|
183
|
+
rescue ActiveRecord::ActiveRecordError
|
|
184
|
+
# Table absent (app has not run the new migration) → no role layer.
|
|
185
|
+
[]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
@_org_role_permissions_cache[key] = perms
|
|
189
|
+
end
|
|
190
|
+
|
|
103
191
|
def matches_permission?(permission, granted_permissions)
|
|
192
|
+
return false if permission.blank?
|
|
104
193
|
return true if granted_permissions.include?(permission)
|
|
105
194
|
return true if granted_permissions.include?("*")
|
|
106
195
|
|
|
@@ -115,7 +204,8 @@ module Rhino
|
|
|
115
204
|
|
|
116
205
|
if perms.is_a?(String)
|
|
117
206
|
begin
|
|
118
|
-
JSON.parse(perms)
|
|
207
|
+
parsed = JSON.parse(perms)
|
|
208
|
+
parsed.is_a?(Array) ? parsed : []
|
|
119
209
|
rescue JSON::ParserError
|
|
120
210
|
[]
|
|
121
211
|
end
|
|
@@ -126,6 +216,12 @@ module Rhino
|
|
|
126
216
|
end
|
|
127
217
|
end
|
|
128
218
|
|
|
219
|
+
def safe_attr(obj, name)
|
|
220
|
+
return nil unless obj.respond_to?(name)
|
|
221
|
+
|
|
222
|
+
obj.public_send(name)
|
|
223
|
+
end
|
|
224
|
+
|
|
129
225
|
def find_user_role(organization)
|
|
130
226
|
return nil unless respond_to?(:user_roles)
|
|
131
227
|
return nil unless organization
|
|
@@ -13,6 +13,8 @@ module Rhino
|
|
|
13
13
|
# rhino_includes :user, :comments
|
|
14
14
|
# rhino_fields :id, :title, :status, :created_at
|
|
15
15
|
# rhino_search :title, :content, 'user.name'
|
|
16
|
+
# rhino_scopes :active, available_for_drivers: Scopes::AvailableForDriversScope
|
|
17
|
+
# rhino_default_scope :active
|
|
16
18
|
# rhino_per_page 25
|
|
17
19
|
# rhino_pagination_enabled true
|
|
18
20
|
# rhino_middleware 'throttle:60,1'
|
|
@@ -24,6 +26,8 @@ module Rhino
|
|
|
24
26
|
|
|
25
27
|
included do
|
|
26
28
|
class_attribute :allowed_filters, default: []
|
|
29
|
+
class_attribute :allowed_scopes, default: {}
|
|
30
|
+
class_attribute :default_rhino_scope, default: nil
|
|
27
31
|
class_attribute :allowed_sorts, default: []
|
|
28
32
|
class_attribute :default_sort_field, default: nil
|
|
29
33
|
class_attribute :allowed_includes, default: []
|
|
@@ -42,6 +46,23 @@ module Rhino
|
|
|
42
46
|
self.allowed_filters = fields.map(&:to_s)
|
|
43
47
|
end
|
|
44
48
|
|
|
49
|
+
# Whitelist client-selectable named scopes for ?scope=.
|
|
50
|
+
# rhino_scopes :active, available_for_drivers: Scopes::AvailableForDriversScope
|
|
51
|
+
# Bare symbols must name an existing ActiveRecord scope/class method on the model.
|
|
52
|
+
# Hash values may be a Proc(relation, user) or a Rhino::ResourceScope subclass.
|
|
53
|
+
def rhino_scopes(*names, **named)
|
|
54
|
+
merged = allowed_scopes.dup
|
|
55
|
+
names.each { |n| merged[n.to_s] = n.to_sym }
|
|
56
|
+
named.each { |k, v| merged[k.to_s] = v }
|
|
57
|
+
self.allowed_scopes = merged
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Named scope applied when no ?scope param is sent. Convenience, not a
|
|
61
|
+
# security boundary. Value is the scope name (string/symbol).
|
|
62
|
+
def rhino_default_scope(name)
|
|
63
|
+
self.default_rhino_scope = name.to_s
|
|
64
|
+
end
|
|
65
|
+
|
|
45
66
|
def rhino_sorts(*fields)
|
|
46
67
|
self.allowed_sorts = fields.map(&:to_s)
|
|
47
68
|
end
|
|
@@ -16,6 +16,10 @@ module Rhino
|
|
|
16
16
|
render json: { message: "This action is unauthorized." }, status: :forbidden
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
rescue_from Rhino::ScopeNotAllowedError do |e|
|
|
20
|
+
render json: { message: "Scope '#{e.message}' is not allowed" }, status: :forbidden
|
|
21
|
+
end
|
|
22
|
+
|
|
19
23
|
# Cache for auto-detected organization paths (class-level, survives across requests)
|
|
20
24
|
@@organization_path_cache = {}
|
|
21
25
|
|
|
@@ -38,7 +42,7 @@ module Rhino
|
|
|
38
42
|
def index
|
|
39
43
|
authorize model_class, :index?, policy_class: policy_for(model_class)
|
|
40
44
|
|
|
41
|
-
builder = QueryBuilder.new(model_class, params: params)
|
|
45
|
+
builder = QueryBuilder.new(model_class, params: params, named_scopes: true)
|
|
42
46
|
apply_organization_scope(builder)
|
|
43
47
|
builder.build
|
|
44
48
|
|
|
@@ -170,7 +174,7 @@ module Rhino
|
|
|
170
174
|
def trashed
|
|
171
175
|
authorize model_class, :view_trashed?, policy_class: policy_for(model_class)
|
|
172
176
|
|
|
173
|
-
builder = QueryBuilder.new(model_class.discarded, params: params)
|
|
177
|
+
builder = QueryBuilder.new(model_class.discarded, params: params, named_scopes: true)
|
|
174
178
|
apply_organization_scope(builder)
|
|
175
179
|
builder.build
|
|
176
180
|
|
|
@@ -900,7 +904,7 @@ module Rhino
|
|
|
900
904
|
end
|
|
901
905
|
|
|
902
906
|
def params_hash
|
|
903
|
-
params.except(:controller, :action, :model_slug, :route_group, :organization, :id, :format).to_unsafe_h
|
|
907
|
+
params.except(:controller, :action, :model_slug, :route_group, :organization, :id, :format, :scope).to_unsafe_h
|
|
904
908
|
end
|
|
905
909
|
end
|
|
906
910
|
end
|
|
@@ -104,6 +104,36 @@ module Rhino
|
|
|
104
104
|
# self.allowed_filters = %w[status user_id category_id]
|
|
105
105
|
self.allowed_filters = []
|
|
106
106
|
|
|
107
|
+
# @!attribute [rw] allowed_scopes
|
|
108
|
+
# Client-selectable named scopes (whitelist for +?scope=+).
|
|
109
|
+
#
|
|
110
|
+
# Controls which named scopes can be requested via +?scope=name+
|
|
111
|
+
# (camelCase on the wire, underscored internally). Only whitelisted
|
|
112
|
+
# scopes are accepted — unknown/unlisted names return 403.
|
|
113
|
+
#
|
|
114
|
+
# Set via DSL: +rhino_scopes :active, available_for_drivers: Scopes::AvailableForDriversScope+
|
|
115
|
+
#
|
|
116
|
+
# Query: +GET /api/posts?scope=availableForDrivers+
|
|
117
|
+
#
|
|
118
|
+
# @return [Hash{String => Symbol, Proc, Class}]
|
|
119
|
+
# @example
|
|
120
|
+
# rhino_scopes :active, available_for_drivers: Scopes::AvailableForDriversScope
|
|
121
|
+
self.allowed_scopes = {}
|
|
122
|
+
|
|
123
|
+
# @!attribute [rw] default_rhino_scope
|
|
124
|
+
# Named scope applied automatically when no +?scope+ param is sent.
|
|
125
|
+
#
|
|
126
|
+
# This is a listing convenience, not a security boundary. The default
|
|
127
|
+
# scope is always requestable by name even if it is not otherwise
|
|
128
|
+
# whitelisted via +rhino_scopes+.
|
|
129
|
+
#
|
|
130
|
+
# Set via DSL: +rhino_default_scope :active+
|
|
131
|
+
#
|
|
132
|
+
# @return [String, nil]
|
|
133
|
+
# @example
|
|
134
|
+
# rhino_default_scope :active
|
|
135
|
+
self.default_rhino_scope = nil
|
|
136
|
+
|
|
107
137
|
# @!attribute [rw] allowed_sorts
|
|
108
138
|
# Sortable columns.
|
|
109
139
|
#
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rhino
|
|
6
|
+
# Lift per-user permissions into the shared org role layer.
|
|
7
|
+
#
|
|
8
|
+
# For each (organization, role) group, the literal intersection of every user's
|
|
9
|
+
# `user_roles.permissions` becomes the `org_role_permissions` row (the shared
|
|
10
|
+
# role layer). Each user's row is then reduced to only its delta
|
|
11
|
+
# (`granted_permissions = permissions − roleLayer`) and its legacy `permissions`
|
|
12
|
+
# is cleared. Effective permissions are preserved exactly (the intersection is a
|
|
13
|
+
# subset of every user's set, so nothing is gained or lost).
|
|
14
|
+
#
|
|
15
|
+
# Safe & idempotent:
|
|
16
|
+
# - Dry-run by default; pass apply: true to write.
|
|
17
|
+
# - Groups that already have an org_role_permissions row are skipped.
|
|
18
|
+
# - After a run the legacy permissions are empty, so a second run is a no-op.
|
|
19
|
+
# - Non-tenant (NULL organization) rows are left untouched.
|
|
20
|
+
class PermissionsMigrator
|
|
21
|
+
Result = Struct.new(:groups_migrated, :rows_reduced, :skipped_existing, :lines, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
def self.call(apply: false)
|
|
24
|
+
new.call(apply: apply)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(apply: false)
|
|
28
|
+
conn = ActiveRecord::Base.connection
|
|
29
|
+
unless conn.data_source_exists?("user_roles") && conn.data_source_exists?("org_role_permissions")
|
|
30
|
+
raise "Required tables (user_roles, org_role_permissions) are missing. Run migrations first."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
groups = conn.select_all(
|
|
34
|
+
"SELECT DISTINCT organization_id, role_id FROM user_roles " \
|
|
35
|
+
"WHERE organization_id IS NOT NULL AND role_id IS NOT NULL"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
groups_migrated = 0
|
|
39
|
+
rows_reduced = 0
|
|
40
|
+
skipped_existing = 0
|
|
41
|
+
lines = []
|
|
42
|
+
|
|
43
|
+
groups.each do |g|
|
|
44
|
+
org_id = g["organization_id"]
|
|
45
|
+
role_id = g["role_id"]
|
|
46
|
+
|
|
47
|
+
rows = conn.select_all(
|
|
48
|
+
ActiveRecord::Base.sanitize_sql_array(
|
|
49
|
+
["SELECT id, permissions, granted_permissions FROM user_roles " \
|
|
50
|
+
"WHERE organization_id = ? AND role_id = ?", org_id, role_id]
|
|
51
|
+
)
|
|
52
|
+
).to_a
|
|
53
|
+
|
|
54
|
+
with_legacy = rows.select { |r| decode(r["permissions"]).any? }
|
|
55
|
+
next if with_legacy.empty?
|
|
56
|
+
|
|
57
|
+
existing = conn.select_value(
|
|
58
|
+
ActiveRecord::Base.sanitize_sql_array(
|
|
59
|
+
["SELECT 1 FROM org_role_permissions WHERE organization_id = ? AND role_id = ? LIMIT 1",
|
|
60
|
+
org_id, role_id]
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
if existing
|
|
64
|
+
skipped_existing += 1
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
sets = with_legacy.map { |r| decode(r["permissions"]) }
|
|
69
|
+
role_layer = sets.reduce { |acc, s| acc & s } || []
|
|
70
|
+
lines << "org=#{org_id} role=#{role_id} → role layer [#{role_layer.join(', ')}] (#{with_legacy.size} user rows)"
|
|
71
|
+
|
|
72
|
+
if apply
|
|
73
|
+
now = Time.now.utc
|
|
74
|
+
conn.execute(
|
|
75
|
+
ActiveRecord::Base.sanitize_sql_array(
|
|
76
|
+
["INSERT INTO org_role_permissions (organization_id, role_id, permissions, created_at, updated_at) " \
|
|
77
|
+
"VALUES (?, ?, ?, ?, ?)", org_id, role_id, JSON.generate(role_layer), now, now]
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
with_legacy.each do |r|
|
|
82
|
+
legacy = decode(r["permissions"])
|
|
83
|
+
grants = decode(r["granted_permissions"])
|
|
84
|
+
delta = ((legacy - role_layer) + grants).uniq
|
|
85
|
+
|
|
86
|
+
conn.execute(
|
|
87
|
+
ActiveRecord::Base.sanitize_sql_array(
|
|
88
|
+
["UPDATE user_roles SET permissions = ?, granted_permissions = ?, updated_at = ? WHERE id = ?",
|
|
89
|
+
JSON.generate([]), JSON.generate(delta), now, r["id"]]
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
groups_migrated += 1
|
|
96
|
+
rows_reduced += with_legacy.size
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
Result.new(
|
|
100
|
+
groups_migrated: groups_migrated,
|
|
101
|
+
rows_reduced: rows_reduced,
|
|
102
|
+
skipped_existing: skipped_existing,
|
|
103
|
+
lines: lines
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def decode(value)
|
|
110
|
+
return value.select { |v| v.is_a?(String) } if value.is_a?(Array)
|
|
111
|
+
|
|
112
|
+
if value.is_a?(String) && !value.empty?
|
|
113
|
+
parsed = begin
|
|
114
|
+
JSON.parse(value)
|
|
115
|
+
rescue JSON::ParserError
|
|
116
|
+
[]
|
|
117
|
+
end
|
|
118
|
+
return parsed.is_a?(Array) ? parsed.select { |v| v.is_a?(String) } : []
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
[]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
data/lib/rhino/query_builder.rb
CHANGED
|
@@ -14,14 +14,16 @@ module Rhino
|
|
|
14
14
|
class QueryBuilder
|
|
15
15
|
attr_reader :scope, :model_class, :params
|
|
16
16
|
|
|
17
|
-
def initialize(model_class, params: {})
|
|
17
|
+
def initialize(model_class, params: {}, named_scopes: false)
|
|
18
18
|
@model_class = model_class
|
|
19
19
|
@scope = model_class.all
|
|
20
20
|
@params = params
|
|
21
|
+
@named_scopes = named_scopes
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
# Apply all query modifications based on params and model config.
|
|
24
25
|
def build
|
|
26
|
+
apply_named_scope if @named_scopes
|
|
25
27
|
apply_filters
|
|
26
28
|
apply_default_sort
|
|
27
29
|
apply_sorts
|
|
@@ -62,6 +64,41 @@ module Rhino
|
|
|
62
64
|
|
|
63
65
|
private
|
|
64
66
|
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
# Named scopes: ?scope=availableForDrivers
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
#
|
|
71
|
+
# Only runs for collection endpoints (index/trashed), which pass
|
|
72
|
+
# +named_scopes: true+. `show` (including its ?include= build path) stays
|
|
73
|
+
# unscoped so a record excluded by the default scope is still viewable.
|
|
74
|
+
def apply_named_scope
|
|
75
|
+
requested = params[:scope].presence
|
|
76
|
+
name = requested ? requested.to_s.underscore : model_class.try(:default_rhino_scope)
|
|
77
|
+
return unless name
|
|
78
|
+
|
|
79
|
+
allowed = model_class.try(:allowed_scopes) || {}
|
|
80
|
+
entry = allowed[name]
|
|
81
|
+
# The default scope is implicitly allowed when requested by name.
|
|
82
|
+
entry ||= name.to_sym if name == model_class.try(:default_rhino_scope)
|
|
83
|
+
|
|
84
|
+
# Echo the client's wire name (not the underscored form) in the error.
|
|
85
|
+
raise Rhino::ScopeNotAllowedError, (requested ? requested.to_s : name) if entry.nil?
|
|
86
|
+
|
|
87
|
+
user = defined?(RequestStore) ? RequestStore.store[:rhino_current_user] : nil
|
|
88
|
+
|
|
89
|
+
@scope =
|
|
90
|
+
case entry
|
|
91
|
+
when Symbol
|
|
92
|
+
# Whitelisted AR scope. Client input never reaches public_send unless the
|
|
93
|
+
# developer declared it via rhino_scopes. .merge composes with default_scopes.
|
|
94
|
+
@scope.merge(model_class.public_send(entry))
|
|
95
|
+
when Proc
|
|
96
|
+
entry.call(@scope, user)
|
|
97
|
+
else
|
|
98
|
+
entry.new.apply(@scope) # Rhino::ResourceScope subclass (user/org/role helpers)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
65
102
|
# ------------------------------------------------------------------
|
|
66
103
|
# Filtering: ?filter[status]=published&filter[user_id]=1
|
|
67
104
|
# ------------------------------------------------------------------
|
data/lib/rhino/tasks/rhino.rake
CHANGED
|
@@ -26,6 +26,19 @@ namespace :rhino do
|
|
|
26
26
|
cmd.perform
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
desc "Lift per-user permissions into the org_role_permissions role layer (APPLY=1 to write)"
|
|
30
|
+
task :permissions_migrate, [:apply] => :environment do |_t, args|
|
|
31
|
+
require "rhino/permissions_migrator"
|
|
32
|
+
apply = args[:apply].to_s == "apply" || ENV["APPLY"] == "1"
|
|
33
|
+
result = Rhino::PermissionsMigrator.call(apply: apply)
|
|
34
|
+
result.lines.each { |line| puts line }
|
|
35
|
+
verb = apply ? "Migrated" : "Would migrate"
|
|
36
|
+
summary = "#{verb} #{result.groups_migrated} (org, role) group(s); #{result.rows_reduced} user row(s) reduced to deltas."
|
|
37
|
+
summary += " Skipped #{result.skipped_existing} group(s) with an existing role layer." if result.skipped_existing.positive?
|
|
38
|
+
puts summary
|
|
39
|
+
puts "Dry-run only. Re-run with APPLY=1 to write these changes." if !apply && result.groups_migrated.positive?
|
|
40
|
+
end
|
|
41
|
+
|
|
29
42
|
desc "Generate TypeScript type definitions from registered Rhino models"
|
|
30
43
|
task :export_types, [:output] => :environment do |_t, args|
|
|
31
44
|
require "rhino/commands/export_types_command"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateOrgRolePermissions < ActiveRecord::Migration[8.0]
|
|
4
|
+
# The shared "role layer": the permission set a role has within a given
|
|
5
|
+
# organization. Defining permissions here (once per org+role) means user_roles
|
|
6
|
+
# rows only need to carry per-user deltas (granted_permissions /
|
|
7
|
+
# denied_permissions) instead of the full permission set.
|
|
8
|
+
def change
|
|
9
|
+
create_table :org_role_permissions do |t|
|
|
10
|
+
t.references :organization, null: false, foreign_key: true
|
|
11
|
+
t.references :role, null: false, foreign_key: true
|
|
12
|
+
t.json :permissions, default: []
|
|
13
|
+
|
|
14
|
+
t.timestamps
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# One row per (organization, role).
|
|
18
|
+
add_index :org_role_permissions, %i[organization_id role_id], unique: true,
|
|
19
|
+
name: "index_org_role_permissions_on_org_role"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -11,7 +11,14 @@ class CreateUserRoles < ActiveRecord::Migration[8.0]
|
|
|
11
11
|
# Group membership scope. NULL = wildcard (member of every group), which
|
|
12
12
|
# keeps existing rows valid when enforce_group_membership is enabled.
|
|
13
13
|
t.string :route_group
|
|
14
|
+
# Legacy per-user permission set (full list). Still honored as an allow
|
|
15
|
+
# layer for backward compatibility.
|
|
14
16
|
t.json :permissions, default: []
|
|
17
|
+
# Layered-permission deltas applied on top of the org role layer
|
|
18
|
+
# (org_role_permissions). granted = additive, denied = subtractive.
|
|
19
|
+
# Deny always wins. Leave empty to inherit the role layer as-is.
|
|
20
|
+
t.json :granted_permissions, default: []
|
|
21
|
+
t.json :denied_permissions, default: []
|
|
15
22
|
|
|
16
23
|
t.timestamps
|
|
17
24
|
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The shared "role layer" for permissions: what a role can do within a given
|
|
4
|
+
# organization. One row per (organization, role).
|
|
5
|
+
#
|
|
6
|
+
# Resolution (see Rhino::HasPermissions):
|
|
7
|
+
# effective = (org_role_permissions ∪ user_roles.granted_permissions)
|
|
8
|
+
# − user_roles.denied_permissions (deny always wins)
|
|
9
|
+
class OrgRolePermission < ApplicationRecord
|
|
10
|
+
belongs_to :organization
|
|
11
|
+
belongs_to :role
|
|
12
|
+
|
|
13
|
+
validates :role_id, uniqueness: { scope: :organization_id }
|
|
14
|
+
end
|
data/lib/rhino/version.rb
CHANGED
data/lib/rhino.rb
CHANGED
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
|
+
version: 4.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bruno Cipolla
|
|
@@ -226,6 +226,7 @@ files:
|
|
|
226
226
|
- lib/rhino/models/audit_log.rb
|
|
227
227
|
- lib/rhino/models/organization_invitation.rb
|
|
228
228
|
- lib/rhino/models/rhino_model.rb
|
|
229
|
+
- lib/rhino/permissions_migrator.rb
|
|
229
230
|
- lib/rhino/policies/invitation_policy.rb
|
|
230
231
|
- lib/rhino/policies/resource_policy.rb
|
|
231
232
|
- lib/rhino/query_builder.rb
|
|
@@ -234,6 +235,7 @@ files:
|
|
|
234
235
|
- lib/rhino/routes.rb
|
|
235
236
|
- lib/rhino/routing/domain_constraint.rb
|
|
236
237
|
- lib/rhino/routing/route_group_validator.rb
|
|
238
|
+
- lib/rhino/scope_not_allowed_error.rb
|
|
237
239
|
- lib/rhino/tasks/rhino.rake
|
|
238
240
|
- lib/rhino/templates/audit_trail/create_audit_logs.rb.erb
|
|
239
241
|
- lib/rhino/templates/generate/factory.rb.erb
|
|
@@ -246,10 +248,12 @@ files:
|
|
|
246
248
|
- lib/rhino/templates/multi_tenant/factories/user_roles.rb.erb
|
|
247
249
|
- lib/rhino/templates/multi_tenant/factories/users.rb.erb
|
|
248
250
|
- lib/rhino/templates/multi_tenant/migrations/add_group_membership.rb.erb
|
|
251
|
+
- lib/rhino/templates/multi_tenant/migrations/create_org_role_permissions.rb.erb
|
|
249
252
|
- lib/rhino/templates/multi_tenant/migrations/create_organizations.rb.erb
|
|
250
253
|
- lib/rhino/templates/multi_tenant/migrations/create_roles.rb.erb
|
|
251
254
|
- lib/rhino/templates/multi_tenant/migrations/create_user_roles.rb.erb
|
|
252
255
|
- lib/rhino/templates/multi_tenant/migrations/create_users.rb.erb
|
|
256
|
+
- lib/rhino/templates/multi_tenant/models/org_role_permission.rb.erb
|
|
253
257
|
- lib/rhino/templates/multi_tenant/models/organization.rb.erb
|
|
254
258
|
- lib/rhino/templates/multi_tenant/models/role.rb.erb
|
|
255
259
|
- lib/rhino/templates/multi_tenant/models/user.rb.erb
|