Rails 6で認証認可入り掲示板APIを構築する #15 pundit導入

←[Rails 6で認証認可入り掲示板APIを構築する #14 seed実行時間の表示](https://qiita.com/rf_p/items/e4e840884e925c0cc053)

# 認証と認可の違い

まずはpunditを入れます。
punditは認可を管理するgemです。

認証と認可の違いは何か？

【認証】とは、言わば運転免許証を見せるようなもの。
あなたが何者であるかを証明する処理です。

【認可】はその免許証に書かれている、どんな車に乗れるかというもの。
免許によっても、原付しか乗れない免許証や、中型・大型等いろいろありますよね。
つまりシステム上でも、あなたが何者かは分かったけれど、この処理は許可されていないよ等を管理する必要があります。

そしてdevise(devise_token_auth)だけで認可ができないのかというと、実はできます。
しかしpunditのような認可gemを使った方が、認可処理だけ担うファイルに分割して管理できる等のメリットが多く、アプリケーションが大きくなるにつれて利点を感じるようになります。

# punditのインストール

さて、punditを入れていきましょう。
punditのドキュメント通りですが、以下の通りインストールと初期設定をしていきます。

```diff:Gemfile
+ gem “pundit”
```

```powershell
$ bundle
```

```diff:app/controllers/v1/application_controller.rb
 class ApplicationController < ActionController::Base
+  include Pundit
…
 end
```

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

ここまでインストールしたら1回`rails s`を止めて再起動しましょう。

#  postのpolicyを作る

参照：https://github.com/varvet/pundit#generator


```powershell
$ rails g pundit:policy post
```

実行するとpolicyファイルとspecファイルができます。

ここで、一般的な掲示板アプリケーションの挙動を想像してみましょう。

* #indexは全員見れていい
* #showも全員見れていい
* #createは認証済みの場合のみ実行できる
* #updateは認証済みかつ自分の投稿だけ編集できる
* #destroyも認証済みかつ自分の投稿だけ削除できる


主に修正が必要なファイルは3つ。

* app/controllers/v1/posts_controller.rb
* app/policies/application_policy.rb
* app/policies/post_policy.rb

それに加え、初期設定として1度だけ修正が必要なファイルが

* app/controllers/application_controller.rb

これら4ファイルを修正しながら、punditの挙動を理解してきましょう。

```diff:app/controllers/v1/posts_controller.rb
     def index
       posts = Post.includes(:user).order(created_at: :desc).limit(20)
+      authorize posts
       render json: posts
     end
 
     def show
+      authorize @post
       render json: @post
     end
 
     def create
+      authorize Post
       post = current_v1_user.posts.new(post_params)
       if post.save
… 
     def update
+      authorize @post
       if @post.update(post_params)
… 
     def destroy
+      authorize @post
       @post.destroy
…
```

ここで注目すべきは、どの位置に`authorize`を入れているか、です。本記事の後半で解説します。

このauthorize {model}とすることで、post_policy.rbの該当するメソッドが呼ばれます。
今はまだpost_policy.rbを直していないので、スーパークラスであるapplication_policy.rbのindex?やshow?が呼ばれます。

```rb:app/policy/application_policy.rb
  def index?
    false
  end
```
`index?`はfalseになっていますね。
`{action}?`に該当するメソッドの返り値がtrueなら許可、falseなら拒否されます。そのため認証エラーとなります。

```powershell
{"status":500,"error":"Internal Server Error","exception":"#\u003cNameError: undefined local variable or method `current_user' for #\u003cV1::PostsController:0x00000000036a49a8\u003e\nDid you mean?  current_v1_user
```

このエラーがなかなか曲者です。
current_userという変数やメソッドはないよ
というエラーですね。

実はpundit、デフォルトだとcurrent_userというメソッドを呼び出してapplication_policy.rbやpost_policy.rbの`@user`に渡す挙動をします。
しかし今回のテストアプリケーションではv1というnamespaceを切っているので、`current_user`ではなく`current_v1_user`を呼ばないといけません。

これはapplication_controller.rbで`pundit_user`というメソッドをオーバーライドしてやると対応できます。

```diff:app/controllers/application_controller.rb
class ApplicationController < ActionController::API
…

+  def pundit_user
+    current_v1_user
+  end
```

これで`current_user`ではなく`current_v1_user`がpunditで呼ばれるようになるので、先程の`undefined local variable or method `current_user'`は解消されます。
再度curlを叩きます。

```powershell
{"status":500,"error":"Internal Server Error","exception":"#\u003cPundit::NotAuthorizedError: not allowed to index? this Post::ActiveRecord_Relation
```

許可されなかった時、500エラーが返っているようです。
権限がない時は403エラーが適切ですので、application_controller.rbで`Pundit::NotAuthorizedError`をrescueしてやれば良さそうですね。

```diff:app/controllers/application_controller.rb
 class ApplicationController < ActionController::API
   include DeviseTokenAuth::Concerns::SetUserByToken
+  rescue_from Pundit::NotAuthorizedError, with: :render_403
…

+  def render_403
+    render status: 403, json: { message: "You don't have permission." }
+  end
…
```

再度実行してみましょう。

```powershell
$ curl localhost:8080/v1/posts -i
HTTP/1.1 403 Forbidden
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-Download-Options: noopen
X-Permitted-Cross-Domain-Policies: none
Referrer-Policy: strict-origin-when-cross-origin
Content-Type: application/json; charset=utf-8
Cache-Control: no-cache
X-Request-Id: e19d413c-89c9-4701-94c5-ece2b12560a9
X-Runtime: 0.003657
Transfer-Encoding: chunked

{"message":"You don't have permission."}
```

403のレスポンスコードと、メッセージもちゃんと返ってきましたね。
なお、試しに`app/policy/application_policy.rb`の`def index?`をfalseからtrueに変えると、正常に投稿一覧が返ってきます。
しかしapplication_policy.rbはスーパークラスであり、ここは原則変えずに全てfalseのままにしておきましょう。
継承したサブクラスであるpost_policy.rbを編集します。

# post_policy.rbの編集

先に最終的なコードを書いておきます。

```rb:app/policies/post_policy.rb
# frozen_string_literal: true

#
# postのポリシークラス
#
class PostPolicy < ApplicationPolicy
  def index?
    true
  end

  def show?
    true
  end

  def create?
    @user.present?
  end

  def update?
    @record.user == @user
  end

  def destroy?
    @record.user == @user
  end

  #
  # scope
  #
  class Scope < Scope
    def resolve
      scope.all
    end
  end
end
```

これで意図した通りに動くはずです。なお、punditの挙動を理解するのを優先するため、今回はテストは最後に書きます。
さて、controllerで`authorize`を挿入した位置を思い出してみます。

```diff:app/controllers/v1/posts_controller.rb
     def index
       posts = Post.includes(:user).order(created_at: :desc).limit(20)
+      authorize posts
       render json: posts
     end
 
     def show
+      authorize @post
       render json: @post
     end
 
     def create
+      authorize Post
       post = current_v1_user.posts.new(post_params)
       if post.save
… 
     def update
+      authorize @post
       if @post.update(post_params)
… 
     def destroy
+      authorize @post
       @post.destroy
…
```

どれも、**そのactionが行う必要な処理が実行される前**に呼び出していることに注目してください。

* #indexはpostの一覧を返すので、`render json`より前に実行
* #showはpostを返すので、`render json`より前に実行
* createは新しいレコードを生成するので、`if post.save`より前に実行
* updateはレコードを更新するので、`if @post.update(post_params)`より前に実行
* destroyはレコードを削除するので、　`@post.destroy`より前に実行

となります。
仮にsaveやupdateの後に`authorize`していたらどうでしょう？
権限がない時に403のレスポンスは返りますが、保存処理が終わった後なので、DB上は書き換えができてしまっているはずです。それだと認可の意味がないですよね。
また、そもそも`authorize`を呼んでないと認可が行われないことにも注意です。

結論としては、`authorize`を必ず呼ぶことと、呼ぶ位置についてしっかり確認する必要があります。

最後に、`create?`と`update?`の処理について解説します。

```rb:app/policies/post_policy.rb
  def create?
    @user.present?
  end
```

`@user`には`current_v1_user`が入ってきますが、未ログインだと`@user`はnilが入ってきます。
つまり、上記メソッドではログイン状態ならtrueで200, 非ログイン状態ならfalseで403が返ります。

```rb:app/controllers/v1/post_controller.rb
    def create
      authorize Post
      post = current_v1_user.posts.new(post_params)
```

controller側も注目です。
`post = current_v1_user.posts.new(post_params)`の下で`authorize post`をしていないことに注目です。
なぜなら前述の通り`current_v1_user`はnilなので、`post = current_v1_user.posts.new(post_params)`の下で`authorize post`を呼び出そうとすると、postsメソッドが存在せず500エラーになるためです。

判定に必要なのは`post`ではなく`user`なので、その上で適当に`Post`を渡してauthorizeを動かしてるのです。

2つ目、update?とdestory?の挙動について。

```rb:app/policies/post_policy.rb
  def update?
    @record.user == @user
  end
```

こちらの場合、更新対象レコードをcontrollerで`authorize @post`と渡しているので`@record`には更新・削除対象レコードが渡ってきます。
そのレコードの`user`と、`current_v1_user`が渡ってきている`@user`を比較し、一致するか判定。
つまり自分自身の投稿か？を判定しているわけですね。

次回の記事ではpunditのテストと、処理をメソッドに切り出す方法を解説します。

# 続き

→[Rails 6で認証認可入り掲示板APIを構築する #16 policyの設定](https://qiita.com/rf_p/items/041e86fef26fb38fed5b)
【[連載目次へ](https://qiita.com/rf_p/items/a846553c11571f4177eb#%E7%9B%AE%E6%AC%A1)】