🏢 organizations – Add organizations with members and invitations to your Rails SaaS
Tip
🚀 Ship your next Rails app 10x faster! I've built RailsFast, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go check it out!
organizations adds organizations with members to any Rails app. It handles team invites, user memberships, roles, and permissions.
[TODO: invitation / member management gif]
It's everything you need to turn a User-based app into a multi-tenant, Organization-based B2B SaaS (users belong in organizations, and organizations share resources and billing, etc.)
It's super easy:
class User < ApplicationRecord
has_organizations
endThat's it. Your users can now create organizations, invite teammates, and jump between accounts:
current_user.create_organization!("Acme Corp")
current_user.send_organization_invite_to!("teammate@acme.com")Then you could switch to the new org like this:
switch_to_organization!(@org)And check your roles / permissions in relation to that organization like this:
current_user.is_organization_owner? # => true
current_user.is_organization_admin? # => true (owners inherit admin permissions)Installation
Add to your Gemfile:
gem "organizations"Note
For beautiful invitation emails, optionally add goodmail.
Then:
bundle install
rails g organizations:install
rails db:migrateAdd has_organizations to your User model:
class User < ApplicationRecord
has_organizations
endThat's the simplest setup. You can also configure per-model options:
class User < ApplicationRecord
has_organizations do
max_organizations 5 # Limit how many orgs a user can own (nil = unlimited)
create_personal_org true # Auto-create org on signup (default: false)
require_organization true # Require users to have at least one org (default: false)
end
endNote: By default, users can exist without any organization (invite-to-join flow). Set
create_personal_org trueif you want to auto-create a personal organization when users sign up.
Mount the engine in your routes:
# config/routes.rb
mount Organizations::Engine => '/'Done. Your app now has full organizations / teams support.
Important
Bring Your Own UI (BYOU): This gem provides all the building blocks — models, controllers, routes, helpers, and mailers — but intentionally does not ship with views. Views are too context-dependent (Tailwind vs Bootstrap, dark mode, your app's design system) to be one-size-fits-all. You'll need to create your own views in app/views/organizations/. For a complete working example, check out the demo app in test/dummy.
Note
This gem uses the term "organization", but the concept is the same as "team", "workspace", or "account". It's essentially just an umbrella under which users / members are organized. This gem works for all those use cases, in the same way. Just use whichever term fits your product best in your UI.
Quick start
Create an organization
org = current_user.create_organization!("Acme Corp")
# User automatically becomes the ownerInvite teammates
current_user.send_organization_invite_to!("teammate@example.com")
# Sends invitation email: "John invited you to join Acme Corp"
# When accepted, user joins as :member (default role)The invitation goes from user to user. The organization is inferred from current_organization. You can also be explicit:
current_user.send_organization_invite_to!("teammate@example.com", organization: other_org)Check roles and permissions
# Quick role checks (in current organization)
current_user.is_organization_owner? # => true/false
current_user.is_organization_admin? # => true/false
# Permission checks
current_user.has_organization_permission_to?(:invite_members)
# "Does current user have organization permission to invite members?"
# Check role in a specific org
current_user.is_admin_of?(@org)
# "Is current user an admin of this org?"
current_user.is_member_of?(@org)
# "Is current user a member of this org?"Switch between organizations
# User belongs to multiple organizations? No problem.
current_user.organizations # => [acme, startup_co, personal]
switch_to_organization!(startup_co) # Changes active org in sessionProtect controllers
class ProjectsController < ApplicationController
before_action :require_organization!
before_action :require_organization_admin!, only: [:create, :destroy]
endLimit seats per plan (with pricing_plans)
Note: This is an integration pattern, not built-in functionality. You implement the limit checks in your callbacks.
If you're using pricing_plans, you can limit how many members an organization can have based on their subscription using callbacks:
# config/initializers/pricing_plans.rb
plan :hobby do
limits :organization_members, to: 3
end
plan :growth do
limits :organization_members, to: 25
endThen hook into the on_member_invited callback to enforce limits. This callback runs BEFORE the invitation is persisted, so raising an error will block the invitation:
# config/initializers/organizations.rb
Organizations.configure do |config|
config.on_member_invited do |ctx|
org = ctx.organization
limit = org.current_plan.limit_for(:organization_members)
if limit && org.member_count >= limit
raise Organizations::InvitationError, "Member limit reached. Please upgrade your plan."
end
end
endThe on_member_invited callback is special — it runs in strict mode, meaning:
- It executes before the invitation is saved to the database
- Raising any error will veto the invitation (it won't be created)
- The error message is returned to the user
This pattern gives you full control over how and when limits are enforced.
Why this gem exists
Organizations / teams are tough to do alone. Wiring up accounts, roles, and invites by hand is a pain you only want to go through once. If you don't implement organizations / teams on day one, adding them later becomes a major refactor — the kind that touches every model, controller, and permission in your app. Even experienced Rails developers have built accounts / teams poorly multiple times before getting it right.
No more asking yourself "should I just roll my own?" No more stitching together acts_as_tenant + rolify + devise_invitable + pundit and writing 500 lines of glue code. No more paying $250/year for a boilerplate template just because it has organizations / teams built in. The organizations gem gives you everything in a single bundle add.
Every B2B Rails app eventually needs organizations / teams. Yet there's no standalone gem that allows you to just flip a switch and add organizations to your app.
| What you need | What exists today |
|---|---|
| Organization model | ✅ Easy, just scaffold it |
| Membership (User ↔ Organization join table) | ❌ Write it yourself |
| Invite users to a specific organization | ❌ devise_invitable invites to the app, not to an org |
| Roles scoped to each organization | ❌ rolify stores roles globally, not per-org |
| Let users jump between organizations | ❌ Write it yourself |
| All of the above, integrated | ❌ Pay $250+ for a boilerplate |
The day will come when you need to associate your users in organizations — and it will be the refactor from your worst nightmares. Rails does not make your life easy when you want to work this way, with multiple tenants. The typical Rails developer stitches together acts_as_tenant + rolify + devise_invitable + pundit and writes 500-1,000 lines of glue code that feels brittle compared to the usual simplicity of Rails. That takes 1-2 months. Some developers have estimated 200+ hours of work. Or you pay $250+/year for a boilerplate template where organizations / teams is the headline feature.
Laravel has Jetstream with a --teams flag. Django has django-organizations. Rails has had nothing — until now.
| Framework | Organizations / Teams Solution | Cost |
|---|---|---|
| Laravel | Jetstream --teams
|
Free |
| Django | django-organizations | Free |
| Rails |
organizations gem |
Free |
organizations gives you the complete User → Membership → Organization pattern with scoped invitations, hierarchical roles, and the ability to switch between organizations – all in a single, well-tested gem that works with your existing Devise setup. What previously took 1.5 months now takes 3 days.
![NOTE] This gem handles organization membership and org-level permissions (who can invite members, who can manage billing). For per-record authorization ("can user X edit document Y"), use Pundit or CanCanCan alongside this gem.
The complete API
User methods
When you add has_organizations to your User model, you get:
# Associations
user.organizations # All organizations user belongs to
user.memberships # All memberships (with roles)
user.owned_organizations # Organizations where user is owner
user.pending_organization_invitations # Invitations waiting to be accepted
# Current organization context
user.organization # Alias for current_organization (most common use)
user.current_organization # Active org for this session
user.current_membership # Membership in active org
user.current_organization_role # Role in current org => :admin
# Quick boolean checks
user.belongs_to_any_organization? # "Does user belong to any org?"
user.has_pending_organization_invitations? # "Does user have pending invites?"
# Permission checks (in current organization)
user.has_organization_permission_to?(:invite_members) # => true/false
user.has_organization_role?(:admin) # => true/false
# Role shortcuts (in current organization)
user.is_organization_owner? # Same as has_organization_role?(:owner)
user.is_organization_admin? # Same as has_organization_role?(:admin)
user.is_organization_member? # Same as has_organization_role?(:member)
user.is_organization_viewer? # Same as has_organization_role?(:viewer)
# Role checks (explicit organization)
user.is_owner_of?(org) # "Is user an owner of this org?"
user.is_admin_of?(org) # "Is user an admin of this org?"
user.is_member_of?(org) # "Is user a member of this org?"
user.is_viewer_of?(org) # "Is user a viewer of this org?"
user.is_at_least?(:admin, in: org) # "Is user at least an admin in this org?"
user.role_in(org) # => :admin
# Actions
user.create_organization!("Acme") # Positional arg
user.create_organization!(name: "Acme") # Keyword arg (both work)
user.leave_organization!(org)
user.leave_current_organization! # Leave the active org
user.send_organization_invite_to!(email) # Invite to current org
user.send_organization_invite_to!(email, organization: org) # Invite to specific orgOrganization methods
# Associations
org.memberships # All memberships
org.members # All users (alias for org.users)
org.users # All users (through memberships)
org.invitations # All invitations (pending + accepted)
org.pending_invitations # Invitations not yet accepted
# Queries
org.owner # User who owns this org
org.admins # Users with admin role or higher
org.has_member?(user) # "Does org have this user as a member?"
org.has_any_members? # "Does org have any members?"
org.member_count # Number of members
# Class methods / Scopes
Organizations::Organization.with_member(user) # Find all orgs where user is a member
# Actions
org.add_member!(user, role: :member)
org.remove_member!(user)
org.change_role_of!(user, to: :admin)
org.transfer_ownership_to!(other_user)
# Invitations (inviter must be a member with :invite_members permission)
org.send_invite_to!(email) # Auto-infers invited_by from Current.user
org.send_invite_to!(email, invited_by: user) # Explicit inviter
# Scopes
org.memberships.owners # Memberships with owner role
org.memberships.admins # Memberships with admin role
org.invitations.pending # Not yet accepted
org.invitations.expired # Past expiration dateMembership methods
membership.role # => "admin"
membership.organization # The organization
membership.user # The user
membership.invited_by # User who invited them (if any)
# Permission checks
membership.has_permission_to?(:invite_members) # => true/false
membership.permissions # => [:view_members, :invite_members, ...]
# Role hierarchy checks
membership.is_at_least?(:member) # => true (if member, admin, or owner)
# Role changes
membership.promote_to!(:admin) # Change role to admin
membership.demote_to!(:member) # Change role to memberInvitation methods
invitation.email # => "teammate@example.com"
invitation.organization # The organization
invitation.role # Role they'll have when accepted
invitation.invited_by # User who sent the invitation
invitation.from # Alias for invited_by
invitation.pending? # => true (not yet accepted)
invitation.accepted? # => true (has accepted_at)
invitation.expired? # => true (past expires_at)
# Actions
invitation.accept! # Accept (auto-infers Current.user)
invitation.accept!(user) # Accept with explicit user
invitation.resend! # Send invitation email againController helpers
Include the controller concern in your ApplicationController:
class ApplicationController < ActionController::Base
include Organizations::Controller
endThis gives you:
# Context helpers
current_organization # Active organization (from session)
current_membership # Current user's membership in active org
organization_signed_in? # Is there an active organization?
# Authorization
require_organization! # Redirect if no active org
require_organization_role!(:admin) # Require at least admin role
require_organization_permission_to!(:invite_members) # Require specific permission
# Authorization shortcuts (for common roles)
require_organization_owner! # Same as require_organization_role!(:owner)
require_organization_admin! # Same as require_organization_role!(:admin)
# Switching
switch_to_organization!(org) # Change active org in sessionProtecting resources
class SettingsController < ApplicationController
before_action :require_organization!
before_action :require_organization_admin! # Shortcut for require_organization_role!(:admin)
def billing
require_organization_owner! # Only owners can manage billing
end
endHandling unauthorized access
Configure how unauthorized access is handled:
# config/initializers/organizations.rb
Organizations.configure do |config|
config.on_unauthorized do |context|
# context.user, context.organization, context.permission, context.required_role
redirect_to root_path, alert: "You don't have permission to do that."
end
config.on_no_organization do |context|
redirect_to new_organization_path, alert: "Please create or join an organization first."
end
endView helpers
Include in your ApplicationHelper:
module ApplicationHelper
include Organizations::ViewHelpers
endPermission checks in views
<% if current_user.has_organization_permission_to?(:invite_members) %>
<%= link_to "Invite teammate", new_invitation_path %>
<% end %>
<% if current_user.is_organization_admin? %>
<%= link_to "Settings", organization_settings_path %>
<% end %>
Organization switcher
Build your own switcher UI with the helper:
<% data = organization_switcher_data %>
<div class="org-switcher">
<button><%= data[:current][:name] %></button>
<ul>
<% data[:others].each do |org| %>
<li>
<%= link_to org[:name], data[:switch_path].call(org[:id]) %>
</li>
<% end %>
</ul>
</div>The helper returns:
{
current: { id: "...", name: "Acme Corp" },
others: [
{ id: "...", name: "Personal" },
{ id: "...", name: "StartupCo" }
],
switch_path: ->(org_id) { "/organizations/switch/#{org_id}" }
}Invitation badge
# Show pending invitation count in your navbar
<%= organization_invitation_badge(current_user) %>
# => <span class="badge">3</span> (if 3 pending invitations)
# => nil (if no pending invitations)Roles and permissions
Organization permissions vs. resource authorization
organizations handles org-level permissions — what a user can do within an organization:
current_user.has_organization_permission_to?(:invite_members)
# "Can they invite people to this org?"
current_user.has_organization_permission_to?(:manage_billing)
# "Can they manage this org's billing?"
require_organization_permission_to!(:manage_settings)
# Gate org settings pagesThis is different from resource authorization (Pundit, CanCanCan) — what a user can do to a specific record:
# Pundit/CanCanCan territory (not what this gem does)
policy(@document).update? # "Can they edit THIS specific document?"
authorize! :destroy, @project # "Can they delete THIS specific project?"
organizations gem |
Pundit / CanCanCan | |
|---|---|---|
| Question | "What can this user do in this org?" | "Can this user do X to record Y?" |
| Scope | Organization-wide capabilities | Per-record authorization |
| Based on | Role in Membership | Policy classes / Ability rules |
| Example | "Admins can invite members" | "Users can edit their own posts" |
Most B2B apps need both. Use organizations for org membership and capabilities. Use Pundit/CanCanCan for fine-grained resource authorization. They're complementary, not competing.
Built-in roles
organizations ships with four hierarchical roles:
owner > admin > member > viewer
Each role inherits all permissions from roles below it.
Default permissions
| Permission | viewer | member | admin | owner |
|---|---|---|---|---|
view_organization |
✅ | ✅ | ✅ | ✅ |
view_members |
✅ | ✅ | ✅ | ✅ |
create_resources |
✅ | ✅ | ✅ | |
edit_own_resources |
✅ | ✅ | ✅ | |
delete_own_resources |
✅ | ✅ | ✅ | |
invite_members |
✅ | ✅ | ||
remove_members |
✅ | ✅ | ||
edit_member_roles |
✅ | ✅ | ||
manage_settings |
✅ | ✅ | ||
view_billing |
✅ | ✅ | ||
manage_billing |
✅ | |||
transfer_ownership |
✅ | |||
delete_organization |
✅ |
Customize roles and permissions
Define your own roles in the initializer:
# config/initializers/organizations.rb
Organizations.configure do |config|
config.roles do
role :viewer do
can :view_organization
can :view_members
end
role :member, inherits: :viewer do
can :create_resources
can :edit_own_resources
can :delete_own_resources
end
role :admin, inherits: :member do
can :invite_members
can :remove_members
can :edit_member_roles
can :manage_settings
end
role :owner, inherits: :admin do
can :manage_billing
can :transfer_ownership
can :delete_organization
end
end
endAdd custom permissions
role :admin, inherits: :member do
can :invite_members
can :remove_members
can :manage_api_keys # Your custom permission
can :export_data # Your custom permission
endThen check them anywhere:
current_user.has_organization_permission_to?(:manage_api_keys)
require_organization_permission_to!(:export_data)Invitations
Sending invitations
Invitations are user-to-user. The inviter is always explicit, and the email reads "John invited you to join Acme Corp".
# Invite to your current organization (most common)
current_user.send_organization_invite_to!("teammate@example.com")
# Invite to a specific organization
current_user.send_organization_invite_to!("teammate@example.com", organization: other_org)
# All invitees join as :member by default. Admins can promote after joining.There's also an organization-centric API if you prefer:
org.send_invite_to!("teammate@example.com", invited_by: current_user)Note: Both APIs enforce authorization. The inviter must be a member of the organization with the
:invite_memberspermission. If not,Organizations::NotAMemberorOrganizations::NotAuthorizedis raised.
Invitation flow
The gem handles both existing users and new signups with a single invitation link:
For existing users:
- Invitation created → Email sent with unique link
- User clicks link → Sees invitation details (org name, inviter, role)
- User clicks "Accept" → Membership created, redirected to org
For new users:
- Invitation created → Email sent with unique link
- User clicks link → Sees invitation details + "Sign up to accept" button
- User registers → Token stored in session, your app calls
invitation.accept!(user)post-signup
The gem stores the invitation token in session[:pending_invitation_token] when an unauthenticated user tries to accept. In your registration callback, check for this token and accept the invitation:
# In your User model or registration controller
after_create :accept_pending_invitation
def accept_pending_invitation
token = session.delete(:pending_invitation_token)
return unless token
invitation = Organizations::Invitation.find_by(token: token)
invitation&.accept!(self, skip_email_validation: true)
endInvitation emails
The gem ships with a clean ActionMailer-based invitation email.
# Customize the mailer in config
Organizations.configure do |config|
config.invitation_mailer = "Organizations::InvitationMailer" # Default
# Or use your own: config.invitation_mailer = "CustomInvitationMailer"
endInvitation expiration
Invitations expire after 7 days by default:
Organizations.configure do |config|
config.invitation_expiry = 7.days # Default
# config.invitation_expiry = 30.days
# config.invitation_expiry = nil # Never expire
endExpired invitations can be resent:
invitation.expired? # => true
invitation.resend! # Generates new token, resets expiry, sends emailAccepted invitations
Accepted invitations are kept for audit purposes:
org.invitations.accepted # Who was invited and when
invitation.accepted_at # When they joined
invitation.invited_by # Who sent the invitationOrganization switching
Users can belong to multiple organizations. The "current" organization is stored in session, and all your queries scope to it automatically.
How it works
- User logs in →
current_organizationset to their most recently used org - User switches org → Session updated,
current_organizationchanges - User is removed from current org → Auto-switches to next available org
Manual switching
# In a controller
def switch
org = current_user.organizations.find(params[:id])
switch_to_organization!(org)
redirect_to dashboard_path
endRoutes provided by the engine
When you mount the engine, you get:
POST /organizations/switch/:id → Organizations::SwitchController#create
GET /invitations/:token → Organizations::InvitationsController#show
POST /invitations/:token/accept → Organizations::InvitationsController#accept
Auto-created organizations
By default, users do not get an auto-created organization on signup (invite-to-join flow). You can enable this if you want:
# When always_create_personal_organization_for_each_user is enabled:
# 1. Organization created with name from config
# 2. User becomes owner of that organization
# 3. current_organization set to this new orgEnable auto-creation
Organizations.configure do |config|
# Enable auto-creation (disabled by default)
config.always_create_personal_organization_for_each_user = true
# Customize the name
config.default_organization_name = ->(user) { "#{user.email.split('@').first}'s Workspace" }
# Default: "Personal"
endBy default (always_create_personal_organization_for_each_user = false), users must explicitly create or be invited to an organization.
Users without organizations (default behavior)
By default, users can exist without any organization (invite-to-join flow):
- User signs up → verifies email
- User is in "limbo" (no organization yet)
- User creates org OR accepts invitation
- User now has an organization
This is the default behavior. If you want to auto-create a personal organization on signup, configure your User model:
class User < ApplicationRecord
has_organizations do
create_personal_org true # Auto-create org on signup
require_organization true # Require users to always have an org
end
endWhen a user has no organization:
current_user.organization # => nil
current_user.current_organization # => nil
current_user.belongs_to_any_organization? # => false
current_user.is_organization_admin? # => false (no org context)
# In controllers
current_organization # => nil
organization_signed_in? # => false
require_organization! # Redirects to on_no_organization handlerHandle the limbo state in your views:
<% if current_user.belongs_to_any_organization? %>
<%= render "dashboard" %>
<% else %>
<%= render "onboarding/create_or_join_organization" %>
<% end %>Configure where to redirect users without an organization:
Organizations.configure do |config|
config.on_no_organization do |context|
redirect_to new_organization_path, notice: "Create or join an organization to continue."
end
endConfiguration
Full configuration options:
# config/initializers/organizations.rb
Organizations.configure do |config|
# === Authentication ===
# Method that returns the current user (default: :current_user)
config.current_user_method = :current_user
# Method that ensures user is authenticated (default: :authenticate_user!)
config.authenticate_user_method = :authenticate_user!
# === Auto-creation ===
# Create personal organization on user signup (default: false)
config.always_create_personal_organization_for_each_user = false
# Name for auto-created organizations
config.default_organization_name = ->(user) { "Personal" }
# === Invitations ===
# How long invitations are valid
config.invitation_expiry = 7.days
# Custom mailer for invitations
config.invitation_mailer = "Organizations::InvitationMailer"
# === Limits ===
# Maximum organizations a user can own (nil = unlimited)
config.max_organizations_per_user = nil
# === Onboarding ===
# Require users to belong to at least one organization
# Set to true if users should always have an organization
config.always_require_users_to_belong_to_one_organization = false # Default
# === Redirects ===
# Where to redirect when user has no organization
config.redirect_path_when_no_organization = "/organizations/new"
# === Handlers ===
# Called when authorization fails
config.on_unauthorized do |context|
redirect_to root_path, alert: "Not authorized"
end
# Called when no organization is set
config.on_no_organization do |context|
redirect_to config.redirect_path_when_no_organization
end
# === Roles & Permissions ===
config.roles do
# ... (see Roles and Permissions section)
end
# === Callbacks ===
config.on_organization_created do |ctx|
# ctx.organization, ctx.user (owner)
end
config.on_member_invited do |ctx|
# ctx.organization, ctx.invitation, ctx.invited_by
end
config.on_member_joined do |ctx|
# ctx.organization, ctx.membership, ctx.user
end
config.on_member_removed do |ctx|
# ctx.organization, ctx.membership, ctx.user, ctx.removed_by
end
config.on_role_changed do |ctx|
# ctx.organization, ctx.membership, ctx.old_role, ctx.new_role, ctx.changed_by
end
config.on_ownership_transferred do |ctx|
# ctx.organization, ctx.old_owner, ctx.new_owner
end
endIntegrations
Works with Devise out of the box
organizations is built for Devise. It uses current_user and authenticate_user! by default. Just add has_organizations and you're done.
Works with other auth systems
Using Rodauth, Sorcery, or custom auth? Configure the methods:
Organizations.configure do |config|
config.current_user_method = :current_account
config.authenticate_user_method = :require_login
endIntegrates with acts_as_tenant
For automatic query scoping, include the integration concern:
class ApplicationController < ActionController::Base
include Organizations::Controller
include Organizations::ActsAsTenantIntegration
# Automatically calls: set_current_tenant(current_organization)
endIntegrates with pricing_plans
Enforce member limits based on pricing plans using callbacks:
# In your Organization model
class Organization < ApplicationRecord
include PricingPlans::PlanOwner
end
# In config/initializers/pricing_plans.rb
plan :starter do
limits :members, to: 5
end
plan :pro do
limits :members, to: 50
endThen hook into callbacks to enforce limits (see "Limit seats per plan" section above for full example).
Integrates with your gem ecosystem
organizations is designed to work with rameerez's gem ecosystem:
# Organization owns API keys (api_keys gem)
class Organization < ApplicationRecord
has_api_keys do
max_keys 10
end
end
# Organization has credits (usage_credits gem)
class Organization < ApplicationRecord
has_credits
end
# Organization has pricing plan (pricing_plans gem)
class Organization < ApplicationRecord
include PricingPlans::PlanOwner
endAll scoped through current_organization:
current_organization.api_keys
current_organization.credits
current_organization.current_pricing_plan
current_organization.memberships # From organizations gemCallbacks
Hook into organization lifecycle events:
Organizations.configure do |config|
config.on_organization_created do |ctx|
SlackNotifier.notify("New org: #{ctx.organization.name}")
Analytics.track(ctx.user, "organization_created")
end
config.on_member_joined do |ctx|
WelcomeMailer.send_team_welcome(ctx.user, ctx.organization).deliver_later
Analytics.track(ctx.user, "joined_organization", org: ctx.organization.name)
end
config.on_member_removed do |ctx|
AuditLog.record(
action: :member_removed,
organization: ctx.organization,
user: ctx.user,
actor: ctx.removed_by
)
end
endAvailable callbacks
| Callback | Context fields | Mode |
|---|---|---|
on_organization_created |
organization, user
|
After |
on_member_invited |
organization, invitation, invited_by
|
Before (strict) |
on_member_joined |
organization, membership, user
|
After |
on_member_removed |
organization, membership, user, removed_by
|
After |
on_role_changed |
organization, membership, old_role, new_role, changed_by
|
After |
on_ownership_transferred |
organization, old_owner, new_owner
|
After |
Callback modes:
- After: Runs after the action completes. Errors are logged but don't block the operation. Use for notifications, analytics, and audit logs.
- Before (strict): Runs before the action. Raising an error vetoes the operation. Use for validation and policy enforcement (e.g., seat limits).
Testing
Test helpers
# test/test_helper.rb
require "organizations/test_helpers"
class ActiveSupport::TestCase
include Organizations::TestHelpers
endFixtures
The gem works with Rails fixtures:
# test/fixtures/organizations.yml
acme:
name: Acme Corp
# test/fixtures/memberships.yml
john_at_acme:
user: john
organization: acme
role: adminTest helpers
# Set organization context in tests
sign_in_as_organization_member(user, org, role: :admin)
set_current_organization(org)
# Or manually
sign_in user
switch_to_organization!(org)Minitest assertions
assert user.is_member_of?(org)
assert user.is_owner_of?(org)
assert user.is_organization_admin?
assert user.has_organization_permission_to?(:invite_members)
assert user.belongs_to_any_organization?Extending the Organization model
The gem provides Organizations::Organization as the base model. You can extend it with your app's specific fields by adding migrations and reopening the class:
# db/migrate/xxx_add_custom_fields_to_organizations.rb
class AddCustomFieldsToOrganizations < ActiveRecord::Migration[8.0]
def change
add_column :organizations, :support_email, :string
add_column :organizations, :billing_address, :text
add_column :organizations, :settings, :jsonb, default: {}
end
end# config/initializers/organization_extensions.rb
# Or: app/models/concerns/organization_extensions.rb (then include in initializer)
Organizations::Organization.class_eval do
# Add your own associations
has_many :projects
has_many :documents
# Add your own validations
validates :support_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
# Add your own methods
def active_projects
projects.where(archived: false)
end
endAlternatively, create your own model that inherits from the gem's model:
# app/models/organization.rb
class Organization < Organizations::Organization
has_many :projects
has_many :documents
validates :support_email, presence: true
endNote: If you create your own
Organizationclass, be aware that internal gem code usesOrganizations::Organization. Your subclass will work for your app code, but associations fromUser#organizationswill returnOrganizations::Organizationinstances.
This is standard Rails practice — the gem provides the foundation (memberships, invitations, roles), your app extends it with domain-specific features.
Database schema
The gem creates three tables:
organizations
organizations
- id (primary key, auto-detects UUID or integer from your app)
- name (string, required)
- metadata (jsonb, default: {})
- created_at
- updated_atNote: The gem automatically detects your app's primary key type (UUID or integer) and uses it for all tables.
memberships
memberships
- id (primary key)
- user_id (foreign key, indexed)
- organization_id (foreign key, indexed)
- role (string, default: 'member')
- invited_by_id (foreign key, nullable)
- created_at
- updated_at
unique index: [user_id, organization_id]organization_invitations
organization_invitations
- id (primary key)
- organization_id (foreign key, indexed)
- email (string, required, indexed)
- role (string, default: 'member')
- token (string, unique, indexed)
- invited_by_id (foreign key, nullable)
- accepted_at (datetime, nullable)
- expires_at (datetime)
- created_at
- updated_at
unique index: [organization_id, email] where accepted_at is nullOwnership rules
- Every organization has exactly one owner
- Owner cannot leave the organization (must transfer ownership first)
- Ownership can be transferred to any admin:
org.transfer_ownership_to!(other_admin) - When ownership is transferred, old owner becomes admin
Edge cases handled
| Scenario | Behavior |
|---|---|
| User removed from current org | Auto-switches to next available org |
| User has no organizations | Redirects to configurable path (or allowed if always_require_users_to_belong_to_one_organization: false) |
| User signs up, no org yet |
current_organization returns nil, belongs_to_any_organization? returns false
|
| Last owner tries to leave | Raises CannotLeaveAsLastOwner, must transfer ownership first |
| Two admins leave simultaneously | Row-level lock prevents both from leaving if one would be last |
| Invitation accepted twice (race condition) | Row-level lock, second request returns existing membership |
| Two admins invite same email | Unique constraint, second returns existing invitation |
| Invitation for existing member | Returns error, doesn't duplicate |
| Expired invitation resent | New token generated, expiry reset |
| Ownership transfer to removed user | Transaction lock, verifies membership exists before transfer |
| Concurrent role changes on same user | Row-level lock on membership row |
| Session points to org user was removed from |
current_organization verifies membership, clears stale session |
| Token collision on invitation | Unique constraint, regenerates token |
Performance notes
The gem is designed to avoid N+1 queries when used correctly. Here's what you need to know.
Eager loading for listings
When iterating over memberships or invitations, use includes to avoid N+1:
# Listing members — GOOD
org.memberships.includes(:user).each do |membership|
membership.user.name # No N+1
end
# Listing members — BAD (N+1 on user)
org.memberships.each do |membership|
membership.user.name # Queries DB for each user
end
# Listing invitations — GOOD
org.invitations.includes(:invited_by).each do |invitation|
invitation.invited_by.name # No N+1
endPermission checks are in-memory
Permission checks never hit the database. They read the role from the already-loaded membership and check against a pre-computed permission hash:
# This does NOT query the DB
user.has_organization_permission_to?(:invite_members)
# Safe to call in loops
org.memberships.includes(:user).each do |m|
m.has_permission_to?(:invite_members) # No DB query, just hash lookup
endRole checks with explicit org
When checking roles against a specific organization, the gem is smart about reusing loaded data:
# If memberships are already loaded, this won't query again
user.organizations.includes(:memberships).each do |org|
user.is_admin_of?(org) # Reuses loaded membership
end
# But if you call it in isolation, it queries the DB
user.is_admin_of?(some_org) # Single query to find membershipOrganization switcher optimization
The organization_switcher_data helper is optimized for navbar use:
# Internally, it:
# 1. Selects only id, name (not full objects)
# 2. Memoizes within the request
# 3. Returns a lightweight hash, not ActiveRecord objects
organization_switcher_data
# => { current: { id: "...", name: "Acme" }, others: [...] }Counter caches for member counts
If you display member counts frequently (pricing pages, org listings), consider adding a counter cache:
# In a migration
add_column :organizations, :memberships_count, :integer, default: 0, null: false
# Reset existing counts
Organization.find_each do |org|
Organization.reset_counters(org.id, :memberships)
endThe gem automatically uses the counter cache if present:
org.member_count
# Uses memberships_count column if it exists
# Falls back to COUNT(*) query otherwiseExistence checks use SQL
Boolean checks use efficient SQL EXISTS queries:
user.belongs_to_any_organization? # SELECT 1 FROM memberships WHERE ... LIMIT 1
user.has_pending_organization_invitations? # SELECT 1 FROM organization_invitations WHERE ... LIMIT 1
org.has_any_members? # SELECT 1 FROM memberships WHERE ... LIMIT 1Scoped associations use JOINs
Methods like org.admins and user.owned_organizations use proper SQL JOINs:
org.admins
# SELECT users.* FROM users
# INNER JOIN memberships ON memberships.user_id = users.id
# WHERE memberships.organization_id = ? AND memberships.role IN ('admin', 'owner')
user.owned_organizations
# SELECT organizations.* FROM organizations
# INNER JOIN memberships ON memberships.organization_id = organizations.id
# WHERE memberships.user_id = ? AND memberships.role = 'owner'Current organization memoization
current_organization is memoized within each request:
# In your controller, these all return the same cached object
current_organization # Queries DB (first call)
current_organization # Returns cached (subsequent calls)
current_organization # Returns cachedBulk operations
For bulk invitations (coming in roadmap), the gem will support skipping per-record callbacks:
# Future API
org.bulk_invite!(emails, skip_callbacks: true)
# Fires on_bulk_invited once instead of on_member_invited N timesData integrity
The gem handles concurrent access and race conditions to ensure data consistency.
Unique constraints
These constraints prevent duplicate data at the database level:
| Constraint | Purpose |
|---|---|
memberships [user_id, organization_id] |
User can only have one membership per org |
invitations [organization_id, email] WHERE accepted_at IS NULL |
Only one pending invitation per email per org |
invitations [token] |
Invitation tokens are globally unique |
Row-level locking
The gem uses SELECT ... FOR UPDATE (row-level locks) to prevent race conditions:
Invitation acceptance:
# Two users clicking "Accept" on same invitation simultaneously
invitation.accept!
# Internally:
# 1. Lock invitation row
# 2. Check accepted_at is nil
# 3. Create membership
# 4. Set accepted_at
# 5. Release lock
# Second request sees accepted_at is set, returns existing membershipOwnership transfer:
org.transfer_ownership_to!(new_owner)
# Internally:
# 1. Lock organization row
# 2. Lock old owner's membership
# 3. Lock new owner's membership
# 4. Verify new owner is a member
# 5. Demote old owner to admin
# 6. Promote new owner to owner
# 7. Release locksLast admin/owner protection:
user.leave_organization!(org)
# Internally:
# 1. Lock organization row
# 2. Count remaining owners/admins
# 3. If last owner, raise CannotLeaveAsLastOwner
# 4. If allowed, destroy membership
# 5. Release lockRole changes:
membership.promote_to!(:admin)
# Internally:
# 1. Lock membership row
# 2. Update role
# 3. Release lockTransaction boundaries
Multi-step operations are wrapped in transactions:
# Organization creation (atomic)
user.create_organization!("Acme")
# Transaction: create org → create owner membership → set as current org
# Invitation acceptance (atomic)
invitation.accept!
# Transaction: lock invitation → create membership → update accepted_at
# Ownership transfer (atomic)
org.transfer_ownership_to!(user)
# Transaction: lock rows → demote old owner → promote new ownerGraceful handling of constraint violations
When unique constraints are violated, the gem handles it gracefully:
# Inviting an already-invited email
current_user.send_organization_invite_to!("already@invited.com")
# => Returns existing pending invitation (doesn't raise)
# Accepting an already-accepted invitation
invitation.accept!
# => Returns existing membership (doesn't raise)
# Adding an existing member
org.add_member!(existing_user)
# => Returns existing membership (doesn't raise)Session integrity
When a user is removed from their current organization:
# User's session points to org_id = 123
# Admin removes user from org 123
# On user's next request:
current_organization
# 1. Finds org 123
# 2. Verifies user has membership in org 123
# 3. Membership doesn't exist → clears session, returns nil
# 4. require_organization! redirects to on_no_organization handlerThis prevents users from accessing organizations they've been removed from, even if their session still references that org.
Database indexes
The gem creates these indexes automatically:
-- Fast membership lookups
CREATE UNIQUE INDEX index_memberships_on_user_and_org ON memberships (user_id, organization_id);
CREATE INDEX index_memberships_on_organization_id ON memberships (organization_id);
CREATE INDEX index_memberships_on_role ON memberships (role);
-- Fast invitation lookups
CREATE UNIQUE INDEX index_invitations_on_token ON organization_invitations (token);
CREATE INDEX index_invitations_on_email ON organization_invitations (email);
CREATE UNIQUE INDEX index_invitations_pending ON organization_invitations (organization_id, LOWER(email)) WHERE accepted_at IS NULL;Migration from 1:1 relationships
If your app currently has User belongs_to :organization (1:1), migrate to User has_many :organizations, through: :memberships by backfilling memberships and removing direct organization_id dependencies incrementally.
Roadmap
- Domain-based auto-join (users with @acme.com auto-join Acme org)
- Bulk invitations (CSV upload)
- Request-to-join workflow
- Organization-level audit logs
- Team hierarchies within organizations
Development
git clone https://github.com/rameerez/organizations
cd organizations
bin/setup
bundle exec rake testContributing
Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/organizations. Our code of conduct is: just be nice and make your mom proud of what you do and post online.
License
The gem is available as open source under the terms of the MIT License.