organizations 0.1.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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +137 -0
  3. data/.simplecov +35 -0
  4. data/AGENTS.md +5 -0
  5. data/Appraisals +9 -0
  6. data/CHANGELOG.md +14 -0
  7. data/CLAUDE.md +5 -0
  8. data/LICENSE +21 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +1496 -0
  11. data/Rakefile +15 -0
  12. data/app/controllers/organizations/application_controller.rb +251 -0
  13. data/app/controllers/organizations/invitations_controller.rb +262 -0
  14. data/app/controllers/organizations/memberships_controller.rb +179 -0
  15. data/app/controllers/organizations/organizations_controller.rb +179 -0
  16. data/app/controllers/organizations/switch_controller.rb +38 -0
  17. data/app/mailers/organizations/invitation_mailer.rb +85 -0
  18. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +98 -0
  19. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +18 -0
  20. data/config/routes.rb +35 -0
  21. data/examples/demo_insert_race_condition.rb +212 -0
  22. data/examples/demo_slugifiable_integration.rb +350 -0
  23. data/lib/generators/organizations/install/install_generator.rb +42 -0
  24. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +128 -0
  25. data/lib/generators/organizations/install/templates/initializer.rb +83 -0
  26. data/lib/organizations/acts_as_tenant_integration.rb +54 -0
  27. data/lib/organizations/callback_context.rb +51 -0
  28. data/lib/organizations/callbacks.rb +120 -0
  29. data/lib/organizations/configuration.rb +286 -0
  30. data/lib/organizations/controller_helpers.rb +292 -0
  31. data/lib/organizations/engine.rb +65 -0
  32. data/lib/organizations/models/concerns/has_organizations.rb +509 -0
  33. data/lib/organizations/models/invitation.rb +295 -0
  34. data/lib/organizations/models/membership.rb +260 -0
  35. data/lib/organizations/models/organization.rb +451 -0
  36. data/lib/organizations/roles.rb +256 -0
  37. data/lib/organizations/test_helpers.rb +167 -0
  38. data/lib/organizations/version.rb +5 -0
  39. data/lib/organizations/view_helpers.rb +353 -0
  40. data/lib/organizations.rb +107 -0
  41. metadata +163 -0
