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

### TL;DR
* Web applicationを書いてると，たいてい業務ロジック実装のための分岐処理でコードが汚くなり，また色々な場所に同様な処理のコピペが発生する
* 権限管理用ライブラリである[Pundit](https://github.com/varvet/pundit)を使って業務ロジックにおける分岐処理を1箇所にまとめるときれいに整理できるケースがある
* 複雑なUser Roleベースの権限管理をするときは[cancancan](https://github.com/CanCanCommunity/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`も閲覧できる

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

## まず普通に実装してみる

```rb: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_user`と`todo.user_id`を比較する箇所をprivateメソッドにまとめれば多少はマシだが，「リソースの取得」と「リソースに対するアクセス可否」をチェックする処理が一つのメソッドに入っていることが直感的に分かりづらい．さらに，同じようなメソッドが全てのResourceのControllerに対して繰り返しコピペされてボイラープレートと化していく．

```rb: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を使って実装してみる

```rb: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`にある．

```rb: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)`](https://github.com/elabs/pundit/blob/80f3e7564c7e1299fa0859afaf8874d6d5afed06/lib/pundit.rb#L112)を実行した際に，`Model名 + Policy`の規則でPolicyClassが特定された上で[インスタンス化され](https://github.com/elabs/pundit/blob/80f3e7564c7e1299fa0859afaf8874d6d5afed06/lib/pundit.rb#L117)，該当するpolicyのmethodが呼ばれてそのアクションが実行可能かどうかを判定する．
ちなみに`TodoPolicy`のconstructorの第1引数にはデフォルトで[`current_user`メソッドの結果が使用される](https://github.com/elabs/pundit/blob/80f3e7564c7e1299fa0859afaf8874d6d5afed06/lib/pundit.rb#L139)．

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

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


# Punditのメリット

## 権限管理の業務ロジックが１箇所に集約される
そもそも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を実装できる．

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

また，PolicyClass自体は[Pure Rubyで実装される](https://github.com/elabs/pundit/blob/80f3e7564c7e1299fa0859afaf8874d6d5afed06/README.md#just-plain-old-ruby)ので，Rubyで書ける処理は何でも書けるという自由度の高さが担保されている．そのため，PolicyClassはとても柔軟に実装でき，複雑な要件であっても対応しやすい．

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

Punditによる権限チェックを行う場合はControllerの各Actionで`authorize`を実行する必要があるが，仮に[実行し忘れた場合にErrorをraiseしてくれるafter_action](https://github.com/elabs/pundit/blob/80f3e7564c7e1299fa0859afaf8874d6d5afed06/README.md#ensuring-policies-are-used)を定義してくれる．実行し忘れると`AuthorizationNotPerformedError`がraiseされるようになっている．

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


# 最後に

railsで権限管理というと[cancancan](https://github.com/CanCanCommunity/cancancan)や[rolify](https://github.com/RolifyCommunity/rolify)といったroleベースの権限管理ライブラリが有名であるが，もっとシンプルに，モデルに近い所に権限管理の実装を集中させたい場合が意外と多いのではないか？と経験上感じたので，そういうケースに対応するための一例としてPunditを紹介させていただいた．

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