PunditとRolifyをrails6に導入してみた

PunditRolifyをrails6に導入した時のまとめを書きます。

前提

2つとも導入は終わっている

導入は下記を参考

PunditRolify

ルートとかバリデーションとかも省略してます

pundit

認可の方をやってくれる。ユーザーがその処理を行う権限をそのアクションに対して持っているのか?と言う部分をauthorizeで簡単に実装出来るようになる。current_userの定義は必須。

rolify

ユーザーに対するrole(役割を与えたり、管理出来る)。adminとかemployeeとかspecialistとか色々な役割を与える事ができる。1人のユーザーに複数持たせることも可能。中間テーブルが出来る。

モデルが無いコントローラーのアクションに対しても認可をかける時

『いいねー、出来るんだー』とか言いながら意外とハマったので書く。

class MysController < ApplicationController
 def index
   @users = User.all
   authorize :my, :index?
 end

 def show
   @user = User.find(params[:id])
   authorize :my, :show?
 end
end

authorize :my, :index?の部分について。authorizeはいつもどーり。:myはポリシーファイル(○○_policy.rb)の○○の部分。:index?はポリシーファイルのどのクエリに当てるかを指定。

2つあって1つ目はシンプル。ApplicationPolicyを継承すれば作れる

class MyPolicy < ApplicationPolicy
中身は省略
end

2つ目

MyPolicy = Struct.new(:user, :my) do
 attr_reader :user, :my

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

 def index?
   user.has_any_role? :manager, :admin
 end

 def show?
   user.has_role? :admin
 end
end

MyPolicy = Struct.new(:user, :my) doはこちらを参考に。若干コードが違うのはrubocopで自動修正入ってます。

:userはcurrent_userでこれがないと誰を認可したいのかわからなくなる。

:myはなんでも良い。コントローラーの:myと繋がってる。current_userがいるから:myは第2引数扱いになる。

attr_reader :user, :myは詳しくわからんけど、policyの中で使いたいやつかなと。描かないと下記のエラーが出たりする。

