Dynamic Roles in a Rails app using Rolify, Devise Invitable, and Pundit.

Muwonge Nicholus
11 min readAug 29, 2023

--

In many applications, where one account can have more than one user, there’s always a need for the account owner(s) to assign roles/permissions to the members who exist on the same account. This can get tricky if the developer had to set roles for each account because they can change depending on the account’s needs this wouldn’t also be scalable.

For a scalable system, an account administrator needs to be able to add, remove, and assign roles to the members. This can be viewed in web applications like the AWS IAM roles setting for example a feature where one can create a role with a couple of policies or permissions. The admin can then assign this role or someone with admin rights to assign roles to the members of that AWS account. This way, someone with a role ie dev_manager automatically gets the permissions that come with the role for instace ability to invite users, remove them but may not be able to create new roles for instance. This can be very convenient if you have a group of people that need to be assigned similar permissions.

This write-up shows a step-by-step breakdown of how I would approach building a dummy application like this. Let’s use an example of a blog post platform like Medium.com where a publishing company can create an account. The publishing company can have all its other employees invited under the same account with different roles, for instance, a reviewer who would flag a post as ready for release.

To kick things off, we need to visualize how this feature would flow;

Data flow in the application

To get things started let’s create an empty project.

rails new publishing_app
cd publishing_app

Let's install the dependencies we’ll need by adding these to the generated Gemfile like below and run bundle install;

gem 'devise' # For user authentication
gem 'rolify' # For role management
gem 'devise_invitable', '~> 2.0.0' # For iniviting members to a publisher account
gem 'pundit' # For authorizations in the application
gem 'letter_opener', group: :development # For opening emails in browser during development

Each User needs to belong to an Account in our case that being the Publisher. So we need to create migrations for that since other migrations rely on it.

bundle exec rails generate migration CreatePublishers name:string description:text

The generated migration file for Publisher.

class CreatePublishers < ActiveRecord::Migration[7.0]
def change
create_table :publishers do |t|
t.string :name
t.text :description

t.timestamps
end
end
end

Let’s generate User migrations and Devise configurations

bundle exec rails generate devise:install # to generate all the necessary initializations and configurations
bundle rails generate devise User # to generate the model scaffolds.
# There are devise controller scaffolds as well incase someone wants to make changes to any of those files or just check them out by just running
# rails generate devise:controllers [scope]

The User migration will look like this

user.rb

# frozen_string_literal: true

class DeviseCreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
## Database authenticatable
t.string :email, null: false, default: ""
t.string :encrypted_password, null: false, default: ""

## Recoverable
t.string :reset_password_token
t.datetime :reset_password_sent_at

## Rememberable
t.datetime :remember_created_at

## Trackable
# t.integer :sign_in_count, default: 0, null: false
# t.datetime :current_sign_in_at
# t.datetime :last_sign_in_at
# t.string :current_sign_in_ip
# t.string :last_sign_in_ip

## Confirmable
# t.string :confirmation_token
# t.datetime :confirmed_at
# t.datetime :confirmation_sent_at
# t.string :unconfirmed_email # Only if using reconfirmable

## Lockable
# t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
# t.string :unlock_token # Only if unlock strategy is :email or :both
# t.datetime :locked_at
t.references :publisher # because user belongs to a publisher.

t.timestamps null: false
end

add_index :users, :email, unique: true
add_index :users, :reset_password_token, unique: true
# add_index :users, :confirmation_token, unique: true
# add_index :users, :unlock_token, unique: true
end
end

Then let's set up devise_invitable

bundle exec rails generate devise_invitable:install
bundle exec rails generate devise_invitable User

The User Invitable migration at this point would look like this

class DeviseInvitableAddToUsers < ActiveRecord::Migration[7.0]
def up
change_table :users do |t|
t.string :invitation_token
t.datetime :invitation_created_at
t.datetime :invitation_sent_at
t.datetime :invitation_accepted_at
t.integer :invitation_limit
t.references :invited_by, polymorphic: true
t.integer :invitations_count, default: 0
t.index :invitation_token, unique: true # for invitable
t.index :invited_by_id
end
end

def down
change_table :users do |t|
t.remove_references :invited_by, polymorphic: true
t.remove :invitations_count, :invitation_limit, :invitation_sent_at, :invitation_accepted_at, :invitation_token, :invitation_created_at
end
end
end

Then let's set up Rolify as well. In this app, the scope is User, but in another app, it could be Admin or something else.

bundle exec rails generate rolify Role User

The Rolify migration would look like

class RolifyCreateRoles < ActiveRecord::Migration[7.0]
def change
create_table(:roles) do |t|
t.string :name
t.references :resource, :polymorphic => true

t.timestamps
end

