PunditとRolifyをrails6に導入してみた
PunditとRolifyをrails6に導入した時のまとめを書きます。
前提
2つとも導入は終わっている
導入は下記を参考
ルートとかバリデーションとかも省略してます
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ってどんな動きしてるんだろうってところ。
ここは正直めちゃめちゃ曖昧な理解です。今の理解は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貼っとこう、という自分の理解。
理解曖昧なので、詳しい方はぜひ教えてください。