5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

[Rails] Administrateに学ぶ安全なCRUDの作り方

Last updated at Posted at 2023-03-18

Administrateは、Rails(Ruby)の管理画面ライブラリの有名どころの一つです。
このAdministrateのControllerには安全なCRUDのノウハウが詰まっており、学ぶところが大変多いです。

Administrateを利用しないとしても、安全なCRUDを作るにはこの辺の知識が一式必要になるため、教材として丁度いいかなと思い、今回はこちらを紐解いてみようかと思います。

AdministrateのControllerを覗いてみる

最新版のソースはこのようになっています。よければGitHubの方で覗いてみてください。

Blameを見るのもオススメです。ライブラリ開発者の苦労が伺えますね。

抜粋

今回はControllerのお話のため、Viewに関する部分や汎用的ではないAdministrateの記述などを取り除いて抜粋すると以下のような感じになります。
(動くコードではないので参考までにしてください)
(Administrate単体では検索機能が弱いため、Administrate Ransackも併用する前提で追記を行っています)

module Administrate
  class ApplicationController < ActionController::Base
    protect_from_forgery with: :exception

    def index
      authorize_resource(resource_class)
      resources = filter_resources(scoped_resource)
      resources = apply_collection_includes(resources)
      resources = order.apply(resources)
      resources = paginate_resources(resources)
    end

    def show
      requested_resource
    end

    def new
      resource = new_resource
      authorize_resource(resource)
      resource
    end

    def edit
      requested_resource
    end

    def create
      resource = new_resource(resource_params)
      authorize_resource(resource)

      if resource.save
        redirect_to(
          after_resource_created_path(resource),
          notice: translate_with_resource("create.success"),
        )
      else
        render :new, locals: {
        }, status: :unprocessable_entity
      end
    end

    def update
      if requested_resource.update(resource_params)
        redirect_to(
          after_resource_updated_path(requested_resource),
          notice: translate_with_resource("update.success"),
        )
      else
        render :edit, locals: {
        }, status: :unprocessable_entity
      end
    end

    def destroy
      if requested_resource.destroy
        flash[:notice] = translate_with_resource("destroy.success")
      else
        flash[:error] = requested_resource.errors.full_messages.join("<br/>")
      end
      redirect_to after_resource_destroyed_path(requested_resource)
    end

    private

    def after_resource_destroyed_path(_requested_resource)
      { action: :index }
    end

    def after_resource_created_path(requested_resource)
      [namespace, requested_resource]
    end

    def after_resource_updated_path(requested_resource)
      [namespace, requested_resource]
    end

    def records_per_page
      params[:per_page] || 20
    end

    def order
      @order ||= Administrate::Order.new(
        sorting_attribute,
        sorting_direction,
        association_attribute: order_by_field(
          dashboard_attribute(sorting_attribute),
        ),
      )
    end

    def order_by_field(dashboard)
      return unless dashboard.try(:options)

      dashboard.options.fetch(:order, nil)
    end

    def dashboard_attribute(attribute)
      dashboard.attribute_types[attribute.to_sym] if attribute
    end

    def sorting_attribute
      sorting_params.fetch(:order) { default_sorting_attribute }
    end

    def default_sorting_attribute
      nil
    end

    def sorting_direction
      sorting_params.fetch(:direction) { default_sorting_direction }
    end

    def default_sorting_direction
      nil
    end

    def sorting_params
      Hash.try_convert(request.query_parameters[resource_name]) || {}
    end

    def requested_resource
      @requested_resource ||= find_resource(params[:id]).tap do |resource|
        authorize_resource(resource)
      end
    end

    def find_resource(param)
      scoped_resource.find(param)
    end

    def scoped_resource
      resource_class.default_scoped
    end

    # https://github.com/blocknotes/administrate_ransack/blob/83d2980c2c447d7c589a25cd7744c915a6431465/lib/administrate_ransack/searchable.rb#L7-L10
    def filter_resources(_scoped_resource)
      @ransack_results = _scoped_resource.ransack(params[:q])
      @ransack_results.result(distinct: true)
    end

    def apply_collection_includes(relation)
      resource_includes = dashboard.collection_includes
      return relation if resource_includes.empty?
      relation.includes(*resource_includes)
    end

    def resource_params
      params.require(resource_class.model_name.param_key).
        permit(dashboard.permitted_attributes).
        transform_values { |v| read_param_value(v) }
    end

    def read_param_value(data)
      data
    end

    delegate :resource_class, :resource_name, :namespace,
      to: :resource_resolver
    helper_method :namespace
    helper_method :resource_name
    helper_method :resource_class

    def translate_with_resource(key)
      t(
        "administrate.controller.#{key}",
        resource: resource_resolver.resource_title,
      )
    end

    def authorized_action?(_resource, _action_name)
      true
    end
    helper_method :authorized_action?

    def new_resource(params = {})
      resource_class.new(params)
    end
    helper_method :new_resource

    def authorize_resource(resource)
      if authorized_action?(resource, action_name)
        resource
      else
        raise Administrate::NotAuthorizedError.new
      end
    end

    def paginate_resources(resources)
      resources.page(params[:_page]).per(records_per_page)
    end
  end
