Omaship

March 19, 2026 . 15 min read

How to Add Team Accounts and Role-Based Permissions to Your Rails 8 SaaS in 2026

Jeronim Morina

Jeronim Morina

Founder, Omaship

The moment a second person needs access to your SaaS, you need team accounts. And the moment that person should not be able to delete the billing subscription, you need roles and permissions. Here is how to build both in Rails 8 without reaching for a framework that will fight you later.

Team accounts are the single most requested feature in every SaaS boilerplate comparison thread. Jumpstart Pro lists it as their headline feature. Bullet Train builds their entire Super Scaffolding system around it. The reason is simple: B2B SaaS requires multitenancy, and multitenancy requires a way to scope data, manage access, and invite new members.

But here is what those boilerplates do not tell you: team accounts are not hard. The data model is straightforward. The invitation flow is a standard Rails controller action. The permission system is a few concern methods. What is hard is getting the scoping right so that User A never sees User B's data -- and that is a testing problem, not an architecture problem.

The data model

Every team-based SaaS needs three models: the team (or account, or organization -- the name does not matter), the membership that connects users to teams, and the invitation that handles the onboarding flow. Here is the schema:

# db/migrate/create_teams.rb
class CreateTeams < ActiveRecord::Migration[8.0]
  def change
    create_table :teams do |t|
      t.string :name, null: false
      t.references :owner, null: false, foreign_key: { to_table: :users }
      t.string :plan, default: "free"
      t.timestamps
    end

    create_table :memberships do |t|
      t.references :user, null: false, foreign_key: true
      t.references :team, null: false, foreign_key: true
      t.string :role, null: false, default: "member"
      t.timestamps
    end

    add_index :memberships, [:user_id, :team_id], unique: true

    create_table :invitations do |t|
      t.references :team, null: false, foreign_key: true
      t.references :invited_by, null: false, foreign_key: { to_table: :users }
      t.string :email, null: false
      t.string :role, null: false, default: "member"
      t.string :token, null: false
      t.datetime :accepted_at
      t.timestamps
    end

    add_index :invitations, :token, unique: true
  end
end

Three tables. No polymorphic associations. No STI. No JSON columns storing permission matrices. The role column is a plain string because enums in the database add complexity without adding value at this stage.

The models

# app/models/team.rb
class Team < ApplicationRecord
  belongs_to :owner, class_name: "User"
  has_many :memberships, dependent: :destroy
  has_many :users, through: :memberships
  has_many :invitations, dependent: :destroy

  validates :name, presence: true

  def member?(user)
    memberships.exists?(user: user)
  end

  def role_for(user)
    memberships.find_by(user: user)&.role
  end
end

# app/models/membership.rb
class Membership < ApplicationRecord
  belongs_to :user
  belongs_to :team

  validates :role, inclusion: { in: %w[owner admin member viewer] }
  validates :user_id, uniqueness: { scope: :team_id }

  ROLES = %w[owner admin member viewer].freeze

  def owner?  = role == "owner"
  def admin?  = role.in?(%w[owner admin])
  def member? = role.in?(%w[owner admin member])
  def viewer? = true
end

# app/models/invitation.rb
class Invitation < ApplicationRecord
  belongs_to :team
  belongs_to :invited_by, class_name: "User"

  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :role, inclusion: { in: Membership::ROLES }
  validates :token, presence: true, uniqueness: true

  before_validation :generate_token, on: :create

  scope :pending, -> { where(accepted_at: nil) }

  def accepted? = accepted_at.present?

  def accept!(user)
    transaction do
      team.memberships.create!(user: user, role: role)
      update!(accepted_at: Time.current)
    end
  end

  private

  def generate_token
    self.token ||= SecureRandom.urlsafe_base64(32)
  end
end

Notice what is not here: no permission gem, no policy objects, no authorization framework. The role check is a method on Membership. Four roles with a clear hierarchy: owner can do everything, admin can manage members, member can use the product, viewer can only read. You can add more roles later -- it is just a string column and an array constant.

Four roles is enough

Before you reach for Pundit, CanCanCan, or Action Policy, consider this: most SaaS products need exactly four permission levels:

  • Owner -- can delete the team, manage billing, transfer ownership. There is exactly one owner per team (enforced by the teams.owner_id foreign key). Ownership transfer is a deliberate action, not a role change.
  • Admin -- can invite and remove members, change roles (except owner), manage team settings. Cannot delete the team or change billing.
  • Member -- can use the product. Create, edit, and delete their own resources. Cannot manage other users or team settings.
  • Viewer -- read-only access. Can see dashboards, reports, and shared resources. Cannot create or modify anything. Perfect for stakeholders, investors, or clients who need visibility without edit access.

This covers 95% of B2B SaaS permission needs. The remaining 5% -- granular per-resource permissions, custom roles, attribute-based access control -- can be added when you have customers asking for it. Do not build a permission matrix for imaginary users.

Scoping data to the current team