create_table(:users_roles, :id => false) do |t|
t.references :user
t.references :role
end

add_index(:roles, :name)
add_index(:roles, [ :name, :resource_type, :resource_id ]) # ensure role is unique per resource and its matching name.
add_index(:users_roles, [ :user_id, :role_id ])
end
end

Let’s run the migrations.

bundle exec rails db:migrate

In this project, we will be using the default db option, the sqlite option but you can configure yours with your DB of choice.

The Models

Let's move on to the models, first the Users.

user. rb

class User < ApplicationRecord
rolify
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
belongs_to :publisher
has_many :permissions, through: :roles


devise :invitable, :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, :invitable # this is for devise invitable

validates_presence_of :publisher
before_validation :assign_role_to_account_owner

private

def assign_role_to_account_owner
admin_role = Role.find_or_create_by(name: "admin", resource: publisher) do |role|
role.permissions = Permission.all
end

add_role(:admin, publisher) if admin_role.persisted? && !invited_by.present?
end
end

Then the Publisher

publisher. rb

class Publisher < ApplicationRecord
has_many :users
accepts_nested_attributes_for :users
# the above method allows us to access the attributes of User through
# publisher and also access the validations and get created through the Publisher


validates :name, presence: true, uniqueness: true, length: {minimum:2}
validates :description, presence: true, if: -> { description.present? }

resourcify
end

The Role would look like
role.rb

class Role < ApplicationRecord
attr_accessor :permission_ids_input # help pick permissions_ids attributes from the Roles form

has_and_belongs_to_many :users, :join_table => :users_roles
belongs_to :resource,
:polymorphic => true,
:optional => true
has_and_belongs_to_many :permissions, join_table: "roles_permissions"

before_validation :assign_permissions_from_input, on: :create
before_validation :normalize_name

validates :resource_type,
:inclusion => { :in => Rolify.resource_types },
:allow_nil => true

validates :name, presence: true
validates :name, uniqueness: { scope: [:resource_type, :resource_id], message: "Role already exists for this resource" }
validate :at_least_one_permission

scopify # A method added from Rolify

private

def assign_permissions_from_input
self.permission_ids = permission_ids_input&.reject(&:blank?) if permission_ids_input.present?
end

def at_least_one_permission
errors.add(:base, 'At least one permission is required') if permissions.empty?
end

def normalize_name
self.name = normalize_string(name)
end

def normalize_string(str)
str.downcase.gsub(/[^a-z0-9]/, '_')
end

end

The permission model would look like
permission. rb

class Permission < ApplicationRecord
has_and_belongs_to_many :roles
end

Since we have a need for a join table for permissions and roles based on this line in the role.rb model

has_and_belongs_to_many :permissions, join_table: "roles_permissions"

We also need to create a migration for that and migrate it as well

class RolesPermissionsJoinTable < ActiveRecord::Migration[7.0]
def change
create_table :roles_permissions, id: false do |t|
t.references :role, null: false
t.references :permission, null: false
end
add_index(:roles_permissions, [ :permission_id, :role_id ], unique: true)
end
end

For how these models relate to each other, please see the diagram at the start of this blog. :)

Controllers:

My controller structure looks like this

├── application_controller.rb
├── concerns
└── users
├── invitations_controller.rb
├── publishers_controller.rb
├── roles_controller.rb
├── sessions_controller.rb
└── users_controller.rb

To be able to generate the invitations_controller.rb, I had to run

rails generate devise_invitable:views users

This generated views for both edit.html.erb and new.html.erb and controllers like invitations_controller.rb to allow us to edit the invitations scaffolds to add any code according to our preference. It so happens that we need to make the following modifications;
1. We need to make sure that any other users apart from the account owners need to be invited with a role attached to them. Like the snippet below;

<div>
<label>Roles:</label>
<% Role.where(resource_id: current_user.publisher.id, resource_type: "Publisher").where.not(name: "admin").each do |role| %>
<%= check_box_tag "role[role_ids][]", role.id %>
<%= role.name&.humanize %><br>
<% end %>
</div>

2. We need to swap out the error messaging for the shared alerts to use a simple alerting system in the form as demonstrated below;

<% resource.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>

app/controllers/users/invitations_controller.rb

class Users::InvitationsController < Devise::InvitationsController
before_action :configure_permitted_parameters, if: :devise_controller?

protected
def after_invite_path_for(_resource)
users_users_path
end

def invite_resource
@resource_roles = Role.where(resource_id: current_user.publisher.id, resource_type: "Publisher").where.not(name: "admin") # roles that belong to that account.

super do |user|
user.publisher = current_user.publisher # the account user must belongs to
user.roles = Role.where(id: params[:role][:role_ids])&.compact_blank # the role to be assigned to this user.
user.save
end
end

