๐Ÿข organizations โ€“ Add organizations with members and invitations to your Rails SaaS

Gem Version Build Status

[!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.

๐ŸŽฎ Try the live demo โ†’

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
end

That'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)

https://github.com/user-attachments/assets/2eddafe2-025b-4670-af9f-e0d5480508c5

Installation

Add to your Gemfile:

gem "organizations"

[!NOTE] For beautiful invitation emails, optionally add goodmail.

Then:

bundle install
rails g organizations:install
rails db:migrate
rails g organizations:views   # copies the reference views (see BYO-UI note below)

Add has_organizations to your User model:

class User < ApplicationRecord
  has_organizations
end

That'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). Can also be a proc.
    require_organization true   # Require users to have at least one org (default: false)
  end
end

Note: By default, users can exist without any organization (invite-to-join flow). Set create_personal_org true if you want to auto-create a personal organization when users sign up. For conditional creation, use a proc: create_personal_org ->(user) { ... } or override should_create_personal_organization? on your User model (see Pattern 4: Hybrid Onboarding).

Mount the engine in your routes:

# config/routes.rb
mount Organizations::Engine => '/'

Don't want the whole engine UI? Declare which route groups to draw (devise_for-style) instead of shadowing routes:

# config/initializers/organizations.rb
config.engine_routes = { except: [:organizations] }  # keep switching/memberships/invitations, no org CRUD
# or: config.engine_routes = { only: [:switching, :public_invitations] }
# Groups: :switching, :organizations, :memberships, :invitations, :public_invitations

[!WARNING] Excluding a group also removes its route helpers โ€” audit anything that references them: a custom on_no_organization handler calling new_organization_path raises NameError once :organizations is excluded (the default redirect_path_when_no_organization = "/organizations/new" would 404 too โ€” point it somewhere real). The reference views also link across groups (the memberships page links to organization_path), so hosts that keep some engine pages while excluding others should retheme the copied views accordingly.

If your account model isn't named User:

config.user_class = "Account"   # set BEFORE the models are first referenced (initializers are fine)
# The install migrations reference `to_table: :users` โ€” adjust them to your user table.

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. Run rails g organizations:views to copy the Tailwind reference views into app/views/organizations/ and retheme them โ€” they're yours. The copies use the heroicon helper (add gem "heroicons", or swap the icon calls for your own set), and the organization page's owner-only Danger Zone renders only if you provide app/views/shared/_danger_zone.html.erb (copy the full-featured one from the dummy). For a complete working example (including the verified-joining screens the generator deliberately doesn't cover), 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 owner

Invite 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 session

Protect controllers

class ProjectsController < ApplicationController
  before_action :require_organization!
  before_action :require_organization_admin!, only: [:create, :destroy]
end

Limit 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 effective pricing plan using callbacks:

# config/initializers/pricing_plans.rb
plan :hobby do
  limits :organization_members, to: 3
end

plan :growth do
  limits :organization_members, to: 25
end

Then enforce the limit in on_member_joining โ€” the membership gate. It runs strictly, inside the creating transaction, immediately before a membership row is inserted, on EVERY join path: add_member!, invitation acceptance, join-request approval (which covers join codes, email domains, allowlists, and the account-email shortcut). Raising vetoes the join and rolls back cleanly:

# config/initializers/organizations.rb
Organizations.configure do |config|
  config.on_member_joining do |ctx|
    org = ctx.organization

    # The gate runs INSIDE the membership-creating transaction, so this row
    # lock serializes concurrent joins to the same org (and refreshes
    # member_count under the lock). Without it, two simultaneous joins can
    # both read "one seat left" and overshoot the cap by one โ€” take the lock
    # when you sell hard seat counts; skip it for advisory/anti-abuse caps
    # where an off-by-one under a race is acceptable.
    org.lock!

    limit = org.current_pricing_plan.limit_for(:organization_members)

    if limit && org.member_count >= limit
      raise Organizations::MembershipVetoed, "Member limit reached. Please upgrade your plan."
    end
  end
end

[!WARNING] Do not enforce seat limits only in on_member_invited. That hook guards the invitation path exclusively โ€” the moment your app enables any verified-joining instrument (a join code, an email domain, an allowlist), invite-only enforcement is silently bypassable. on_member_joining is the one gate that covers every path. (Keeping an on_member_invited check too is nice UX โ€” it rejects at invite time instead of accept time โ€” but it's an optimization, not the enforcement.)

Details worth knowing:

  • A vetoed join-request approval leaves the request pending (resumable โ€” approve again after the upgrade); a vetoed invitation acceptance leaves the invitation pending too.
  • A vetoed join-code redemption still consumes a use: uses are counted at redemption, before approval reaches the gate. max_uses is an anti-abuse cap, not a seat counter โ€” seats live here, in the gate.
  • The gate deliberately does NOT fire when an owner membership is created together with its organization (creating your own org is not "joining"), for idempotent already-a-member paths, or for role changes.
  • ctx carries organization, user, role, joined_via, and the instrument (invitation/join_request) when one applies.
  • Raising bare Organizations::MembershipVetoed uses a localized default message.

Verified joining: let users prove they belong

Invitations cover orgโ†’user. Since v0.5.0 the gem also covers userโ†’org: people who claim to belong to an organization and need to prove it. Four mechanisms, all converging on a regular Membership with provenance stamped on it:

Mechanism Proof Typical use
Request-to-join a human approves small teams, communities
Email domain 6-digit code emailed to anything@yourdomain.com companies, universities
Join code ("PIN") possession of the code (ยฑ domain email) posters, newsletters, classrooms
Allowlist / roster 6-digit code emailed to a rostered address clubs with no own domain

The key idea: the proven email is decoupled from the account email. A user signed up with a personal Gmail can still prove they control j.doe@acme.com and join Acme as a verified member. The proven address is recorded on the membership (verified_email) and is unique per organization โ€” one inbox, one member.

# โ”€โ”€ Request-to-join (manual approval) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
request = user.request_to_join!(org, message: "I'm in the Madrid office")
org.pending_join_requests                      # approval queue
org.approve_join_request!(request, approved_by: admin)  # => Membership
org.reject_join_request!(request, rejected_by: admin, reason: "unknown")

# โ”€โ”€ Email domain โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
org.add_domain!("acme.com")
request = user.request_to_join!(org)
request.start_email_verification!(email: "j.doe@acme.com")  # emails a 6-digit code
request.verify_email_code!("492817")           # => Membership (auto-approved)

# Zero-friction shortcut when the ACCOUNT email is already confirmed
# (e.g. Devise :confirmable) and its domain is enrolled:
org.(user)             # => Membership, no code needed

# โ”€โ”€ Join code (PIN) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
code = org.generate_join_code!(label: "office poster", max_uses: 500,
                               expires_at: 3.months.from_now)
Organizations::JoinCode.redeem("7FHK-2MPX", user: user)  # => Membership
code.revoke!                                   # rotation = revoke + generate

# Reinforced mode: PIN + corporate email required (per-code!)
strict = org.generate_join_code!(requires_verified_domain_email: true)
request = Organizations::JoinCode.redeem(strict.code, user: user)  # => JoinRequest
request.start_email_verification!(email: "j.doe@acme.com")
request.verify_email_code!("492817")           # => Membership

# โ”€โ”€ Allowlist / roster โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
org.import_allowlist!(["ana@gmail.com", "luis@yahoo.es"], source: "csv_2026-07")
# Rostered users still complete the email challenge โ€” a leaked roster grants
# nothing without inbox access. The entry is claimed on join.

Cohorts without the gem knowing (membership_metadata)

Every join instrument (domain, code, allowlist entry, invitation) carries a membership_metadata hash that's merged onto memberships it creates. The gem never interprets it โ€” it's your cohort/segmentation channel:

org.add_domain!("acme.com",          membership_metadata: { member_kind: "employee" })
org.add_domain!("students.acme.edu", membership_metadata: { member_kind: "student" })
# laterโ€ฆ
membership.["member_kind"]  # => "student"
membership.joined_via               # => "domain_email"
membership.verified_email           # => "x@students.acme.edu"
membership.verified?                # => true

Building the join UI: JoinFlow + JoinState

The models above are the exception-raising programmatic layer. For controllers and views, use the headless join kit โ€” one call in, one state out, no rescue ladders:

# The controller: every join input goes through ONE facade
result = Organizations::JoinFlow.attempt(
  user: current_user, organization: @org,
  code: params[:code],                        # a join code (PIN), if typed
  email: params[:email],                      # start the emailed challenge
  verification_code: params[:verification_code],  # the typed 6-digit code
  message: params[:message]                   # note for manual approval
)
result.outcome     # :member | :challenge_sent | :pending | :vetoed | :error
result.reason      # stable symbol from JoinFlow::REASONS (map your copy off this)
result.message     # localized human message (rides the i18n catalog)
result.membership / result.join_request

# The view: one object decides which screen state renders
@state = Organizations::JoinState.for(user: current_user, organization: @org, result: result)
@state.status          # :member | :verifying | :pending | :entry
@state.resend_seconds  # cooldown for the "resend code" button, derived from config
@state.error_message   # localized message of a just-failed action

# Which entry forms to show โ€” the org's actual instruments decide:
org.accepts_domain_joining?  # email form (domains or unclaimed allowlist entries)
org.accepts_code_joining?    # code form (at least one actually-redeemable code)
org.accepts_join_requests?   # anything at all

Reference UI to copy: the demo app ships a complete verified-joining implementation โ€” the four-state join screen (test/dummy/app/controllers/joins_controller.rb + view) and the org-admin Access surface (domains/codes/allowlist + request queue, access_controller.rb). Copy them into your app and retheme; their headers carry the production checklist.

Rate limiting your join endpoints

The gem deliberately ships no join routes/controllers, so it cannot rate-limit them for you โ€” and code redemption and code verification are enumeration surfaces. With Rails' built-in rate_limit:

class JoinsController < ApplicationController
  # Joining/redeeming: per-user, generous but bounded
  rate_limit to: 10, within: 1.hour, by: -> { current_user.id }, only: :create
  # Public landings (if any are unauthenticated): per-IP against slug/code enumeration
  # rate_limit to: 60, within: 1.minute, by: -> { request.remote_ip }, only: :show
end

Layer them per surface: the gem already throttles per request (60s resend interval, 5 sends, 5 attempts per challenge); your controller limits cap per user/IP across requests โ€” mail-bombing and brute-force across fresh challenges. And keep the generic error copy for unknown codes: JoinFlow already collapses unknown/revoked/expired/foreign codes into one reason โ€” don't "improve" it into an oracle. (One narrow residual the rate limits also cover: an expired org-scoped code does slightly more work than the fast-reject paths before raising - identical response shape, but a measurable timing side-channel without endpoint limits.)

Security posture

  • Codes are stored as SHA-256 digests only (peppered by row id), compared in constant time, single-use, expire after verification_code_ttl (15 min default), capped at verification_max_attempts (5) with resend throttles.
  • Emails are normalized before the uniqueness check (case, whitespace, and +tag plus-addressing collapse โ€” override via config.verification_email_normalizer).
  • Domain matching is exact and evasion-hardened: acme.com.evil.com, evilacme.com, multi-@ shapes never match; subdomains are separate domains on purpose (they often mean different cohorts).
  • BYO-UI as always: the gem ships models/APIs/mailers and state; you must rate-limit your join/redemption/verification endpoints (see above).
  • Hard member caps belong in on_member_joining (the strict membership gate) โ€” the on_join_request_* after-callbacks are error-isolated and cannot veto. Take ctx.organization.lock! inside the gate when the cap must hold under concurrent joins (see "Limit seats per plan").
  • max_uses is an anti-abuse cap, not a seat counter: join-code uses are counted at redemption, so a redemption later vetoed by the gate still consumed a use.
  • One deliberate exception to the one-generic-reason rule: an exhausted code surfaces its own reason (:join_code_exhausted), so legitimate holders learn the code ran out instead of retyping it forever. This confirms such a code exists โ€” if that trade-off bothers you, render it with the same copy as :join_code_invalid.
  • Verification-email delivery failures are handled: the gem rolls the throttle bookkeeping back (immediate retry, the failed send doesn't count) and fires on_verification_delivery_failed โ€” wire it to your error tracker so mail outages are visible:
config.on_verification_delivery_failed do |ctx|
  Rails.error.report(RuntimeError.new("Verification email failed: #{ctx.["error_message"]}"),
                     context: { join_request_id: ctx.join_request.id })
end

Upgrading an existing install: rails g organizations:upgrade && rails db:migrate (additive only).

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 org

Organization 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 date

# Creation (ops/provisioning primitive: no session switching, no per-user
# cap, fires on_organization_created โ€” for consoles, seeds, admin panels)
Organizations::Organization.create_with_owner!(owner: admin, name: "Acme Corp")

# Verified joining
org.domains / org.join_codes / org.allowlist_entries / org.join_requests
org.add_domain!("acme.com", membership_metadata: {})
org.generate_join_code!(label:, requires_verified_domain_email:, auto_approve:, expires_at:, max_uses:)
org.import_allowlist!(emails, source:, membership_metadata: {})
org.approve_join_request!(req, approved_by:) / org.reject_join_request!(req, rejected_by:, reason:)
org.(user)
org.accepts_domain_joining?     # email form? (domains or unclaimed roster entries)
org.accepts_code_joining?       # code form? (at least one actually-redeemable code)
org.accepts_join_requests?      # any self-serve mechanism at all
org.join_codes.active           # SQL twin of code.active?

# The join kit (controller/view layer โ€” see "Verified joining")
Organizations::JoinFlow.attempt(user:, organization:, code:, email:, verification_code:, message:)
Organizations::JoinState.for(user:, organization:, result:)

# Housekeeping
Organizations::JoinRequest.purge_stale!(older_than: 12.months)  # GDPR sweep: old decided/expired requests

# Typed boolean toggles over the metadata bag (also on Membership)
Organizations::Organization. :beta_features, default: false

Membership 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 member

Invitation 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 again

Controller helpers

Include the controller concern in your ApplicationController:

class ApplicationController < ActionController::Base
  include Organizations::Controller
end

This 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?

# Pending invitation helpers
pending_organization_invitation_token   # Get pending invitation token from session
pending_organization_invitation         # Get pending invitation (clears if expired)
pending_organization_invitation?        # Check if valid pending invitation exists
pending_organization_invitation_email   # Get invited email (for signup prefill)
clear_pending_organization_invitation!  # Clear invitation token and cache

# Invitation acceptance (canonical helper for post-signup flows)
accept_pending_organization_invitation!(user)                    # Accept with session token
accept_pending_organization_invitation!(user, token: token)      # Explicit token
accept_pending_organization_invitation!(user, switch: false)     # Don't auto-switch org
accept_pending_organization_invitation!(user, return_failure: true) # Returns failure object on rejection
pending_invitation_acceptance_redirect_path_for(user)            # Accept + resolve redirect path
handle_pending_invitation_acceptance_for(user, redirect: true)   # Accept + optionally redirect
# Returns InvitationAcceptanceResult or nil

# Invitation redirect helpers
redirect_path_when_invitation_requires_authentication(invitation)  # Get auth redirect
redirect_path_after_invitation_accepted(invitation, user: user)    # Get post-accept redirect
redirect_path_after_organization_switched(org, user: user)         # Get post-switch redirect

# No-organization helpers
redirect_path_when_no_organization(user: nil)                     # Resolve configured no-org redirect path
no_organization_redirect_path                                     # Alias
redirect_to_no_organization!(alert: "...", notice: "...")         # Redirect and return false

# Organization creation helper
create_organization_and_switch!(current_user, name: "Acme")       # Create and set context in one call
create_organization_with_context!(current_user, name: "Acme")     # Backward-compatible alias

# 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 session
switch_to_organization!(org, user: user)  # Explicit user (for auth-transition flows)

Protecting 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
end

manage_billing and view_billing are authorization checks only. They control who in the organization can access your billing UI, but they do not imply an active Stripe subscription or determine the effective pricing plan.

Handling 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
end

View helpers

Include in your ApplicationHelper:

module ApplicationHelper
  include Organizations::ViewHelpers
end

Permission 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) %>
# => 3 (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 pages

This 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
end

Add 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
end

Then 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_members permission. If not, Organizations::NotAMember or Organizations::NotAuthorized is raised.

Invitation flow

The gem handles both existing users and new signups with a single invitation link:

For existing users:

  1. Invitation created โ†’ Email sent with unique link
  2. User clicks link โ†’ Sees invitation details (org name, inviter, role)
  3. User clicks "Accept" โ†’ Membership created, redirected to org

For new users:

  1. Invitation created โ†’ Email sent with unique link
  2. User clicks link โ†’ Sees invitation details + "Sign up to accept" button
  3. User registers โ†’ Token stored in session, your app calls invitation.accept!(user) post-signup

The gem stores the invitation token in session[:organizations_pending_invitation_token] when an unauthenticated user tries to accept. Use the built-in helper to accept the invitation in your auth callbacks:

# In your ApplicationController (works with Devise or any auth system)
def (resource)
  if (path = pending_invitation_acceptance_redirect_path_for(resource))
    return path
  end
  super
end

def (resource)
  if (path = pending_invitation_acceptance_redirect_path_for(resource))
    return path
  end
  super
end

The accept_pending_organization_invitation! helper handles:

  • Token lookup from session
  • Invitation validation (expired, already accepted, email match)
  • Membership creation
  • Organization context switching
  • Session cleanup

It returns an InvitationAcceptanceResult object or nil:

result = accept_pending_organization_invitation!(user)
result.accepted?      # => true if freshly accepted
result.already_member? # => true if user was already a member
result.switched?      # => true if org context was switched
result.invitation     # => the invitation record
result.membership     # => the membership record

# Default flash notice (when using pending_invitation_acceptance_redirect_path_for)
# accepted?       -> "Welcome to <organization>!"
# already_member? -> "You're already a member of <organization>."

If you want structured failure reasons instead of nil, pass return_failure: true:

result = accept_pending_organization_invitation!(user, return_failure: true)

if result.success?
  # InvitationAcceptanceResult
else
  # InvitationAcceptanceFailure
  result.failure_reason # => :missing_token, :email_mismatch, :invitation_expired, etc.
end

Configure redirects in your initializer:

Organizations.configure do |config|
  config.redirect_path_when_invitation_requires_authentication = "/users/sign_up"
  config.redirect_path_after_invitation_accepted = "/dashboard"
  config.redirect_path_after_organization_switched = "/dashboard"

  # Or use procs for dynamic paths:
  config.redirect_path_after_invitation_accepted = ->(inv, user) {
    "/org/#{inv.organization_id}/welcome"
  }
  config.redirect_path_after_organization_switched = ->(org, user) {
    "/orgs/#{org.id}?user=#{user.id}"
  }
end

Note: When accepting invitations in custom auth flows (Devise overrides, bypass_sign_in, etc.), the gem handles stale memoization issues automatically by passing the explicit user to switch_to_organization!.

Invitation 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"
end

Invitation 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
end

Expired invitations can be resent:

invitation.expired?   # => true
invitation.resend!    # Generates new token, resets expiry, sends email

Accepted 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 invitation

Organization 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

  1. User logs in โ†’ current_organization set to their most recently used org
  2. User switches org โ†’ Session updated, current_organization changes
  3. User is removed from current org โ†’ Auto-switches to next available org

[!WARNING] The session is the source of truth โ€” always read current_organization through the CONTROLLER helper. The model-level user.current_organization reads a per-request attribute that is only seeded by that controller helper; called anywhere else (a background job, a view that bypassed the helper, a console) it silently falls back to the user's first organization no matter what they switched to. This burned a production host's org switcher on first render: reading the model in the view showed the first org forever. In jobs/services, pass the organization explicitly.

Manual switching

# In a controller
def switch
  org = current_user.organizations.find(params[:id])
  switch_to_organization!(org)
  redirect_to dashboard_path
end

Routes provided by the engine

When you mount the engine, you get:

POST /organizations/switch/:id  โ†’ Organizations::SwitchController#create
GET  /invitations/:token        โ†’ Organizations::PublicInvitationsController#show
POST /invitations/:token/accept โ†’ Organizations::PublicInvitationsController#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 org

Enable 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"
end

By 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):

  1. User signs up โ†’ verifies email
  2. User is in "limbo" (no organization yet)
  3. User creates org OR accepts invitation
  4. 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
end

When 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 handler

Handle 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
end

User Onboarding Patterns

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.

Pattern 1: Instant Access (auto-create)

Think: Notion, Slack, Trello โ€” "Sign up and start using it in 10 seconds"

config.always_create_personal_organization_for_each_user = true
config.default_organization_name = "My Workspace"
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  User signs up  โ†’  "My Workspace" created  โ†’  Dashboard ๐ŸŽ‰  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Users land in the app immediately. Zero friction. They can invite teammates later.

# config/initializers/organizations.rb
Organizations.configure do |config|
  config.always_create_personal_organization_for_each_user = true
  config.default_organization_name = "My Workspace"
  # Or personalize it:
  # config.default_organization_name = ->(user) { "#{user.email.split('@').first}'s Workspace" }
end

Best for: productivity tools, note apps, personal SaaS, anything where "just let me in" matters.

Pattern 2: Guided Onboarding (manual create)

Think: Stripe, HubSpot, Salesforce โ€” "Tell us about your company first"

config.always_create_personal_organization_for_each_user = false
config.redirect_path_when_no_organization = "/onboarding/create_organization"
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  User signs up  โ†’  Onboarding wizard  โ†’  "Create your company"  โ†’  App  โ”‚
โ”‚                    (collect company name, billing email, etc.)           โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

You control the experience. Collect whatever info you need before they enter.

# config/initializers/organizations.rb
Organizations.configure do |config|
  config.always_create_personal_organization_for_each_user = false
  config.redirect_path_when_no_organization = "/onboarding/create_organization"
  config.no_organization_notice = "Let's set up your company first."
  config.additional_organization_params = [:billing_email, :company_size, :industry]
end
# app/controllers/onboarding_controller.rb
def create_organization
  @organization = current_user.create_organization!(
    name: params[:company_name],
    billing_email: params[:billing_email]
  )
  redirect_to dashboard_path
end

Best for: B2B SaaS, enterprise tools, apps that need company details for billing/compliance.

Pattern 3: Invitation-Only

Think: Internal company tools, private beta, enterprise deployments

config.always_create_personal_organization_for_each_user = false
config.redirect_path_when_no_organization = "/waiting_room"
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  User signs up  โ†’  "Waiting for invitation" page  โ†’  (nothing yet)    โ”‚
โ”‚                                                                       โ”‚
โ”‚  Admin invites user  โ†’  User accepts  โ†’  Joins org  โ†’  Dashboard ๐ŸŽ‰   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Users can't do anything until an admin invites them. Full control over who gets in.

Best for: internal tools, private beta programs, enterprise B2B where orgs are pre-provisioned.

Pattern 4: Hybrid Onboarding

Think: Best of both worlds โ€” instant access for direct signups, no clutter for invited users

Direct signups get a personal workspace immediately. Invited users skip the personal workspace and join the organization that invited them directly. This avoids creating unnecessary "My Workspace" organizations for users who are joining an existing team.

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Direct signup  โ†’  "My Workspace" auto-created  โ†’  Dashboard ๐ŸŽ‰              โ”‚
โ”‚                                                                              โ”‚
โ”‚  Invited signup  โ†’  No personal org  โ†’  Joins invited org  โ†’  Dashboard ๐ŸŽ‰   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Implementation requires overriding the should_create_personal_organization? method on your User model:

# app/models/user.rb
class User < ApplicationRecord
  has_organizations

  # Skip personal org creation for users signing up via invitation
  attr_accessor :skip_personal_organization

  def should_create_personal_organization?
    return false if skip_personal_organization
    super
  end
end

Then set the flag before the user is persisted. With Devise, override build_resource:

# app/controllers/users/registrations_controller.rb
class Users::RegistrationsController < Devise::RegistrationsController
  protected

  def build_resource(hash = {})
    super
    # Skip personal org for users signing up via invitation
    resource.skip_personal_organization = true if pending_organization_invitation?
  end
end

The should_create_personal_organization? method is the official extension seam. It evaluates:

  1. Your method override (if defined)
  2. The create_personal_org setting (boolean or proc)
  3. The global always_create_personal_organization_for_each_user config

You can also use a proc for the setting itself:

# In has_organizations block
has_organizations do
  create_personal_org ->(user) { !user.skip_personal_organization }
end

Best for: SaaS products that want instant onboarding for individual users but clean team onboarding for invited members.


Configuration

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"

  # Where to redirect after organization is created (nil = default show page)
  # Can be a String or Proc: ->(org) { "/orgs/#{org.id}/setup" }
  config.after_organization_created_redirect_path = "/dashboard"

  # === Invitation Flow Redirects ===
  # Where to redirect unauthenticated users when they try to accept an invitation
  # Default: nil = known-user promotion (since 0.5.0): if the invited address
  # already has an account, sign-IN (new_user_session_path); otherwise
  # sign-UP (new_user_registration_path), falling back to root_path.
  # Set this to opt out of the lookup.
  config.redirect_path_when_invitation_requires_authentication = "/users/sign_up"
  # Or use a Proc: ->(invitation, user) { "/signup?invite=#{invitation.token}" }

  # Where to redirect after an invitation is accepted
  # Default: nil (uses root_path)
  config.redirect_path_after_invitation_accepted = "/dashboard"
  # Or use a Proc: ->(invitation, user) { "/org/#{invitation.organization_id}/welcome" }

  # Where to redirect after organization switch
  # Default: nil (uses root_path)
  config.redirect_path_after_organization_switched = "/dashboard"
  # Or use a Proc: ->(organization, user) { "/orgs/#{organization.id}" }

  # Optional flash messages for built-in no-organization redirects.
  # Leave nil to keep default alert behavior.
  config.no_organization_alert = "Please create an organization first."
  config.no_organization_notice = "Please create or join an organization to continue."

  # === Organizations Controller ===
  # Additional params to permit when creating/updating organizations
  # Use this to add custom fields like support_email, billing_email, logo
  config.additional_organization_params = [:support_email]

  # === Engine Controllers ===
  # Base controller for authenticated routes (default: ::ApplicationController)
  config.parent_controller = "::ApplicationController"

  # Base controller for public routes like invitation acceptance.
  # Works with Devise out of the box - no configuration needed.
  # Only override if using custom auth or needing specific inheritance.
  # Default: ActionController::Base
  # config.public_controller = "ActionController::Base"

  # Layout overrides for engine controllers (optional)
  # Resolved at request-time, so runtime config changes are respected.
  config.authenticated_controller_layout = "dashboard"
  config.public_controller_layout = "devise"

  # === 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
end

Integrations

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
end

Integrates 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)
end

Integrates with pricing_plans

Enforce member limits based on the effective pricing plan 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
end

Then 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
end

All scoped through current_organization:

current_organization.api_keys
current_organization.credits
current_organization.current_pricing_plan
current_organization.memberships  # From organizations gem

Callbacks

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
end

Available callbacks

Callback Context fields Mode
on_organization_created organization, user After
on_member_invited organization, invitation, invited_by Before (strict)
on_member_joining organization, user, role, joined_via, invitation/join_request Before (strict) โ€” THE membership gate; fires pre-persist on every join path
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
on_join_request_created organization, user, join_request After
on_join_request_approved organization, user, join_request, membership, decided_by (nil for auto) After
on_join_request_rejected organization, user, join_request, decided_by After
on_verification_delivery_failed organization, user, join_request, metadata (error_class/error_message) 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
end

Fixtures

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: admin

Test helpers

# Set organization context in tests
(user, org, role: :admin)
set_current_organization(org)

# Complete the emailed-code flow without intercepting mail: force a KNOWN
# plaintext code onto the request's challenge (the DB only stores digests โ€”
# don't reverse-engineer the digest recipe in your tests)
request.start_email_verification!(email: "j.doe@acme.com")
code = issue_verification_code(request)   # => "424242"
request.verify_email_code!(code)          # => Membership

# Or manually
 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. Extend it with your app's specific fields by adding migrations (host columns on gem tables are the sanctioned path) and registering an extension via load hooks:

# db/migrate/xxx_add_custom_fields_to_organizations.rb
class AddCustomFieldsToOrganizations < ActiveRecord::Migration[8.0]
  def change
    add_column :organizations_organizations, :support_email, :string
    add_column :organizations_organizations, :billing_address, :text
    add_column :organizations_organizations, :settings, :jsonb, default: {}
  end
end
# app/models/concerns/organization_extensions.rb
module OrganizationExtensions
  extend ActiveSupport::Concern

  included do
    has_many :projects
    has_many :documents
    validates :support_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  end

  def active_projects
    projects.where(archived: false)
  end
end
# config/initializers/organizations.rb (top of the file, outside the configure block)
ActiveSupport.on_load(:organizations_organization) do
  include OrganizationExtensions   # `self` is Organizations::Organization
end
ActiveSupport.on_load(:organizations_membership) do
  # typed boolean toggles over the metadata bag, with defaults:
   :show_on_profile, default: true
end

Why load hooks and not class_eval in an initializer: the gem's model classes are reload-safe under Zeitwerk (they reload in development), and an initializer runs ONCE โ€” a bare class_eval patch evaporates on the first code reload, and a hand-rolled to_prepare block is one shared basket where a single bad constant silently kills every extension after it. Load hooks re-fire per model on every reload (ActiveSupport::LazyLoadHooks). One hook per model: :organizations_organization, :organizations_membership, :organizations_invitation, :organizations_domain, :organizations_join_code, :organizations_allowlist_entry, :organizations_join_request, plus :organizations_application_controller and :organizations_public_controller for the engine controllers.

For the common public-controller case โ€” host layouts on the invitation-acceptance pages need your app's helper modules (the public controller inherits ActionController::Base, not your ApplicationController) โ€” use the declarative option instead:

config.public_controller_helpers = ["ApplicationHelper", "PageHelper"]

Alternatively, create your own model that inherits from the gem's model:

# app/models/organization.rb
class Organization < Organizations::Organization
  has_many :projects
end

Note: If you create your own Organization class, be aware that internal gem code uses Organizations::Organization. Your subclass will work for your app code, but associations from User#organizations will return Organizations::Organization instances.

This is standard Rails practice โ€” the gem provides the foundation (memberships, invitations, roles, verified joining), your app extends it with domain-specific features.

URL-scoped organizations (OrganizationScoped)

The engine's controllers are session-scoped: one "current" organization per user, workspace-style. Overlay-style apps (communities, marketplaces โ€” users belong to orgs but don't "work inside" one) address organizations by URL instead: /org/:slug/admin. For those surfaces, include the concern:

