How to use CanCan / CanCanCan

  • 310
    いいね
  • 2
    コメント

ネットサーフィンをしていると「CanCan便利!」「CanCanはRailsでの権限管理デファクトスタンダードだよ」などの声をぼちぼち見かけますが、あまりまとまったCanCanの使い方に関する記事を見つけられなかったので覚書をしたためます。
ほとんどCanCanの配布元Wikiに書いてあることですので、英語が読める方はそちらを参照するほうが正確だと思われます。
内容へのツッコミや訂正大歓迎です!

CanCanとは

(by CanCan repo README - github)
CanCan is an authorization library for Ruby on Rails which restricts what resources a given user is allowed to access. All permissions are defined in a single location (the Ability class) and not duplicated across controllers, views, and database queries.

(意訳)
Railsを使ってると、このユーザーはindexにアクセスできるけど、createが出来ないとか、そういうことしたくなるよな。
よくあるけど、そういう認証処理がコントローラーやビュー、はたまたSQLクエリなんかのいろいろな場所にばら撒かれるのはCoolじゃねえ。いっちょAbilityクラスとか作ってそこでどうにかできたら素敵!
なーんていう夢を提供しているのさ!

CanCanCanとは

(by CanCanCan repo README - github)
This repo is a continuation of the dead CanCan project. Our mission is to keep CanCan alive and >

moving forward, with maintenance fixes and new features. Pull Requests are welcome!
I am currently focusing on the 1.x branch for the immediate future, making sure it is up to date as well as ensuring compatibility with Rails 4+. I will take a look into the 2.x branch and try to see what improvements, reorganizations and redesigns Ryan was attempting and go forward from there.

Any help is greatly appreciated, feel free to submit pull-requests or open issues.

(意訳)
...という夢を見たんだ。
どうもCanCanはメンテナンスが止まってしまった。しかしまだまだオワコンにはさせたくない。夢を見続けたいんだ。Rails4でもCanCan使ったりしたいとかね!
ってわけで勝手にCanCanのメンテナンスを続けているけど、もちろんみんなからのプルリクとか意見・要望とかウェルカムさ!

ざっくりとした解説

例えば以下のようなアプリケーションがあったとします。

  • 「店頭ページ」と「倉庫ページ」があるショッピングサイトで、訪問者は店頭ページに表示された製品を購入できる。
  • システム管理者はすべてのページを閲覧できる。すべての操作が可能。
  • 倉庫管理者は、自分の担当する倉庫に保管された製品を閲覧できる。担当倉庫の製品を店頭に出す、あるいは店頭の製品を倉庫にしまうことができる。
  • 店長はどの製品がどの倉庫にしまわれているか、一覧を見ることができる。しまわれている製品を削除することができる。任意の倉庫に新しく製品を追加できる。

店頭ページを作成すると

controllers/shop_controller.rb
def index
  if current_user.customer? || current_user.sys_admin? || current_user.product_manager?
    # 製品一覧の表示
  else
    render 403 # アクセス権限エラー 
  end
end

と、このようになります。
同様に、倉庫ページ、製品の取り出し、製品の購入などのページでも「もしユーザーがーXXかYYなら使える。そうでないならアクセス権限ページに移動する」という処理を作ることになります。

数カ所ならば大した問題ではありませんが、アプリケーション全体でこのような処理をつど入れると、大変複雑なシステムになってしまいます。

そうではなく何処か一箇所で、

  • 管理者ならなんでもできる
  • 倉庫管理者なら
    • Aができる
    • Bができる
  • 訪問者なら
    • Aができる
    • Bはできない

などと設定し、

can? :do, A          # ログインユーザーはAできるか
cannot? :do, B       # ログインユーザーはBできないか
can? :index, Product # ログインユーザーは製品の一覧を見れるか

@product = Product.find(1)
can? :show, @product # ログインユーザーはid=1の製品に関して詳細を見れるか

と、判定できれば、新しく権限が追加されたり、「倉庫管理者も製品の削除がしたいよ」となったとき、その何処か一箇所だけをいじればよく、システムを簡潔に保てます。

