Pundit + Railsで認可の仕組みをシンプルに作る

[Pundit](https://github.com/elabs/pundit)というgemを使ってRailsに認可の仕組みを作ってみます。認可というと[cancancan](https://github.com/CanCanCommunity/cancancan)が有名です。

cancancanはユーザに対して、どんなアクションが許可するかを定義するのに対して、Punditではリソースに対して誰が許可されるのかを定義します。反対からの目線ですね。cancancanがコントローラ寄りならば、Punditはモデル寄りの責務です。また、cancancanがDSLなのに対し、PunditはピュアRubyな書き方になっています。

個人的には複雑で大量の認可設定を書いていくと、ユーザ目線かつDSLで記述していくのは大変だなぁ＆Abilityクラスが肥大化しがちで見通しが悪いと感じたので、Punditに移行しました。Punditのソースコード自体も読みやすく、どのように動いているのかが把握しやすいのもメリットです。ここらへんはDeviseよりもAuthLogicを選ぶのに似ているかもしれません。

- [Authlogic + Railsで認証の仕組みをシンプルに作る](http://qiita.com/zaru/items/a451de2b165a9e422d62)

というわけで、シンプルにかつ複雑なルールでも作りやすいPunditの使い方を紹介します。

## 前提

- Rails 5.1
- Ruby 2.4
- Pundit 1.1.0
- RSpec 3.5

## 認可の仕組み

Punditは、コントローラの各アクションで `authorize リソースオブジェクト` を呼ぶと対象のリソースに対して権限があるかを確認してくれます。そのポリシーを `app/policies` にあるポリシーファイルで細かく定義することができます。

### ポリシーファイル

コントローラのアクション名 + `?` のメソッドを定義し、返り値が `true` なら許可、 `false` なら拒否になります。以下の例だと、 ArticleControllerのindexアクションは必ず拒否されて `Pundit::NotAuthorizedError` の例外が投げられます。（もちろん独自のアクションも同名で定義できます）

```ruby
class ArticlePolicy < ApplicationPolicy
  def index?
    false
  end
end
```

コントローラで以下のように使います。

```ruby
authorize Article
authorize @article
```


また、ApplicationPolicyの方で `@user` と `@record` というインスタンス変数をセットしています。これを利用することでアクセスしているユーザオブジェクトと、対象のリソースオブジェクトを知ることができます。なお、Punditはデフォルトで `current_user` メソッドを呼んで `user` として自動的に引数に渡しています。

```ruby
class ApplicationPolicy
  attr_reader :user, :record

  def initialize(user, record)
    @user = user
    @record = record
  end
end
```

以下のように、参照しようとしているオブジェクトの `user_id` が、アクセスしている `user.id` と一致しているが比較できます。

```ruby
class ArticlePolicy < ApplicationPolicy
  def show?
    record.user_id == user.id
  end
end
```

### コントローラ

ポリシーファイルを作ってもコントローラで設定しないと動きません。`authorize` メソッドにリソースオブジェクトを渡して認可状況を確認します。

```ruby
class ArticlesController < ApplicationController
  def show
    @article = Article.find(params[:id])
    authorize @article
  end
end
```

以上がPunditの認可の仕組みです。

## 使い方

### 初期設定

Punditを適用したいコントローラの継承元でincludeします。

#### app/controllers/application_controller.rb

```ruby
class ApplicationController < ActionController::Base
  include Pundit
end
```

ジェネレータがあるので、叩きます。

```
$ rails g pundit:install
```

`app/policies/application_policy.rb` にファイルが生成されます。これから作るポリシーは `ApplicationPolicy` を継承して作ると楽です。

### 使ってみる

例として User と Article モデルがあり1:Nなhas_many関係にあるもので作ってみます。1ユーザが複数の記事を所持している感じです。scaffoldで作ったスタンダードなRailsアプリを想定しています。

```ruby
class User < ApplicationRecord
  has_many :articles
end

class Article < ApplicationRecord
  belongs_to :user
end
```

#### app/controllers/articles_controller.rb

各アクションの実行前に `authorize` を実行して人か確認をします。

```ruby
class ArticlesController < ApplicationController
  before_action :set_article, only: [:show, :edit, :update, :destroy]
  before_action :authorize_article, only: [:index, :new, :create]
  after_action :verify_authorized
  
  private
    def set_article
      @article = Article.find(params[:id])
      authorize @article
    end

    def authorize_article
      authorize Article
    end
end

```

#### app/policies/article_policy.rb

とりあえず、全部を許可するようなポリシーを作ります。

```ruby
class ArticlePolicy < ApplicationPolicy
  def index?
    true
  end

  def show?
    true
  end

  def create?
    true
  end

  def new?
    create?
  end

  def update?
    true
  end

  def edit?
    update?
  end

  def destroy?
    true
  end
end
```

これでPunditのポリシーが適用されたArticle画面ができました。とはいえ全部認可しているので、自身が保持しているリソースのみアクセスできるようにポリシーを修正します。

```diff
   def show?
-    true
+    record.user_id == user.id
   end
 
   def update?
-    true
+    record.user_id == user.id
   end
   
   def destroy?
-    true
+    record.user_id == user.id
   end
```

以上でPunditを使った認可システムを作ることができると思います。ピュアRubyクラスなので、普通にプログラミングする感覚でコードがかけるので楽ですね。

### スコープ

スコープという機能を紹介します。Punditのスコープを上手く使うことで複雑になりがちな管理画面をシンプルに作ることができます。

例えば記事一覧のページで、自身が保持している記事のみ表示したい場合、 `policy_scope` メソッドを呼ぶと、対象ポリシーファイル内で定義されたスコープを実行してくれます。

```ruby
class ArticlesController < ApplicationController
  before_action :authorize_article, only: [:index]
  
  def index
    @articles = policy_scope(Article)
  end
  
  private
    def authorize_article
      authorize policy_scope(Article)
    end
end
```

```ruby
class ArticlePolicy < ApplicationPolicy
  class Scope < Scope
    def resolve
      scope.where(user_id: user.id)
    end
  end
end
```

#### 管理者とユーザの切り分け

管理者と一般ユーザがいて、管理者はすべてのリソースにアクセスできるが、一般ユーザは自身が保持しているリソースのみといった認可を設定してみます。

管理者はUserモデルにEnumでroleを適用する感じにします。

```
$ rails g migration AddRoleToUser role:integer
```

```ruby
class AddRoleToUser < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :role, :integer, null: false, default: 0
  end
end
```

```ruby
class User < ApplicationRecord
  enum role: { normal: 0, admin: 1 }
end
```

`user.admin?` で管理者かどうか判断できるようになったので、ポリシーを変更します。管理者であれば、全記事閲覧／全記事修正可能というルールです。

```diff
 class ArticlePolicy < ApplicationPolicy
   class Scope < Scope
     def resolve
-      scope.where(user_id: user.id)
+      if user.admin?
+        scope.all
+      else
+        scope.where(user_id: user.id)
+      end
     end
   end
   
   def update?
-    record.user_id == user.id
+    record.user_id == user.id || user.admin?
   end
```

次に削除は管理者のみに許可するようにします。Viewの中で `policy` ヘルパーを呼び出すことできるので、これでボタンの表示非常時をコントロールしています。

```diff
   def destroy?
-    true
+    user.admin?
   end
```

```diff
-        <td><%= link_to 'Destroy', article, method: :delete, data: { confirm: 'Are you sure?' } %></td>
+        <td>
+          <% if policy(article).destroy? %>
+            <%= link_to 'Destroy', article, method: :delete, data: { confirm: 'Are you sure?' } %>
+          <% end %>
+        </td>
```

### StrongParametersの切り分け

ユーザによってPOSTするフィールドが違う場合に対応してみます。

ポリシーファイル内に `permitted_attributes` というメソッドを定義して、その中でStrongParametersに渡すフィールド配列を定義して、コントローラ側で `policy(@article).permitted_attributes` と呼び出すと簡単に切り分けができます。

```ruby
class ArticlePolicy < ApplicationPolicy
  def permitted_attributes
    if user.admin?
      [:title, :description]
    else
      [:title]
    end
  end
  
  def description?
    user.admin?
  end
end
```

```ruby
class ArticlesController < ApplicationController

  private
    def article_params
      params.require(:article).permit(policy(@article).permitted_attributes)
    end
    
end
```

また、必要ないフィールドをViewに表示しないようにするために `description?` というポリシーメソッドを追加して、 `policy(article).description?` で条件分岐ができます。

```ruby
<% if policy(article).description? %>
  <div class="field">
    <%= f.label :description %>
    <%= f.text_field :description %>
  </div>
<% end %>
```

`permitted_attributes_for_create` や `permitted_attributes_for_edit` といったヘルパーも用意されていますが、ちょっと使い勝手が悪いように感じるので、個人的には使っていません。

### policy適用漏れを防ぐ

`verify_authorized` を使えば `authorize` がコールされていない場合に `Pundit::AuthorizationNotPerformedError` 例外になります。各コントローラもしくは継承元のコントローラの `after_action` で呼び出してください。ただしこの機能は開発を手助けするためのものであり、フェールセーフ的に使わない方が良いです。

```
class ApplicationController < ActionController::Base
  include Pundit
  after_action :verify_authorized
end
```

また、 `verify_policy_scoped` というのもあります。

### 例外を403ステータスにする

`Pundit::NotAuthorizedError` 例外を403HTTPステータスにします。

```ruby
config.action_dispatch.rescue_responses["Pundit::NotAuthorizedError"] = :forbidden
```

### current_user以外を使う

DeviseやAuthLogicで標準で存在する `current_user` がない場合は以下のようにすれば変更できます。

```ruby
class ApplicationController < ActionController::Base
  def pundit_user
    current_admin
  end
end
```

### テストコードを書く

RSpec + FactoryGirlを例にします。 `permit` というヘルパーがあるので、これにユーザとリソースオブジェクトを渡すだけです。

```ruby
require 'rails_helper'

RSpec.describe ArticlePolicy do
  subject { described_class }

  permissions :update?, :edit? do
    let(:admin_user) { create(:admin) }
    let(:normal_user) { create(:user) }
    let(:my_article) { Article.create(user_id: normal_user.id) }
    let(:other_article) { Article.create(user_id: create(:user)) }

    it { expect(subject).to permit(normal_user, my_article) }
    it { expect(subject).not_to permit(normal_user, other_article) }
    it { expect(subject).to permit(admin_user, my_article) }
    it { expect(subject).to permit(admin_user, other_article) }

  end
end
```

もろもろ省略していますが、なんとなくニュアンスは伝わるかと。

ポリシーのユニットテスト以外にもリクエストテストで、実際にアクセスができるかどうかのテストを併用すると、より良いと思います。


```ruby
it {
  user1 = create(:user)
  user2 = create(:user)
  article = create(:article, user_id: user1.id)

  login user1
  get articles_path(article)
  expect(response.code).to eq "200"

  login user2
  get articles_path(article)
  expect(response.code).to eq "302"
  expect(response).to redirect_to root_path
}
```