organizations 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fba49577a4eb70a0bd69d5b554609a3a3b5a73046f1b0007219773b2e3cccd48
4
- data.tar.gz: 8eba41990e24a6558de87d4760fabf09b65561fd43e4314e27f58ec5d838dbc3
3
+ metadata.gz: e3c621c2b6bb7bd32be611858d49af9cb12cf9fa80bc0309d1227eafc819f8d8
4
+ data.tar.gz: c6028a9e424ab92432c44ae45bcba367d2a70eab56e3901314b1d5ff9b9b102f
5
5
  SHA512:
6
- metadata.gz: f49c6a4eba6854e3988c2a39d175f5616af10ef8723e3c6f91d461ca8f3bc4151cb9ddc64e0baf5ff5a83e474690eacc86362d149354c46e72c97b92bf2e9c7a
7
- data.tar.gz: 9b83cc21331c095d64ea22ba5f4874a11716e634f2dffd664a7b5b3ed4768c9ac048e2f03a4c93bddd92e3661d6003dceeac0edc21f671c5ff327dff4241490e
6
+ metadata.gz: 267e7126501538f4461616e2be708f1b3696a016b428135337193dc8eaf24532a446a9c651b1989610862025298cbb4b9ab38b7f1d901b0fcb3f49f68d28ec56
7
+ data.tar.gz: 98d2dbd1b5369c247c6b8147c7472f0b53e74aa25c0c000b8458834ddff6e81f0ec6f3e599611f2ba8ef10b2dd88ddb5775fe15816cef13d57fb48a393ddce6a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.8.1] - 2026-10-25
4
+
5
+ **The joins take the lock order of the destroys.** A patch release with one fix. No migration is needed.
6
+
7
+ ### Fixed
8
+
9
+ - **A join could deadlock with a deletion on PostgreSQL.** 0.8.0 made every destroy lock the user row, then the organization rows, then their child rows. Several joins locked their own row first, and the organization row (or a user row) only later, when they wrote. So a join and a deletion could each hold a row that the other waited for. PostgreSQL then aborted one of them with `ActiveRecord::Deadlocked`. These paths now take a guard on the user rows that their writes name (in id order), then lock the organization row, then their own row:
10
+ - `Invitation#accept!` (the accepting user and the inviter). Before, an acceptance deadlocked with the inviter's account deletion or with a destroy of the organization.
11
+ - `Invitation#revoke!` (no user row). Before, the revoke of an invitation with grants deadlocked with a destroy of its organization or with the inviter's account deletion.
12
+ - `JoinRequest#approve!` and the automatic approval after a verified email (the requester and the decider). Before, an approval deadlocked with the requester's account deletion.
13
+ - `JoinRequest#verify_email_code!` (the requester). Before, the check of an emailed code deadlocked with a destroy of the organization, which deletes the domain before the request. The requester's guard comes first, so the automatic approval inside a host transaction asks for no user row while it holds the organization row.
14
+ - `JoinCode#redeem!` (the redeeming user). Before, a redemption deadlocked with the account deletion of the code's creator or with a destroy of the organization.
15
+ - **The user guard is a key share lock** (`FOR KEY SHARE` on PostgreSQL, a shared lock on MySQL, nothing on SQLite). It conflicts with the deletion's `FOR UPDATE`, and with nothing else: an update of the user and the foreign key checks of other writers pass. So `reject!` (which names its decider), `withdraw!`, `resend!` and a grant on a pending invitation (which names its granter) keep their single row lock and do not wait for the guards.
16
+ - **An acceptance from a stale copy locks the current inviter.** A reissue of an expired invitation (`send_invite_to!`) names the new inviter. A loaded copy can still name the old one. The acceptance reads the row again under its lock. When the row names other ids than the ones locked, the acceptance releases its locks (a savepoint) and tries again with the current ids, up to three times. Then it raises `Invitation::InvitationChanged`, an `InvitationError`. `accept_pending_organization_invitation!` returns it as the failure `:invitation_changed` and keeps the session token. The engine's accept action answers with the localized message: HTML goes back to the invitation page, JSON gets a 409. Before, the acceptance could insert a membership that named an unlocked inviter, and deadlock with an ownership transfer to that inviter.
17
+ - Inside a host transaction, the order holds only when the locks that the host took earlier in that transaction keep it too.
18
+ - README: "Row-level locking" states the one lock order. The steps of the ownership transfer now start with the new owner's row, as the code has done since 0.7.0.
19
+
20
+ ## [0.8.0] - 2026-10-24
21
+
22
+ **Hand a personal organization over, and keep organizations that hold records with a retention period.** An invoicing app on the fleet built both by hand and runs them in production. This release moves them into the gem, with their tests. Both are opt-in, and no migration is needed.
23
+
24
+ ### Added
25
+
26
+ - **Hand a personal organization over.** The need: the owner confirms that a member takes over their personal workspace, which then becomes shared, and a refused handover must leave nothing half done. `org.transfer_ownership_to!(admin, convert_to_shared: true)` makes a personal organization shared and transfers it in one step, in its own savepoint. A refused transfer leaves it personal, also when the caller rescues the error inside its own transaction, and the record in memory is read again. The transfer locks the new owner's row before the organization row, as the deletion guard does. A transfer to the personal owner itself is still refused. `config.convert_personal_organizations_on_transfer = true` makes the conversion the default, also for the engine's transfer action. The default (`false`) keeps the refusal of 0.7.0.
27
+ - **Keep organizations that hold records with a retention period.** The need: an organization that holds records the law makes you keep, such as issued invoices or the books, must never be destroyed when its owner deletes their account. `config.retain_organization = ->(organization) { ... }` (default `nil`) names those organizations. `user.account_deletion_plan.organizations_to_retain` lists them apart from `organizations_to_destroy`. They do not block, so `allowed?` is unchanged. `user.destroy` returns false while the user owns one, with a message that names it, and the app closes the account instead. Any other destroy of a retained organization raises `Organizations::Organization::CannotDestroyRetainedOrganization` before any row goes. The destroy reads the organization again under its row lock, and the rule gets that copy, so a stale record cannot destroy it. Whatever creates the retained records must lock the organization row, or insert them with a foreign key that restricts deletion. The engine's controllers answer the error with a redirect back and the message (HTML), or a 422 (JSON).
28
+
29
+ ### Changed
30
+
31
+ - `AccountDeletionPlan#to_h` has an `organizations_to_retain` key. It is an empty list when nothing is retained.
32
+ - `user.destroy` takes its locks before its first write. It locks the user row, then the row of every organization in which it writes, in id order: its memberships, the invitations it sent and their grants, the memberships it invited, its join requests, the requests it decided, its join codes, the allowlist entries it claimed, the domains it verified and the grants it gave. Before, it locked only the owned organizations, after it deleted the grants of its sent invitations. **The callback order changes:** a new first `before_destroy` callback, `lock_rows_for_deletion`, takes the locks. It runs before `drop_grants_of_sent_invitations` and the guard, which keep their order and their names.
33
+ - Every organization destroy locks its row before its `dependent: :destroy` cascades, with or without `retain_organization`. With the account deletion above, both paths take the organization row first. Before, a member's account deletion and a destroy of their organization could deadlock on PostgreSQL.
34
+ - The account-deletion guard reads its plan with the query cache off. Before, a plan built earlier in the same request (a confirmation screen's preview) could answer from the cache, and miss a member that another connection had added.
35
+
36
+ ### Upgrading
37
+
38
+ No migration is needed, and nothing changes until you set one of the two options. An app that prepends its own `transfer_ownership_to!` to convert personal organizations can remove it and set `config.convert_personal_organizations_on_transfer = true`. An app that refuses the destroy of such organizations in its own `before_destroy` can remove it and set `config.retain_organization`.
39
+
3
40
  ## [0.7.1] - 2026-10-19
4
41
 
5
42
  **A user whose invitation was accepted can now be deleted.** A patch release with one fix and one README correction. No migration is needed.
data/README.md CHANGED
@@ -575,6 +575,7 @@ user.create_personal_organization! # Create it, or return the existing
575
575
 
576
576
  # Account deletion
577
577
  user.account_deletion_plan # What user.destroy would do (changes nothing)
578
+ Organizations::AccountDeletionPlan.for(user, lock: true) # The same plan under the guard's locks
578
579
  ```
579
580
 
580
581
  ### Organization methods
@@ -604,7 +605,7 @@ Organizations::Organization.backfill_personal_owners!(mapping, dry_run: true)
604
605
  # Personal organizations
605
606
  org.personal? # "Is this someone's personal organization?"
606
607
  org.personal_owner # The user it belongs to
607
- org.convert_to_shared! # Stop being personal (needed before a transfer)
608
+ org.convert_to_shared! # Stop being personal
608
609
 
609
610
  # Actions
610
611
  # Pass the acting user (removed_by:, changed_by:) so the role rule is checked;
@@ -613,6 +614,7 @@ org.add_member!(user, role: :member) # no actor: never checked, autho
613
614
  org.remove_member!(user, removed_by: current_user)
614
615
  org.change_role_of!(user, to: :admin, changed_by: current_user)
615
616
  org.transfer_ownership_to!(other_user)
617
+ org.transfer_ownership_to!(other_user, convert_to_shared: true) # Hand over a personal org: it becomes shared
616
618
 
617
619
  # Invitations (inviter must be a member with :invite_members permission)
618
620
  org.send_invite_to!(email) # Auto-infers invited_by from Current.user
@@ -720,6 +722,10 @@ invitation.grant!(record, capabilities: %i[read])
720
722
  # Actions
721
723
  invitation.accept! # Accept (auto-infers Current.user)
722
724
  invitation.accept!(user) # Accept with explicit user
725
+ # accept! raises Organizations::InvitationExpired, Organizations::InvitationAlreadyAccepted,
726
+ # Invitation::EmailMismatch, Invitation::CannotAcceptAsOwner, or Invitation::InvitationChanged:
727
+ # a reissue named another inviter at each of three tries to lock the rows. Nothing is
728
+ # written then, and the person can try again (organizations.errors.invitation_changed).
723
729
  invitation.resend!(resent_by: current_user) # Send invitation email again
724
730
  invitation.revoke!(revoked_by: current_user) # Delete it, so the link stops working
725
731
  ```
@@ -1323,6 +1329,8 @@ else
1323
1329
  end
1324
1330
  ```
1325
1331
 
1332
+ The reasons: `:missing_user`, `:missing_token`, `:invitation_not_found`, `:invitation_expired`, `:email_mismatch`, `:already_accepted_without_membership` and `:invitation_changed`. `:invitation_changed` means that a reissue changed the invitation during the acceptance (`Invitation::InvitationChanged`). Nothing was written, and the session keeps the token, so the person can try again. The engine's accept action answers it with the localized message (`organizations.errors.invitation_changed`): HTML goes back to the invitation page, JSON gets a 409 with `{ "error": message }`.
1333
+
1326
1334
  Configure redirects in your initializer:
1327
1335
 
1328
1336
  ```ruby
@@ -1523,7 +1531,7 @@ Organizations::Organization.not_personal # every other organization
1523
1531
  The rules:
1524
1532
 
1525
1533
  - The link is `organizations.personal_owner_id`, with a unique index. A user has at most one personal organization.
1526
- - The personal owner is also the owner membership. The gem writes both together, and `create_with_owner!` and `create_organization!` refuse a `personal_owner` who is not the new owner. `org.transfer_ownership_to!` raises `CannotTransferPersonalOrganization` for a personal organization: call `org.convert_to_shared!` first.
1534
+ - The personal owner is also the owner membership. The gem writes both together, and `create_with_owner!` and `create_organization!` refuse a `personal_owner` who is not the new owner. `org.transfer_ownership_to!` raises `CannotTransferPersonalOrganization` for a personal organization, so that nobody's own workspace moves by accident. To hand it over, see below.
1527
1535
  - A personal organization can have other members. To keep it owner-only, veto joins in the membership gate, which covers every join path:
1528
1536
 
1529
1537
  ```ruby
@@ -1546,6 +1554,25 @@ The rules:
1546
1554
 
1547
1555
  Conflict reasons: `:organization_not_found`, `:not_the_owner`, `:organization_personal_for_another_user`, `:user_has_another_personal_organization`, `:organization_mapped_twice`, `:user_mapped_twice`. The write runs in its own savepoint: a conflict found at write time (a concurrent provisioning) rolls back every assignment and comes back in the report.
1548
1556
 
1557
+ **Hand a personal organization over.** When your transfer screen tells the owner that the workspace becomes shared, and the owner confirms, convert and transfer in one step:
1558
+
1559
+ ```ruby
1560
+ org.transfer_ownership_to!(admin, convert_to_shared: true)
1561
+ org.personal? # => false
1562
+ org.owner # => admin
1563
+ ```
1564
+
1565
+ To make this the default, also for the engine's transfer action:
1566
+
1567
+ ```ruby
1568
+ config.convert_personal_organizations_on_transfer = true # default: false
1569
+ ```
1570
+
1571
+ - The conversion and the transfer run in one savepoint. A refused transfer (the new owner is not an admin, say) leaves the organization personal, also when you rescue the error inside a transaction of yours. The record in memory is read again, so it does not say shared either.
1572
+ - A transfer to the personal owner itself still raises `CannotTransferPersonalOrganization`: there is nothing to hand over.
1573
+ - The transfer locks the new owner's row before the organization row, the order of the deletion guard, so it cannot deadlock with a deletion of the new owner.
1574
+ - `convert_to_shared: false` refuses again for one call.
1575
+
1549
1576
  A personal organization tells you who probably pays. Billing and entitlements stay with your app.
1550
1577
 
1551
1578
  ## Deleting user accounts
@@ -1559,6 +1586,7 @@ plan = user.account_deletion_plan
1559
1586
  plan.allowed? # => false
1560
1587
  plan.blockers # => [#<struct organization=#<Organization "Acme">, reason=:owns_organization_with_members>]
1561
1588
  plan.organizations_to_destroy # organizations deleted with the account
1589
+ plan.organizations_to_retain # organizations kept, see "Organizations that must be kept"
1562
1590
  plan.memberships_to_remove # the user's other memberships, removed with the account
1563
1591
  plan.to_h # ids and symbols, for an audit log or a confirmation screen
1564
1592
  user.account_deletion_plan(policy: :destroy_personal_organization) # preview another policy
@@ -1582,9 +1610,48 @@ What the gem guarantees:
1582
1610
 
1583
1611
  - It checks the complete owned set before it deletes anything. One blocker blocks the whole deletion.
1584
1612
  - The organization deletion and the user deletion are one transaction, with its own savepoint, also inside a transaction of yours. A host `before_destroy` veto on the organization, or any later failure, rolls back everything, and the veto message is on `user.errors`.
1585
- - The guard locks the user row and then the owned organization rows. A concurrent join or organization creation for that user, a join or transfer in an owned organization, and an ownership transfer to that user (`transfer_ownership_to!` locks the new owner's row) wait for the deletion, or finish first and are seen by it.
1613
+ - Before its first write, `user.destroy` locks the user row, then the row of every organization in which it writes (memberships, sent invitations and their grants, join requests and the other records it nullifies), in id order. An organization's destroy also locks its row before its cascades, so the two cannot deadlock. The joins take the same order (see "Row-level locking"). A concurrent join or organization creation for that user, a join, transfer or destroy in one of those organizations, and an ownership transfer to that user (`transfer_ownership_to!` locks the new owner's row) wait for the deletion, or finish first and are seen by it. The guard reads the database under its locks, not the query cache.
1586
1614
  - The user's other memberships are deleted (`dependent: :destroy`), as before. Invitations they sent, join requests they decided, join codes they created and allowlist entries they claimed keep the record and lose the user reference.
1587
1615
 
1616
+ ### Organizations that must be kept
1617
+
1618
+ Some organizations hold records that the law makes you keep for years, such as issued invoices or the books. Deleting the user must never destroy such an organization. Tell the gem which ones they are:
1619
+
1620
+ ```ruby
1621
+ config.account_deletion_policy = :destroy_personal_organization
1622
+ config.retain_organization = ->(organization) { organization.invoices.exists? } # default: nil
1623
+ ```
1624
+
1625
+ The plan lists them apart. They do not block, so `allowed?` still answers the ownership question:
1626
+
1627
+ ```ruby
1628
+ plan = user.account_deletion_plan
1629
+ plan.allowed? # => true
1630
+ plan.organizations_to_retain # => [#<Organization "Ana's books">]
1631
+ plan.organizations_to_destroy # => []
1632
+ ```
1633
+
1634
+ - `user.destroy` returns false while the user owns a retained organization, because the organization would lose its owner. `user.errors` names each one: "Ana's books holds records that must be kept, so it cannot be deleted." Reword it with the `organizations.errors.organization_retained` key.
1635
+ - Close the account in your app instead. Lock the user row, then build the plan under locks (the guard's order). Keep the retained organizations, delete the rest, and anonymize the user row:
1636
+
1637
+ ```ruby
1638
+ User.transaction do
1639
+ user.lock!
1640
+ plan = Organizations::AccountDeletionPlan.for(user, lock: true)
1641
+ raise "Transfer or delete the blocking organizations first" unless plan.allowed?
1642
+
1643
+ plan.organizations_to_retain.each { |organization| organization.close! } # your app's closure
1644
+ plan.organizations_to_destroy.each(&:destroy!)
1645
+ plan.memberships_to_remove.each(&:destroy!)
1646
+ user.anonymize! # your app's anonymization
1647
+ end
1648
+ ```
1649
+
1650
+ - Every other destroy of a retained organization raises `Organizations::Organization::CannotDestroyRetainedOrganization` before any row goes: an admin screen, a console, a cascade. The engine's controllers answer it like a refused form: HTML goes back with the message as an alert, JSON gets a 422 with `{ "error": message }`. `delete` and `delete_all` skip callbacks, so they skip this check too.
1651
+ - The rule gets the organization read again under its row lock, and the lock holds until the destroy commits. The rule runs with the query cache off. A stale copy, a cached association or a cached query cannot let a retained organization go. A record added after the preview still keeps the organization.
1652
+ - Whatever creates the retained records must lock the organization row (`organization.lock!` in the same transaction), or insert them with a foreign key to the organization that restricts deletion. Then a concurrent destroy waits for the record and sees it, or the database refuses the delete.
1653
+ - An organization that blocks (shared, or with other members) stays a blocker. Under `:prevent` nothing is listed as retained, because nothing would be deleted.
1654
+
1588
1655
  ## User Onboarding Patterns
1589
1656
 
1590
1657
  The `always_create_personal_organization_for_each_user` setting controls how new users get their first organization. This is one of the most important decisions when integrating the gem.
@@ -1777,6 +1844,14 @@ Organizations.configure do |config|
1777
1844
  # when they are its only member. See "Deleting user accounts".
1778
1845
  config.account_deletion_policy = :prevent
1779
1846
 
1847
+ # Organizations that hold records with a retention period: never destroyed
1848
+ # (default: nil). See "Organizations that must be kept".
1849
+ config.retain_organization = nil
1850
+
1851
+ # transfer_ownership_to! makes a personal organization shared and transfers
1852
+ # it, instead of refusing (default: false). See "Personal organizations".
1853
+ config.convert_personal_organizations_on_transfer = false
1854
+
1780
1855
  # === Invitations ===
1781
1856
  # How long invitations are valid
1782
1857
  config.invitation_expiry = 7.days
@@ -2636,7 +2711,9 @@ These constraints prevent duplicate data at the database level:
2636
2711
 
2637
2712
  ### Row-level locking
2638
2713
 
2639
- The gem uses `SELECT ... FOR UPDATE` (row-level locks) to prevent race conditions:
2714
+ The gem uses `SELECT ... FOR UPDATE` (row-level locks) to prevent race conditions.
2715
+
2716
+ The gem takes its row locks in one order: the user rows that a write names (in id order), then the organization row, then the rows of the organization. A user's deletion locks its user row `FOR UPDATE`. The joins take a key share guard on their user rows (`FOR KEY SHARE` on PostgreSQL): only a deletion of those users waits for it, and ordinary updates and foreign key checks pass. A user's deletion, an organization's destroy, an invitation acceptance or revoke, a join request approval, the check of an emailed code and a join code redemption all keep this order. So they wait for each other, and do not deadlock on PostgreSQL. Inside your own transaction, the order holds only when your earlier locks in that transaction keep it too. Keep the same order in your own code that writes in an organization.
2640
2717
 
2641
2718
  **Invitation acceptance:**
2642
2719
  ```ruby
@@ -2644,11 +2721,14 @@ The gem uses `SELECT ... FOR UPDATE` (row-level locks) to prevent race condition
2644
2721
  invitation.accept!
2645
2722
 
2646
2723
  # Internally:
2647
- # 1. Lock invitation row
2648
- # 2. Check accepted_at is nil
2649
- # 3. Create membership
2650
- # 4. Set accepted_at
2651
- # 5. Release lock
2724
+ # 1. Take the key share guard on the accepting user and the inviter
2725
+ # 2. Lock organization row
2726
+ # 3. Lock invitation row (when it now names another inviter, release the
2727
+ # locks and start again with the current one, up to three times)
2728
+ # 4. Check accepted_at is nil
2729
+ # 5. Create membership
2730
+ # 6. Set accepted_at
2731
+ # 7. Release locks
2652
2732
  # Second request sees accepted_at is set, returns existing membership
2653
2733
  ```
2654
2734
 
@@ -2657,13 +2737,14 @@ invitation.accept!
2657
2737
  org.transfer_ownership_to!(new_owner)
2658
2738
 
2659
2739
  # Internally:
2660
- # 1. Lock organization row
2661
- # 2. Lock old owner's membership
2662
- # 3. Lock new owner's membership
2663
- # 4. Verify new owner is a member
2664
- # 5. Demote old owner to admin
2665
- # 6. Promote new owner to owner
2666
- # 7. Release locks
2740
+ # 1. Lock the new owner's user row
2741
+ # 2. Lock organization row
2742
+ # 3. Lock old owner's membership
2743
+ # 4. Lock new owner's membership
2744
+ # 5. Verify new owner is a member
2745
+ # 6. Demote old owner to admin
2746
+ # 7. Promote new owner to owner
2747
+ # 8. Release locks
2667
2748
  ```
2668
2749
 
2669
2750
  **Last admin/owner protection:**
@@ -27,6 +27,10 @@ module Organizations
27
27
  # Ensure user is authenticated for all actions
28
28
  before_action :authenticate_organizations_user!
29
29
 
30
+ # A retained organization (config.retain_organization) refuses every
31
+ # destroy. Answer that like a refused form, not with an error page.
32
+ rescue_from Organizations::Organization::CannotDestroyRetainedOrganization, with: :respond_organization_retained
33
+
30
34
  # Expose current_user to views (ControllerHelpers exposes org-related helpers)
31
35
  helper_method :current_user
32
36
 
@@ -67,6 +71,15 @@ module Organizations
67
71
  # Alias for compatibility
68
72
  alias_method :current_organizations_user, :current_user
69
73
 
74
+ # Nothing changed: the destroy rolled back, and the current organization
75
+ # stays in the session.
76
+ def respond_organization_retained(error)
77
+ respond_to do |format|
78
+ format.html { redirect_back fallback_location: main_app.root_path, alert: error.message }
79
+ format.json { render json: { error: error.message }, status: 422 }
80
+ end
81
+ end
82
+
70
83
  def organizations_authenticated_layout
71
84
  configured_layout = Organizations.configuration.authenticated_controller_layout
72
85
  return nil if configured_layout.nil?
@@ -66,18 +66,13 @@ module Organizations
66
66
 
67
67
  def respond_invitation_acceptance_failure(failure)
68
68
  case failure.failure_reason
69
- when :email_mismatch
70
- return respond_invitation_email_mismatch
71
- when :invitation_expired
72
- return respond_invitation_expired
69
+ when :email_mismatch then respond_invitation_email_mismatch
70
+ when :invitation_expired then respond_invitation_expired
71
+ when :invitation_changed then respond_invitation_changed(failure.invitation || @invitation)
72
+ else
73
+ respond_invitation_error(html_path: main_app.root_path, alert: "Unable to accept this invitation.",
74
+ json_error: "Acceptance failed", status: 422)
73
75
  end
74
-
75
- respond_invitation_error(
76
- html_path: main_app.root_path,
77
- alert: "Unable to accept this invitation.",
78
- json_error: "Acceptance failed",
79
- status: 422
80
- )
81
76
  end
82
77
 
83
78
  def respond_invitation_email_mismatch
@@ -98,6 +93,14 @@ module Organizations
98
93
  )
