Rails + ロールでの認可制御を調べたところ、以下のページに認可制御のgemがまとまっていました。
Category: User Authorization - The Ruby Toolbox
まずは一番Githubのstarが多いgemからみてみようということで、 Pundit
をさわってみることにしました。
varvet/pundit: Minimal authorization through OO design and pure Ruby classes
ただ、PunditのREADMEを読んでみたものの、ロールを使った認可制御は記載されていませんでした。
そのため、ロール機能だけを扱えるgemがないかを上記ページで見たところ、 Rolify
がありました。
RolifyCommunity/rolify: Role management library with resource scoping
RolifyのREADMEを読むと
This library can be easily integrated with any authentication gem (devise, Authlogic, Clearance) and authorization gem* (CanCanCan, authority, Pundit)
https://github.com/rolifycommunity/rolify
とあり、Punditと組み合わせて使えそうでした。
そこで、Pundit + Rolify を使って、RailsのAPIアプリでロールによる認可制御を行ってみた時のメモを残します。
なお、記事が長いので、あらかじめソースコードのありかを記載しておきます。
https://github.com/thinkAmi-sandbox/rails_with_pundit-sample
目次
環境
なお、動作確認にはWSL2上のターミナルで curl
を使って行います。
Windowsにも curl
はありますが、 "
や日本語の扱いに難があります。
アプリ(密書管理システム) を作ってPundit +Rolify を理解する
PunditやRolifyの使い方については、色々と記事があり参考になります。
また、企業ブログの中でも登場したりします。
ただ、上記資料を読んだだけでは理解しきれませんでした。
そこで、具体的な認可制御のイメージをうかべながら理解するために、ストーリーに基づき Pundit + Rolify アプリを作り進めていくことにしました。
今回は「edo時代のbo藩が密書を管理するシステムを作る」というストーリーでアプリを作っていきます。
edo時代のbo藩では藩内システムをRailsで内製していました。
そんな中、他藩で密書がもれるという事件が起こりました。この事件を重くみたbakufuは全藩に「密書を適切に管理していない藩は取り潰す」とのおふれを出しました。
そのおふれを受け取ったbo藩が、密書管理システムの構築を検討し始めるというところから物語は始まります。
やらないこと
認証機能をしっかり作ること
今回の目的は認可制御なので、認証機能についてはまともに作らないことにしました。
つまり
- 認証機能にDeviseは使わない
- 今回はAPIアプリでは、Basic認証にする
- Authorizationヘッダにユーザーとパスワードを入れる
- そのユーザーとパスワードの組み合わせで認証する
- ユーザーとパスワードは
users
テーブルに生データとして入れる
- データベースがクラックされたらパスワードが流出する
という超簡易的なものにしました。
本番運用するには完全に不適切ですが、今回は認可機能に集中するため、あえてこのような構成にしています。
ストーリー
bakufuのおふれを受けて、bo藩の家老たちが会議室に集まりました。
bo藩では、密書管理システムをRailsでアプリを作ることにしました。今まで内製してきたRailsの資産を活かすためです。
また、密書なのでリッチな画面は必要とせず、APIエンドポイントを用意してデータを取り出せれば良いと考えました。
以上により、システムの概要が決まったため、まずは密書データを読み書きできる機能から作り始めることとしました。
アプリの作成
セットアップ
APIエンドポイントを用意するだけなので、今回はAPIモードでrails newします。
# APIモードで rails new する
$ bundle exec rails new . --api --skip-bundle
$ bundle install
実装
POSTとGETを追加します。
モデル SecretMessage
(密書) を作ります。
$ bin/rails g model SecretMessage title:string description:text
invoke active_record
create db/migrate/20221001100112_create_secret_messages.rb
create app/models/secret_message.rb
invoke test_unit
create test/models/secret_message_test.rb
create test/fixtures/secret_messages.yml
マイグレーションを実行します。
$ bin/rails db:migrate
== 20221001100112 CreateSecretMessages: migrating =============================
-- create_table(:secret_messages)
-> 0.0012s
== 20221001100112 CreateSecretMessages: migrated (0.0013s) ====================
コントローラを作ります。
今のところ、密書の登録と一覧表示ができればよいので、 index
と create
のみを用意します。
class Api::SecretMessagesController < ApplicationController
def index
render json: SecretMessage.all
end
def create
SecretMessage.create(create_params)
end
private def create_params
params.permit(:title, :description)
end
ルーティングを追加します。
Rails.application.routes.draw do
namespace :api do
resources :secret_messages, only: [:index, :create]
end
end
動作確認
WSL2上でRailsアプリを起動します。
次に、WSL2上のターミナルにて、curlで密書を作成します。
$ curl -v -X POST -H "Content-Type: application/json; charset=UTF-8" -d '{"title":"テスト","description":"テストの密書です"}' http://127.0.0.1:3000/api/secret_messages
続いて、作成したデータを取得します。
$ curl http://127.0.0.1:3000/api/secret_messages
[{"id":1,"title":"テスト","description":"テストの密書です","created_at":"2022-10-01T10:52:26.415Z","updated_at":"2022-10-01T10:52:26.415Z"}]
意図した通りに動作しました。第一歩としては成功です。
ここまでのコミット
https://github.com/thinkAmi-sandbox/rails_with_pundit-sample/pull/1/commits/55693150dce3f709878ea0a16ada50983f8d1f16
Basic認証を使い、認証されたユーザーのみデータを取得可能にする
ストーリー
密書の作成・閲覧機能ができたので、次は認証機能を組み込むことにしました。
edo時代では「Basic認証ができれば十分である。パスワードも生で保存すればよい」という雰囲気だったため、認証機能としてRailsのBasic認証を使うことにしました。
※現実では、今回のようなシステムの認証にBasic認証を採用してはいけません
※現実では、パスワードを生で保存してはいけません
アプリの作成
認証用のUserモデルを作成する
ユーザー名とパスワードの情報を保存するためのUserモデルを作成します。
$ bin/rails g model User name:string password:text
invoke active_record
create db/migrate/20221001110542_create_users.rb
create app/models/user.rb
invoke test_unit
create test/models/user_test.rb
create test/fixtures/users.yml
生成されたマイグレーションファイルを適用します。
$ bin/rails db:migrate
== 20221001110542 CreateUsers: migrating ======================================
-- create_table(:users)
-> 0.0010s
== 20221001110542 CreateUsers: migrated (0.0011s) =============================
Basic認証用の authenticate_or_request_with_http_basic をコントローラに組み込む
Railsには、Basic認証を行うためのメソッド authenticate_or_request_with_http_basic
があります。
密書を管理するAPIエンドポイントにはすべてBasic認証を組み込みたいため、各コントローラの親コントローラを用意します。
また、今回はAPIモードでrails newしたため、 ActionController::HttpAuthentication::Basic::ControllerMethods
をincludeしています。
class BasicAuthController < ApplicationController
include ActionController::HttpAuthentication::Basic::ControllerMethods
before_action :basic_auth
def basic_auth
authenticate_or_request_with_http_basic do |username, password|
User.exists?(name: username, password: password)
end
end
end
APIエンドポイントのコントローラでは、親となるクラスを差し替えます。
class Api::SecretMessagesController < BasicAuthController
end
動作確認用にUserモデルにデータを登録します。
Basic認証情報がない場合、エラーになります。
$ curl http://127.0.0.1:3000/api/secret_messages
HTTP Basic: Access denied.
一方、Basic認証情報を付与してあげると、データの取得ができます。
$ curl -u karo:pass http://127.0.0.1:3000/api/secret_messages
[{"id":1,"title":"テスト","description":"テストの密書です","created_at":"2022-10-01T10:52:26.415Z","updated_at":"2022-10-01T10:52:26.415Z"}]
ここまでのコミット
https://github.com/thinkAmi-sandbox/rails_with_pundit-sample/pull/1/commits/217ee2136724d0c0c2b4070fea8e77697b210682
Punditを使って、自分の作成した密書のみ更新可能にする
ストーリー
ここまでで、密書の作成と読み取りはできるようになりました。
しかし、今のままでは一度作成した密書は変更できなくて不便です。そこで変更機能を追加することにしました。
ただ、家老たちからは「自分が作った密書を他人に書き換えられると困る」という話が出ました。
そのため、「作成者本人のみ密書を更新できる」という認可制御を追加することにしました。
続いて、認可制御をどのように実現するかを話し合った結果、 Pundit
gemを使うことにしました。
varvet/pundit: Minimal authorization through OO design and pure Ruby classes
また、今後モデルがちょっと複雑になるかもしれないと考え、Annotate
gemでモデルの属性をコメント化するようにしました。
ctran/annotate_models: Annotate Rails classes with schema and routes info
アプリの作成
PunditとAnnotateをセットアップ
Gemfileに追加します。
gem 'pundit'
group :development do
gem 'annotate'
end
bundle installと、各gemのinstallを行います。
$ bundle install
$ bin/rails g pundit:install
create app/policies/application_policy.rb
$ bin/rails g annotate:install
create lib/tasks/auto_annotate_models.rake
SecretMessageモデルに、作成者として user の別名で owner を追加
密書に対する 作成者本人
を特定するため、SecretMessageモデルに owner
を追加します。
ownerは
- 外部キーとして、Userの
id
を参照
- NOT NULL制約を追加
とします。
NOT NULL制約を追加した場合、密書を作成した user を削除したくなったら困るかもしれません。ただ、今のところbo藩では削除は想定していないため、NOT NULL制約とします。
ここで、前回の動作確認で作成した密書にはowner情報がないため、NOT NULL制約は付与できません。
そのため、本来であれば
- nullableでownerを追加
- 移行用のrake taskを作成し、既存の密書にownerの値をセット
- ownerをNOT NULL にする
という手順が必要そうです。
ただ、まだ登録済の密書が少ないため、今回は全データを削除してマイグレーションを適用することにしました。
まず、SecretMessageモデルの全データを削除した後、「NOT NULL制約なownerを追加する」マイグレーションを作成します。
$ bin/rails g migration AddOwnerToSecretMessage
invoke active_record
create db/migrate/20221001234046_add_owner_to_secret_message.rb
ここで、Railsのデフォルトでは、外部キー名が参照先のモデル名 (今回の場合 user) になります。
ただ、 user
という単語だと作成者かどうかが分かりづらいため、外部キー名を別名の owner
にしたいです。
そこで、以下を参考に、外部キーに別名を設定するマイグレーションにします。
Railsで別名の外部キーを設定する方法 - Qiita
class AddOwnerToSecretMessage < ActiveRecord::Migration[7.0]
def change
add_reference :secret_messages, :owner, foreign_key: { to_table: :users }
end
end
マイグレーションファイルができたので、適用します。
$ bin/rails db:migrate
== 20221001234046 AddOwnerToSecretMessage: migrating ==========================
-- add_reference(:secret_messages, :owner, {:foreign_key=>{:to_table=>:users}})
-> 0.0085s
== 20221001234046 AddOwnerToSecretMessage: migrated (0.0086s) =================
外部キーが作成されたので、次はモデルでの関連付けを行います。
今のところ、 SecretMessage → User の方向でしか参照を行わないため、SecretMessageモデルにのみ関連付けを追加します。
また、 class_name
オプションを使い、 owner
という関連を使った場合は User
モデルを見るよう設定します。
4.1.2.3 :counter_cache | Active Record の関連付け - Railsガイド
class SecretMessage < ApplicationRecord
belongs_to :owner, class_name: 'User', foreign_key: :owner_id
end
ownerにデータをセット
SecretMessageモデルに owner
を追加できたので、次は owner
に値を設定できるようにします。
owner
の値は、各HTTPリクエストのBasic認証で渡される username
を元に、Userモデルから取得します。
そこで、Basic認証を行っている処理に機能を追加し、 current_user
という attr_reader
でUserモデルの情報を保持します。
class BasicAuthController < ApplicationController
attr_reader :current_user
def basic_auth
authenticate_or_request_with_http_basic do |username, password|
@current_user = User.find_by(name: username, password: password)
return @current_user.present?
end
end
end
次に、 create
メソッドにて、 current_user
から取り出したUserをセットするようにします。
class Api::SecretMessagesController < BasicAuthController
def index
render json: SecretMessage.all
end
def create
SecretMessage.create(create_params)
end
private def create_params
params.permit(:title, :description).merge(owner: current_user)
end
end
ownerに値が設定できるようになったため、curlで動作確認します。
まずは密書を作成します。
curl -X POST -H "Content-Type: application/json; charset=UTF-8" -u karo:pass http://127.0.0.1:3000/api/secret_messages -d '{"title":"テスト","description":"密書"}'
次に、作成した密書を読み込みます。 owner
に値が設定されていることが確認できました。
$ curl -u karo:pass http://127.0.0.1:3000/api/secret_messages
[{"id":1,"title":"テスト","description":"密書","created_at":"2022-10-02T01:22:09.742Z","updated_at":"2022-10-02T01:22:09.742Z","owner_id":1}]
Punditを使い、密書の更新はownerのみ可能にする
Punditを使った認可は、 Policy
を元に制御されます。
そこで、PunditのREADMEや以下の記事を参考に、PunditのPolicyを作っていきます。
Policyを作成
rails g pundit:install
時に app/policies/application_policy.rb
として ApplicationPolicy
が生成されています。このPolicyでは各アクションの認可は不可となっています。
そこで、 ApplicationPolicy
を継承して、今回のPolicyを作成します。
まずは policies/api/secret_message_policy.rb
ファイルを作成します。
このPolicyが動く時点では認証制御は終わっているため、認可制御だけ記載します。
今回は
- indexとcreateは、認証されていれば誰でもOK
- updateは、対象密書の
owner
が、HTTPリクエストしてきたユーザーと一致していればOK
とします。
なお、PunditのREADMEには
In your controller, Pundit will call the current_user
method to retrieve what to send into this argument
とあります。
そのため、先ほど作成した current_user
メソッドの戻り値が Api::SecretMessagePolicy
インスタンスの user
へと設定されています。
class Api::SecretMessagePolicy < ApplicationPolicy
def index?
true
end
def create?
true
end
def update?
record.owner == user
end
end
認可制御もれを防ぐため、verify_authorizedを実装
作成したPolicyをコントローラで適用するには authorize
メソッドを使うことになります。
https://github.com/varvet/pundit#policies
ただ、うっかり authorize
を実行し忘れると大変なことになります。
そこで、作成したPolicyをコントローラで適用し忘れないような仕組みとして、Punditには verify_authorized
があります。
Pundit has a handy feature which reminds you in case you forget. Pundit tracks whether you have called authorize anywhere in your controller action. Pundit also adds a method to your controllers called verify_authorized. This method will raise an exception if authorize has not yet been called. You should run this method in an after_action hook to ensure that you haven't forgotten to authorize the action.
https://github.com/varvet/pundit#ensuring-policies-and-scopes-are-used
ただ、
This verification mechanism only exists to aid you while developing your application, so you don't forget to call authorize. It is not some kind of failsafe mechanism or authorization mechanism. You should be able to remove these filters without affecting how your app works in any way.
とあるように開発時向けの機能であるため、今回はコントローラ本体に実装するのではなく、
app/controllers/concerns/pundit_authorizable.rb
というConcernで作成
verify_authorized
は includedとして、includeされた時に動作するように指定
- コントローラ本体ではConcernをinclude
とします。
module PunditAuthorizable
extend ActiveSupport::Concern
include Pundit::Authorization
included do
after_action :verify_authorized
end
end
Policy Namespacingを実装
今回のコントローラは app/controllers/api
ディレクトリの中に入れるため、Railsのデフォルトからは一階層深い位置にコントローラが置かれています。
ただ、Punditのデフォルトでは
app/controllers/
の下にコントローラがある
app/policies/
の下にPolicyがある
という前提のため、今回のような構造ではPolicyを発見できません。
そこで、Punditの Policy Namespacing
という機能を使います。
authorize([:admin, post])
のように authorize
メソッドの第一引数に配列を渡せば良いようです。
また、毎回配列を渡すのが手間なので、 authorize
メソッドを使う場合は常に api
ディレクトリの下を探すよう指定します。
これも認可本体の処理とは関係ないので、Concernに実装しておきます。
module PunditAuthorizable
extend ActiveSupport::Concern
include Pundit::Authorization
def authorize(record, query = nil)
super([:api, record], query)
end
end
BasicAuthControllerにPunditAuthorizable Concernを組み込む
PunditAuthorizable
はBasic認証をしている全コントローラで動作してほしいため、 BasicAuthController
に組み込みます。
なお、Basic認証後にConcernの before_action
が動作してほしいため、Concernをincludeするのは before_action :basic_auth
の後とします。
Rails: includeされた時にクラスメソッドとインスタンスメソッドを同時に追加する頻出パターン | TECHSCORE BLOG
class BasicAuthController < ApplicationController
attr_reader :current_user
include Pundit::Authorization
include ActionController::HttpAuthentication::Basic::ControllerMethods
before_action :basic_auth
include PunditAuthorizable
end
コントローラで authorize または skip_authorization メソッドを追加する
ここまでで update
メソッドを定義するための準備ができたため、最後にコントローラを実装します。
なお、Punditで認可制御するためには各メソッドで authorize
メソッドを呼びます。
また、「認証制御は必要だけど、認可制御は不要」なメソッド、今回の場合だと index
や create
に対して authorize
メソッドを定義しないと verify_authorized
に引っかかってしまいます。
回避するには
- 個々のコントローラメソッドで
skip_authorization
メソッドを呼ぶ
verify_authorized
の before_action
で、 except
や only
を定義する
のどちらかを実装します。
except
や only
だと範囲が広すぎる気がするので、今回は個々のコントローラメソッド ( index
や create
) で skip_authorization
を呼ぶこととします。
class Api::SecretMessagesController < BasicAuthController
def index
skip_authorization
render json: SecretMessage.all
end
def create
skip_authorization
SecretMessage.create(create_params)
render status: :created
end
def update
record = SecretMessage.find_by(id: params[:id])
authorize record
SecretMessage.update(update_params)
render status: :no_content
end
private def create_params
params.permit(:title, :description)
params.permit(:title, :description).merge(owner: current_user)
end
private def update_params
params.permit(:id, :title, :description)
end
end
Policyに基づく認可エラー時の例外送出に対処する
Punditでは、Policyに基づく認可エラーの場合、例外 Pundit::NotAuthorizedError
を送出します。
https://github.com/varvet/pundit#rescuing-a-denied-authorization-in-rails
この例外を捕捉しない場合、RailsはHTTP500エラーをHTTPリクエスト元に返します。
HTTP500エラーではなく403などで返したい場合、
- Railsの仕組み
rescue_from
を使って捕捉して対応する
- Punditの一括設定で対応する
な実装となります。
今のところ、Basic認証のときだけPunditを使っていることから、Basic認証用コントローラで rescue_from
して例外を捕捉し、403エラーへと変換します。
class BasicAuthController < ApplicationController
attr_reader :current_user
include Pundit::Authorization
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
private def user_not_authorized
render status: :forbidden
end
end
今回は owner のみ密書の更新ができることを確認します。
まずは、別のユーザーを追加します。
name |
password |
another_karo |
pass |
次に、ownerが更新できるかを確認します。
既存の密書は karo
ユーザーであれば更新できるため、試してみます。
$ curl -X PUT -H "Content-Type: application/json; charset=UTF-8" -u karo:pass http://127.0.0.1:3000/api/secret_messages/1 -d '{"title":"本人","description":"更新"}'
とすると更新できました。
続いて、ownerでない another_karo
ユーザーで確認します。
$ curl -X PUT -H "Content-Type: application/json; charset=UTF-8" -u another_karo:pass http://127.0.0.1:3000/api/secret_messages/1 -d '{"title":"他人","description":"どうなる"}'
とすると、403エラーになりました。
なお、indexメソッドについては認可制御をしていないため
$ curl -u another_karo:pass http://127.0.0.1:3000/api/secret_messages/
だとデータの取得ができます。
RSpecによるrequest specで動作確認する
そろそろ curl での動作確認がつらくなってきました。
そこで、RSpec + factory_bot_rails によるテストコードを書きます。
Punditでは policy に対するテストコード(Policy spec)を書くことができます。
Policy Specs | varvet/pundit: Minimal authorization through OO design and pure Ruby classes
ただ、今回はcurlでやっていることと同等のテストコードとしたいことから、request specでテストコードを書いていくことにします。
セットアップ
Gemfileに追加します。
なお、READMEより、Rails7系に正式対応している rspec-rails は6系になります。
rspec-rails 6.x for Rails 6.1 or 7.x.
https://github.com/rspec/rspec-rails
ただ、まだ rspec-rails の6系はRCであり、正式リリースがなされていません。
RSpec Rails 6.0 Release Plan · Issue #2560 · rspec/rspec-rails
そこで、rspec-railsはバージョンを固定してGemfileに記載します。
group :development, :test do
gem 'rspec-rails', '6.0.0.rc1'
gem 'factory_bot_rails'
end
記載後、 bundle install
します。
bundle install後、RSpecのセットアップを行います。
$ bin/rails generate rspec:install
create .rspec
create spec
create spec/spec_helper.rb
create spec/rails_helper.rb
今回、認証はBasic認証を使っています。
そのため、テストコードの中でもBasic認証用のAuthorizationヘッダを設定してあげる必要があります。
ただ、ヘッダの設定は手間がかかるため方法を探したところ、gistsに記載がありました。
fwilkens commented on 13 Sep 2018
Working with request specs with rspec-rails 3.x (3.7.2 in my case), you can pass the auth in the headers:
get '/path', headers: { 'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials(username, password) }
https://gist.github.com/mattconnolly/4158961?permalink_comment_id=2705188#gistcomment-2705188
今回はこのやり方でAuthorizationヘッダを設定します。
factory_botによるfactoryを作成
SecretMessage用とUser用のfactoryを作成します。
SecretMessage用
FactoryBot.define do
factory :secret_message do
title { 'タイトル' }
description { '説明' }
end
end
User用
FactoryBot.define do
factory :user, aliases: [:owner] do
name { 'foo' }
password { 'password' }
end
end
テストコードを書く
今まで作成した index/create/update の各機能のテストコードを書きます。
すべて載せると長いので、ここではupdate用のみ記載します。
require 'rails_helper'
RSpec.describe "Api::SecretMessages", type: :request do
let(:karo) { create(:user, name: 'karo', password: 'ps') }
let(:karo_message) { create(:secret_message, owner: karo) }
let(:others) { create(:user, name: 'others', password: 'ps') }
let(:others_message) { create(:secret_message, owner: others) }
let(:request_body) do
{
title: '更新タイトル',
description: '更新説明',
}
end
let(:header) { {'Content-Type' => 'application/json', 'Accept' => 'application/json'} }
let(:params) { request_body.to_json }
describe "PUT /api/select_messages" do
context '正しいAuthorizationヘッダあり' do
let(:valid_basic_auth) { ActionController::HttpAuthentication::Basic.encode_credentials(karo.name, karo.password) }
context '家老の密書' do
subject do
put api_secret_message_path(karo_message), params: params, headers: { HTTP_AUTHORIZATION: valid_basic_auth }.merge(header)
end
it '成功してDBが更新されている' do
subject
expect(response).to have_http_status(204)
expect(karo_message.reload).to have_attributes(
title: '更新タイトル',
description: '更新説明',
owner: karo
)
end
end
context '他人の密書' do
subject do
put api_secret_message_path(others_message), params: params, headers: { HTTP_AUTHORIZATION: valid_basic_auth }.merge(header)
end
it '失敗して、DBは更新されていない' do
subject
expect(response).to have_http_status(403)
expect(others_message.reload).to have_attributes(
title: 'タイトル',
description: '説明'
)
end
end
end
context '誤ったAuthorizationヘッダ' do
let(:invalid_basic_auth) { ActionController::HttpAuthentication::Basic.encode_credentials('foo', 'bar') }
it 'エラー' do
get api_secret_messages_path, headers: { HTTP_AUTHORIZATION: invalid_basic_auth }
expect(response).to have_http_status(401)
end
end
context 'Authorizationヘッダなし' do
it 'エラー' do
get api_secret_messages_path
expect(response).to have_http_status(401)
end
end
end
end
テストコードを流すと、すべてのテストがパスします。
ここまでのコミット
https://github.com/thinkAmi-sandbox/rails_with_pundit-sample/pull/1/commits/600c249e54aead9a75a4e20ed6edc6d099a8692e
Rolifyを使い、家老ロールのみ密書の登録・更新・閲覧を可能にする
ストーリー
ここまでの機能で密書管理システムは完成したように見えました。
そんな中、ある家老が子供に家督を譲って引退しました。
ただ、密書管理システムの設定はそのままだったため、引退した家老が昔の密書を勝手に閲覧したり更新してしまいました。
このことがbo藩で問題となったため、現役の家老たちはシステムを修正することにしました。
修正において、やりたいことは以下の通りです。
- ログインしたユーザーのうち、現役の家老のみ、密書の閲覧・作成・更新を可能にしたい
- ただし、更新については、今まで通り密書の作成者のみ可能にしたい
- 現役の家老は複数人で構成され、誰かに譲ることもできる
認可制御が複雑になってきたため、権限マトリックスを作成します。
認証 |
役職 |
|
閲覧 |
作成 |
更新 |
あり |
家老 |
|
o |
o |
作成した密書のみ |
あり |
なし |
|
x |
x |
x |
なし |
- |
|
x |
x |
x |
アプリの作成
家老という役職をロールとデータ構造のどちらで表現するか
やりたいことから考えると、 家老
という役職での認可制御は RBAC(Role-Based Access Control) のロールとして考えると良さそうです。
ロールを扱うためには、独自のデータベース構造を導入し、ユーザーに紐づければよさそうです。
ただ、この記事の冒頭でふれたように、 Rolify
gemがそのあたりをいい感じに扱ってくれそうです。
そこで、今回は Rolify にてロールによる認可制御を実装してみます。
Rolifyのセットアップ
RolifyのREADMEに従い、セットアップを行います。
RolifyCommunity/rolify: Role management library with resource scoping
Gemfileに追加し、 bundle install
します。
gem 'rolify'
Rolifyで管理する 家老
という役職は User モデルに紐づきます。
そこで、RolifyのジェネレータでUserモデルに対するRoleモデルを生成します。
$ bin/rails g rolify Role User
invoke active_record
create app/models/role.rb
invoke rspec
create spec/models/role_spec.rb
invoke factory_bot
create spec/factories/roles.rb
insert app/models/role.rb
create db/migrate/20221002124719_rolify_create_roles.rb
insert app/models/user.rb
create config/initializers/rolify.rb
===============================================================================
An initializer file has been created here: config/initializers/rolify.rb, you
can change rolify settings to match your needs.
Defaults values are commented out.
A Role class has been created in app/models (with the name you gave as
argument otherwise the default is role.rb), you can add your own business logic
inside.
Inside your User class (or the name you gave as argument otherwise the default
is user.rb), rolify method has been inserted to provide rolify methods.
マイグレーションファイルが自動生成されるので、適用します。
$ bin/rails db:migrate
== 20221002124719 RolifyCreateRoles: migrating ================================
-- create_table(:roles)
-> 0.0016s
-- create_table(:users_roles, {:id=>false})
-> 0.0011s
-- add_index(:roles, :name)
-> 0.0004s
-- add_index(:roles, [:name, :resource_type, :resource_id])
-> 0.0005s
-- add_index(:users_roles, [:user_id, :role_id])
-> 0.0005s
== 20221002124719 RolifyCreateRoles: migrated (0.0043s) =======================
Annotated (6): app/models/role.rb, spec/models/role_spec.rb, spec/factories/roles.rb, app/models/secret_message.rb, test/models/secret_message_test.rb, test/fixtures/secret_messages.yml
また、このマイグレーションを適用することにより、Userモデルに rolify
が追加されます。
class User < ApplicationRecord
rolify
end
Userにロールを割り当てる
今回はメンテナンス用の画面がないため、Rails Console を使って対象のユーザーへロールを割り当てます。
なお、RolifyのWikiによるとリソースに対するロールも割り当てることができそうです。
To define a role scoped to a resource instance
user = User.find(2)
user.add_role :moderator, Forum.first
https://github.com/RolifyCommunity/rolify/wiki/Usage
ただ、今回はリソースに対する制御は不要なため、リソースは指定せずに割り当てます。
>> karo = User.find_by(name: 'karo')
>> karo.add_role :chief_retainer
>> another = User.find_by(name: 'another_karo')
>> another.add_role :chief_retainer
ロールを見て認可制御するようPolicyを修正
今までは認証できていれば、閲覧や作成は可能でした。
ただ、今回からは 家老
ロールのみ可能とすることから、 以下を満たせるようPolicyを修正します。
- indexとcreateは家老ロールのみ許可
- updateは家老ロールかつ密書の作成者のみ許可
なお、都度 user.has_role? :chief_retainer
と書くのは手間なので、 chief_retainer?
というメソッドを生やして定義しています。
class Api::SecretMessagePolicy < ApplicationPolicy
def index?
chief_retainer?
end
def create?
chief_retainer?
end
def update?
chief_retainer? && record.owner == user
end
private def chief_retainer?
user.has_role? :chief_retainer
end
end
コントローラを修正
Policyの index?
と create?
に修正を加えたため、コントローラの authorize
メソッドにも修正を加えます。
index
や create
ではモデルのインスタンスは使用しません。そのため、PunditのREADMEの
If you don't have an instance for the first argument to authorize, then you can pass the class
https://github.com/varvet/pundit#policies
に従い、モデルクラス SecretMessage
を渡すよう修正します。
class Api::SecretMessagesController < BasicAuthController
def index
authorize SecretMessage
def create
authorize SecretMessage
end
テストコードの修正
家老ロールを生成するfactoryを追加
テストコードの中で毎回家老ロールを割り当てるのは手間なので、factoryにて生成する時点でロールを割り当てます。
以下のstackoverflowに従い、 after(:create)
にて家老ロールを割り当てます。
ruby on rails - Setting roles through rolify in FactoryBot definition - Stack Overflow
FactoryBot.define do
factory :user, aliases: [:owner] do
name { 'foo' }
password { 'password' }
factory :chief_retainer do
after(:create) {|user| user.add_role(:chief_retainer)}
end
end
end
追加したfactoryは、テストコードで以下のようにして使えます。
context '家老ロール' do
let(:karo) { create(:chief_retainer, name: 'karo', password: 'ps') }
it '成功' do
get api_secret_messages_path, headers: { HTTP_AUTHORIZATION: valid_basic_auth }
expect(response).to have_http_status(200)
end
end
あとは権限マトリックス通りにテストコードを書き、テストがパスすればOKです。
ここまでのコミット
https://github.com/thinkAmi-sandbox/rails_with_pundit-sample/pull/1/commits/ac365b75105d735965a2344ac08f30044fdf92dd
密書の作成者グループを追加し、作成者グループのメンバーのみ密書を更新可能にする
ストーリー
ここまでの修正にて、現役の家老たちのみ密書を取り扱えるようになりました。
そんな中、家老が過去の密書を見返していたところ、修正が必要そうな密書を見つけました。
しかし、その密書はすでに引退している家老だったため、修正することができません。
さすがにそれでは困るので、作成者本人でなくても修正できるよう、作成者グループという概念を導入・実装することにしました。
更新処理が行える権限について、「作成者」から「作成者グループ」へと変更します。
また、作成者グループは密書ごとに異なります。つまり、密書Aと密書Bの作成者グループに含まれる家老は異なります。
一方、それ以外の処理については今まで通りにします。
認証 |
役職 |
作成者グループ |
|
閲覧 |
作成 |
更新 |
あり |
家老 |
含まれる |
|
o |
o |
作成者グループの密書のみ |
あり |
家老 |
含まれない |
|
o |
o |
x |
あり |
なし |
- |
|
x |
x |
x |
なし |
- |
- |
|
x |
x |
x |
アプリの作成
作成者グループをロールとデータ構造のどちらで表現するか
今回の「作成者グループ」は、密書ごとに異なる必要があります。
そのため、ロールとして作成者グループを定義した場合、密書ごとにロールが必要になってしまいます。
そこで、作成者グループはテーブル構造で表現することとします。
作成者グループモデルの導入
密書・作成者グループ・ユーザーの関係を考えたところ、以下となりました。
- 密書 : 作成者グループ = 1 : 0..n
- 密書ごとに作成者グループが必要
- 密書に対する作成者グループは後から追加可能とする
- 作成者グループ : ユーザー = 1 : 0..n
- 作成者グループには複数のユーザーが含まれる
- 作成者グループに対するユーザーは後から追加可能とする
上記の関係に従い、マイグレーションを作成します。
$ bin/rails g model Author user:references secret_message:references
invoke active_record
create db/migrate/20221003110255_create_authors.rb
create app/models/author.rb
invoke rspec
create spec/models/author_spec.rb
invoke factory_bot
create spec/factories/authors.rb
生成されたマイグレーションは外部キーが nullable になっていません。そこで、手動でマイグレーションを修正し、nullableにします。
class CreateAuthors < ActiveRecord::Migration[7.0]
def change
create_table :authors do |t|
t.references :user, null: true, foreign_key: true
t.references :secret_message, null: false, foreign_key: true
t.timestamps
end
end
end
マイグレーションを適用します。
$ bin/rails db:migrate
== 20221003110255 CreateAuthors: migrating ====================================
-- create_table(:authors)
-> 0.0024s
== 20221003110255 CreateAuthors: migrated (0.0024s) ===========================
各モデルに関連付け(belongs_to/has_many)を追加
外部キーを生成したので、モデルにも関連付けを定義します。
SecretMessage
class SecretMessage < ApplicationRecord
belongs_to :owner, class_name: 'User', foreign_key: :owner_id
has_many :authors
end
User
class User < ApplicationRecord
rolify
has_many :authors
end
Author
class Author < ApplicationRecord
belongs_to :user
belongs_to :secret_message
end
Policyを変更する
更新権限を 家老、かつ、作成者グループに含まれること
へと変更します。
class Api::SecretMessagePolicy < ApplicationPolicy
def update?
author?
end
private def author?
chief_retainer? && record.authors.exists?(user: user)
end
テストコードの修正
作成者グループのfactoryを追加
外部キーのデフォルト値は無しとする、作成者グループのfactoryを定義します。
FactoryBot.define do
factory :author do
user { nil }
secret_message { nil }
end
end
テストコードの修正
更新系のテストコードを修正します。
ここまでのコミット
https://github.com/thinkAmi-sandbox/rails_with_pundit-sample/pull/1/commits/d56fccece0cdaae5f77b0235c44e8dd5011cf88a
奉行ロールを追加し、作成者グループにいる奉行は作成した密書の更新と閲覧を可能にする
ストーリー
一通りの認可制御ができ、家老たちは密書管理システムを運用していました。
そんな中、ある家老から「密書を奉行に見せたりしているんだが、都度システムの前に呼んだり密書をプリントアウトしたりしている。手間がかかるし、プリントアウトした密書を落としたら大変なことになってしまう。」と聞きました。
また、別の家老からは「内容によっては奉行に直接修正してもらって構わないものもあるんだが、今は奉行から手書きで修正依頼を上げてもらっている。手書きなので修正を間違えることがあり、そのうちオオゴトになってしまいそうだ。」とも聞きました。
それらの話の内容からすると奉行にもシステムを使わせたほうが良さそうでした。
そこで、家老と同じような、 奉行
ロールを導入して認可制御することにします。
奉行の認可制御としては、作成者グループに含まれている密書のみ閲覧・更新ができれば良さそうです。
認証 |
役職 |
作成者グループ |
|
閲覧 |
作成 |
更新 |
あり |
家老 |
含まれる |
|
o |
o |
作成グループの密書のみ |
あり |
家老 |
含まれない |
|
o |
o |
x |
あり |
奉行 |
含まれる |
|
作成グループの密書のみ |
o |
作成グループの密書のみ |
あり |
奉行 |
含まれない |
|
x |
x |
x |
あり |
なし |
- |
|
x |
x |
x |
なし |
- |
- |
|
x |
x |
x |
アプリの作成
Policyの修正
奉行ロールは Rolify では :magistrate
で表すことにします。
更新権限の変更
作成者グループ、かつ、家老・奉行であれば更新できるようにします。
def update?
author? && (chief_retainer? || magistrate?)
end
private def magistrate?
user.has_role? :magistrate
end
閲覧権限の変更
奉行の閲覧権限は「奉行は自分が作成者グループに含まれる密書のみ閲覧できる」となります。
ただ、 index?
メソッドはbooleanを返す必要があるため、「自分が作成者グループに含まれる密書のみ取得する」を index?
メソッドには実装できません。
その代わり、DBから取得する際に絞り込む方法として Policy Scope
という機能があります。
Often, you will want to have some kind of view listing records which a particular user has access to. When using Pundit, you are expected to define a class called a policy scope.
https://github.com/varvet/pundit#scopes
そこで、 index?
メソッドとPolicy Scopeを使った
index?
では家老・奉行だけを許可する
- データの取得についてはPolicy Scopeを使い、奉行の場合は「自分が作成者グループに含まれる密書」のみ取得する
- コントローラでは
policy_scope
メソッドを使い、必要なデータを取得する
という形で実装していきます。
まずは index?
を実装します。
def index?
chief_retainer? || magistrate?
end
次に、Policy Scopeを使うため、Policyの内部クラスとしてScopeを定義します。
class Api::SecretMessagePolicy < ApplicationPolicy
class Scope < Scope
def resolve
if user.has_role? :chief_retainer
scope.all
else
scope.joins(:authors).where(authors: {user: user})
end
end
end
コントローラの修正
コントローラでは以下の流れで実装します。
authorize
メソッドで認可制御する
policy_scope
メソッドで必要なデータのみ取得する
def index
authorize SecretMessage
records = policy_scope(SecretMessage, policy_scope_class: Api::SecretMessagePolicy::Scope)
render json: records
end
モデルの修正
以前の実装で、マイグレーションの外部キーをnullableとしました。
ただ、実際に外部キーへNULLを入れると ActiveRecord::RecordInvalid: Validation failed: User must exist
とのエラーになります。
これはRails側のバリデーションに引っかかってしまっているようです。
そのため、以下の記事を参考に、モデルの belongs_to
に optional
を設定します。
class Author < ApplicationRecord
belongs_to :user, optional: true
belongs_to :secret_message
end
テストの修正
User factoryに奉行を追加
家老のfactory同様、奉行のfactoryを追加します。
FactoryBot.define do
factory :user, aliases: [:owner] do
name { 'foo' }
password { 'password' }
factory :magistrate do
after(:create) {|user| user.add_role(:magistrate)}
end
end
end
テストコードの追加
奉行ロールの場合に権限マトリックスに従っているかを確認します。
context '奉行ロール' do
let!(:samurai) { create(:magistrate, name: 'samurai', password: 'ps') }
context 'authorに含まれる' do
before do
create(:author, user: samurai, secret_message: secret_message)
end
it '成功' do
get api_secret_messages_path, headers: { HTTP_AUTHORIZATION: valid_basic_auth }
expect(response).to have_http_status(200)
expect(parsed_response_body.length).to eq(1)
titles = parsed_response_body.map {|msg| msg['title']}
expect(titles).to eq(%w[密書1])
end
end
context 'authorに含まれない' do
it '失敗' do
get api_secret_messages_path, headers: { HTTP_AUTHORIZATION: valid_basic_auth }
expect(response).to have_http_status(200)
expect(parsed_response_body.length).to eq(0)
end
end
end
ここまでのコミット
https://github.com/thinkAmi-sandbox/rails_with_pundit-sample/pull/1/commits/52832a3c00dc47d0899afd9b7bf27756b865f057
派閥モデルを追加し、家老と同じ派閥のユーザーは家老が作成した密書を閲覧可能にする
ストーリー
奉行も密書管理システムを使えるようになって「便利になったわー」と言われる日々を過ごしていました。
そんな中、建物の中にプリントアウトした密書が落ちていた事件が発生しました。
家老たちに話を聞くと、「派閥内の情報共有のために、自分の書いた密書は同じ派閥のメンバーにもプリントアウトして共有している」とのことでした。
プリントアウトされた密書が落ちていたとしても落とし主を追跡できないため、派閥のメンバーにも密書管理システムを使ってもらうことになりました。
さらに、bo藩での派閥について家老に聞いたところ、以下の組織体のようでした。
- 家臣は1つの派閥に所属できる
- ただし、一匹狼で過ごしたい場合は、派閥に属さないこともできる
- 派閥に所属していない家老や奉行もいる
そこで、密書管理システムに「派閥のメンバーは、家老が作成者グループに所属している密書を閲覧できる」という認可制御を追加することにしました。
認証 |
役職 |
作成者グループ |
家老の派閥 |
|
閲覧 |
作成 |
更新 |
あり |
家老 |
含まれる |
- |
|
o |
o |
作成グループの密書のみ |
あり |
家老 |
含まれない |
- |
|
o |
o |
x |
あり |
奉行 |
含まれる |
所属 |
|
作成グループもしくは派閥の密書のみ |
o |
作成グループの密書のみ |
あり |
奉行 |
含まれない |
無所属 |
|
x |
x |
x |
あり |
なし |
- |
所属 |
|
派閥の密書のみ |
x |
x |
なし |
- |
- |
無所属 |
|
x |
x |
x |
アプリの作成
派閥をロールとデータ構造のどちらで表現するか
派閥
という概念をロールとテーブル構造のどちらで表現するか考えましたが、ロールだと
のように派閥が増えるごとにロールも増えていきそうでした。
また、ロールで表現してしまうと「ユーザーは1つの派閥にだけ所属できる」というバリデーションが必要になりそうでした。
そこで、今回はテーブル構造にて派閥を表現することにしました。
テーブル間の多重度ですが、ユーザーは一つの派閥にだけ所属できることから、 ユーザー : 派閥 = 1 : 0..1 とすれば良さそうです。
まずはマイグレーションを追加します。
$ bin/rails g model Faction name:string
invoke active_record
create db/migrate/20221004232102_create_factions.rb
create app/models/faction.rb
invoke rspec
create spec/models/faction_spec.rb
invoke factory_bot
create spec/factories/factions.rb
次に、Userモデルに派閥(Faction)を外部キーとして持てるようなマイグレーションを追加します。
$ bin/rails g migration AddFactionToUsers faction:references
invoke active_record
create db/migrate/20221004232303_add_faction_to_users.rb
なお、派閥は無所属でもよいため、マイグレーションを編集してnullableにします。
class AddFactionToUsers < ActiveRecord::Migration[7.0]
def change
add_reference :users, :faction, null: true, foreign_key: true
end
end
DBに外部キーを追加したため、モデルにも関連を設定します。
「Userはどの派閥に属するか」を確認できればよいため、今回はUserモデルにのみ関連を追加します。
また、派閥に属さなくても良いため、 optional: true
も追加しておきます。
class User < ApplicationRecord
rolify
has_many :authors
belongs_to :faction, optional: true
end
Policyの修正
Policyでは「派閥に所属するユーザー」は「家老が作成者グループの密書を取得」して閲覧できることを表現します。
そのため、Policyを以下とします。
index?
に「派閥に所属するユーザー」の制御を追加
- Policy Scopeに「家老が作成者グループの密書を取得」を追加
index? の修正
ユーザーが派閥に属しているかをOR条件に追加します。
無所属の家老や奉行もいるため、他の条件は今まで通りにします。
def index?
chief_retainer? || magistrate? || belonging_to_faction?
end
private def belonging_to_faction?
user.faction.present?
end
Policy Scopeの修正
権限マトリックスに従い、Policy Scopeで取得できるデータを制御します。
下記の①~③が実装できれば良さそうです。
- 家老
- それ以外
- ②自分が作成者グループの密書
- ③作成者グループに同じ派閥グループの家老が含まれる密書
①~③の条件をActiveRecordで書いていきます。
①は全件取得になります。
scope.all
②は、joinsでつなげてwhereで絞り込めば良さそうです。
scope.joins(authors: [user: :faction])
.where(authors: {users: user})
③は少々手間です。
- ②の条件に
or
で連結
- 作成者グループは、副問合せで作成者グループのユーザーを家老に絞り込む
- select で id を取得して、副問合せの外側の
users.id
と一致させる
- 「Userのロールが家老であること」は、Rolifyの
with_role
を使って表現する
.or(scope.joins(authors: [user: :faction]).where(
authors: {
users: {
id: User.with_role(:chief_retainer).select('users.id'),
factions: {
id: user.faction.id
}
}
}
))
念のため、②と③を組み合わせた時に発行されるSQLを確認します。
今回はActiveRecordの to_sql
メソッドでSQLを表示し、とVS Code拡張の SQL Formatter Mod
を使って整形して確認します。
確認したSQLは以下です。想定通りできていそうです。
SELECT
DISTINCT "secret_messages".*
FROM
"secret_messages"
INNER JOIN "authors" ON "authors"."secret_message_id" = "secret_messages"."id"
INNER JOIN "users" ON "users"."id" = "authors"."user_id"
INNER JOIN "factions" ON "factions"."id" = "users"."faction_id"
WHERE
(
"authors"."user_id" = 1
OR "users"."id" IN (
SELECT
"users"."id"
FROM
"users"
INNER JOIN "users_roles" ON "users_roles"."user_id" = "users"."id"
INNER JOIN "roles" ON "roles"."id" = "users_roles"."role_id"
WHERE
(
(
(roles.name = 'chief_retainer')
AND (roles.resource_type IS NULL)
AND (roles.resource_id IS NULL)
)
)
)
AND "factions"."id" = 1
)
上記①~③を組み込んだPolicyは以下となります。プロダクションコードはこれで完成です。
class Api::SecretMessagePolicy < ApplicationPolicy
class Scope < Scope
def resolve
if user.has_role? :chief_retainer
scope.all
else
scope.joins(:authors).where(authors: {user: user})
return scope.all
end
scope.joins(authors: [user: :faction])
.where(authors: {users: user})
.or(scope.joins(authors: [user: :faction]).where(
authors: {
users: {
id: User.with_role(:chief_retainer).select('users.id'),
factions: {
id: user.faction.id
}
}
}
))
.distinct
上記のPolicyを書く時に参考にした、ActiveRecordの書き方についてもメモを残しておきます。
joinsで複数テーブルを連結する場合は、ハッシュと配列を使う
13.1.3 複数の関連付けを結合する | Active Record クエリインターフェイス - Railsガイド
結合したテーブルのwhereでは、文字列またはハッシュで条件を記述する
13.1.4 結合テーブルで条件を指定する | Active Record クエリインターフェイス - Railsガイド
今回はハッシュで記述しました。
なお、ハッシュのキーにはテーブル名もしくはエイリアスが使えるようです。
Rails 6.1: whereの関連付けでjoinedテーブルのエイリアス名を参照可能になった(翻訳)|TechRacho by BPS株式会社
Rails4のActiveRecordは、IN演算子のサブクエリを使える - USO800
テストコードの修正
権限マトリックスに従ってテストコードを修正します。
権限マトリックスが複雑なためテストコードは掲載しませんが、以下のようなパターンをテストしています。
$ bundle exec rspec --format d
...
Api::SecretMessages
GET /api/select_messages
正しいAuthorizationヘッダあり
家老ロール
authorに含まれる
behaves like すべて閲覧できる
authorに含まれない
behaves like すべて閲覧できる
奉行ロール
authorに含まれる
同じ派閥の家老
同じauthorにいる
behaves like authorに同じ派閥の家老がいる密書(samurai_message)のみ閲覧できる
別のauthorにいる
behaves like すべて閲覧できる
authorにいない
behaves like 自分がauthorの密書(samurai_message)のみ閲覧できる
同じ派閥の奉行
同じauthorにいる
behaves like 自分がauthorの密書(samurai_message)のみ閲覧できる
別のauthorにいる
behaves like 自分がauthorの密書(samurai_message)のみ閲覧できる
いない
behaves like 自分がauthorの密書(samurai_message)のみ閲覧できる
同じ派閥の一般
いる
behaves like 自分がauthorの密書(samurai_message)のみ閲覧できる
いない
behaves like 自分がauthorの密書(samurai_message)のみ閲覧できる
authorに含まれない
同じ派閥の家老
authorにいる
behaves like authorに同じ派閥の家老がいる密書(samurai_message)のみ閲覧できる
authorにいない
behaves like 閲覧可能なデータがない
同じ派閥の奉行
authorにいる
behaves like 閲覧可能なデータがない
authorにいない
behaves like 閲覧可能なデータがない
同じ派閥の一般
いる
behaves like 閲覧可能なデータがない
いない
behaves like 閲覧可能なデータがない
ロールなし(一般)
派閥に所属
同じ派閥の家老
authorに含まれる
behaves like authorに同じ派閥の家老がいる密書(samurai_message)のみ閲覧できる
authorに含まれない
behaves like 閲覧可能なデータがない
別の派閥の家老
authorに含まれる
behaves like 閲覧可能なデータがない
同じ派閥の奉行
authorに含まれる
behaves like 閲覧可能なデータがない
authorに含まれない
behaves like 閲覧可能なデータがない
別の派閥の奉行
authorに含まれる
behaves like 閲覧可能なデータがない
派閥に所属していない
別の派閥の家老がauthorにいる
behaves like 閲覧できない
別の派閥の奉行がauthorにいる
behaves like 閲覧できない
別の派閥の役職者がauthorにいない
behaves like 閲覧できない
誤ったAuthorizationヘッダ
エラー
Authorizationヘッダなし
エラー
ここまでのコミット
https://github.com/thinkAmi-sandbox/rails_with_pundit-sample/pull/1/commits/0e724a8da458b1a33e89cf78676421b7684db06b
まとめ
ストーリー
ここまでの実装にて、bo藩は密書管理システムを構築できました。
bakufuからのおふれにも従えて満足できるシステムになりました。めでたしめでたし。
実装してみての感想
今回、Pundit + Rolify を使って認可制御をしました。
100行未満のPolicyファイルで期待する認可制御が実現できたため、あとから見直しても容易に読み解けそうと感じました。
また、
- コントローラのメソッドを認可するかどうか
- どんなデータを取得するか
が分離できているのも分かりやすく感じました。
一方、権限マトリックス通りに実装できているかについては、プロダクションコードをパッと見ても分かりづらく感じました。
ただ、そこはテストコードを見ればよいと割り切れば良さそうです。
Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_with_pundit-sample
今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_with_pundit-sample/pull/1