organizations 0.5.0 → 0.6.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: 0ffa0f2d13cc9a1eb2ca3c25bc1333f9ea27d2d1967d95b5acbd5bd5a57cd9b9
4
- data.tar.gz: fd625579a6db02a11c5784eaef031a1789334cf01e3cf69cbb53d9dfbe0a2894
3
+ metadata.gz: 883d468209035eb168c343e8ffc8261ee27155f1c16b3879d367fa081ce937e2
4
+ data.tar.gz: d38f2b84422a031248b03682a1485dc43367deef95135e3e04fe7e32190c2e33
5
5
  SHA512:
6
- metadata.gz: 167e836ce9aa5af1a2c99707170605be25bbaaf9923d88a8fbe13c4b2a2cc69af40febdde5c9debef799592f1a1d42040fcb1c0ec2d96ebe8a0f6c47e7c854e8
7
- data.tar.gz: 76cd432b695b887649d9ed9cf9425e62b51b7c9a2d09f69374534afd27bf72db77f4b442e1a064130674513c23ef06467a965dd3e29bcfccca303a69dbb5bafa
6
+ metadata.gz: fc9d92ca2dac7522fd4911ba60e375fc60f8ef56a172d94b969b643fe2d9e719b1b69247dfa6b07064009e1b3d73fb893d30fdea6356b00d358f54def4a696a9
7
+ data.tar.gz: eca61e80a95a36ebb6e917283770c99c146b5b719a01d3c526b3a89e0444073eac08f678872bf9e151d731cb7f419eddac7a93aa9dd90ea50577a12c791ea423
data/Appraisals CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  appraise "rails-7.2" do
4
4
  gem "rails", "~> 7.2.3"
5
+ # json 3 removed the quirks_mode option that ActiveSupport before 8.1 passes.
6
+ gem "json", "< 3"
5
7
  end
6
8
 
7
9
  appraise "rails-8.1" do
data/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ ## [0.6.0] - 2026-10-18
2
+
3
+ **Custom role names.** Register a role outside owner > admin > member > viewer (a `client`, an `auditor`, a `guest`) in the existing `config.roles` block. No migration is needed. Installs without custom roles change nothing.
4
+
5
+ ### Added
6
+
7
+ - **Custom roles.** `role :client, inherits: :viewer do can :answer_questions end` in `config.roles` adds a role with its own permissions plus its parent's. A custom role sits directly above its parent in `Roles.hierarchy`. A custom role without `inherits:` sits at the bottom. Two custom roles with the same parent stack in the order you define them.
8
+ - Custom roles work in `add_member!`, memberships, invitations, `default_invitation_role`, promotions, demotions, `at_least?`, `by_role_hierarchy` and every permission helper. The generated views and the dummy reference views list them in the role pickers.
9
+ - **Safety rules, checked when the configuration loads.** A custom role inherits from `member`, `viewer` or another custom role, never from `admin` or `owner`, so it ranks below admin. A custom role cannot grant a permission that the owner role has and the admin role lacks (`manage_billing`, `transfer_ownership`, `delete_organization` by default), because admins can assign it. A built-in role cannot inherit a custom role that ranks above it. A role name is lowercase letters, digits and underscores, and a parent must be defined above its child.
10
+ - `Roles.hierarchy_for(definition)` returns the hierarchy of a roles definition.
11
+
12
+ ### Changed
13
+
14
+ - `RoleBuilder` keeps roles outside the four built-in ones as custom roles. Before, it dropped them without an error. A `config.roles` block with such a role now registers it, or raises `ConfigurationError` when the role breaks a safety rule.
15
+ - A block that only adds custom roles keeps the default permissions of the built-in roles. A block that redefines a built-in role defines only the built-in roles it lists, as before. An empty block still defines no permissions.
16
+ - `Membership` and `Invitation` check the role against the configured roles only when the role is set or changed. A membership of a role that you remove from the configuration stays readable and updatable, and ranks below every configured role. Move such memberships with `org.change_role_of!(user, to: :member)`. A pending invitation for a removed role cannot be accepted.
17
+ - `by_role_hierarchy` follows the configured hierarchy, custom roles included, with unknown roles last.
18
+ - The hierarchy and the permissions are one immutable snapshot for each roles definition, and each comparison reads one snapshot. A reconfiguration, or a rolled-back one, can no longer leave a stale hierarchy in place.
19
+
1
20
  ## [0.5.0] - 2026-07-16