class Portal::BaseController < ApplicationController
  include Organizations::OrganizationScoped

  self.organization_param  = :slug
  self.organization_finder = ->(param) { Organizations::Organization.find_by(slug: param) }
  require_organization_role :admin   # gate every action behind a minimum role
end

You get current_scoped_organization / current_scoped_membership (also as view helpers), and a configurable not-found posture: the default raises ActionController::RoutingError, so unknown orgs, strangers, and under-role members are indistinguishable โ€” no existence oracle, the right stance for customer-facing portals. Set self.organization_not_found_behavior = :forbidden for internal tools where a 403 is fine. Both addressing modes coexist in one app โ€” the concern never touches the session context.

Internationalization (i18n)

Every user-facing string the gem produces โ€” error messages, validation messages, role/status labels, invitation-flow notices, the stock mailer copy โ€” resolves through locale files under the organizations. namespace. English and Spanish ship with the gem; add or override any key in your app's locale files (app locale files load after engine ones, so yours win):

# config/locales/en.yml (host app) โ€” override just what you want
en:
  organizations:
    roles:
      admin: "Manager"
    errors:
      join_code_invalid: "Hmm, that code doesn't work."

Two deliberate boundaries: developer-facing errors (ArgumentError, ConfigurationError) stay English, and custom roles defined via config.roles fall back to humanize unless you add a matching organizations.roles.<name> key.