The most critical part of team accounts is data isolation. Every query in your application must be scoped to the current team. A single unscoped query is a data leak waiting to happen.

# app/controllers/concerns/team_scoped.rb
module TeamScoped
  extend ActiveSupport::Concern

  included do
    before_action :set_current_team
    helper_method :current_team
  end

  private

  def current_team
    @current_team
  end

  def set_current_team
    @current_team = current_user.teams.find_by(id: session[:current_team_id])
    @current_team ||= current_user.teams.first

    if @current_team
      session[:current_team_id] = @current_team.id
    else
      redirect_to new_team_path, alert: "Create or join a team to continue."
    end
  end
end

Include this concern in your ApplicationController (or a base controller for team-scoped routes). Every controller action now has access to current_team, and every query should be scoped through it:

# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
  include TeamScoped

  def index
    @projects = current_team.projects
  end

  def create
    @project = current_team.projects.build(project_params)
    # ...
  end
end

The key discipline: never write Project.find(params[:id]). Always write current_team.projects.find(params[:id]). The former is a data leak. The latter is scoped. If you want a safety net, use the acts_as_tenant gem, which adds automatic scoping and raises an error if you accidentally query without a tenant set.

The invitation flow

Team invitations follow a predictable pattern: admin enters an email, system sends an invitation link, recipient clicks the link and either signs up or signs in, invitation is accepted, membership is created. Here is the controller:

# app/controllers/invitations_controller.rb
class InvitationsController < ApplicationController
  include TeamScoped
  before_action :require_admin, only: [:new, :create, :destroy]

  def new
    @invitation = current_team.invitations.build
  end

  def create
    @invitation = current_team.invitations.build(invitation_params)
    @invitation.invited_by = current_user

    if @invitation.save
      InvitationMailer.invite(@invitation).deliver_later
      redirect_to team_members_path, notice: "Invitation sent."
    else
      render :new, status: :unprocessable_entity
    end
  end

  # GET /invitations/:token/accept
  def accept
    @invitation = Invitation.pending.find_by!(token: params[:token])

    if current_user
      @invitation.accept!(current_user)
      redirect_to root_path, notice: "Welcome to #{@invitation.team.name}!"
    else
      # Store token in session and redirect to signup/login
      session[:pending_invitation_token] = @invitation.token
      redirect_to login_path, notice: "Sign in or create an account to join #{@invitation.team.name}."
    end
  end

  def destroy
    invitation = current_team.invitations.find(params[:id])
    invitation.destroy
    redirect_to team_members_path, notice: "Invitation revoked."
  end

  private

  def invitation_params
    params.require(:invitation).permit(:email, :role)
  end

  def require_admin
    membership = current_team.memberships.find_by(user: current_user)
    redirect_to root_path, alert: "Not authorized." unless membership&.admin?
  end
end

The accept action handles both cases: logged-in users accept immediately, logged-out users are redirected to sign in first. After authentication, check for a pending invitation token in the session and complete the acceptance.

Authorization without a gem

For simple role checks, a concern is cleaner than a full authorization framework:

# app/controllers/concerns/authorization.rb
module Authorization
  extend ActiveSupport::Concern

  private

  def current_membership
    @current_membership ||= current_team&.memberships&.find_by(user: current_user)
  end

  def require_owner
    deny_access unless current_membership&.owner?
  end

  def require_admin
    deny_access unless current_membership&.admin?
  end

  def require_member
    deny_access unless current_membership&.member?
  end

  def deny_access
    redirect_to root_path, alert: "You do not have permission to do that."
  end
end

Use it as a before_action:

class Team::SettingsController < ApplicationController
  include TeamScoped
  include Authorization

  before_action :require_admin

  def edit
    # Only owners and admins can access team settings
  end
end

class Team::BillingController < ApplicationController
  include TeamScoped
  include Authorization

  before_action :require_owner

  def show
    # Only the owner can view/change billing
  end
end

When should you reach for Pundit or Action Policy? When your permission logic goes beyond role hierarchy. If "members can edit their own projects but not others' projects" or "admins in the marketing department can only access marketing resources" -- those are policy decisions that benefit from dedicated policy objects. But start with the concern. Refactor when the concern grows past 30 lines.

Team switching

Users who belong to multiple teams need a way to switch between them. This is a session-level operation:

# app/controllers/team_switches_controller.rb
class TeamSwitchesController < ApplicationController
  def create
    team = current_user.teams.find(params[:team_id])
    session[:current_team_id] = team.id
    redirect_to root_path, notice: "Switched to #{team.name}."
  end
end

Add a dropdown in your navigation that lists the user's teams. The current team is highlighted. Clicking another team sends a POST to team_switches#create. The session updates, the page refreshes, and all scoped queries now return data for the new team. No subdomain magic, no URL parameter threading -- just a session value.

Testing team isolation

The most important tests in a team-based SaaS verify that data does not leak between teams. Write these tests first:

# test/controllers/projects_controller_test.rb
class ProjectsControllerTest < ActionDispatch::IntegrationTest
  setup do
    @team_a = teams(:alpha)
    @team_b = teams(:beta)
    @user_a = users(:alice)  # member of team_a
    @user_b = users(:bob)    # member of team_b
    @project_a = projects(:alpha_project)  # belongs to team_a
    @project_b = projects(:beta_project)   # belongs to team_b
  end

  test "user cannot see projects from another team" do
    sign_in @user_a
    get project_path(@project_b)
    assert_response :not_found
  end

  test "user cannot update projects from another team" do
    sign_in @user_a
    patch project_path(@project_b), params: { project: { name: "Hacked" } }
    assert_response :not_found
    assert_equal "Beta Project", @project_b.reload.name
  end

  test "index only shows current team projects" do
    sign_in @user_a
    get projects_path
    assert_includes response.body, @project_a.name
    assert_not_includes response.body, @project_b.name
  end
end

These three tests catch the most common team isolation bugs. If every resource controller has them, you can sleep at night knowing that switching teams does not leak data.

When to use Pundit, Action Policy, or CanCanCan

Authorization gems are tools, not requirements. Here is when each one earns its place:

  • Pundit -- when you need per-resource policies. "Can this user edit this specific project?" Policy objects map 1:1 to models and are easy to test in isolation. Lightweight, no DSL, just Ruby classes. The most popular choice for Rails SaaS in 2026.
  • Action Policy -- when you need Pundit-like policies with caching, scoping, and i18n built in. More batteries-included than Pundit but also more opinionated. Good choice if you want pre-scoping (automatically filtering collections by policy).
  • CanCanCan -- when you want a centralized ability file. All permissions defined in one place (app/models/ability.rb). Works well for simple apps but the single-file approach gets unwieldy as permissions grow. Better suited for admin panels than full SaaS applications.

For most Rails SaaS products at launch: skip the gem. Use the concern-based approach above. Add Pundit when you hit the first permission check that cannot be expressed as a simple role hierarchy.

Billing and seats: connecting teams to subscriptions

Team accounts and billing interact in predictable ways. The most common patterns:

  • Per-seat pricing: The subscription quantity matches the team's member count. When a member is added, update the Stripe subscription quantity. When a member is removed, update it again. Stripe handles prorating automatically.
  • Tier-based pricing: Plans have member limits (Free: 1 user, Pro: 5 users, Business: unlimited). Check the limit before accepting an invitation. If the team is at capacity, prompt the owner to upgrade.
  • Free viewers: Charge for members who create content but allow unlimited free viewers. This is increasingly popular because it reduces friction for adoption within organizations.
# app/models/team.rb
class Team < ApplicationRecord
  def can_add_member?
    return true if plan == "business"  # unlimited

    member_limit = { "free" => 1, "pro" => 5 }.fetch(plan, 1)
    memberships.where(role: %w[owner admin member]).count < member_limit
  end

  def billable_member_count
    memberships.where(role: %w[owner admin member]).count
  end
end

Keep the billing logic on the Team model. The controller checks can_add_member? before creating a membership. If it returns false, redirect to the upgrade page. Clean separation, easy to test.

Common mistakes

  • Scoping by user instead of team. current_user.projects breaks the moment a user belongs to multiple teams. Always scope through current_team.
  • Forgetting the unique index on memberships. Without add_index :memberships, [:user_id, :team_id], unique: true, a user can be added to the same team twice. The database should enforce this, not your application code.
  • Storing permissions in a JSON column. It seems flexible until you need to query "find all admins" or "count teams where this user is an owner." Use a plain string column with a validates inclusion.
  • Building custom roles from day one. "Customers will want to create their own roles with granular permissions." No, they will not. Not until you have 50+ paying teams. Start with four fixed roles. Add custom roles when someone asks for them and is willing to pay for the plan that includes them.
  • Using subdomains for team switching. team-name.yourapp.com looks professional but adds SSL wildcard complexity, cookie scoping issues, and breaks development on localhost. Session-based team switching is simpler and works everywhere.

How Omaship handles teams

Omaship ships with a team-aware data model from the start. The Ship model (your provisioned application) is scoped to the authenticated user, with the architecture designed for team expansion when your product needs it.

The authentication system uses Rails 8's built-in generator -- no Devise dependency to work around when adding team-scoped sessions. The session model, the authentication concern, and the membership pattern all compose cleanly because they are all plain Rails code.

When you add team accounts to an Omaship-generated app, you are adding three models and two concerns to a codebase that was designed for exactly this extension. No framework fights, no gem conflicts, no migration headaches.

Build your SaaS on a foundation designed for team accounts.

Omaship gives you Rails 8 authentication, clean data models, and conventions that make adding teams, roles, and permissions straightforward -- not a framework battle.

Start building

Continue reading

We use analytics and session recordings to learn which parts of Omaship help and which need work. Accept all, or customize what you share.

Privacy policy