書いた人:鈴木雄大 (@nekogeruge_987)
webアプリケーション開発において、ユーザーの権限管理はよくある悩ましい課題の1つだと思います。本記事ではその課題の解決策の1つとして、Punditというgemをご紹介します。
権限管理を行うgemといえばcancan(Rails4対応版はcancancan)が有名ですが、最近、実務(Railsアプリケーションの開発)で権限管理の実装を行うにあたりPunditとの比較を行い、Punditのシンプルな実装と柔軟性を気に入り、採用しました。
今回はPunditの使用方法に加え、ユーザーの権限管理の設計と実装を、簡単なCRUDができるサンプルアプリケーションを例にご紹介させていただきます。
また、アプリケーションはHerokuにホストしておりますので、動作確認はこちらでも可能です。
サンプルアプリケーションではcontrollerにあるメソッド一つ一つに対して判定を行うのと、役割に対して実行できるアクションをユーザーが自由に設定させたいという理由で、後述するRolesテーブルとAbilitiesテーブルの組み合わせで実装を行っています。
しかし、Punditには、権限管理を行う上での制約や規約が用意されているわけではありません。権限情報の持たせ方や権限判定のロジックは利用者の設計に委ねられます。
サンプルアプリケーションほど厳密に行う必要が無い場合(例えば権限は管理者と一般ユーザーの2種類で良い)は、UsersテーブルのRoleカラムに”admin”という文字列を入れておいたり、adminというカラムを用意してbool値で判定したり、Rail4.1の新しい機能であるEnumを使用するといった実装も可能です。
このサンプルアプリケーションでの設計は Pundit を利用した一つの権限管理の実装例としてお読みください。
本記事内における「リソース」という単語はRailsアプリケーションが操作するオブジェクトの総称を指しています。
例えば、_rails generate scaffold user_や_rails generate scaffold book_した際の_user_や_book_のことを指します。
まず、下記の準備を行います (後述しますが、generate タスクが用意されています)。
権限が適用される条件とは具体的に、controllerに存在するメソッドが実行できる条件です。
例えば、_UsersController_の_update_メソッドが実行できる権限を実装する場合、_UserPolicy_クラスに_update?_というメソッドを作成します。最終的に、_UserPolicy.new(current_user, @user).update?_というメソッドが呼ばれるように実装していきます。
また、Punditには_authorize_というメソッドが用意されているので、引数にcontrollerに対応するモデルのインスタンスを渡し、controllerの_before_action_でメソッドを呼ぶようにすると便利です。具体的には、下記のような実装になります。
class ApplicationController < ActionController::Base
include Pundit
protect_from_forgery
end
class UsersController < ApplicationController
before_action :pundit_auth
...
private
def pundit_auth
authorize User.new
end
例えば_app/model/user.rb_に_User#admin?_いうメソッドを用意しておき、Userリソースはadminしか操作できないようにしておきたい場合には、_UserPolicy_クラスに下記のような実装を行います。
class UserPolicy < ApplicationPolicy
def update?
user.admin?
end
こうしておけば、_UsersController#update_が実行された際に_current_user.admin?_が呼ばれ、falseだった際に_NotAuthorizedError_という例外が発生するようになります。
これだけだとイメージが湧きづらいと思いますので、次はサンプルアプリケーションの実装例を見ていきましょう。
Railsで実装したCRUDができる簡単なアプリケーションを用意しています。 また、ユーザー認証機能を持たせるために、deviseを使用しています。 deviseを選んだ理由は、認証機能を持たせるgemの中では今の所一番メジャーだと考えたためです。
権限情報は、_Roles_テーブルと_Abilities_テーブル、中間テーブルの_RolesAbilities_テーブルによって保持されます。 _Abilities_テーブルの_domain_カラムに操作ができるリソースの名称を格納し、_ability_カラムに_domain_に対して可能なcontrollerのメソッド名を格納します。
サンプルアプリケーションはUserに対し、index, show, create, update, destroy_の操作ができるので、domain = userに対し、_index, show, create, update, destroy_の計5レコードを作成をします。管理者の場合は、_domain,_ability_共に_admin_の文字列が入っているというルールにします。
_Roles_に対してどの_Ability_が紐づいてるのかを中間テーブル_RolesAbilities_で管理し、その情報を_User#ability_メソッドで取得できるようにしておきます。この情報をもとに権限の有無を判定しています。
RailsでPunditを使用する場合、app配下に_policies_ディレクトリを作成します。この作業は 通常Punditをinstallした際に使用できるようになるgenerateタスクで行います。
policies配下にはgenerateした際に_application_policy.rb_が作成されます。リソースごとの権限(policy)を設定するには_リソース名_policy.rb_を作成していきます。こちらもgenerateタスクで作成できます。
policyクラスはcontrollerクラスと同じように、_application_policy.rb_の_ApplicationPolicy_クラスを各policyのクラスに継承する形で作成していきます。generateで作成した直後のコードは以下のようになっています。
class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record)
@user = user
@record = record
end
def index?
false
end
def show?
scope.where(:id => record.id).exists?
end
def create?
false
end
def new?
create?
end
def update?
false
end
def edit?
update?
end
def destroy?
false
end
def scope
Pundit.policy_scope!(user, record.class)
end
end
class UserPolicy < ApplicationPolicy
class Scope < Struct.new(:user, :scope)
def resolve
scope
end
end
end
Userリソースに対して権限の判定ロジックを実装する場合、_user_policy.rb_に実装していきます。 まず、管理者の場合は全ての操作が可能なように実装します。 _authorize_メソッドを使用した場合、_user_は_current_user_になるのでこのように書けます。
def index?
user.admin?
end
def show?
user.admin?
end
def create?
user.admin?
end
def new?
create?
end
def update?
user.admin?
end
def edit?
update?
end
def destroy?
user.admin?
end
さらに、abilityごとの権限の判定を実装していきます。まず、_ApplicationPolicy_クラスに下記のメソッドを実装します。
def can?(ability)
(user.ability.include?(record.class.to_s.underscore) && user.ability[record.class.to_s.underscore].include?(ability))
end
User#abilityの返り値の中にリソース、メソッドの名称が存在しているかを判定するメソッドを記述します。 上記メソッドをUserPolicyクラスで使用すれば完了です。
def index?
user.admin? or can? "index"
end
def show?
user.admin? or can? "show"
end
def create?
user.admin? or can? "create"
end
def new?
create?
end
def update?
user.admin? or can? "update"
end
def edit?
update?
end
def destroy?
user.admin? or can? "destroy"
end
これは例えば、UserPolicy_で_can?(“update”)_が実行された際、(user.ability.include?(“user”) && user.ability[“user”].include?(“update”))_が実行されるようになります。
これで権限が存在しないユーザーが各メソッドを実行した場合は例外が発生するようになります。 404に飛ばしたい場合は、_ApplicationController_で下記のように例外をキャッチしてあげればOKです。
rescue_from NotAuthorizedError, with: :render_404
def render_404(exception = nil)
if exception
logger.info "Rendering 404 with exception: #{exception.message}"
end
render file: "#{Rails.root}/public/404.html", status: 404, content_type: 'text/html'
end
ではなぜ、これだけの実装で権限管理機能が実現できているのかを、_authorize_メソッドの処理を追いながら見ていきましょう。
def authorize(record, query=nil)
query ||= params[:action].to_s + "?"
@_policy_authorized = true
policy = policy(record)
unless policy.public_send(query)
error = NotAuthorizedError.new("not allowed to #{query} this #{record}")
error.query, error.record, error.policy = query, record, policy
raise error
end
true
end
まず、変数queryに”呼び出されたメソッドの名前” + “?”を格納します。 _@_policy_authorized_は_authorize_メソッドが呼び出されたかどうかのフラグのようなものなので無視します。
次に、policyメソッドの結果を変数policyに格納しています。ここで引数になっているrecordはUserのインスタンスです。 (サンプルアプリケーションだと_User.new_しただけのオブジェクト)
policyメソッドを見ていきましょう。
def policy(record)
@policy or Pundit.policy!(pundit_user, record)
end
attr_writer :policy
def pundit_user
current_user
end
policyメソッドはPundit.policy!メソッドを呼んでいます。また、pundit_userはcurrent_userを呼び出していることがわかります。 Pundit.policy!メソッドを見ていきましょう。
def policy!(user, record)
PolicyFinder.new(record).policy!.new(user, record)
end
ちょっとわかり辛いですが、順に追っていきます。 まず、PolicyFinder#policy!メソッドを呼んでいます。 PolicyFinderクラスは短いので全部載せてしまいます。
module Pundit
class PolicyFinder
attr_reader :object
def initialize(object)
@object = object
end
def scope
policy::Scope if policy
rescue NameError
nil
end
def policy
klass = find
klass = klass.constantize if klass.is_a?(String)
klass
rescue NameError
nil
end
def scope!
scope or raise NotDefinedError, "unable to find scope #{find}::Scope for #{object}"
end
def policy!
policy or raise NotDefinedError, "unable to find policy #{find} for #{object}"
end
private
def find
if object.respond_to?(:policy_class)
object.policy_class
elsif object.class.respond_to?(:policy_class)
object.class.policy_class
else
klass = if object.respond_to?(:model_name)
object.model_name
elsif object.class.respond_to?(:model_name)
object.class.model_name
elsif object.is_a?(Class)
object
else
object.class
end
"#{klass}Policy"
end
end
end
end
_PolicyFinder#policy!_内で_PolicyFinder#policy_メソッドを呼んでいます。 _PolicyFinder#policy_は何をしているのかというと、_find_というprivateメソッドを呼んでその結果を返しています。 _find_の処理を見てみると、newした際に渡されてきたobject(ここではUserのインスタンス)のクラス名を取得し、最終的に”オブジェクトのクラス名” + “Policy”という文字列にして返しているのがわかります。 _policy_メソッドに戻ると、_find_で返ってきた文字列をconstantize(文字列をクラスとして扱うactivesupportのメソッド)してreturnしています。つまり、_PolicyFinder.new(record).policy!.new(user, record)_は_UserPolicy.new(user, record)_を行っているということになります。
要は、_authorize_メソッド内で呼ばれている_policy = policy(record)_という処理は、_authorize_メソッドで渡されたインスタンスからそのインスタンスのpolicyクラスを取得し、そのpolicyクラスのインスタンスを生成しているのです。
その後の_policy.public_send(query)_は_UserPolicy.new(User.new).update?_を実行しているということですね。メタプログラミングをうまく使った実装になっています。
次はviewです。サンプルアプリケーションではhelperに下記メソッドを追加しています。
module UsersHelper
def user_show?(user = current_user)
Pundit.policy(user, User.new).show?
end
def user_edit?(user = current_user)
Pundit.policy(user, User.new).edit?
end
def user_destroy?(user = current_user)
Pundit.policy(user, User.new).destroy?
end
def user_create?(user = current_user)
Pundit.policy(user, User.new).create?
end
end
上記のようにしておくと、各メソッドを呼び出すリンクに対して下記のように記述ができるようになります。
しかし、これは冗長な記述で条件分岐も多くなるため、ここまで厳密に管理する必要がないのであればpartialで分けてしまう方法でもいいかもしれません。
また、helperメソッドはデフォルト引数を指定するようにしていますが、これはテストをしやすくするようにするためです。UnitTestを実行した際は_current_user_が作成されないため、引数でテストしたいUserのオブジェクトが指定できるようにしてあります。
具体的には、下記のようになります。
require 'spec_helper'
describe UsersHelper do
describe ".user_show?" do
context '管理者' do
include_context "管理者"
subject { user_show?(user) }
it { should be_true }
end
context '権限保持者' do
include_context "User"
subject { user_show?(user) }
it { should be_true }
end
context '権限非保持者' do
include_context "Role"
subject { user_show?(user) }
it { should be_false }
end
end
...
end
PunditにはScopeという機能があります。 generatorでpoclicyを作成した際、このようなメソッドがデフォルトでついてきます。
class UserPolicy < ApplicationPolicy
class Scope < Struct.new(:user, :scope)
def resolve
scope
end
end
end
これはどういう時に使う機能かというと、例えばindexメソッドで一覧を作成した際に、ユーザーの権限ごとに表示するレコードを制御したい際に役に立ちます。 例えば、ログインしたユーザーが管理者以外の場合、indexで表示されるユーザーに管理者ユーザーは表示したくない場合は下記のように実装します。
scope :except_admin, -> {
joins(:role).where.not(roles: { name: "administrator" } )
}
class Scope < Struct.new(:user, :scope)
def resolve
if user.admin?
scope.all
else
scope.except_admin
end
end
end
def index
@users = policy_scope(User)
end
_policy_scope_メソッドは_authorize_と同様、渡されたオブジェクトのPolicyクラスを探し出して_resolve_メソッドを実行を行っています。
サンプルアプリケーションでは実装していないのですが、policyクラスを利用してStrongParametersで許可するパラメータを権限ごとに分けるという使い方がREADMEで紹介されています。
READMEではブログシステムを例に、
というケースをでの使い方の紹介をしています。
まず、policyクラスに_permitted_attributes_というメソッドを用意し、権限ごとに許可するパラメータの配列を定義します。
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
def permitted_attributes
if user.admin? || user.owner_of?(post)
[:title, :body, :tag_list]
else
[:tag_list]
end
end
end
controllerのStrongParametersの設定を行うメソッド内で_permitted_attributes_メソッドを呼び、permitに渡す引数を動的に定義しています。
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def update
@post = Post.find(params[:id])
if @post.update(post_params)
redirect_to @post
else
render :edit
end
end
private
def post_params
params.require(:post).permit(*policy(@post || Post).permitted_attributes)
end
end
Punditにはpolicyクラスのテストを行うために、_permissions_という独自のmatcherが用意されており、_spec_helper_でrequireすることで使用することができます。 サンプルアプリケーションでは_shared_context_を使ってuserのオブジェクトの作成をしていますので、このように書いています。
require 'spec_helper'
describe UserPolicy do
subject { UserPolicy }
...
permissions :update? do
context '管理者' do
include_context "管理者"
it { should permit(user, User.new) }
end
context '権限保持者' do
include_context "User"
it { should permit(user, User.new) }
end
context '権限非保持者' do
include_context "Role"
it { should_not permit(user, User.new) }
end
end
...
しかし、これだけでは前述のScope機能のテストが行えないのでcontrollerのテストでカバーする必要がありそうです。 また、READMEの中でまた違ったテストの方法のアプローチが紹介されています。
このように、PunditはメタプログラミングとRailsの機能をうまく利用し、少ないコードでうまく権限管理の機能を実現しています。権限の判定のロジックもリソース毎、メソッド毎に分離しており、自由に実装できて柔軟性も高いです。また、@znzさんのブログにもある通り、Railsの変化にも強そうです。
個人的には、メタプログラミングの勉強としても参考になるコードだと感じています。前述の通りコードの量も非常に少ないので、コードリーディングのお題としてもおすすめです。
Punditを使う際には以下の注意点があります。
私が直面した問題ではないのですが、PolicyFinderのfindメソッドは上記のケースが考慮されていません。このようなケースの場合はパッチを書く必要があります。
Rails以外でも使えなくも無いですが、基本的にRailsで使われることが前提の作りになっているように感じます。また、activesupportは必須です。
pundit_userを見ると、current_userメソッドが存在することが前提となっています。また、current_userをpundit_userで使用したくなければoverrideするようにREADMEに記載があります。
これは個人的な悩みなのですが、サンプルアプリケーションではshared_exampleとshared_contextを利用してユーザーのコンテキストごとに全てテストを実行するようにしています。書いた時はこれで良いと思っていたのですが、リソースが増えてくるにつれてテストの量がふくれ上がってきます。権限のカバーができているかはpoliciesに任せてしまい、controllerやrequestのテストにおいては全てのユーザーコンテキストをカバーするのではなく、権限管理の異常系はクリティカルな部分のみに絞ったり、特定のリソースの全ケースをカバーするのみにとどめても良いのかもしれません。このあたりのご意見をプルリクエストやtwitterでいただけるととても嬉しいです。
鈴木雄大 (@nekogeruge_987) (有) エクスヴィジョンズ所属。最近Webサービス企業から小規模SIerに転職し、Ruby、Railsの受託開発の仕事をしています。Hubotに会社の雑務を行わせるのがマイブーム。