Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

Rails Authentication・Authorization パターン

こんにちは、 新しいポケモンを発売当初に買わず、完全に乗り遅れてしまった、 広告技術部のサンドバーグです。

この記事は Gunosy Advent Calendar 2022の13日目の記事です。昨日の記事は hamashita さんの『突撃隣の作業環境2022』でした。去年も同じ企画での記事がありましたが、毎年みんなの環境がレベルアップしている感じがあり、見応えがあります!

本記事は弊社でもよく作ることのあるRails管理画面のAuthentication/Authorizationのパターン紹介になります。結構シンプルな内容ではあるので、ご了承ください!

前提

  • 今回主な実装はDeviseでされている想定で書いています
  • そもそもユーザーロール管理が必要のないシステムであれば、この話はないです!

まず初めに

今回紹介するパターンは一部広告技術部で取り入れてはいますが、完全一致で採用しているわけではないので、個人的な見解なども含みます。それも踏まえて、弊社ではいくつか管理画面を作る上で、度々課題として上がってくるのがユーザの機能制限とコードの共通化の範囲です。

Authentication自体は基本DeviseやDoorkeeperといったユーザセッション管理、ユーザモデル生成のgemを使う限り、失敗することはないと思います。

github.com

github.com

Authorizationも複雑な処理は特に必要なく、gemを使う場合はpundit、使わない場合ではControllerなどでのbefore_actionでのロールチェックするのが普通かと。

github.com

ただ、管理画面においてAuthorizationを曖昧に設計してしまうと発生しがちなのが、ControllerとViewの複雑化です。

具体的な課題ケース

管理画面において、複数のロールが共通して使いたくなる機能が存在します。 今回はロールが三種類(admin/client/medium)あるとして、仮に「スレッド」を立てられると想定してControllerを書くと、以下のようになるかと思います。

class ThreadsController < ApplicationController
   before_action :deny_medium_user

  def create
    @thread = Thread.new(create_params)
    if @thread.save 
      redirect_to threads_path
    else  
      flash.now[:error] = @thread.errors.full_messages
      render :new, status: :unprocessable_entity
    end
  end 

  private
  
  def create_params
    strong_params = params.require(:thread).permit(:title, :content, :index)
    strong_params.except!(:index) if current_user.client?
  end
end

admin/clientはスレッドを立てられますが、mediumは使えないため、before_actionで操作をdenyします。 その上、スレッド作成時に管理者にしか設定ができないパラメータがある場合、Strong Parameterからは条件付きではじく必要があります。

上記の問題としては、それぞれのロールにおいてThreadが誰が作れる物であって、どこまでの設定で作れるのかが明確になっていないという物です。パラメータ・ロールの追加されるたび、コードがより複雑になるだけではなく、ちょっとした修正ミスにより、公開されるべきでない機能・画面が容易に出てしまう可能性があります。

言わずともViewにおいても、パラメータの違いを考慮してcurrent_userから表示・非表示の制御を追加する必要が出てくるため、html上での分岐が増えてしまいます。

解決に向けて

まず、弊社で行っているのがControllerとViewのロールごとでの完全分離です。 同じような画面・機能であっても、Routingをロールのnamespaceで切り分けていて、Controller/Viewもそれに合わせてディレクトリ/ファイルを完全に分けています。

├── controllers
│   ├── admin
│   │   └── dashboards_controller.rb
│   ├── application_controller.rb
│   ├── client
│   │   └── dashboards_controller.rb
│   ├── concerns
│   └── medium
│       └── dashboards_controller.rb
└── views
    ├── admin
    │   └── dashboards
    │       └── show.html.erb
    ├── client
    │   └── dashboards
    │       └── show.html.erb
    └── medium
        └── dashboards
            └── show.html.erb

ThreadsControllerの例で行くと、以下のような書き方に変わります。

module Admin
  class ThreadsController < ApplicationController
     before_action :only_admin

    def create
      @thread = Thread.new(create_params)
      if @thread.save 
        redirect_to threads_path
      else  
        flash.now[:error] = @thread.errors.full_messages
        render :new, status: :unprocessable_entity
      end
    end 

    private
  
    def create_params
      strong_params = params.require(:thread).permit(:title, :content, :index)
    end
  end 
end

素朴ですが、パラメータ管理の際に分岐を増やす必要もなくなる上、before_actionの権限保持者も明確になります。バグが含まれてしまった場合でも、特定のロールしか影響を受けないので、比較的安全です。

用意するファイル数は増えてしまいますが、各ファイルで書くコード量と複雑さは減らせるので、デメリットに対してはメリットが多いかと思います。

さらに

Adminしかアクセスを期待しないため、都度before_actionを指定せずとも、Application Controllerから仮にAdminControllerを用意すれば、毎度before_actionを設定しなくとも、一つのControllerに権限管理を任せることができます。

ただ、権限管理をしている単位がそもそもnamespaceで分かれてくるため、自然と行きつく先がroutes.rb自体でのAuthorizationとAuthenticationをまとめた管理だと思います。

github.com

上記のレポジトリーに動くコードを用意しましたが、肝になるところは以下のroutes.rbのコードになります。

Rails.application.routes.draw do
  devise_for :users

  authenticated :user, ->(u) { u.admin? } do
    root 'admin/dashboards#show', as: :admin_root

    namespace :admin do
      resource :dashboard, only: :show
    end
  end

  authenticated :user, ->(u) { u.client? } do
    root 'client/dashboards#show', as: :client_root

    namespace :client do
      resource :dashboard, only: :show
    end
  end

  authenticated :user, ->(u) { u.medium? } do
    root 'medium/dashboards#show', as: :medium_root

    namespace :medium do
      resource :dashboard, only: :show
    end
  end

  devise_scope :user do
    root 'devise/sessions#new', as: :unauthenticated_root
  end
end

Deviseの認証で使うauthenticatedメソッドは、認証用のmodelを渡すだけではなく、lambdaでそのモデルオブジェクトに対してもメソッド実行ができるので、そのまま対象のロールかどうかを判定できます。

devise/routes.rb at 6d32d2447cc0f3739d9732246b5a5bde98d9e032 · heartcombo/devise · GitHub

routes.rbで管理することで、Controller/Viewを用意した時点でnamespaceによりロールのアクセス権限も制限されるため、Controllerでの権限考慮は一切考える必要がなくなります。 moduleも、明確にロールが定義されているため、見えずらい・わかりづらいも特にないかとは思います。

最後に

Deviseの機能もそうですが、概念的にも普通に昔からある物だとは思うので、今更な話かもしれません。ただ、改めてシステムの初期設計の段階でここの整理をおろそかにすることで後々大変な目に合うこともあると思うので、参考になればと思います。

本記事は、Deviseを用いたRailsでのAuthenticationとAuthorizationの一パターンのまとめになります。Railsでの管理画面開発自体かなりニッチではあると思うので、どれだけの人がこれで悩んでいるかわからないですが、これを見てふと考えるきっかけになれば幸いです。

次回は johnmanjiro さんの 『Go でサクッと GitHub CLI の拡張機能を作る』です!