def configure_permitted_parameters
devise_parameter_sanitizer.permit(:invite, keys: [:email, :role_ids]) # role_ids is the modification to the permitted params for the invitations
end
end

app/controllers/users/publishers_controller.rb

class Users::PublishersController < ApplicationController
before_action :set_publisher, only: %i[ show ]
before_action :authenticate_user!, only: %i[ show ]

# GET /publishers or /publishers.json
def index
@publishers = Publisher.all
end

# GET /publishers/1 or /publishers/1.json
def show
end

# GET /publishers/new
def new
@publisher = Publisher.new
@publisher.users.build
end

# POST /publishers or /publishers.json
def create
@publisher = Publisher.new(publisher_params)

respond_to do |format|
if @publisher.save
format.html { redirect_to users_publisher_url(@publisher), notice: "Publisher was successfully created." }
format.json { render :show, status: :created, location: @publisher }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @publisher.errors, status: :unprocessable_entity }
end
end
end

private
# Use callbacks to share common setup or constraints between actions.
def set_publisher
@publisher = Publisher.find(params[:id])
end

# Only allow a list of trusted parameters through.
def publisher_params
params.require(:publisher).permit(:name, :description, users_attributes: [:email, :password, :password_confirmation])
end
end

app/controllers/users/roles_controller.rb

class Users::RolesController < ApplicationController
before_action :authenticate_user!
before_action :set_role, only: %i[ show ]

# GET /roles or /roles.json
def index
@roles = Role.where(resource_id: current_user.publisher.id).all
end

# GET /roles/1 or /roles/1.json
def show
end

# GET /roles/new
def new
@role = Role.new
@user = current_user
end

# POST /roles or /roles.json
def create
@role = Role.new(role_params)
@role.resource = current_user.publisher
respond_to do |format|
if @role.save
format.html { redirect_to users_roles_path, notice: "Role was successfully created." }
format.json { render :show, status: :created, location: @role }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @role.errors, status: :unprocessable_entity }
end
end
end

private
# Use callbacks to share common setup or constraints between actions.
def set_role
@role = role.find(params[:id])
end

# Only allow a list of trusted parameters through.
def role_params
params.require(:role).permit(:name, permission_ids_input: [])
end
end

app/controllers/users/users_controller.rb

class Users::UsersController < ApplicationController
before_action :authenticate_user!
before_action :set_user, only: %i[show]

def index
@users = User.where(publisher: current_user.publisher).all
end

def show
end

private

def set_user
@user = User.find(params["id"])
end
end

The Routes

config/routes.rb

Rails.application.routes.draw do
devise_for :users, controllers: { invitations: 'users/invitations' }

namespace :users do
resources :users
resources :publishers
resources :roles
end
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

# Defines the root path route ("/")
root "users/users#index" # set this to what you prefer
end

The Views

.
├── layouts
│ ├── application.html.erb
│ ├── mailer.html.erb
│ └── mailer.text.erb
└── users
├── invitations
│ ├── edit.html.erb
│ └── new.html.erb
├── mailer
│ ├── invitation_instructions.html.erb
│ └── invitation_instructions.text.erb
├── publishers
│ ├── _form.html.erb
│ ├── _publisher.html.erb
│ ├── _publisher.json.jbuilder
│ ├── edit.html.erb
│ ├── index.html.erb
│ ├── index.json.jbuilder
│ ├── new.html.erb
│ ├── show.html.erb
│ └── show.json.jbuilder
├── roles
│ ├── _form.html.erb
│ ├── _role.html.erb
│ ├── _role.json.jbuilder
│ ├── edit.html.erb
│ ├── index.html.erb
│ ├── index.json.jbuilder
│ ├── new.html.erb
│ ├── show.html.erb
│ └── show.json.jbuilder
└── users
├── _user.html.erb
├── index.html.erb
├── index.json.jbuilder
├── show.html.erb
└── show.json.jbuilder

The changes I made in the app/views/users/invitations/new.html.erb to be able to assign roles during the invitation process.

app/views/users/invitations/new.html.erb

<h2><%= t "devise.invitations.new.header" %></h2>

<%= form_for(resource, as: resource_name, url: invitation_path(resource_name), html: { method: :post }) do |f| %>
<% resource.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>

<% resource.class.invite_key_fields.each do |field| -%>
<div class="field">
<%= f.label field %><br />
<%= f.text_field field %>
</div>
<br/>
<div>
<label>Roles:</label>
<% Role.where(resource_id: current_user.publisher.id, resource_type: "Publisher").where.not(name: "admin").each do |role| %>
<%= check_box_tag "role[role_ids][]", role.id %>
<%= role.name&.humanize %><br>
<% end %>
</div>
<br/>
<hr />
<% end -%>

