Pundit + Rolify を使って、Rails製APIアプリでロールによる認可制御を行ってみた

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 を使って、RailsAPIアプリでロールによる認可制御を行ってみた時のメモを残します。

なお、記事が長いので、あらかじめソースコードのありかを記載しておきます。
https://github.com/thinkAmi-sandbox/rails_with_pundit-sample

 
目次

 

環境

  • Rails 7.0.4
    • APIモードで作成
  • Pundit 2.2.0
  • Rolify 6.0.0
  • WSL2上のUbuntuに構築

なお、動作確認にはWSL2上のターミナルで curl を使って行います。

Windowsにも curl はありますが、 " や日本語の扱いに難があります。

 

アプリ(密書管理システム) を作ってPundit +Rolify を理解する

PunditやRolifyの使い方については、色々と記事があり参考になります。

 
また、企業ブログの中でも登場したりします。

 
ただ、上記資料を読んだだけでは理解しきれませんでした。

そこで、具体的な認可制御のイメージをうかべながら理解するために、ストーリーに基づき 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) ====================

 
コントローラを作ります。

今のところ、密書の登録と一覧表示ができればよいので、 indexcreate のみを用意します。

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認証ができれば十分である。パスワードも生で保存すればよい」という雰囲気だったため、認証機能としてRailsBasic認証を使うことにしました。

※現実では、今回のようなシステムの認証に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

 

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"}]

 

ここまでのコミット

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制約は付与できません。

そのため、本来であれば

  1. nullableでownerを追加
  2. 移行用のrake taskを作成し、既存の密書にownerの値をセット
  3. 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を組み込む

PunditAuthorizableBasic認証をしている全コントローラで動作してほしいため、 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 メソッドを呼びます。

 
また、「認証制御は必要だけど、認可制御は不要」なメソッド、今回の場合だと indexcreate に対して authorize メソッドを定義しないと verify_authorized に引っかかってしまいます。

回避するには

のどちらかを実装します。

exceptonly だと範囲が広すぎる気がするので、今回は個々のコントローラメソッド ( indexcreate ) で 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.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
  # 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-railsBasic認証付きのテストコードを書く方法について

今回、認証は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

 
テストコードを流すと、すべてのテストがパスします。

 

ここまでのコミット

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 メソッドにも修正を加えます。

indexcreate ではモデルのインスタンスは使用しません。そのため、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
  # 家老ロールを持った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です。

 

ここまでのコミット

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
      # 外側の Api::SecretMessagePolicy の chief_retainer? は参照できない
      if user.has_role? :chief_retainer
        scope.all
      else
        scope.joins(:authors).where(authors: {user: user})
      end
    end
  end
# ...

 

コントローラの修正

コントローラでは以下の流れで実装します。

  1. authorize メソッドで認可制御する
  2. 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_tooptional を設定します。

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

 

ここまでのコミット

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

 

アプリの作成

派閥をロールとデータ構造のどちらで表現するか

派閥 という概念をロールとテーブル構造のどちらで表現するか考えましたが、ロールだと

  • 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(scope.joins(authors: [user: :faction]).where(
  authors: {
    users: {
      id: User.with_role(:chief_retainer).select('users.id'),
      factions: {
        id: user.faction.id
      }
    }
  }
))

 
念のため、②と③を組み合わせた時に発行されるSQLを確認します。

今回はActiveRecordto_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

      # 上段: 自分が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ヘッダなし
      エラー

 

ここまでのコミット

https://github.com/thinkAmi-sandbox/rails_with_pundit-sample/pull/1/commits/0e724a8da458b1a33e89cf78676421b7684db06b

 

まとめ

ストーリー

ここまでの実装にて、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