CanCanは、このような権限管理を目的としたRuby on Rails用のGemです。
ユーザー権限ごとのアクセス制限に関する処理や設定を、極力一箇所(Abilityクラスを作成するのが通例)に集中させることが出来ます。
CanCanCanは、開発の止まったCanCanの後続プロジェクトで、Rails4にも対応しているようです。
この記事では主に、Rails4とCanCanCanを用いてることを前提に説明しますが、だいたい同じようなことがRails3でも可能です。

この記事について

本記事の前提

ユーザーに権限をもたせる場合、システムによって

  • 1ユーザーは1つの権限しか持たない
  • 1ユーザーは複数の権限を持ちうる

のパターンが存在するでしょう。
それぞれの方法に長所短所が存在しますが、本記事のテーマから外れるので解説はしません。
本記事では、後者の「1ユーザーは複数の権限を持ちうる」場合を前提にします。

権限設定(Abilityクラスの作成)

CanCanは、Ruby on Rails用のGemです。
ユーザー権限ごとのアクセス制限に関する処理や設定を、極力一箇所(Abilityクラスを作成するのが通例)に集中させることを目的にしています。
コンソールでRailsプロジェクト以下に移動し

$ rails g cancan:ability

を実行することで

models/ability.rb
class Ability
  include CanCan::Ability

  def initialize(user)
  end
end

を作成してくれます。

権限があるかはcan?メソッドで判定することが出来ますが、このとき暗黙的に

def current_ability
  @current_ability ||= Ability.new(current_user)
end

が呼び出されます。

もし、権限を判定するユーザーにcurrent_userを使いたくない場合は、ApplicationControllerでcurrent_abilityをオーバーライドすることで、いろいろ弄くり回せます。(例えば、オペレーション権限を持つユーザーが、ログイン後に他のユーザーに成りすましてシステムを操作する場合があるなど)

基本的な使い方

models/ability.rb
class Ability
  include CanCan::Ability

  def initialize(user)
    user ||= User.new  # ログインしていない場合は、からユーザーを用意し判定に用いる

    # default parmission
    cannot :buy, Product

    if user.sys_admin?
      can :manage, :all
    end

    if user.product_manager?
      can :manage, Stockpile, owner: user # 自分がオーナーの倉庫には全権限を持つ
      can :read, Stockpile                 # そうでなくても、読み取り権限を持つ

      # 自分の倉庫にある製品に対してすべての権限を持つ
      can :manage, Product, stockpile: {owner: user}
      # ただし、新規登録、削除はできない
      cannot [:create, :destroy], Product
    end

    if user.customer?
      # 複数のモデルに権限を付与できる
      can :read, [Stockpile, Product]

      # 独自権限も作れる
      can :buy, Procuct, stockpile: nil # 倉庫から出されている製品を買える
    end
  end
end

基本の権限には

  • read ... 参照
  • create ... 登録
  • update ... 更新
  • destroy ... 削除
  • manage ... すべて

があります。注意したいのは「manage / すべて」というのは「read+crete+update+destroy」というわけではないことです。
上の例では「buy / 購入」という権限を独自に作っていますが、manage権限ではbuyも可能になります。
そこでデフォルトの権限付与で、製品の購入を禁じ、もしuser.customer? = trueならば購入可能になるようにしています。
あるモデルのある権限にcancannotが重なって付与された場合、最後に適応されたほうが有効になるようです。

controllers/stockpiles_controller.rb
# current_userがproduct_managerの場合
can? :manage, Stockpile   #=> true
can? :read, Stockpile   #=> true
can? :update, Stockpile   #=> true

# 1. あるモデルに権限Xを持っているか判定する
@stockpile = Stockpile.find_by(owner: user)
can? :manage, @stockpile  #=> true

# 2. あるレコードに権限Xを持っているか判定する
@stockpile_2 = Stockpile.where("owner_id != ?", user.id).first
can? :manage, @stockpile_2  #=> false
can? :read, @stockpile_2    #=> true
can? :update, @stockpile_2  #=> false

# 3. あるモデルから権限を持っているレコードを取得する
Stockpile.accessible_by(current_ability).to_sql
#=> SELECT "stockpiles".*
#     FROM "stockpiles"
#    WHERE "stockpiles"."owner_id" = 1

ということが出来ます。
3の方法では、index権限での検索がデフォルトのようですが、

