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:
Your models may look like this. We are using Mongoid but the same design would work with ActiveRecord.
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.
Mapping roles to permissions
To keep things simple users can belong to only one client.
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.
This will give internal users access to all records.
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.
This will give readonly access to Client records via index
and show
to admin
and readonly_admin
and edit/update access to other roles.
Checking for @user.user_clients.where(client: @record.client).in(roles: ...)
is not DRY so we can extract it into separate class.
Permissions for Account and Company are a little different.
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
.
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.
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.
Headless policies
Let’s say you have report_admin
role that allows user to run various reports from the dashboard.
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.
Scopes
Internal users can see all records but client specific users can see only accounts and companies scoped to that client.
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.
You also want to show/hide the Status field in the Client edit page. Just call permitted_attributes
method.
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.
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
.
Your controller responds with either HTML or JSON output.
Now your frontend application JS can use output from http://localhost:3000/accounts.json
to check permissions and show/hide/disable appropriate UI controls.
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.
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