end

安全なCRUDの要件を整理

まずは、安全なCRUDとは何か、要件を整理していきましょう。
Administrateの実装に寄せた要件ではありますが、最低限必要なものはざっと以下のようになるかと思います。

※ページングやソートまで必要かどうかは作るものによるかと思いますが、大抵は必要になるのでしれっと入れていきましょう。

  • 一覧画面
    • 一覧が表示できる
      • ユーザーがアクセス不可能なリソースが表示されない
      • ユーザーがアクセス不可能な詳細画面や編集画面へのボタンが表示されない
    • 検索項目で絞り込める
      • 許可されていない項目では絞り込めない(ransackable_scopes/ransackable_attributesなど)
    • ページングができる(Kaminari)
    • ソートができる
    • N+1が発生しない(includes)
  • 詳細画面
    • 詳細が表示できる
      • ユーザーが当該リソースの表示権限を持たない場合、表示されない
        • 適切なHTTPステータス(403/404)と共にエラー画面を表示する
      • ユーザーが編集権限を持たない場合、編集画面へのボタンが表示されない
      • ユーザーが削除権限を持たない場合、削除ボタンが表示されない
  • 新規作成画面
    • 新規作成フォームが表示できる
      • ユーザーが新規作成権限を持たない場合、表示されない
        • 適切なHTTPステータス(403/404)と共にエラー画面を表示する
    • 新規作成ができる
      • ユーザーがアクセス不可能なリソースは作成できない
        • 適切なHTTPステータス(403/404)と共にエラー画面を表示する
      • 許可されていないカラムを編集できない(StrongParameter)
      • CSRFが検証される(protect_from_forgery)
    • バリデーションされる
      • バリデーションに失敗した場合は新規作成フォームに戻り、
        • エラー内容が表示される
        • 入力内容が全て保持される
        • ネストしたリソースまで含めDBには何も保存されない
    • フォーム送信後にリダイレクトが適切に動作し、
      • ブラウザの更新ボタンで二重送信されたりしない
  • 編集画面
    • 編集フォームが表示できる
      • ユーザーが当該リソースの編集権限を持たない場合、表示されない
        • 適切なHTTPステータス(403/404)と共にエラー画面を表示する
    • 編集ができる
      • ユーザーがアクセス不可能なリソースは編集できない
        • 適切なHTTPステータス(403/404)と共にエラー画面を表示する
      • 許可されていないカラムを編集できない(StrongParameter)
      • CSRFが検証される(protect_from_forgery)
    • バリデーションされる
      • バリデーションに失敗した場合は編集フォームに戻り、
        • エラー内容が表示される
        • 入力内容が全て保持される
        • ネストしたリソースまで含めDBには何も保存されない
    • フォーム送信後にリダイレクトが適切に動作し、
      • ブラウザの更新ボタンで二重送信されたりしない
  • 削除
    • 削除できる
      • ユーザーがアクセス不可能なリソースは削除できない
        • 適切なHTTPステータス(403/404)と共にエラー画面を表示する
    • 削除後に削除した詳細画面に遷移してエラーになったりしないよう、遷移先を制御する
  • その他
    • リソース名やカラム名などの日本語化
    • エラーの日本語化
    • Flashメッセージの日本語化
    • action系以外のメソッドはprivate(意外と大事)
    • action系のメソッドにはなるべく手を入れず、全ControllerのCRUD全てで統一的な動作が保証されるようにする(努力目標)

