organizations 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fba49577a4eb70a0bd69d5b554609a3a3b5a73046f1b0007219773b2e3cccd48
4
- data.tar.gz: 8eba41990e24a6558de87d4760fabf09b65561fd43e4314e27f58ec5d838dbc3
3
+ metadata.gz: cee8b956413b0859d592b794078046a1f5ed95fea6fd391e8b3a6aee83dbc14d
4
+ data.tar.gz: 3f0ef38dd0d343ffbba6c7f9a28f1943f55f2610d90b77b36306908b79d18e64
5
5
  SHA512:
6
- metadata.gz: f49c6a4eba6854e3988c2a39d175f5616af10ef8723e3c6f91d461ca8f3bc4151cb9ddc64e0baf5ff5a83e474690eacc86362d149354c46e72c97b92bf2e9c7a
7
- data.tar.gz: 9b83cc21331c095d64ea22ba5f4874a11716e634f2dffd664a7b5b3ed4768c9ac048e2f03a4c93bddd92e3661d6003dceeac0edc21f671c5ff327dff4241490e
6
+ metadata.gz: fa931ef4f5a12370ae4f86ceb15bc9d601a35ef042db467cc9c4c4ff125022f16760fbfe1c9a140c0a3001862b61386d7a2529e1e2074be2b98576760983747f
7
+ data.tar.gz: a33b77589dc25b53c5a0783899d4056ac8a0fcb73e578f089eb3fece8565a3ae9c2f098c3c64babf43e752907807064ad3e78a0855b1e5a858631749bcce8461
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.8.0] - 2026-10-24
4
+
5
+ **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.
6
+
7
+ ### Added
8
+
9
+ - **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.
10
+ - **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).
11
+
12
+ ### Changed
13
+
14
+ - `AccountDeletionPlan#to_h` has an `organizations_to_retain` key. It is an empty list when nothing is retained.
15
+ - `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.
16
+ - 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.
17
+ - 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.
18
+
19
+ ### Upgrading
20
+
21
+ 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`.
22
+
3
23
  ## [0.7.1] - 2026-10-19
4
24
 
5
25
  **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
@@ -1523,7 +1525,7 @@ Organizations::Organization.not_personal # every other organization
1523
1525
  The rules:
1524
1526
 
1525
1527
  - 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.
1528
+ - 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
1529
  - A personal organization can have other members. To keep it owner-only, veto joins in the membership gate, which covers every join path:
1528
1530
 
