権限管理のgem、Punditの紹介

はじめに

webアプリケーション開発において、ユーザーの権限管理はよくある悩ましい課題の1つだと思います。本記事ではその課題の解決策の1つとして、Punditというgemをご紹介します。

権限管理を行うgemといえばcancan(Rails4対応版はcancancan)が有名ですが、最近、実務(Railsアプリケーションの開発)で権限管理の実装を行うにあたりPunditとの比較を行い、Punditのシンプルな実装と柔軟性を気に入り、採用しました。

今回はPunditの使用方法に加え、ユーザーの権限管理の設計と実装を、簡単なCRUDができるサンプルアプリケーションを例にご紹介させていただきます。

また、アプリケーションはHerokuにホストしておりますので、動作確認はこちらでも可能です。

Pundit と権限情報管理の設計

サンプルアプリケーションではcontrollerにあるメソッド一つ一つに対して判定を行うのと、役割に対して実行できるアクションをユーザーが自由に設定させたいという理由で、後述するRolesテーブルとAbilitiesテーブルの組み合わせで実装を行っています。

しかし、Punditには、権限管理を行う上での制約や規約が用意されているわけではありません。権限情報の持たせ方や権限判定のロジックは利用者の設計に委ねられます。

サンプルアプリケーションほど厳密に行う必要が無い場合(例えば権限は管理者と一般ユーザーの2種類で良い)は、UsersテーブルのRoleカラムに"admin"という文字列を入れておいたり、adminというカラムを用意してbool値で判定したり、Rail4.1の新しい機能であるEnumを使用するといった実装も可能です。

このサンプルアプリケーションでの設計は Pundit を利用した一つの権限管理の実装例としてお読みください。

記事中での用語について

本記事内における「リソース」という単語はRailsアプリケーションが操作するオブジェクトの総称を指しています。

例えば、rails generate scaffold userrails generate scaffold bookした際のuserbookのことを指します。

使用方法

app/policiesディレクトリの作成とpolicyファイルの準備

まず、下記の準備を行います (後述しますが、generate タスクが用意されています)。

  1. 各リソースに対してpolicyクラスを作成する
  2. 権限が適用される条件をpolicyクラスに実装する

権限が適用される条件とは具体的に、controllerに存在するメソッドが実行できる条件です。

例えば、UsersControllerupdateメソッドが実行できる権限を実装する場合、UserPolicyクラスにupdate?というメソッドを作成します。最終的に、UserPolicy.new(current_user, @user).update?というメソッドが呼ばれるように実装していきます。

また、Punditにはauthorizeというメソッドが用意されているので、引数にcontrollerに対応するモデルのインスタンスを渡し、controllerのbefore_actionでメソッドを呼ぶようにすると便利です。具体的には、下記のような実装になります。

   1|class ApplicationController < ActionController::Base
   2|  include Pundit
   3|  protect_from_forgery
   4|end
   1|class UsersController < ApplicationController
   2|  before_action :pundit_auth
   3| 
   4|  ...
   5| 
   6|  private
   7|    def pundit_auth
   8|      authorize User.new
   9|    end

例えばapp/model/user.rbUser#admin?いうメソッドを用意しておき、Userリソースはadminしか操作できないようにしておきたい場合には、UserPolicyクラスに下記のような実装を行います。

   1|class UserPolicy < ApplicationPolicy
   2|  def update?
   3|    user.admin?
   4|  end

こうしておけば、UsersController#updateが実行された際にcurrent_user.admin?が呼ばれ、falseだった際にNotAuthorizedErrorという例外が発生するようになります。

これだけだとイメージが湧きづらいと思いますので、次はサンプルアプリケーションの実装例を見ていきましょう。

サンプルアプリケーションについて

Railsで実装したCRUDができる簡単なアプリケーションを用意しています。 また、ユーザー認証機能を持たせるために、deviseを使用しています。 deviseを選んだ理由は、認証機能を持たせるgemの中では今の所一番メジャーだと考えたためです。