実装時に意外に見落としがち

いかがでしょうか?
意外に見落としている項目が多いのではないでしょうか?

特に、「ユーザーがアクセス不可能なリソースを操作できない」系が見落とされていて、URLを直打ちされると見れないはずの画面が見れてしまう、編集できてしまうなどが起こりがちかと思います。
また、StrongParameterも必要なものだけに絞り込まれていなかったり、意識していないとN+1も起こりがちかと思います。

Administrateに学ぶ

それではいよいよ、AdministrateのControllerに学んでいきましょう。
メソッドひとつずつ、どういう用途かどのような使い方か等を見てみようと思います。

認証

認証はAdministrateの管轄外のため省略しますが、Administrate外で認証されたユーザーが以下のように全Controllerで利用できるようになっていることを前提とします。

module Manager
  class ApplicationController < Administrate::ApplicationController
    before_action :authenticate_manager!

    private

    helper_method :current_user
    def current_user
      @current_user ||= authenticated_manager
    end
  end
end

認可系

認可(ユーザーが行える操作を制限する・許可する)は以下のような方法で行われています。
全アクションでこれらを確実に通るようにすることが安全なCRUDへの第一歩です。

    private

    def new_resource(params = {})
      resource_class.new(params)
    end

    def scoped_resource
      resource_class.default_scoped
    end

    def authorized_action?(_resource, _action_name)
      true
    end
    helper_method :authorized_action?

    def authorize_resource(resource)
      if authorized_action?(resource, action_name)
        resource
      else
        raise Administrate::NotAuthorizedError.new
      end
    end

scoped_resourceは個別のControllerで適宜Overrideして使用します。
また、new_resourceも必要に応じてOverrideして使用します。

module Manager
  class BookmarkController < Manager::ApplicationController
    private

    def new_resource(params = {})
      current_user.bookmarks.build(params)
    end

    def scoped_resource
      current_user.bookmarks
    end
  end
end

module Manager
  class CategoryController < Manager::ApplicationController
    private

    def scoped_resource
      Category.default_scope
    end
  end
end

authorized_action?はApplicationControllerの方に用意しておき、全てのリソースに対応しておくと便利です。

module Manager
  class ApplicationController < Administrate::ApplicationController
    private

    def authorized_action?(_resource, _action_name)
      case _resource
      when Bookmark
        true
      when Category
        if current_user.privileged?
          # 特権があればすべての操作を許可
          true
        else
          # 特権がなければ書き込み系を許可しない(読み込み系のみ許可する)
          [:index, :show].include?(_action_name)
        end
      else
        false
      end
    end
  end
end

Pundit

AdministrateではPunditを簡単に使用できるようになっているため、Pundit版のソースも参考にしてみてください。

何が起こるのか

上の例のように適宜Overrideしておくことで、各actionで適切に認可されるようになります。

もう一度AdministrateのControllerを見てみましょう。
認可している部分のみ取り出すと以下のようになっています。

module Administrate
  class ApplicationController < ActionController::Base
    def index
      authorize_resource(resource_class)
      resources = scoped_resource
      ...
    end

    def show
      requested_resource
    end

    def new
      resource = new_resource
      authorize_resource(resource)
    end

    def edit
      requested_resource
    end

    def create
      resource = new_resource(resource_params)
      authorize_resource(resource)
      ...
    end

    def update
      if requested_resource.update(resource_params)
      ...
    end

    def destroy
      if requested_resource.destroy
      ...
    end

    private

    def requested_resource
      @requested_resource ||= find_resource(params[:id]).tap do |resource|
        authorize_resource(resource)
      end
    end

    def find_resource(param)
      scoped_resource.find(param)
    end

    def new_resource(params = {})
      resource_class.new(params)
    end

    def authorize_resource(resource)
      if authorized_action?(resource, action_name)
        resource
      else
        raise Administrate::NotAuthorizedError.new
      end
    end

ひとつずつ見ていきましょう。

requested_resource

