Rails Webook

Web業界で働いています。Railsを中心にプログラミングや事業開発のようなことを書いていきます。

Railsの権限管理CanCanCanのソースコードリーディング

Railsの権限管理のCanCanCanのソースコードを読んでみてまとめました。 本体のコード量的は1,000行程度で、同じ権限管理をするPundit(300行程度)と比べると多めです。

1. 目的

下記のようなこと中心にソースコードを見てい来たいと思います。

  • 権限管理の実装方法
  • 権限管理の仕組みをRailsに組み込む方法

2. 基本情報

対象バージョン

2018/9/16に作られた2.0.0で確認します。

コード量

全体のrubyコードが2,700行で、lib配下だけだと1000行程度なのでコード量は少ないと思います。

> cloc cancancan/lib
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
Ruby                            16            179            619            982
-------------------------------------------------------------------------------
SUM:                            16            179            619            982

ディレクトリ構成

gemなのでシンプルですが、libがgemの本体でspecがrspecのテストの2つがメインのディレクトリです。
libを見ると、lib/cancancan.rbがエントリポイントですが、cancanを読んでいます。
これは、CanCanCanのREADMEのMissionに記載されているのですが、CanCanという権限管理(Authorization)をするgemがあったのですが、そのプロジェクトが止まってしまったので別リポジトリを作って開発を継続しているためです。

cancancan
├── lib
│   ├── cancan
│   │   ├── model_adapters
│   │   │   ├── abstract_adapter.rb
│   │   │   ├── active_record_4_adapter.rb
│   │   │   ├── active_record_adapter.rb
│   │   │   └── default_adapter.rb
│   │   ├── ability.rb
│   │   ├── controller_additions.rb
│   │   ├── controller_resource.rb
│   │   ├── exceptions.rb
│   │   ├── matchers.rb
│   │   ├── model_additions.rb
│   │   ├── rule.rb
│   │   └── version.rb
│   ├── generators
│   ├── cancan.rb
│   └── cancancan.rb
├── spec
├── gemfiles
│
...

クラス図

f:id:nipe880324:20180922212250p:plain:w420

ドキュメント

CanCanCanのインストール方法や簡単な使い方や機能を眺めておくとソースコードリーディングも理解しやすくなります。
CanCanCan README

基本的な使い方

CanCanCanでは、Abilityクラスでcancannotメソッドで権限のルールを作成します。
そして、ビューやコントローラーでcan?cannot?メソッドで権限があるかないかを取得したり、コントローラーでauthorize!メソッドで権限がなければエラーを発生させることができるようになります。

Abilityクラスでユーザーの権限を定義します。 暗黙的にcurrent_userの値がuser引数で渡されてきます。

class Ability
  include CanCan::Ability

  def initialize(user)
    can :read, :all # すべてのユーザーがリソースを読める、ログインしていないユーザー含む

    if user.present?
      can :manage, Post, user_id: user.id # ログインしていればPostを管理できる
      if user.admin?
        can :manage, :all # 管理者はすべてのリソースを管理できる
      end
    end
  end
end

can?cannot?メソッドがビューやコントローラーで使えます。
CanCanCanはコントローラー内にcurrent_userメソッドが実装されていることを前提に動きます。

# ビューファイルでの権限による出しわけ
<% if can? :update, @post %>
  <%= link_to "Edit", edit_post_path(@post) %>
<% end %>

コントローラー内でauthorize!メソッドを使えます。権限がなければエラーが発生します。

def show
  @post = Post.find(params[:id])
  authorize! :read, @post
end

TODO やる?

Loader, Strong Parameter

3. CanCanCanの権限管理を実装する方法

まずは、Abilityクラスで権限管理のルールを定義している方法を確認します。

AbilityクラスでインクルードするCanCan::Abilityをみていきます。
ここで、ルールを定義するcancannotメソッドが実装されています。

class Ability
  include CanCan::Ability

  def initialize(user)
    can :read, :all
    ...
  end
end

