Ruby
Rails4
権限管理

Punditから考えるDRYな権限管理の設計方針

More than 1 year has passed since last update.

TL;DR

  • Web application書いてると大体業務ロジック実装のための分岐処理でコードが汚くなるし色々な場所に業務が分散する
  • Punditを使って業務ロジックにおける分岐処理を1箇所にまとめると良いケースがある
  • 複雑なUser Roleベースの権限管理をするときはcancanなどを使うべきで、目的に応じた使い分けが大事

書いていないこと

  • Punditの詳しい使い方(コードベースが非常にシンプルでdocも充実しているので自分で読んだ方が早い)
  • 他の権限管理ライブラリの使い方(筆者より優秀なエンジニアが書いた記事が沢山あるのでググった方がいい)

前置き

Web applicationがある程度大きくなった時に生じる2大問題

ビジネスロジックの条件分岐でコードが汚れる問題

以下の様なビジネスロジックを実装するためにControllerやModelのメソッドがif文やswitch文だらけになってコードが汚れる。

  • A Resourceの所有者がログインユーザーと同じだったらそのA Resourceインスタンスはログインユーザーが編集できる
  • A Resourceを作ることができるUserはB Resourceを更新することもできる
  • A ResourceとB Resourceはログインしていなければ閲覧できないが、C Resourceは誰でも閲覧可能

if current_user.admin?問題

どのResourceに対しても何でもできるが、中途半端な全知全能の神adminユーザーを作るとこの傾向は同様の理由でさらに加速する

  • Admin UserだったらA Resourceの所有者じゃなくてもA ResourceへのCRUD全てが可能になる
  • Admin UserだったらB Resourceの参照だけは可能になるが、それ以外は本人じゃなきゃダメ
  • Admin UserでもC Resourceだけは個人情報のカタマリだから閲覧も許可しない

この問題でどのように困るのか

  • 超複雑な条件分岐、同一の業務ロジックがものすごく広い範囲に分散するなどの理由により、コードの可読性が低下してメンテコストが増大する。長~~くメンテを続けていく自社サービスを運営する組織にとっては寿命が長くなるに連れてコストが増加していく文字通りの負の遺産と化す。
  • 業務ロジックが分散することで、ちょっとした修正漏れが命取りのバグになりかねない状況が発生する。例えばif文の分岐条件を間違えて、他人のResourceが見れてしまうとか、逆に自分のResourceなのに参照できないとか更新できない等のクリティカルなバグを生み出しやすい状況になっていく。

この問題にどう立ち向かうか

  • この問題を乱暴に集約すると「ログインユーザーと操作するResourceに関する業務ロジックの実装が分散している」ことが根本的な原因だと考えられる。
  • つまり、本来は共通化されるべきログインユーザーに関する業務ロジックを、各ユースケースごとにベタで実装せざるを得ない状況になっていることが原因。

Punditを使うとどうなるか

Controllerの実装で比較してみる。
例えばよくあるTodoアプリケーションにおいて、

  • ログインしているユーザー以外のTodoを参照しようとしたらエラーにする
  • ただしログインしているユーザーがAdminUserだったらどのTodoも閲覧できる

というビジネスロジックを実装したいとする。

まず普通に実装してみる

app/controllers/todo_controller.rb
class TodoController < ApplicationController
  before_action :login_needed #login check

  def show
    @todo = Todo.find(params[:id])
    raise Forbidden if !current_user.admin? && @todo.user_id != current_user.id
  end
end

todoの取得と、current_usertodo.user_idを比較する箇所をprivateメソッドにまとめれば多少はマシだが、「リソースの取得」と「リソースに対するアクセス可否」をチェックする処理が一つのメソッドに入っていることが直感的に分かりづらい。さらに、同じようなメソッドが全てのResourceのControllerに対して繰り返し実装されてボイラープレートと化す。。

app/controllers/todo_controller.rb
class TodoController < ApplicationController
  before_action :login_needed #login check

  def show
    @todo = find_todo
  end

  ...

  private 

  def find_todo
    todo = Todo.find(params[:id])
    raise Forbidden if !current_user.admin? && todo.user_id != current_user.id
    todo
  end
end

Punditを使って実装してみる

app/controllers/todo_controller.rb
class TodoController < ApplicationController
  before_action :login_needed #login check

  def show
    @todo = Todo.find(params[:id])
    authorize @todo
  end
end

見た目はすっきりしたが、代わりにauthorize @todoという新しい行が追加されている。
authorize @todoの行で条件を満たさない場合、NotAuthorizedErrorというエラーがraiseされ、条件を満たした場合は何もせずに素通りできる、という挙動になる。

