LoginSignup
319
239

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-02-27

Punditというgemを使ってRailsに認可の仕組みを作ってみます。認可というとcancancanが有名です。

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

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

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

前提

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

認可の仕組み

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

ポリシーファイル

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

class ArticlePolicy < ApplicationPolicy
  def index?
    false
  end
end

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

authorize Article
authorize @article

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

class ApplicationPolicy
  attr_reader :user, :record

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

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

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

コントローラ

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

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

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

使い方

初期設定

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

app/controllers/application_controller.rb

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アプリを想定しています。

class User < ApplicationRecord
  has_many :articles
end

class Article < ApplicationRecord
  belongs_to :user
end

app/controllers/articles_controller.rb

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

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

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

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画面ができました。とはいえ全部認可しているので、自身が保持しているリソースのみアクセスできるようにポリシーを修正します。

   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 メソッドを呼ぶと、対象ポリシーファイル内で定義されたスコープを実行してくれます。

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
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
class AddRoleToUser < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :role, :integer, null: false, default: 0
  end
end
class User < ApplicationRecord
  enum role: { normal: 0, admin: 1 }
end

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

 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 ヘルパーを呼び出すことできるので、これでボタンの表示非常時をコントロールしています。

   def destroy?
-    true
+    user.admin?
   end
-        <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 と呼び出すと簡単に切り分けができます。

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

  def description?
    user.admin?
  end
end
class ArticlesController < ApplicationController

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

end

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

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

permitted_attributes_for_createpermitted_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ステータスにします。

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

current_user以外を使う

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

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

テストコードを書く

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

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

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

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

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
}
319
239
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
319
239