動作環境

  • Ruby2.1.2
  • Rails4.1.1
  • MySQL5.6.17
  • OSX10.9.2(Marvericks)

設計

テーブル設計

pundit_sumple.png

権限情報の持たせ方

権限情報は、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メソッドで取得できるようにしておきます。この情報をもとに権限の有無を判定しています。

$ bin/rails console
4.1.0@2.1.1 (main)> User.find(1).ability

 => {
  "admin" => [
      [0] "admin"
  ]
 }

 4.1.0@2.1.1 (main)> User.find(1).admin?

 => true

 4.1.0@2.1.1 (main)> User.find(2).ability

 => {
  "user" => [
      [0] "index",
      [1] "show",
      [2] "create",
      [3] "update",
      [4] "destroy"
  ]
 }

 4.1.0@2.1.1 (main)> User.find(2).admin?

 => false

権限を判定するメソッドの実装

policyファイルの作成

RailsでPunditを使用する場合、app配下にpoliciesディレクトリを作成します。この作業は 通常Punditをinstallした際に使用できるようになるgenerateタスクで行います。

policies配下にはgenerateした際にapplication_policy.rbが作成されます。リソースごとの権限(policy)を設定するにはリソース名_policy.rbを作成していきます。こちらもgenerateタスクで作成できます。

policyクラスの実装

policyクラスはcontrollerクラスと同じように、application_policy.rbApplicationPolicyクラスを各policyのクラスに継承する形で作成していきます。generateで作成した直後のコードは以下のようになっています。

   1|class ApplicationPolicy
   2|  attr_reader :user, :record
   3| 
   4|  def initialize(user, record)
   5|    @user = user
   6|    @record = record
   7|  end
   8| 
   9|  def index?
  10|    false
  11|  end
  12| 
  13|  def show?
  14|    scope.where(:id => record.id).exists?
  15|  end
  16| 
  17|  def create?
  18|    false
  19|  end
  20| 
  21|  def new?
  22|    create?
  23|  end
  24| 
  25|  def update?
  26|    false
  27|  end
  28| 
  29|  def edit?
  30|    update?
  31|  end
  32| 
  33|  def destroy?
  34|    false
  35|  end
  36| 
  37|  def scope
  38|    Pundit.policy_scope!(user, record.class)
  39|  end
  40|end
   1|class UserPolicy < ApplicationPolicy
   2|  class Scope < Struct.new(:user, :scope)
   3|    def resolve
   4|      scope
   5|    end
   6|  end
   7|end

Userリソースに対して権限の判定ロジックを実装する場合、user_policy.rbに実装していきます。 まず、管理者の場合は全ての操作が可能なように実装します。 authorizeメソッドを使用した場合、usercurrent_userになるのでこのように書けます。

   1|  def index?
   2|    user.admin?
   3|  end
   4|  
   5|  def show?
   6|    user.admin?
   7|  end
   8|  
   9|  def create?
  10|    user.admin?
  11|  end
  12|  
  13|  def new?
  14|    create?
  15|  end
  16|  
  17|  def update?
  18|    user.admin?
  19|  end
  20|  
  21|  def edit?
  22|    update?
  23|  end
  24|  
  25|  def destroy?
  26|    user.admin?
  27|  end

さらに、abilityごとの権限の判定を実装していきます。まず、ApplicationPolicyクラスに下記のメソッドを実装します。

   1|  def can?(ability)
   2|    (user.ability.include?(record.class.to_s.underscore) && user.ability[record.class.to_s.underscore].include?(ability))
   3|  end

User#abilityの返り値の中にリソース、メソッドの名称が存在しているかを判定するメソッドを記述します。 上記メソッドをUserPolicyクラスで使用すれば完了です。

   1|  def index?
   2|    user.admin? or can? "index"
   3|  end
   4|  
   5|  def show?
   6|    user.admin? or can? "show"
   7|  end
   8|  
   9|  def create?
  10|    user.admin? or can? "create"
  11|  end
  12|  
  13|  def new?
  14|    create?
  15|  end
  16|  
  17|  def update?
  18|    user.admin? or can? "update"
  19|  end
  20|  
  21|  def edit?
  22|    update?
  23|  end
  24|  
  25|  def destroy?
  26|    user.admin? or can? "destroy"
  27|  end