ところで、raise Forbidden if !current_user.admin? && todo.user_id != current_user.idの辺の処理はどこへ行ってしまったのか?
真相は上述のコードを削除した代わりに実装したPolicyClassにある。

app/policy/todo_policy.rb
class TodoPolicy
  attr_reader :user, :todo

  def initialize(user, todo)
    @user = user
    @todo = todo
  end

  def show?
    user.admin? or @todo.user_id == user.id
  end
end

Controller側でauthorize(record,query=nil)を実行した際に、Model名 + Policyの規則でPolicyClassが特定された上でインスタンス化され、該当するpolicyのmethodが呼ばれてそのアクションが実行可能かどうかを判定する。
ちなみにTodoPolicyのconstructorの第1引数にはデフォルトでcurrent_userメソッドの結果が使用される

どのmethodで判定するかは第二引数で指定できるが、指定しなければ呼ばれたcontrollerのaction名 + ?のmethodが指定される。つまり今ログインしているユーザーを渡す形となる。

判定した結果falseが返された場合はNotAuthorizedErrorがraiseされるので、403でレスポンスしたければNotAuthorizedErrorrescue_fromで拾って403を返したりすればいい、ということになる。

Punditのメリット

権限管理の業務ロジックが1箇所に集約される

そもそもsessionにControllerからしかアクセスできないという理由で、ログインユーザーが絡む権限管理はどうしてもControllerかControllerのconcernに実装されがちだが、WebApplicationにおける権限管理の本来の関心事はControllerではなくModelである。「/todos/:idにアクセスできるのはログインしてる本人かadminだけね!」みたいな会話をしているとついControllerに実装したくなってしまうが、結局のところ最終的に注目するのはモデルのインスタンスである。であれば権限管理の実装もモデルに近い所の方が責務が明確になり、Controllerのコードがボイラープレートなコードで汚れるのを防ぐことができると筆者は考えている。

Userではなくリソースに集中した権限チェックの実装ができる

cancancanなどの有名な権限管理gemはUserRoleに対して権限を管理する設計だった(間違ってたらごめんなさい)。これはUserが複数のRoleを持ち、Roleごとに各リソースに対して実行できるactionを定義したい場合には分かりやすく便利な設計である。

しかし、Userの種類はせいぜいNormalUserとAdminUserの二種類程度で、かつリソースに対するActionの権限管理はリソースの種類ごとに全く異なるような場合はリソースに注目した方が設計として分かり易くなるケースがある(これはリソースの種類や実装するアプリケーションによる)。

例えばUserが特定の組織に所属していて、かつアクセスしたいリソースがその組織の持ち物であることが条件である場合、Userのroleは関係しない条件のみで構成されるが、Punditであればリソースに着目して下記のようにpolicyを実装できる。

app/policy/todo_policy.rb
class TodoPolicy
  def show_organization_todo?
    todo.organization.belongs_to?(user)
  end
end

また、PolicyClass自体はPure Rubyで実装されるので、Rubyで書ける処理は何でも書けるという自由度の高さが担保されている。そのため、PolicyClassはとても柔軟に実装でき、複雑な要件であっても対応しやすい。

authorizeし忘れるとraiseして教えてくれる

Punditによる権限チェックを行う場合はControllerの各Actionでauthorizeを実行する必要があるが、仮に実行し忘れた場合にErrorをraiseしてくれるafter_actionを定義してくれる。実行し忘れるとAuthorizationNotPerformedErrorがraiseされるようになっている。

単なるafter_action callbackとして定義されるのでcontroller単位、action単位でskipすることは容易だが、実装ミスによる権限チェック漏れを極力減らしてくれる。権限周りのチェックをcontrollerのbefore/after callbackとして実装しているとcallbackのかけ忘れでインシデントにつながったりすることがよくあるが、そういったミスを無くす上ではとても有効な機構である。

最後に

railsで権限管理というとcancancanrolifyといったroleベースの権限管理ライブラリが有名であるが、もっとシンプルに、モデルに近い所に権限管理の実装を集中させたい場合が意外と多いのではないか?と経験上感じたので、そういうケースに対応するための一例としてPunditを紹介させていただいた。

権限管理は一見すると地味なレイヤーだが、アプリケーションの規模が大きくなればなるほど真っ先にコードの汚れの影響を受ける部分であり、かついざ問題が発生した時には深刻なセキュリティインシデントの原因になる重要なレイヤーである。だからこそシンプルに、分かり易く実装していく必要があると筆者は考える。この文章を読んで、自分が開発しているWebApplicationの権限管理の実装方法について考えるきっかけとなれば幸いである。