[Rails] Deviseを使ってadminユーザーのみがユーザーを登録/編集できるようにする

業務システムなど、ユーザー管理を管理者のみ行えるようにしたい場面が度々あると思います。

私の場合はRailsで業務システムを開発しており、上記機能を作成する必要がありました。
Railsにおいて認証システムを開発する際は、Deviseというgemを使用するのが一般的かと思いますので、Deviseを改造して上記のシステム構築を行いました。

なお、今回は以下のgemを導入している想定で話を進めます。

```Gemfile
gem 'devise'
gem 'cancancan'
gem 'rolify'
```

これらのgemの導入方法については、他にもたくさんの素晴らしい記事が公開されています。
今回の主題ではありませんのでスキップします。

また、認証で扱うモデル名は`User`とします。

## 手を入れるソース

Deviseのユーザー登録/編集処理は[registrations_controller.rb](https://github.com/plataformatec/devise/blob/master/app/controllers/devise/registrations_controller.rb)で定義されています。

このControllerをオーバーライドしてカスタマイズしていきます。
まずは、オーバーライドするためのファイルを作成します。

```
$ rails g devise:controllers users
Running via Spring preloader in process 94640
      create  app/controllers/users/confirmations_controller.rb
      create  app/controllers/users/passwords_controller.rb
      create  app/controllers/users/registrations_controller.rb
      create  app/controllers/users/sessions_controller.rb
      create  app/controllers/users/unlocks_controller.rb
      create  app/controllers/users/omniauth_callbacks_controller.rb
```

今回編集するのは`app/controllers/users/registrations_controller.rb`です。
中身を見てみると、以下のようになっています。

```registrations_controller.rb
# frozen_string_literal: true

class Users::RegistrationsController < Devise::RegistrationsController
  # before_action :configure_sign_up_params, only: [:create]
  # before_action :configure_account_update_params, only: [:update]

  # GET /resource/sign_up
  # def new
  #   super
  # end

  # POST /resource
  # def create
  #   super
  # end

  # GET /resource/edit
  # def edit
  #   super
  # end

  # PUT /resource
  # def update
  #   super
  # end

  # DELETE /resource
  # def destroy
  #   super
  # end

  # GET /resource/cancel
  # Forces the session data which is usually expired after sign
  # in to be expired now. This is useful if the user wants to
  # cancel oauth signing in/up in the middle of the process,
  # removing all OAuth session data.
  # def cancel
  #   super
  # end

  # protected

  # If you have extra params to permit, append them to the sanitizer.
  # def configure_sign_up_params
  #   devise_parameter_sanitizer.permit(:sign_up, keys: [:attribute])
  # end

  # If you have extra params to permit, append them to the sanitizer.
  # def configure_account_update_params
  #   devise_parameter_sanitizer.permit(:account_update, keys: [:attribute])
  # end

  # The path used after sign up.
  # def after_sign_up_path_for(resource)
  #   super(resource)
  # end

  # The path used after sign up for inactive accounts.
  # def after_inactive_sign_up_path_for(resource)
  #   super(resource)
  # end
end
```

## 登録

まずは、サインイン済みでもユーザー登録ができるようにします。

`Devise::RegistrationsController`では、以下が定義されているため、ユーザーがサインインしている状態で`:new`や`:create`を行うことができません。

```
prepend_before_action :require_no_authentication, only: [:new, :create, :cancel]
```

そこで、オーバーライドして、サインインしていも`:new`と`:create`にアクセスできるようにしてやります。

```.diff
--- a/app/controllers/users/registrations_controller.rb
+++ b/app/controllers/users/registrations_controller.rb
@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 class Users::RegistrationsController < Devise::RegistrationsController
+  prepend_before_action :require_no_authentication, only: [:cancel]
   # before_action :configure_sign_up_params, only: [:create]
   # before_action :configure_account_update_params, only: [:update]
```

次に、ユーザー登録後の自動サインインを回避します。
[Devise::ReigistrationsController](https://github.com/plataformatec/devise/blob/master/app/controllers/devise/registrations_controller.rb)では`sign_up`という関数の中で`sign_in`という関数を呼んでいるだけなので、`sign_up`をオーバーライドします。

```.diff
--- a/app/controllers/users/registrations_controller.rb
+++ b/app/controllers/users/registrations_controller.rb
@@ -38,7 +38,7 @@ class Users::RegistrationsController < Devise::RegistrationsController
-  # protected
+  protected
@@ -72,6 +72,10 @@ class Users::RegistrationsController < Devise::RegistrationsController
     devise_parameter_sanitizer.permit(:account_update, keys: [:name])
   end
 
+  def current_user_is_admin?
+    user_signed_in? && current_user.has_role?(:admin)
+  end
+
   # The path used after sign up.
   # def after_sign_up_path_for(resource)
   #   super(resource)
@@ -83,6 +87,8 @@ class Users::RegistrationsController < Devise::RegistrationsController
   # end
 
   def sign_up(resource_name, resource)
-    sign_in(resource_name, resource)
+    if !current_user_is_admin?
+      sign_in(resource_name, resource)
+    end
   end
 end
```

このままだとどのユーザーでも登録できるようになっているので、権限チェックを行います。

```.diff
--- a/app/controllers/users/registrations_controller.rb
+++ b/app/controllers/users/registrations_controller.rb
@@ -3,6 +3,6 @@ class Users::RegistrationsController < Devise::RegistrationsController
   prepend_before_action :set_minimum_password_length, only: [:new, :edit]
+  before_action :creatable?, only: [:new, :create]
 
   # GET /resource/sign_up
   # def new
@@ -129,7 +130,14 @@ class Users::RegistrationsController < Devise::RegistrationsController
     resource.update_without_password(params)
   end
 
+  def creatable?
+    raise CanCan::AccessDenied unless user_signed_in?
+
+    if !current_user_is_admin?
+      raise CanCan::AccessDenied
+    end
+  end
+
```

## 編集

編集の場合は登録より複雑です。
まず、routeを定義するところからです。

```.diff
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -157,7 +157,10 @@ Rails.application.routes.draw do
   devise_for :users, :controllers => {
     :registrations => 'users/registrations'
   }
-
+  devise_scope :user do
+    get 'users/:id/edit' => 'users/registrations#edit', as: :edit_other_user_registration
+    match 'users/:id', to: 'users/registrations#update', via: [:patch, :put], as: :other_user_registration
+  end
   resources :users, only: [:show, :index]
 
   resources :csv_import, only: :index
```

続いて、`edit`メソッドを変更します。
[Devise::ReigistrationsController](https://github.com/plataformatec/devise/blob/master/app/controllers/devise/registrations_controller.rb)では以下の定義がされています。

```
prepend_before_action :authenticate_scope!, only: [:edit, :destroy]
```

[:authenticate_scope!](https://www.rubydoc.info/github/plataformatec/devise/Devise/RegistrationsController#authenticate_scope!-instance_method)は`self.resource`にcurrent_userをぶち込んでしまいますので、`:edit`と`:update`は対象外にしておきます。

また、update関数を[Devise::ReigistrationsController](https://github.com/plataformatec/devise/blob/master/app/controllers/devise/registrations_controller.rb)からコピーした後、adminユーザーの場合は現在のパスワードを入力せずとも編集できるようにコードを変更します。

```.diff
--- a/app/controllers/users/registrations_controller.rb
+++ b/app/controllers/users/registrations_controller.rb
 class Users::RegistrationsController < Devise::RegistrationsController
   prepend_before_action :require_no_authentication, only: [:cancel]
+  prepend_before_action :authenticate_scope!, only: [:update, :destroy]
@@ -19,17 +19,30 @@ class Users::RegistrationsController < Devise::RegistrationsController
 
   # GET /resource/edit
   def edit
-    render :edit
+    if by_admin_user?(params)
+      self.resource = resource_class.to_adapter.get!(params[:id])
+    else
+      authenticate_scope!
+      super
+    end
   end
 
   # PUT /resource
-  # We need to use a copy of the resource because we don't want to change
-  # the current user in place.
   def update
-    self.resource = resource_class.to_adapter.get!(send(:"current_#{resource_name}").to_key)
+    if by_admin_user?(params)
+      self.resource = resource_class.to_adapter.get!(params[:id])
+    else
+      self.resource = resource_class.to_adapter.get!(send(:"current_#{resource_name}").to_key)
+    end
+
     prev_unconfirmed_email = resource.unconfirmed_email if resource.respond_to?(:unconfirmed_email)
 
-    resource_updated = update_resource(resource, account_update_params)
+    if by_admin_user?(params)
+      resource_updated = update_resource_without_password(resource, account_update_params)
+    else
+      resource_updated = update_resource(resource, account_update_params)
+    end
+
     yield resource if block_given?
     if resource_updated
       if is_flashing_format?
@@ -72,6 +85,10 @@ class Users::RegistrationsController < Devise::RegistrationsController
     devise_parameter_sanitizer.permit(:account_update, keys: [:name])
   end
 
+  def by_admin_user?(params)
+    params[:id].present? && current_user_is_admin?
+  end
+
   def current_user_is_admin?
     defined?(current_user) && current_user.present? && current_user.has_role?(:admin)
   end
@@ -91,4 +108,8 @@ class Users::RegistrationsController < Devise::RegistrationsController
       sign_in(resource_name, resource)
     end
   end
+  
+  def update_resource_without_password(resource, params)
+    resource.update_without_password(params)
+  end
 end
```

登録時と同じように権限チェックを行います。

```.diff
--- a/app/controllers/users/registrations_controller.rb
+++ b/app/controllers/users/registrations_controller.rb
@@ -3,6 +3,6 @@ class Users::RegistrationsController < Devise::RegistrationsController
   prepend_before_action :set_minimum_password_length, only: [:new, :edit]
+  before_action :editable?, only: [:edit, :update]
@@ -129,7 +130,14 @@ class Users::RegistrationsController < Devise::RegistrationsController
     resource.update_without_password(params)
   end
 
-  def writable_check
+  def editable?
+    raise CanCan::AccessDenied unless user_signed_in?
+
+    if !current_user_is_admin?
+      raise CanCan::AccessDenied
+    end
+  end
+
```

管理者が他のユーザーのパスワードを変更できるようにします。
`registrations_controller.rb`で利用するようにした`resource.update_without_password`はデフォルトではパスワードの更新ができないようになっています。

```lib/devise/models/database_authenticatable.rb
def update_without_password(params, *options)
  params.delete(:password)
  params.delete(:password_confirmation)

  result = update_attributes(params, *options)
  clean_up_passwords
  result
end
```

これをUserモデルでオーバーライドします。
`update_with_password`関数を参考に実装しますが、パスワードチェックは行わないようにします。
ただし、この関数が管理者以外から呼び出されることがあってはいけません。

今回のように呼び出し元で管理者権限を持っているかチェックするようにしましょう。

```.diff
--- a/app/models/user.rb
+++ b/app/models/user.rb
+  # deviseのupdate_without_passwordは本来パスワードは変更できない
+  # 管理者しかこの関数を呼ばない前提で、パスワードがなくてもパスワードを変更できるようにオーバーラ
イドする
+  # update_with_password関数を参考にする
+  def update_without_password(params, *options)
+    # current_password = params.delete(:current_password)
+
+    if params[:password].blank?
+      params.delete(:password)
+      params.delete(:password_confirmation) if params[:password_confirmation].blank?
+    end
+
+    # result = if valid_password?(current_password)
+    #   update_attributes(params, *options)
+    # else
+    #   self.assign_attributes(params, *options)
+    #   self.valid?
+    #   self.errors.add(:current_password, current_password.blank? ? :blank : :invalid)
+    #   false
+    # end
+
+    update_attributes(params, *options)
+
+    clean_up_passwords
+    result
+  end
```

## 成果物

Viewについてなど、いろいろと説明を省いてしまいましたが、Gistに[最終的なソース](https://gist.github.com/h-sakano/3b5bd6d8c07416295b057e6be4bdc36e)をアップロードしました。
是非御覧ください。