これは例えば、UserPolicycan?("update")が実行された際、(user.ability.include?("user") && user.ability["user"].include?("update"))が実行されるようになります。

これで権限が存在しないユーザーが各メソッドを実行した場合は例外が発生するようになります。 404に飛ばしたい場合は、ApplicationControllerで下記のように例外をキャッチしてあげればOKです。

   1|rescue_from NotAuthorizedError, with: :render_404
   2|  
   3|def render_404(exception = nil)
   4|  if exception
   5|    logger.info "Rendering 404 with exception: #{exception.message}"
   6|  end
   7|   
   8|  render file: "#{Rails.root}/public/404.html", status: 404, content_type: 'text/html'
   9|end

Punditの仕組み

ではなぜ、これだけの実装で権限管理機能が実現できているのかを、authorizeメソッドの処理を追いながら見ていきましょう。

   1|  def authorize(record, query=nil)
   2|    query ||= params[:action].to_s + "?"
   3|    @_policy_authorized = true
   4|   
   5|    policy = policy(record)
   6|    unless policy.public_send(query)
   7|      error = NotAuthorizedError.new("not allowed to #{query} this #{record}")
   8|      error.query, error.record, error.policy = query, record, policy
   9|      
  10|      raise error
  11|    end
  12|   
  13|    true
  14|  end

まず、変数queryに"呼び出されたメソッドの名前" + "?"を格納します。 @_policy_authorizedauthorizeメソッドが呼び出されたかどうかのフラグのようなものなので無視します。

次に、policyメソッドの結果を変数policyに格納しています。ここで引数になっているrecordはUserのインスタンスです。 (サンプルアプリケーションだとUser.newしただけのオブジェクト)

policyメソッドを見ていきましょう。

   1|  def policy(record)
   2|    @policy or Pundit.policy!(pundit_user, record)
   3|  end
   4|  attr_writer :policy
   5|  
   6|  def pundit_user
   7|    current_user
   8|  end

policyメソッドはPundit.policy!メソッドを呼んでいます。また、pundit_userはcurrent_userを呼び出していることがわかります。 Pundit.policy!メソッドを見ていきましょう。

   1|  def policy!(user, record)
   2|    PolicyFinder.new(record).policy!.new(user, record)
   3|  end

ちょっとわかり辛いですが、順に追っていきます。 まず、PolicyFinder#policy!メソッドを呼んでいます。 PolicyFinderクラスは短いので全部載せてしまいます。

   1|module Pundit
   2|  class PolicyFinder
   3|    attr_reader :object
   4|    
   5|    def initialize(object)
   6|      @object = object
   7|    end
   8|    
   9|    def scope
  10|      policy::Scope if policy
  11|    rescue NameError
  12|      nil
  13|    end
  14|    
  15|    def policy
  16|      klass = find
  17|      klass = klass.constantize if klass.is_a?(String)
  18|      klass
  19|    rescue NameError
  20|      nil
  21|    end
  22|    
  23|    def scope!
  24|      scope or raise NotDefinedError, "unable to find scope #{find}::Scope for #{object}"
  25|    end
  26|    
  27|    def policy!
  28|      policy or raise NotDefinedError, "unable to find policy #{find} for #{object}"
  29|    end
  30|    
  31|    private
  32|    
  33|    def find
  34|      if object.respond_to?(:policy_class)
  35|        object.policy_class
  36|      elsif object.class.respond_to?(:policy_class)
  37|        object.class.policy_class
  38|      else
  39|        klass = if object.respond_to?(:model_name)
  40|          object.model_name
  41|        elsif object.class.respond_to?(:model_name)
  42|          object.class.model_name
  43|        elsif object.is_a?(Class)
  44|          object
  45|        else
  46|          object.class
  47|        end
  48|        "#{klass}Policy"
  49|      end
  50|    end
  51|  end
  52|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の実装(リンク表示/非表示の制御)