2
21
 
3
22
  **Verified Joining** — users can now join organizations by proving they belong, closing three roadmap items (domain-based joining, request-to-join, roster/bulk import). Fully additive: existing installs change nothing until they enroll a domain, generate a code, or import a roster. Run `rails g organizations:upgrade` + `rails db:migrate` to get the new tables/columns.
data/README.md CHANGED
@@ -804,6 +804,34 @@ Organizations.configure do |config|
804
804
  end
805
805
  ```
806
806
 
807
+ ### Add custom roles
808
+
809
+ Need a role that is not a step on the owner > admin > member > viewer ladder — a `client` who sees and answers for their own records, an `auditor`, a `guest`? Define it in the same block. A custom role inherits from one role and sits directly above it in the hierarchy:
810
+
811
+ ```ruby
812
+ Organizations.configure do |config|
813
+ config.roles do
814
+ role :client, inherits: :viewer do
815
+ can :view_own_records
816
+ can :answer_questions
817
+ end
818
+ end
819
+ end
820
+
821
+ Organizations::Roles.hierarchy # => [:owner, :admin, :member, :client, :viewer]
822
+ org.add_member!(user, role: :client)
823
+ user.role_in(org) # => :client
824
+ user.has_organization_permission_to?(:answer_questions) # => true
825
+ require_organization_role!(:client) # clients and everyone above them
826
+ ```
827
+
828
+ - A custom role gets its own permissions plus its parent's (the parent's defaults when the block does not redefine a built-in parent).
829
+ - A custom role without `inherits:` sits at the bottom of the hierarchy. Two custom roles with the same parent stack in the order you define them, the first one highest.
830
+ - A block that only adds custom roles keeps the built-in roles' default permissions; a block that redefines a built-in role defines the built-in roles it lists, as before.
831
+ - A custom role ranks below admin: it inherits from `member`, `viewer` or another custom role, never from `admin` or `owner`, so it never outranks the people who can assign it. A custom role also cannot grant a permission that the owner role has and the admin role lacks (`manage_billing`, `transfer_ownership`, `delete_organization` by default), because admins can assign it. A role name is lowercase letters, digits and underscores; a parent must be a built-in role or a custom role defined above it. Both are checked when the configuration loads.
832
+ - Memberships, invitations, `default_invitation_role`, `add_member!`, promotions and demotions, `by_role_hierarchy` and every permission helper accept custom roles. The generated views list them in the role pickers.
833
+ - Removing a custom role from the configuration later leaves its memberships readable and updatable (the role is checked only when it is set or changed) and ranks them below every configured role; move them to a configured role with `org.change_role_of!(user, to: :member)`. A pending invitation for a retired role cannot be accepted; revoke it and invite again with a configured role.
834
+
807
835
  ### Add custom permissions
808
836
 
809
837
  ```ruby
@@ -4,6 +4,7 @@ source "https://rubygems.org"
4
4
 
5
5
  gem "rake", "~> 13.0"
6
6
  gem "rails", "~> 7.2.3"
7
+ gem "json", "< 3"
7
8
 
8
9
  group :development do
9
10
  gem "irb"
@@ -35,7 +35,7 @@
35
35
  <div>
36
36
  <%= form.label :role, "Role", class: "block text-sm font-medium text-gray-700 mb-1" %>
37
37
  <%= form.select :role,
38
- Organizations::Roles::HIERARCHY.reject { |role| role == :owner }.map { |role| [role.to_s.capitalize, role] },
38
+ Organizations::Roles.hierarchy.reject { |role| role == :owner }.map { |role| [role.to_s.capitalize, role] },
39
39
  { selected: @invitation.role || :member },
40
40
  required: true,
41
41
  class: "block w-full rounded-lg border-gray-300 shadow-sm focus:border-slate-500 focus:ring-slate-500 sm:text-sm" %>
@@ -63,7 +63,7 @@
63
63
  <div class="flex items-center justify-end gap-2">
64
64
  <%= form_with(model: membership, url: membership_path(membership), method: :patch, local: true, class: "flex items-center gap-2") do |form| %>
65
65
  <%= form.select :role,