99
94
  end
100
95
 
96
+ # A reissue changed the invitation during the acceptance, and nothing was
97
+ # written. Send the person back to it (its current link) to try again.
98
+ def respond_invitation_changed(invitation)
99
+ message = Organizations.t(:"errors.invitation_changed", organization: invitation.organization)
100
+ respond_invitation_error(html_path: invitation_path(invitation.token), alert: message, json_error: message,
101
+ status: 409)
102
+ end
103
+
101
104
  def respond_invitation_acceptance_success(result)
102
105
  after_path = redirect_path_after_invitation_accepted(result.invitation, user: current_user)
103
106
  payload, status = invitation_acceptance_json_response(result)
@@ -72,6 +72,7 @@ en:
72
72
  # — Invitations —
73
73
  invitation_expired: "This invitation has expired"
74
74
  invitation_already_accepted: "This invitation has already been accepted"
75
+ invitation_changed: "This invitation changed while it was being accepted. Try again."
75
76
  invitation_cannot_resend_accepted: "Cannot resend an accepted invitation"
76
77
  invitation_email_mismatch: "This invitation was sent to a different email address"
77
78
  invitation_accept_as_owner: "Cannot accept invitation as owner. Invite as admin, then use transfer_ownership_to! after joining."
@@ -114,6 +115,7 @@ en:
114
115
  organization_limit_reached: "Maximum number of organizations (%{max}) reached"
