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_authorization
やskip_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
メソッドにはユーザーと対象を指定する。