Railsの権限管理のPunditのソースコードを読んでみてまとめました。 本体のコード量的には300行程度なので比較的簡単に読めるかと思います。
1. 目的
下記のようなこと中心にソースコードを見ていきたいと思います。
- 権限管理の実装方法
- 権限管理の仕組みをRailsに組み込む方法
- RailsのGeneratorの作り方
2. 基本情報
対象バージョン
2018/7/22に作られたv2.0.0で確認します。
コード量
全体のrubyコードが1,200行程で、lib
配下だけだと400行程度なのでコード量は少ないと思います。
> cloc pundit/lib ------------------------------------------------------------- Language files blank comment code ------------------------------------------------------------- Ruby 12 95 163 396 ------------------------------------------------------------- SUM: 12 95 163 396 -------------------------------------------------------------
ディレクトリ構成
gemなのでシンプルですが、lib
とspec
ディレクトリがあり、Punditの本体はlib/pundit.rb
とlib/pundit配下
にあります。
pundit ├── lib │ ├── generators │ │ ├── pundit │ │ ├── rspec │ │ └── test_unit │ ├── pundit │ │ ├── policy_finder.rb │ │ ├── rspec.rb │ │ └── version.rb │ └── pundit.rb └── spec ├── policies │ └── post_policy_spec.rb ├── policy_finder_spec.rb ├── pundit_spec.rb └── spec_helper.rb
クラス図
Pundit
モジュールにクラスメソッド(下線あり)やインスタンスメソッドが定義されており、モジュールなのでインクルードすることで、authorize
やpolicy
をインターフェースとして権限を確認することができるようになります。
内部ではPolicyFinder
クラスを使って、権限を確認したいリソースに対応するポリシークラスを取得します。
ポリシークラスは、ApplicationPolicy
を継承して自分で実装します。
※クラス図のPostPolicy
クラスはサンプルで作成したクラスでPundit内のクラスではありません。また、見やすさのためにメソッド名など一部省略しています。
ドキュメント
Punditのインストール方法や簡単な使い方や機能を眺めておくとソースコードリーディングも理解しやすくなります。
Pundit ドキュメント
3. Punditの権限管理の実装する方法
最も基本的な使い方としては、クラスメソッドのauthorize
を呼び出します。(※コントローラーなどにincludeすると思うので実際は直接実行しないと思います)
引数には、userとrecord、query(アクション)を渡し、userがrecordに対してquery(アクション)が可能かどうかをチェックしています。
可能な場合はrecordを返し、可能でない場合はerrorが発生します。
# userがpostを編集可能か user = current_user post = Post.find(params[:id]) Pundit.authorize(user, post, :update?) # => post (可能な場合) # => Pundit::NotAuthorizedErrorPundit::NotAuthorizedError (可能でない場合)
では、Pundit.authorize
メソッドの中身をみます。
policyクラスをuserとrecordで作成し、その後、policyのメソッド(#update?)などを呼び、false
の場合はNotAuthorizedError
を発生させています。
policyクラスの作成では、引数でpolicy_class
が渡された場合はそのクラスで初期化し、なければ、policy!(user, record)
でポリシークラスを探して、初期化しています。
# pundit/lib/pundit.rb module Pundit ... class << self # recordからpolicyを取得し、userとrecordでpolicyを初期化し、userが許可されてない場合errorを発生させる # # @param user [Object] ユーザー # @param record [Object] パーミッションを確認するオブジェクト # @param query [Symbol, String] ポリシー上のメソッド (例 `:update?`) # @param policy_class [Class] ポリシークラスを指定したい場合にClassを渡す (例 `HogePolicy`) # @raise [NotAuthorizedError] falseが返って来た場合に発生 # @return [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 end ... end
policy!
メソッドで、ポリシークラスを探し、初期化している処理をみます。
PolicyFinder
にポリシークラスを探す処理はまかせ、取得したpolicyをuserとrecordで初期化しています。
# pundit/lib/pundit.rb module Pundit ... class << self ... # recordからpolicyを取得する # # @see https://github.com/varvet/pundit#policies # @param user [Object] ユーザー # @param record [Object] ポリシーを取得するオブジェクト # @raise [NotDefinedError] もしポリシーが見つからない場合発生 # @raise [InvalidConstructorError] もしポリシーの初期化が不正な場合発生 # @return [Object] ポリシークラスのインスタンスを返す 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 private def pundit_model(record) record.is_a?(Array) ? record.last : record end end ... end
PolicyFinder#policy!
の実装を確認します。
policy!
とpolicy
は、!
がついたメソッドが!
がついてないメソッドを呼び出して、nilやfalseなどが返って来たらエラーを発生させています。ActiveRecordのsaveとsave!などでも同じように実装しています。
policy
では、find
でポリシークラス(Stroing or Class)を取得し、Stringの場合はClassに変換しています。
find
とfind_class_name
が少しややこしいのですが、配列で名前空間を識別してクラス名を作成したり、渡されたobject
に定義されたpolicy_class
でポリシークラスを作ったりしています。
基本的にはXxxxPolicy
というポリシークラスの命名規則があるので、渡されたobject
からXxxx
を作成するように頑張っている感じが伝わってきます。
# pundit/lib/pundit/policy_finder.rb module Pundit class PolicyFinder attr_reader :object def initialize(object) @object = object end 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 ... private # 配列の場合、名前空間として認識する # authorize(post) # => PostPolicy # authorize([:admin, post]) # => Admin::PostPolicy # authorize([:foo, :bar, post]) # => Foo::Bar::PostPolicy # # policy_classメソッドが定義されていればそのクラスを返す # class Post # def self.policy_class # PostablePolicy # end # end # # それ以外の場合はfind_class_nameにまかし、取得したklassとくっつけて'XxxxPolicy'を返しています # authorize(post) # => PostPolicy 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}" # SUFFIXは"Policy" end end # model_nameはActiveModelのメソッドで post.model_name.to_s => 'Post' のようになります 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 end end
適切なポリシークラスを取得し、初期化したので、権限確認のコードを確認にします。
policy.public_send(query)
は各ポリシークラスのメソッドを呼んでいるだけです。
# pundit/lib/pundit.rb module Pundit ... class << self def authorize(user, record, query, policy_class: nil) # PolicyFinderをつかって適切なポリシークラスが取得され、そして、user, recordで初期化される 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 end ... end
ポリシークラスは次のようにApplicationPolicy
を継承して作成するので、シンプルな権限管理だけのメソッドになります。
class PostPolicy < ApplicationPolicy def update? user.admin? || !record.published? end end
いちおう、ApplicationPolicy
を確認すると、initialize
メソッドでuserとrecordを受け取って初期化することや、各アクションがfalseを返すように実装されています。そのため、PostPolicyなどサブクラスで実装していないメソッドはデフォルトではfalseになるのでNotAuthorizedError
が発生します。
class ApplicationPolicy attr_reader :user, :record def initialize(user, record) @user = user @record = record end def index? false end def show? false end def create? false end def update? false end ... end
このように、ポリシークラスを探して、初期化し、ポリシークラスのメソッドに権限を確認するという流れで権限管理をしていることがわかりました。
4. 権限管理機能をRailsと統合する方法
アプリケーションコントローラーにPundit
をインクルードし、ポリシーを宣言することで、コントローラーのアクションやビューファイルで権限を確認し、アクションの実行可否や表示の可否を判断をできるようになります。
基本的には、コントローラーのauthorize
メソッドや、ビューのpolicy
メソッドはinclude Pundit
をすることでMixInされています。
class ApplicationController < ActionController::Base # Punditをinclude ※メソッドが少し汚れる include Pundit protect_from_forgery end # ポリシークラスを実装 class PostPolicy < ApplicationPolicy def update? user.admin? || !record.published? end end # コントローラーで利用 def update @post = Post.find(params[:id]) authorize @post #=> 権限がなければエラーが発生 if @post.update(post_params) redirect_to @post else render :edit end end # ビューで利用 <% if policy(@post).update? %> <%= link_to "Edit post", edit_post_path(@post) %> <% end %>
まずは、コントローラーのauthorize
メソッドについて見ていきます。
Pundit.authorize(user, record, query)
では、3つの引数が必要でしたが、コントローラー側のauthorize
メソッドではquery
はアクション名、user
はcurrent_user
から暗黙的に取得するような実装になっており、必須な引数はrecord
のみです。
後の、実装内容はPundit.authorize
とほぼ同じです。
# pundit/lib/pundit.rb module Pundit ... def authorize(record, query = nil, policy_class: nil) query ||= "#{action_name}?" # index, show, update, destroyなどコントローラーのアクション名が入る @_pundit_policy_authorized = true # ポリシークラスを探し、userとrecordで初期化する policy = policy_class ? policy_class.new(pundit_user, record) : policy(record) # ポリシークラスのqueryを呼び出し、falseの場合はNotAuthorizedErrorが発生 raise NotAuthorizedError, query: query, record: record, policy: policy unless policy.public_send(query) record end def policy(record) policies[record] ||= Pundit.policy!(pundit_user, record) end # #authorize, #policy, #policy_socpeメソッドの初期化時にポリシーに渡されるユーザー def pundit_user current_user end ...
次に、ビューファイルのpolicy
ヘルパーメソッドを見ます。
include Pundit
時に、コントローラーの場合は、ヘルパーメソッドをいくつか追加しています。
ここで追加されたpolicy
メソッドはポリシーインスタンスを返すので、あとは、自分でアクション(#update?
など)を呼ぶことができます。
# pundit/lib/pundit.rb module Pundit ... # @api private module Helper def policy_scope(scope) pundit_policy_scope(scope) end end included do # Helperモジュールを追加 helper Helper if respond_to?(:helper) # helper_methodで3つヘルパーメソッドを追加 if respond_to?(:helper_method) helper_method :policy helper_method :pundit_policy_scope helper_method :pundit_user end end ... protected # ポリシーインスタンスを取得する # ポリシーインスタンスの再利用のためにpoliciesを使っている def policy(record) policies[record] ||= Pundit.policy!(pundit_user, record) end # ポリシーインスタンスをキャッシュ (privateメソッド) def policies @_pundit_policies ||= {} end ... end
他にも、権限に応じたScopeやStrong Parametersもあるので興味があれば読んで見てください。
5. RailsのGeneratorの作り方
Rails ジェネレータとテンプレート入門 - Railsガイドを一読すると理解しやすいと思います。
Punditでは次の2つのgeneratorが定義されています。
- pundit:install ... ApplicationPoicyを作成する
- pundit:policy ... Policyクラスを作成する(例 pundit:policy user -> app/policies/user_policy.rbが作成される)
次にgenerators配下のディレクトリ構成を確認します。
generatorのコマンドと同様に、pundit/install
、pundit/policy
というディレクトリが定義されています。
また、それぞれのディレクトリ内にUSAGE、xxxx_generator.rb、templatesが存在していることがわかります。
これらはジェネレーターの基本セットで、USAGEはコマンドのhelp、xxx_generator.rbはGeneratorの実際のコード、tempaltesはGeneratorで作成するファイルの元となるテンプレートファイルを配置します。
pundit/lib/generators ├── pundit │ ├── install │ │ ├── USAGE │ │ ├── install_generator.rb │ │ └── templates │ │ └── application_policy.rb │ └── policy │ ├── USAGE │ ├── policy_generator.rb │ └── templates │ └── policy.rb ├── rspec │ ├── policy_generator.rb │ └── templates │ └── policy_spec.rb └── test_unit ├── policy_generator.rb └── templates └── policy_test.rb
5.1. pundit:install
まずは、pundit:install
から見ていきます。
USAGE
は次のようにコマンドの説明が記載されています。
# pundit/lib/generators/pundit/install/USAGE Description: Generates an application policy as a starting point for your application.
次に、install_generator.rb
を見てみます。Rails::Generators::Base
を継承しています。
source_root
でテンプレートディレクトリを指定し、copy_application_policy
メソッドでテンプレートディレクトリ内のapplication_policy.rb
をapp/policies/applicatin_policy.rb
にコピーさせるようにしています。
# pundit/lib/generators/pundit/install/install_generator.rb module Pundit module Generators class InstallGenerator < ::Rails::Generators::Base source_root File.expand_path('templates', __dir__) def copy_application_policy template 'application_policy.rb', 'app/policies/application_policy.rb' end end end end
一応、ApplicationPolicyを軽くみると次のようなシンプルなRubyオブジェクトです。
# pundit/lib/generators/pundit/install/tempaltes/application_policy.rb class ApplicationPolicy attr_reader :user, :record def initialize(user, record) @user = user @record = record end def index? false end ... class Scope attr_reader :user, :scope def initialize(user, scope) @user = user @scope = scope end def resolve scope.all end end end
5.2. pundit:policy [model]
次に、pundit:policy [model]
コマンドをみます。
USAGE
は次のようにコマンドの説明が記載されています。
# pundit/lib/generators/pundit/policy/USAGE Description: Generates a policy for a model with the given name. Example: rails generate pundit:policy user This will create: app/policies/user_policy.rb
次に、policy_generator.rb
を見てみます。
こちらは、Rails::Generators::NamedBase
を継承しています。 NamedBaseはGeneratorコマンドで1つの引数を受け取ります。今回はuserなどのモデル名を渡すことが想定されています。
create_policy
メソッドで、テンプレートのpolicy.rb
をapp/policies/
配下に配置しています。
class_path
, file_name
などのメソッドは、NamedBaseクラスに実装されています。NamedBaseクラスのコード
そして、最後にhook_for :test_framework
でTestUnitやRSpecなどRailsで設定されているテストフレームワークのファイルも生成されます。
# pundit/lib/generators/pundit/policy/policy_generator.rb module Pundit module Generators class PolicyGenerator < ::Rails::Generators::NamedBase source_root File.expand_path('templates', __dir__) def create_policy template 'policy.rb', File.join('app/policies', class_path, "#{file_name}_policy.rb") end hook_for :test_framework end end end
一応、テンプレート内のpolicy.rb
も確認します。
# pundit/lib/generators/pundit/policy/templates/policy.rb <% module_namespacing do -%> class <%= class_name %>Policy < ApplicationPolicy class Scope < Scope def resolve scope.all end end end <% end -%>
hook_forでは、pundit/lib/generators/rspec/policy_generator.rb
やpundit/lib/generators/test_unit/policy_generator.rb
が呼ばれます。
それぞれ中を見ると同じような形でテンプレートからテストファイルの雛形を作るGeneratorが実装されていることがわかります。
# pundit/lib/generators/rspec/policy_generator.rb module Rspec module Generators class PolicyGenerator < ::Rails::Generators::NamedBase source_root File.expand_path('templates', __dir__) def create_policy_spec template 'policy_spec.rb', File.join('spec/policies', class_path, "#{file_name}_policy_spec.rb") end end end end # pundit/lib/generators/test_unit/policy_generator.rb module TestUnit module Generators class PolicyGenerator < ::Rails::Generators::NamedBase source_root File.expand_path('templates', __dir__) def create_policy_test template 'policy_test.rb', File.join('test/policies', class_path, "#{file_name}_policy_test.rb") end end end end
まとめ
PunditはRailsにauthorize(record)
というインターフェースを提供し、recordから適切なポリシークラスを探し、userとrecordで初期化し、ポリシーインスタンスのメソッドを呼び出すというシンプルな実装でした。
ポリシークラスを探して初期化する箇所は、Railsらしい設定より規約になっているなと思いました。
以上です。