はじめに
個人開発の 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内において、決められた範囲のレコードを取得する処理になります。
今回は、
- Policy / Scope を先に実装
- 後から 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にし、拒否の状態にしています。
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を一括で適用しています。
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編集
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新規作成
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内で
current_user.memories.find_by!(uuid: params[:uuid])
というように権限管理していましたが、変更後は、
def set_memory
@memory = Memory.find_by!(uuid: params[:uuid])
authorize @memory
end
という形になり、
- controller → データ取得
- policy → 権限判定
という責務分離ができました。
このset_memoryメソッドはshow edit update destroyアクションで適用しています。
@memory = Memory.find_by!(uuid: params[:uuid])
で単純に投稿を探し、
authorize @memory
で各アクションのpolicyの権限を呼んでいます。
show - 複雑な認可を Policy に集約
showアクションでは、MemoryPolicy#show?が呼ばれ、
def show?
record.published? ||
owner? ||
(record.unlisted? && family_member_of_owner?)
end
- 投稿公開ステータスがpublished(一般公開)
- 自分の投稿
- 投稿公開ステータスがunlisted(限定公開)かつ同じ家族グループなら閲覧可能
この三つのうちどれかに当てはまれば閲覧可能というようにしています。
このような複雑な認可を controller ではなく Policy に集約できたのが、今回一番恩恵の大きかった部分です。
edit / update / destroy
edit update destroyアクションは、いずれもowner?で所有者のみ編集削除可能になります。
def owner?
record.owned_by?(user)
end
editアクションは親クラスのapplication_policy.rbで
def edit? = update?
とあるのでupdate?と同じ権限になります。(newアクションもcreateアクションと同じ)
new / create
new createアクションでは、
def create? = user.present?
でユーザーが存在することを確認しています。nilの状態で作成できないようにしています。
policy_scope
indexアクション
def index? = true
一覧の取得自体、policyの権限管理は許可状態にしており、controllerのbefore_action :authenticate_user!でdeviseによるログイン状態を確認するのみになっています。
indexアクションの中でスコープを利用しています。
@my_memories = policy_scope(Memory)
policy_scopeは Pundit が提供しているヘルパーです。policy_scope(Memory)を呼ぶと、内部でMemoryPolicy::Scope.new(current_user, Memory).resolveを実行します。
def resolve
return scope.none if user.nil?
scope.where(user: user)
end
により、実際には
Memory.where(user: user)
として動き、ユーザーのMemoryのみが取得されます。
verify_authorized
Punditにはあらかじめ、policy適用漏れを防ぐ機能verify_authorizedがあります。
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
<% if policy(@memory).edit? %>
このようにviewの中でpolicyヘルパーを使い、policyで定義した権限管理を呼び出し、表示非表示をコントロールすることもできます。
まとめ
Pundit 導入前は、コントローラやビューに認可ロジックが散らばっており、重複コードも多くなっていました。
導入後は、
- コントローラの責務を減らせた
- 認可ルールを Policy に集約できた
- view でも
policy(@memory)が使える -
verify_authorizedにより認可漏れを検知できる
ようになり、コード全体の見通しがかなり良くなりました。
特に今回のような、
- 公開範囲
- 所有者判定
など、条件分岐が多い認可は Pundit の相性が良いと感じています。
認可ロジックが複雑になりそうな Rails アプリでは、早めにPolicyに寄せておくと後からの拡張がかなり楽になると感じました。
既存 Rails アプリに後から導入しましたが、段階的に適用することで比較的安全に移行できました。
参考資料