I am a Sr. Software Developer at Oracle Cloud. The opinions expressed here are my own and not necessarily those of my employer.
Roles and permissions - switching from CanCanCan to Pundit
Recently we switched our application from CanCanCan to pundit. CanCanCan is a great gem but we outgrew it. Here are the various lessons learned.
First, it’s important to acknowlege that CanCanCan is very easy to get started with and has great integrations with RailsAdmin, Devise and other gems. All permissions are defined in ability.rb
but with time this file can grow quite large. Other downsides are inability to define field level permissions and unit testing role permissions separately from other code.
Pundit separates permissions into individual policy classes which can inherit from each other. So you treat them as POROs with methods.
- Grouping policies
- Mapping roles to permissions
- Beyond RESTful actions
- Require authorize in application controller for all actions
- Headless policies
- Scopes
- Field level permissions
- UI
- Testing
- Usefull links
Grouping policies
Frequently you have lots of models that need to share the same permsisions. So you might not want to create policy files to each model and duplicate code. Since your policy files are just Ruby classes you can do this:
# app/policies/application_policy.rb
class ApplicationPolicy
# define common permissions here
end
class UserPolicy < ApplicationPolicy
# customize permissions for various methods index?, show?, etc
# call super if needed
end
class CommonPolicy < ApplicationPolicy
...
end
Your models may look like this. We are using Mongoid but the same design would work with ActiveRecord.
# app/models/user.rb
class User
belongs_to :client
# will automatically use UserPolicy
end
class Client
has_many :accounts
has_many :users
def self.policy_class
CommonPolicy # manuall specify policy
end
end
class Account
belongs_to :client
def self.policy_class
CommonPolicy
end
end
class Company
belongs_to :client
def self.policy_class
CommonPolicy
end
end
This can be a common case with Single Table Inhertiance. Often the permissions for the different models derived form the same base model are the same so you could share policies.
Alternatively you could create separate policies for Client, Account and Company and then you would not need to do self.policy_class
. You also could specify more granular permissions for Client, Account and Company models if needed.
class ClientPolicy < CommonPolicy
...
end
class AccountPolicy < CommonPolicy
...
end
class CompanyPolicy < CommonPolicy
...
end
Mapping roles to permissions
To keep things simple users can belong to only one client.
class User
has_one :user_client
end
class Client
has_many :user_clients
end
class UserClient
belongs_to :client
belongs_to :user
field :roles, type: Array
extend Enumerize
enumerize :roles, in: [:admin, :readonly_admin, :account_admin, :company_admin],
multiple: true
end
class UserClientPolicy < ApplicationPolicy
...
end
admin
can edit it’s client record and do CRUD on client children records. readonly_admin
can only view all records, account_admin
can do CRUD operations on accounts and company_admin
can do the same for company records. For this we needed to create separate policies for Client, Account and Company models.
Additionally there are system wide roles (for internal users) defined directly on User model. Only internal users can create/destroy create new clients but Client Admins can modify Client attributes.
class User
extend Enumerize
enumerize :roles, in: [:sysadmin, :acnt_mngr], multiple: true
end
This will give internal users access to all records.
class ApplicationPolicy
def index?
return true if @user.roles.include? ['sysadmin', 'acnt_mngr']
end
def show?
index?
end
def update?
index?
end
def edit?
index?
end
def create?
index?
end
def new?
index?
end
def destroy?
# must have higher level permissions
return true if @user.roles.include? ['sysadmin']
end
end
So this works great for granting application wide permissions but client specific users need to be resticted more. Additionally when we are in show
, update
, edit
or destroy
we can get client from the record. In index
we have multiple records and in new
/ create
the record does not exist yet so we need to get client from user.
class User
def get_client_id
user_client.client_id
end
end
class ApplicationPolicy
def get_client_id
# or we could just always get client from user
return @record.client_id if @record.try(:client_id)
return @user.get_client_id
end
end
This will give readonly access to Client records via index
and show
to admin
and readonly_admin
and edit/update access to other roles.
class ClientPolicy
def index?
return true if @user.user_clients.where(client: get_client_id)
.in(roles: ['admin', 'readonly_admin']).count > 0
super
end
def show?
index?
end
def edit?
return true if @user.user_clients.where(client: get_client_id)
.in(roles: ['admin']).count > 0
super
end
def update?
edit?
end
# new?, create? and destroy? are not set so it uses ApplicationPolicy
end
Checking for @user.user_clients.where(client: @record.client).in(roles: ...)
is not DRY so we can extract it into separate class.
# app/services/role_check.rb
class RoleCheck
def initialize user:, client:, roles: nil
@user = user
@client = client
@roles = roles
end
def perform
return true if @user.roles.include? :sysadmin
roles2 = [:admin, @roles].flatten
return true if @user.user_clients.in(client_id: @client)
.in(roles: roles2).count > 0
end
end
#
class ClientPolicy
def index?
RoleCheck.new(user: user, client: get_client_id,
roles: [:client_admin, :readonly_admin]).perform
end
end
Permissions for Account and Company are a little different.
class AccountPolicy
def index?
RoleCheck.new(user: user, client: get_client_id,
roles: [:account_admin, :readonly_admin]).perform
super
end
def show?
index?
end
def edit?
RoleCheck.new(user: user, client: get_client_id,
roles: [:account_admin]).perform
super
end
def update?
edit?
# same checks for new?, create? and destroy?
end
end
class CompanyPolicy
# similar checks using 'company_admin' role
end
You also could use Rolify gem to map users to roles but we already had UserClient model for other reasons so we leveraged that.
Beyond RESTful actions
You start with :index?
, :show?
, etc but then you need to define more custom permissions. Let’s say user has to be admin
to activate?
an account
.
class AccountPolicy
def activate?
# no need to pass admin role as RoleCheck automatically includes it
RoleCheck.new(user: user, client: @record.client).perform
end
end
These kinds of custom actions will usually be specific to only one model but if they are common to several you could push them into lower policy class and inherit from it in the model specific policy.
To check these custom permissions you could create a non-RESTful action in your AccountsController.
class AccountsController < ApplicationController
def activate
authorize @account
@account.update(status: 'active')
end
end
# or to stick with traditional REST actions you create a separate controller
class Accounts::ActivateController < ApplicationController
def update
authorize @account
@account.update(status: 'active')
end
end
Then you just call authorize
.
Require authorize in application controller for all actions
I personally prefer to require authorize for all controller actions even I put def index? true; end
to give everyone access.
class AccountsController < ApplicationController
after_action except: [:index] { authorize @account }
after_action only: [:index] { authorize @accounts }
end
Headless policies
Let’s say you have report_admin
role that allows user to run various reports from the dashboard.
class DashboardPolicy < Struct.new(:user, :dashboard)
def index?
RoleCheck.new(user: user, client: user.get_client_id,
roles: [:report_admin]).perform
end
end
# somehere in the UI navbar
<%= link_to('Dashboard', dashboard_index_path) if policy(:dashboard).index? %> |
Make sure your policy file only contains the basic permission check. When you run rails g pundit:policy dashboard
it will include placeholder for class Scope < Scope
. Otherwise you hit this github issue.
Pundit::NotDefinedError at /dashboard
unable to find policy `DashboardPolicy` for `:dashboard`
Scopes
Internal users can see all records but client specific users can see only accounts and companies scoped to that client.
class AccountPolicy < ApplicationPolicy
...
class Scope < Scope
def resolve
if @user.roles.include? ['sysadmin', 'acnt_mngr']
scope.all
else
scope.in(client_id: @user.get_client_id)
end
end
end
end
Field level permissions
Sometimes you need to define permissions on specific field w/in record. Let’s say that that only sysadmin
can edit Client status field.
class ClientPolicy < ApplicationPolicy
def permitted_attributes
if user.roles.include? :sysadmin
[:name, :status]
else
[:name]
end
end
end
class ClientController < ApplicationController
def update
if @client.update_attributes(permitted_attributes(@client))
...
end
You also want to show/hide the Status field in the Client edit page. Just call permitted_attributes
method.
# app/views/clients/_form.html.erb
<% if policy(@client).permitted_attributes.include? :status %>
<div class="form-inputs">
<%= f.input :status %>
</div>
<% end %>
I am working on a better solution to use CSS visibility or disabled attributes and push the logic into decorator.
UI
In traditional erb/haml server generated UI you can use check recommended on Pundit wiki page.
<% if policy(@account).update? %>
<%= link_to "Edit account", edit_account_path(@account) %>
<% end %>
But what if you are building Single Page Application? We used ActiveModelSerializers and dynamically added methods with define_method
. You could even push some of the common actions into ApplicationSerializer
.
class AccountSerializer < ApplicationSerializer
attributes :id, :name
...
actions = [:index?, :show?, :new?, :create?, :edit?, :update?, :destroy?]
attributes actions
actions.each do |action|
define_method(action) do
policy = "#{object.class.name}Policy".constantize
policy.new(current_user, object).send(action)
end
end
end
Your controller responds with either HTML or JSON output.
class AccountsController < ApplicationController
def index
@accounts = Account.all
respond_to do |format|
format.html
format.json { render json: @accounts }
end
end
end
Now your frontend application JS can use output from http://localhost:3000/accounts.json
to check permissions and show/hide/disable appropriate UI controls.
[
{
id: "1",
name: "account 1",
index?: true,
show?: true,
new?: false,
create?: false,
edit?: null,
update?: null,
destroy?: false
},
]
Testing
Testing these policies interaction with the common RoleCheck
code can get quite repetitive. That’s where stubbing can be a valuable tool. This will simulate passing user, client and roles parameters to RoleCheck and returning true or nil.
# spec/policies/account_policy_spec.rb
permissions :index?, :show? do
it 'valid' do
rl = double('RoleCheck', perform: true)
RoleCheck.stub(:new).with(user: user, client: client,
roles: ['admin', 'readonly_admin']).and_return(rl)
expect(subject).to permit(user, Account.new(client: client))
end
it 'invalid' do
rl = double('RoleCheck', perform: nil)
RoleCheck.stub(:new).with(user: user, client: client,
roles: ['admin', 'readonly_admin']).and_return(rl)
expect(subject).to permit(user, Account.new(client: client))
end
end
permissions :create?, :update?, :new?, :edit?, :destroy? do
it 'valid' do
rl = double('RoleCheck', perform: true)
RoleCheck.stub(:new).with(user: user, client: client,
roles: ['admin']).and_return(rl)
expect(subject).to permit(user, Account.new(client: client))
end
...
end
Also checkout pundit-matchers gem.
Usefull links
- http://blog.carbonfive.com/2013/10/21/migrating-to-pundit-from-cancan/
- https://www.viget.com/articles/pundit-your-new-favorite-authorization-library
- http://through-voidness.blogspot.com/2013/10/advanced-rails-4-authorization-with.html
- https://www.sitepoint.com/straightforward-rails-authorization-with-pundit/
- https://www.varvet.com/blog/simple-authorization-in-ruby-on-rails-apps/
- https://github.com/sudosu/rails_admin_pundit