Mastering Rails Authorization: Pundit Gem Simplifies Complex Role-Based Access Control

Pundit gem simplifies RBAC in Rails. Define policies, authorize actions, scope records, and test permissions. Supports custom queries, policy namespaces, and strong parameters integration for flexible authorization.

Mastering Rails Authorization: Pundit Gem Simplifies Complex Role-Based Access Control

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!