🕳️

【Rails】Alias, 罠 of CanCanCan

2024/03/15に公開

こんにちは!ラブグラフ開発インターンの arawi です!
Rails の CanCanCan gem を利用していたときに陥った罠についてお話します!

https://github.com/CanCanCommunity/cancancan

TL;DR

  • CanCanCan では、デフォルトでアクションのエイリアスが設定されている
  • それを理解しておかないと予期しない挙動をすることがある
  • また、CanCanCan のルールはあとに書いたものが優先される

発端

あるリソースに対して、show は限られた id のものに絞って、 index はできないという権限を作りたいときがありました。
ラブグラフでは権限管理に CanCanCan を利用しているので、CanCanCan の Ability モジュールを include した Ability クラスに、以下のような記述を追加しました。

ability.rb
class Ability
  include CanCan::Ability

  def initialize(staff)
    cannot :index, Resource
    can :read, Resource, id: some_ids
  end
end

これによって、

  • some_ids に含まれる id の /resources/:id は見れる
  • some_ids に含まれない id の /resources/:id は見れない
  • /resources/ は見れない

といった結果を得ることを期待しました。しかし、実際は

  • some_ids に含まれる id の /resources/:id は見れる⭕️
  • some_ids に含まれない id の /resources/:id は見れない⭕️
  • /resources/ が見れてしまう

という結果になりました。
cannot で明示的に否定しているのに index が見れてしまって、不可解に感じました。

Ability のインスタンスを見てみる

調査のため、Rails コンソールで Ability のインスタンスの中身をみてみました。

pry(dev)> staff = Staff.last
pry(dev)> ability = Ability.new(staff)
pry(dev)> ability.can?(:index, Resource)
=> true # ここでもちゃんと true になっていて、バグではないことがわかる
pry(dev)> ability
=> #<Ability:0x0000aaaae4c07990
 @aliased_actions=
  {:read=>[:index, :show],
   :create=>[:new],
   :update=>[:edit]},
 @expanded_actions=
  {[:index]=>[:index],
   [:index, :show]=>[:index, :show],
   [:read]=>[:read, :index, :show]},
 @rules=
  [#<CanCan::Rule:0x0000aaaae4bdc5d8
    @actions=[:index],
    @base_behavior=false,
    @block=nil,
    @conditions={},
    @expanded_actions=[:index],
    @match_all=false,
    @subjects=
     [Resource(id: integer, ... , created_at: datetime, updated_at: datetime)]>,
   #<CanCan::Rule:0x0000aaaae4763ba0
    @actions=[:read],
    @base_behavior=true,
    @block=nil,
    @conditions={:order_id=>[1]},
    @expanded_actions=[:read, :index, :show],
    @match_all=false,
    @subjects=
    ...

aliased_actions とか expanded_actions とかいう怪しい存在がいます!!こいつらか!!

我々は docs の奥地へと向かった・・・

CanCanCan のドキュメントを見ると、次のようなことが書いてありました。

CanCanCan offers four aliases: :read, :create, :update, :destroy for the actions. These aren't the same as the seven Restful actions in Rails. CanCanCan automatically adds some convenient aliases for mapping the controller actions.

read: [:index, :show]
create: [:new, :create]
update: [:edit, :update]
destroy: [:destroy]

要約すると、便宜のためデフォルトで readindex, show のエイリアスになっているようです、これにより、can :read, Resource と記述するだけで indexshow ができるようになります。

リソース全体に対して can するときにはそれでいいんですが、今回のケースのように対象のリソースを id で絞りたいようなときでも、もれなく index がついてきてしまい、結局 index できてしまいます。このようにして、冒頭の問題は引き起こされていたのでした。

ちなみに、上にもある通り createnewcreate のエイリアスになっています。これが原因で create を許可するともれなく new もついて来てしまうので、結構困ってる人もいるようです。

その issue: https://github.com/CanCanCommunity/cancancan/issues/832

でも cannot も書いたやん

しかし今回は明示的に cannot :index, Resource を書いたので、それが通用しそうな感じがします。なぜこの cannot は効いてないのでしょうか?

結論から言えば、あとに書いたルールが優先されるためです。

https://github.com/CanCanCommunity/cancancan/blob/178d2c5bdf97225751a1a089e9815a4060b03265/lib/cancan/ability/rules.rb#L33-L45

こちらのメソッド relevant_rules は与えられたアクションと対象に対して関連のあるルール[1]を抽出してくるメソッドです。

内部のコードを見ると、

  • relevant を取ってくるところまでは定義順でルールが並べられます。
  • その後、reverse! が呼ばれることで、順番が入れ替わります。
  • このメソッドの外側で relevant_rules に対して detect メソッドが呼ばれ[2]、前方からルールが取られる

ので、結果的に定義が遅いほどルールが優先されるということになります。

どうするべ

さて、原因がわかったところで、問題を解決するためにはどうしたらよいでしょうか?
私は以下の2つの解決策を考えました。

  1. cannot :indexcan :read のあとに書く。
    • cannot が優先され、期待通りの挙動になります。
  2. read を使わず、showindex に分けてルールを書く。
    • これは以下のようになります。
class Ability
  include CanCan::Ability

  def initialize(staff)
    cannot :index, Resource
-   can :read, Resource, id: some_ids
+   can :show, Resource, id: some_ids
  end
end

個人的には、2 の書き方が明示的で良いと思います。

おわりに

とりあえず can :read, Resource, id: ... には気をつけろ

本当に理由がわからず結構沼ったので、原因がわかってよかったです。
また、 binding.pry を使ったり、 GitHub にソースコードを読みに行ったりして、ちゃんと理解できたのも成長につながったと感じています!

以上です!ここまで読んでいただきありがとうございました!

脚注
  1. can とか cannot のメソッドで定義されるものは内部ではルールと呼ばれます。 ↩︎

  2. detect メソッドは要素に対してブロックを評価した値が真になった最初の要素を返します。 ↩︎

ラブグラフのエンジニアブログ

Discussion