Punditで権限管理

ユーザーによってページの表示を許可、拒否したり、アクションを制限したりしたいことがあると思います。
今回は認可の仕組みを提供してくれるgem Punditを使って実装してみたいと思います。

単純なuserのCRUD機能とログイン機能を持ったアプリを作りました。
userはadmin(管理者)とgeneral(一般ユーザー)の2種類います。
adminがログインした時には制限はありませんが、generalがログインした時はユーザーの編集、削除ができないようにしてみます。
adminであるtanakaでログインすると

Image from Gyazo

こんな感じです。
generalのyamadaでログインしても今は同じ画面になります。
Punditを導入してgeneralができることを制限してみましょう。

Punditの導入

Gemfileに

gem 'pundit'

としてbundle installして、コントローラでincludeします。

class ApplicationController < ActionController::Base
  include Pundit
end

下記のコマンドで

rails g pundit:install

app/policies/配下にapplication_policy.rbが作成されます。

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 new?
    create?
  end

  def update?
    false
  end

  def edit?
    update?
  end

  def destroy?
    false
  end

  class Scope
    attr_reader :user, :scope

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

    def resolve
      scope.all
    end
  end
end

Policyの作成

userに関するpolicy(方針)を記述するファイルuser_policy.rbを作成し、

class UserPolicy < ApplicationPolicy
  def index?
    true
  end

  def edit?
    user.admin?
  end
end

ApplicationPolicyを継承させ、アクションを定義します。
admin?enum

class User < ApplicationRecord
  enum role: { general: 0, admin: 1 }
end

このように定義して使えるようになったメソッドです。
def アクション名? endで認可ルールを記述します。

def index?
  true
end

def edit?
  user.admin?
end

index?はtrueが返るので制限はかかりません。
edit?にはuserがadminの場合に許可しtrueが返ります。
userにはデフォルトでcurrent_userが入るようになっているみたいです。
adminでなければPundit::NotAuthorizedErrorが発生します。

コントローラでPunditを呼び出し

コントローラ側で

class UsersController < ApplicationController
  before_action :set_user, only: %i[ show edit update destroy ]

  def index
    authorize(User)
    @users = User.all
  end

  def edit
    authorize(@user) # 編集は特定のユーザー情報が必要なので「@user」で特定のユーザーを引数に指定
  end

  private

  def set_user
    @user = User.find(params[:id])
  end
end

authorizeメソッドを記述することで該当するPolicy(この場合UserPolicy)を確認してくれます。
indexアクションからはindex?アクションを確認し、index?アクションはtrueを返すのでadminでもgeneralでもユーザー一覧が表示されます。
editアクションからはedit?アクションを確認し、edit?アクションはuser.admin?となっているのでadminの場合だけtrueが返り、編集ページへアクセスできます。

Viewで表示を制限

View側でもpolicyメソッドを使って表示自体を制限できます。

<% @users.each do |user| %>
  <tr>
    <td><%= user.name %></td>
    <td><%= user.role %></td>
    <td><%= link_to 'Show', user %></td>
    <% if policy(user).edit? %>
      <td><%= link_to 'Edit', edit_user_path(user) %></td>
    <% end %>
    <% if policy(user).destroy? %>
      <td><%= link_to 'Destroy', user, method: :delete, data: { confirm: 'Are you sure?' } %></td>
    <% end %>
  </tr>
<% end %>

このようにすると「Edit」「Destroy」のリンク自体、adminでログインしている場合でないと表示されなくなります。

Image from Gyazo

もしusers/1/editのように直接URLからアクセスしようとしても

def edit?
  user.admin?
end

これにより弾くことができます。

Pundit::NotAuthorizedError

権限がないgeneralで編集ページにアクセスしようとするとPundit::NotAuthorizedErrorが発生します。

Image from Gyazo

エラーページを用意してそちらを表示させることができます。
public配下に403.htmlファイルを作成します。

<!DOCTYPE html>
<html>
<head>
  <title>権限がありません</title>
  <meta name="viewport" content="width=device-width,initial-scale=1">
</head>

<body>
  <p>権限がありません。</p>
</body>
</html>

そしてapplication_controller.rb

class ApplicationController < ActionController::Base
  include Pundit

  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized # これと

  private

  def user_not_authorized #これを追加
    render file: 'public/403.html', status: :forbidden, layout: false
  end
end

このようにします。
わざとエラーを発生させると

Image from Gyazo

エラーページに遷移しました。

また、config/application.rbにconfig.action_dispatch.rescue_responses["Pundit::NotAuthorizedError"] = :forbiddenの記述をし、
config/environments/development.rbのconfig.consider_all_requests_localをtrueからfalseに変更してサーバーを再起動することでもエラーページに遷移できました。

最後に

動作確認しながら書きましたが、最低限だけ書こうとして端折ったところもあるのでもしかしたら矛盾があるかもしれません。
あったらごめんなさい。
今回は以上です。ありがとうございました。

参考サイト

Punditを使って権限を管理する - Qiita

Punditをなるべくやさしく解説する - Qiita

Punditの使い方 - Wataruの技術備忘録

GitHub - varvet/pundit: Minimal authorization through OO design and pure Ruby classes