115
116
  no_current_organization_to_leave: "No current organization to leave"
116
117
  cannot_delete_user_owns_organizations: "Cannot delete a user who still owns organizations. Transfer ownership or delete those organizations first."
118
+ organization_retained: "%{organization} holds records that must be kept, so it cannot be deleted."
117
119
 
118
120
  # — Controller guards (default unauthorized copy) —
119
121
  unauthorized_role: "You need %{role} access to perform this action"
@@ -54,6 +54,7 @@ es:
54
54
 
55
55
  invitation_expired: "Esta invitación ha caducado"
56
56
  invitation_already_accepted: "Esta invitación ya ha sido aceptada"
57
+ invitation_changed: "Esta invitación cambió mientras se aceptaba. Inténtalo de nuevo."
57
58
  invitation_cannot_resend_accepted: "No se puede reenviar una invitación ya aceptada"
58
59
  invitation_email_mismatch: "Esta invitación se envió a otra dirección de email"
59
60
  invitation_accept_as_owner: "No se puede aceptar una invitación como propietario. Invita como administrador y transfiere la propiedad después."
@@ -94,6 +95,7 @@ es:
94
95
  organization_limit_reached: "Has alcanzado el máximo de organizaciones (%{max})"
95
96
  no_current_organization_to_leave: "No hay ninguna organización activa de la que salir"
