Punditメモ

ACL機能を追加するGem。Pundit 2.1.0で確認。

簡単に説明すると、人-もの-操作の組み合わせが許可されているかチェックするGemである。「管理者ZがユーザーAのブログを削除するのは許可」や「ユーザーAがユーザーBのブログを変更するのは不許可」など。

類似Gem

同カテゴリにCanCanCanとBankenというGemがある。

CanCanCanはDSLで設定ファイルを記述していくようなGemで、設定が一箇所に集中するため破綻しやすい(と言われている。使ったこともないし、趣味に合わなそうなので検証してもいない)。

BankenはPunditより後発のGemでほとんどPunditと同じ。Modelに紐づくPunditに対してBankenはControllerに対応する。

Bankenが作られた経緯は以下の通り。

アプリが複雑になっていき特定のModelを扱うControllerが複数存在する場合、つまり1つのModelに対し複数のPolicyが必要になる場合をPunditはサポートしていません。

The difference between Banken and Pundit (Japanese)

しかし、(Bankenの作成が開始された時点で)ネームスペース機能が存在するし、(2018/7時点で)authorizeメソッドにpolicy_class引数が追加されているため、今となっては機能の優劣はなく考え方の違いでしかないと言える。

強いて言えば、Punditはネームスペースをどう使うかという点で設計にいくらか気を配らなければならないが、BankenはControllerと紐づくため設計面で考えることがないといえる。ただ、コントローラー間で同じチェックがあった場合などコードが重複しやすく、重複を解消するためにModelが犠牲になりやすい。

なので、設計する余裕がないならBanken、ちゃんとやるならPunditを使うのが良いと個人的には考える。

なお、Bankenは日本で多少知名度があるだけで、Punditの方が圧倒的に有名である。コミュニティによるメンテを期待するならPunditを選ぶしかない。といっても、Bankenは実質的なコードが100行程度しかないので、自分でメンテしていくことは十分可能である。

セットアップ

以下のgemを追加する。

gem 'pundit'

bin/rails g pundit:installでPunditの基底クラスを作成する。

bin/rails g pundit:policy userでUserモデルに対応したPolicyクラスを作成する。

前提

ユーザーを返すcurrent_userメソッドがコントローラーになければならない。また、コントローラーでinclude Punditをしなければならない。

class ApplicationController < ActionController::Base
  include Pundit

  def current_user
    @current_user ||= User.find(session[:user_id])
  end
  helper_method :current_user
end

current_userメソッドが返す値をPundit用にカスタマイズしたいが、current_userメソッドは他のGemで使われていてオーバーライドできないという場合がある(ACLのためにアクセス元のIPアドレスを利用したいなど)。

そういう場合、pundit_userメソッドをオーバーライドすればよい。Punditの中で定義されているpundit_userメソッドはcurrent_userメソッドを呼ぶだけのメソッドで、Punditの中でユーザーを参照するときはpundit_userメソッドを利用している。

Policy

「人-もの-操作」の組み合わせが許可されているかチェックする処理を記述するクラスをPolicyという。

# bin/rails g pundit:installで生成されるファイル(変更なし)
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

以下のようにApplicationPolicyを継承したクラスを作り、update?メソッドなどをオーバーライドする。なお、このApplicationPolicyを継承したクラスはモデルに対応する。

# update?メソッド以外は
# bin/rails g pundit:policy user
# で生成した内容のまま
class UserPolicy < ApplicationPolicy
  def update?
    user == record
  ene

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

コントローラーでのACL

以下のようにauthorizeメソッドを使う。引数と実行されているactionから呼び出すPolicyクラスとメソッドを自動的に解決する。

class UsersController < ApplicationController
  def edit
    @user = User.find(params[:id])
    # authorizeメソッドの中でUserPolicy.new(current_user, @user).edit?を呼び出す
    authorize @user
  end
end

authorizeメソッドは不許可だった場合(Policyクラスのメソッドがfalsyな値を返した場合)、Pundit::NotAuthorizedError例外を投げる。なので、rescue_fromなどで処理する必要がある。

コントローラーのアクション名と異なるPolicyのメソッドを呼び出したい場合は以下のようにする。

  def preview
    @post = Post.find(params[:id])
    @post.attributes = post_params
    # previewアクションだがPostPolicy#update?を呼ぶ
    authorize @post, :update?
  end

Modelに紐づかないPolicyクラスを利用したい場合は以下のようにする。

def edit
    @user = User.find(params[:id])
    # AccountPolicy#edit?が呼ばれる
    authorize @user, policy_class: AccountPolicy
end

indexアクションなど特定のリソースに対する操作でない場合、authorizeの第一引数はクラスを指定できる。

def index
    authorize User
end

ViewでのACL

Viewの場合、「人-もの-操作」の組み合わせが許可されているかどうかでリンクを有効にする無効にするという制御が必要になる。

そのためにpolicyヘルパーが用意されており、以下のように使う。

<% if policy(@user).update? %>
  <%= link_to('Edit', edit_user_path(@user)) %>
<% end %>

