Multitenant scoping using Pundit

672 views Asked by At

I'm using Pundit for authorization and I want to make use of its scoping mechanisms for multi-tenancy (driven by hostname).

I've been doing this manually to date by virtue of:

class ApplicationController < ActionController::Base
  # Returns a single Client record
  def current_client
    @current_client ||= Client.by_host(request.host)
  end
end

And then in my controllers doing things like:

class PostsController < ApplicationController
  def index
    @posts = current_client.posts
  end
end

Pretty standard fare, really.

I like the simplicity of Pundit's verify_policy_scoped filter for ensuring absolutely every action has been scoped to the correct Client. To me, it really is worthy of a 500 error if scoping has not been officially performed.

Given a Pundit policy scope:

class PostPolicy < ApplicationPolicy
  class Scope < Scope
    def resolve
      # have access to #scope => Post class
      # have access to #user => User object or nil
    end
  end
end

Now, Pundit seems to want me to filter Posts by user, e.g.:

def resolve
  scope.where(user_id: user.id)
end

However, in this scenario I actually want to filter by current_client.posts as the default case. I'm not sure how to use Pundit scopes in this situation but my feeling is it needs to look something like:

def resolve
  current_client.posts
end

But current_client is naturally not going to be available in the Pundit scope.

One solution could be to pass current_client.posts to policy_scope:

def index
  @posts = policy_scope(current_client.posts)
end

But I feel this decentralizes my tenancy scoping destroys the purpose of using Pundit for this task.

Any ideas? Or am I driving Pundit beyond what it was designed for?

1

There are 1 answers

0
Pamplemousse On

The most "Pundit-complient" way to deal with this problem would be to create a scope in your Post model:

Class Post < ActiveRecord::Base
  scope :from_user, -> (user) do
    user.posts
  end
end

Then, you will be able to use it in your policy, where user is filled with the current_user from your controller:

class PostPolicy < ApplicationPolicy
  class Scope
    attr_reader :user, :scope

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

    def resolve
      scope.from_user(user)
    end
  end
end

If you are returning an ActiveRecord::Relation from the scope, you can stop reading from here.


If your scope returns an array

The default ApplicationPolicy implement the method show using a where: source.

So if your scope does not return an AR::Relation but an array, one work-around could be to override this show method:

class PostPolicy < ApplicationPolicy
  class Scope
    # same content than above
  end

  def show?
    post = scope.find do |post_in_scope|
      post_in_scope.id == post.id
    end
    post.present?
  end
end

Whatever your implementation is, you just need to use the PostPolicy from your controller the "Pundit-way":

class PostsController < ApplicationController
  def index
    @posts = policy_scope(Post)
  end

  def show
    @post = Post.find(params[:id])
    authorize @post
  end
end