96
97
  cannot_delete_user_owns_organizations: "No se puede eliminar un usuario que aún es propietario de organizaciones. Transfiere la propiedad o elimina esas organizaciones primero."
98
+ organization_retained: "%{organization} contiene registros que se deben conservar, así que no se puede eliminar."
97
99
 
98
100
  unauthorized_role: "Necesitas acceso de %{role} para hacer esto"
99
101
  unauthorized_permission: "No tienes permiso para %{permission}"
@@ -10,6 +10,7 @@ module Organizations
10
10
  # plan.allowed? # => false
11
11
  # plan.blockers # => [#<Blocker organization=…, reason=:owns_organization_with_members>]
12
12
  # plan.organizations_to_destroy # personal organizations deleted with the account
13
+ # plan.organizations_to_retain # organizations that config.retain_organization keeps
13
14
  # plan.memberships_to_remove # the user's other memberships, removed with the account
14
15
  #
15
16
  # Blocker reasons:
@@ -18,6 +19,14 @@ module Organizations
18
19
  # :personal_organization_with_members - the personal organization has other members
19
20
  # :personal_organization - the policy is :prevent
20
21
  #
22
+ # Retained organizations (config.retain_organization) are the ones that
23
+ # the policy would delete with the account, but that hold records the app
24
+ # must keep. They do not block: allowed? stays about ownership. But
25
+ # user.destroy refuses while there is one, because it would leave the
26
+ # organization without its owner. The app closes the account instead: it
27
+ # keeps those organizations, destroys organizations_to_destroy and
28
+ # memberships_to_remove, and anonymizes the user.
29
+ #
21
30
  # Database effects only. Subscriptions, external services and retained
22
31
  # records belong to the host's account-closure workflow: a rollback cannot
23
32
  # undo an external call, so run those after the deletion commits.
@@ -31,7 +40,7 @@ module Organizations
31
40
  personal_organization
32
41
  ].freeze
33
42
 
34
- attr_reader :user, :policy, :blockers, :organizations_to_destroy, :memberships_to_remove
43
+ attr_reader :user, :policy, :blockers, :organizations_to_destroy, :organizations_to_retain, :memberships_to_remove
35
44
 
36
45
  # @param user [User]
37
46
  # @param policy [Symbol] one of Configuration::ACCOUNT_DELETION_POLICIES