@@ -0,0 +1,451 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "slugifiable/model"
4
+
5
+ module Organizations
6
+ # Organization model representing a team, workspace, or account.
7
+ # Users belong to organizations through memberships with specific roles.
8
+ #
9
+ # @example Creating an organization
10
+ # org = Organization.create!(name: "Acme Corp")
11
+ #
12
+ # @example Adding members
13
+ # org.add_member!(user, role: :admin)
14
+ #
15
+ # @example Querying members
16
+ # org.owner # => User (the owner)
17
+ # org.admins # => [User, User] (admins including owner)
18
+ # org.member_count # => 5
19
+ #
20
+ class Organization < ActiveRecord::Base
21
+ self.table_name = "organizations"
22
+ include Slugifiable::Model
23
+
24
+ # Keep slug semantics aligned with README and slugifiable defaults:
25
+ # organization names become URL-friendly slugs, with collision handling.
26
+ generate_slug_based_on :name
27
+
28
+ # Error raised when trying to perform invalid operations on organization
29
+ class CannotRemoveOwner < Organizations::Error; end
30
+ class CannotDemoteOwner < Organizations::Error; end
31
+ class CannotHaveMultipleOwners < Organizations::Error; end
32
+ class CannotTransferToNonMember < Organizations::Error; end
33
+ class CannotTransferToNonAdmin < Organizations::Error; end
34
+ class CannotInviteAsOwner < Organizations::Error; end
35
+ class NoOwnerPresent < Organizations::Error; end
36
+ class MemberAlreadyExists < Organizations::Error; end
37
+
38
+ # === Associations ===
39
+
40
+ has_many :memberships,
41
+ class_name: "Organizations::Membership",
42
+ inverse_of: :organization,
43
+ dependent: :destroy
44
+
45
+ has_many :users,
46
+ through: :memberships
47
+
48
+ has_many :invitations,
49
+ class_name: "Organizations::Invitation",
50
+ inverse_of: :organization,
51
+ dependent: :destroy
52
+
53
+ # === Validations ===
54
+
55
+ validates :name, presence: true
56
+ validates :slug, presence: true, uniqueness: { case_sensitive: false }
57
+
58
+ # === Callbacks ===
59
+
60
+ # slugifiable persists slugs in after_create by default, but this gem keeps
61
+ # organizations.slug as NOT NULL and validates presence. We therefore compute
62
+ # a slug before validation when needed.
63
+ before_validation :ensure_slug_present, on: :create, if: -> { slug.blank? && name.present? }
64
+
65
+ # === Scopes ===
66
+
67
+ # Find all organizations where a user is a member
68
+ # Uses efficient JOIN query
69
+ # @param user [User] The user
70
+ # @return [ActiveRecord::Relation]
71
+ scope :with_member, ->(user) {
72
+ joins(:memberships).where(memberships: { user_id: user.id })
73
+ }
74
+
75
+ # === Member Query Methods ===
76
+
77
+ # Get the owner of this organization
78
+ # @return [User, nil]
79
+ def owner
80
+ owner_membership&.user
81
+ end
82
+
83
+ # Get the owner's membership
84
+ # @return [Membership, nil]
85
+ def owner_membership
86
+ memberships.find_by(role: "owner")
87
+ end
88
+
89
+ # Get all admins (users with admin role or higher)
90
+ # Uses efficient JOIN query to avoid N+1
91
+ # @return [ActiveRecord::Relation<User>]
92
+ def admins
93
+ users.where(memberships: { role: %w[owner admin] }).distinct
94
+ end
95
+
96
+ # Alias for users (semantic convenience)
97
+ alias members users
98
+
99
+ # Check if organization has a specific user as member
100
+ # @param user [User] The user to check
101
+ # @return [Boolean]
102
+ def has_member?(user)
103
+ return false unless user
104
+
105
+ memberships.exists?(user_id: user.id)
106
+ end
107
+
108
+ # Check if organization has any members
109
+ # Uses efficient EXISTS query
110
+ # @return [Boolean]
111
+ def has_any_members?
112
+ memberships.exists?
113
+ end
114
+
115
+ # Get member count (uses counter cache if available, otherwise COUNT)
116
+ # @return [Integer]
117
+ def member_count
118
+ if has_attribute?(:memberships_count)
119
+ self[:memberships_count] || memberships.count
120
+ else
121
+ memberships.count
122
+ end
123
+ end
124
+
125
+ # Get pending invitations
126
+ # @return [ActiveRecord::Relation<Invitation>]
127
+ def pending_invitations
128
+ invitations.pending
129
+ end
130
+
131
+ # === Member Management Methods ===
132
+
133
+ # Add a user as member with specified role
134
+ # Handles race conditions with unique constraint
135
+ # @param user [User] The user to add
136
+ # @param role [Symbol] The role (default: :member)
137
+ # @return [Membership] The created or existing membership
138
+ # @raise [ArgumentError] if role is invalid
139
+ # @raise [CannotHaveMultipleOwners] if role is :owner (use transfer_ownership_to!)
140
+ def add_member!(user, role: :member)
141
+ role_sym = role.to_sym
142
+ validate_role!(role_sym)
143
+
144
+ # Owner role is only assignable via transfer_ownership_to! or initial creation
145
+ if role_sym == :owner
146
+ raise CannotHaveMultipleOwners, "Cannot add member as owner. Use transfer_ownership_to! instead."
147
+ end
148
+
149
+ # Check if already a member (idempotent operation)
150
+ existing = memberships.find_by(user_id: user.id)
151
+ return existing if existing
152
+
153
+ membership = nil
154
+ ActiveRecord::Base.transaction do
155
+ membership = memberships.create!(
156
+ user: user,
157
+ role: role_sym.to_s
158
+ )
159
+ end
160
+
161
+ Callbacks.dispatch(
162
+ :member_joined,
163
+ organization: self,
164
+ membership: membership,
165
+ user: user
166
+ )
167
+
168
+ membership
169
+ rescue ActiveRecord::RecordNotUnique
170
+ # Race condition: membership was created by another process
171
+ memberships.find_by!(user_id: user.id)
172
+ end
173
+
174
+ # Remove a user from the organization
175
+ # @param user [User] The user to remove
176
+ # @param removed_by [User, nil] Who is removing them (for callbacks)
177
+ # @raise [CannotRemoveOwner] if trying to remove the owner
178
+ def remove_member!(user, removed_by: nil)
179
+ membership = memberships.find_by(user_id: user.id)
180
+ return unless membership
181
+
182
+ if membership.role.to_sym == :owner
183
+ raise CannotRemoveOwner, "Cannot remove the organization owner. Transfer ownership first."
184
+ end
185
+
186
+ ActiveRecord::Base.transaction do
187
+ # Lock organization to prevent race conditions
188
+ lock!
189
+ membership.destroy!
190
+ end
191
+
192
+ Callbacks.dispatch(
193
+ :member_removed,
194
+ organization: self,
195
+ membership: membership,
196
+ user: user,
197
+ removed_by: removed_by
198
+ )
199
+ end
200
+
201
+ # Change a user's role in the organization
202
+ # @param user [User] The user whose role to change
203
+ # @param to [Symbol] The new role
204
+ # @param changed_by [User, nil] Who is making the change (for callbacks)
205
+ # @return [Membership] The updated membership
206
+ # @raise [CannotHaveMultipleOwners] if promoting to owner when one exists
207
+ # @raise [CannotRemoveLastOwner] if demoting the only owner
208
+ def change_role_of!(user, to:, changed_by: nil)
209
+ new_role = to.to_sym
210
+ validate_role!(new_role)
211
+
212
+ membership = memberships.find_by!(user_id: user.id)
213
+ old_role = membership.role.to_sym
214
+
215
+ return membership if old_role == new_role
216
+
217
+ ActiveRecord::Base.transaction do
218
+ # Lock organization to prevent race conditions
219
+ lock!
220
+
221
+ # Lock membership row to prevent concurrent changes
222
+ membership.lock!
223
+
224
+ # Enforce exactly-one-owner invariant
225
+ if new_role == :owner && old_role != :owner
226
+ # Promoting to owner - this is only allowed via transfer_ownership_to!
227
+ # Direct role change to owner is not permitted
228
+ raise CannotHaveMultipleOwners, "Cannot promote to owner. Use transfer_ownership_to! instead."
229
+ end
230
+
231
+ if old_role == :owner && new_role != :owner
232
+ # Demoting owner - not allowed directly
233
+ raise CannotDemoteOwner, "Cannot demote owner directly. Use transfer_ownership_to! instead."
234
+ end
235
+
236
+ membership.update!(role: new_role.to_s)
237
+ end
238
+
239
+ Callbacks.dispatch(
240
+ :role_changed,
241
+ organization: self,
242
+ membership: membership,
243
+ old_role: old_role,
244
+ new_role: new_role,
245
+ changed_by: changed_by
246
+ )
247
+
248
+ membership
249
+ end
250
+
251
+ # Transfer ownership to another user
252
+ # The new owner must be an admin of the organization
253
+ # Old owner becomes admin
254
+ # @param new_owner [User] The user to become owner
255
+ # @raise [CannotTransferToNonMember] if user is not a member
256
+ # @raise [CannotTransferToNonAdmin] if user is not an admin
257
+ def transfer_ownership_to!(new_owner)
258
+ ActiveRecord::Base.transaction do
259
+ # Lock organization first to prevent concurrent operations
260
+ lock!
261
+
262
+ old_owner_membership = owner_membership
263
+ new_owner_membership = memberships.find_by(user_id: new_owner.id)
264
+
265
+ unless old_owner_membership
266
+ raise NoOwnerPresent, "Cannot transfer ownership because organization has no owner membership"
267
+ end
268
+
269
+ unless new_owner_membership
270
+ raise CannotTransferToNonMember, "Cannot transfer ownership to a non-member"
271
+ end
272
+
273
+ # New owner must be at least an admin (per README: "Ownership can be transferred to any admin")
274
+ unless Roles.at_least?(new_owner_membership.role.to_sym, :admin)
275
+ raise CannotTransferToNonAdmin, "Cannot transfer ownership to non-admin. Promote them to admin first."
276
+ end
277
+
278
+ # No-op transfer to the current owner.
279
+ return old_owner_membership if old_owner_membership.user_id == new_owner.id
280
+
281
+ # Lock both memberships
282
+ old_owner_membership.lock!
283
+ new_owner_membership.lock!
284
+
285
+ old_owner_user = old_owner_membership.user
286
+
287
+ # Demote old owner to admin
288
+ old_owner_membership.update!(role: "admin")
289
+
290
+ # Promote new owner
291
+ new_owner_membership.update!(role: "owner")
292
+
293
+ Callbacks.dispatch(
294
+ :ownership_transferred,
295
+ organization: self,
296
+ old_owner: old_owner_user,
297
+ new_owner: new_owner
298
+ )
299
+ end
300
+ end
301
+
302
+ # === Invitation Methods ===
303
+
304
+ # Send an invitation to join this organization
305
+ # @param email [String] Email address to invite
306
+ # @param invited_by [User, nil] Who is sending the invitation (uses Current.user if not provided)
307
+ # @param role [Symbol] Role for the invitation (default: from config)
308
+ # @return [Invitation] The created or existing invitation
309
+ # @raise [CannotInviteAsOwner] if role is :owner
310
+ def send_invite_to!(email, invited_by: nil, role: nil)
311
+ inviter = invited_by || current_user_from_context
312
+ unless inviter
313
+ raise ArgumentError, "invited_by is required (or set Current.user)"
314
+ end
315
+
316
+ authorize_inviter!(inviter)
317
+
318
+ role ||= Organizations.configuration.default_invitation_role
319
+ role_sym = role.to_sym
320
+
321
+ # Owner role cannot be assigned via invitation - only via transfer_ownership_to!
322
+ if role_sym == :owner
323
+ raise CannotInviteAsOwner, "Cannot invite as owner. Invite as admin, then use transfer_ownership_to! after they join."
324
+ end
325
+
326
+ normalized_email = email.downcase.strip
327
+
328
+ # Check for existing pending invitation (idempotent)
329
+ existing = invitations.pending.for_email(normalized_email).first
330
+ return existing if existing
331
+
332
+ # Check if already a member (case-insensitive)
333
+ if users.where("LOWER(email) = ?", normalized_email).exists?
334
+ raise Organizations::InvitationError, "User is already a member of this organization"
335
+ end
336
+
337
+ # Allow callback hooks to veto invitations (e.g., plan seat limits) before write.
338
+ invitation_context = invitations.build(
339
+ email: normalized_email,
340
+ invited_by: inviter,
341
+ role: role.to_s,
342
+ expires_at: calculate_expiry
343
+ )
344
+
345
+ Callbacks.dispatch(
346
+ :member_invited,
347
+ strict: true,
348
+ organization: self,
349
+ invitation: invitation_context,
350
+ invited_by: inviter
351
+ )
352
+
353
+ invitation = nil
354
+ ActiveRecord::Base.transaction do
355
+ # Check for expired invitation and refresh it instead of creating duplicate
356
+ expired_invitation = invitations.expired.for_email(normalized_email).first
357
+ if expired_invitation
358
+ expired_invitation.lock!
359
+ expired_invitation.update!(
360
+ invited_by: inviter,
361
+ role: role.to_s,
362
+ token: generate_unique_token,
363
+ expires_at: calculate_expiry
364
+ )
365
+ invitation = expired_invitation
366
+ else
367
+ invitation = invitations.create!(
368
+ email: normalized_email,
369
+ invited_by: inviter,
370
+ role: role.to_s,
371
+ token: generate_unique_token,
372
+ expires_at: calculate_expiry
373
+ )
374
+ end
375
+ end
376
+
377
+ # Send invitation email
378
+ send_invitation_email(invitation)
379
+
380
+ invitation
381
+ rescue ActiveRecord::RecordNotUnique
382
+ # Race condition: invitation was created by another process
383
+ invitations.pending.for_email(normalized_email).first!
384
+ end
385
+
386
+ private
387
+
388
+ def ensure_slug_present
389
+ self.slug = compute_slug if slug.blank?
390
+ end
391
+
392
+ # Defense in depth for organization-centric API usage.
393
+ # The user-level API already checks this, but direct calls to `org.send_invite_to!`
394
+ # must enforce membership and invite permission as well.
395
+ def authorize_inviter!(inviter)
396
+ inviter_membership = memberships.find_by(user_id: inviter.id)
397
+
398
+ unless inviter_membership
399
+ raise Organizations::NotAMember.new(
400
+ "Only organization members can send invitations",
401
+ organization: self,
402
+ user: inviter
403
+ )
404
+ end
405
+
406
+ return if Roles.has_permission?(inviter_membership.role.to_sym, :invite_members)
407
+
408
+ raise Organizations::NotAuthorized.new(
409
+ "You don't have permission to invite members",
410
+ permission: :invite_members,
411
+ organization: self,
412
+ user: inviter
413
+ )
414
+ end
415
+
416
+ def validate_role!(role)
417
+ unless Roles.valid_role?(role)
418
+ raise ArgumentError, "Invalid role: #{role}. Must be one of: #{Roles.valid_roles.join(', ')}"
419
+ end
420
+ end
421
+
422
+ def generate_unique_token
423
+ loop do
424
+ token = SecureRandom.urlsafe_base64(32)
425
+ break token unless Invitation.exists?(token: token)
426
+ end
427
+ end
428
+
429
+ def calculate_expiry
430
+ expiry = Organizations.configuration.invitation_expiry
431
+ return nil unless expiry
432
+
433
+ Time.current + expiry
434
+ end
435
+
436
+ def send_invitation_email(invitation)
437
+ mailer_class = Organizations.configuration.invitation_mailer.constantize
438
+ mailer_class.invitation_email(invitation).deliver_later
439
+ rescue StandardError => e
440
+ # Log but don't fail - invitation is created, email can be resent
441
+ Callbacks.log_error("[Organizations] Failed to send invitation email: #{e.message}")
442
+ end
443
+
444
+ def current_user_from_context
445
+ # Try to get Current.user if available (Rails 5.2+)
446
+ if defined?(Current) && Current.respond_to?(:user)
447
+ Current.user
448
+ end
449
+ end
450
+ end
451
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # Roles and permissions management module.
5
+ # Provides hierarchical roles with inherited permissions.
6
+ #
7
+ # The role hierarchy is: owner > admin > member > viewer
8
+ # Each role inherits all permissions from roles below it.
9
+ #
10
+ # Permission lookups are O(1) using pre-computed permission sets.
11
+ #
12
+ module Roles
13
+ # Role hierarchy from highest to lowest
14
+ HIERARCHY = %i[owner admin member viewer].freeze
15
+
16
+ # Default permissions for each role.
17
+ # Matches the permission table in the README.
18
+ DEFAULT_PERMISSIONS = {
19
+ viewer: %i[
20
+ view_organization
21
+ view_members
22
+ ].freeze,
23
+ member: %i[
24
+ view_organization
25
+ view_members
26
+ create_resources
27
+ edit_own_resources
28
+ delete_own_resources
29
+ ].freeze,
30
+ admin: %i[
31
+ view_organization
32
+ view_members
33
+ create_resources
34
+ edit_own_resources
35
+ delete_own_resources
36
+ invite_members
37
+ remove_members
38
+ edit_member_roles
39
+ manage_settings
40
+ view_billing
41
+ ].freeze,
42
+ owner: %i[
43
+ view_organization
44
+ view_members
45
+ create_resources
46
+ edit_own_resources
47
+ delete_own_resources
48
+ invite_members
49
+ remove_members
50
+ edit_member_roles
51
+ manage_settings
52
+ view_billing
53
+ manage_billing
54
+ transfer_ownership
55
+ delete_organization
56
+ ].freeze
57
+ }.freeze
58
+
59
+ class << self
60
+ # Get the default permission structure
61
+ # @return [Hash<Symbol, Array<Symbol>>]
62
+ def default
63
+ DEFAULT_PERMISSIONS
64
+ end
65
+
66
+ # Get the role hierarchy
67
+ # @return [Array<Symbol>]
68
+ def hierarchy
69
+ HIERARCHY
70
+ end
71
+
72
+ # Get all valid role names
73
+ # @return [Array<Symbol>]
74
+ def valid_roles
75
+ HIERARCHY
76
+ end
77
+
78
+ # Check if a role is valid
79
+ # @param role [Symbol, String] The role to check
80
+ # @return [Boolean]
81
+ def valid_role?(role)
82
+ return false if role.nil?
83
+
84
+ HIERARCHY.include?(role.to_sym)
85
+ end
86
+
87
+ # Check if role_a is at least as high as role_b in hierarchy
88
+ # @param role_a [Symbol, String] The role to check
89
+ # @param role_b [Symbol, String] The minimum required role
90
+ # @return [Boolean]
91
+ #
92
+ # @example
93
+ # Roles.at_least?(:owner, :admin) # => true (owner >= admin)
94
+ # Roles.at_least?(:member, :admin) # => false (member < admin)
95
+ #
96
+ def at_least?(role_a, role_b)
97
+ return false if role_a.nil? || role_b.nil?
98
+
99
+ idx_a = HIERARCHY.index(role_a.to_sym)
100
+ idx_b = HIERARCHY.index(role_b.to_sym)
101
+
102
+ return false unless idx_a && idx_b
103
+
104
+ # Lower index = higher rank (owner is index 0)
105
+ idx_a <= idx_b
106
+ end
107
+
108
+ # Compare two roles
109
+ # @param role_a [Symbol, String] First role
110
+ # @param role_b [Symbol, String] Second role
111
+ # @return [Integer] -1 if a > b, 0 if equal, 1 if a < b
112
+ def compare(role_a, role_b)
113
+ idx_a = HIERARCHY.index(role_a.to_sym)
114
+ idx_b = HIERARCHY.index(role_b.to_sym)
115
+
116
+ return 0 if idx_a == idx_b
117
+
118
+ idx_a < idx_b ? -1 : 1
119
+ end
120
+
121
+ # Get all permissions for a role (pre-computed, O(1) lookup)
122
+ # @param role [Symbol, String] The role
123
+ # @return [Array<Symbol>] All permissions for the role
124
+ def permissions_for(role)
125
+ return [] if role.nil?
126
+
127
+ role_sym = role.to_sym
128
+ permissions[role_sym] || []
129
+ end
130
+
131
+ # Check if a role has a specific permission (O(1) lookup)
132
+ # @param role [Symbol, String] The role
133
+ # @param permission [Symbol, String] The permission to check
134
+ # @return [Boolean]
135
+ #
136
+ # @example
137
+ # Roles.has_permission?(:admin, :invite_members) # => true
138
+ # Roles.has_permission?(:member, :invite_members) # => false
139
+ #
140
+ def has_permission?(role, permission)
141
+ return false if role.nil? || permission.nil?
142
+
143
+ permission_sets[role.to_sym]&.include?(permission.to_sym) || false
144
+ end
145
+
146
+ # Get the pre-computed permission hash (for direct access)
147
+ # @return [Hash<Symbol, Array<Symbol>>]
148
+ def permissions
149
+ @permissions ||= compute_permissions
150
+ end
151
+
152
+ # Get the pre-computed permission sets (for O(1) lookups)
153
+ # @return [Hash<Symbol, Set<Symbol>>]
154
+ def permission_sets
155
+ @permission_sets ||= permissions.transform_values { |perms| Set.new(perms) }
156
+ end
157
+
158
+ # Reset computed permissions (used when custom roles are defined)
159
+ def reset!
160
+ @permissions = nil
161
+ @permission_sets = nil
162
+ end
163
+
164
+ # Get the next role up in hierarchy
165
+ # @param role [Symbol, String] Current role
166
+ # @return [Symbol, nil] Next higher role or nil if already highest
167
+ def higher_role(role)
168
+ return nil if role.nil?
169
+
170
+ idx = HIERARCHY.index(role.to_sym)
171
+ return nil unless idx && idx > 0
172
+
173
+ HIERARCHY[idx - 1]
174
+ end
175
+
176
+ # Get the next role down in hierarchy
177
+ # @param role [Symbol, String] Current role
178
+ # @return [Symbol, nil] Next lower role or nil if already lowest
179
+ def lower_role(role)
180
+ return nil if role.nil?
181
+
182
+ idx = HIERARCHY.index(role.to_sym)
183
+ return nil unless idx && idx < HIERARCHY.length - 1
184
+
185
+ HIERARCHY[idx + 1]
186
+ end
187
+
188
+ private
189
+
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
194
+
195
+ # Build custom permissions using DSL
196
+ builder = RoleBuilder.new
197
+ builder.instance_eval(&custom_def)
198
+ builder.to_permissions
199
+ end
200
+ end
201
+
202
+ # DSL builder for custom role definitions
203
+ class RoleBuilder
204
+ def initialize
205
+ @roles = {}
206
+ @current_role = nil
207
+ end
208
+
209
+ # Define a role with optional inheritance
210
+ # @param name [Symbol] Role name
211
+ # @param inherits [Symbol, nil] Role to inherit from
212
+ # @yield Block to define permissions
213
+ def role(name, inherits: nil, &block)
214
+ @roles[name] = {
215
+ inherits: inherits,
216
+ permissions: []
217
+ }
218
+ @current_role = name
219
+ instance_eval(&block) if block_given?
220
+ @current_role = nil
221
+ end
222
+
223
+ # Add a permission to the current role
224
+ # @param permission [Symbol] Permission to add
225
+ def can(permission)
226
+ raise "can must be called within a role block" unless @current_role
227
+
228
+ @roles[@current_role][:permissions] << permission.to_sym
229
+ end
230
+
231
+ # Build final permissions hash with inheritance resolved
232
+ # @return [Hash<Symbol, Array<Symbol>>]
233
+ def to_permissions
234
+ result = {}
235
+
236
+ # Process roles in reverse hierarchy order (lowest first)
237
+ # so inheritance works correctly
238
+ Roles::HIERARCHY.reverse_each do |role_name|
239
+ next unless @roles.key?(role_name)
240
+
241
+ role_def = @roles[role_name]
242
+ perms = role_def[:permissions].dup
243
+
244
+ # Add inherited permissions
245
+ if role_def[:inherits] && result.key?(role_def[:inherits])
246
+ perms = (result[role_def[:inherits]] + perms).uniq
247
+ end
248
+
249
+ result[role_name] = perms.freeze
250
+ end
251
+
252
+ result.freeze
253
+ end
254
+ end
255
+ end
256
+ end