CanCan::Abilityモジュールのcancancanメソッドは、Ruleインスタンスを作成し、「ルールの配列(@rules)」と「ルールのインデックスのハッシュ(@rules_index)」にインスタンスを追加しています。

# lib/cancan/ability.rb
module CanCan
  module Ability
    # ルールを定義する
    #
    # 使い方の例
    #   can :manage, :all
    #   can :update, :all
    #   can :manage, Post
    #
    #   ※ :manageは全てのアクション、他には :read, :create, :update, :destroyがある
    #
    def can(action = nil, subject = nil, conditions = nil, &block)
      add_rule(Rule.new(true, action, subject, conditions, block))
    end

    def cannot(action = nil, subject = nil, conditions = nil, &block)
      add_rule(Rule.new(false, action, subject, conditions, block))
    end

    ...

    protected

    def rules
      @rules ||= []
    end

    private

    ...

    def add_rule(rule)
      rules << rule
      add_rule_to_index(rule, rules.size - 1)
    end

    # subjectをキーと、rules配列の添字をバリューにしたハッシュを作る
    # 例: { Post => [0, 2], :all => [1] }
    def add_rule_to_index(rule, position)
      @rules_index ||= Hash.new { |h, k| h[k] = [] }

      subjects = rule.subjects.compact
      subjects << :all if subjects.empty?

      subjects.each do |subject|
        @rules_index[subject] << position
      end
    end

    ...

  end
end

では、この定義したルールを使ってどのようにcan?cannot?メソッドは権限を確認していくかをみます。
かなり泥臭くマッチするルールを探し、ルールがあれば、そのルールのbase_behavior = true|falseを確認して権限があるかどうかを返しています。細かな実装を説明するのが難しいので説明は省きます。

canメソッドでのルール定義は、条件やブロックなどでも定義でき、権限定義が複雑になって、機能追加が増えて、条件分岐が増えて、ソースコードが泥臭くごちゃごちゃしてしまったのかなと思いました。そう思うのと、権限管理の定義や実現の実装方法は、Punditの設計の方が権限の柔軟性もあり、テストもしやすく、外部インターフェース的にも内部実装的にも使いやすい気がしました。

# lib/cancan/ability.rb
module CanCan
  module Ability
    # オブジェクトのアクションが許可されているかチェックする
    #
    # 使い方の例
    #
    #  can? :update, @post
    #
    def can?(action, subject, *extra_args)
      match = extract_subjects(subject).lazy.map do |a_subject|
        # マッチするルールを探す
        relevant_rules_for_match(action, a_subject).detect do |rule|
          # ルールの条件がマッチしているかどうか
          rule.matches_conditions?(action, a_subject, extra_args)
        end
      end.reject(&:nil?).first
      match ? match.base_behavior : false
    end

    def cannot?(*args)
      !can?(*args)
    end

    ...

    private

    # It translates to an array the subject or the hash with multiple subjects given to can?.
    def extract_subjects(subject)
      if subject.is_a?(Hash) && subject.key?(:any)
        subject[:any]
      else
        [subject]
      end
    end

    def relevant_rules_for_match(action, subject)
      relevant_rules(action, subject).each do |rule|
        next unless rule.only_raw_sql?
        raise Error,
              "The can? and cannot? call cannot be used with a raw sql 'can' definition."\
              " The checking code cannot be determined for #{action.inspect} #{subject.inspect}"
      end
    end

    # actionとsubjectがマッチしたRuleインスタンスの配列を返す
    def relevant_rules(action, subject)
      return [] unless @rules
      relevant = possible_relevant_rules(subject).select do |rule|
        rule.expanded_actions = expand_actions(rule.actions)
        rule.relevant? action, subject # ルールがactionとsubjectにマッチしているどうか
      end
      relevant.reverse!.uniq!
      optimize_order! relevant
      relevant
    end

    # @rules_index(例: @rules_index = { Post => [0, 2], :all => [1] })から
    # subject(Postなど)にマッチするバリューを取得し、
    # ルールの配列を取得する
    def possible_relevant_rules(subject)
      if subject.is_a?(Hash)
        rules
      else
        positions = @rules_index.values_at(subject, *alternative_subjects(subject))
        positions.flatten!.sort!
        positions.map { |i| @rules[i] }
      end
    end

    # Optimizes the order of the rules, so that rules with the :all subject are evaluated first.
    def optimize_order!(rules)
      first_can_in_group = -1
      rules.each_with_index do |rule, i|
        (first_can_in_group = -1) && next unless rule.base_behavior
        (first_can_in_group = i) && next if first_can_in_group == -1
        next unless rule.subjects == [:all]
        rules[i] = rules[first_can_in_group]
        rules[first_can_in_group] = rule
        first_can_in_group += 1
      end
    end
  end