@@ -49,11 +58,15 @@ module Organizations
49
58
  @policy = policy
50
59
  @blockers = []
51
60
  @organizations_to_destroy = []
52
- classify(owned_organizations(lock: lock))
53
- @memberships_to_remove = remaining_memberships
61
+ @organizations_to_retain = []
62
+ # Under locks, read the database. The query cache can hold an answer
63
+ # from earlier in the request, from before another connection
64
+ # committed (a new member, a retained record).
65
+ lock ? ActiveRecord::Base.uncached { build(lock) } : build(lock)
54
66
  end
55
67
 
56
- # @return [Boolean] true when the user can be deleted
68
+ # @return [Boolean] true when no owned organization blocks the deletion.
69
+ # With organizations_to_retain, the account closes instead.
57
70
  def allowed?
58
71
  blockers.empty?
59
72
  end
@@ -66,12 +79,18 @@ module Organizations
66
79
  policy: policy,
67
80
  blockers: blockers.map { |b| { organization_id: b.organization.id, reason: b.reason } },
68
81
  organizations_to_destroy: organizations_to_destroy.map(&:id),
82
+ organizations_to_retain: organizations_to_retain.map(&:id),
69
83
  memberships_to_remove: memberships_to_remove.map(&:id)
70
84
  }
71
85
  end
72
86
 
73
87
  private
74
88
 
89
+ def build(lock)
90
+ classify(owned_organizations(lock: lock))
91
+ @memberships_to_remove = remaining_memberships
92
+ end
93
+
75
94
  # Organizations the user owns, plus any organization still linked to them
76
95
  # as personal owner (a raw write can leave a link without the membership).
77
96
  def owned_organizations(lock:)
@@ -83,18 +102,25 @@ module Organizations
83
102
  end
84
103
 
85
104
  def classify(organizations)
86
- @shared_ids = Membership.where(organization_id: organizations.map(&:id)).where.not(user_id: user.id)
87
- .distinct.pluck(:organization_id).to_set
105
+ @shared_ids = shared_organization_ids(organizations)
88
106
  organizations.each do |organization|
89
107
  reason = blocker_reason(organization)
90
108
  if reason
91
109
  blockers << Blocker.new(organization: organization, reason: reason)
110
+ elsif Organizations.configuration.retain_organization?(organization)
111
+ organizations_to_retain << organization
92
112
  else
93
113
  organizations_to_destroy << organization
94
114
  end
95
115
  end
96
116
  end
97
117
 
118
+ # The owned organizations that have a member other than this user
119
+ def shared_organization_ids(organizations)
120
+ Membership.where(organization_id: organizations.map(&:id)).where.not(user_id: user.id)
121
+ .distinct.pluck(:organization_id).to_set
122
+ end
123
+
98
124
  def blocker_reason(organization)
99
125
  others = @shared_ids.include?(organization.id)
100
126
  personal = personal_column? && organization.personal_owner_id == user.id
@@ -93,6 +93,27 @@ module Organizations
93
93
  # as before. Needs the personal_owner_id column (rails g organizations:upgrade).
94
94
  attr_accessor :record_personal_organizations
95
95
 
96
+ # The default of transfer_ownership_to!(new_owner, convert_to_shared:).
97
+ # false (default): a personal organization raises
98
+ # CannotTransferPersonalOrganization. true: the transfer makes it shared
99
+ # first, in the same step. Turn it on when your transfer screen tells the
100
+ # owner that the workspace becomes shared. The engine's transfer action
101
+ # uses this default.
102
+ attr_accessor :convert_personal_organizations_on_transfer
103
+
104
+ # Organizations that must be kept because they hold records with a
105
+ # retention period (issued invoices, the books). A callable that receives
106
+ # the organization and returns true to keep it. nil (default) keeps
107
+ # nothing. Deleting the user never destroys a kept organization:
108
+ # user.account_deletion_plan lists it in organizations_to_retain,
109
+ # user.destroy refuses while the user owns one, and any destroy of it
110
+ # raises Organization::CannotDestroyRetainedOrganization. Your app closes
111
+ # it instead. The callable gets the organization read again under its
112
+ # row lock, so whatever creates the retained records must lock that row
113
+ # too, or insert them with a foreign key to it that restricts deletion.
114
+ # config.retain_organization = ->(organization) { organization.invoices.exists? }
115
+ attr_accessor :retain_organization
116
+
96
117
  # What happens when a user who owns organizations is deleted:
97
118
  # :prevent (default) - any owned organization blocks the deletion
98
119
  # :destroy_personal_organization - the user's personal organization is
@@ -339,6 +360,8 @@ module Organizations
339
360
  @always_create_personal_organization_for_each_user = false
340
361
  @default_organization_name = "Personal"
341
362
  @record_personal_organizations = false
363
+ @convert_personal_organizations_on_transfer = false
364
+ @retain_organization = nil
342
365
  @vocabulary = nil
343
366
  @full_resource_access_role = :admin
344
367
  @grant_capabilities = nil
@@ -601,6 +624,18 @@ module Organizations
601
624
  Array(@blocked_email_domains).map { |d| d.to_s.strip.downcase.delete_prefix("@") }
602
625
  end
603
626
 
627
+ # @api private
628
+ # Whether config.retain_organization keeps this organization
629
+ # @param organization [Organizations::Organization]
630
+ # @return [Boolean]
631
+ def retain_organization?(organization)
632
+ return false unless @retain_organization
633
+
634
+ # Not from the query cache: a request can have cached "no retained
635
+ # records" before another connection committed one.
636
+ ActiveRecord::Base.uncached { @retain_organization.call(organization) } ? true : false
637
+ end
638
+
604
639
  # @api private
605
640
  # Resolve the default organization name for a user
606
641
  # @param user [Object] The user object
@@ -682,6 +717,11 @@ module Organizations
682
717
  raise ConfigurationError, "record_personal_organizations must be true or false"
683
718
  end
684
719
 
720
+ unless [true, false].include?(@convert_personal_organizations_on_transfer)
721
+ raise ConfigurationError, "convert_personal_organizations_on_transfer must be true or false"
722
+ end
723
+
724
+ validate_callable_option!(@retain_organization, "retain_organization")
685
725
  return if ACCOUNT_DELETION_POLICIES.include?(@account_deletion_policy)
686
726
 
687
727
  raise ConfigurationError, "account_deletion_policy must be one of: #{ACCOUNT_DELETION_POLICIES.join(', ')}"
@@ -290,6 +290,14 @@ module Organizations
290
290
  return_failure: return_failure,
291
291
  invitation: invitation
292
292
  )
293
+ rescue Organizations::Invitation::InvitationChanged
294
+ # A reissue changed the invitation at each try to lock it. Nothing
295
+ # was written, and the person can try again: keep the session token.
296
+ return invitation_acceptance_failure(
297
+ :invitation_changed,
298
+ return_failure: return_failure,
299
+ invitation: invitation
300
+ )
293
301
  rescue Organizations::InvitationAlreadyAccepted
294
302
  # Check if user is actually a member
295
303
  membership = Organizations::Membership.find_by(
@@ -10,6 +10,7 @@ module Organizations
10
10
  invitation_expired
11
11
  email_mismatch
12
12
  already_accepted_without_membership
13
+ invitation_changed
13
14
  ].freeze
14
15
 
15
16
  attr_reader :reason, :invitation
@@ -185,6 +185,8 @@ module Organizations
185
185
  # this user attached to pending invitations die with the account,
186
186
  # so an orphaned invitation never hands out unchecked access.
187
187
  before_destroy :drop_grants_of_sent_invitations, prepend: true
