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.
- checksums.yaml +7 -0
- data/.rubocop.yml +137 -0
- data/.simplecov +35 -0
- data/AGENTS.md +5 -0
- data/Appraisals +9 -0
- data/CHANGELOG.md +14 -0
- data/CLAUDE.md +5 -0
- data/LICENSE +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +1496 -0
- data/Rakefile +15 -0
- data/app/controllers/organizations/application_controller.rb +251 -0
- data/app/controllers/organizations/invitations_controller.rb +262 -0
- data/app/controllers/organizations/memberships_controller.rb +179 -0
- data/app/controllers/organizations/organizations_controller.rb +179 -0
- data/app/controllers/organizations/switch_controller.rb +38 -0
- data/app/mailers/organizations/invitation_mailer.rb +85 -0
- data/app/views/organizations/invitation_mailer/invitation_email.html.erb +98 -0
- data/app/views/organizations/invitation_mailer/invitation_email.text.erb +18 -0
- data/config/routes.rb +35 -0
- data/examples/demo_insert_race_condition.rb +212 -0
- data/examples/demo_slugifiable_integration.rb +350 -0
- data/lib/generators/organizations/install/install_generator.rb +42 -0
- data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +128 -0
- data/lib/generators/organizations/install/templates/initializer.rb +83 -0
- data/lib/organizations/acts_as_tenant_integration.rb +54 -0
- data/lib/organizations/callback_context.rb +51 -0
- data/lib/organizations/callbacks.rb +120 -0
- data/lib/organizations/configuration.rb +286 -0
- data/lib/organizations/controller_helpers.rb +292 -0
- data/lib/organizations/engine.rb +65 -0
- data/lib/organizations/models/concerns/has_organizations.rb +509 -0
- data/lib/organizations/models/invitation.rb +295 -0
- data/lib/organizations/models/membership.rb +260 -0
- data/lib/organizations/models/organization.rb +451 -0
- data/lib/organizations/roles.rb +256 -0
- data/lib/organizations/test_helpers.rb +167 -0
- data/lib/organizations/version.rb +5 -0
- data/lib/organizations/view_helpers.rb +353 -0
- data/lib/organizations.rb +107 -0
- 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
|