【Gem Pundit】Punditの内部実装で学んだこと

この記事は、 PLEX Advent Calendar 2024の24日目の記事です。

はじめに

2024年に株式会社プレックスにエンジニアとして新卒入社した佐藤祐飛(@yuhi_junior)と申します。

業務でGem Punditを利用する機会があり、その内部実装を読んで学びが多くあったため本ブログではそちらを共有したいと思います。

Punditとは

PunditはRuby on Railsのための認可ライブラリで、Policy Objectデザインパターンを簡単に実装することができます。

Punditの使用例

class ApplicationController < ActionController::Base
  include Pundit::Authorization
  ...
end
class PostPolicy
  attr_reader :user, :post

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

  def update?
    user.admin? || !post.published?
  end
end
def update
  @post = Post.find(params[:id])
  authorize @post
  ...
end

Punditが内部で行なっていること

Punditがauthorizeメソッドで行っていることは大きく3つ存在します。特に3つ目はRubyメタプログラミングを利用しているので、その部分を中心に解説していきます。

  1. Policyクラスのインスタンスをキャッシュ
  2. Punditユーザーが定義したPolicyクラスのメソッドを実行
  3. authorizeメソッドの引数に渡されたインスタンスに対応するPolicyクラスを動的に特定

内部実装

今回は上記のPunditの利用例についての内部実装を見ていきます。

Pundit::Authorization#authorize

Postモデルの例におけるコントローラー内で実行されているauthorizeメソッドで、recordには@postが代入されます。action_nameメソッドは実行されているコントローラーのアクション名を返します。その結果queryにはupdate?が代入されます。policy_classにはnilが代入されます。具体の実装はPundit::Authorization#punditメソッドが返すオブジェクトのauthorizeメソッドに定義されています。

def authorize(record, query = nil, policy_class: nil)
  query ||= "#{action_name}?"

  @_pundit_policy_authorized = true

  pundit.authorize(record, query: query, policy_class: policy_class) 
end

https://github.com/varvet/pundit/blob/main/lib/pundit/authorization.rb#L84

Pundit::Authorization#pundit

Pundit::Authorization#punditメソッドはPundit::Contextクラスのインスタンスを返します。Pundit::ContextについてはContextとはで解説します。pundit_userはコントローラー内でcurrent_userメソッドを実行した結果が入ります(実装)。policy_cacheには権限判定のキャッシュに利用する空のハッシュが内部的に定義されます(実装)。これはPunditが内部で行なっていることの2つ目に該当します。

def pundit
  @pundit ||= Pundit::Context.new(
    user: pundit_user,
    policy_cache: Pundit::CacheStore::LegacyStore.new(policies)
  )
end

https://github.com/varvet/pundit/blob/main/lib/pundit/authorization.rb#L33

Pundit::Context#authorize

possibly_namespaced_recordには@postが代入されており、pundit_modelメソッドでは今回はそのまま@postを返します(実装)。possibly_namespaced_というプレフィックスがついているのはPunditがPolicy Namespacingという機能を提供しているためです。

policy!メソッドはpossibly_namespaced_recordから動的にPolicyクラスを特定し、そのインスタンスを返し、policyに代入します。その後、policy.update?メソッドを実行し、権限判定を行います(Punditが内部で行なっていることの2つ目に該当)。もし権限がない場合はNotAuthorizedErrorが発生します。

def authorize(possibly_namespaced_record, query:, policy_class:)
  record = pundit_model(possibly_namespaced_record)
  policy =
    ...
    policy!(possibly_namespaced_record)
  end

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

  record
end

https://github.com/varvet/pundit/blob/main/lib/pundit/context.rb#L56

Pundit::Context#policy!

cached_findメソッドに対してrecord&:policy!を引数として渡しています。cached_findメソッドの内部のyieldの第一引数をレシーバとしてpolicy!メソッドが実行されることになります。このcached_findメソッドはPunditが内部で行なっていることの3つ目に該当します。

def policy!(record)
  cached_find(record, &:policy!)
end

https://github.com/varvet/pundit/blob/main/lib/pundit/context.rb#L86

Pundit::Context#cached_find

Pundit::Contextインスタンス作成時に定義されたpolicy_cachefetchメソッドを叩きます。recordをキーとしてキャッシュが存在すればそのPolicyインスタンスを返し、キャッシュが存在しなければブロックが実行されます(実装)。ブロック内部ではまず、policy_finderメソッドを実行して、recordに対応するPolicyを特定するためのPolicyFinderインスタンスを取得します(実装)。その後、Pundit::Context#policy!で述べたとおり、yieldによってPolicyFinderインスタンス.policy!が実行されます。modelには@postがそのまま代入され、Policyクラスのインスタンスが返されます。

