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)
とあり、Punditと組み合わせて使えそうでした。
そこで、Pundit + Rolify を使って、RailsのAPIアプリでロールによる認可制御を行ってみた時のメモを残します。
なお、記事が長いので、あらかじめソースコードのありかを記載しておきます。
https://github.com/thinkAmi-sandbox/rails_with_pundit-sample
目次
- 環境
- アプリ(密書管理システム) を作ってPundit +Rolify を理解する
- やらないこと
- 認証機能がないRails APIアプリの作成
- Basic認証を使い、認証されたユーザーのみデータを取得可能にする
- Punditを使って、自分の作成した密書のみ更新可能にする
- Rolifyを使い、家老ロールのみ密書の登録・更新・閲覧を可能にする
- 密書の作成者グループを追加し、作成者グループのメンバーのみ密書を更新可能にする
- 奉行ロールを追加し、作成者グループにいる奉行は作成した密書の更新と閲覧を可能にする
- 派閥モデルを追加し、家老と同じ派閥のユーザーは家老が作成した密書を閲覧可能にする
- まとめ
- ソースコード
環境
なお、動作確認にはWSL2上のターミナルで curl
を使って行います。
Windowsにも curl
はありますが、 "
や日本語の扱いに難があります。
アプリ(密書管理システム) を作ってPundit +Rolify を理解する
PunditやRolifyの使い方については、色々と記事があり参考になります。
- Gem Punditの基本的な使い方まとめ
- Punditをなるべくやさしく解説する - Qiita
- Pundit + Railsで認可の仕組みをシンプルに作る - Qiita
- Punditメモ · Linyclar
- 権限管理のgem、Punditの紹介
- PunditとRolifyをrails6に導入してみた - tenrakatsuno
- 週刊Railsウォッチ: RailsConf 2022が5月17〜19日開催、認可機能解説記事ほか(20220418前編)|TechRacho by BPS株式会社
また、企業ブログの中でも登場したりします。
- 権限管理の苦い思い出を新規サービスで昇華した話 - Link and Motivation Developers' Blog
- ニコニコ漫画と認可(前編) | トリスタinside
- MNTSQ CLMの認可の実装 - Techブログ - MNTSQ, Ltd.
- ブログの中で触れられている資料
- Gunosy管理画面を支えるRails技術 - Gunosy Tech Blog
ただ、上記資料を読んだだけでは理解しきれませんでした。
そこで、具体的な認可制御のイメージをうかべながら理解するために、ストーリーに基づき Pundit + Rolify アプリを作り進めていくことにしました。
今回は「edo時代のbo藩が密書を管理するシステムを作る」というストーリーでアプリを作っていきます。
edo時代のbo藩では藩内システムをRailsで内製していました。
そんな中、他藩で密書がもれるという事件が起こりました。この事件を重くみたbakufuは全藩に「密書を適切に管理していない藩は取り潰す」とのおふれを出しました。
そのおふれを受け取ったbo藩が、密書管理システムの構築を検討し始めるというところから物語は始まります。
やらないこと
認証機能をしっかり作ること
今回の目的は認可制御なので、認証機能についてはまともに作らないことにしました。
つまり
- 認証機能にDeviseは使わない
- 今回はAPIアプリでは、Basic認証にする
- Authorizationヘッダにユーザーとパスワードを入れる
- そのユーザーとパスワードの組み合わせで認証する
- ユーザーとパスワードは
users
テーブルに生データとして入れる- データベースがクラックされたらパスワードが流出する
という超簡易的なものにしました。
本番運用するには完全に不適切ですが、今回は認可機能に集中するため、あえてこのような構成にしています。
認証機能がないRails APIアプリの作成
ストーリー
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"}]
意図した通りに動作しました。第一歩としては成功です。
ここまでのコミット
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
があります。
- ActionController::HttpAuthentication::Basic
- ActionController::HttpAuthentication::Basic::ControllerMethods
密書を管理する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
curlで動作確認
動作確認用にUserモデルにデータを登録します。
name | password |
---|---|
karo | pass |
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"}]
ここまでのコミット
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]) # => will look for an Admin::PostPolicy
のように 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 # APIモードで作ったのでincludeが必要 include ActionController::HttpAuthentication::Basic::ControllerMethods before_action :basic_auth # basic_authの後に PunditAuthorizable 内の before/after が動いてほしいので # この位置でinclude 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 # 認証OKなら誰でも見れる render json: SecretMessage.all end def create skip_authorization # 認証OKなら誰でも作れる 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
curlで動作確認
今回は 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系はRCであり、正式リリースがなされていません。
RSpec Rails 6.0 Release Plan · Issue #2560 · rspec/rspec-rails
そこで、rspec-railsはバージョンを固定してGemfileに記載します。
group :development, :test do # Rails7系のため、rspec-railsは6系にする 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
rspec-railsでBasic認証付きのテストコードを書く方法について
今回、認証は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) # reloadで再読み込み 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) # reloadで再読み込み 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
テストコードを流すと、すべてのテストがパスします。
ここまでのコミット
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
ただ、今回はリソースに対する制御は不要なため、リソースは指定せずに割り当てます。
>> 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
に従い、モデルクラス 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 # 家老ロールを持ったUserを生成 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です。
ここまでのコミット
密書の作成者グループを追加し、作成者グループのメンバーのみ密書を更新可能にする
ストーリー
ここまでの修正にて、現役の家老たちのみ密書を取り扱えるようになりました。
そんな中、家老が過去の密書を見返していたところ、修正が必要そうな密書を見つけました。
しかし、その密書はすでに引退している家老だったため、修正することができません。
さすがにそれでは困るので、作成者本人でなくても修正できるよう、作成者グループという概念を導入・実装することにしました。
権限マトリックス
更新処理が行える権限について、「作成者」から「作成者グループ」へと変更します。
また、作成者グループは密書ごとに異なります。つまり、密書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
テストコードの修正
更新系のテストコードを修正します。
ここまでのコミット
奉行ロールを追加し、作成者グループにいる奉行は作成した密書の更新と閲覧を可能にする
ストーリー
一通りの認可制御ができ、家老たちは密書管理システムを運用していました。
そんな中、ある家老から「密書を奉行に見せたりしているんだが、都度システムの前に呼んだり密書をプリントアウトしたりしている。手間がかかるし、プリントアウトした密書を落としたら大変なことになってしまう。」と聞きました。
また、別の家老からは「内容によっては奉行に直接修正してもらって構わないものもあるんだが、今は奉行から手書きで修正依頼を上げてもらっている。手書きなので修正を間違えることがあり、そのうちオオゴトになってしまいそうだ。」とも聞きました。
それらの話の内容からすると奉行にもシステムを使わせたほうが良さそうでした。
そこで、家老と同じような、
奉行
ロールを導入して認可制御することにします。
権限マトリックス
奉行の認可制御としては、作成者グループに含まれている密書のみ閲覧・更新ができれば良さそうです。
認証 | 役職 | 作成者グループ | 閲覧 | 作成 | 更新 | |
---|---|---|---|---|---|---|
あり | 家老 | 含まれる | 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.
そこで、 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 # 外側の Api::SecretMessagePolicy の chief_retainer? は参照できない 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 メソッドで認可制御する authorize SecretMessage # Policy Scopeを使ってデータを絞り込む 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 # optionalを追加 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
ここまでのコミット
派閥モデルを追加し、家老と同じ派閥のユーザーは家老が作成した密書を閲覧可能にする
ストーリー
奉行も密書管理システムを使えるようになって「便利になったわー」と言われる日々を過ごしていました。
そんな中、建物の中にプリントアウトした密書が落ちていた事件が発生しました。
家老たちに話を聞くと、「派閥内の情報共有のために、自分の書いた密書は同じ派閥のメンバーにもプリントアウトして共有している」とのことでした。
プリントアウトされた密書が落ちていたとしても落とし主を追跡できないため、派閥のメンバーにも密書管理システムを使ってもらうことになりました。
さらに、bo藩での派閥について家老に聞いたところ、以下の組織体のようでした。
- 家臣は1つの派閥に所属できる
- ただし、一匹狼で過ごしたい場合は、派閥に属さないこともできる
- 派閥に所属していない家老や奉行もいる
そこで、密書管理システムに「派閥のメンバーは、家老が作成者グループに所属している密書を閲覧できる」という認可制御を追加することにしました。
権限マトリックス
認証 | 役職 | 作成者グループ | 家老の派閥 | 閲覧 | 作成 | 更新 | |
---|---|---|---|---|---|---|---|
あり | 家老 | 含まれる | - | o | o | 作成グループの密書のみ | |
あり | 家老 | 含まれない | - | o | o | x | |
あり | 奉行 | 含まれる | 所属 | 作成グループもしくは派閥の密書のみ | o | 作成グループの密書のみ | |
あり | 奉行 | 含まれない | 無所属 | x | x | x | |
あり | なし | - | 所属 | 派閥の密書のみ | x | x | |
なし | - | - | 無所属 | x | x | x |
アプリの作成
派閥をロールとデータ構造のどちらで表現するか
派閥
という概念をロールとテーブル構造のどちらで表現するか考えましたが、ロールだと
- A派閥
- B派閥
- ...
のように派閥が増えるごとにロールも増えていきそうでした。
また、ロールで表現してしまうと「ユーザーは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にはSecretMessageモデルが入る
scope.all
②は、joinsでつなげてwhereで絞り込めば良さそうです。
scope.joins(authors: [user: :faction]) .where(authors: {users: user})
③は少々手間です。
- ②の条件に
or
で連結 - 作成者グループは、副問合せで作成者グループのユーザーを家老に絞り込む
- select で id を取得して、副問合せの外側の
users.id
と一致させる
- select で 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
を使って整形して確認します。
- Railsの技: to_sqlでActive Recordが生成するクエリを調べる(翻訳)|TechRacho by BPS株式会社
- SQL Formatter Mod - Visual Studio Marketplace
確認した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 # 上段: 自分がauthor # 下段: 同じ派閥の家老がauthor 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 # 1つのsecret_messagesに対しfactionが等しいauthorが複数いる時のためdistinctする # ...
ActiveRecordの使い方メモ
上記のPolicyを書く時に参考にした、ActiveRecordの書き方についてもメモを残しておきます。
joinsで複数テーブルを連結する場合は、ハッシュと配列を使う
13.1.3 複数の関連付けを結合する | Active Record クエリインターフェイス - Railsガイド
結合したテーブルのwhereでは、文字列またはハッシュで条件を記述する
13.1.4 結合テーブルで条件を指定する | Active Record クエリインターフェイス - Railsガイド
今回はハッシュで記述しました。
なお、ハッシュのキーにはテーブル名もしくはエイリアスが使えるようです。
Rails 6.1: whereの関連付けでjoinedテーブルのエイリアス名を参照可能になった(翻訳)|TechRacho by BPS株式会社
ActiveRecordでの副問合せの書き方について
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ヘッダなし エラー
ここまでのコミット
まとめ
ストーリー
ここまでの実装にて、bo藩は密書管理システムを構築できました。
bakufuからのおふれにも従えて満足できるシステムになりました。めでたしめでたし。
実装してみての感想
今回、Pundit + Rolify を使って認可制御をしました。
100行未満のPolicyファイルで期待する認可制御が実現できたため、あとから見直しても容易に読み解けそうと感じました。
また、
- コントローラのメソッドを認可するかどうか
- index? など
- どんなデータを取得するか
- Policy Scope
が分離できているのも分かりやすく感じました。
一方、権限マトリックス通りに実装できているかについては、プロダクションコードをパッと見ても分かりづらく感じました。
ただ、そこはテストコードを見ればよいと割り切れば良さそうです。
ソースコード
Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_with_pundit-sample
今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_with_pundit-sample/pull/1