Database schema

The gem creates seven tables (three core + four for verified joining, all created by organizations:install; existing installs get the four via organizations:upgrade):

Note: The gem automatically detects your app's primary key type (UUID or integer) and uses it for all tables, and emits adapter-aware DDL (partial unique indexes on PostgreSQL/SQLite, generated-column emulation on MySQL).

organizations_organizations

organizations_organizations
  - id (primary key, auto-detects UUID or integer from your app)
  - name (string, required)
  - memberships_count (integer, counter cache, default: 0)
  - metadata (jsonb, default: {})
  - created_at / updated_at

organizations_memberships

organizations_memberships
  - id (primary key)
  - user_id (foreign key, indexed)
  - organization_id (foreign key, indexed)
  - role (string, default: 'member')
  - invited_by_id (foreign key, nullable)
  - joined_via (string, nullable: invited|code|domain_email|allowlist|manual)
  - verified_email / verified_email_normalized (string, nullable)
  - verified_at (datetime, nullable)
  - metadata (jsonb, default: {})
  - created_at / updated_at

  unique index: [user_id, organization_id]
  partial unique index: [organization_id] where role = 'owner'   (single owner; pg/sqlite โ€”
    generated-column emulation on MySQL)
  unique index: [organization_id, verified_email_normalized]     (one proven inbox = one member;
    plain composite โ€” NULLs never collide, so no WHERE clause is needed)

