organizations 0.6.0 → 0.7.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/.simplecov +17 -10
  5. data/CHANGELOG.md +93 -0
  6. data/README.md +687 -33
  7. data/app/controllers/organizations/invitations_controller.rb +27 -22
  8. data/app/controllers/organizations/memberships_controller.rb +37 -30
  9. data/app/controllers/organizations/organizations_controller.rb +8 -8
  10. data/app/controllers/organizations/public_invitations_controller.rb +1 -1
  11. data/app/mailers/organizations/invitation_mailer.rb +2 -2
  12. data/app/mailers/organizations/verification_mailer.rb +1 -1
  13. data/app/models/organizations/grant.rb +6 -0
  14. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +11 -11
  15. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +7 -7
  16. data/app/views/organizations/verification_mailer/code_email.html.erb +3 -3
  17. data/app/views/organizations/verification_mailer/code_email.text.erb +3 -3
  18. data/config/locales/en.yml +22 -0
  19. data/config/locales/es.yml +22 -0
  20. data/config/routes.rb +1 -2
  21. data/gemfiles/rails_7.2.gemfile +5 -1
  22. data/gemfiles/rails_8.1.gemfile +5 -1
  23. data/lib/generators/organizations/grants/grants_generator.rb +47 -0
  24. data/lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb +59 -0
  25. data/lib/generators/organizations/install/install_generator.rb +1 -0
  26. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +15 -0
  27. data/lib/generators/organizations/join/join_generator.rb +113 -0
  28. data/lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb +146 -0
  29. data/lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb +93 -0
  30. data/lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb +142 -0
  31. data/lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb +8 -0
  32. data/lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb +113 -0
  33. data/lib/generators/organizations/join/templates/routes.rb +19 -0
  34. data/lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb +29 -0
  35. data/lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb +22 -0
  36. data/lib/generators/organizations/upgrade/upgrade_generator.rb +79 -14
  37. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +1 -1
  38. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +2 -2
  39. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +1 -1
  40. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +21 -13
  41. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +1 -1
  42. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +3 -3
  43. data/lib/generators/organizations/views/views_generator.rb +4 -6
  44. data/lib/organizations/account_deletion_plan.rb +118 -0
  45. data/lib/organizations/configuration.rb +152 -0
  46. data/lib/organizations/controller_helpers.rb +55 -15
  47. data/lib/organizations/grant_access.rb +75 -0
  48. data/lib/organizations/grant_holder.rb +152 -0
  49. data/lib/organizations/grantable.rb +28 -0
  50. data/lib/organizations/join_flow.rb +16 -12
  51. data/lib/organizations/models/concerns/has_organizations.rb +217 -9
  52. data/lib/organizations/models/domain.rb +283 -1
  53. data/lib/organizations/models/grant.rb +152 -0
  54. data/lib/organizations/models/invitation.rb +76 -7
  55. data/lib/organizations/models/join_request.rb +102 -43
  56. data/lib/organizations/models/membership.rb +87 -5
  57. data/lib/organizations/models/organization.rb +219 -48
  58. data/lib/organizations/organization_scoped.rb +16 -0
  59. data/lib/organizations/personal_owner_backfill.rb +122 -0
  60. data/lib/organizations/public_email_domains.rb +49 -0
  61. data/lib/organizations/resource.rb +11 -0
  62. data/lib/organizations/roles.rb +55 -0
  63. data/lib/organizations/version.rb +1 -1
  64. data/lib/organizations/view_helpers.rb +40 -9
  65. data/lib/organizations.rb +99 -4
  66. metadata +22 -2
@@ -32,6 +32,11 @@ module Organizations
32
32
  # see Organizations::MetadataFlags.
33
33
  extend Organizations::MetadataFlags
34
34
 
35
+ # grant!, revoke_grant!, grant_for, granted? (scoped resource grants)
36
+ include Organizations::GrantHolder
37
+ # limited_by_grants?, capabilities_for, can?, visible, granted_ids
38
+ include Organizations::GrantAccess
39
+
35
40
  # Error raised when trying to demote below current role
36
41
  class CannotDemoteOwner < Organizations::Error; end
37
42
  class CannotPromoteToOwner < Organizations::Error; end
@@ -51,6 +56,12 @@ module Organizations
51
56
  class_name: Organizations.user_class_name,
52
57
  optional: true
53
58
 
59
+ # Scoped resource grants (rails g organizations:grants). Removed with the
60
+ # membership: a member who leaves keeps no access. (No dependent:
61
+ # option, because the table is optional: see GrantHolder.)
62
+ has_many :grants, class_name: "Organizations::Grant", inverse_of: :membership
63
+ before_destroy :delete_grants
64
+
54
65
  # === Validations ===
55
66
 
56
67
  validates :role, presence: true
@@ -81,6 +92,11 @@ module Organizations
81
92
  # Memberships with viewer role
82
93
  scope :viewers, -> { where(role: "viewer") }
83
94
 
95
+ # Memberships with a grant on a resource
96
+ scope :with_grant_on, lambda { |resource|
97
+ where(id: Organizations::Grant.active.for_resource(resource).select(:membership_id))
98
+ }
99
+
84
100
  # Order by role hierarchy (owners first)
85
101
  # (the configured hierarchy, custom roles included; unknown roles last)
86
102
  scope :by_role_hierarchy, -> {
@@ -165,25 +181,87 @@ module Organizations
165
181
  Roles.compare(role_sym, other.role_sym)
166
182
  end
167
183
 
184
+ # Check if this member may give another member the specified role, or
185
+ # take it away from them: only roles that rank at or below this member's
186
+ # own role qualify. Useful to filter the role picker in an admin UI.
187
+ # @param role [Symbol, String] The role to give or take away
188
+ # @return [Boolean]
189
+ #
190
+ # @example
191
+ # admin_membership.can_assign_role?(:admin) # => true
192
+ # member_membership.can_assign_role?(:admin) # => false
193
+ #
194
+ def can_assign_role?(role)
195
+ Roles.can_assign?(role_sym, role)
196
+ end
197
+
168
198
  # === Role Change Methods ===
169
199
 
200
+ # Raise unless changed_by may move this member to new_role. A member who
201
+ # changes another member's role must rank at or above both the role being
202
+ # taken away and the role being given (see Roles.can_assign?), so a custom
203
+ # role that can edit member roles still cannot create or demote an admin.
204
+ # A changed_by who holds no membership in this organization, such as a
205
+ # console session or platform staff, is only recorded for the callbacks,
206
+ # as before; authorizing that account is the host's job. promote_to!,
207
+ # demote_to! and Organization#change_role_of! all call this.
208
+ # @param new_role [Symbol, String] The role being given
209
+ # @param changed_by [User, nil] Who is making the change
210
+ # @raise [Organizations::NotAuthorized] if either role ranks above changed_by's own
211
+ def authorize_role_change!(new_role, changed_by:)
212
+ self.class.authorize_rank!(organization, changed_by, [role_sym, new_role],
213
+ permission: :edit_member_roles,
214
+ message_key: :"errors.role_change_outranks_actor")
215
+ end
216
+
217
+ # @api private
218
+ # The one check behind every action on a role: inviting to it, changing a
219
+ # member to or from it, removing a member who holds it, and revoking or
220
+ # resending an invitation for it. An actor may only act on roles that
221
+ # rank at or below its own and, below admin, whose permissions it holds
222
+ # (see Roles.assignment_refusal). An actor that is nil or holds no
223
+ # membership in the organization is not checked: the gem records it for
224
+ # the callbacks and the host authorizes it.
225
+ # @param message_key [Symbol] The message for a role that ranks above the actor's own
226
+ # @raise [Organizations::NotAuthorized] naming the first role the actor may not act on
227
+ def self.authorize_rank!(organization, actor, roles, permission:, message_key:)
228
+ actor_membership = actor && organization.memberships.find_by(user_id: actor.id)
229
+ return unless actor_membership
230
+
231
+ roles.map(&:to_sym).each do |role|
232
+ refusal = Roles.assignment_refusal(actor_membership.role_sym, role)
233
+ next unless refusal
234
+
235
+ key = refusal == :outranks ? message_key : :"errors.role_has_permissions_actor_lacks"
236
+ raise Organizations::NotAuthorized.new(
237
+ Organizations.t(key, organization: organization,
238
+ role: Organizations.t(:"roles.#{role}", organization: organization,
239
+ default: role.to_s.humanize)),
240
+ permission: permission,
241
+ organization: organization,
242
+ user: actor
243
+ )
244
+ end
245
+ end
246
+
170
247
  # Promote to a higher role
171
248
  # @param new_role [Symbol, String] The new role
172
249
  # @param changed_by [User, nil] Who is making the change
173
250
  # @return [self]
174
251
  # @raise [InvalidRoleChange] if new role is not higher
175
252
  # @raise [CannotPromoteToOwner] if trying to promote to owner (use transfer_ownership_to!)
253
+ # @raise [Organizations::NotAuthorized] if changed_by is a member whose role ranks below the new role
176
254
  def promote_to!(new_role, changed_by: nil)
177
255
  new_role_sym = new_role.to_sym
178
256
  validate_role!(new_role_sym)
179
257
 
180
258
  # Owner role is only assignable via transfer_ownership_to!
181
259
  if new_role_sym == :owner
182
- raise CannotPromoteToOwner, Organizations.t(:"errors.cannot_promote_to_owner")
260
+ raise CannotPromoteToOwner, Organizations.t(:"errors.cannot_promote_to_owner", organization: organization)
183
261
  end
184
262
 
185
263
  unless Roles.at_least?(new_role_sym, role_sym)
186
- raise InvalidRoleChange, Organizations.t(:"errors.promote_not_higher", new_role: new_role, role: role)
264
+ raise InvalidRoleChange, Organizations.t(:"errors.promote_not_higher", organization: organization, new_role: new_role, role: role)
187
265
  end
188
266
 
189
267
  change_role_to!(new_role_sym, changed_by: changed_by)
@@ -195,16 +273,17 @@ module Organizations
195
273
  # @return [self]
196
274
  # @raise [CannotDemoteOwner] if trying to demote owner
197
275
  # @raise [InvalidRoleChange] if new role is not lower
276
+ # @raise [Organizations::NotAuthorized] if changed_by is a member whose role ranks below the current role
198
277
  def demote_to!(new_role, changed_by: nil)
199
278
  new_role_sym = new_role.to_sym
200
279
  validate_role!(new_role_sym)
201
280
 
202
281
  if owner?
203
- raise CannotDemoteOwner, Organizations.t(:"errors.cannot_demote_owner")
282
+ raise CannotDemoteOwner, Organizations.t(:"errors.cannot_demote_owner", organization: organization)
204
283
  end
205
284
 
206
285
  unless Roles.at_least?(role_sym, new_role_sym)
207
- raise InvalidRoleChange, Organizations.t(:"errors.demote_not_lower", new_role: new_role, role: role)
286
+ raise InvalidRoleChange, Organizations.t(:"errors.demote_not_lower", organization: organization, new_role: new_role, role: role)
208
287
  end
209
288
 
210
289
  change_role_to!(new_role_sym, changed_by: changed_by)
@@ -220,7 +299,7 @@ module Organizations
220
299
 
221
300
  return unless existing_owner.exists?
222
301
 
223
- errors.add(:role, Organizations.t(:"attributes.owner_taken"))
302
+ errors.add(:role, Organizations.t(:"attributes.owner_taken", organization: organization))
224
303
  end
225
304
 
226
305
  def validate_role!(role)
@@ -237,6 +316,9 @@ module Organizations
237
316
  ActiveRecord::Base.transaction do
238
317
  # Lock row to prevent concurrent changes
239
318
  lock!
319
+ # Checked on the locked, freshly read role, so a concurrent change
320
+ # cannot slip an admin past the check.
321
+ authorize_role_change!(new_role, changed_by: changed_by)
240
322
  update!(role: new_role.to_s)
241
323
  end
242
324
 
@@ -31,6 +31,7 @@ module Organizations
31
31
  class CannotInviteAsOwner < Organizations::Error; end
32
32
  class NoOwnerPresent < Organizations::Error; end
33
33
  class MemberAlreadyExists < Organizations::Error; end
34
+ class CannotTransferPersonalOrganization < Organizations::Error; end
34
35
 
35
36
  # === Associations ===
36
37
 
@@ -42,6 +43,10 @@ module Organizations
42
43
  has_many :users,
43
44
  through: :memberships
44
45
 
46
+ # Every grant of every membership (scoped resource grants). Grants held
47
+ # by pending invitations are not included: see invitation.grants.
48
+ has_many :grants, through: :memberships
49
+
45
50
  has_many :invitations,
46
51
  class_name: "Organizations::Invitation",
47
52
  inverse_of: :organization,
@@ -73,9 +78,19 @@ module Organizations
73
78
  inverse_of: :organization,
74
79
  dependent: :destroy
75
80
 
81
+ # === Personal organizations (v0.7.0) ===
82
+
83
+ # The user whose personal workspace this is (nil for every other
84
+ # organization). Set by user.create_personal_organization!, never guessed.
85
+ # The column is added by `rails g organizations:upgrade`.
86
+ belongs_to :personal_owner,
87
+ class_name: Organizations.user_class_name,
88
+ optional: true
89
+
76
90
  # === Validations ===
77
91
 
78
92
  validates :name, presence: true
93
+ validate :personal_owner_holds_ownership, on: :update, if: :personal_owner_changed_to_someone?
79
94
 
80
95
  # === Scopes ===
81
96
 
@@ -87,6 +102,12 @@ module Organizations
87
102
  joins(:memberships).where(organizations_memberships: { user_id: user.id })
88
103
  }
89
104
 
105
+ # Personal organizations (each one linked to the user it belongs to)
106
+ scope :personal, -> { where.not(personal_owner_id: nil) }
107
+
108
+ # Every organization that is not a personal organization
109
+ scope :not_personal, -> { where(personal_owner_id: nil) }
110
+
90
111
  # === Creation ===
91
112
 
92
113
  # Create an organization WITH its owner membership in one transaction —
@@ -114,6 +135,8 @@ module Organizations
114
135
  def self.create_with_owner!(owner:, **attributes)
115
136
  raise ArgumentError, "owner is required" unless owner
116
137
 
138
+ ensure_personal_owner_is_owner!(attributes, owner)
139
+
117
140
  organization = nil
118
141
  ActiveRecord::Base.transaction do
119
142
  organization = create!(**attributes)
@@ -125,6 +148,51 @@ module Organizations
125
148
  organization
126
149
  end
127
150
 
151
+ # @api private (public only because HasOrganizations#create_organization! calls it)
152
+ # A new organization may be personal only for the user who becomes its
153
+ # owner in the same transaction (user.create_organization!,
154
+ # create_with_owner!). Raw Organization.create! writes stay privileged.
155
+ # @raise [ArgumentError]
156
+ def self.ensure_personal_owner_is_owner!(attributes, owner)
157
+ attributes = attributes.to_h.symbolize_keys
158
+ personal_owner_id =
159
+ attributes.key?(:personal_owner) ? attributes[:personal_owner]&.id : attributes[:personal_owner_id]
160
+ return if personal_owner_id.nil? || personal_owner_id.to_s == owner.id.to_s
161
+
162
+ raise ArgumentError, "personal_owner must be the owner of the new organization"
163
+ end
164
+
165
+ # Link reviewed users to the organizations that are their personal
166
+ # organizations, for installs that created personal workspaces before
167
+ # 0.7.0. Nothing is guessed: pass the mapping you reviewed. A pair
168
+ # conflicts when the organization is missing, the user is not its owner,
169
+ # the organization is linked to another user, the user already has another
170
+ # personal organization, or one organization is mapped twice. When any
171
+ # pair conflicts, nothing is written.
172
+ #
173
+ # @param mapping [Hash{User, id => Organization, id}]
174
+ # @param dry_run [Boolean] report only, write nothing
175
+ # @return [Organizations::PersonalOwnerBackfill::Report]
176
+ def self.backfill_personal_owners!(mapping, dry_run: false)
177
+ PersonalOwnerBackfill.new(mapping).run(dry_run: dry_run)
178
+ end
179
+
180
+ # === Personal organizations ===
181
+
182
+ # Whether this is a user's personal organization
183
+ # @return [Boolean]
184
+ def personal?
185
+ has_attribute?(:personal_owner_id) && personal_owner_id.present?
186
+ end
187
+
188
+ # Stop treating this organization as the owner's personal organization.
189
+ # Do this before you transfer its ownership. Members, roles and data stay.
190
+ # @return [self]
191
+ def convert_to_shared!
192
+ update!(personal_owner_id: nil)
193
+ self
194
+ end
195
+
128
196
  # === Member Query Methods ===
129
197
 
130
198
  # Get the owner of this organization
@@ -196,7 +264,7 @@ module Organizations
196
264
 
197
265
  # Owner role is only assignable via transfer_ownership_to! or initial creation
198
266
  if role_sym == :owner
199
- raise CannotHaveMultipleOwners, Organizations.t(:"errors.cannot_add_as_owner")
267
+ raise CannotHaveMultipleOwners, Organizations.t(:"errors.cannot_add_as_owner", organization: self)
200
268
  end
201
269
 
202
270
  # Check if already a member (idempotent operation)
@@ -260,17 +328,33 @@ module Organizations
260
328
  # @param user [User] The user to remove
261
329
  # @param removed_by [User, nil] Who is removing them (for callbacks)
262
330
  # @raise [CannotRemoveOwner] if trying to remove the owner
331
+ # @raise [Organizations::NotAuthorized] if removed_by is a member whose role
332
+ # ranks below the removed member's role
263
333
  def remove_member!(user, removed_by: nil)
264
334
  membership = memberships.find_by(user_id: user.id)
265
335
  return unless membership
266
336
 
267
337
  if membership.role.to_sym == :owner
268
- raise CannotRemoveOwner, Organizations.t(:"errors.cannot_remove_owner")
338
+ raise CannotRemoveOwner, Organizations.t(:"errors.cannot_remove_owner", organization: self)
269
339
  end
270
340
 
271
341
  ActiveRecord::Base.transaction do
272
342
  # Lock organization to prevent race conditions
273
343
  lock!
344
+
345
+ # Re-read the role under a row lock, without reloading the record (a
346
+ # reload would drop the live counter cache and fail on a row that is
347
+ # already gone). A member promoted, or made owner, since the lookup
348
+ # above is judged by the role they hold now.
349
+ locked_role = Membership.where(id: membership.id).lock.pick(:role)
350
+ if locked_role == "owner"
351
+ raise CannotRemoveOwner, Organizations.t(:"errors.cannot_remove_owner", organization: self)
352
+ end
353
+
354
+ # Only roles removed_by may act on (see Roles.assignment_refusal)
355
+ Membership.authorize_rank!(self, removed_by, [locked_role || membership.role],
356
+ permission: :remove_members,
357
+ message_key: :"errors.member_removal_outranks_actor")
274
358
  membership.destroy!
275
359
  end
276
360
 
@@ -290,6 +374,8 @@ module Organizations
290
374
  # @return [Membership] The updated membership
291
375
  # @raise [CannotHaveMultipleOwners] if promoting to owner when one exists
292
376
  # @raise [CannotRemoveLastOwner] if demoting the only owner
377
+ # @raise [Organizations::NotAuthorized] if changed_by is a member whose role
378
+ # ranks below the member's current role or the new role
293
379
  def change_role_of!(user, to:, changed_by: nil)
294
380
  new_role = to.to_sym
295
381
  validate_role!(new_role)
@@ -310,14 +396,17 @@ module Organizations
310
396
  if new_role == :owner && old_role != :owner
311
397
  # Promoting to owner - this is only allowed via transfer_ownership_to!
312
398
  # Direct role change to owner is not permitted
313
- raise CannotHaveMultipleOwners, Organizations.t(:"errors.cannot_promote_to_owner")
399
+ raise CannotHaveMultipleOwners, Organizations.t(:"errors.cannot_promote_to_owner", organization: self)
314
400
  end
315
401
 
316
402
  if old_role == :owner && new_role != :owner
317
403
  # Demoting owner - not allowed directly
318
- raise CannotDemoteOwner, Organizations.t(:"errors.cannot_demote_owner_directly")
404
+ raise CannotDemoteOwner, Organizations.t(:"errors.cannot_demote_owner_directly", organization: self)
319
405
  end
320
406
 
407
+ # Only roles at or below changed_by's own may be given or taken away
408
+ membership.authorize_role_change!(new_role, changed_by: changed_by)
409
+
321
410
  membership.update!(role: new_role.to_s)
322
411
  end
323
412
 
@@ -341,8 +430,10 @@ module Organizations
341
430
  # @raise [CannotTransferToNonAdmin] if user is not an admin
342
431
  def transfer_ownership_to!(new_owner)
343
432
  ActiveRecord::Base.transaction do
344
- # Lock organization first to prevent concurrent operations
345
- lock!
433
+ lock_new_owner_row!(new_owner)
434
+ lock! # then the organization row, to prevent concurrent operations
435
+
436
+ ensure_not_personal_for_transfer!
346
437
 
347
438
  # Always perform a fresh read in this write path, even if memberships
348
439
  # are preloaded on this instance, to avoid stale-owner selection.
@@ -350,24 +441,23 @@ module Organizations
350
441
  new_owner_membership = memberships.find_by(user_id: new_owner.id)
351
442
 
352
443
  unless old_owner_membership
353
- raise NoOwnerPresent, Organizations.t(:"errors.transfer_no_owner")
444
+ raise NoOwnerPresent, Organizations.t(:"errors.transfer_no_owner", organization: self)
354
445
  end
355
446
 
356
447
  unless new_owner_membership
357
- raise CannotTransferToNonMember, Organizations.t(:"errors.transfer_to_non_member")
448
+ raise CannotTransferToNonMember, Organizations.t(:"errors.transfer_to_non_member", organization: self)
358
449
  end
359
450
 
360
451
  # New owner must be at least an admin (per README: "Ownership can be transferred to any admin")
361
452
  unless Roles.at_least?(new_owner_membership.role.to_sym, :admin)
362
- raise CannotTransferToNonAdmin, Organizations.t(:"errors.transfer_to_non_admin")
453
+ raise CannotTransferToNonAdmin, Organizations.t(:"errors.transfer_to_non_admin", organization: self)
363
454
  end
364
455
 
365
456
  # No-op transfer to the current owner.
366
457
  return old_owner_membership if old_owner_membership.user_id == new_owner.id
367
458
 
368
459
  # Lock both memberships
369
- old_owner_membership.lock!
370
- new_owner_membership.lock!
460
+ [old_owner_membership, new_owner_membership].each(&:lock!)
371
461
 
372
462
  old_owner_user = old_owner_membership.user
373
463
 
@@ -393,8 +483,14 @@ module Organizations
393
483
  # @param invited_by [User, nil] Who is sending the invitation (uses Current.user if not provided)
394
484
  # @param role [Symbol] Role for the invitation (default: from config)
395
485
  # @return [Invitation] The created or existing invitation
486
+ # @param grants [Hash{resource => Array}, nil] resources the new member
487
+ # gets on acceptance, with their capabilities (scoped resource grants).
488
+ # The inviter needs config.grant_management_permission and, when grants
489
+ # limit it, may give only access it has. On an existing pending
490
+ # invitation they are added to it.
396
491
  # @raise [CannotInviteAsOwner] if role is :owner
397
- def send_invite_to!(email, invited_by: nil, role: nil)
492
+ # @raise [Organizations::NotAuthorized] if the role ranks above the inviter's own
493
+ def send_invite_to!(email, invited_by: nil, role: nil, grants: nil)
398
494
  inviter = invited_by || current_user_from_context
399
495
  unless inviter
400
496
  raise ArgumentError, "invited_by is required (or set Current.user)"
@@ -407,18 +503,22 @@ module Organizations
407
503
 
408
504
  # Owner role cannot be assigned via invitation - only via transfer_ownership_to!
409
505
  if role_sym == :owner
410
- raise CannotInviteAsOwner, Organizations.t(:"errors.invite_as_owner")
506
+ raise CannotInviteAsOwner, Organizations.t(:"errors.invite_as_owner", organization: self)
411
507
  end
412
508
 
509
+ # Nobody can invite to a role that ranks above their own
510
+ authorize_invited_role!(inviter, role_sym)
511
+
413
512
  normalized_email = email.downcase.strip
513
+ grants = grants.to_a
414
514
 
415
515
  # Check for existing pending invitation (idempotent)
416
516
  existing = invitations.pending.for_email(normalized_email).first
417
- return existing if existing
517
+ return add_invitation_grants(existing, grants, inviter) if existing
418
518
 
419
519
  # Check if already a member (case-insensitive)
420
520
  if users.where("LOWER(email) = ?", normalized_email).exists?
421
- raise Organizations::InvitationError, Organizations.t(:"errors.invitation_already_member")
521
+ raise Organizations::InvitationError, Organizations.t(:"errors.invitation_already_member", organization: self)
422
522
  end
423
523
 
424
524
  # Allow callback hooks to veto invitations (e.g., plan seat limits) before write.
@@ -429,6 +529,9 @@ module Organizations
429
529
  expires_at: calculate_expiry
430
530
  )
431
531
 
532
+ # Checked before anything is written or sent
533
+ invitation_context.authorize_grants!(grants, inviter)
534
+
432
535
  Callbacks.dispatch(
433
536
  :member_invited,
434
537
  strict: true,
@@ -445,24 +548,18 @@ module Organizations
445
548
  ActiveRecord::Base.transaction(requires_new: true) do
446
549
  # Check for expired invitation and refresh it instead of creating duplicate
447
550
  expired_invitation = invitations.expired.for_email(normalized_email).first
448
- if expired_invitation
449
- expired_invitation.lock!
450
- expired_invitation.update!(
451
- invited_by: inviter,
452
- role: role.to_s,
453
- token: generate_unique_token,
454
- expires_at: calculate_expiry
455
- )
456
- invitation = expired_invitation
457
- else
458
- invitation = invitations.create!(
459
- email: normalized_email,
460
- invited_by: inviter,
461
- role: role.to_s,
462
- token: generate_unique_token,
463
- expires_at: calculate_expiry
464
- )
465
- end
551
+ invitation = if expired_invitation
552
+ reissue_invitation!(expired_invitation, inviter, role)
553
+ else
554
+ invitations.create!(
555
+ email: normalized_email,
556
+ invited_by: inviter,
557
+ role: role.to_s,
558
+ token: generate_unique_token,
559
+ expires_at: calculate_expiry
560
+ )
561
+ end
562
+ apply_invitation_grants(invitation, grants, inviter)
466
563
  end
467
564
 
468
565
  # Send invitation email
@@ -470,8 +567,9 @@ module Organizations
470
567
 
471
568
  invitation
472
569
  rescue ActiveRecord::RecordNotUnique
473
- # Race condition: invitation was created by another process
474
- invitations.pending.for_email(normalized_email).first!
570
+ # Race condition: invitation was created by another process. Its
571
+ # grants are added the same way as for any existing invitation.
572
+ add_invitation_grants(invitations.pending.for_email(normalized_email).first!, grants.to_a, inviter)
475
573
  end
476
574
 
477
575
  # === Verified Joining Methods (v0.5.0) ===
@@ -479,9 +577,12 @@ module Organizations
479
577
  # Enroll an email domain for domain-verified joining.
480
578
  # @param domain [String] e.g. "inizio.com" (exact match — subdomains must be enrolled separately)
481
579
  # @param membership_metadata [Hash] copied onto memberships created through this domain
580
+ # @param join_policy [Symbol, String, nil] :none, :suggest or :auto_join (acts once the domain is verified)
482
581
  # @return [Domain]
483
- def add_domain!(domain, membership_metadata: {})
484
- domains.create!(domain: domain, membership_metadata: membership_metadata)
582
+ def add_domain!(domain, membership_metadata: {}, join_policy: nil)
583
+ attributes = { domain: domain, membership_metadata: membership_metadata }
584
+ attributes[:join_policy] = join_policy.to_s unless join_policy.nil?
585
+ domains.create!(attributes)
485
586
  end
486
587
 
487
588
  # Generate a shareable join code (PIN).
@@ -495,7 +596,7 @@ module Organizations
495
596
  # @return [JoinCode]
496
597
  # Every parameter is an optional keyword with a safe default — this IS
497
598
  # the public API surface, not incidental complexity.
498
- # rubocop:disable Metrics/ParameterLists
599
+ # rubocop:disable-next Metrics/ParameterLists
499
600
  def generate_join_code!(label: nil, requires_verified_domain_email: false, auto_approve: true,
500
601
  expires_at: nil, max_uses: nil, created_by: nil, membership_metadata: {})
501
602
  join_codes.create!(
@@ -508,7 +609,6 @@ module Organizations
508
609
  membership_metadata: membership_metadata
509
610
  )
510
611
  end
511
- # rubocop:enable Metrics/ParameterLists
512
612
 
513
613
  # Bulk-import roster emails as allowlist entries (idempotent per address:
514
614
  # already-enrolled addresses are skipped, not duplicated).
@@ -549,7 +649,7 @@ module Organizations
549
649
  # challenge). Drives whether a join screen shows the email form.
550
650
  # @return [Boolean]
551
651
  def accepts_domain_joining?
552
- domains.exists? || allowlist_entries.unclaimed.exists?
652
+ domains.joinable.exists? || allowlist_entries.unclaimed.exists?
553
653
  end
554
654
 
555
655
  # Whether this organization has at least one code that can actually be
@@ -599,19 +699,19 @@ module Organizations
599
699
  # the email is unconfirmed, or the feature is disabled
600
700
  def join_with_account_email!(user)
601
701
  unless Organizations.configuration.trust_confirmed_account_email
602
- raise VerificationEmailNotEligible, Organizations.t(:"errors.account_email_trust_disabled")
702
+ raise VerificationEmailNotEligible, Organizations.t(:"errors.account_email_trust_disabled", organization: self)
603
703
  end
604
704
 
605
705
  email = user.respond_to?(:email) ? user.email.to_s : ""
606
706
  confirmed = user.respond_to?(:confirmed_at) && user.confirmed_at.present?
607
707
 
608
708
  unless confirmed
609
- raise VerificationEmailNotEligible, Organizations.t(:"errors.account_email_unconfirmed")
709
+ raise VerificationEmailNotEligible, Organizations.t(:"errors.account_email_unconfirmed", organization: self)
610
710
  end
611
711
 
612
- matched_domain = domains.matching_email(email).first
712
+ matched_domain = domains.joinable.matching_email(email).first
613
713
  unless matched_domain
614
- raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_not_eligible")
714
+ raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_not_eligible", organization: self)
615
715
  end
616
716
 
617
717
  # Uniform funnel: every self-serve join goes through a JoinRequest so
@@ -622,7 +722,7 @@ module Organizations
622
722
  ActiveRecord::Base.transaction do
623
723
  if Membership.where(organization_id: id, verified_email_normalized: normalized).exists?
624
724
  raise VerificationEmailAlreadyClaimed,
625
- Organizations.t(:"errors.verification_email_already_claimed")
725
+ Organizations.t(:"errors.verification_email_already_claimed", organization: self)
626
726
  end
627
727
 
628
728
  request.assign_attributes(
@@ -635,11 +735,72 @@ module Organizations
635
735
  request.save!
636
736
  end
637
737
 
638
- request.approve!(decided_by: nil)
738
+ request.send(:auto_approve!)
639
739
  end
640
740
 
641
741
  private
642
742
 
743
+ # Lock the new owner's user row before the organization row: the
744
+ # account-deletion guard locks a user row before organization rows, so
745
+ # both take their locks in the same order and cannot deadlock. A
746
+ # concurrent deletion of that user either finishes first (the membership
747
+ # is gone and the transfer is refused) or waits until the transfer
748
+ # commits (and sees the new ownership).
749
+ def lock_new_owner_row!(new_owner)
750
+ user_class = new_owner.class
751
+ return if user_class.unscoped.where(user_class.primary_key => new_owner.id).lock.pick(user_class.primary_key)
752
+
753
+ raise CannotTransferToNonMember, Organizations.t(:"errors.transfer_to_non_member", organization: self)
754
+ end
755
+
756
+ # A personal organization belongs to its owner. Call convert_to_shared!
757
+ # first to move it to someone else.
758
+ def ensure_not_personal_for_transfer!
759
+ return unless personal?
760
+
761
+ raise CannotTransferPersonalOrganization, Organizations.t(:"errors.transfer_personal_organization", organization: self)
762
+ end
763
+
764
+ def personal_owner_changed_to_someone?
765
+ has_attribute?(:personal_owner_id) && will_save_change_to_personal_owner_id? && personal_owner_id.present?
766
+ end
767
+
768
+ # The personal owner is the owner of the organization. A second,
769
+ # independent ownership record would be an authorization hole.
770
+ def personal_owner_holds_ownership
771
+ # Lock this organization's row first: transfer_ownership_to! takes the
772
+ # same lock, so the owner read here cannot go stale before the write.
773
+ self.class.unscoped.where(id: id).lock.pick(:id)
774
+ owner_id = memberships.where(role: "owner").pick(:user_id)
775
+ return if owner_id.present? && owner_id == personal_owner_id
776
+
777
+ errors.add(:personal_owner, Organizations.t(:"attributes.personal_owner_not_owner", organization: self))
778
+ end
779
+
780
+ # Refresh an expired invitation instead of creating a duplicate. It
781
+ # carries only the grants of the new call: the old ones were authorized
782
+ # for another inviter and role.
783
+ def reissue_invitation!(invitation, inviter, role)
784
+ invitation.lock!
785
+ invitation.grants.delete_all(:delete_all) if Organizations::GrantHolder.grants_table?
786
+ invitation.update!(invited_by: inviter, role: role.to_s, token: generate_unique_token,
787
+ expires_at: calculate_expiry)
788
+ invitation
789
+ end
790
+
791
+ def add_invitation_grants(invitation, grants, inviter)
792
+ return invitation if grants.empty?
793
+
794
+ ActiveRecord::Base.transaction(requires_new: true) { apply_invitation_grants(invitation, grants, inviter) }
795
+ invitation
796
+ end
797
+
798
+ def apply_invitation_grants(invitation, grants, inviter)
799
+ grants.each do |resource, capabilities|
800
+ invitation.grant!(resource, capabilities: capabilities, granted_by: inviter)
801
+ end
802
+ end
803
+
643
804
  def ensure_join_request_belongs_here!(join_request)
644
805
  return if join_request.organization_id == id
645
806
 
@@ -662,7 +823,7 @@ module Organizations
662
823
 
663
824
  unless inviter_membership
664
825
  raise Organizations::NotAMember.new(
665
- Organizations.t(:"errors.invite_not_a_member"),
826
+ Organizations.t(:"errors.invite_not_a_member", organization: self),
666
827
  organization: self,
667
828
  user: inviter
668
829
  )
@@ -671,13 +832,23 @@ module Organizations
671
832
  return if Roles.has_permission?(inviter_membership.role.to_sym, :invite_members)
672
833
 
673
834
  raise Organizations::NotAuthorized.new(
674
- Organizations.t(:"errors.invite_not_authorized"),
835
+ Organizations.t(:"errors.invite_not_authorized", organization: self),
675
836
  permission: :invite_members,
676
837
  organization: self,
677
838
  user: inviter
678
839
  )
679
840
  end
680
841
 
842
+ # An inviter may only invite to roles that rank at or below their own (see
843
+ # Roles.can_assign?), so a custom role that can invite members still
844
+ # cannot invite an admin. A role that is not configured at all passes
845
+ # here and fails the invitation's own role validation instead.
846
+ def authorize_invited_role!(inviter, role)
847
+ Membership.authorize_rank!(self, inviter, [role],
848
+ permission: :invite_members,
849
+ message_key: :"errors.invite_role_outranks_actor")
850
+ end
851
+
681
852
  def validate_role!(role)
682
853
  unless Roles.valid_role?(role)
683
854
  raise ArgumentError, "Invalid role: #{role}. Must be one of: #{Roles.valid_roles.join(', ')}"