Stockpile.accessible_by(current_ability, :destroy)
Stockpile.accessible_by(current_ability, :my_permission)

などとすることで、他の権限でのレコード取得も可能です。

余談ですが、もし権限が複数あり、Abilityクラスのdef initalize(user)が大きくなってしまうのが嫌ならば、以下のようにするといいでしょう。

models/ability.rb
class Ability
  include CanCan::Ability

  def initialize(user)
    user ||= User.new

    # user.roles => ['sys_admin', 'product_manager'...]
    # などと動作する User#roles があるとして 
    user.roles.each do |role_name|
      send("#{role_name}_ability", user)
    end
  end

  private

  def sys_admin_ability(_)
    can :manage, :all
  end

  def product_manager_ability(user)
    can :manage, Stockpile, owner: user 
    can :read, Stockpile
    ... # 省略
  end

  def customer_ability(_)
    ...
  end
end

注意

Hashによる設定は、レコードの検索時と判定時に利用されます。
しかしこの仕様では、例えばEnumなどでレコードの実値にたいして別名をつけているような場合に、ハマったりすることがあるかもしれません。

# gem "enumerize" で Adminのrole_typeに別名をつけている
admin = Admin.where(role_type: :sysad).first
#=> SELECT "admins".* FROM "admins" WHERE "admins"."role_type" = 1 LIMIT 1
admin.role_type #=> "sysad"

# can :read, Admin, role_type: :sysad と設定すると..
Admin.accessible_by(current_ability) #=> 「WHERE "admins"."role_type" = 'sysad'」が発行されてしまいエラー

# can :read, Admin, role_type: 1 と設定すると..
admin = Admin.where(role_type: :sysad).first
can? :read, admin  #=> 「"sysad" == 1」と判定され false になる

これを回避するには、検索条件と判定条件を別々に設定する必要があります(後述)。

Modelのないコントローラーへの権限設定

もし店舗を表すShopモデルのようなものがなく、「店舗ページへのアクセス権限」というものを作成したい場合はどうすればいいでしょうか。

1つの方法はからのShopモデルを作成し、それに対してcan :manage, Shopなどと権限を付与することです。
しかし、実際に使わないからのモデルを作成するのは、後からプロジェクトに参加した人間に混乱を招くかもしれません。
通常は、以下のように設定すれば十分なはずです。

# models/ability.rb
can :manage, :shop

# controller/shops_controller.rb
can? :read, :shop #=> true or false

権限のAND条件

# 自身が登録した食べ物に参照権限がある
can :show, Product, type: 'food', created_by: user.id

# 一年前〜半年前に登録された食べ物の削除権限がある
can :destroy, Product, type: 'food', created_at: (1.years.ago..6.months.ago)

# どう検索されるか確認
Stockpile.accessible_by(current_ability, :show)
#  WHERE "stockpiles"."type" = 'food' AND "stockpiles"."created_by" = 1
Stockpile.accessible_by(current_ability, :destroy)
#  WHERE "stockpiles"."type" = 'food' AND "stockpiles"."created_at" BETWEEN '2014-12-1' AND '2014-6-1'

権限のOR条件

# 食べ物、または飲み物の閲覧権限がある
can :read, Product, type: 'food'
can :read, Product, type: 'drink'

# どう検索されるか確認
Stockpile.accessible_by(current_ability)
#  WHERE "stockpiles"."type" = 'food' OR "stockpiles"."type" = 'drink'

権限判定でhas_oneな関係のレコードが必要

has_one belongs_to で関連を設定したレコードの場合、以下のようにして権限判定に利用することが出来ます。

# 製品が格納されている倉庫の管理者なら、製品を更新できる
can :update, Product, stockpile: {owner_id: user.id}

ModelのスコープやStringによる権限設定

権限の設定には上記であげたHashを使った指定以外にも、違うやり方があります。
仮に、「Productには、秘匿(secret)でないものだけ閲覧権限がある」という設定をしたい場合、以下のようないくつかの方法があります。

can :read, Product, secret: false
can :read, Product, "secret = false"
can :read, Product, ["secret = ?" , false]
can :read, Product, Product.where(secret: false)

