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名(brand
やtags
など)だけで、_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
# 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()
のみの記述の状態を保つ
- ※Modelで適切に設定し、Controllerでは
- Flashメッセージ(エラー表示、書き込み系処理の結果表示)
- リダイレクト・二重送信防止
- 絞り込み・検索
- ページング
- ソート
- パフォーマンス
- N+1問題
- セキュリティ
- CSRF
- SSL
- Strict-Transport-Security
- Secure Cookie
また、Overrideを想定していないメソッドにはこれ以上なるべく手を入れずにそのまま使うことをオススメします。
特にaction系のメソッドはAdministrateの記述量で必要十分なため、この状態以上に何も書かなくても大抵のことは実現可能なはずです。Railsは優秀なので。
※jsonのレスポンスに対応していないので、htmlでの使用限定です。jsonレスポンスにも対応してほしいな。
おわり
いかがでしたでしょうか?
ざっとですが、CRUDの知識一式をおさらいしてみました。
Administrateに学ぶことはありましたでしょうか?
見落としていた項目や開発のヒントなど、何か見つけて頂けていれば幸いです。