def cached_find(record)
  policy_cache.fetch(user: user, record: record) do
    klass = yield policy_finder(record)
    
    ...

    model = pundit_model(record)

    begin
        klass.new(user, model)
    rescue ArgumentError
        raise InvalidConstructorError, "Invalid #<#{klass}> constructor is called"
    end
  end
end

https://github.com/varvet/pundit/blob/main/lib/pundit/context.rb#L147

Pundit::PolicyFinder#policy!

policy!メソッドは実質policyメソッドを実行しています。findメソッドでいよいよPolicyクラスを特定します。

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

https://github.com/varvet/pundit/blob/main/lib/pundit/policy_finder.rb#L46

Pundit::PolicyFinder#find

findメソッドはfind_class_nameメソッドを実行して、Policyクラス名を特定します。find_class_nameメソッドの内部でsubject.classが実行され、@post.classが返されます。その後、find_class_nameメソッドの結果にSUFFIXが付与され、PostPolicyが返されます。

def find(subject)
  ...
  else
    klass = find_class_name(subject)
    "#{klass}#{SUFFIX}"
  end
end

def find_class_name(subject)
  ...
  else
    subject.class
  end
end

https://github.com/varvet/pundit/blob/main/lib/pundit/policy_finder.rb#L86 https://github.com/varvet/pundit/blob/main/lib/pundit/policy_finder.rb#L110

Contextとは

僕が初めてこのコードを読んだ時はContextって何?Pundit::AuthorizationにそのままContextクラスの内容を定義すれば良いのではないか?と思いました。

Punditのレポジトリを漁ると以下のようなCOMMITを見つけました。

元々はPundit::Authorization#authorizeにおいてPundit.authorizeを叩いていましたが、Pundit::Contextクラスを導入する変更がなされています。また、Pundit.authorizeでもPundit::Context#authorizeを呼び出すように変更されています。

+ def pundit
+   @pundit ||= Pundit::Context.new(
+     user: pundit_user,
+     policy_cache: policies
+   )
+ end

  def authorize(record, query = nil, policy_class: nil)
    query ||= "#{action_name}?"

    @_pundit_policy_authorized = true

-   Pundit.authorize(pundit_user, record, query, policy_class: policy_class, cache: policies)
+   pundit.authorize(record, query: query, policy_class: policy_class)
  end
- def authorize(user, possibly_namespaced_record, query, policy_class: nil, cache: {})
-   record = pundit_model(possibly_namespaced_record)
-   policy = if policy_class
-     policy_class.new(user, record)
-   else
-     cache[possibly_namespaced_record] ||= policy!(user, possibly_namespaced_record)
-   end
-   raise NotAuthorizedError, query: query, record: record, policy: policy unless policy.public_send(query)
-   record
+ # @see [Pundit::Context#authorize]
+ def authorize(user, record, query, policy_class: nil, cache: {})
+   Context.new(user: user, policy_cache: cache).authorize(record, query: query, policy_class: policy_class)
  end

https://github.com/varvet/pundit/commit/9045680e4faef2ee87a2273c09d7d77d7d024f6e

Contextの導入によってPundit全体のインターフェースを薄くすることに成功しています。具体的には以下の箇所で、userとcacheをContextに保持することで、authorizeメソッドの引数が減り、authorizeメソッドの呼び出し側のコードが簡潔になります。また、PunditにはScopesPolicy Namespacingなどの機能もあり、それらの機能もContextに閉じ込めることで、Punditのインターフェースを薄くすることに成功しています。userとcacheをContextの責務と捉えてDRY原則を体現していると言えます。

-   Pundit.authorize(pundit_user, record, query, policy_class: policy_class, cache: policies)
+   pundit.authorize(record, query: query, policy_class: policy_class)

Punditの内部実装とデザインパターン

Punditの内部実装を読んでいくと、StrategyパターンとService Locatorパターンが使われていることがわかりました。

Policyクラスに定義されているupdate?などの認可メソッドはqueryとして利用されており、StrategyパターンにおけるStrategyに該当します。Pundit::ContextはそのままContextに該当します。

Pundit::PolicyFinderはPolicyクラスを特定するためのクラスであり、Service Locatorパターンに該当します。

まとめ

Gem Punditの内部実装を読んでいきました。Rubyらしさが溢れていて読むのが楽しく、Rubyのコードリーディング力が向上した気がします。今後もOSSのコードリーディングを続けていきたいなと思います。