organizations_invitations

organizations_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)
  - metadata / membership_metadata (jsonb, default: {})
  - created_at / updated_at

  partial unique index: [organization_id, lower(email)] where accepted_at is null

organizations_domains

organizations_domains
  - id, organization_id (foreign key)
  - domain (string, required โ€” stored lowercased, no leading '@')
  - membership_metadata / metadata (jsonb, default: {})
  - created_at / updated_at

  unique index: [organization_id, domain]; index: [domain] (email โ†’ candidate orgs)

organizations_join_codes

organizations_join_codes
  - id, organization_id (foreign key)
  - code (string, globally unique)
  - label (string, nullable โ€” campaign attribution)
  - requires_verified_domain_email (boolean, default: false)
  - auto_approve (boolean, default: true)
  - expires_at (datetime, nullable) / max_uses (integer, nullable) / uses_count (integer, default: 0)
  - revoked_at (datetime, nullable)
  - created_by_id (foreign key, nullable)
  - membership_metadata / metadata (jsonb, default: {})
  - created_at / updated_at

organizations_allowlist_entries

organizations_allowlist_entries
  - id, organization_id (foreign key)
  - email (string, as provided) / email_normalized (string)
  - source (string, nullable โ€” e.g. "csv_2026-07")
  - claimed_at (datetime, nullable) / claimed_by_id (foreign key, nullable)
  - membership_metadata / metadata (jsonb, default: {})
  - created_at / updated_at

  unique index: [organization_id, email_normalized]