end

4. CanCanCanの権限管理をRailsに東吾する方法

CanCan::AbilityとRailsをどうやって統合しているかを見ていきます。

CanCanCanをインストールすると、ビューやコントローラーでcan?cannot?を使うことができます。また、コントローラーでauthorize!などの権限確認のメソッドを使うことができます。

# コントローラーでauthorize!
def show
  @post = Post.find(params[:id])
  authorize! :read, @post
end

# ビューファイルでのcan?
<% if can? :update, @post %>
  <%= link_to "Edit", edit_post_path(@post) %>
<% end %>

CanCan::ControllerAdditionsモジュールでcan?cannot?authorize!メソッドが定義されています。また、それぞれのメソッドは、Abilityクラスに処理を委譲しています。
そして、これらのメソッドをActionController::Baseにインクルードさせることでコントローラーで使えるようにしています。
また、self.includedメソッドで、インクルードしたときにcan?cannnot?をヘルパーメソッドに追加しています。こうすることで、ビューファイルでも使えるようにしています。

# lib/cancan/controller_additions.rb
module CanCan
  module ControllerAdditions
    ...

    def self.included(base)
      base.extend ClassMethods
      base.helper_method :can?, :cannot?, :current_ability if base.respond_to? :helper_method
    end

    def can?(*args)
      current_ability.can?(*args)
    end

    def cannot?(*args)
      current_ability.cannot?(*args)
    end

    def authorize!(*args)
      @_authorized = true
      current_ability.authorize!(*args)
    end

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

# ActionController::Baseがあれば、
# CanCan::ControllerAdditionsのメソッドをコントローラーに追加する
if defined? ActionController::Base
  ActionController::Base.class_eval do
    include CanCan::ControllerAdditions
  end
end

ちなみに、can?cannot?authorize!などは、current_abilityに依存しているので、current_abilityメソッドをオーバーライドして別のAbilityクラスをインスタンス化することでコントローラーごとに異なる権限管理を定義できます。

class SomeController < ApplicationController

  ...

  def current_ability
    @current_ability ||= AdminAbility.new(current_admin)
  end
end

5. その他気になったことメモ

その他コードをみていて、気になったことをメモします。

cancancan.rbを見るとわかりますが、CanCanCanは内部的にはrequire 'cancan'でCanCanを呼び出しているだけです。なので、Abilityクラスの実装もCanCan::Abilityをインクルードする形になっていたのだと思います。

# lib/cancancan.rb
require 'cancan'

module CanCanCan
end

まとめ

CanCanCanは、CanCanをラップしたGemでした。
CanCan::Abilityモジュールを実装したAbilityクラスを作成し、そこでcancannnotメソッドで権限のルールを定義します。
そして、can?cannnot?authorize!メソッドでビューやコントローラーから権限を確認できるようになります。

ルール定義がブロックやハッシュなどで定義できるからか、ルールにマッチしているか探す箇所が、けっこう泥臭いイメージを感じました。

同じ権限管理を実装したPunditのソースコードを読みましたが、機能はほぼほぼ同じですが、そちらのほうが、実装的にシンプルで、使い勝手も権限ルールの定義が柔軟で、拡張性があり、テストがしやすいと思いました。

以上です。