organizations 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cee8b956413b0859d592b794078046a1f5ed95fea6fd391e8b3a6aee83dbc14d
4
- data.tar.gz: 3f0ef38dd0d343ffbba6c7f9a28f1943f55f2610d90b77b36306908b79d18e64
3
+ metadata.gz: d3074b8343b80c07b277e1038a0181981274d1baaa7a52af85dd425edf09c5d3
4
+ data.tar.gz: 8529247495ba8c215216cf4acea19e7764b3e20ac16d997a1ca3b1ecc49f86d7
5
5
  SHA512:
6
- metadata.gz: fa931ef4f5a12370ae4f86ceb15bc9d601a35ef042db467cc9c4c4ff125022f16760fbfe1c9a140c0a3001862b61386d7a2529e1e2074be2b98576760983747f
7
- data.tar.gz: a33b77589dc25b53c5a0783899d4056ac8a0fcb73e578f089eb3fece8565a3ae9c2f098c3c64babf43e752907807064ad3e78a0855b1e5a858631749bcce8461
6
+ metadata.gz: cb91ccb8891c4a17c65a85698c13c15931505c3d32688bc075402fdc8582dbd0ecaf2a6a0e581696f471991cd23344998563f340206855c9532352da22086fc7
7
+ data.tar.gz: 1e15480d1f104e0fddea00dd2509223a7b43707e9eccd8930461c24928c3c81338387ba6e95e6b093058ee1bed2741c15b8c2ba919d8ba2449cdd98388f54a62
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.9.0] - 2026-10-25
4
+
5
+ **The current organization, outside controllers.** One addition, opt-in to read. No migration is needed.
6
+
7
+ ### Added
8
+
9
+ - **`Organizations::Current`**, an `ActiveSupport::CurrentAttributes` with one attribute, `organization`. The controller helpers set it whenever `current_organization` resolves or changes (a fallback, `switch_to_organization!`, `current_organization =`, a cleared session), so models and mailers in the same request can read it. Rails resets it before every request and every job. Session-based resolution is unchanged.
10
+ - **`Organizations.with_organization(organization) { ... }`** for jobs, scripts and consoles. It sets the organization for the block, returns the block's value, and puts back the previous one afterwards, also when the block raises, so blocks nest.
11
+ - The README documents the one seam for a host's tenant context (for example a PostgreSQL setting that row-level security policies read): prepend a module that overrides `Organizations::Current#organization=`, which every change goes through, and add a `resets` callback. Asked for by ReplyBrain, whose reused gems must be scoped per request and per job.
12
+
13
+ ## [0.8.1] - 2026-10-25
14
+
15
+ **The joins take the lock order of the destroys.** A patch release with one fix. No migration is needed.
16
+
17
+ ### Fixed
18
+
19
+ - **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:
20
+ - `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.
21
+ - `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.
22
+ - `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.
23
+ - `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.
24
+ - `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.
25
+ - **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.
26
+ - **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.
27
+ - Inside a host transaction, the order holds only when the locks that the host took earlier in that transaction keep it too.
28
+ - 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.
29
+
3
30
  ## [0.8.0] - 2026-10-24
4
31
 
5
32
  **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.
data/README.md CHANGED
@@ -722,6 +722,10 @@ invitation.grant!(record, capabilities: %i[read])
722
722
  # Actions
723
723
  invitation.accept! # Accept (auto-infers Current.user)
724
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).
725
729
  invitation.resend!(resent_by: current_user) # Send invitation email again
726
730
  invitation.revoke!(revoked_by: current_user) # Delete it, so the link stops working
727
731
  ```
@@ -1325,6 +1329,8 @@ else
1325
1329
  end
1326
1330
  ```
1327
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
+
1328
1334
  Configure redirects in your initializer:
1329
1335
 
1330
1336
  ```ruby
@@ -1604,7 +1610,7 @@ What the gem guarantees:
1604
1610
 
1605
1611
  - It checks the complete owned set before it deletes anything. One blocker blocks the whole deletion.
1606
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`.
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.
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.
1608
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.
1609
1615
 
1610
1616
  ### Organizations that must be kept
@@ -2027,6 +2033,41 @@ Organizations.configure do |config|
2027
2033
  end
2028
2034
  ```
2029
2035
 