188
+ # First of all, before any write of this destroy: the locks.
189
+ before_destroy :lock_rows_for_deletion, prepend: true
188
190
  end
189
191
  end
190
192
 
@@ -774,32 +776,73 @@ module Organizations
774
776
 
775
777
  # The account-deletion guard (config.account_deletion_policy). Runs
776
778
  # inside the destroy transaction, before the dependent memberships
777
- # are destroyed. It locks this user's row first, so a concurrent
778
- # join or organization creation for this user waits, then locks the
779
- # owned organizations, so a concurrent join or transfer in them
780
- # waits too. It checks the complete owned set before it deletes
781
- # anything; a blocker, or any failure, aborts the whole destroy and
782
- # rolls back every organization deleted so far.
779
+ # are destroyed, and after lock_rows_for_deletion took the user row
780
+ # and the organization rows. It checks the complete owned set
781
+ # before it deletes anything; a blocker, a retained organization,
782
+ # or any failure, aborts the whole destroy and rolls back every
783
+ # organization deleted so far.
783
784
  def prevent_deletion_while_owning_organizations
784
- lock_own_row_for_deletion
785
785
  plan = Organizations::AccountDeletionPlan.for(self, lock: true)
786
786
  unless plan.allowed?
787
787
  errors.add(:base, Organizations.t(:"errors.cannot_delete_user_owns_organizations"))
788
788
  throw(:abort)
789
789
  end
790
790
 
791
+ refuse_deletion_while_retaining(plan.organizations_to_retain)
791
792
  plan.organizations_to_destroy.each { |organization| destroy_personal_organization_for_deletion(organization) }
792
793
  memberships.reset if plan.organizations_to_destroy.any?
793
794
  end
794
795
 
796
+ # A retained organization must keep its records and its owner, so
797
+ # the user row stays: the app closes the account instead. The
798
+ # message names each organization, as a veto on its destroy would.
799
+ def refuse_deletion_while_retaining(organizations)
800
+ return if organizations.empty?
801
+
802
+ organizations.each do |organization|
803
+ errors.add(:base, Organizations.t(:"errors.organization_retained", organization: organization))
804
+ end
805
+ throw(:abort)
806
+ end
807
+
795
808
  def drop_grants_of_sent_invitations
796
809
  return unless Organizations::GrantHolder.grants_table?
797
810
 
798
811
  Organizations::Grant.where(invitation_id: sent_organization_invitations.select(:id)).delete_all
799
812
  end
800
813
 
801
- def lock_own_row_for_deletion
814
+ # The user row, then the row of every organization in which this
815
+ # destroy writes, in id order, before its first write. The user row
816
+ # keeps a concurrent join, organization creation or transfer to
817
+ # this user out. An organization's destroy locks its row before its
818
+ # cascades write the child rows, so with the organization rows
819
+ # first both paths take one order and cannot deadlock. The id order
820
+ # keeps two account deletions apart.
821
+ def lock_rows_for_deletion
802
822
  self.class.unscoped.where(self.class.primary_key => id).lock.pick(self.class.primary_key)
823
+ organizations = Organizations::Organization
824
+ scope = organization_ids_written_by_deletion.map { |ids| organizations.where(id: ids) }.reduce(:or)
825
+ scope = scope.or(organizations.where(personal_owner_id: id)) if organizations.column_names.include?("personal_owner_id")
826
+ scope.order(:id).lock.pluck(:id)
827
+ end
828
+
829
+ # The organizations of the rows this destroy deletes or nullifies:
830
+ # the dependent: options of has_organizations, the grant cleanup,
831
+ # and the ON DELETE rules of the install migrations.
832
+ def organization_ids_written_by_deletion
833
+ relations = [
834
+ Organizations::Membership.where(user_id: id), Organizations::Membership.where(invited_by_id: id),
835
+ Organizations::Invitation.where(invited_by_id: id),
836
+ Organizations::JoinRequest.where(user_id: id), Organizations::JoinRequest.where(decided_by_id: id),
837
+ Organizations::JoinCode.where(created_by_id: id), Organizations::AllowlistEntry.where(claimed_by_id: id)
838
+ ]
839
+ relations << Organizations::Domain.where(verified_by_id: id) if Organizations::Domain.authority_columns?
840
+ if Organizations::GrantHolder.grants_table?
841
+ granted = Organizations::Grant.where(granted_by_id: id)
842
+ relations << Organizations::Membership.where(id: granted.select(:membership_id))
843
+ relations << Organizations::Invitation.where(id: granted.select(:invitation_id))
844
+ end
845
+ relations.map { |relation| relation.select(:organization_id) }
803
846
  end
804
847
 
805
848
  def destroy_personal_organization_for_deletion(organization)
@@ -124,6 +124,12 @@ module Organizations
124
124
  # Error raised when trying to accept an invitation with owner role
125
125
  class CannotAcceptAsOwner < Organizations::InvitationError; end
126
126
 
127
+ # The invitation named another inviter at each of ACCEPT_LOCK_ATTEMPTS
128
+ # tries to lock its rows (it was reissued meanwhile).
129
+ class InvitationChanged < Organizations::InvitationError; end
130
+
131
+ ACCEPT_LOCK_ATTEMPTS = 3
132
+
127
133
  # Accept the invitation and create membership
128
134
  # Uses row-level locking to prevent race conditions
129
135
  # @param user [User, nil] The user accepting (uses Current.user if not provided)
@@ -149,8 +155,7 @@ module Organizations
149
155
  membership = nil
150
156
 
151
157
  ActiveRecord::Base.transaction do
152
- # Lock the invitation row to prevent race conditions
153
- lock!
158
+ lock_rows_for_acceptance!(accepting_user)
154
159
 
155
160
  # Re-check status after acquiring lock
156
161
  if accepted?
@@ -231,6 +236,10 @@ module Organizations
231
236
  # @raise [Organizations::NotAuthorized] if the role ranks above revoked_by's own
232
237
  def revoke!(revoked_by: nil)
233
238
  ActiveRecord::Base.transaction do
239
+ # The organization row first (RowLocks), as an organization destroy
240
+ # and the inviter's deletion lock it before this invitation and its
241
+ # grants.
242
+ RowLocks.lock_organization!(organization_id)
234
243
  lock!
235
244
  authorize_management!(revoked_by)
236
245
  destroy!
@@ -257,6 +266,31 @@ module Organizations
257
266
 
258
267
  private
259
268
 
269
+ # The gem's lock order (RowLocks): the users that the membership names
270
+ # (the accepting user and the inviter), then the organization row, then
271
+ # the invitation row. A deletion of the inviter or of the organization
272
+ # takes the same order, so it waits for this acceptance, or this one for
273
+ # it. This copy can name a stale inviter: a reissue of the expired row
274
+ # (send_invite_to!) names the new one. The row lock reads the row again.
275
+ # When it names other ids than the ones locked, the savepoint rolls back,
276
+ # which releases its locks, and the next attempt locks the current ids.
277
+ def lock_rows_for_acceptance!(accepting_user)
278
+ ACCEPT_LOCK_ATTEMPTS.times do
279
+ locked = [invited_by_id, organization_id]
280
+ held = ActiveRecord::Base.transaction(requires_new: true) do
281
+ RowLocks.lock_users!(accepting_user.id, invited_by_id)
282
+ RowLocks.lock_organization!(organization_id)
283
+ lock!
284
+ raise ActiveRecord::Rollback unless [invited_by_id, organization_id] == locked
285
+
286
+ true
287
+ end
288
+ return if held
289
+ end
290
+
291
+ raise InvitationChanged, Organizations.t(:"errors.invitation_changed", organization: organization)
292
+ end
293
+
260
294
  # The invitation's grants become the membership's (a grant on the same