Pundit::InvalidConstructorError (Invalid #<MyPolicy> constructor is called):

initializeはそのままで初期値の設定。

has_any_role?が結構便利で、adminかmanagerだったらtrueにしたい時とかに使える。単体の場合はhas_role?でOK。

ちなみにrole(役割)ってどうやって与えてんの!ってところだけど、今はコンソールでユーザーにadd_roleメソッドとか使って付与してる。これユーザーの管理ページとかで調整したいな。

class ApplicationController < ActionController::Base
 include Pundit               追加

 helper_method :current_user            必要

 rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized   例外拾うよ

 private

 def user_not_authorized
   flash[:alert] = '権限がありません'
   redirect_to(request.referrer || root_path)
 end

 def current_user
   @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
 end

end

applicationコントローラーの記述。各記述大事なので忘れずに。flashメッセージの表示についてはこちらを参考

PunditのScope機能

punditのscope機能。何がどう楽になるんだろう?これ使う前のイメージ。

使ってみた後。結構楽出来る〜良い、ただauthorizeとの重複とかログインしていないユーザーに対してとかは気おつけないと。

簡単な使い方

基本的にScopeの中しか気にしなくて良いです。

今回処理する内容は、ログインしていないユーザーは弾いて、managerだったら自分の投稿のみで、adminだったら全て表示出来るといった感じです。

class PostPolicy
 attr_reader :user, :post

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

 class Scope
   attr_reader :user, :scope

   def initialize(user, scope)
     raise Pundit::NotAuthorizedError, 'must be logged in' unless user

     @user = user
     @scope = scope
   end

   def resolve
     if user.has_any_role? :manager
       scope.where(user_id: user.id)
     elsif user.has_role? :admin
       scope.all
     else
       scope.where(id: 3)
     end
     
     # 下記みたいな感じだと、二行だと、エラーが出て書けなかった。片方1行だけだったらいける
     # scope.where(user_id: user.id) if user.has_any_role? :manager
     # scope.all if user.has_role? :admin
     
   end
 end

 def show?
   user.has_any_role? :admin
 end
end

attr_readerも同じように使用する変数を用意。

initializeでインスタンス変数に代入するのと、ログインしてないユーザーを弾いている。例外処理はapplicationcontrollerで拾ってくれます。

scopeの返り値になるresolveはユーザーのroleによって返り値を決めています。

class PostsController < ApplicationController

 def index
   @posts = policy_scope(Post)
 end
end

コントローラーで呼び出す時。

policy_scopeを呼び出している。この返り値はresolveになります。モデルとか返してね〜って感じです。

Instances of this class respond to the method resolve, which should return some kind of result which can be iterated over. For ActiveRecord classes, this would usually be an ActiveRecord::Relation

引数には第2引数として扱われる値を代入している。この値はなんでも良いらしい。ちなみに第1引数にはcurrent_userです

The second argument is a scope of some kind on which to perform some kind of query. It will usually be an ActiveRecord class or a ActiveRecord::Relation, but it could be something else entirely

<% policy_scope(@posts).each do |post|%>
 <ul>
   <li><%=post.user.name%> | <%=post.title%><%= link_to '詳細', post_path(post)%></li>
 </ul>
<% end %>

ビュー側でscopeを呼び出す時。

この時コントローラーは普通に下記で取ってくる。authorizeとかも要らないかなと。

@posts = Post.all

そんな感じでscopeが動いてますよってところです。

モデル

nameカラムにpresence: trueとかでバリデーション。

class Role < ApplicationRecord
 has_and_belongs_to_many :users, join_table: :users_roles

 belongs_to :resource,
            polymorphic: true,
            optional: true

 validates :resource_type,
           inclusion: { in: Rolify.resource_types },
           allow_nil: true

 validates :name, presence: true    // 追記

 scopify
end

Rolify.resource_typesは何をしてる?

って感じ。

取り敢えずrole(役割)だけデータベースに設置したい時

add_roleでcreate,attachしてくれるような形でやってくれますが、まだユーザーには紐付けないけどroleは置いておきたいなって時

下記コマンドで作成出来ます。new,saveを使うとかもあると思うけど。

Role.create!(name: 'admin')

登録と編集でのrole追加、削除

ここでやるのはadminがmanagerとかemployeeのroleを変更したりするところです。あえて登録も実装します。

準備

Rolifyを入れてる人はUsersRolesっていうモデルファイルを用意しましょう。

中身は特に要らないかなと。

これでUsersRoles.allとか出来るようにしてます。中間テーブルのレコードを触れるように。。というところです。

class UsersRoles < ApplicationRecord
end
ファイル名 users_roles.rb

で、次はrails cでコンソールに入ってRoleのレコードを作成します。

Role.create!(name: "admin")
Role.create!(name: "employee")
Role.create!(name: "manager")

上は参考ですが、こんな感じで役割書いていきましょう。

Roll.allで中身確認出来たら次に進みましょう。

登録

<%= form_with( url: signup_path, method: :post, local: true) do |f| %>
 <div class="mb-4">
   <div class="mb-1">
     <%= f.label "名前", class: 'form-label' %>
     <%= f.text_field :name, class: 'form-control', value: session[:name] %>
   </div>

   <%= f.collection_check_boxes :roles, Role.all, :id, :name %>

   <div class="mb-1">
     <%= f.label "メールアドレス", class: 'form-label' %>
     <%= f.email_field :email, class: 'form-control mb-1', value: session[:email] %>
   </div>
   <div class="mb-1">
     <%= f.label "パスワード", class: 'form-label' %>
     <%= f.password_field :password, class: 'form-control mb-2', id: 'js-password' %>
   </div>
   <div class="mb-1">
     <%= f.label "確認パスワード", class: 'form-label' %>
     <%= f.password_field :password, class: 'form-control mb-2', id: 'js-password' %>
   </div>
   
   <div class="Registration__actions mb-4">
     <%= f.submit class: 'btn btn-primary', id: 'js-submit-button' %>
   </div>
 </div>
<%end%>

collection_check_boxesを使ってroleを選択できるようにする。rolesの名前で飛んでいくようにしてる。

def create
 user = User.new(reg_params)
 if user.save
   params[:roles].delete_if do |del_role|
     del_role == ''
   end
   params[:roles].each do |role|
     role_status = Role.find(role.to_i)
     v = role_status.name
     user.add_role v
   end

   session[:user_id] = user.id
   redirect_to '/'
 else
   redirect_to '/signup'
 end
end

delete_ifは配列の最初が””だったり求めている値以外を弾いてます。書かなかったらCouldn’t find Role with ‘id’=0的なエラーになるはず。

Role.find(role.to_i)で先ほど作成したroleの何を選択しているのかfindで取得しています。

user.add_role vでユーザーに役割を与えています。add_roleはrolifyのメソッドです。

ここまで出来たらログインできていると思います。コンソール等でUsersRoles.allなどしてみてレコードが出来ているか確認してみましょう。

編集

編集では登録時と大きく2つの違うポイントがあります。

view・・・collection_check_boxesで既にそのユーザーに付与されている役割をcheck: trueにする必要がある。

controller ・・・ 中間テーブルを使って追加、削除を行う。

<%= form_with( url: user_path, method: :put, local: true) do |f| %>
 <div class="mb-4">
   <div class="mb-1">
     <%= f.label "名前", class: 'form-label' %>
     <%= f.text_field :name, class: 'form-control', value: @user.name %>
   </div>

   <%= f.collection_check_boxes :roles, Role.all, :id, :name, { checked: @user.roles.map(&:to_param) } %>

   <div class="mb-1">
     <%= f.label "メールアドレス", class: 'form-label' %>
     <%= f.email_field :email, class: 'form-control mb-1', value: @user.email %>
   </div>
   
   <div class="Registration__actions mb-4">
     <%= f.submit '更新', class: 'btn btn-primary', id: 'js-submit-button' %>
   </div>
 </div>
<%end%>

{ checked: @user.roles.map(&:to_param) }で既に付与されているroleをtrueにしています。

def update
 @user.update!(update_params)

 before_cut = []
 @user.roles.map do |base_role|
   before_cut.push(base_role.id.to_s)
 end
 
 params[:roles].delete_if do |del_role|
   del_role == ''
 end
 params[:roles].each do |role|
   role_status = Role.find(role.to_i)
   v = role_status.name
   @user.add_role v
 end


 before_cut.difference(params[:roles]).map do |delete_role|
   delete_role_status = Role.find(delete_role.to_i)
   delete_record = UsersRoles.find_by(user_id: @user.id, role_id: delete_role_status)
   delete_record.delete
 end

 redirect_to user_path(@user)
end

before_cutで更新する前の役割をmapメソッドを用いて配列に格納しています。

上記の配列と新しく更新用に取得したparams[:roles]をdifferenceメソッドを用いて差分を出しています。詳しくはリンク先を。

Role.find(delete_role.to_i)で削除するroleを取得

UsersRoles.find_by(user_id: @user.id, role_id: delete_role_status)で中間テーブルのレコードを取得します。

@user.idの部分をcurrent_userとかにするとadminがemployeeとかを編集する際にワケワカメになるかもしれないので使っていません。そこまで来たら削除するだけですね。

なぜ削除の時は中間テーブルを?

ズバリ、Roleモデルのレコードを残すため。

rolifyにはremove_roleというメソッドが用意されているのですが、それを使用して削除してしまうとRoleモデルに用意したadminやemployee,なども一緒に消えてしまいます。

そうなると新しく登録する際にcheckboxにemployeeが無い!!なんてことになってしまいます。

それを防ぐため中間テーブルを使用しています。

更新が出来たら、再度コンソールなどでレコードの確認など行いましょう。

ただし

ユーザーに紐づいたRoleモデルが最後の1つの場合は、 手前にガード(if文とか)を置くなどして中間テーブルを使わずにremove_roleで良くない?っていう方法もあります。こっちの方がシンプルかなと。

まとめ

・rolifyはrole(役割を与えてくれる)、中間テーブルを上手く使えば登録や編集といった処理が可能。

・punditはユーザーがそのアクションを実行できるのか?の認可の処理を端としてくれる。if文などが散らばるのを防いでくれるので便利。ピュアルビー。scope機能が便利だった。

まだわからない部分はrolifyのマイグレーションファイルにあるresource_typeとかresource_id、モデルファイルのRolify.resource_typesとかがどんな動きをしているのか見えてない。

そんなところがモヤモヤしてます。

readme以外と読みやすい、特にpundit。(DeepLが便利すぎるだけですねw)

初見で書いていますので、間違いがあったら教えて下さい。

以上!

ちゃんと理解してないけど、今の理解を。間違ってたら教えてください(多分間違ってるかも?)

rolifyの中間テーブルのresource_typeとかの動き

マイグレーションファイルにある => :resource_type, :resource_id とかroleモデルにあるinclusion: { in: Rolify.resource_types },とかresourceってどんな動きしてるんだろうってところ。

docとかgitとか。

ここは正直めちゃめちゃ曖昧な理解です。今の理解はrolifyを使ってユーザー以外にもrolifyを適用させたい時に使うためのもの。

例えばユーザーモデルにはadminとかemployee, managerのroleを置くけど、itemモデルにもpublic, private, draftといったroleを置きたい時にresourceを使って分けて処理できるようにするんじゃないのかなと。

そんな時は下記のコマンド

user.add_role :admin
item.add_role :publish

で、itemモデルにresourcifyを追加する。

class Item < ActiveRecord
 resourcify       追記
end

ただこれ以上はうまく理解できてません。実際に触ってないのもある。

下記のコマンドでroleモデルとか中間テーブルのマイグレーションファイルが出来るんだけど、resorce_typeとかresorce_idとか何やってんだろうって感じ。

1マイグレーションファイルで2個テーブル作ってる。

ヨクワカラナイ。

rails g rolify Role User

マイグレーションファイル

下記がデフォルトで、これに追加した方が良いと思っているのはnameに対してのnull: false。

これはname(役割名)が空のレコードを作りたくないと言う考えのもと。

class RolifyCreateRoles < ActiveRecord::Migration[6.1]
 def change
   create_table(:roles) do |t|
     t.string :name
     t.references :resource, :polymorphic => true

     t.timestamps
   end

   create_table(:users_roles, :id => false) do |t|
     t.references :user
     t.references :role
   end
   
   add_index(:roles, :name)
   add_index(:roles, [ :name, :resource_type, :resource_id ])
   add_index(:users_roles, [ :user_id, :role_id ])
 end
end

rolifyのマイグレーション周り。中間テーブルが出来るけどresource_idとかどこと繋がってるんだろう?

polymorphicは理解曖昧です。ユーザーモデルの役割をもったroleモデルとして扱えるようにしている。

言い換えると1つのモデルに対して同じように扱えるroleモデルを用意していると言うこと。

create_table(:users_roles, id: false)はid: falseの部分で主キーを自動生成されないようにしている。docにはプライマリーキーの無いテーブルを作成って書いてある。

add_index(:roles, %i[name resource_type resource_id])はresource_type resource_idこの2つなんなん?って感じ。

add_indexについてはよく検索されるカラムに対してはindex貼っとこう、という自分の理解。

理解曖昧なので、詳しい方はぜひ教えてください。