この記事は、 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のメタプログラミングを利用しているので、その部分を中心に解説していきます。
Policy
クラスのインスタンスをキャッシュ- Punditユーザーが定義した
Policy
クラスのメソッドを実行 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_cache
のfetch
メソッドを叩きます。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にはScopesやPolicy 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のコードリーディングを続けていきたいなと思います。