2017/11/28

Ruby - 使用 Pundit

Pundit 是一個用來做身分驗證的工具。對於每一個 Model 來說,目前登入者能不能對這個 Model 進行某種操作,可以被定義在 Policy 上。

Pundit 並不是一個複雜的 gem,但仍然很多人使用,我認為他存在的價值跟 rails 一樣,都是在提出一個收納的概念,教你如何存放你的 code 到正確的位置。

Pundit:我認為所有跟登入者權限相關的東西都應該被儲存在同一個 class(Policy),同一個資料夾下(Policy),我創造了一個架構,使得所有按照我架構的寫法的 code 可以少寫一些字,並且讓專案的 code 看起來更乾淨。

如果你想要檢查登入者(user) 有沒有辦法對資料(record) 進行某種操作 (update?),可以這樣寫:

class PostPolicy < ApplicationPolicy
  def update?
    user.admin? or not record.published?
  end
end

下面的 code 跟 上面的 code 是等價的

class PostPolicy
  attr_reader :user, :post

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

  def update?
    user.admin? or not post.published?
  end
end

當你在 Controller 的 action 裡面寫到 authorize model 時,Pundit 會幫你看有沒有跟 action 同名的 Policy。透過 currentuser 跟 model 這兩個值去做檢查。 注意:Devise 剛好會生成一個方法叫做是 currentuser。

舉例來說,這個 action:

def update
  @post = Post.find(params[:id])
  authorize @post
  if @post.update(post_params)
    redirect_to @post
  else
    render :edit
  end
end

執行 authorize @post 時,等於去執行以下程式:

  unless PostPolicy.new(current_user, @post).update?
    raise Pundit::NotAuthorizedError, "not allowed to update? this #{@post.inspect}"
  end

你也可以自己指定要執行的 policy 方法名稱

authorize @post :update?

如果 policy 不需要 record 變數就可以做,則 record 可以不傳,改傳 record 的 Class

# in controller
def admin_list
  authorize Post # we don't have a particular post to authorize
  # Rest of controller action
end

# in policy
class PostPolicy < ApplicationPolicy
  def admin_list?
    user.admin?
  end
end

authorize 會回傳傳入的參數,所以可以在 authorize 的時候一邊指定要儲存到哪個變數。

# in controller
def show
  @user = authorize User.find(params[:id])
end

policy method 可以讓你取得 policy 物件

policy(@post)

等於

PostPolicy.new(current_user, @post)

Pundit 不只對 model 可以加 policy,也可以對 symbol 加 policy。

# in policy
class DashboardPolicy < Struct.new(:user, :dashboard)
  # ...
end

#in controller
authorize :dashboard, :show?

# In views
<% if policy(:dashboard).show? %>

Pundit 對於 Scope 也有處理:

class PostPolicy < ApplicationPolicy
  class Scope
    attr_reader :user, :scope

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

    def resolve
      if user.admin?
        scope.all
      else
        scope.where(published: true)
      end
    end
  end

  def update?
    user.admin? or not post.published?
  end
end

可以簡寫為

class PostPolicy < ApplicationPolicy
  class Scope < Scope
    def resolve
      if user.admin?
        scope.all
      else
        scope.where(published: true)
      end
    end
  end

  def update?
    user.admin? or not post.published?
  end
end

當你定義好 scope 之後,可以這樣去使用它:

def index
  @posts = policy_scope(Post)
end

policy_scope 傳入的參數會是一個可以下 query 的物件,然後會執行相當於以下程式:

def index
  @posts = PostPolicy::Scope.new(current_user, Post).resolve
end

如果你不想忘記寫 authorize,那你可以在 ApplicationController 加入下面的程式:

after_action :verify_authorized, except: :index
after_action :verify_policy_scoped, only: :index

這樣的話就會在 controller action 中沒有呼叫 authorize 或 policyscope 的時候跳出錯誤。 如果真的不需要驗證,那你可以在 action 中寫入 skipauthorization。

def show
  record = Record.find_by(attribute: "value")
  if record.present?
    authorize record
  else
    skip_authorization
  end
end

你可以指定 Model 對應的 Policy 名稱

class Post
  def self.policy_class
    PostablePolicy
  end
end

pundit 也有提供 generator

# in bash:
rails g pundit:policy post

你可以做一個 ApplicationPolicy ,讓他被繼承到每一個 Policy, 使得所有的 Policy 都會去檢查是否使用者有登入

class ApplicationPolicy
  def initialize(user, record)
    raise Pundit::NotAuthorizedError, "must be logged in" unless user
    @user   = user
    @record = record
  end
end

然後你就可以在 ApplicationController 用 rescue_from 去接他。

class ApplicationController < ActionController::Base
  protect_from_forgery
  include Pundit

  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

  private

  def user_not_authorized
    flash[:alert] = "You are not authorized to perform this action."
    redirect_to(request.referrer || root_path)
  end
end

或者在你的 config/application.rb 去接他 config.actiondispatch.rescueresponses["Pundit::NotAuthorizedError"] = :forbidden

pundit 也可以用來處理 params

# in policy
class PostPolicy < ApplicationPolicy
  def permitted_attributes
    if user.admin? || user.owner_of?(post)
      [:title, :body, :tag_list]
    else
      [:tag_list]
    end
  end
end

# in controller
def update
  @post = Post.find(params[:id])
  if @post.update_attributes(permitted_attributes(@post))
    redirect_to @post
  else
    render :edit
  end
end

你可以針對每個不同的 action 提供不同的 permitted_attributes

class PostPolicy < ApplicationPolicy
  def permitted_attributes_for_create
    [:title, :body]
  end

  def permitted_attributes_for_edit
    [:body]
  end
end

沒有留言: