organizations 0.5.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 (67) 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/Appraisals +2 -0
  6. data/CHANGELOG.md +112 -0
  7. data/README.md +714 -32
  8. data/app/controllers/organizations/invitations_controller.rb +27 -22
  9. data/app/controllers/organizations/memberships_controller.rb +37 -30
  10. data/app/controllers/organizations/organizations_controller.rb +8 -8
  11. data/app/controllers/organizations/public_invitations_controller.rb +1 -1
  12. data/app/mailers/organizations/invitation_mailer.rb +2 -2
  13. data/app/mailers/organizations/verification_mailer.rb +1 -1
  14. data/app/models/organizations/grant.rb +6 -0
  15. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +11 -11
  16. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +7 -7
  17. data/app/views/organizations/verification_mailer/code_email.html.erb +3 -3
  18. data/app/views/organizations/verification_mailer/code_email.text.erb +3 -3
  19. data/config/locales/en.yml +22 -0
  20. data/config/locales/es.yml +22 -0
  21. data/config/routes.rb +1 -2
  22. data/gemfiles/rails_7.2.gemfile +6 -1
  23. data/gemfiles/rails_8.1.gemfile +5 -1
  24. data/lib/generators/organizations/grants/grants_generator.rb +47 -0
  25. data/lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb +59 -0
  26. data/lib/generators/organizations/install/install_generator.rb +1 -0
  27. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +15 -0
  28. data/lib/generators/organizations/join/join_generator.rb +113 -0
  29. data/lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb +146 -0
  30. data/lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb +93 -0
  31. data/lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb +142 -0
  32. data/lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb +8 -0
  33. data/lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb +113 -0
  34. data/lib/generators/organizations/join/templates/routes.rb +19 -0
  35. data/lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb +29 -0
  36. data/lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb +22 -0
  37. data/lib/generators/organizations/upgrade/upgrade_generator.rb +79 -14
  38. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +1 -1
  39. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +2 -2
  40. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +1 -1
  41. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +21 -13
  42. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +1 -1
  43. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +3 -3
  44. data/lib/generators/organizations/views/views_generator.rb +4 -6
  45. data/lib/organizations/account_deletion_plan.rb +118 -0
  46. data/lib/organizations/configuration.rb +155 -2
  47. data/lib/organizations/controller_helpers.rb +55 -15
  48. data/lib/organizations/grant_access.rb +75 -0
  49. data/lib/organizations/grant_holder.rb +152 -0
  50. data/lib/organizations/grantable.rb +28 -0
  51. data/lib/organizations/join_flow.rb +16 -12
  52. data/lib/organizations/models/concerns/has_organizations.rb +217 -9
  53. data/lib/organizations/models/domain.rb +283 -1
  54. data/lib/organizations/models/grant.rb +152 -0
  55. data/lib/organizations/models/invitation.rb +79 -8
  56. data/lib/organizations/models/join_request.rb +102 -43
  57. data/lib/organizations/models/membership.rb +96 -15
  58. data/lib/organizations/models/organization.rb +219 -48
  59. data/lib/organizations/organization_scoped.rb +16 -0
  60. data/lib/organizations/personal_owner_backfill.rb +122 -0
  61. data/lib/organizations/public_email_domains.rb +49 -0
  62. data/lib/organizations/resource.rb +11 -0
  63. data/lib/organizations/roles.rb +198 -36
  64. data/lib/organizations/version.rb +1 -1
  65. data/lib/organizations/view_helpers.rb +40 -9
  66. data/lib/organizations.rb +99 -4
  67. metadata +22 -2
@@ -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(', ')}"
@@ -74,6 +74,15 @@ module Organizations
74
74
  def require_organization_role(role, **options)
75
75
  before_action(**options) { require_scoped_organization_role!(role) }
76
76
  end
77
+
78
+ # Gate actions behind a permission of the viewer's role in the URL's
79
+ # organization (custom roles included). Same not-found posture as
80
+ # require_organization_role.
81
+ # require_organization_permission :invite_members
82
+ # require_organization_permission :manage_settings, only: %i[verify_domain]
83
+ def require_organization_permission(permission, **options)
84
+ before_action(**options) { require_scoped_organization_permission!(permission) }
85
+ end
77
86
  end
78
87
 
79
88
  # The organization resolved from the URL for this request.
@@ -120,6 +129,13 @@ module Organizations
120
129
  scoped_organization_not_found!
121
130
  end
122
131
 
132
+ def require_scoped_organization_permission!(permission)
133
+ membership = current_scoped_membership
134
+ return if membership&.has_permission_to?(permission)
135
+
136
+ scoped_organization_not_found!
137
+ end
138
+
123
139
  def scoped_organization_not_found!
124
140
  case self.class.organization_not_found_behavior
125
141
  when :forbidden
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # Links reviewed users to their personal organizations, for installs that
5
+ # created personal workspaces before personal organizations existed.
6
+ # Called through Organizations::Organization.backfill_personal_owners!.
7
+ #
8
+ # The mapping is the only source of truth. A sole member, a name such as
9
+ # "Personal", or the oldest organization never proves that an organization
10
+ # is someone's personal workspace, and a wrong link would later let the
11
+ # account-deletion policy delete a one-person business.
12
+ class PersonalOwnerBackfill
13
+ # assignments - [[user_id, organization_id], ...] written (or to write, in a dry run)
14
+ # unchanged - [[user_id, organization_id], ...] already linked
15
+ # conflicts - [{ user_id:, organization_id:, reason: }, ...]
16
+ Report = Struct.new(:assignments, :unchanged, :conflicts, :applied, keyword_init: true) do
17
+ def applied? = applied
18
+ end
19
+
20
+ CONFLICT_REASONS = %i[
21
+ organization_not_found
22
+ not_the_owner
23
+ organization_personal_for_another_user
24
+ user_has_another_personal_organization
25
+ organization_mapped_twice
26
+ user_mapped_twice
27
+ ].freeze
28
+
29
+ def initialize(mapping)
30
+ user_id_type = Organizations.user_class.type_for_attribute(Organizations.user_class.primary_key)
31
+ organization_id_type = Organization.type_for_attribute(Organization.primary_key)
32
+ # Pairs, not a Hash: a record and its id are two keys for one user.
33
+ @pairs = mapping.map do |user, organization|
34
+ [user_id_type.cast(id_of(user)), organization_id_type.cast(id_of(organization))]
35
+ end
36
+ end
37
+
38
+ # @param dry_run [Boolean]
39
+ # @return [Report]
40
+ def run(dry_run: false)
41
+ return analyze if dry_run
42
+
43
+ apply
44
+ rescue ActiveRecord::RecordNotUnique
45
+ # A concurrent provisioning linked one of these users meanwhile. The
46
+ # savepoint rolled back every write; report the state as it is now.
47
+ analyze
48
+ end
49
+
50
+ private
51
+
52
+ def apply
53
+ report = nil
54
+ # SAVEPOINT: a rollback must undo only these writes, also inside a host
55
+ # transaction, and leave it usable on PostgreSQL.
56
+ Organization.transaction(requires_new: true) do
57
+ # Lock the organizations so the checks hold until the write.
58
+ Organization.where(id: @pairs.map(&:last)).order(:id).lock.to_a
59
+ report = analyze
60
+ raise ActiveRecord::Rollback unless report.conflicts.empty?
61
+
62
+ report.assignments.each do |user_id, organization_id|
63
+ Organization.find(organization_id).update!(personal_owner_id: user_id)
64
+ end
65
+ report.applied = true
66
+ end
67
+ report
68
+ end
69
+
70
+ def analyze
71
+ load_lookups
72
+ report = Report.new(assignments: [], unchanged: [], conflicts: [], applied: false)
73
+ @pairs.each do |user_id, organization_id|
74
+ reason = conflict_for(user_id, organization_id)
75
+ if reason
76
+ report.conflicts << { user_id: user_id, organization_id: organization_id, reason: reason }
77
+ elsif @linked[user_id] == organization_id
78
+ report.unchanged << [user_id, organization_id]
79
+ else
80
+ report.assignments << [user_id, organization_id]
81
+ end
82
+ end
83
+ report
84
+ end
85
+
86
+ # One query per rule, for the whole mapping.
87
+ def load_lookups
88
+ user_ids = @pairs.map(&:first)
89
+ organization_ids = @pairs.map(&:last)
90
+ @organizations = Organization.where(id: organization_ids).index_by(&:id)
91
+ @owners = Membership.where(organization_id: @organizations.keys, role: "owner")
92
+ .pluck(:organization_id, :user_id).to_h
93
+ @linked = Organization.personal.where(personal_owner_id: user_ids).pluck(:personal_owner_id, :id).to_h
94
+ @mapped_twice = repeated(organization_ids)
95
+ @users_mapped_twice = repeated(user_ids)
96
+ end
97
+
98
+ def repeated(ids) = ids.tally.select { |_, count| count > 1 }.keys
99
+
100
+ def conflict_for(user_id, organization_id)
101
+ organization = @organizations[organization_id]
102
+ return :organization_not_found unless organization
103
+ return :user_mapped_twice if @users_mapped_twice.include?(user_id)
104
+ return :organization_mapped_twice if @mapped_twice.include?(organization_id)
105
+ return :not_the_owner unless @owners[organization_id] == user_id
106
+
107
+ link_conflict(user_id, organization)
108
+ end
109
+
110
+ def link_conflict(user_id, organization)
111
+ if organization.personal_owner_id.present? && organization.personal_owner_id != user_id
112
+ :organization_personal_for_another_user
113
+ elsif @linked[user_id] && @linked[user_id] != organization.id
114
+ :user_has_another_personal_organization
115
+ end
116
+ end
117
+
118
+ def id_of(record)
119
+ record.respond_to?(:id) ? record.id : record
120
+ end
121
+ end
122
+ end