66
- Organizations::Roles::HIERARCHY.reject { |r| r == :owner }.map { |role| [role.to_s.capitalize, role] },
66
+ Organizations::Roles.hierarchy.reject { |r| r == :owner }.map { |role| [role.to_s.capitalize, role] },
67
67
  { selected: membership.role },
68
68
  class: "rounded-md border-gray-300 text-sm focus:border-slate-500 focus:ring-slate-500" %>
69
69
  <%= form.submit "Update",
@@ -126,7 +126,7 @@
126
126
  <div class="flex items-center gap-2">
127
127
  <%= form_with(model: membership, url: organizations.membership_path(membership), method: :patch, local: true, class: "flex items-center gap-2") do |form| %>
128
128
  <%= form.select :role,
129
- Organizations::Roles::HIERARCHY.reject { |r| r == :owner }.map { |role| [role.to_s.capitalize, role] },
129
+ Organizations::Roles.hierarchy.reject { |r| r == :owner }.map { |role| [role.to_s.capitalize, role] },
130
130
  { selected: membership.role },
131
131
  class: "rounded-md border-gray-300 text-xs focus:border-slate-500 focus:ring-slate-500 py-1" %>
132
132
  <%= form.submit "Update",
@@ -558,8 +558,9 @@ module Organizations
558
558
  raise ConfigurationError, "invitation_expiry must be a Duration (e.g., 7.days) or nil"
559
559
  end
560
560
 
561
- unless Roles::HIERARCHY.include?(@default_invitation_role.to_sym)
562
- raise ConfigurationError, "default_invitation_role must be one of: #{Roles::HIERARCHY.join(', ')}"
561
+ roles = Roles.hierarchy_for(@custom_roles_definition)
562
+ unless roles.include?(@default_invitation_role.to_sym)
563
+ raise ConfigurationError, "default_invitation_role must be one of: #{roles.join(', ')}"
563
564
  end
564
565
  end
565
566
 
@@ -38,7 +38,9 @@ module Organizations
38
38
 
39
39
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
40
40
  validates :token, presence: true, uniqueness: true
41
- validates :role, presence: true, inclusion: { in: ->(_) { Roles::HIERARCHY.map(&:to_s) } }
41
+ validates :role, presence: true
42
+ validates :role, inclusion: { in: ->(_) { Roles.valid_roles.map(&:to_s) } },
43
+ if: -> { new_record? || will_save_change_to_role? }
42
44
 
43
45
  # Validate only one non-accepted invitation per email per organization
44
46
  # This matches the DB constraint (accepted_at IS NULL), not expiry status
@@ -53,7 +53,11 @@ module Organizations
53
53
 
54
54
  # === Validations ===
55
55
 
56
- validates :role, presence: true, inclusion: { in: ->(_) { Roles::HIERARCHY.map(&:to_s) } }
56
+ validates :role, presence: true
57
+ # Checked when the role is set or changed, so a role retired from the
58
+ # configuration does not block unrelated updates of existing memberships.
59
+ validates :role, inclusion: { in: ->(_) { Roles.valid_roles.map(&:to_s) } },
60
+ if: -> { new_record? || will_save_change_to_role? }
57
61
  # Proc message: resolved at VALIDATION time so it follows I18n.locale —
58
62
  # a literal string here would be frozen in whatever locale loaded first.
59
63
  validates :user_id, uniqueness: { scope: :organization_id,
@@ -78,16 +82,11 @@ module Organizations
78
82
  scope :viewers, -> { where(role: "viewer") }
79
83
 
80
84
  # Order by role hierarchy (owners first)
85
+ # (the configured hierarchy, custom roles included; unknown roles last)
81
86
  scope :by_role_hierarchy, -> {
82
- order(Arel.sql(<<~SQL.squish))
83
- CASE role
84
- WHEN 'owner' THEN 0
85
- WHEN 'admin' THEN 1
86
- WHEN 'member' THEN 2
87
- WHEN 'viewer' THEN 3
88
- ELSE 4
89
- END
90
- SQL
87
+ roles = Roles.hierarchy # one snapshot for the ranks and the fallback
88
+ ranks = roles.each_with_index.map { |role, index| "WHEN #{connection.quote(role.to_s)} THEN #{index}" }
89
+ order(Arel.sql("CASE role #{ranks.join(' ')} ELSE #{roles.length} END"))
91
90
  }
92
91
 
93
92
  # === Verified Joining (v0.5.0) ===
@@ -56,6 +56,9 @@ module Organizations
56
56
  ].freeze
