Rails Webook

自社のECを開発している会社で働いています。Rails情報やサービスを成長させる方法を書いていきます

Railsの権限管理Punditのソースコードリーディング

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なのでシンプルですが、libspecディレクトリがあり、Punditの本体はlib/pundit.rblib/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

クラス図

f:id:nipe880324:20180921105357p:plain:w420

Punditモジュールにクラスメソッド(下線あり)やインスタンスメソッドが定義されており、モジュールなのでインクルードすることで、authorizepolicyをインターフェースとして権限を確認することができるようになります。
内部では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に変換しています。

findfind_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はアクション名、usercurrent_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

他にも、権限に応じたScopeStrong 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/installpundit/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.rbapp/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.rbapp/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.rbpundit/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らしい設定より規約になっているなと思いました。

以上です。