次はviewです。サンプルアプリケーションではhelperに下記メソッドを追加しています。

   1|module UsersHelper
   2|  def user_show?(user = current_user)
   3|    Pundit.policy(user, User.new).show?
   4|  end
   5| 
   6|  def user_edit?(user = current_user)
   7|    Pundit.policy(user, User.new).edit?
   8|  end
   9| 
  10|  def user_destroy?(user = current_user)
  11|    Pundit.policy(user, User.new).destroy?
  12|  end
  13| 
  14|  def user_create?(user = current_user)
  15|    Pundit.policy(user, User.new).create?
  16|  end
  17|end

上記のようにしておくと、各メソッドを呼び出すリンクに対して下記のように記述ができるようになります。

 h1 Listing users

 table
   thead
  tr
    th Name
    th Role Id
    th Role Name
    th Email
    th
    th
    th

   tbody
  - @users.each do |user|
    tr
      td #{user.name}
      td #{user.role_id}
      td #{user.role.name}
      td #{user.email}
      - if user_show?
        td = link_to 'Show', user
      - if user_edit?
        td = link_to 'Edit', edit_user_path(user)
      - if user_destroy?
        td = link_to 'Destroy', user, method: :delete, data: { confirm: 'Are you sure?' }

 br

 * if user_create?
   = link_to 'New User', new_user_path

しかし、これは冗長な記述で条件分岐も多くなるため、ここまで厳密に管理する必要がないのであればpartialで分けてしまう方法でもいいかもしれません。

また、helperメソッドはデフォルト引数を指定するようにしていますが、これはテストをしやすくするようにするためです。UnitTestを実行した際はcurrent_userが作成されないため、引数でテストしたいUserのオブジェクトが指定できるようにしてあります。

具体的には、下記のようになります。

   1|require 'spec_helper'
   2|
   3|describe UsersHelper do
   4|  describe ".user_show?" do
   5|    context '管理者' do
   6|      include_context "管理者"
   7|      subject { user_show?(user) }
   8|      it { should be_true }
   9|    end
  10|    
  11|    context '権限保持者' do
  12|      include_context "User"
  13|      subject { user_show?(user) }
  14|      it { should be_true }
  15|    end
  16|    
  17|    context '権限非保持者' do
  18|      include_context "Role"
  19|      subject { user_show?(user) }
  20|      it { should be_false }
  21|    end
  22|  end
  23|
  24|  ...
  25|
  26|end

Scopeについて

PunditにはScopeという機能があります。 generatorでpoclicyを作成した際、このようなメソッドがデフォルトでついてきます。

  • app/policies/user_policy.rb
   1|class UserPolicy < ApplicationPolicy
   2|  class Scope < Struct.new(:user, :scope)
   3|    def resolve
   4|      scope
   5|    end
   6|  end
   7|end

これはどういう時に使う機能かというと、例えばindexメソッドで一覧を作成した際に、ユーザーの権限ごとに表示するレコードを制御したい際に役に立ちます。 例えば、ログインしたユーザーが管理者以外の場合、indexで表示されるユーザーに管理者ユーザーは表示したくない場合は下記のように実装します。

   1|scope :except_admin, -> {
   2|  joins(:role).where.not(roles: { name: "administrator" } )
   3|}
   1|class Scope < Struct.new(:user, :scope)
   2|  def resolve
   3|    if user.admin?
   4|      scope.all
   5|    else
   6|      scope.except_admin
   7|    end
   8|  end
   9|end
   1|  def index
   2|    @users = policy_scope(User)
   3|  end

policy_scopeメソッドはauthorizeと同様、渡されたオブジェクトのPolicyクラスを探し出してresolveメソッドを実行を行っています。

