What is the DRY way to restrict an entire controller with Pundit in Rails?
Asked Answered
G

3

6

I'm using Pundit with Rails, and I have a controller that I need to completely restrict from a specific user role. My roles are "Staff" and "Consumer." The staff should have full access to the controller, but the consumers should have no access.

Is there a way to do this that is more DRY than restricting each action one-by-one?

For instance, here is my policy:

class MaterialPolicy < ApplicationPolicy
  attr_reader :user, :material

  def initialize(user, material)
    @user     = user
    @material = material
  end

  def index?
    user.staff?
  end

  def show?
    index?
  end

  def new?
    index?
  end

  def edit?
    index?
  end

  def create?
    index?
  end

  def update?
    create?
  end

  def destroy?
    update?
  end
end

And my controller:

class MaterialsController < ApplicationController
  before_action :set_material, only: [:show, :edit, :update, :destroy]

  # GET /materials
  def index
    @materials = Material.all
    authorize @materials
  end

  # GET /materials/1
  def show
    authorize @material
  end

  # GET /materials/new
  def new
    @material = Material.new
    authorize @material
  end

  # GET /materials/1/edit
  def edit
    authorize @material
  end

  # POST /materials
  def create
    @material = Material.new(material_params)
    authorize @material

    respond_to do |format|
      if @material.save
        format.html { redirect_to @material, notice: 'Material was successfully created.' }
      else
        format.html { render :new }
      end
    end
  end

  # PATCH/PUT /materials/1
  def update
    authorize @material
    respond_to do |format|
      if @material.update(material_params)
        format.html { redirect_to @material, notice: 'Material was successfully updated.' }
      else
        format.html { render :edit }
      end
    end
  end

  # DELETE /materials/1
  def destroy
    authorize @material
    @material.destroy
    respond_to do |format|
      format.html { redirect_to materials_url, notice: 'Material was successfully destroyed.' }
    end
  end

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

    # Never trust parameters from the scary internet, only allow the white list through.
    def material_params
      params.require(:material).permit(:name)
    end
end

Is there a way to do this that I'm not understanding, or is that how Pundit is designed, to require you to be explicit?

Grunter answered 6/2, 2019 at 2:16 Comment(1)
Does this answer your question? How to make pundit policies more DRY?Slather
S
9

The first step is just to move the call to authorize to your callback:

def set_material
  @material = Material.find(params[:id])
  authorize @material
end

You can also write @material = authorize Material.find(params[:id]) if your Pundit version is up to date (previous versions returned true/false instead of the record).

Pundit has a huge amount of flexibility in how you choose to use it. You could for example create a separate headless policy:

class StaffPolicy < ApplicationPolicy
  # the second argument is just a symbol (:staff) and is not actually used
  def initialize(user, symbol)
    @user = user
  end
  def access?
    user.staff?
  end
end

And then use this in a callback to authorize the entire controller:

class MaterialsController < ApplicationController
  before_action :authorize_staff
  # ...

  def authorize_staff
    authorize :staff, :access?
  end
end

Or you can just use inheritance or mixins to dry your policy class:

class StaffPolicy < ApplicationPolicy
  %i[ show? index? new? create? edit? update? delete? ].each do |name|
    define_method name do
      user.staff?
    end
  end
end

class MaterialPolicy < StaffPolicy
  # this is how you would add additional restraints in a subclass
  def show?
    super && some_other_condition
  end
end

Pundit is after all just plain old Ruby OOP.

Septuagesima answered 6/2, 2019 at 10:40 Comment(1)
what about in the cases where I have to create the new object and then authorize? like @material = Material.new; authorize @material; Is it okay to keep this for every method or maybe move it into callback as well? which goes for create and new, but for create I have to use params.Slather
S
1

Pundit doesn't require you to be explicit, but it allows it. If the index? method in your policy wasn't duplicated, you'd want the ability to be explicit.

You can start by looking at moving some of the authorization checks into the set_material method, that cuts down over half of the checks.

The other half could be abstracted out into other private methods if you wanted, but I think they're fine as-is.

You could also look at adding a before_action callback to call the authorizer based on the action name, after you've memoized @material via your other callback, but readability is likely to suffer.

Siena answered 6/2, 2019 at 5:8 Comment(2)
Thanks. That makes sense. Is there any way to make the policy class any more concise? Right now the only way I can get it to work is to explicitly list each action in the policy and declare who is able to access that action. Is there not some way to set all of them at once in the policy? I was playing around with Scope but can't get it working.Grunter
You can use something like alias or alias_method. We write our policies just as you've written yours. It makes modifying them easier later. The scopes are a bit trickier; you have to use policy_scope or call the resolve method of the policy explicitly (e.g. MaterialPolicy::Scope.new(current_user, Material).resolve`. As long as you have your policy inherit from a base ApplicationPolicy following the example, or implement the same structure itself, it should "just work" github.com/varvet/pundit#scopes. If you're still having problems with scope drop a question with what you haveSiena
T
0

Use the second argument for the authorize method. Eg:

authorize @material, :index?

You can now remove all the other methods that just calls index?

Tanbark answered 6/2, 2019 at 2:23 Comment(3)
Do you mean in the policy? Is there any way to restrict access on a controller as a whole, and not have to call authorize on each action?Grunter
@LeeMcAlilly I think you can use before_action since you need only the userTanbark
Thanks a before action did the trick for the controller, but that policy file still seems wrong. Is there not a more concise way to express that?Grunter

© 2022 - 2024 — McMap. All rights reserved.