57
57
  }.freeze
58
58
 
59
+ # One published roles configuration (see Roles.snapshot).
60
+ Snapshot = Struct.new(:definition, :hierarchy, :permissions, :permission_sets)
61
+
59
62
  class << self
60
63
  # Get the default permission structure
61
64
  # @return [Hash<Symbol, Array<Symbol>>]
@@ -66,13 +69,13 @@ module Organizations
66
69
  # Get the role hierarchy
67
70
  # @return [Array<Symbol>]
68
71
  def hierarchy
69
- HIERARCHY
72
+ snapshot.hierarchy
70
73
  end
71
74
 
72
75
  # Get all valid role names
73
76
  # @return [Array<Symbol>]
74
77
  def valid_roles
75
- HIERARCHY
78
+ hierarchy
76
79
  end
77
80
 
78
81
  # Check if a role is valid
@@ -81,7 +84,7 @@ module Organizations
81
84
  def valid_role?(role)
82
85
  return false if role.nil?
83
86
 
84
- HIERARCHY.include?(role.to_sym)
87
+ hierarchy.include?(role.to_sym)
85
88
  end
86
89
 
87
90
  # Check if role_a is at least as high as role_b in hierarchy
@@ -96,8 +99,9 @@ module Organizations
96
99
  def at_least?(role_a, role_b)
97
100
  return false if role_a.nil? || role_b.nil?
98
101
 
99
- idx_a = HIERARCHY.index(role_a.to_sym)
100
- idx_b = HIERARCHY.index(role_b.to_sym)
102
+ order = hierarchy # one snapshot for both lookups
103
+ idx_a = order.index(role_a.to_sym)
104
+ idx_b = order.index(role_b.to_sym)
101
105
 
102
106
  return false unless idx_a && idx_b
103
107
 
@@ -110,8 +114,10 @@ module Organizations
110
114
  # @param role_b [Symbol, String] Second role
111
115
  # @return [Integer] -1 if a > b, 0 if equal, 1 if a < b
112
116
  def compare(role_a, role_b)
113
- idx_a = HIERARCHY.index(role_a.to_sym)
114
- idx_b = HIERARCHY.index(role_b.to_sym)
117
+ # A role that is no longer configured ranks below every configured role.
118
+ order = hierarchy # one snapshot for both lookups
119
+ idx_a = order.index(role_a.to_sym) || order.length
120
+ idx_b = order.index(role_b.to_sym) || order.length
115
121
 
116
122
  return 0 if idx_a == idx_b
117
123
 
@@ -146,19 +152,18 @@ module Organizations
146
152
  # Get the pre-computed permission hash (for direct access)
147
153
  # @return [Hash<Symbol, Array<Symbol>>]
148
154
  def permissions
149
- @permissions ||= compute_permissions
155
+ snapshot.permissions
150
156
  end
151
157
 
152
158
  # Get the pre-computed permission sets (for O(1) lookups)
153
159
  # @return [Hash<Symbol, Set<Symbol>>]
154
160
  def permission_sets
155
- @permission_sets ||= permissions.transform_values { |perms| Set.new(perms) }
161
+ snapshot.permission_sets
156
162
  end
157
163
 
158
164
  # Reset computed permissions (used when custom roles are defined)
159
165
  def reset!
160
- @permissions = nil
161
- @permission_sets = nil
166
+ @snapshot = nil
162
167
  end
163
168
 
164
169
  # Get the next role up in hierarchy
@@ -167,10 +172,11 @@ module Organizations
167
172
  def higher_role(role)
168
173
  return nil if role.nil?
169
174
 
170
- idx = HIERARCHY.index(role.to_sym)
175
+ order = hierarchy # one snapshot for the lookup and the result
176
+ idx = order.index(role.to_sym)
171
177
  return nil unless idx && idx > 0
172
178
 
173
- HIERARCHY[idx - 1]
179
+ order[idx - 1]
174
180
  end
175
181
 
176
182
  # Get the next role down in hierarchy
@@ -179,23 +185,54 @@ module Organizations
179
185
  def lower_role(role)
180
186
  return nil if role.nil?
