1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rails7.2 アプリに後からPunditを導入、公開範囲の認可を整理した記録

1
Posted at

はじめに

個人開発の Rails 7.2 アプリに、 Pundit を段階的に導入した記録になります。
このアプリは写真とエピソードを投稿・共有するアプリになっており、公開範囲(非公開 / 限定公開 / 一般公開)機能を実装するにあたり、コントローラやビューに散らばっていた認可ロジックを整理したかったため、Pundit を導入しました。

gem pundit公式ドキュメント

前提

項目 内容
アプリ こどもとの小さな思い出を写真とエピソードで記録共有するサービス
Rails 7.2.2 / Ruby 3.3.6
認証 Devise + omniauth-line
フロント Hotwire (Turbo + Stimulus) + 一部 React 19
DB PostgreSQL 17
認可(今回追加) Pundit

導入前の状態:

  • view (memories/show, public_memories/show): if current_user == @memory.user で編集/削除ボタンの表示を出し分け
  • controller (memories_controller): current_user.memories.find_by!(uuid: params[:uuid]) で 投稿者本人のスコープに絞って取得することで、他人の投稿に対しては 404 を返す形で間接的に認可

どちらも「投稿者本人かどうか」しか判定できず、「限定公開」「一般公開」など複雑な権限を入れにくい状態だった

なぜ Pundit にしたか

選定時の比較対象は CanCanCan。
Pundit を選んだ理由:

こちらの記事に

cancancanはユーザに対して、どんなアクションが許可するかを定義するのに対して、Punditではリソースに対して誰が許可されるのかを定義

と書いてある通り、今回は「投稿の公開範囲」を中心に認可を管理したかったため、モデル寄りで認可を整理しやすい Pundit を採用しました。

gem Punditの使い方

Punditの使い方は上記の記事をご覧いただくとわかりやすいです。
記事の中に

Punditは、コントローラの各アクションで authorize リソースオブジェクト を呼ぶと対象のリソースに対して権限があるかを確認してくれます。そのポリシーを app/policies にあるポリシーファイルで細かく定義することができます。

また、scopeという機能もあり、これは認可されたpolicy内において、決められた範囲のレコードを取得する処理になります。

今回は、

  1. Policy / Scope を先に実装
  2. 後から controller に適用

という段階導入の形で進めました。

どのコントローラにPunditを適用するか

執筆時点では、アプリ内にあるコントローラは以下のようになっています。
以下の判断軸を元に実装しました。

controller action 説明
Dashboard top ログイン後のtopページ表示
FamilyGroups new, create, edit, update, show, destroy 家族グループの作成削除
FamilyMemories index 家族グループでの投稿を表示
Invitations create, show, join 招待リンク作成とグループ参加
Memories index, new, create, edit, update, show, destroy 思い出投稿のCRUD操作
Api::MemoryGames show ゲーム画面の表示
Mypages show プロフィール表示
PublicMemories index, show みんなに公開設定された思い出を表示
StaticPages top ログイン前のtopページ表示
UserFamilyGroups update, destroy 家族メンバーシップ管理
軸 メソッド 当てはまるアクション
個別レコードの操作認可管理が必要 authorize(対象のリソースに対して権限があるかを確認) Memories CRUD, FamilyGroups CRUD, Invitations create, UserFamilyGroups update/destroy
レコードを絞り込む policy_scope(操作できるレコードを取得)+ skip_after_action :verify_authorized Memories index, Dashboard top, FamilyMemories, Api::MemoryGames show
認可判定不要 skip_after_action :verify_authorized(Pundit非対応化) PublicMemories / Mypages / StaticPages / Devise

Devise 系 controller は認証ライブラリ側が責務を持つため対象外にしています。
また、PublicMemories は公開情報のみを扱うため、Pundit を適用していません。
Mypagesは自分自身のプロフィール表示のみのため、Policy化していません。

段階導入

順 内容
1 gem 追加 + ApplicationController + ApplicationPolicy 認可基盤を作成
2 各 Policy + Scope(コントローラ未適用)
3 各Controller に Pundit 適用
4 verify_authorized 有効化 + viewに必要があれば認可による表示分岐をpolicy(Model)に書き換え

順序の理由

  • Policy を controller より先に設置: 先に認可ルールを整理したかったため、controller より前に Policy を作成。
  • verify_authorized を最後に有効化: アクション内で authorize が呼ばれていない場合に例外を発生。そのため、すべての controller への適用が終わった後に有効化。

実装

※わかりやすくするためコードは一部省略しています。

親クラス

application_policy.rb新規作成

各policyの基底クラス。policyファイルはアクション一つ一つに認可を判定します。
各アクションに対し、アクション名と?(クエスチョンマーク)で定義し、true(許可)とfalse(拒否)で認可を管理します。基底クラスなので、オーバーライドするpolicyクラスに万が一認可を書き忘れた時のためにデフォルトはfalseにし、拒否の状態にしています。