organizations_join_requests

organizations_join_requests
  - id, organization_id (foreign key), user_id (foreign key)
  - status (string, default: 'pending': pending|approved|rejected|withdrawn; :expired is derived)
  - joined_via (string, nullable) / join_code_id (foreign key, nullable) / message (string, nullable)
  - verification_email / verification_email_normalized (string, nullable)
  - verification_code_digest (string, nullable โ€” SHA-256 only, never plaintext)
  - verification_sent_at / verification_expires_at (datetime) / verified_at (datetime)
  - verification_attempts / verification_sends_count (integer, default: 0)
  - decided_by_id (foreign key, nullable) / decided_at (datetime) / expires_at (datetime)
  - metadata (jsonb, default: {})
  - created_at / updated_at

  partial unique index: [organization_id, user_id] where status = 'pending'

Ownership 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
end

Permission 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
end

Role 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 membership

Organization 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

The install migration includes a memberships_count counter cache on organizations, and member_count reads from it directly:

org.member_count
# Uses the memberships_count counter cache

Existence checks use SQL

Boolean checks use efficient SQL EXISTS queries:

user.belongs_to_any_organization?     # SELECT 1 FROM organizations_memberships WHERE ... LIMIT 1
user.has_pending_organization_invitations?  # SELECT 1 FROM organizations_invitations WHERE ... LIMIT 1
org.has_any_members?                  # SELECT 1 FROM organizations_memberships WHERE ... LIMIT 1