181
187
 
182
- idx = HIERARCHY.index(role.to_sym)
183
- return nil unless idx && idx < HIERARCHY.length - 1
188
+ order = hierarchy # one snapshot for the lookup and the result
189
+ idx = order.index(role.to_sym)
190
+ return nil unless idx && idx < order.length - 1
184
191
 
185
- HIERARCHY[idx + 1]
192
+ order[idx + 1]
193
+ end
194
+
195
+ # The role hierarchy (highest first) of a roles definition: the four
196
+ # built-in roles, and each custom role directly above the role it
197
+ # inherits from (a custom role without `inherits:` sits at the bottom).
198
+ def hierarchy_for(definition)
199
+ return HIERARCHY unless definition
200
+
201
+ builder = RoleBuilder.new
202
+ builder.instance_eval(&definition)
203
+ builder.validate_privileges!
204
+ builder.hierarchy
186
205
  end
187
206
 
188
207
  private
189
208
 
190
- def compute_permissions
191
- # Use custom roles if defined, otherwise use defaults
192
- custom_def = Organizations.configuration&.custom_roles_definition
193
- return DEFAULT_PERMISSIONS.dup unless custom_def
209
+ # The hierarchy and permissions of one roles definition, computed
210
+ # together and published as one immutable value. It belongs to the
211
+ # definition object it was built from: after a reconfiguration (or a
212
+ # rolled-back one) the next reader sees the identity change and builds a
213
+ # new snapshot, so a reader that raced a reset can never keep an
214
+ # obsolete hierarchy in place.
215
+ def snapshot
216
+ definition = Organizations.configuration&.custom_roles_definition
217
+ current = @snapshot
218
+ return current if current && current.definition.equal?(definition)
219
+
220
+ @snapshot = build_snapshot(definition)
221
+ end
194
222
 
195
- # Build custom permissions using DSL
196
- builder = RoleBuilder.new
197
- builder.instance_eval(&custom_def)
198
- builder.to_permissions
223
+ def build_snapshot(definition)
224
+ if definition
225
+ builder = RoleBuilder.new
226
+ builder.instance_eval(&definition)
227
+ builder.validate_privileges!
228
+ hierarchy = builder.hierarchy
229
+ permissions = builder.to_permissions
230
+ else
231
+ hierarchy = HIERARCHY
232
+ permissions = DEFAULT_PERMISSIONS
233
+ end
234
+ sets = permissions.transform_values { |perms| Set.new(perms).freeze }.freeze
235
+ Snapshot.new(definition, hierarchy, permissions, sets).freeze
199
236
  end
200
237
  end
201
238
 
@@ -211,6 +248,10 @@ module Organizations
211
248
  # @param inherits [Symbol, nil] Role to inherit from
212
249
  # @yield Block to define permissions
213
250
  def role(name, inherits: nil, &block)