261
295
  # resource takes the invitation's capabilities). The inviter was
262
296
  # checked when the invitation was sent.
@@ -152,10 +152,7 @@ module Organizations
152
152
  outcome = nil
153
153
 
154
154
  ActiveRecord::Base.transaction do
155
- # Lock the code row: uses_count accounting and the max_uses cap must
156
- # be race-safe (two concurrent redemptions of a max_uses: 1 code must
157
- # yield exactly one success).
158
- lock!
155
+ lock_rows_for_redemption!(user)
159
156
  ensure_redeemable!
160
157
 
161
158
  # Already a member — idempotent no-op that does NOT consume a use.
@@ -188,6 +185,18 @@ module Organizations
188
185
 
189
186
  private
190
187
 
188
+ # The gem's lock order (RowLocks): the redeeming user, then the
189
+ # organization row, then the code row. The deletion of the code's
190
+ # creator and an organization destroy take the organization row before
191
+ # the code row. The code row lock makes the uses_count accounting and
192
+ # the max_uses cap race-safe (two concurrent redemptions of a max_uses: 1
193
+ # code must yield exactly one success).
194
+ def lock_rows_for_redemption!(user)
195
+ RowLocks.lock_users!(user.id)
196
+ RowLocks.lock_organization!(organization_id)
197
+ lock!
198
+ end
199
+
191
200
  def ensure_redeemable!
192
201
  raise JoinCodeInvalid, Organizations.t(:"errors.join_code_invalid") if revoked? || expired?
193
202
  raise JoinCodeExhausted, Organizations.t(:"errors.join_code_exhausted") if exhausted?
@@ -180,7 +180,7 @@ module Organizations
180
180
  failure = nil
181
181
 
182
182
  ActiveRecord::Base.transaction do
183
- lock!
183
+ lock_rows_for_verification!
184
184
  ensure_open!
185
185
  ensure_active_challenge!
186
186
  # The domain or roster entry may have been removed, revoked or
@@ -319,11 +319,30 @@ module Organizations
319
319
 
320
320
  private
321
321
 
322
+ # The gem's lock order (RowLocks): the requester, then the organization
323
+ # row, then this request. An organization destroy deletes the domains
324
+ # and allowlist entries before this request, and the check locks one of
325
+ # them after this request. The automatic approval takes the requester's
326
+ # guard again: inside a host transaction, this one already holds it, so
327
+ # the approval does not ask for a user row while it holds the
328
+ # organization row.
329
+ def lock_rows_for_verification!
330
+ RowLocks.lock_users!(user_id)
331
+ RowLocks.lock_organization!(organization_id)
332
+ lock!
333
+ end
334
+
322
335
  def perform_approval!(decided_by:, recheck_instruments:)
323
336
  membership = nil
324
337
  reused_existing = false
325
338
 
326
339
  ActiveRecord::Base.transaction do
340
+ # The gem's lock order (RowLocks): the requester and the decider,
341
+ # then the organization row, then this request. The requester's
342
+ # deletion locks the requester, then the organization, and deletes
343
+ # this request.
344
+ RowLocks.lock_users!(user_id, decided_by&.id)
345
+ RowLocks.lock_organization!(organization_id)
327
346
  lock!
328
347
 
329
348
  return approved_membership! if approved? # idempotent re-approval
@@ -32,6 +32,7 @@ module Organizations
32
32
  class NoOwnerPresent < Organizations::Error; end
33
33
  class MemberAlreadyExists < Organizations::Error; end
34
34
  class CannotTransferPersonalOrganization < Organizations::Error; end
35
+ class CannotDestroyRetainedOrganization < Organizations::Error; end
35
36
 
36
37
  # === Associations ===
37
38
 
@@ -87,6 +88,11 @@ module Organizations
87
88
  class_name: Organizations.user_class_name,
88
89
  optional: true
89
90
 
91
+ # Before the dependent: :destroy cascades: lock the row, so that every
92
+ # path takes the organization row before its memberships and
93
+ # invitations, and keep a retained organization (config.retain_organization).
94
+ before_destroy :lock_row_and_refuse_if_retained, prepend: true
95
+
90
96
  # === Validations ===
91
97
 
92
98
  validates :name, presence: true
@@ -186,7 +192,9 @@ module Organizations
186
192
  end
187
193
 
188
194
  # Stop treating this organization as the owner's personal organization.
189
- # Do this before you transfer its ownership. Members, roles and data stay.
195
+ # Members, roles and data stay. To hand it over to another member, use
196
+ # transfer_ownership_to!(member, convert_to_shared: true), which does
197
+ # both in one step.
190
198
  # @return [self]
191
199
  def convert_to_shared!
192
200
  update!(personal_owner_id: nil)
@@ -426,32 +434,35 @@ module Organizations
426
434
  # The new owner must be an admin of the organization
427
435
  # Old owner becomes admin
428
436
  # @param new_owner [User] The user to become owner
437
+ # @param convert_to_shared [Boolean] for a personal organization: make it
438
+ # shared and transfer it in one step (default:
439
+ # config.convert_personal_organizations_on_transfer). A refused transfer
440
+ # leaves it personal. A transfer to the personal owner itself is still
441
+ # refused.
429
442
  # @raise [CannotTransferToNonMember] if user is not a member
430
443
  # @raise [CannotTransferToNonAdmin] if user is not an admin
431
- def transfer_ownership_to!(new_owner)
432
- ActiveRecord::Base.transaction do
444
+ # @raise [CannotTransferPersonalOrganization] for a personal organization
445
+ # without convert_to_shared
446
+ def transfer_ownership_to!(
447
+ new_owner, convert_to_shared: Organizations.configuration.convert_personal_organizations_on_transfer
448
+ )
449
+ converting = false
450
+ # A conversion gets its own savepoint: a refusal after it (the new owner
451
+ # is not an admin, say) undoes it, also when the caller rescues the
452
+ # error inside a transaction of its own.
453
+ ActiveRecord::Base.transaction(requires_new: convert_to_shared) do
433
454
  lock_new_owner_row!(new_owner)
434
455
  lock! # then the organization row, to prevent concurrent operations
435
456
 
436
- ensure_not_personal_for_transfer!
437
-
438
- # Always perform a fresh read in this write path, even if memberships
439
- # are preloaded on this instance, to avoid stale-owner selection.
440
- old_owner_membership = memberships.find_by(role: "owner")
441
- new_owner_membership = memberships.find_by(user_id: new_owner.id)
442
-
443
- unless old_owner_membership
444
- raise NoOwnerPresent, Organizations.t(:"errors.transfer_no_owner", organization: self)
445
- end
446
-
447
- unless new_owner_membership
448
- raise CannotTransferToNonMember, Organizations.t(:"errors.transfer_to_non_member", organization: self)
457
+ # Read under the lock: lock! read the personal link again. A transfer
458
+ # to the personal owner itself converts nothing, so it is refused.
459
+ if convert_to_shared && personal? && personal_owner_id != new_owner.id
460
+ converting = true
461
+ convert_to_shared!
449
462
  end
463
+ ensure_not_personal_for_transfer!
450
464
 