Scoped associations use JOINs

Methods like org.admins and user.owned_organizations use proper SQL JOINs:

org.admins
# SELECT users.* FROM users
# INNER JOIN organizations_memberships ON organizations_memberships.user_id = users.id
# WHERE organizations_memberships.organization_id = ? AND organizations_memberships.role IN ('admin', 'owner')

user.owned_organizations
# SELECT organizations_organizations.* FROM organizations_organizations
# INNER JOIN organizations_memberships ON organizations_memberships.organization_id = organizations_organizations.id
# WHERE organizations_memberships.user_id = ? AND organizations_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 cached

Bulk 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 times

Data 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
memberships [organization_id] WHERE role = 'owner' Exactly one owner per org
memberships [organization_id, verified_email_normalized] One proven inbox = one member per org
invitations [organization_id, LOWER(email)] WHERE accepted_at IS NULL Only one pending invitation per email per org
invitations [token] Invitation tokens are globally unique
join_requests [organization_id, user_id] WHERE status = 'pending' One open join request per user per org
join_codes [code] Join codes 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 membership

Ownership 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 locks

Last 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 lock

Role changes:

membership.promote_to!(:admin)

# Internally:
# 1. Lock membership row
# 2. Update role
# 3. Release lock

Transaction 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 owner

Graceful 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 handler