application_policy.rb
class ApplicationPolicy
  attr_reader :user, :record

  def initialize(user, record)
    @user = user
    @record = record
  end

  def index?     = false
  def show?      = false
  def create?    = false
  def new?       = create?
  def update?    = false
  def edit?      = update?
  def destroy?   = false

  # index アクションでログインユーザーが閲覧可能なレコード集合を返す Scope。
  # `policy_scope(Model)` を controller で呼ぶと `Scope.new(current_user, Model).resolve` が走る。
  class Scope
    attr_reader :user, :scope

    def initialize(user, scope)
      @user = user
      @scope = scope
    end

    def resolve
      raise NoMethodError, "You must define #resolve in #{self.class}"
    end
  end
end

解説

  attr_reader :user, :record

は以下を自動生成し、

def user
  @user
end

def record
  @record
end

インスタンス変数の読み取りメソッドを定義します。

def initialize(user, record)
  @user = user
  @record = record
end

この部分は、誰が(user)何を(record)操作しようとしているかを Policy クラス内で使えるように定義しています。
このおかげで後述するauthorizeメソッドは、例えばコントローラでauthorize @memoryと書くとMemoryと一致する MemoryPolicyクラスを自動的に解決し、そのクラスをインスタンス化(MemoryPolicy.new(current_user, @memory))して、現在のユーザーと指定されたレコードを渡します。

class Scope
    attr_reader :user, :scope

    def initialize(user, scope)
      @user = user
      @scope = scope
    end

    def resolve
      raise NoMethodError, "You must define #resolve in #{self.class}"
    end
  end

この部分のScopeクラスでは、アクセスできるレコードの範囲を定義します。
Scope クラスでは、policy_scope(Model) で渡された Model を scope として受け取り、
resolve 内で利用します。
policy_scope(Model) を controller で呼ぶと Scope.new(current_user, Model).resolve が内部で実行されます。
この親クラスでraise NoMethodError, "You must define #resolve in #{self.class}"と記述することで子クラスでの書き忘れ時にキャッチできるよう意図的にエラーを出しています。

application_controller.rb編集

Punditを一括で適用しています。

application_controller.rb
class ApplicationController < ActionController::Base
+ include Pundit::Authorization
  # Devise 系コントローラは認証ライブラリ側が責務を持つため一括除外。
+ after_action :verify_authorized, unless: :devise_controller?
+ rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
end

解説

after_action :verify_authorized, unless: :devise_controller?
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

verify_authorizedはauthorize を呼び忘れたアクションで例外を発生させます。
またrescue_from Pundit::NotAuthorizedErrorは、authorizeの判定が false になった(=権限がない)場合の例外を捕捉します。

子クラス

主要なコントローラーのみを載せます。

memories_controller.rb編集

memories_controller.rb
class MemoriesController < ApplicationController
  before_action :authenticate_user!
+ before_action :set_memory, only: %i[show edit update destroy]

  # index は policy_scope のみ使用するため除外
+ skip_after_action :verify_authorized, only: :index

  def index
    # ユーザーが閲覧可能な投稿のみ取得
-   @my_memories = current_user.memories
+   @my_memories = policy_scope(Memory)
  end

  def new
    @memory = Memory.new
    # 作成権限を確認
+   authorize @memory
  end

  def create
    @memory = current_user.memories.build(memory_params)
    # 作成権限を確認
+   authorize @memory
    if @memory.save
      redirect_to memories_path
    else
      render :new, status: :unprocessable_entity
    end
  end

  def edit
-   @memory = current_user.memories.find_by!(uuid: params[:uuid])
  end

  def update
-   @memory = current_user.memories.find_by!(uuid: params[:uuid])
    if @memory.update(memory_params)
      redirect_to memories_path
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
-   memory = current_user.memories.find_by!(uuid: params[:uuid])
-   memory.destroy!
+   @memory.destroy!
    redirect_to memories_path
  end

  def show
-   @memory = current_user.memories.find_by!(uuid: params[:uuid])
  end

  private

+ def set_memory
+   @memory = Memory.find_by!(uuid: params[:uuid])
+   # 操作権限を確認
+   authorize @memory
+ end

  def memory_params
    params.require(:memory).permit(
      :title,
      :description,
      :memory_date,
      :visibility
    )
  end
end

memory_policy.rb新規作成