2036
+ ### The current organization outside controllers
2037
+
2038
+ Once `current_organization` resolves in a request, the same organization is in `Organizations::Current.organization`, readable from models, mailers and anything else that runs in that request. It follows switches, and Rails resets it when the request ends. Resolve it early, with `before_action :require_organization!` or a `current_organization` call, so the rest of the request sees it.
2039
+
2040
+ Jobs, scripts and consoles have no controller, so they say which organization they act for:
2041
+
2042
+ ```ruby
2043
+ class SendInvoiceJob < ApplicationJob
2044
+ def perform(invoice)
2045
+ Organizations.with_organization(invoice.organization) do
2046
+ InvoiceMailer.paid(invoice).deliver_now
2047
+ end
2048
+ end
2049
+ end
2050
+ ```
2051
+
2052
+ The block puts back whatever was current before, even when it raises, so blocks nest. Rails resets `Organizations::Current` before every job too.
2053
+
2054
+ Every change goes through one writer, `Organizations::Current.organization=`, and every reset through its `resets` callbacks. Those two points are where to hook your own tenant context, such as the PostgreSQL setting your row-level security policies read (a `SET LOCAL` in the transaction they run in):
2055
+
2056
+ ```ruby
2057
+ # config/initializers/organizations.rb
2058
+ module TenantContext
2059
+ def organization=(organization)
2060
+ super
2061
+ Tenant.enter(organization) # your code: remember it, set the database setting...
2062
+ end
2063
+ end
2064
+
2065
+ Organizations::Current.prepend(TenantContext)
2066
+ Organizations::Current.resets { Tenant.leave }
2067
+ ```
2068
+
2069
+ Session-based resolution is unchanged: the session still decides which organization is current, and `Organizations::Current` only publishes it.
2070
+
2030
2071
  ### Integrates with acts_as_tenant
2031
2072
 
2032
2073
  For automatic query scoping, include the integration concern:
@@ -2705,7 +2746,9 @@ These constraints prevent duplicate data at the database level:
2705
2746
 
2706
2747
  ### Row-level locking
2707
2748
 
2708
- The gem uses `SELECT ... FOR UPDATE` (row-level locks) to prevent race conditions:
2749
+ The gem uses `SELECT ... FOR UPDATE` (row-level locks) to prevent race conditions.
2750
+
2751
+ 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.
2709
2752
 
2710
2753
  **Invitation acceptance:**
2711
2754
  ```ruby
@@ -2713,11 +2756,14 @@ The gem uses `SELECT ... FOR UPDATE` (row-level locks) to prevent race condition
2713
2756
  invitation.accept!
2714
2757
 
2715
2758
  # Internally:
2716
- # 1. Lock invitation row
2717
- # 2. Check accepted_at is nil
2718
- # 3. Create membership
2719
- # 4. Set accepted_at
2720
- # 5. Release lock
2759
+ # 1. Take the key share guard on the accepting user and the inviter
2760
+ # 2. Lock organization row
2761
+ # 3. Lock invitation row (when it now names another inviter, release the
2762
+ # locks and start again with the current one, up to three times)
2763
+ # 4. Check accepted_at is nil
2764
+ # 5. Create membership
2765
+ # 6. Set accepted_at
2766
+ # 7. Release locks
2721
2767
  # Second request sees accepted_at is set, returns existing membership
2722
2768
  ```
2723
2769
 
@@ -2726,13 +2772,14 @@ invitation.accept!
2726
2772
  org.transfer_ownership_to!(new_owner)
2727
2773
 
2728
2774
  # Internally:
2729
- # 1. Lock organization row
2730
- # 2. Lock old owner's membership
2731
- # 3. Lock new owner's membership
2732
- # 4. Verify new owner is a member
2733
- # 5. Demote old owner to admin
2734
- # 6. Promote new owner to owner
2735
- # 7. Release locks
2775
+ # 1. Lock the new owner's user row
2776
+ # 2. Lock organization row
2777
+ # 3. Lock old owner's membership
2778
+ # 4. Lock new owner's membership
2779
+ # 5. Verify new owner is a member
2780
+ # 6. Demote old owner to admin
2781
+ # 7. Promote new owner to owner
2782
+ # 8. Release locks
2736
2783
  ```
2737
2784
 
2738
2785
  **Last admin/owner protection:**
@@ -66,18 +66,13 @@ module Organizations
66
66
 
67
67
  def respond_invitation_acceptance_failure(failure)
68
68
  case failure.failure_reason
69
- when :email_mismatch
70
- return respond_invitation_email_mismatch
71
- when :invitation_expired
72
- return respond_invitation_expired
69
+ when :email_mismatch then respond_invitation_email_mismatch
70
+ when :invitation_expired then respond_invitation_expired
71
+ when :invitation_changed then respond_invitation_changed(failure.invitation || @invitation)
72
+ else
73
+ respond_invitation_error(html_path: main_app.root_path, alert: "Unable to accept this invitation.",
74
+ json_error: "Acceptance failed", status: 422)
73
75
  end
74
-
75
- respond_invitation_error(
76
- html_path: main_app.root_path,
77
- alert: "Unable to accept this invitation.",
78
- json_error: "Acceptance failed",
79
- status: 422
80
- )
81
76
  end
82
77
 
83
78
  def respond_invitation_email_mismatch
@@ -98,6 +93,14 @@ module Organizations
98
93
  )
99
94
  end
100
95
 
96
+ # A reissue changed the invitation during the acceptance, and nothing was
97
+ # written. Send the person back to it (its current link) to try again.
98
+ def respond_invitation_changed(invitation)
99
+ message = Organizations.t(:"errors.invitation_changed", organization: invitation.organization)
100
+ respond_invitation_error(html_path: invitation_path(invitation.token), alert: message, json_error: message,
101
+ status: 409)
102
+ end
103
+
101
104
  def respond_invitation_acceptance_success(result)
102
105
  after_path = redirect_path_after_invitation_accepted(result.invitation, user: current_user)
103
106
  payload, status = invitation_acceptance_json_response(result)
@@ -72,6 +72,7 @@ en:
72
72
  # — Invitations —
73
73
  invitation_expired: "This invitation has expired"
74
74
  invitation_already_accepted: "This invitation has already been accepted"
75
+ invitation_changed: "This invitation changed while it was being accepted. Try again."
75
76
  invitation_cannot_resend_accepted: "Cannot resend an accepted invitation"
76
77
  invitation_email_mismatch: "This invitation was sent to a different email address"
77
78
  invitation_accept_as_owner: "Cannot accept invitation as owner. Invite as admin, then use transfer_ownership_to! after joining."
@@ -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."
@@ -38,7 +38,7 @@ module Organizations
38
38
  # Returns the current organization from session
39
39
  # Validates membership - if user was removed, auto-switches to next available org
40
40
  # Falls back to most recently joined org if no session set
41
- # Memoized within the request
41
+ # Memoized within the request, and published as Organizations::Current.organization
42
42
  # @return [Organizations::Organization, nil]
43
43
  def current_organization
44
44
  return @_current_organization if defined?(@_current_organization)
@@ -58,7 +58,7 @@ module Organizations
58
58
  if org && user.is_member_of?(org)
59
59
  # Valid membership - use this org
60
60
  user._current_organization_id = org.id
61
- @_current_organization = org
61
+ self.resolved_current_organization = org
62
62
  else
63
63
  # User was removed from this org OR no session set
64
64
  # Auto-switch to next available org (most recently joined)
@@ -68,9 +68,9 @@ module Organizations
68
68
  if fallback_org
69
69
  session[session_key] = fallback_org.id
70
70
  user._current_organization_id = fallback_org.id
71
- @_current_organization = fallback_org
71
+ self.resolved_current_organization = fallback_org
72
72
  else
73
- @_current_organization = nil
73
+ self.resolved_current_organization = nil
74
74
  end
75
75
  end
76
76
  end
@@ -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(
@@ -451,7 +459,7 @@ module Organizations
451
459
 
452
460
  if org
453
461
  session[session_key] = org.id
454
- @_current_organization = org
462
+ self.resolved_current_organization = org
455
463
  forget_current_membership!
456
464
 
457
465
  # Update user's context
@@ -570,13 +578,21 @@ module Organizations
570
578
  def clear_organization_session!
571
579
  session_key = Organizations.configuration.session_key
572
580
  session.delete(session_key)
573
- @_current_organization = nil
581
+ self.resolved_current_organization = nil
574
582
  forget_current_membership!
575
583
 
576
584
  user = organizations_current_user
577
585
  user&.clear_organization_cache!
578
586
  end
579
587
 
588
+ # Memoizes the organization for this request and publishes it to
589
+ # Organizations::Current, so models and the host's tenant hook see the
590
+ # one the helpers resolved.
591
+ def resolved_current_organization=(organization)
592
+ @_current_organization = organization
593
+ Organizations::Current.organization = organization
594
+ end
595
+
580
596
  # Drop the memoized membership so the next current_membership call reads
581
597
  # it again for the organization that is current by then. Assigning nil
582
598
  # would not do: current_membership memoizes nil too, so it would keep
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/current_attributes"
4
+
5
+ module Organizations
6
+ # The organization the current request or job acts for.
7
+ #
8
+ # The controller helpers set it whenever `current_organization` resolves
9
+ # or changes (a switch, a fallback, a sign-out from the organization).
10
+ # Jobs and scripts set it with `Organizations.with_organization`. Rails
11
+ # resets it before every request and every job, so a value never leaks
12
+ # from one to the next.
13
+ #
14
+ # Organizations::Current.organization # => #<Organizations::Organization id: 42>
15
+ #
16
+ # It is the one place a host hooks its own tenant context, such as a
17
+ # PostgreSQL setting that row-level security policies read: every change
18
+ # goes through `organization=`, and every reset through `resets`.
19
+ #
20
+ # module TenantContext
21
+ # def organization=(organization)
22
+ # super
23
+ # Tenant.enter(organization)
24
+ # end
25
+ # end
26
+ #
27
+ # Organizations::Current.prepend(TenantContext)
28
+ # Organizations::Current.resets { Tenant.leave }
29
+ class Current < ActiveSupport::CurrentAttributes
30
+ attribute :organization
31
+ end
32
+ end
@@ -10,6 +10,7 @@ module Organizations
10
10
  invitation_expired
11
11
  email_mismatch
12
12
  already_accepted_without_membership
13
+ invitation_changed
13
14
  ].freeze
14
15
 
15
16
  attr_reader :reason, :invitation
@@ -124,6 +124,12 @@ module Organizations
124
124
  # Error raised when trying to accept an invitation with owner role
125
125
  class CannotAcceptAsOwner < Organizations::InvitationError; end
126
126
 
127
+ # The invitation named another inviter at each of ACCEPT_LOCK_ATTEMPTS
128
+ # tries to lock its rows (it was reissued meanwhile).
129
+ class InvitationChanged < Organizations::InvitationError; end
130
+
131
+ ACCEPT_LOCK_ATTEMPTS = 3
132
+
127
133
  # Accept the invitation and create membership
128
134
  # Uses row-level locking to prevent race conditions
129
135
  # @param user [User, nil] The user accepting (uses Current.user if not provided)
@@ -149,8 +155,7 @@ module Organizations
149
155
  membership = nil
150
156
 
151
157
  ActiveRecord::Base.transaction do
152
- # Lock the invitation row to prevent race conditions
153
- lock!
158
+ lock_rows_for_acceptance!(accepting_user)
154
159
 
155
160
  # Re-check status after acquiring lock
156
161
  if accepted?
@@ -231,6 +236,10 @@ module Organizations
231
236
  # @raise [Organizations::NotAuthorized] if the role ranks above revoked_by's own
232
237
  def revoke!(revoked_by: nil)
233
238
  ActiveRecord::Base.transaction do
239
+ # The organization row first (RowLocks), as an organization destroy
240
+ # and the inviter's deletion lock it before this invitation and its
241
+ # grants.
242
+ RowLocks.lock_organization!(organization_id)
234
243
  lock!
235
244
  authorize_management!(revoked_by)
236
245
  destroy!
@@ -257,6 +266,31 @@ module Organizations
257
266
 
258
267
  private
259
268
 
269
+ # The gem's lock order (RowLocks): the users that the membership names
270
+ # (the accepting user and the inviter), then the organization row, then
271
+ # the invitation row. A deletion of the inviter or of the organization
272
+ # takes the same order, so it waits for this acceptance, or this one for
273
+ # it. This copy can name a stale inviter: a reissue of the expired row
274
+ # (send_invite_to!) names the new one. The row lock reads the row again.
275
+ # When it names other ids than the ones locked, the savepoint rolls back,
276
+ # which releases its locks, and the next attempt locks the current ids.
277
+ def lock_rows_for_acceptance!(accepting_user)
278
+ ACCEPT_LOCK_ATTEMPTS.times do
279
+ locked = [invited_by_id, organization_id]
280
+ held = ActiveRecord::Base.transaction(requires_new: true) do
281
+ RowLocks.lock_users!(accepting_user.id, invited_by_id)
282
+ RowLocks.lock_organization!(organization_id)
283
+ lock!
284
+ raise ActiveRecord::Rollback unless [invited_by_id, organization_id] == locked
285
+
286
+ true
287
+ end
288
+ return if held
289
+ end
290
+
291
+ raise InvitationChanged, Organizations.t(:"errors.invitation_changed", organization: organization)
292
+ end
293
+
260
294
  # The invitation's grants become the membership's (a grant on the same
261
295
  # resource takes the invitation's capabilities). The inviter was
262
296
  # checked when the invitation was sent.
@@ -152,10 +152,7 @@ module Organizations
152
152
  outcome = nil
153
153
 
154
154
  ActiveRecord::Base.transaction do
155
- # Lock the code row: uses_count accounting and the max_uses cap must
156
- # be race-safe (two concurrent redemptions of a max_uses: 1 code must
157
- # yield exactly one success).
158
- lock!
155
+ lock_rows_for_redemption!(user)
159
156
  ensure_redeemable!
160
157
 
161
158
  # Already a member — idempotent no-op that does NOT consume a use.
@@ -188,6 +185,18 @@ module Organizations
188
185
 
189
186
  private
190
187
 
188
+ # The gem's lock order (RowLocks): the redeeming user, then the
189
+ # organization row, then the code row. The deletion of the code's
190
+ # creator and an organization destroy take the organization row before
191
+ # the code row. The code row lock makes the uses_count accounting and
192
+ # the max_uses cap race-safe (two concurrent redemptions of a max_uses: 1
193
+ # code must yield exactly one success).
194
+ def lock_rows_for_redemption!(user)
195
+ RowLocks.lock_users!(user.id)
196
+ RowLocks.lock_organization!(organization_id)
197
+ lock!
198
+ end
199
+
191
200
  def ensure_redeemable!
192
201
  raise JoinCodeInvalid, Organizations.t(:"errors.join_code_invalid") if revoked? || expired?
193
202
  raise JoinCodeExhausted, Organizations.t(:"errors.join_code_exhausted") if exhausted?
@@ -180,7 +180,7 @@ module Organizations
180
180
  failure = nil
181
181
 
182
182
  ActiveRecord::Base.transaction do
183
- lock!
183
+ lock_rows_for_verification!
184
184
  ensure_open!
185
185
  ensure_active_challenge!
186
186
  # The domain or roster entry may have been removed, revoked or
@@ -319,11 +319,30 @@ module Organizations
319
319
 
320
320
  private
321
321
 
322
+ # The gem's lock order (RowLocks): the requester, then the organization
323
+ # row, then this request. An organization destroy deletes the domains
324
+ # and allowlist entries before this request, and the check locks one of
325
+ # them after this request. The automatic approval takes the requester's
326
+ # guard again: inside a host transaction, this one already holds it, so
327
+ # the approval does not ask for a user row while it holds the
328
+ # organization row.
329
+ def lock_rows_for_verification!
330
+ RowLocks.lock_users!(user_id)
331
+ RowLocks.lock_organization!(organization_id)
332
+ lock!
333
+ end
334
+
322
335
  def perform_approval!(decided_by:, recheck_instruments:)
323
336
  membership = nil
324
337
  reused_existing = false
325
338
 
326
339
  ActiveRecord::Base.transaction do
340
+ # The gem's lock order (RowLocks): the requester and the decider,
341
+ # then the organization row, then this request. The requester's
342
+ # deletion locks the requester, then the organization, and deletes
343
+ # this request.
344
+ RowLocks.lock_users!(user_id, decided_by&.id)
345
+ RowLocks.lock_organization!(organization_id)
327
346
  lock!
328
347
 
329
348
  return approved_membership! if approved? # idempotent re-approval
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # The one lock order of the gem: the user rows (in id order), then the
5
+ # organization row, then the rows of the organization. A user's deletion
6
+ # locks the user row, then the row of every organization in which it
7
+ # writes. An organization's destroy locks its row before its cascades. A
8
+ # path that writes in an organization takes the same order, or it can
9
+ # deadlock with them on PostgreSQL.
10
+ #
11
+ # The user rows are the ones that the path's writes reference: a row that
12
+ # names a user takes a key share lock on it when it is written, which
13
+ # waits for a deletion of that user. Lock them first, so that the wait
14
+ # comes before the organization row and not after it.
15
+ #
16
+ # The user guard is a key share lock (FOR KEY SHARE on PostgreSQL). It
17
+ # conflicts with the deletion's FOR UPDATE, which is its purpose, and with
18
+ # nothing else: an ordinary update of the user and the foreign key check
19
+ # of another writer pass. A FOR UPDATE guard would block those writers,
20
+ # and a writer that holds a row this path waits for would then close a
21
+ # cycle (a reject! that sets decided_by_id, a grant that names its
22
+ # granter).
23
+ #
24
+ # Inside a host transaction the order holds only when the host's earlier
25
+ # locks in that transaction keep it too.
26
+ #
27
+ # @api private
28
+ module RowLocks
29
+ module_function
30
+
31
+ # Take the key share guard on these users' rows, in id order. Nil ids
32
+ # and users that are gone are skipped.
33
+ # @param ids [Array<Object>]
34
+ def lock_users!(*ids)
35
+ ids = ids.flatten.compact.uniq
36
+ return if ids.empty?
37
+
38
+ user_class = Organizations.user_class
39
+ key = user_class.primary_key
40
+ user_class.unscoped.where(key => ids).order(key).lock(key_share_clause(user_class.connection)).pluck(key)
41
+ end
42
+
43
+ # PostgreSQL has a lock that only a delete or a key change conflicts
44
+ # with. MySQL has a shared lock. SQLite has no row locks: Rails drops
45
+ # the clause there.
46
+ def key_share_clause(connection)
47
+ adapter = connection.adapter_name.downcase
48
+ return "FOR KEY SHARE" if adapter.include?("postg")
49
+ return "LOCK IN SHARE MODE" if adapter.include?("mysql") || adapter.include?("trilogy")
50
+
51
+ true
52
+ end
53
+
54
+ # Lock the organization row. A destroyed organization is skipped: the
55
+ # caller's own row lock then finds its row gone.
56
+ # @param id [Object]
57
+ def lock_organization!(id)
58
+ Organizations::Organization.unscoped.where(id: id).lock.pick(:id)
59
+ end
60
+ end
61
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Organizations
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/organizations.rb CHANGED
@@ -112,6 +112,7 @@ module Organizations
112
112
  autoload :ActsAsTenantIntegration, "organizations/acts_as_tenant_integration"
113
113
  autoload :TestHelpers, "organizations/test_helpers"
114
114
  autoload :CurrentUserResolution, "organizations/current_user_resolution"
115
+ autoload :Current, "organizations/current"
115
116
  autoload :InvitationAcceptanceResult, "organizations/invitation_acceptance_result"
116
117
  autoload :InvitationAcceptanceFailure, "organizations/invitation_acceptance_failure"
117
118
  autoload :EmailNormalizer, "organizations/email_normalizer"
@@ -126,6 +127,7 @@ module Organizations
126
127
  autoload :GrantAccess, "organizations/grant_access"
127
128
  autoload :Grantable, "organizations/grantable"
128
129
  autoload :AccountDeletionPlan, "organizations/account_deletion_plan"
130
+ autoload :RowLocks, "organizations/row_locks"
129
131
 
130
132
  # Alias for README compatibility: `include Organizations::Controller`
131
133
  Controller = ControllerHelpers
@@ -212,6 +214,21 @@ module Organizations
212
214
  raise
213
215
  end
214
216
 
217
+ # Runs the block with `organization` as the current organization, then
218
+ # puts back whatever was current before, even if the block raises. For
219
+ # jobs, scripts and consoles, where no controller resolves one.
220
+ #
221
+ # @example
222
+ # Organizations.with_organization(invoice.organization) do
223
+ # InvoiceMailer.paid(invoice).deliver_now
224
+ # end
225
+ #
226
+ # @param organization [Organizations::Organization, nil]
227
+ # @return the block's value
228
+ def with_organization(organization, &)
229
+ Current.set(organization: organization, &)
230
+ end
231
+
215
232
  # Reset configuration to defaults
216
233
  # Primarily used in tests
217
234
  def reset_configuration!
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.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-24 00:00:00.000000000 Z
10
+ date: 2026-10-25 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: railties
@@ -155,6 +155,7 @@ files:
155
155
  - lib/organizations/callbacks.rb
156
156
  - lib/organizations/configuration.rb
157
157
  - lib/organizations/controller_helpers.rb
158
+ - lib/organizations/current.rb
158
159
  - lib/organizations/current_user_resolution.rb
159
160
  - lib/organizations/email_normalizer.rb
160
161
  - lib/organizations/engine.rb
@@ -180,6 +181,7 @@ files:
180
181
  - lib/organizations/public_email_domains.rb
181
182
  - lib/organizations/resource.rb
182
183
  - lib/organizations/roles.rb
184
+ - lib/organizations/row_locks.rb
183
185
  - lib/organizations/test_helpers.rb
184
186
  - lib/organizations/version.rb
185
187
  - lib/organizations/view_helpers.rb