This 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_organizations_memberships_on_user_and_org ON organizations_memberships (user_id, organization_id);
CREATE INDEX index_organizations_memberships_on_organization_id ON organizations_memberships (organization_id);
CREATE INDEX index_organizations_memberships_on_role ON organizations_memberships (role);

-- Fast invitation lookups
CREATE UNIQUE INDEX index_organizations_invitations_on_token ON organizations_invitations (token);
CREATE INDEX index_organizations_invitations_on_email ON organizations_invitations (email);
CREATE UNIQUE INDEX index_organizations_invitations_pending ON organizations_invitations (organization_id, LOWER(email)) WHERE accepted_at IS NULL;

Migrating from a hand-rolled Organization model

Adopting the gem in an app that already has its own Organization model is a well-trodden path โ€” one production host migrated a live SaaS (billing, API keys, usage credits, all tenant-scoped) onto the gem with zero downtime. The distilled playbook, as four ordered migrations:

# 1. Park the legacy table (keep it around until you're confident)
class BackupOrganizationsForGem < ActiveRecord::Migration[8.0]
  def change
    # Drop FKs that point INTO the legacy table first, then:
    rename_table :organizations, :organizations_legacy
  end
end

# 2. Create the gem tables โ€” vendor the install generator's migration:
#    rails g organizations:install   (take the migration, skip the initializer if you have one)

