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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c15dd271d0b613f2effa9bb7c8d050b3214c74884e3021461150017f0f159af6
4
- data.tar.gz: 5b4e5dcb736acc54674ad24e9d689cd93582e7ba0195554ef99e7735f2c33050
3
+ metadata.gz: 0155b30eedf93061b38295ed96254194529de0abf03aad309becc312a512c5ba
4
+ data.tar.gz: 5e1ce1624bab2a77f8603ebdf6984a43e4bd71d277f51900ae533465e4fd0881
5
5
  SHA512:
6
- metadata.gz: 93c2de702533d23edcfe6b334aa0ffaf0f327efe6518a8c608e8072b4fe27862430d6591a651e730f198f9e9e8cf39456005464490a12137dadce641613ef268
7
- data.tar.gz: 7fe767badf982f99dd3fca7b72c441929ee0eb336b72b117ed4a5296cdcaafacda6582a338025e6e611f1a0e87117d6d906469c8b01abc9339109d9da32c711a
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 access to everything
17
- # - 'posts.*' grants access to all actions on posts
15
+ # Wildcard support on every layer:
16
+ # - '*' grants/denies everything
17
+ # - 'posts.*' grants/denies all actions on posts
18
18
  #
19
- # Two permission sources:
20
- # - users.permissions: used for non-tenant route groups (no organization context).
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
- # Resolution:
26
- # 1. When an organization is provided (tenant route group) → checks role.permissions
27
- # for that specific organization via user_roles.
28
- # 2. When no organization is provided (non-tenant route group) → checks users.permissions
29
- # directly on the user model.
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), not the heuristic.
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 false unless membership
62
+ return decide_for_record(permission, membership, organization)
63
+ end
47
64
 
48
- ur_permissions = parse_permissions(membership.respond_to?(:permissions) ? membership.permissions : nil)
49
- return matches_permission?(permission, ur_permissions) if ur_permissions.present?
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
- 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
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
- return false
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
- # Tenant route group: check permissions for this organization
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
- # Non-tenant route group: check users.permissions directly
83
- user_perms = parse_permissions(respond_to?(:permissions) ? self.permissions : nil)
84
- matches_permission?(permission, user_perms)
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
@@ -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
  # ------------------------------------------------------------------
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rhino
4
+ # Raised when a client requests a ?scope= name that is not whitelisted via
5
+ # rhino_scopes / rhino_default_scope. Rendered as 403 by ResourcesController.
6
+ class ScopeNotAllowedError < StandardError; end
7
+ end
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rhino
4
- VERSION = "4.2.1"
4
+ VERSION = "4.4.0"
5
5
  end
data/lib/rhino.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "rhino/version"
4
4
  require "rhino/configuration"
5
5
  require "rhino/auth_rejected"
6
+ require "rhino/scope_not_allowed_error"
6
7
  require "rhino/auth_hooks"
7
8
  require "rhino/group_membership"
8
9
  require "rhino/resource_scope"
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.2.1
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