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 +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +97 -16
- data/app/controllers/organizations/application_controller.rb +13 -0
- data/app/controllers/organizations/public_invitations_controller.rb +14 -11
- data/config/locales/en.yml +2 -0
- data/config/locales/es.yml +2 -0
- data/lib/organizations/account_deletion_plan.rb +32 -6
- data/lib/organizations/configuration.rb +40 -0
- data/lib/organizations/controller_helpers.rb +8 -0
- data/lib/organizations/invitation_acceptance_failure.rb +1 -0
- data/lib/organizations/models/concerns/has_organizations.rb +51 -8
- data/lib/organizations/models/invitation.rb +36 -2
- data/lib/organizations/models/join_code.rb +13 -4
- data/lib/organizations/models/join_request.rb +20 -1
- data/lib/organizations/models/organization.rb +86 -22
- data/lib/organizations/row_locks.rb +61 -0
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e3c621c2b6bb7bd32be611858d49af9cb12cf9fa80bc0309d1227eafc819f8d8
|
|
4
|
+
data.tar.gz: c6028a9e424ab92432c44ae45bcba367d2a70eab56e3901314b1d5ff9b9b102f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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.
|
|
2648
|
-
# 2.
|
|
2649
|
-
# 3.
|
|
2650
|
-
#
|
|
2651
|
-
#
|
|
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
|
|
2661
|
-
# 2. Lock
|
|
2662
|
-
# 3. Lock
|
|
2663
|
-
# 4.
|
|
2664
|
-
# 5.
|
|
2665
|
-
# 6.
|
|
2666
|
-
# 7.
|
|
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
|
-
|
|
71
|
-
when :
|
|
72
|
-
|
|
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)
|
data/config/locales/en.yml
CHANGED
|
@@ -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"
|
data/config/locales/es.yml
CHANGED
|
@@ -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
|
-
|
|
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(', ')}"
|
|
@@ -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(
|
|
@@ -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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
|
@@ -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
|
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.
|
|
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-
|
|
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
|