<div class="actions">
<%= f.submit t("devise.invitations.new.submit_button") %>
</div>
<% end %>

app/views/users/publishers/_form.html.erb

This is the form used to create the Publisher account and the account owner.

<%= form_with(model: publisher, url: users_publishers_path) do |form| %>
<% if publisher.errors.any? %>
<div style="color: red">
<h2><%= pluralize(publisher.errors.count, "error") %> prohibited this publisher from being saved:</h2>

<ul>
<% publisher.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>

<div>
<%= form.label :name, style: "display: block" %>
<%= form.text_field :name %>
</div>
<%= form.fields_for :users do |user_form| %>
<div class="field">
<%= user_form.label :email %><br />
<%= user_form.email_field :email, autofocus: true, autocomplete: "email" %>
</div>

<div class="field">
<%= user_form.label :password %>
<% if @minimum_password_length %>
<em>(<%= @minimum_password_length %> characters minimum)</em>
<% end %><br />
<%= user_form.password_field :password, autocomplete: "new-password" %>
</div>

<div class="field">
<%= user_form.label :password_confirmation %><br />
<%= user_form.password_field :password_confirmation, autocomplete: "new-password" %>
</div>
<% end %>

<div>
<%= form.label :description, style: "display: block" %>
<%= form.text_area :description %>
</div>

<div>
<%= form.submit %>
</div>
<% end %>

For further reference, the link to the repo files will be shared at the end of the blog.
At this point, we need to add Pundit to the application for authorization.

Add this to the application_controller.rb

include Pundit::Authorization

Run the initializer below to generate the policies folder and the app/policies/application_policy.rb file.

bundle exec rails g pundit:install

We’ll go ahead and create a new User Policy inheriting from the application_policy to help us authorize users and limit the scope of who can view users that belong to an account based on the permissions they possess.

app/policies/user_policy.rb

class UserPolicy < ApplicationPolicy
class Scope < Scope
def resolve
if user.user?
scope.all
end
end
end

def index?
user.has_role?(:admin, user&.publisher) or has_permission("view_users")
end

private

def has_permission(permission)
user.permissions.exists?(name: permission)
end
end

We’ll go ahead and add the authorization logic to the users_controller.rb

class Users::UsersController < ApplicationController
...
def index
@users = User.where(publisher: current_user.publisher).all
authorize current_user
end
...
end

This means that any user who isn’t the owner of the account or any user without ‘view_users’ rights will get an exception when they navigate to the index.

Pundit::NotAuthorizedError

Since we haven’t fully handled a fallback screen for the exception, in this example we can explore other ways the authorization may work like showing certain content to authorized users and different content for non-authorised users. So we’ll make the adjustment in our code below

app/views/users/users/index.html.erb

<div id="users">
<% if policy(current_user).index? %>
<% @users.each do |user| %>
<%= render user %>
<p>
<%= link_to "Show this user", users_user_path(user) %>
</p>
<hr />
<% end %>
<% else %>
<div>You're not authorised to view the users</div>
<% end %>
</div>

and omit the ‘authorize current_user’ line from the user controller.

A few final steps to take this for a test drive. We need permissions to be present to be able to create roles in the application. So we will seed a few roles for testing purposes but in a production environment, you’d be better off using the ‘permissions.yml’ file to keep all the new additional permissions and pick them from there to update the production db.

db/seeds.rb

Permission.create!(name: "view_users", controller: "users", description: "can view current publisher account users", controller_method: "index")
Permission.create!(name: "edit_users", controller: "users", description: "can update current publisher account users profiles", controller_method: "update")

Run seeders to Populate the Permissions table to aid in creating the Publisher account roles.

bundle exec rails db:seed

So to test and see the application in operation, we would need to go through the following steps:

  • Create a Publisher account as an owner
  • login and create roles for that account ( Role requires at least one permission to be created.)
  • Invite other users and assign roles to them
  • Invited user accepts invitation and logs in to see if he/she is able to view users for that account based on the permissions they have.
An illustration of how the authorization works.

There’s a lot that can still be done like writing some tests, improving the UI, challenging oneself to have scoped authorizations for system admins and other users, and adding more functionality like creating blog posts. However, this couldn't be covered in this blog post.

Any feedback is appreciated. Thank you.

You can find the demo project files here.

Find me on LinkedIn https://www.linkedin.com/in/muwonge-nicholus-868468144/

--

--

Muwonge Nicholus
Muwonge Nicholus

Written by Muwonge Nicholus

I am a Javascript and Ruby engineer. I love designing user interfaces and currently working in payment systems.

No responses yet