Managing complex role-based access control (RBAC) in Ruby on Rails can be a tricky beast, but the Pundit gem makes it a whole lot easier. I’ve been using Pundit for years now, and it’s become my go-to solution for handling authorization in Rails apps. Let’s dive into how to set it up and use it effectively.
First things first, you’ll want to add the Pundit gem to your Gemfile:
gem 'pundit'
Then run bundle install
to install it. Once that’s done, you’ll need to include Pundit in your ApplicationController:
class ApplicationController < ActionController::Base
include Pundit
end
Now, let’s say we have a Blog model and we want to control who can create, read, update, and delete blog posts. We’ll start by generating a policy for our Blog model:
rails generate pundit:policy blog
This will create a file called blog_policy.rb
in the app/policies
directory. This is where we’ll define our authorization rules.
Here’s what a basic Blog policy might look like:
class BlogPolicy < ApplicationPolicy
def index?
true
end
def show?
true
end
def create?
user.present?
end
def update?
user.present? && (user.admin? || record.user == user)
end
def destroy?
user.present? && (user.admin? || record.user == user)
end
end
In this policy, we’re saying that anyone can view the index and show pages, but only logged-in users can create new blog posts. For updating and deleting, we’re allowing it if the user is an admin or if they’re the owner of the blog post.
Now, in our BlogsController, we can use these policies like this:
class BlogsController < ApplicationController
def index
@blogs = Blog.all
authorize @blogs
end
def show
@blog = Blog.find(params[:id])
authorize @blog
end
def new
@blog = Blog.new
authorize @blog
end
def create
@blog = Blog.new(blog_params)
@blog.user = current_user
authorize @blog
if @blog.save
redirect_to @blog, notice: 'Blog was successfully created.'
else
render :new
end
end
def update
@blog = Blog.find(params[:id])
authorize @blog
if @blog.update(blog_params)
redirect_to @blog, notice: 'Blog was successfully updated.'
else
render :edit
end
end
def destroy
@blog = Blog.find(params[:id])
authorize @blog
@blog.destroy
redirect_to blogs_url, notice: 'Blog was successfully destroyed.'
end
end
The authorize
method checks the corresponding method in the policy (e.g., index?
for the index action) and raises a Pundit::NotAuthorizedError if the action isn’t allowed.
But what if we want to get more granular with our permissions? Say we want to allow moderators to edit blog posts, but not delete them. We can add a moderator?
method to our User model:
class User < ApplicationRecord
def moderator?
role == 'moderator'
end
def admin?
role == 'admin'
end
end
Then we can update our BlogPolicy:
class BlogPolicy < ApplicationPolicy
def update?
user.present? && (user.admin? || user.moderator? || record.user == user)
end
def destroy?
user.present? && (user.admin? || record.user == user)
end
end
Now moderators can update blog posts, but only admins and the post owner can delete them.
But what if we want to restrict what fields a moderator can edit? We can create a separate method for this:
class BlogPolicy < ApplicationPolicy
def permitted_attributes
if user.admin?
[:title, :content, :published]
elsif user.moderator?
[:title, :content]
else
[]
end
end
end
And in our controller:
def update
@blog = Blog.find(params[:id])
authorize @blog
if @blog.update(blog_params)
redirect_to @blog, notice: 'Blog was successfully updated.'
else
render :edit
end
end
private
def blog_params
params.require(:blog).permit(policy(@blog).permitted_attributes)
end
This way, moderators can edit the title and content, but only admins can change the published status.
Now, let’s say we want to implement a more complex scenario. Maybe we have a multi-tenant system where users belong to different organizations, and we want to allow organization admins to manage all blogs within their organization.
First, let’s add an organization association to our User and Blog models:
class User < ApplicationRecord
belongs_to :organization
# ...
end
class Blog < ApplicationRecord
belongs_to :organization
belongs_to :user
# ...
end
Now we can update our BlogPolicy:
class BlogPolicy < ApplicationPolicy
class Scope < Scope
def resolve
if user.admin?
scope.all
else
scope.where(organization: user.organization)
end
end
end
def index?
true
end
def show?
user.admin? || record.organization == user.organization
end
def create?
user.present?
end
def update?
user.admin? ||
(record.organization == user.organization && (user.organization_admin? || record.user == user))
end
def destroy?
user.admin? ||
(record.organization == user.organization && (user.organization_admin? || record.user == user))
end
end
Here, we’re using Pundit’s scope feature to filter what blogs a user can see. We’re also updating our other methods to take into account the user’s organization and whether they’re an organization admin.
In our controller, we’d use it like this:
def index
@blogs = policy_scope(Blog)
end
This will automatically apply the scope we defined in our policy.
One of the things I love about Pundit is how it encourages you to keep your authorization logic separate from your models and controllers. This makes it much easier to reason about and test your authorization rules.
Speaking of testing, Pundit makes it pretty straightforward to test your policies. Here’s an example of how you might test the BlogPolicy we’ve been working with:
require 'rails_helper'
RSpec.describe BlogPolicy do
subject { described_class }
let(:organization) { Organization.create! }
let(:other_organization) { Organization.create! }
let(:admin) { User.create!(role: 'admin', organization: organization) }
let(:org_admin) { User.create!(role: 'org_admin', organization: organization) }
let(:user) { User.create!(organization: organization) }
let(:other_user) { User.create!(organization: other_organization) }
let(:blog) { Blog.create!(user: user, organization: organization) }
permissions :show? do
it "allows anyone to see blogs in their organization" do
expect(subject).to permit(user, blog)
expect(subject).to permit(org_admin, blog)
end
it "does not allow users from other organizations to see the blog" do
expect(subject).not_to permit(other_user, blog)
end
it "allows admins to see any blog" do
expect(subject).to permit(admin, blog)
end
end
permissions :update? do
it "allows org admins to update any blog in their organization" do
expect(subject).to permit(org_admin, blog)
end
it "allows users to update their own blogs" do
expect(subject).to permit(user, blog)
end
it "does not allow users to update other users' blogs" do
expect(subject).not_to permit(other_user, Blog.create!(user: other_user, organization: other_organization))
end
it "allows admins to update any blog" do
expect(subject).to permit(admin, blog)
end
end
# ... more tests for other actions ...
end
These tests help ensure that our authorization logic is working as expected and make it easier to refactor with confidence.
Now, let’s talk about some advanced features of Pundit that can really level up your RBAC game. One of my favorites is the ability to use Pundit outside of controllers. This can be super useful when you need to check permissions in, say, a background job or a mailer.
Here’s how you might use it in a mailer:
class BlogMailer < ApplicationMailer
def new_blog_notification(user, blog)
if Pundit.policy(user, blog).show?
mail(to: user.email, subject: "New Blog Post: #{blog.title}")
end
end
end
This ensures that we only send notifications to users who are actually allowed to view the blog post.
Another cool feature is the ability to define custom query methods. Let’s say we want to allow users to feature blogs, but only if they’re an admin or if they’ve written more than 10 approved blogs. We could do this:
class BlogPolicy < ApplicationPolicy
def feature?
user.admin? || (user.blogs.approved.count > 10)
end
end
Then in our controller:
def feature
@blog = Blog.find(params[:id])
authorize @blog, :feature?
@blog.update(featured: true)
redirect_to @blog, notice: 'Blog was successfully featured.'
end
Pundit also allows you to use policy namespaces, which can be really useful for organizing complex authorization schemes. For example, if you have different rules for the admin section of your site, you could create an Admin::BlogPolicy:
class Admin::BlogPolicy < ApplicationPolicy
def index?
user.admin?
end
def show?
user.admin?
end
# ... other methods ...
end
Then in your Admin::BlogsController:
class Admin::BlogsController < ApplicationController
def index
@blogs = Blog.all
authorize [:admin, @blogs]
end
# ... other actions ...
end
This keeps your admin policies separate from your regular policies, making your code more organized and easier to manage.
One last tip: Pundit plays really well with Rails’ strong parameters. You can use the permitted_attributes
method we defined earlier directly in your controller:
def blog_params
params.require(:blog).permit(policy(@blog).permitted_attributes)
end
This ensures that users can only update the attributes they’re allowed to, based on their role and permissions.
In conclusion, Pundit provides a flexible and powerful way to implement RBAC in your Rails applications. It encourages you to keep your authorization logic separate from your business logic, making your code more maintainable and testable. With its scope feature, you can easily filter records based on user permissions, and its ability to work outside of controllers makes it versatile for use throughout your application.
Remember, good authorization is crucial for keeping your application secure. Always think carefully about your authorization rules and test them thoroughly. With Pundit, you have a robust tool at your disposal to implement even the most complex RBAC scenarios. Happy coding!