【Rails】Policyオブジェクト入門|認可ロジックを整理する

Ruby on Rails (以下、Rails) でWebアプリケーションを開発する際、ユーザー権限に基づいた操作制御、すなわち「認可(Authorization)」の実装は避けて通れません。しかし、この認可ロジックがコントローラーやモデルに散在すると、コードは複雑化し、メンテナンス性も低下しがちです。これは「Fat Controller」や「Fat Model」と呼ばれるアンチパターンにつながります。

この記事では、こうした課題を解決する Policyオブジェクト パターンと、その導入を容易にする定番Gem Pundit について、概念から具体的な実装、テスト方法までを、Rails初心者にも分かりやすく解説します。

目次

なぜ認可ロジックの分離が必要か? 問題点

まず、認可ロジックが他の箇所に混在する場合の問題点を確認しましょう。

  1. Fat Controller: アクション内に認可判定 (if user.admin? || ...) が増え、コントローラーが肥大化し、本来の責務から逸脱します。
  2. Fat Model: モデルに認可メソッド (can_edit?(user)) を持たせると、モデルがデータ関連以外の責務を負い、単一責任の原則に反しやすくなります。
  3. ロジックの重複: 同様の権限チェックが複数箇所で必要になると、コピペによる重複が発生し、修正漏れのリスクが高まります。
  4. テストの困難さ: 認可ロジックが他の処理と密結合していると、独立したテストが書きにくく、テストが複雑化します。

これらの問題を解決するのが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 (対象リソース) を使って判定し、truefalse を返します。
  • usernil (未ログイン) のケースも考慮しましょう (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. ポリシーメソッド (アクション権限) のテスト

permitforbid マッチャーで、許可/拒否を直感的にテストできます。

# 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_exactlyinclude/not_to include で、期待されるレコードが含まれるか検証します。

pundit-matchersのメリット

  • 可読性: テストが認可ルールの仕様のように読めます。
  • 簡潔性: 定型的な記述が減り、テストの意図が明確になります。

Punditを使うなら、pundit-matchers でテストの品質と保守性を高めましょう。

まとめ

Railsアプリケーションの認可ロジックは、PolicyオブジェクトパターンとPundit Gemを使うことで、クリーンでテストしやすく、保守性の高い形で実装できます。
コントローラーやモデルの肥大化、コードの重複、テストの困難さに悩んでいるなら、ぜひPunditの導入を検討してください。
初期コストはかかりますが、長期的な開発効率とコード品質の向上に繋がるはずです。

未経験からエンジニアへ転職!おすすめの転職サービスはこちら

「未経験だけどエンジニアになりたい…」「IT業界に興味があるけど、どこから始めるべきかわからない…」
そんな方におすすめなのが、プログラミングスクールを活用した転職活動です。
実績豊富なスクールを利用すれば、未経験からでもエンジニアとしての転職がぐっと近づきます!

この記事が気に入ったら
フォローしてね!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次