requested_resourceはURLの末尾が/:id系のactionで使用されます。(:show,:edit,:update,:destroy
これらのactionではリソースを1件特定して各操作を行いますが、適切に認可が行われていないと許可のないユーザーによって編集や削除が行われてしまいます。
場合によっては事故に繋がりますので、確実にrequested_resourceを通すようにしておきたいものです。

先程のBookmarkの実装を例にしてrequested_resourceを展開すると以下のようになっていることが分かります。

requested_resource = current_user.bookmarks.find(params[:id])

このようになっていることで、例えばこのユーザーが所有しているBookmarkが[1, 5, 10]だった場合に、params[:id]=2などの他ユーザー所有のBookmarkにアクセスされても例外が発生してアクセス不可となります。
params[:id]はURLで渡される値のため、URLを書き換えるだけでいくらでも不正な試行が可能です。このようないたずらの被害に遭わないためにも、Bookmark.find(params[:id])などとはしないようにしましょう。絶対に。

findであることもポイントです。findだと該当のIDが見つからない場合に「例外」が発生します。
find_by_idで例外を回避する実装も見受けられますが、RailsのControllerの場合は素直に例外を出すのがベターかなと思います。個人的には。

new_resource

new_resourceは新規作成系actionで使用します。(:new, :create
新規作成に認可は不要かと思われがちですが、当然必要ですのできちんと入れていきましょう。

先程のBookmarkの実装ではこのようになっていました。

    def new_resource(params = {})
      current_user.bookmarks.build(params)
    end

このようにしておくことで、Bookmarkの新規レコードには最初からcurrent_userがセットされた状態となります。
それの何が良いかというと、params[:user_id]current_userのIDを渡したり、acionの中の実装でbookmark.user = current_userなどと追記しなくてよくなります。

特に、params[:user_id]にIDを渡す実装をよく見かけますが、絶対にしてはいけません。
URLと同様にHTMLフォームの送信内容も容易に偽装できますので、フォームからユーザーのIDを渡す実装になっていると他人のIDで新規リソースが作成できてしまい、事故に繋がります。

認可に関わる値は外部からの入力値を使用せず、自明な値をバックエンドで確実にセットするようにしましょう。

authorize_resource

authorize_resourceは全てのactionで必ず通します。
authorized_action?で定義されたルールに適合しないアクセスで確実に例外を出すことができます。

helper_method :authorized_action?

authorized_action?をViewHelperにしておくことで、Viewから同メソッドを利用できるようになります。
AdministrateではViewHelper化したauthorized_action?を全てのボタン(各actionへの遷移用・実行用)の出力条件に使用しており、アクセス権限のないactionへのボタンは絶対に表示されないようになっています。
ボタンの表示非表示の判断とactionでの権限チェックが同じメソッドになっていることで、ボタンが表示されていなければURL直アクセスもできないという状態が保証され、とても安全です。
これはなかなかいい手法ですね。

StrongParameter

許可されていないカラムを編集できないようにするため、StrongParameterを必ず通すようにします。
Administrateでの実装はこのようになっています。

    def resource_params
      params.require(resource_class.model_name.param_key).
        permit(dashboard.permitted_attributes).
        transform_values { |v| read_param_value(v) }
    end

AdministrateではDashboardの設定だけで新規/編集フォームの全項目のpermitted_attributesが自動的に収集されます。
※Dashboardで指定しているのはattribute名(brandtagsなど)だけで、_id_ids_attributes: []remove_などはFieldクラスで自動で付加してくれるため、漏れることもなく安心です。

>> dashboard.permitted_attributes
[
  :brand_id,            # Field::BelongsTo
  :title,               # Field::String
  :description,         # Field::Text
  :published_at,        # Field::DateTime
  tags_ids: [],         # Field::HasMany
  authors_attributes: [ # Field::NestedHasMany
    :name, :job, :_destroy
  ],
  :image_id, :remove_image_id, #Field::Refile
  :status               # Field::Enumerize
]

Administrateを使用しなくても、StrongParameterを漏れなく設定することで編集されたくない項目の変更を防ぐことができます。
たとえ、フォームから不正にparams[:user_id]=2などが送られてきてもStrongParameterが:user_idを取り除いてくれるため、ユーザーが不正に更新されることがなくなり安全です。
それでいて、.build(params)とシンプルな記述量を維持できるのでスッキリしてて良いですね。

また、ネストしたモデルまで一度に生成して一緒にバリデーションすることができるため、actionのコードを書き足すことなく全てのCRUD画面で統一的な編集機能を提供できます。

Flashメッセージ

古典的な機能なので好き嫌いがあるとは思いますが、更新系各処理の実行後にはFlashメッセージ(notice:,flash)を日本語で出力します。
エラーがある場合もFlashメッセージに全文載せてあげるのがシンプルで扱いやすかったりします。(エラーの表示は見た目も重要なので一ヶ所で全文表示はあまりないと思いますが)

リダイレクト

更新系の処理の後は内部リダイレクトします。
リダイレクトした後の画面はリロードしてもフォームが再送信されないため、二重送信問題の簡易的な対策になります。

Ransack(絞り込み・検索)

    # https://github.com/blocknotes/administrate_ransack/blob/83d2980c2c447d7c589a25cd7744c915a6431465/lib/administrate_ransack/searchable.rb#L7-L10
    def filter_resources(_scoped_resource)
      @ransack_results = _scoped_resource.ransack(params[:q])
      @ransack_results.result(distinct: true)
    end

検索をRansackに任せることで、.ransack(params[:q])と書くだけで検索機能の実装が完了します。
あとはRansackのドキュメントを見て好きな項目での検索フォームを作成するだけです。

ただこれだと、全カラムを自由に検索できてしまうので、安心できないですね。
できればこうしたいです。

      @ransack_results = _scoped_resource.ransack(params[:q], auth_object: set_ransack_auth_object)

こうなっていればModelの方でransackable_attributesなどで調整可能になります。

ページング

Kaminariにお任せ

ソート

Administrateのようにメソッド分割しておけば、各Controllerでこんな感じにできます。


module Manager
  class BookmarkController < Manager::ApplicationController
    private

    def default_sorting_attribute
      :created_at
    end

    def default_sorting_direction
      :desc
    end
  end
end

これでこのControllerでは「ソート項目無指定の場合は最新順(作成日時降順)」という設定にできます。

N+1問題

これは相当意識していないと忘れがちなため、自動対応されているととても嬉しいです。
AdministrateではDashboardの処理の中で自動的にN+1になりそうなRelationをcollection_includesに取っておいてくれて、apply_collection_includesで勝手に入れてくれます。ありがたき。

    def apply_collection_includes(relation)
      resource_includes = dashboard.collection_includes
      return relation if resource_includes.empty?
      relation.includes(*resource_includes)
    end

CSRF対策

Railsの標準機能のため、確実に設定しておく。

    protect_from_forgery with: :exception

ついでにその他のセキュリティ設定

force_ssl

config/environments/production.rb
  # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
  config.force_ssl = true

本番ではforce_sslを設定しておくことで以下が強制的に有効になります。※詳細は省略します。

  • http->httpsへの強制リダイレクト
  • Strict-Transport-Security
  • Secure Cookies

まとめ

簡単にまとめると、CRUDの開発で最低限常に意識すべき項目は以下になります。

  • 認証
  • 認可
  • StrongParameter
  • バリデーション
    • ※Modelで適切に設定し、Controllerではsave() update()のみの記述の状態を保つ
  • Flashメッセージ(エラー表示、書き込み系処理の結果表示)
  • リダイレクト・二重送信防止
  • 絞り込み・検索
  • ページング
  • ソート
  • パフォーマンス
    • N+1問題
  • セキュリティ
    • CSRF
    • SSL
    • Strict-Transport-Security
    • Secure Cookie

また、Overrideを想定していないメソッドにはこれ以上なるべく手を入れずにそのまま使うことをオススメします。
特にaction系のメソッドはAdministrateの記述量で必要十分なため、この状態以上に何も書かなくても大抵のことは実現可能なはずです。Railsは優秀なので。
※jsonのレスポンスに対応していないので、htmlでの使用限定です。jsonレスポンスにも対応してほしいな。

おわり

いかがでしたでしょうか?
ざっとですが、CRUDの知識一式をおさらいしてみました。

Administrateに学ぶことはありましたでしょうか?
見落としていた項目や開発のヒントなど、何か見つけて頂けていれば幸いです。

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?