March 19, 2026 . 15 min read
How to Add Team Accounts and Role-Based Permissions to Your Rails 8 SaaS in 2026
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_idforeign 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.projectsbreaks the moment a user belongs to multiple teams. Always scope throughcurrent_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.comlooks 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