# 3. Re-add YOUR columns onto the gem's organizations table
class AddAppColumnsToOrganizations < ActiveRecord::Migration[8.0]
  def change
    add_column :organizations_organizations, :support_email, :string
    # ...every host column your legacy table had
  end
end

# 4. Copy the data โ€” the load-bearing details:
class MigrateOrganizationData < ActiveRecord::Migration[8.0]
  def up
    # a) Copy legacy rows PRESERVING IDs (every FK in your app keeps working).
    # b) Create memberships: pick each org's owner deterministically
    #    (e.g. earliest user) โ€” the gem enforces exactly one owner.
    # c) Backfill memberships_count.
    # d) โš ๏ธ REWRITE POLYMORPHIC TYPE STRINGS: every table storing
    #    "Organization" in a *_type column must now say
    #    "Organizations::Organization" โ€” check pay (customers, merchants),
    #    pricing_plans (assignments, enforcement_states, usages),
    #    api_keys, usage_credits (wallets, fulfillments), and your own
    #    polymorphic associations. This is the step everyone forgets.
    # e) Re-add + validate the FKs you dropped in step 1.
  end
end

Afterwards: has_organizations on User, move your model code into an extension concern (see "Extending the Organization model"), and keep a users.organization_id-style pointer only if you need a "last active org" fallback. If you previously had User belongs_to :organization (1:1), the same playbook applies โ€” the memberships you create in step 4b simply all have one member.

Roadmap

  • [x] Domain-based joining (users prove control of an @acme.com inbox โ€” shipped in 0.5.0 as part of verified joining)
  • [x] Request-to-join workflow (shipped in 0.5.0)
  • [x] Roster/allowlist bulk import (shipped in 0.5.0; covers the bulk-invite use case via import_allowlist!)
  • [ ] Bulk invitations (CSV upload of token invitations)
  • [ ] Organization-level audit logs
  • [ ] Team hierarchies within organizations

Development

git clone https://github.com/rameerez/organizations
cd organizations
bin/setup
bundle exec rake test

Contributing

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.