punditのpolicy_classで別モデルのPolicyを実行する

こんにちは!kossyです!



環境

Ruby 2.6.8
Rails 6.0.4
MacOS BigSur
pundit 2.1.1




なお、Userモデル、Postモデル、Commentモデルが存在するものとします。

policy_classの使い方

例えば、CommentクラスのインスタンスにPostモデルのPolicyを実行したいケースです。

def show
  @comment = Comment.find(params[:id])

  authorize(@comment, policy_class: PostPolicy)
end

このように記述すると、PostPolicyが実行されるようになります。

class PostPolicy < ApplicationPolicy

  def show?
    user.admin?
  end

end

authorizeメソッドの引数にはsymbolで実行したいPolicyのメソッド名も指定することができます。

# PostPolicyクラスのindex?メソッドを実行する

authorize(@comment, :index?, policy_class: PostPolicy)

authorizeメソッドのコードを追ってみる

ではauthorizeメソッドのコードを追ってみて、どのように上記の挙動を実現しているのか確認してみましょう。

github.com

    # Retrieves the policy for the given record, initializing it with the
    # record and user and finally throwing an error if the user is not
    # authorized to perform the given action.
    #
    # @param user [Object] the user that initiated the action
    # @param record [Object] the object we're checking permissions of
    # @param query [Symbol, String] the predicate method to check on the policy (e.g. `:show?`)
    # @param policy_class [Class] the policy class we want to force use of
    # @raise [NotAuthorizedError] if the given query method returned false
    # @return [Object] Always returns the passed object record
    def authorize(user, record, query, policy_class: nil)
      policy = policy_class ? policy_class.new(user, record) : policy!(user, record)

      raise NotAuthorizedError, query: query, record: record, policy: policy unless policy.public_send(query)

      record.is_a?(Array) ? record.last : record
    end

コメントアウト部分を日本語に訳してみます。

Retrieves the policy for the given record, initializing it with the record and user and finally throwing an error
if the user is not authorized to perform the given action.

指定されたレコードのポリシーを取得し、レコードとユーザーで初期化し、
ユーザーが指定されたアクションの実行を許可されていない場合は、最後にエラーをスローします。

@param user [Object] アクションを開始したユーザー
@param record [Object]パーミッションをチェックしているオブジェクト
@param query [Symbol、String]ポリシーをチェックするためのメソッド(例: show?)
@param policy_class [Class]強制的に使用するポリシークラス

指定されたクエリメソッドがfalseを返した場合は@raise [NotAuthorizedError]

@return [Object]常に渡されたオブジェクトレコードを返します

出典: pundit/pundit.rb at master · varvet/pundit · GitHub

引数と返り値についての記載がありました。

メソッドの中身を見ます。

policy = policy_class ? policy_class.new(user, record) : policy!(user, record)

この部分は、引数のpolicy_classが存在する場合、そのpolicy_classをuserとrecordを引数にしてイニシャライズしています。
policy_classがない場合はpolicy!メソッドを実行していますので、処理を見に行ってみます。

policy!

    # Retrieves the policy for the given record.
    #
    # @see https://github.com/varvet/pundit#policies
    # @param user [Object] the user that initiated the action
    # @param record [Object] the object we're retrieving the policy for
    # @raise [NotDefinedError] if the policy cannot be found
    # @raise [InvalidConstructorError] if the policy constructor called incorrectly
    # @return [Object] instance of policy class with query methods
    def policy!(user, record)
      policy = PolicyFinder.new(record).policy!
      policy.new(user, pundit_model(record))
    rescue ArgumentError
      raise InvalidConstructorError, "Invalid #<#{policy}> constructor is called"
    end

コメントアウト部分を日本語に訳してみます。

Retrieves the policy for the given record.

指定されたレコードのポリシーを取得します。

@param user [Object] アクションを開始したユーザー
@param record [Object]ポリシーを取得するオブジェクト
ポリシーが見つからない場合は@raise [NotDefinedError]
ポリシーコンストラクターが誤って呼び出された場合は@raise [InvalidConstructorError]
@return [Object]クエリメソッドを使用したポリシークラスのインスタンス

出典: pundit/pundit.rb at master · varvet/pundit · GitHub

引数と返り値、例外が発生する条件について記載がありました。

処理を見てみます。

policy = PolicyFinder.new(record).policy!
policy.new(user, pundit_model(record))

PolicyFinderインスタンスのpolicy!メソッドを見る必要がありそう。


def initialize(object)
  @object = object
end

# @return [Class] policy class with query methods
# @raise [NotDefinedError] if policy could not be determined
#
def policy!
  policy or raise NotDefinedError, "unable to find policy `#{find(object)}` for `#{object.inspect}`"
end

def policy
  klass = find(object)
  klass.is_a?(String) ? klass.safe_constantize : klass
end

def find(subject)
  if subject.is_a?(Array)
    modules = subject.dup
    last = modules.pop
    context = modules.map { |x| find_class_name(x) }.join("::")
    [context, find(last)].join("::")
  elsif subject.respond_to?(:policy_class)
    subject.policy_class
  elsif subject.class.respond_to?(:policy_class)
    subject.class.policy_class
  else
    klass = find_class_name(subject)
    "#{klass}#{SUFFIX}"
  end
end

def find_class_name(subject)
  if subject.respond_to?(:model_name)
    subject.model_name
  elsif subject.class.respond_to?(:model_name)
    subject.class.model_name
  elsif subject.is_a?(Class)
    subject
  elsif subject.is_a?(Symbol)
    subject.to_s.camelize
  else
    subject.class
  end
end

findメソッドかfind_class_nameメソッドでPolicyを実行するクラスを返して、返されたモデル名がString型ならsafe_constantizeメソッドでクラスとして扱えるようにし、
String型でなければPolicyを実行するクラスをそのまま返却しています。

つまり、PolicyFinderクラスのインスタンスメソッドであるpolicy!メソッドの返り値はPolicyを実行するクラスが返ることになります。

だいぶコードを追ったので改めてPunditモジュールのpolicy!メソッドの処理を見ます。

def policy!(user, record)
  policy = PolicyFinder.new(record).policy!
  policy.new(user, pundit_model(record))
rescue ArgumentError
  raise InvalidConstructorError, "Invalid #<#{policy}> constructor is called"
end

引数に誤りがあれば例外を投げて、とくに誤りがなければPolicyFinderインスタンスのpolicy!メソッドで見つけたクラスをnewして返却しています。


ようやくauthorizeメソッドに返ってくることができます、、、

def authorize(user, record, query, policy_class: nil)
  policy = policy_class ? policy_class.new(user, record) : policy!(user, record)

  raise NotAuthorizedError, query: query, record: record, policy: policy unless policy.public_send(query)

  record.is_a?(Array) ? record.last : record
end

引数のpolicy_classが存在する場合、そのpolicy_classをuserとrecordを引数にしてイニシャライズしています。
policy_classが渡されていない場合は、recordのクラスのPolicyインスタンスを返却しています。

public_sendの返り値がnilまたはfalseの場合はNotAuthorizedErrorをthrowしています。

recordが配列ならrecordの最後のインスタンスを、配列でなければrecordをそのまま返します。

余談

authorizeの返り値がobjectなので、

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

のような使い方ができるんですね。

github.com


感想

policy_classを用いればかなり柔軟にPolicyを実行することができるので、エッジケースに対応できそうです。


大いに参考にさせていただいた記事

素晴らしいコンテンツの提供誠にありがとうございます。

pundit/README.md at master · varvet/pundit · GitHub