1529
1531
  ```ruby
@@ -1546,6 +1548,25 @@ The rules:
1546
1548
 
1547
1549
  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
1550
 
1551
+ **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:
1552
+
1553
+ ```ruby
1554
+ org.transfer_ownership_to!(admin, convert_to_shared: true)
1555
+ org.personal? # => false
1556
+ org.owner # => admin
1557
+ ```
1558
+
1559
+ To make this the default, also for the engine's transfer action:
1560
+
1561
+ ```ruby
1562
+ config.convert_personal_organizations_on_transfer = true # default: false
1563
+ ```
1564
+
1565
+ - 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.
1566
+ - A transfer to the personal owner itself still raises `CannotTransferPersonalOrganization`: there is nothing to hand over.
1567
+ - 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.
1568
+ - `convert_to_shared: false` refuses again for one call.
1569
+
1549
1570
  A personal organization tells you who probably pays. Billing and entitlements stay with your app.
1550
1571
 
1551
1572
  ## Deleting user accounts
@@ -1559,6 +1580,7 @@ plan = user.account_deletion_plan
1559
1580
  plan.allowed? # => false
1560
1581
  plan.blockers # => [#<struct organization=#<Organization "Acme">, reason=:owns_organization_with_members>]
1561
1582
  plan.organizations_to_destroy # organizations deleted with the account
1583
+ plan.organizations_to_retain # organizations kept, see "Organizations that must be kept"
1562
1584
  plan.memberships_to_remove # the user's other memberships, removed with the account
1563
1585
  plan.to_h # ids and symbols, for an audit log or a confirmation screen
1564
1586
  user.account_deletion_plan(policy: :destroy_personal_organization) # preview another policy
@@ -1582,9 +1604,48 @@ What the gem guarantees:
1582
1604
 
1583
1605
  - It checks the complete owned set before it deletes anything. One blocker blocks the whole deletion.
1584
1606
  - 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.
1607
+ - 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. 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
1608
  - 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
1609
 
1610
+ ### Organizations that must be kept
1611
+
1612
+ 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:
1613
+
1614
+ ```ruby
1615
+ config.account_deletion_policy = :destroy_personal_organization
1616
+ config.retain_organization = ->(organization) { organization.invoices.exists? } # default: nil
1617
+ ```
1618
+
1619
+ The plan lists them apart. They do not block, so `allowed?` still answers the ownership question:
1620
+
1621
+ ```ruby
1622
+ plan = user.account_deletion_plan
1623
+ plan.allowed? # => true
1624
+ plan.organizations_to_retain # => [#<Organization "Ana's books">]
1625
+ plan.organizations_to_destroy # => []
1626
+ ```
1627
+
1628
+ - `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.
1629
+ - 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:
1630
+
1631
+ ```ruby
1632
+ User.transaction do
1633
+ user.lock!
1634
+ plan = Organizations::AccountDeletionPlan.for(user, lock: true)
1635
+ raise "Transfer or delete the blocking organizations first" unless plan.allowed?
1636
+
1637
+ plan.organizations_to_retain.each { |organization| organization.close! } # your app's closure
1638
+ plan.organizations_to_destroy.each(&:destroy!)
1639
+ plan.memberships_to_remove.each(&:destroy!)
1640
+ user.anonymize! # your app's anonymization
1641
+ end
1642
+ ```
1643
+
1644
+ - 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.
1645
+ - 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.
1646
+ - 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.
1647
+ - An organization that blocks (shared, or with other members) stays a blocker. Under `:prevent` nothing is listed as retained, because nothing would be deleted.
1648
+
1588
1649
  ## User Onboarding Patterns
1589
1650
 
1590
1651
  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 +1838,14 @@ Organizations.configure do |config|
1777
1838
  # when they are its only member. See "Deleting user accounts".
1778
1839
  config.account_deletion_policy = :prevent
1779
1840
 
1841
+ # Organizations that hold records with a retention period: never destroyed
1842
+ # (default: nil). See "Organizations that must be kept".
1843
+ config.retain_organization = nil
1844
+
1845
+ # transfer_ownership_to! makes a personal organization shared and transfers
1846
+ # it, instead of refusing (default: false). See "Personal organizations".
1847
+ config.convert_personal_organizations_on_transfer = false
1848
+
1780
1849
  # === Invitations ===
1781
1850
  # How long invitations are valid
1782
1851
  config.invitation_expiry = 7.days
@@ -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?
@@ -114,6 +114,7 @@ en:
114
114
  organization_limit_reached: "Maximum number of organizations (%{max}) reached"
115
115
  no_current_organization_to_leave: "No current organization to leave"
116
116
  cannot_delete_user_owns_organizations: "Cannot delete a user who still owns organizations. Transfer ownership or delete those organizations first."
117
+ organization_retained: "%{organization} holds records that must be kept, so it cannot be deleted."
117
118
 
118
119
  # — Controller guards (default unauthorized copy) —
119
120
  unauthorized_role: "You need %{role} access to perform this action"
@@ -94,6 +94,7 @@ es:
94
94
  organization_limit_reached: "Has alcanzado el máximo de organizaciones (%{max})"
95
95
  no_current_organization_to_leave: "No hay ninguna organización activa de la que salir"
96
96
  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."
97
+ organization_retained: "%{organization} contiene registros que se deben conservar, así que no se puede eliminar."
97
98
 
98
99
  unauthorized_role: "Necesitas acceso de %{role} para hacer esto"
99
100
  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(', ')}"
@@ -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)
@@ -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
 
@@ -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.0"
5
5
  end
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.0
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-24 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: railties