451
- # New owner must be at least an admin (per README: "Ownership can be transferred to any admin")
452
- unless Roles.at_least?(new_owner_membership.role.to_sym, :admin)
453
- raise CannotTransferToNonAdmin, Organizations.t(:"errors.transfer_to_non_admin", organization: self)
454
- end
465
+ old_owner_membership, new_owner_membership = memberships_for_transfer!(new_owner)
455
466
 
456
467
  # No-op transfer to the current owner.
457
468
  return old_owner_membership if old_owner_membership.user_id == new_owner.id
@@ -474,6 +485,11 @@ module Organizations
474
485
  new_owner: new_owner
475
486
  )
476
487
  end
488
+ rescue StandardError
489
+ # Only a started conversion changed this copy. A refusal before it (a
490
+ # record with unsaved changes cannot be locked) keeps those changes.
491
+ forget_refused_conversion if converting
492
+ raise
477
493
  end
478
494
 
479
495
  # === Invitation Methods ===
@@ -753,8 +769,56 @@ module Organizations
753
769
  raise CannotTransferToNonMember, Organizations.t(:"errors.transfer_to_non_member", organization: self)
754
770
  end
755
771
 
756
- # A personal organization belongs to its owner. Call convert_to_shared!
757
- # first to move it to someone else.
772
+ # The savepoint undid the conversion, but this copy can still say shared.
773
+ # Read it again. A reload that fails must not hide why the transfer was
774
+ # refused.
775
+ def forget_refused_conversion
776
+ reload if persisted?
777
+ rescue ActiveRecord::ActiveRecordError
778
+ nil
779
+ end
780
+
781
+ # The owner's and the new owner's memberships, read fresh in this write
782
+ # path even if memberships are preloaded on this instance, to avoid a
783
+ # stale owner. The new owner must be at least an admin (README:
784
+ # "Ownership can be transferred to any admin").
785
+ def memberships_for_transfer!(new_owner)
786
+ old_owner_membership = memberships.find_by(role: "owner")
787
+ new_owner_membership = memberships.find_by(user_id: new_owner.id)
788
+
789
+ raise NoOwnerPresent, Organizations.t(:"errors.transfer_no_owner", organization: self) unless old_owner_membership
790
+
791
+ unless new_owner_membership
792
+ raise CannotTransferToNonMember, Organizations.t(:"errors.transfer_to_non_member", organization: self)
793
+ end
794
+
795
+ unless Roles.at_least?(new_owner_membership.role.to_sym, :admin)
796
+ raise CannotTransferToNonAdmin, Organizations.t(:"errors.transfer_to_non_admin", organization: self)
797
+ end
798
+
799
+ [old_owner_membership, new_owner_membership]
800
+ end
801
+
802
+ # Every destroy locks the organization row before the cascades delete
803
+ # its memberships and invitations. The account-deletion guard also locks
804
+ # organization rows before it writes their child rows, so the two paths
805
+ # take the same order and cannot deadlock.
806
+ #
807
+ # A record with a retention period must outlive every destroy path, not
808
+ # only the account deletion: an admin screen, a console, a cascade.
809
+ # This copy can be stale (attributes, cached associations), so the rule
810
+ # gets the row read again under the lock. The lock holds until the
811
+ # destroy and its cascades commit, so a writer that locks the row, or
812
+ # inserts a row with a foreign key to it, waits or is seen.
813
+ def lock_row_and_refuse_if_retained
814
+ current = self.class.unscoped.lock.find_by(self.class.primary_key => id)
815
+ return unless current && Organizations.configuration.retain_organization?(current)
816
+
817
+ raise CannotDestroyRetainedOrganization, Organizations.t(:"errors.organization_retained", organization: current)
818
+ end
819
+
820
+ # A personal organization belongs to its owner. Pass convert_to_shared:
821
+ # true, or call convert_to_shared! first, to move it to someone else.
758
822
  def ensure_not_personal_for_transfer!
759
823
  return unless personal?
760
824
 
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # The one lock order of the gem: the user rows (in id order), then the
5
+ # organization row, then the rows of the organization. A user's deletion
6
+ # locks the user row, then the row of every organization in which it
7
+ # writes. An organization's destroy locks its row before its cascades. A
8
+ # path that writes in an organization takes the same order, or it can
9
+ # deadlock with them on PostgreSQL.
10
+ #
11
+ # The user rows are the ones that the path's writes reference: a row that
12
+ # names a user takes a key share lock on it when it is written, which
13
+ # waits for a deletion of that user. Lock them first, so that the wait
14
+ # comes before the organization row and not after it.
15
+ #
16
+ # The user guard is a key share lock (FOR KEY SHARE on PostgreSQL). It
17
+ # conflicts with the deletion's FOR UPDATE, which is its purpose, and with
18
+ # nothing else: an ordinary update of the user and the foreign key check
19
+ # of another writer pass. A FOR UPDATE guard would block those writers,
20
+ # and a writer that holds a row this path waits for would then close a
21
+ # cycle (a reject! that sets decided_by_id, a grant that names its
22
+ # granter).
23
+ #
24
+ # Inside a host transaction the order holds only when the host's earlier
25
+ # locks in that transaction keep it too.
26
+ #
27
+ # @api private
28
+ module RowLocks
29
+ module_function
30
+
31
+ # Take the key share guard on these users' rows, in id order. Nil ids
32
+ # and users that are gone are skipped.
33
+ # @param ids [Array<Object>]
34
+ def lock_users!(*ids)
35
+ ids = ids.flatten.compact.uniq
36
+ return if ids.empty?
37
+
38
+ user_class = Organizations.user_class
39
+ key = user_class.primary_key
40
+ user_class.unscoped.where(key => ids).order(key).lock(key_share_clause(user_class.connection)).pluck(key)
41
+ end
42
+
43
+ # PostgreSQL has a lock that only a delete or a key change conflicts
44
+ # with. MySQL has a shared lock. SQLite has no row locks: Rails drops
45
+ # the clause there.
46
+ def key_share_clause(connection)
47
+ adapter = connection.adapter_name.downcase
48
+ return "FOR KEY SHARE" if adapter.include?("postg")
49
+ return "LOCK IN SHARE MODE" if adapter.include?("mysql") || adapter.include?("trilogy")
50
+
51
+ true
52
+ end
53
+
54
+ # Lock the organization row. A destroyed organization is skipped: the
55
+ # caller's own row lock then finds its row gone.
56
+ # @param id [Object]
57
+ def lock_organization!(id)
58
+ Organizations::Organization.unscoped.where(id: id).lock.pick(:id)
59
+ end
60
+ end
61
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Organizations
4
- VERSION = "0.7.1"
4
+ VERSION = "0.8.1"
5
5
  end
data/lib/organizations.rb CHANGED
@@ -126,6 +126,7 @@ module Organizations
126
126
  autoload :GrantAccess, "organizations/grant_access"
127
127
  autoload :Grantable, "organizations/grantable"
128
128
  autoload :AccountDeletionPlan, "organizations/account_deletion_plan"
129
+ autoload :RowLocks, "organizations/row_locks"
129
130
 
130
131
  # Alias for README compatibility: `include Organizations::Controller`
131
132
  Controller = ControllerHelpers
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: organizations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-19 00:00:00.000000000 Z
10
+ date: 2026-10-25 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: railties
@@ -180,6 +180,7 @@ files:
180
180
  - lib/organizations/public_email_domains.rb
181
181
  - lib/organizations/resource.rb
182
182
  - lib/organizations/roles.rb
183
+ - lib/organizations/row_locks.rb
183
184
  - lib/organizations/test_helpers.rb
184
185
  - lib/organizations/version.rb
185
186
  - lib/organizations/view_helpers.rb