# Publicモデルで`scope :not_secret, ->{ where(secret: false) }`を設定している
can :read, Product, Product.not_secret

注意

上記のようにModelのスコープやStringによって権限を設定した場合、その設定は検索条件には使われますが、判定条件には使えません。

can :read, Product, ["expired_at <= ?", Date.today]

can? :read, Product #=> true
Product.accessible_by(current_ability)
# SELECT "products".* FROM "products" WHERE "products"."expired_at" <= '2014-12-24'

product = Product.accessible_by(current_ability).first
can? :read, product
#CanCan::Error: The can? and cannot? call cannot be used with a raw sql 'can' definition. The checking code cannot be determined for :read

レコードの権限判定でエラーが起きました。
["expired_at <= ?", Date.today]は検索条件にはなりますが、レコードを判定する場合には使えません。
判定にも使いたい場合はブロックを用いて、以下のように書く必要があります。

can :read, Product, ["expired_at <= ?", Date.today] do |record|
  # レコードの権限判定
  record.expired_at <= Date.today
end

ModelのスコープやStringによって権限を設定した場合、Hashを使った権限設定とのORができないという問題もあります。

can :read, Product, publish: true
can :read, Product, Product.where(secret: false)

Product.accessible_by(current_ability)
#=> CanCan::Error: Unable to merge an Active Record scope with other conditions. Instead use a hash or SQL for read Product ability.

権限認証(Controllerでの設定)

そろそろ書くのに疲れてきましたので、さくさく行きます。

authorize!

can?true / falseを返すのに対し、authorize!は失敗時にraiseします

#hoges_controller.rb
def index
  authorize! :index, Hoge
end

load_resource

controllerの各アクションに対して暗黙的に、うまいことレコードをロードしてくれます。

class HogesController < ApplicationController
  load_resource

  def index
    # 暗黙的に @hoges 変数に Hoge.accessible_by(current_ability) を設定
  end

  def show
    # 暗黙的に @hoge 変数に@product Hoge.find(params[:id]) を設定
  end

index アクションならアクセスできるクラス
show, edit, update, destroy アクションはidで検索
new アクションでは上手いことアクセスできる権限のレコードをデフォルトで作成してくれます

詳細はドキュメント読むとなんとなくわかります。

authorize_resource

controllerの各アクションにたいする、権限を暗黙的に判定してくれます。
判定は、レコードではなくモデルへの権限なことに注意してください。

class HogesController < ApplicationController
  authorize_resource

  def index
    #暗黙的に authorize! :index, Hoge
  end

  def show
    #暗黙的に authorize! :show, Hoge
  end

  def oreore_method
    #暗黙的に authorize! :oreore_method, Hoge
  end

load_and_authorize_resource

load_resourceauthorize_resourceの合わせ技をしてくれます。

class ProductsController < ActionController::Base
  load_and_authorize_resource
  def discontinue
    # 暗黙的にレコードを検索し、その権限を判定する
    # @product = Product.find(params[:id])
    # authorize! :discontinue, @product
  end
end

特定のアクションにだけこれらの設定をしたい / 特定のアクションでは判定をしなくない場合、以下のように設定できます。

load_and_authorize_resource :only => [:index, :show]
load_and_authorize_resource :except => [:index, :show]

終わりに

その他、テストの書き方、デバッグの仕方、コントローラーの作り方サンプル、StrongParameerとの食いあわせ、などなど
有益な情報がリポジトリのWikiに書いてあります。
一度目を通してみるのをおすすめします。そして誰か、もっと詳細な和訳解説を書いてください :relaxed:

おまけ

本記事では実際に使う時のイメージを優先し、「モデルに権限を付与する」「レコードの権限を判定する」などという言い回しを使っています。
しかし、正確には「クラス / インスタンスに権限を付与する」というのが正しいです。

例えば

can? :read, 'aiueo' #=> false
can? :read, Fixnum  #=> false

can :manage, :all
can? :read, 'aiueo' #=> true

cannot :read, String
can? :read, 'aiueo' #=> false
can? :read, 123     #=> true

「へえ、何の意味があるの?」と問われれば「いや、何も…」となるのですが。

[修正 2015/11/27] 誤った利用法「can :read, :string」について削除

参考サイト

関連記事など