27. 権限管理のリファクタリング

今日から溜まりに溜まったモデルの話をしようと思っていたが、ページの描画などをしているうちに権限関係のメソッドが使いづらいことに気づいた。いろいろなことを考えてモデルやヘルパを先んじて作っているが、実際に使ってみるといろいろと不具合が出てくるものだ。テストは十分に書いているつもりなので、思い切ってガッツリとリファクタリングしてみる。

権限周りの見直し

現在は以下のような手順で権限管理をしている。

  • ユーザがログインすると、welcome#index に飛ばされる
  • welcome#index で current_user または current_admin に対して set_privileges が呼ばれる
  • user の場合、user の権限を調べ、user_session に権限を記録する
  • admin の場合、化ける user が設定されている場合、その user の権限を調べ、admin_session に権限を記録する
  • view や controller では、controller の action に対応する権限一覧を controller に記録されている hash から取得する
  • その後、取得した権限一覧を session に書かれている権限と比較し、権限のあるなしを返却する

ここで問題なのは、最後の確認メソッド has_privileges? を user や admin のインスタンスメソッドにしてしまったことだ。実際には、セッションとの比較をしているだけなので、user や admin の情報は必要ない。簡単に考えると、現在 concerns に記録しているメソッドを User に移動しクラスメソッドとすればよいのだが、これだとオブジェクト指向的に筋が悪い(user 個人の権限を該当者でない User クラスに聞くのは気持ち悪い)。

今回のリファクタリングでは user が権限を持つかではなく、controller が権限を許すかというように発想の転換を行ってみる。そもそも controller から権限一覧の hash を取り出すために constantize をしているため、class は取得できている。この controller class に対して、権限を聞くようにする。上記の手順における最後の二つは以下のように変更する。

  • view や controller では、controller class に対して session を渡し、権限のあるなしを返却する。

コントローラテストの追加

上記の user への依存性があったため、テストコードはかなり複雑なものになっていた。has_privileges? については controller に移動する。まずは、このテストを spec/controllers/application_spec.rb に記述する。第一引数でコントローラ、第二引数以降は可変にして、action, 許可する権限、許可しない権限を並べて記述する。設定されていないデータがあった場合に許可しないことも確認しておく。

RSpec.describe ApplicationController, type: :controller do

  privilege_test Teacher::KyoumusController,
    :index, %w( is_kyoumu? ), %w( is_staff? ),
    :no_action, %w(), %w( is_kyoumu? is_staff? ) # 設定してない場合
  privilege_test Teacher::YearsController,
    :index, %w( is_kyoumu? ), %w( is_staff? ),
    :create, %w( is_kyoumu? ), %w( is_staff? )
end

privilege_test メソッドは spec/rails_helper.rb に記述する。ここで、controller_class に対して、has_privileges? を呼んでいる。

# @param [Controller] controller_class コントローラクラス
# @param [Array] test_array 検証するデータセット
def privilege_test(controller_class, *array)
  it "should have correct privileges" do
    array.each_slice(3) do |(action, permit_privileges, reject_privileges)|
      permit_privileges.each do |p|
        expect(controller_class.has_privileges?(action, { p => true })).to be_truthy
      end
      reject_privileges.each do |p|
         expect(controller_class.has_privileges?(action, { p => true })).to be_falsey
       end
    end
  end
end

controller の has_privileges? クラスメソッド実装

has_privileges? はほぼすべてのコントローラで使用するので、親クラスである app/controllers/application_controler.rb で実装する。継承しているので、self::Privileges は各子クラスで設定したものが読み込まれるので問題ない。中身は user に書いていたものと同様であるが、controller に記述した hash をわざわざ外部から読み取る必要がなくなった分すっきりしている。

  def self.has_privileges?(action, session)
    privileges = self::Privileges[action]
    session.values_at(*privileges).compact.first
  end

これで、上記のテストは通過した。

権限処理の切り替え

コントローラ側での権限管理が作成できたので、現在のユーザ側での権限管理から切替を行う。一つ一つ探していくのは大変なので、モデル側の実装を削除してしまい、問題が起こるテストを一つ一つ潰していけばよい。

まず、app/models/concerns/privilges.rb を削除する。

rm app/models/concerns/privilege.rb

app/models/admin.rb と app/models/users.rb にて include していたので、それらを削除する。