memory_policy.rb
class MemoryPolicy < ApplicationPolicy
  def index?   = true
  def show?    = record.published? || owner? || (record.unlisted? && family_member_of_owner?)
  def create?  = user.present?
  def update?  = owner?
  def destroy? = owner?

  class Scope < ApplicationPolicy::Scope
    def resolve
      return scope.none if user.nil?
      scope.where(user: user)
    end
  end

  private

  def owner?
    record.owned_by?(user)
  end

  # current_user の所属家族グループと record.user の所属家族グループに重複があるか。
  # 「1 ユーザー 1 グループ」制約下では、実質「同じ家族グループに属しているか」の判定。
  def family_member_of_owner?
    return false if user.nil?
    user.family_groups.where(id: record.user.family_groups.select(:id)).exists?
  end
end

ポイント

責務分離

以前はcontroller内で

memories_controller.rb
current_user.memories.find_by!(uuid: params[:uuid])

というように権限管理していましたが、変更後は、

memories_controller.rb
def set_memory
  @memory = Memory.find_by!(uuid: params[:uuid])
  authorize @memory
end

という形になり、

  • controller → データ取得
  • policy → 権限判定

という責務分離ができました。
このset_memoryメソッドはshow edit update destroyアクションで適用しています。

memories_controller.rb
@memory = Memory.find_by!(uuid: params[:uuid])

で単純に投稿を探し、

memories_controller.rb
authorize @memory

で各アクションのpolicyの権限を呼んでいます。

show - 複雑な認可を Policy に集約

showアクションでは、MemoryPolicy#show?が呼ばれ、

memory_policy.rb
def show?
  record.published? ||
  owner? ||
  (record.unlisted? && family_member_of_owner?)
end
  • 投稿公開ステータスがpublished(一般公開)
  • 自分の投稿
  • 投稿公開ステータスがunlisted(限定公開)かつ同じ家族グループなら閲覧可能

この三つのうちどれかに当てはまれば閲覧可能というようにしています。
このような複雑な認可を controller ではなく Policy に集約できたのが、今回一番恩恵の大きかった部分です。

edit / update / destroy

edit update destroyアクションは、いずれもowner?で所有者のみ編集削除可能になります。

memory_policy.rb
def owner?
  record.owned_by?(user)
end

editアクションは親クラスのapplication_policy.rbで

application_policy.rb
def edit?      = update?

とあるのでupdate?と同じ権限になります。(newアクションもcreateアクションと同じ)

new / create

new createアクションでは、

memory_policy.rb
def create? = user.present?

でユーザーが存在することを確認しています。nilの状態で作成できないようにしています。

policy_scope

indexアクション

memory_policy.rb
def index? = true

一覧の取得自体、policyの権限管理は許可状態にしており、controllerのbefore_action :authenticate_user!でdeviseによるログイン状態を確認するのみになっています。

indexアクションの中でスコープを利用しています。

memories_controller.rb
@my_memories = policy_scope(Memory)

policy_scopeは Pundit が提供しているヘルパーです。policy_scope(Memory)を呼ぶと、内部でMemoryPolicy::Scope.new(current_user, Memory).resolveを実行します。

memory_policy.rb
def resolve
  return scope.none if user.nil?
  scope.where(user: user)
end

により、実際には

Memory.where(user: user)

として動き、ユーザーのMemoryのみが取得されます。

verify_authorized

Punditにはあらかじめ、policy適用漏れを防ぐ機能verify_authorizedがあります。

memories_controller.rb
skip_after_action :verify_authorized, only: :index

子クラスのコントローラのauthorizeを呼んでいないアクションに対し、skipを適用させ例外を発生させないようにしています。indexアクションでは、authorizeではなく、policy_scopeのみを利用しているためskipしています。
Pundit には verify_policy_scoped という、policy_scope の呼び忘れを検知する仕組みもあります。今回はシンプルさを優先し、取り入れていません。
一覧取得系のアクションでは、
after_action :verify_policy_scoped, only: :index
のように設定すると、policy_scope の呼び忘れを検知できます。

view

views/memories/show.html.erb
<% if policy(@memory).edit? %>

このようにviewの中でpolicyヘルパーを使い、policyで定義した権限管理を呼び出し、表示非表示をコントロールすることもできます。

まとめ

Pundit 導入前は、コントローラやビューに認可ロジックが散らばっており、重複コードも多くなっていました。
導入後は、

  • コントローラの責務を減らせた
  • 認可ルールを Policy に集約できた
  • view でも policy(@memory) が使える
  • verify_authorized により認可漏れを検知できる

ようになり、コード全体の見通しがかなり良くなりました。
特に今回のような、

  • 公開範囲
  • 所有者判定

など、条件分岐が多い認可は Pundit の相性が良いと感じています。
認可ロジックが複雑になりそうな Rails アプリでは、早めにPolicyに寄せておくと後からの拡張がかなり楽になると感じました。
既存 Rails アプリに後から導入しましたが、段階的に適用することで比較的安全に移行できました。

参考資料

1
1
0

Register as a new user and use Qiita more conveniently

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?