Roleごとに許可するパラメータを分ける

サンプルアプリケーションでは実装していないのですが、policyクラスを利用してStrongParametersで許可するパラメータを権限ごとに分けるという使い方がREADMEで紹介されています

READMEではブログシステムを例に、

  • 管理者および記事のオーナーの場合はタイトル、内容、タグの編集を許可する
  • それ以外はタグの編集のみ許可する

というケースをでの使い方の紹介をしています。

まず、policyクラスにpermitted_attributesというメソッドを用意し、権限ごとに許可するパラメータの配列を定義します。

   1|# app/policies/post_policy.rb
   2|class PostPolicy < ApplicationPolicy
   3|  def permitted_attributes
   4|    if user.admin? || user.owner_of?(post)
   5|      [:title, :body, :tag_list]
   6|    else
   7|      [:tag_list]
   8|    end
   9|  end
  10|end

controllerのStrongParametersの設定を行うメソッド内でpermitted_attributesメソッドを呼び、permitに渡す引数を動的に定義しています。

   1|# app/controllers/posts_controller.rb
   2|class PostsController < ApplicationController
   3|  def update
   4|    @post = Post.find(params[:id])
   5|    if @post.update(post_params)
   6|      redirect_to @post
   7|    else
   8|      render :edit
   9|    end
  10|  end
  11|
  12|  private
  13|
  14|  def post_params
  15|    params.require(:post).permit(*policy(@post || Post).permitted_attributes)
  16|  end
  17|end

テストについて

Punditにはpolicyクラスのテストを行うために、permissionsという独自のmatcherが用意されておりspec_helperでrequireすることで使用することができます。 サンプルアプリケーションではshared_contextを使ってuserのオブジェクトの作成をしていますので、このように書いています。

   1|require 'spec_helper'
   2| 
   3|describe UserPolicy do
   4|  subject { UserPolicy }
   5|
   6|  ...
   7|
   8|  permissions :update? do
   9|    context '管理者' do
  10|      include_context "管理者"
  11|      it { should permit(user, User.new) }
  12|    end
  13| 
  14|    context '権限保持者' do
  15|      include_context "User"
  16|      it { should permit(user, User.new) }
  17|    end
  18|    
  19|    context '権限非保持者' do
  20|      include_context "Role"
  21|      it { should_not permit(user, User.new) }
  22|    end
  23|  end
  24|
  25|  ...

しかし、これだけでは前述のScope機能のテストが行えないのでcontrollerのテストでカバーする必要がありそうです。 また、READMEの中でまた違ったテストの方法のアプローチが紹介されています。

まとめ

このように、PunditはメタプログラミングとRailsの機能をうまく利用し、少ないコードでうまく権限管理の機能を実現しています。権限の判定のロジックもリソース毎、メソッド毎に分離しており、自由に実装できて柔軟性も高いです。また、@znzさんのブログにもある通り、Railsの変化にも強そうです。

個人的には、メタプログラミングの勉強としても参考になるコードだと感じています。前述の通りコードの量も非常に少ないので、コードリーディングのお題としてもおすすめです。

Punditを使用する際の注意点

Punditを使う際には以下の注意点があります。

名前空間が異なる同じ名前のクラスがある場合、本来取得したいクラスのPolicyが取得できない可能性がある。

私が直面した問題ではないのですが、PolicyFinderのfindメソッドは上記のケースが考慮されていません。このようなケースの場合はパッチを書く必要があります。

Rails以外だと使用し辛い

Rails以外でも使えなくも無いですが、基本的にRailsで使われることが前提の作りになっているように感じます。また、activesupportは必須です。

current_userメソッドを作成する必要がある

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に会社の雑務を行わせるのがマイブーム。

更新日時:2014/07/01 04:04:01
キーワード:
参照:[Rubyist Magazine 0047 号] [0047 号 巻頭言] [Rubyist Magazine 十周年] [分野別目次] [各号目次]