251
+ name = name.to_sym
252
+ inherits = inherits&.to_sym
253
+ validate_role_definition!(name, inherits)
254
+
214
255
  @roles[name] = {
215
256
  inherits: inherits,
216
257
  permissions: []
@@ -228,28 +269,94 @@ module Organizations
228
269
  @roles[@current_role][:permissions] << permission.to_sym
229
270
  end
230
271
 
272
+ # The hierarchy, highest first: the built-in roles in their fixed order,
273
+ # and each custom role inserted directly above the role it inherits from.
274
+ # @return [Array<Symbol>]
275
+ def hierarchy
276
+ order = Roles::HIERARCHY.dup
277
+ custom_roles.each do |name|
278
+ parent = @roles[name][:inherits]
279
+ parent ? order.insert(order.index(parent), name) : order << name
280
+ end
281
+ order.freeze
282
+ end
283
+
231
284
  # Build final permissions hash with inheritance resolved
232
285
  # @return [Hash<Symbol, Array<Symbol>>]
233
286
  def to_permissions
234
- result = {}
235
-
236
287
  # Process roles in reverse hierarchy order (lowest first)
237
288
  # so inheritance works correctly
238
- Roles::HIERARCHY.reverse_each do |role_name|
239
- next unless @roles.key?(role_name)
289
+ hierarchy.reverse_each.with_object({}) do |role_name, result|
290
+ perms = @roles.key?(role_name) ? defined_permissions(role_name, result) : omitted_permissions(role_name)
291
+ result[role_name] = perms.freeze if perms
292
+ end.freeze
293
+ end
240
294
 
241
- role_def = @roles[role_name]
242
- perms = role_def[:permissions].dup
295
+ # An admin can assign a custom role, so a custom role may not grant a
296
+ # permission that the owner role has and the admin role lacks (for
297
+ # example manage_billing or transfer_ownership). Otherwise an admin
298
+ # could give another account a power that the admin does not have.
299
+ def validate_privileges!
300
+ validate_built_in_parents!
301
+ permissions = to_permissions
302
+ owner_only = (permissions[:owner] || DEFAULT_PERMISSIONS[:owner]) -
303
+ (permissions[:admin] || DEFAULT_PERMISSIONS[:admin])
304
+ custom_roles.each do |name|
305
+ granted = @roles[name][:permissions] & owner_only
306
+ next if granted.empty?
307
+
308
+ raise ConfigurationError, "Custom role #{name} cannot grant #{granted.join(', ')}: " \
309
+ "admins can assign it, and only the owner has that permission"
310
+ end
311
+ end
243
312
 
244
- # Add inherited permissions
245
- if role_def[:inherits] && result.key?(role_def[:inherits])
246
- perms = (result[role_def[:inherits]] + perms).uniq
247
- end
313
+ private
314
+
315
+ # A built-in role may inherit a custom role only when that custom role
316
+ # ranks below it; otherwise the inheritance would be dropped silently.
317
+ def validate_built_in_parents!
318
+ order = hierarchy
319
+ (@roles.keys & Roles::HIERARCHY).each do |name|
320
+ parent = @roles[name][:inherits]
321
+ next unless parent && custom_roles.include?(parent)
322
+ next if order.index(parent) > order.index(name)
248
323
 
249
- result[role_name] = perms.freeze
324
+ raise ConfigurationError, "Role #{name} cannot inherit from #{parent}: #{parent} ranks above #{name}"
325
+ end
326
+ end
327
+
328
+ def custom_roles = @roles.keys - Roles::HIERARCHY
329
+
330
+ # A role's own permissions plus its parent's. A custom role that inherits
331
+ # a built-in role the block does not redefine inherits its defaults.
332
+ def defined_permissions(role_name, result)
333
+ role_def = @roles[role_name]
334
+ parent = role_def[:inherits]
335
+ inherited = parent && (result[parent] || (custom_roles.include?(role_name) && DEFAULT_PERMISSIONS[parent]))
336
+ inherited ? (inherited + role_def[:permissions]).uniq : role_def[:permissions].dup
337
+ end
338
+
339
+ # A block that only adds custom roles keeps the built-in roles' defaults.
340
+ # An empty block still defines no permissions, as before custom roles.
341
+ def omitted_permissions(role_name) = custom_only? ? DEFAULT_PERMISSIONS[role_name] : nil
342
+ def custom_only? = custom_roles.any? && !@roles.keys.intersect?(Roles::HIERARCHY)
343
+
344
+ # A custom role needs a plain name, and a parent that already exists (a
345
+ # built-in role below admin or a custom role defined above it). Nothing
346
+ # inherits admin or owner.
347
+ def validate_role_definition!(name, inherits)
348
+ unless name.to_s.match?(/\A[a-z][a-z0-9_]{0,31}\z/)
349
+ raise ConfigurationError, "Role name #{name.inspect} must be lowercase letters, digits and underscores"
350
+ end
351
+ return if Roles::HIERARCHY.include?(name)
352
+ raise ConfigurationError, "Role #{name} is defined twice" if @roles.key?(name)
353
+ # A custom role ranks below admin, so it never outranks the people who can assign it.
354
+ if %i[owner admin].include?(inherits)
355
+ raise ConfigurationError, "Custom role #{name} cannot inherit from #{inherits}: a custom role ranks below admin"
250
356
  end
357
+ return if inherits.nil? || %i[member viewer].include?(inherits) || @roles.key?(inherits)
251
358
 
252
- result.freeze
359
+ raise ConfigurationError, "Custom role #{name} inherits from #{inherits}, which is not defined above it"
253
360
  end
254
361
  end
255
362
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Organizations
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: organizations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-07-22 00:00:00.000000000 Z
10
+ date: 2026-10-17 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: railties