ruby-on-railsrubypundit

Rails 7 - pundit scope for has many through


I recently started using the Pundit gem for authorization in my Rails app. In my app I have models for Company, each Company can have multiple Employees, this is done through a has many through relationship:

# company.rb
has_many :employees, dependent: :destroy
has_many :users, through: :employees

# employee.rb
belongs_to :company

# user ...

Employees can either be published or not published. In my view for listing company employees, I want to only display published employees for regular users, but other employees of a given company should see all employees for that company.

In my employees_controller#index I have a solution that currently looks like this:

def index
  @employees = if current_user.is_employee?(@company)
                 @company.employees.all
               else
                 @company.employees.select { |employee|   employee.published == true }
               end
end

This works, but I'm trying to understand how to utilise Pundits scopes to achieve the same.

Pseudo code for a Pundit Scope (that's clearly not working, but gives an example of what I want to achieve):

# EmployeePolicy
class Scope
  def initialize(user, scope)
    @user = user
    @scope = scope
  end

  def resolve
    if user.is_employee?(...) # can't get company from scope
      scope.all
    else
      scope.where(published: true)
    end
  end
end

I've seen some examples of using joins to achieve this, but that seems a bit cumbersome, is there some other recommended way to achieve this using Pundit?


Solution

  • # app/models/user.rb
    class User < ApplicationRecord
      has_many :employees, dependent: :destroy
      has_many :companies, through: :employees
    end
    
    # app/models/employee.rb
    class Employee < ApplicationRecord
      belongs_to :company
      belongs_to :user
    end
    
    # app/models/company.rb
    class Company < ApplicationRecord
      has_many :employees, dependent: :destroy
      has_many :users, through: :employees
    end
    
    Company.create!(name: "one", users: [User.new, User.new])
    Company.create!(name: "two", users: [User.new, User.new])
    Employee.find(3).update(published: true)
    
    >> Employee.all.as_json
    => [{"id"=>1, "published"=>nil,  "company_id"=>1, "user_id"=>1},
        {"id"=>2, "published"=>nil,  "company_id"=>1, "user_id"=>2},
        {"id"=>3, "published"=>true, "company_id"=>2, "user_id"=>3},
        {"id"=>4, "published"=>nil,  "company_id"=>2, "user_id"=>4}]
    
    # app/policies/employee_policy.rb
    
    class EmployeePolicy < ApplicationPolicy
      class Scope < Scope
        def resolve
          scope.where(company: user.company_ids).or(
            scope.where(published: true)
          )
        end
      end
    end
    

    User #1 can see every employee from company #1 and only published from company #2:

    >> Pundit.policy_scope(User.first, Employee).as_json
    => [{"id"=>1, "published"=>nil,  "company_id"=>1, "user_id"=>1},
        {"id"=>2, "published"=>nil,  "company_id"=>1, "user_id"=>2},
        {"id"=>3, "published"=>true, "company_id"=>2, "user_id"=>3}]
    

    User from company #2 can't see other company employees:

    >> Pundit.policy_scope(User.third, Employee).as_json
    => [{"id"=>3, "published"=>true, "company_id"=>2, "user_id"=>3},
        {"id"=>4, "published"=>nil,  "company_id"=>2, "user_id"=>4}]
    

    Scope that is not related to authorization should be applied in a controller:

    >> employee_scope = Pundit.policy_scope(User.first, Employee)
    
    >> employee_scope.where(company: Company.first).as_json
    => [{"id"=>1, "published"=>nil, "company_id"=>1, "user_id"=>1},
        {"id"=>2, "published"=>nil, "company_id"=>1, "user_id"=>2}]
    
    # user #1 authorization to see their own company employees didn't change
    # just currently looking at the other company
    >> employee_scope.where(company: Company.second).as_json
    => [{"id"=>3, "published"=>true, "company_id"=>2, "user_id"=>3}]