Ruby on Rails (以下、Rails) でWebアプリケーションを開発する際、ユーザー権限に基づいた操作制御、すなわち「認可(Authorization)」の実装は避けて通れません。しかし、この認可ロジックがコントローラーやモデルに散在すると、コードは複雑化し、メンテナンス性も低下しがちです。これは「Fat Controller」や「Fat Model」と呼ばれるアンチパターンにつながります。
この記事では、こうした課題を解決する Policyオブジェクト パターンと、その導入を容易にする定番Gem Pundit について、概念から具体的な実装、テスト方法までを、Rails初心者にも分かりやすく解説します。
なぜ認可ロジックの分離が必要か? 問題点
まず、認可ロジックが他の箇所に混在する場合の問題点を確認しましょう。
- Fat Controller: アクション内に認可判定 (
if user.admin? || ...
) が増え、コントローラーが肥大化し、本来の責務から逸脱します。 - Fat Model: モデルに認可メソッド (
can_edit?(user)
) を持たせると、モデルがデータ関連以外の責務を負い、単一責任の原則に反しやすくなります。 - ロジックの重複: 同様の権限チェックが複数箇所で必要になると、コピペによる重複が発生し、修正漏れのリスクが高まります。
- テストの困難さ: 認可ロジックが他の処理と密結合していると、独立したテストが書きにくく、テストが複雑化します。
これらの問題を解決するのがPolicyオブジェクトです。
Policyオブジェクトとは? 認可の「判断」を専門家に任せる
Policyオブジェクトは、特定の リソース (@post
など) に対し、特定の ユーザー (current_user
など) が特定の アクション (:update
など) を実行 できるか? を判断する責務だけを持つ、シンプルなRubyクラスです。
- 責務を分離: 認可ロジックをコントローラーやモデルから切り離し、Policyクラスに集約します。
- 命名規則: 通常
[モデル名]Policy
(例:PostPolicy
) という名前を付けます。 - メソッド:
update?
,destroy?
など、アクション名に?
を付けた真偽値 (true
/false
) を返すメソッドでルールを定義します。
このパターンにより、各クラスが自身の責務に集中でき、コード全体が整理され、テストしやすくなります。
Pundit Gem: RailsでのPolicy実装を簡単に
RailsでPolicyオブジェクトを実装するなら、Pundit Gemがデファクトスタンダードです。Policyクラスの作成や利用を助ける規約とヘルパーを提供します。
Punditは以下の思想に基づいています。
Minimal authorization through OO design and pure Ruby classes
(オブジェクト指向設計とPureなRubyクラスによる、最小限の認可)
– Pundit GitHub Repository: https://github.com/varvet/pundit
1. インストールと初期設定
Gemfile
に以下を追加します。
# Minimal authorization through OO design and pure Ruby classes [https://github.com/varvet/pundit]
gem 'pundit'
bundle install
を実行後、ジェネレータで基本設定を行います。
$ bundle install
$ rails g pundit:install
create app/policies/application_policy.rb
これにより、全Policyの基底となる ApplicationPolicy
が生成されます。共通ルールはここに記述できます。
2. Policyオブジェクトの作成
Post
モデルに対する PostPolicy
を生成します。
$ rails g pundit:policy Post
create app/policies/post_policy.rb
app/policies/post_policy.rb
を編集して、認可ルールを実装します。
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
# ポリシーメソッドは user と record を引数に取る
# 公開記事は誰でも、下書きは作成者か管理者のみ閲覧可
def show?
record.published? || (user && (record.user == user || user.admin?))
end
# ログインユーザーなら作成可
def create?
user.present?
end
# 管理者か作成者なら更新可
def update?
user && (user.admin? || record.user == user)
end
# 管理者のみ削除可
def destroy?
user && user.admin?
end
# --- 一覧取得時の絞り込み用 (後述) ---
class Scope < Scope
def resolve
if user&.admin?
scope.all
elsif user
scope.where(published: true).or(scope.where(user: user, published: false))
else
scope.where(published: true)
end
end
end
end
アクション名?
メソッドでルールを定義します (edit?
はupdate?
、new?
はcreate?
を自動参照)。user
(操作ユーザー) とrecord
(対象リソース) を使って判定し、true
かfalse
を返します。user
がnil
(未ログイン) のケースも考慮しましょう (user&.admin?
やuser && ...
)。
3. コントローラーでの利用
まず ApplicationController
でPunditを有効化し、エラーハンドリングを設定します。
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include Pundit::Authorization
# 認可失敗時 (Pundit::NotAuthorizedError) の共通処理
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
private
# Punditはデフォルトで `current_user` を使う。違う場合は `pundit_user` を定義。
# def pundit_user
# current_admin
# end
def user_not_authorized(exception)
# エラー内容をログに出力しても良い
flash[:alert] = "この操作を実行する権限がありません。"
redirect_back(fallback_location: root_path)
end
end
次に、各アクションで authorize
ヘルパーを呼び出して認可チェックを行います。
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
before_action :set_post, only: [:show, :edit, :update, :destroy]
def show
authorize @post # PostPolicy#show? が呼ばれる
end
def edit
authorize @post # PostPolicy#update? (または edit?) が呼ばれる
end
def update
authorize @post # PostPolicy#update? が呼ばれる
if @post.update(post_params)
# ... redirect ...
else
# ... render ...
end
end
def destroy
authorize @post # PostPolicy#destroy? が呼ばれる
@post.destroy
# ... redirect ...
end
def new
@post = Post.new
authorize @post # PostPolicy#create? (または new?) が呼ばれる
end
def create
@post = Post.new(post_params)
authorize @post # PostPolicy#create? が呼ばれる
if @post.save
# ... redirect ...
else
# ... render ...
end
end
private
def set_post
@post = Post.find(params[:id])
end
def post_params
params.require(:post).permit(:title, :content, :published)
end
end
authorize @resource
が、対応するPolicyメソッドを呼び出します。- 結果が
false
ならPundit::NotAuthorizedError
が発生し、rescue_from
で捕捉した処理が実行されます(エラー画面の代わりにリダイレクトなど)。
4. ビューでの利用
ビューで権限に応じて要素(ボタンなど)の表示を切り替えるには policy
ヘルパーを使います。
<%# app/views/posts/show.html.erb %>
<% if policy(@post).edit? %>
<%= link_to '編集', edit_post_path(@post) %>
<% end %>
<% if policy(@post).destroy? %>
<%= button_to '削除', @post, method: :delete, data: { turbo_confirm: '削除しますか?' } %>
<% end %>
<%# 新規作成リンク (クラスを渡す) %>
<%# if policy(Post).new? %>
<%# <%= link_to '新規投稿', new_post_path %>
<%# end %>
policy(record_or_class).アクション名?
で、そのユーザーが操作可能か (true
/false
) を判定できます。
5. Indexアクションでの絞り込み (Policy Scope)
一覧表示 (index
) で、ユーザーが見る権限のあるレコードだけを取得するには Policy Scope を使います。Policy
クラス内の Scope
クラスにある resolve
メソッドを実装します。
# app/policies/post_policy.rb
# (前述の Policy::Scope#resolve の実装を参照)
コントローラーの index
アクションで policy_scope
ヘルパーを使います。
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
# PostPolicy::Scope#resolve が呼ばれ、絞り込まれたスコープが返る
@posts = policy_scope(Post.includes(:user).order(created_at: :desc))
# ビューでは @posts をそのまま使う
end
# ...
end
policy_scope(Model or Relation)
が、Policy::Scope#resolve
を呼び出し、適切なレコード群 (ActiveRecord::Relation
) を返します。
Policyオブジェクト (Pundit) のメリット
- コード整理: 認可ロジックがPolicyクラスに集約され、コントローラーやモデルがスリムになります。
- 関心の分離: 各クラスが責務に集中でき、コードの可読性と保守性が向上します。
- テスト容易性: Policyクラスは独立してテストしやすいため、認可ロジックの品質を担保しやすくなります。
- 再利用性: 同じ認可ルールを複数箇所で簡単に再利用できます (DRY)。
- 保守性: 仕様変更時の影響範囲がPolicyクラスに限定され、修正が容易になります。
Punditを補完するGem: テストを容易にする pundit-matchers
Punditで整理した認可ロジックが正しく動作するかを保証するには、自動テストが不可欠です。
RSpecを使用している場合、pundit-matchers Gemを使うと、Policyのテストを簡潔かつ宣言的に記述できます。
1. インストール
Gemfile
の :test
グループに追加し bundle install
します。
# Gemfile
group :test do
# A set of RSpec matchers for testing Pundit authorisation policies.[https://github.com/pundit-community/pundit-matchers]
gem 'pundit-matchers'
end
spec/rails_helper.rb
(または spec_helper.rb
) に require 'pundit/matchers'
を追加します。
2. ポリシーメソッド (アクション権限) のテスト
permit
と forbid
マッチャーで、許可/拒否を直感的にテストできます。
# spec/policies/post_policy_spec.rb
require 'rails_helper'
RSpec.describe PostPolicy, type: :policy do
subject { described_class }
let(:author) { create(:user) }
let(:admin) { create(:user, :admin) }
let(:other_user) { create(:user) }
let(:guest) { nil }
let(:post) { create(:post, user: author) }
# 例: update? アクションのテスト
permissions :update?, :edit? do
context 'when user is admin' do
it { is_expected.to permit(admin, post) } # is_expected は expect(subject) と同じ
end
context 'when user is author' do
it { is_expected.to permit(author, post) }
end
context 'when user is other user' do
it { is_expected.to forbid(other_user, post) }
end
context 'when user is guest' do
it { is_expected.to forbid(guest, post) }
end
end
# 例: create? アクションのテスト
permissions :create?, :new? do
let(:new_post) { Post.new } # クラスのインスタンスでテスト
it 'grants access if user is logged in' do
expect(subject).to permit(admin, new_post)
expect(subject).to permit(author, new_post)
expect(subject).to permit(other_user, new_post)
end
it 'denies access if user is guest' do
expect(subject).to forbid(guest, new_post)
end
end
# 他のアクション (show?, destroy?) も同様にテストする
end
permissions :action? do ... end
でアクションをグループ化できます。permit(user, resource)
は許可されることを、forbid(user, resource)
は拒否されることを検証します。context
で条件を分け、it
で期待する結果を記述します。
3. ポリシースコープ (Policy Scope) のテスト
Scope#resolve
が返すレコード群を検証します。
# spec/policies/post_policy_scope_spec.rb
require 'rails_helper'
RSpec.describe PostPolicy::Scope, type: :policy do
let(:admin) { create(:user, :admin) }
let(:user1) { create(:user) }
let(:guest) { nil }
let!(:admin_post) { create(:post, user: admin, published: true) }
let!(:user1_published) { create(:post, user: user1, published: true) }
let!(:user1_draft) { create(:post, user: user1, published: false) }
let(:scope) { Post.all }
context 'for admin user' do
it 'returns all posts' do
resolved_scope = described_class.new(admin, scope).resolve
expect(resolved_scope).to contain_exactly(admin_post, user1_published, user1_draft)
end
end
context 'for user1' do
it 'returns published posts and own draft posts' do
resolved_scope = described_class.new(user1, scope).resolve
expect(resolved_scope).to contain_exactly(admin_post, user1_published, user1_draft)
end
end
context 'for guest user' do
it 'returns only published posts' do
resolved_scope = described_class.new(guest, scope).resolve
expect(resolved_scope).to contain_exactly(admin_post, user1_published)
end
end
end
described_class.new(user, scope).resolve
で結果を取得します。contain_exactly
やinclude
/not_to include
で、期待されるレコードが含まれるか検証します。
pundit-matchersのメリット
- 可読性: テストが認可ルールの仕様のように読めます。
- 簡潔性: 定型的な記述が減り、テストの意図が明確になります。
Punditを使うなら、pundit-matchers
でテストの品質と保守性を高めましょう。
まとめ
Railsアプリケーションの認可ロジックは、PolicyオブジェクトパターンとPundit Gemを使うことで、クリーンでテストしやすく、保守性の高い形で実装できます。
コントローラーやモデルの肥大化、コードの重複、テストの困難さに悩んでいるなら、ぜひPunditの導入を検討してください。
初期コストはかかりますが、長期的な開発効率とコード品質の向上に繋がるはずです。