spec/models/user_spec.rb から has_privileges? 関係のテストを削除する。以下のように set_privileges だけのテストになり、見やすくなった。

  context 'Each user of users' do
    context 'checking the privileges' do
      answers = {
        hkob: [ nil, true ],
        skyoumu: [ true, true ],
        m11001: [ nil, nil ],
      }
      UserFactoryHash.keys.each do |user_key|
        it "#{user_key} should have correct privileges" do
          user = user_factory(user_key)
          session = {}
          user.set_privileges(session)
          expect(session.values_at(*User::Privileges)).to eq answers[user_key]
        end
      end
    end
  end

肝心の privilege_check は現在、このようになっている。この時点で怪しいと思うべきだった。

  # @note ページの権限を確認して、権限がない場合にはリダイレクト
  # @return [Boolean] 権限がある場合には true を返す
  def privilege_check
    set_campus_id_to_session
    if ua = current_user_or_admin
      privileges = self.class::Privileges[self.action_name.to_sym]
      redirect_to_root_path_with_message(ua.has_privileges?(each_session, privileges))
    else
      redirect_to new_user_session_path, alert: 'ログインしないとこのページは表>示できません'
    end
  end

改訂後は以下のように簡単になった。

  # @note ページの権限を確認して、権限がない場合にはリダイレクト
  # @return [Boolean] 権限がある場合には true を返す
  def privilege_check
    set_campus_id_to_session
    if ua = current_user_or_admin
      has_pr = self.class.has_privileges?(self.action_name, each_session)
      redirect_to_root_path_with_message(has_pr)
    else                                
      redirect_to new_user_session_path, alert: 'ログインしないとこのページは表示できません'                                              
    end                                                    
  end 

次に、メニュー記述関係の権限を修正する。最初に write_menu のテストを修正する。ユーザがなくなりすっきりした。

  it 'should obtain a table line' do
    ans = write_menu({ 'is_kyoumu?' => true }, teacher_kyoumus_path, 'abc')
    expect(ans).to eq '<tr><td><a data-method="get" href="/teacher/kyoumus">[教>務室トップページ]</a></td><td>abc</td><td>教務室</td></tr>'
    ans = write_menu({}, teacher_kyoumus_path, 'abc')
    expect(ans).to eq '<tr><td>教務室トップページ</td><td>abc</td><td>教務室</td></tr>'                               
  end 

この実装は以下の様になり、user または admin がなくなった。また、has_privileges_from_path という権限だけをpath から取得するメソッドを別に作った。この部分は別の場所でも使うためである。そういえば、method を渡すのも忘れていたので追記した。

  # @param [Hash] es user_session または admin_session
  # @param [String] path URL
  # @param [String] detail 説明文字列
  # @param [String] method GET 以外の場合は記述
  # @return [String] 表示するメニュー行
  def write_menu(es, path, detail, method = nil)
    hp = has_privileges_from_path(es, path)
    content_tag :tr do
      concat content_tag(:td, link_to_if(hp, lh(title_from_path(path), hp), path, method: method))
      concat content_tag(:td, detail)           
      concat content_tag(:td, privileges_str(privileges))
    end  
  end

この has_privileges_from_path のためにテストを記述する。

  it 'should obtain has_privileges from path' do
    kyoumu = { 'is_kyoumu?' => true }
    expect(has_privileges_from_path(kyoumu, teacher_kyoumus_path)).to be_truthy
    gaku = { 'is_gaku?' => true }
    expect(has_privileges_from_path(gaku, teacher_kyoumus_path)).to be_nil
  end

このテストのための実装は以下の様になる。

  # @param [Hash] es user_session または admin_session
  # @param [String] path URL
  # @return [Boolean] 権限があるかどうか
  def has_privileges_from_path(es, path)
    path_hash = Rails.application.routes.recognize_path(path)
    ca, a = path_hash.values_at(:controller, :action)
    klass = "#{ca}_controller".classify.constantize
    klass.has_privileges?(a.to_sym, es)
  end

最後に受入テストでエラーが出たので、該当行を修正する。

      = write_menu es, teacher_curriculums_path, 'カリキュラムを設定します'

guard ですべてのテストでエラーがなくなったので、リファクタリング終了。あらゆるところで、user が必要なくなるので、テストや view の描画が簡略化すると思われる。

かなり大きい変更なので今日はここまで。

written by iHatenaSync