TL;DR
- Web applicationを書いてると,たいてい業務ロジック実装のための分岐処理でコードが汚くなり,また色々な場所に同様な処理のコピペが発生する
- 権限管理用ライブラリであるPunditを使って業務ロジックにおける分岐処理を1箇所にまとめるときれいに整理できるケースがある
- 複雑なUser Roleベースの権限管理をするときはcancancanなどを使うべきで,目的に応じた使い分けが大事
書いていないこと
- 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ユーザー
を作ってしまうとこの傾向は同様の理由でさらに加速する.
-
current_user
がAdmin Userだったら,A Resourceの所有者じゃなくてもA ResourceへのCRUD全てが可能になる -
current_user
がAdmin Userだったら,B Resourceの参照だけは可能になるが,それ以外は本人じゃなきゃダメ -
current_user
がAdmin Userでも,C Resourceだけは個人情報のカタマリだから閲覧も許可しない
この問題でどのように困るのか
- 超複雑な条件分岐の実装,同一の業務ロジックが広い範囲にコピペされるなどの理由により,コードの可読性が低下してメンテコストが増大する.長~~くメンテを続けていく自社サービスを運営する組織にとっては,サービスの寿命が長くなるに連れコストが増加し,最終的に負の遺産と化す.
- 業務ロジックが分散することで,ちょっとした修正漏れが命取りのバグになりかねない状況が発生する.例えば,if文の分岐条件を間違えて,見えてはいけないResourceが見れてしまうとか,逆に自分のResourceなのに参照できないとか更新できない等のクリティカルなバグを生み出しやすい状況になっていく.
この問題にどう立ち向かうか
- この問題を乱暴に集約すると「ログインユーザーと操作するResourceに関する業務ロジックの実装が分散している」ことが根本的な原因だと考えられる.
- つまり,本来は共通化されるべきログインユーザーに関する業務ロジックを,各ユースケースごとにベタで実装せざるを得ない状況になっていることが原因である,と定義できる.
Punditを使うとどうなるか
Controllerの実装で比較してみる.
例えばよくあるTodoアプリケーションにおいて,
- ログインしているユーザー以外の
Todo
を参照しようとしたらエラーにする - ただしログインしているユーザーが
AdminUser
だったらどのTodo
も閲覧できる
というビジネスロジックを実装したいとする.
まず普通に実装してみる
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_user
とtodo.user_id
を比較する箇所をprivateメソッドにまとめれば多少はマシだが,「リソースの取得」と「リソースに対するアクセス可否」をチェックする処理が一つのメソッドに入っていることが直感的に分かりづらい.さらに,同じようなメソッドが全てのResourceのControllerに対して繰り返しコピペされてボイラープレートと化していく.
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を使って実装してみる
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
にある.
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でレスポンスしたければNotAuthorizedError
をrescue_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を実装できる.
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で権限管理というとcancancanやrolifyといったroleベースの権限管理ライブラリが有名であるが,もっとシンプルに,モデルに近い所に権限管理の実装を集中させたい場合が意外と多いのではないか?と経験上感じたので,そういうケースに対応するための一例としてPunditを紹介させていただいた.
権限管理は一見すると地味なレイヤーだが,アプリケーションの規模が大きくなればなるほど真っ先にコードの汚れの影響を受ける部分であり,かついざ問題が発生した時には深刻なセキュリティインシデントの原因になる重要なレイヤーである.だからこそシンプルに,分かり易く実装していく必要があると筆者は考える.この文章を読んで,自分が開発しているWebApplicationの権限管理の実装方法について考えるきっかけとなれば幸いである.