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_create
や permitted_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
}