もし、Modelと紐づかないPolicyクラスを利用したい場合はAccountPolicy.new(current_user, @user).update?のように直接インスタンス化するしかない(簡単にコードを確認した感じでは)。

モデルインスタンスがないPolicy

特定のモデルインスタンスはなく、ユーザー情報だけでアクセスコントロールをしたいケースがある。たとえば、ダッシュボード画面は「人-もの-操作」ではなく「管理者Aはダッシュボード画面を表示できるか」のような「人-操作」になっている。そのような場合、以下のようにする。

class DashboardPolicy < Struct.new(:user, :dashboard)
  # ...
end
class DashboardController < ApplicationController
  def show
    # ModelインスタンスではなくSymbolを渡す
    authorize :dashboard
  end

応用として、管理者画面は管理者ユーザーだけしか操作できないことを強制する例をあげる。

class AdminPolicy < Struct.new(:user, :admin)
  def index?
    user.admin?
  end

  def show?
    user.admin?
  end

  # ...
end

module Admin
  class ApplicationController < ApplicationController
    before_action :authorize_admin

    # authorize :admin でも良いが
    # verify_authorizedが無意味になるため
    # Punditのauthorizeメソッドを参考にしたコードにしている
    def authorize_admin
      record = :admin
      policy = policy(record)
      query = "#{action_name}?"
      return if policy.public_send(query)

      raise NotAuthorizedError, query: query, record: record, policy: policy
    end
  end
end

Scope

ユーザーが操作できるレコードを取得する処理として、以下のようなscopeを定義することが多い。

class Blog < ApplicationRecord
  scope :owner_is, lambda { |user|
    return if user.admin?

    where(user_id: user)
  }
end

PunditではPolicyの中にこのScopeを定義することができる。

class BlogPolicy < ApplicationPolicy
  # 親クラスのScopeはApplicationPolicyで定義
  class Scope < Scope
    # scopeはpolicy_scopeメソッドの引数
    def resolve
      return scope.all if user.admin?

      scope.where(user_id: user)
    end
  end
end

policy_scopeメソッドを使ってpolicy_scope(Blog)のようにするとresolveメソッドの内容が適用された状態になる。引数はそのまま渡されているだけなので、policy_scope(Blog.where(...))のようにActiveRecord::Relationを渡すこともできる。

ポリシーとスコープの強制

authorizeメソッドが一度も実行されていなければ例外を投げるverify_authorizedメソッド、policy_scopeメソッドが一度も実行されていなければ例外を投げるverify_policy_scopedメソッドがある。

チェック漏れを防ぐために、以下のようにすることができる。

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

authroizeメソッドやpolicy_scopeメソッドを実行するパスと実行しないパスをもつアクションがある場合、実行しないパスでverify_authorizedによって例外が投げられてしまう。このようなアクションの場合、実行しないパスでskip_authorizationskip_policy_scopeを使うことで例外の発生を抑制できる。

def edit
  @blog = Blog.find(params[:id])
  if @blog.blank?
    skip_authorization
    redirect_to root_url
    return
  end

  authorize @blog
end

ネームスペース

PolicyはModuleに入れることができる。

module Admin
  class UserPolicy < ApplicationPolicy
    # ...
  end
end

Moduleの指定はauthorize([:admin, User.first])のように行える。

Strong parameters

ユーザー権限ごとに編集できる内容を変えたい場合、以下のようにすることが多い。

  def update
    @book = Book.find(params[:id])
    if @book.update(book_params)
      # ...
    end
  end

  # 管理者はタイトル、値段、発売日を変更できる
  # それ以外の権限はタイトルだけ変更できる
  def book_params
    base = params.require(:book)
    return base.permit(:title, :price, :release_date) if current_user.admin?

    base.permit(:title)
  end

Punditの機能を使うと以下のようにできる。

  def update
    @book = Book.find(params[:id])
    if @book.update(permitted_attributes(@book))
      # ...
    end
  end

class BookPolicy < ApplicationPolicy
  def permitted_attributes
    return [:title, :price, :release_date] if user.admin?

    [:title]
  end
end

アクションごとに異なる処理が必要な場合、permitted_attributes_for_アクション名というメソッドを用意すれば良い。

params.requireメソッドの引数はモデルのクラス名から自動的に指定される。もし、この挙動を変更したい場合はコントローラーにpundit_params_forメソッドを作れば良い。

class BooksController < ApplicationController

  private

  def pundit_params_for(record)
    params.require(:product)
  end
end

RSpec matcher

require 'pundit/rspec'spec_helper.rbに追加する。

require 'rails_helper'

RSpec.describe BookPolicy, type: :policy do
  let(:user) { User.new }

  subject { described_class }

  permissions :new?, :create? do
    it '本の追加は常に許可される' do
      expect(subject).to permit(user, Book.new)
    end
  end
end

permissionsは引数に対象の操作のメソッドを指定する。permissions :new?, :create?のように指定すればnew?メソッドとcreate?メソッドの両方に対してテストが実行される。permitメソッドにはユーザーと対象を指定する。