organizations 0.8.1 → 0.9.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 +15 -1
- data/README.md +35 -0
- data/lib/organizations/controller_helpers.rb +14 -6
- data/lib/organizations/current.rb +32 -0
- data/lib/organizations/models/concerns/has_organizations.rb +13 -4
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations.rb +16 -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: bd2f1f7295aa0ba982150d0f5917b5ad002b9115fabd0bb4b645e8c5d5c68f15
|
|
4
|
+
data.tar.gz: 6821a5a24efe57ef744118ea038f75fc04c703e5528bb3170ffa81536738a3f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 376ad9c58f8888e9120f9232836b633ce847e1fb900dca2f1968b6052881eaa05039c3e606ad1a346e37ec5823b7c77dd2d70a5fe68f653de7c89b11b27e232e
|
|
7
|
+
data.tar.gz: 451933a3d39140138e7690c071c9d9becfa6b57fd79aded5bc6ec8eae88f4b7d37b3e718d10afd9709e68ed3ffb8a4037d313807f92f3f98d92f263b29132db1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
|
-
## [
|
|
1
|
+
## [0.9.1] - 2026-11-09
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
|
|
5
|
+
- `max_organizations` holds under concurrency. `create_organization!` counted the user's owned organizations before its transaction, so two creates at the same moment could both count below the limit and both insert, and a host that sells organization slots gave away an extra one. When the limit is set, the count now runs inside the transaction under a lock on the user's row (the gem's lock order: the user row first), so the second create waits and then raises `OrganizationLimitReached`. That count skips the query cache, so a count read earlier in the same request cannot stand in for it. Without a limit nothing changes and nothing is locked.
|
|
6
|
+
|
|
7
|
+
## [0.9.0] - 2026-10-25
|
|
8
|
+
|
|
9
|
+
**The current organization, outside controllers.** One addition, opt-in to read. No migration is needed.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`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.
|
|
14
|
+
- **`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.
|
|
15
|
+
- 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.
|
|
2
16
|
|
|
3
17
|
## [0.8.1] - 2026-10-25
|
|
4
18
|
|
data/README.md
CHANGED
|
@@ -2033,6 +2033,41 @@ Organizations.configure do |config|
|
|
|
2033
2033
|
end
|
|
2034
2034
|
```
|
|
2035
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
|
+
|
|
2036
2071
|
### Integrates with acts_as_tenant
|
|
2037
2072
|
|
|
2038
2073
|
For automatic query scoping, include the integration concern:
|
|
@@ -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
|
-
|
|
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
|
-
|
|
71
|
+
self.resolved_current_organization = fallback_org
|
|
72
72
|
else
|
|
73
|
-
|
|
73
|
+
self.resolved_current_organization = nil
|
|
74
74
|
end
|
|
75
75
|
end
|
|
76
76
|
end
|
|
@@ -459,7 +459,7 @@ module Organizations
|
|
|
459
459
|
|
|
460
460
|
if org
|
|
461
461
|
session[session_key] = org.id
|
|
462
|
-
|
|
462
|
+
self.resolved_current_organization = org
|
|
463
463
|
forget_current_membership!
|
|
464
464
|
|
|
465
465
|
# Update user's context
|
|
@@ -578,13 +578,21 @@ module Organizations
|
|
|
578
578
|
def clear_organization_session!
|
|
579
579
|
session_key = Organizations.configuration.session_key
|
|
580
580
|
session.delete(session_key)
|
|
581
|
-
|
|
581
|
+
self.resolved_current_organization = nil
|
|
582
582
|
forget_current_membership!
|
|
583
583
|
|
|
584
584
|
user = organizations_current_user
|
|
585
585
|
user&.clear_organization_cache!
|
|
586
586
|
end
|
|
587
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
|
+
|
|
588
596
|
# Drop the memoized membership so the next current_membership call reads
|
|
589
597
|
# it again for the organization that is current by then. Assigning nil
|
|
590
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
|
|
@@ -488,15 +488,12 @@ module Organizations
|
|
|
488
488
|
attributes = name_or_attributes.is_a?(Hash) ? name_or_attributes : { name: name_or_attributes }
|
|
489
489
|
Organizations::Organization.ensure_personal_owner_is_owner!(attributes, self)
|
|
490
490
|
|
|
491
|
-
# Check max organizations limit
|
|
492
491
|
settings = self.class.organization_settings
|
|
493
492
|
max = settings[:max_organizations]
|
|
494
|
-
if max && owned_organizations.count >= max
|
|
495
|
-
raise OrganizationLimitReached, Organizations.t(:"errors.organization_limit_reached", max: max)
|
|
496
|
-
end
|
|
497
493
|
|
|
498
494
|
org = nil
|
|
499
495
|
ActiveRecord::Base.transaction do
|
|
496
|
+
ensure_below_organization_limit!(max) if max
|
|
500
497
|
org = Organizations::Organization.create!(attributes)
|
|
501
498
|
|
|
502
499
|
Organizations::Membership.create!(
|
|
@@ -757,6 +754,18 @@ module Organizations
|
|
|
757
754
|
|
|
758
755
|
private
|
|
759
756
|
|
|
757
|
+
# The limit is counted under a lock on this user's row, so two
|
|
758
|
+
# concurrent creates cannot both count below it and both insert.
|
|
759
|
+
# A user row first keeps the gem's lock order (RowLocks). Uncached:
|
|
760
|
+
# a count read earlier in the request must not stand in for the one
|
|
761
|
+
# taken under the lock.
|
|
762
|
+
def ensure_below_organization_limit!(max)
|
|
763
|
+
self.class.unscoped.where(self.class.primary_key => id).lock.pick(self.class.primary_key)
|
|
764
|
+
return if ActiveRecord::Base.uncached { owned_organizations.count } < max
|
|
765
|
+
|
|
766
|
+
raise OrganizationLimitReached, Organizations.t(:"errors.organization_limit_reached", max: max)
|
|
767
|
+
end
|
|
768
|
+
|
|
760
769
|
def create_personal_organization_if_configured
|
|
761
770
|
# Check condition inside callback (not via `if:` option) to ensure
|
|
762
771
|
# proper evaluation of instance state, including singleton methods
|
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"
|
|
@@ -213,6 +214,21 @@ module Organizations
|
|
|
213
214
|
raise
|
|
214
215
|
end
|
|
215
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
|
+
|
|
216
232
|
# Reset configuration to defaults
|
|
217
233
|
# Primarily used in tests
|
|
218
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.
|
|
4
|
+
version: 0.9.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-11-09 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
|