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 +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +72 -3
- data/app/controllers/organizations/application_controller.rb +13 -0
- data/config/locales/en.yml +1 -0
- data/config/locales/es.yml +1 -0
- data/lib/organizations/account_deletion_plan.rb +32 -6
- data/lib/organizations/configuration.rb +40 -0
- data/lib/organizations/models/concerns/has_organizations.rb +51 -8
- data/lib/organizations/models/organization.rb +86 -22
- data/lib/organizations/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cee8b956413b0859d592b794078046a1f5ed95fea6fd391e8b3a6aee83dbc14d
|
|
4
|
+
data.tar.gz: 3f0ef38dd0d343ffbba6c7f9a28f1943f55f2610d90b77b36306908b79d18e64
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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?
|
data/config/locales/en.yml
CHANGED
|
@@ -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"
|
data/config/locales/es.yml
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
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
|
|
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 =
|
|
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
|
|
778
|
-
#
|
|
779
|
-
#
|
|
780
|
-
#
|
|
781
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
432
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
757
|
-
#
|
|
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
|
|
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.
|
|
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-
|
|
10
|
+
date: 2026-10-24 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: railties
|