Search code examples
ruby-on-railsrubypundit

Pundit Authorizing Index Action with Non-directly associated models


I am struggling to authorize an index for a model that doesn't have a direct relationship with my User model. Actually, I am struggling to wrap my head around the idea of Pundit scopes.

I understand that I can't authorize @sites within my SitePolicy, and from all the reading I have done, I believe I just need to do it in a Scope within the SitePolicy Class, but am unsure how to do this.

Here's what I have:

Models

User
  has_one :business
  has_many :locations, :through => :business
end

Business
  belongs_to :user
  has_many :locations
end

Location
  extend FriendlyId
  belongs_to :business
  has_one :user, :through => :business
  has_many :sites, dependent: :destroy
  friendly_id :custom_url, use: :slugged
end

Site
  belongs_to :location
end

routes.rb

 resources :locations do
    resources :sites
  end

sites_controller.rb

class SitesController < ApplicationController
  before_action :set_site, only: [:show, :edit, :update, :destroy]
  before_action :set_location, only: [:new, :show, :edit, :index, :update, :destroy]


  def index
    @sites = @location.sites.all
    authorize Site
  end

  private
    def set_site
      @site = Site.find(params[:id])
    end
    def set_location
      @location = Location.friendly.find(params[:location_id])
    end
    def site_params
      params.require(:site).permit(:location_id, :site, :url, :review_site_id, :number_of_reviews, :average_rating, :extra_data)
    end
end

site_policy.rb

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

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

      def resolve
        if user.has_role? :admin
          scope.all
        else
          scope.where(scope.location.user == user)
        end
      end
    end

  def index? 
    return true if user.present? and user.has_role? :admin
  end
  ...

schema.rb

ActiveRecord::Schema.define(version: 2018_05_28_085645) do

  # These are extensions that must be enabled in order to support this database
  enable_extension "plpgsql"

  create_table "businesses", force: :cascade do |t|
    t.string "name"
    t.text "description"
    t.bigint "user_id"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.boolean "more_than_one_location"
    t.boolean "signed_up"
    t.string "slug"
    t.index ["more_than_one_location"], name: "index_businesses_on_more_than_one_location"
    t.index ["user_id"], name: "index_businesses_on_user_id"
  end

  create_table "friendly_id_slugs", force: :cascade do |t|
    t.string "slug", null: false
    t.integer "sluggable_id", null: false
    t.string "sluggable_type", limit: 50
    t.string "scope"
    t.datetime "created_at"
    t.index ["slug", "sluggable_type", "scope"], name: "index_friendly_id_slugs_on_slug_and_sluggable_type_and_scope", unique: true
    t.index ["slug", "sluggable_type"], name: "index_friendly_id_slugs_on_slug_and_sluggable_type"
    t.index ["sluggable_id"], name: "index_friendly_id_slugs_on_sluggable_id"
    t.index ["sluggable_type"], name: "index_friendly_id_slugs_on_sluggable_type"
  end

  create_table "locations", force: :cascade do |t|
    t.string "location_name"
    t.string "address_line_1"
    t.string "address_line_2"
    t.string "city"
    t.string "region"
    t.string "country"
    t.string "postal_code"
    t.string "website"
    t.string "phone_number"
    t.string "location_contact_email"
    t.bigint "business_id"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.string "slug", null: false
    t.string "custom_url", null: false
    t.index ["business_id"], name: "index_locations_on_business_id"
    t.index ["custom_url"], name: "index_locations_on_custom_url", unique: true
    t.index ["slug"], name: "index_locations_on_slug", unique: true
  end

  create_table "roles", force: :cascade do |t|
    t.string "name"
    t.string "resource_type"
    t.bigint "resource_id"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["name", "resource_type", "resource_id"], name: "index_roles_on_name_and_resource_type_and_resource_id"
    t.index ["resource_type", "resource_id"], name: "index_roles_on_resource_type_and_resource_id"
  end

  create_table "sites", force: :cascade do |t|
    t.bigint "location_id"
    t.string "site"
    t.string "url"
    t.string "review_site_id"
    t.integer "number_of_reviews"
    t.decimal "average_rating"
    t.jsonb "extra_data", default: {}, null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["extra_data"], name: "index_sites_on_extra_data", using: :gin
    t.index ["location_id"], name: "index_sites_on_location_id"
  end

  create_table "users", force: :cascade do |t|
    t.string "name"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.string "email", default: "", null: false
    t.string "encrypted_password", default: "", null: false
    t.string "reset_password_token"
    t.datetime "reset_password_sent_at"
    t.datetime "remember_created_at"
    t.integer "sign_in_count", default: 0, null: false
    t.datetime "current_sign_in_at"
    t.datetime "last_sign_in_at"
    t.inet "current_sign_in_ip"
    t.inet "last_sign_in_ip"
    t.index ["email"], name: "index_users_on_email", unique: true
    t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
  end

  create_table "users_roles", id: false, force: :cascade do |t|
    t.bigint "user_id"
    t.bigint "role_id"
    t.index ["role_id"], name: "index_users_roles_on_role_id"
    t.index ["user_id", "role_id"], name: "index_users_roles_on_user_id_and_role_id"
    t.index ["user_id"], name: "index_users_roles_on_user_id"
  end

  add_foreign_key "businesses", "users"
  add_foreign_key "locations", "businesses"
  add_foreign_key "sites", "locations"
end

Solution

  • To authorize collections of records, you normally do this with a scope rather than authorizing the action itself. In other words, instead of this:

    def index
      @sites = @location.sites.all
      authorize Site
    end
    

    You need to do this:

    def index
      @sites = policy_scope(@location.sites)
    end
    

    Since the link between a site and the user goes through several models, you unfortunately need to JOIN all the way back to the user in order to perform this query in SQL:

    class SitePolicy < ApplicationPolicy
      class Scope < Scope
        def resolve
          if user.has_role? :admin
            scope.all
          else
            scope.joins(location: :business)
                 .where(locations: {businesses: {user: user}})
          end
        end
      end
    end
    

    Following this design pattern is why Pundit recommends adding the following code to your application:

    class ApplicationController < ActionController::Base
      include Pundit
      after_action :verify_authorized, except: :index
      after_action :verify_policy_scoped, only: :index
    end