0

Gem Pundit

Ứng dụng web liên quan đến quản lý người dùng có hai phần, đó là xác thực và ủy quyền. Khi bạn xây dựng một ứng dụng rails với nhiều vai trò của người dùng, điều quan trọng là phân cấp người dùng của bạn. Có 2 biện pháp hiện đang được sử dụng rộng rãi là: Cancancan và Pundit. Nhưng trong bài viết này tôi sẽ giới thiệu cho bạn gem pundit một gem để quản lý vai trò người dùng. Khi có nhu cầu hạn chế quyền truy cập vào ứng dụng của bạn đối với một số người dùng nhất định, ủy quyền dựa trên vai trò sẽ có tác dụng. Pundit giúp chúng ta xác định các chính sách là PORC - Plain Old Ruby Classes - có nghĩa là lớp này không kế thừa từ các lớp khác cũng như không bao gồm trong các mô-đun khác từ framework

Pundit cung cấp một bộ helpers hướng dẫn bạn sử dụng các class Ruby thông thường và các mẫu thiết kế hướng đối tượng để xây dụng một hệ thống ủy quyền đơn giản , mạnh mẽ và có thể mở rộng.

Cách bạn làm việc với Pundit

  • Đầu tiên tạo class Policy liên quan đến việc cho phép truy cập vào một bản ghi cụ thể
  • Sau đó gọi hàm ủy quyền tích hợp, chuyển qua những gì bạn đang cố gắng ủy quyền truy cập
  • Pundit sẽ tìm class Policy phù hợp và method Policy khớp với tên của method bạn đang ủy quyền. Nếu nó trả về đúng, bạn có quyền thực hiện action, còn không sẽ raise ra một exception.

Cài đặt

Thêm vào gem file sau đó chạy bundle install

gem "pundit"

include Pundit vào trong application controller:

class ApplicationController < ActionController::Base
  include Pundit
end

Sau đó có thể chạy generator, trình tạo sẽ thiết lập chính sách ứng dụng với một số giá trị mặc định hữu ích

rails g pundit:install

Sau khi tạo chính sách ứng dụng của bạn, khởi động lại server để Rails có thể chọn bất kỳ class nào trong thư mục app/policies/ directory .

Policies

Pundit tập trung vào các khái niệm về policy classes. Nên đưa các class vào app/policies . Đây là một ví dụ đơn giản cho phép cập nhật bài đăng nếu người dùng là quả trị viên hoặc nếu bài đăng chưa được xuất bản :

class PostPolicy
  attr_reader :user, :post

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

  def update?
    user.admin? || !post.published?
  end
end

Như bạn có thể thấy, đây chỉ là một lớp Ruby đơn giản. Pundit đưa ra các giả định sau về class này :

  • Class có cùng tên với một số loại model class, chỉ có hậu tố là 'Policy'
  • Đối số đầu tiên là người dùng . Trong controller, Pundit sẽ gọi method current_user để truy xuất những gì cần gửi vào đối số này.
  • Đối số thứ 2 là một loại mô hình đối tượng mà bạn muốn kiếm trả ủy quyền. Điều này không cần phải là một ActiveRecord hoặc thậm chí là một đối tượng ActiveModel
  • Class thực hiện một số loại phương thức truy vấn trong trường hợp này là update?. Thông thường, điều này sẽ ánh xạ đến tên của một hành động bộ điều khiển cụ thể.
 class PostPolicy < ApplicationPolicy
  def update?
    user.admin? or not record.published?
  end
End

Trong ApplicationPolicy đã tạo, model object được gọi là record Giả sử rằng bạn có một phiên bản của class Post, Pundit giờ đây cho phép bạn thực hiện việc này trong controller của mình

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

Phương thức ủy quyền tự động suy ra rằng Post sẽ có một class PostPolicy và khởi tạo class này đưa vào người dùng hiện tại và bản ghi đã cho. Sau đó , nó suy ra từ tên action, nó được gọi là update? trong trường hợp này của policy. Trong trường hợp này, bạn có thể tưởng tượng rằng authorize sẽ thực hiện một cái gì đó như thế này:

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

Bạn có thể chuyển đối số thứ 2 để ủy quyền nếu tên của quyền bạn muốn kiểm tra không khớp mới tên action


def publish
  @post = Post.find(params[:id])
  authorize @post, :update?
  @post.publish!
  redirect_to @post
end

Bạn có thể chuyển một đối số để ghi đè class Policy nếu cần.

def create
  @publication = find_publication # assume this method returns any model that behaves like a publication
  # @publication.class => Post
  authorize @publication, policy_class: PublicationPolicy
  @publication.publish!
  redirect_to @publication
end

Nếu bạn không có một thể hiện cho đối số đầu tiên để ủy quyền, thì bạn có thể chuyển lớp :

Policy:

class PostPolicy < ApplicationPolicy
  def admin_list?
    user.admin?
  end
end

Controller:

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

authorize trả về instance chuyển cho nó vì vậy bạn có thể chain nó như sau:

Controller:

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

# return the record even for namespaced policies
def show
  @user = authorize [:admin, User.find(params[:id])]
End

Bạn có thể dễ dàng nắm giữ một phiên bản policy thông qua cá method policy trong cả view và controller. Điều này đặc biệt hữu ích để có thể hiển thị các điều kiện các liên kết hoặc button trong chế độ xem:

<% if policy(@post).update? %>
  <%= link_to "Edit post", edit_post_path(@post) %>
<% end %>

Headless policies

do có một policy không có model/ruby class , bạn có thể truy suất nó bằng cách chuyển 1 symbol

# app/policies/dashboard_policy.rb
class DashboardPolicy < Struct.new(:user, :dashboard)
  # ...
end

Lưu ý rằng chính sách không đầu vẫn cần chấp nhận hai đối số. Đối số thứ 2 chỉ là biểu tượng dashboard trong trường hợp này là những gì được chuyển dưới dạng bản ghi để authorize

# In controllers
authorize :dashboard, :show?
# In views
<% if policy(:dashboard).show? %>
  <%= link_to 'Dashboard', dashboard_path %>
<% end %>

Scopes

Thông thường bạn sẽ mong muốn có một số loại bản ghi danh sách dạng xem mà một người dùng cụ thể có quyền truy cập. Khi sử dụng Pundit, bạn phải xác định một lớp gọi là policy scope. Nó có thể trông giống như sau:

class PostPolicy < ApplicationPolicy
  class Scope
    def initialize(user, scope)
      @user  = user
      @scope = scope
    end

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

    private

    attr_reader :user, :scope
  end

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

Pundit đưa ra các giả định sau về class này :

  • Class có tên là scope và được lồng trong lớp policy
  • Đối số đầu tiên là người dùng. Trong controller, Pundit sẽ gọi phương thức current_user để truy xuất những gì cần gửi vào đối số này.
  • Đối số thứ hai là một scope của một số loại để thực hiện một số truy vấn. Nó thường sẽ là một class ActiveRecord hoặc một ActiveRecord::Relation.
  • Các phiên bản của class này phản hồi với một method resolve, sẽ trả về một số loại kết quả có thể lặp lại. Đối với các lớp ActiveRecord, đây sẽ thường là một ActiveRecord::Relation. Bạn có thể muốn kế thừa từ policy scope ứng dụng được tạo bởi trình tạo hoặc tạo lớp cơ sở của riêng bạn để kế thừa :
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 record.published?
  end
end

Bây giờ bạn có thể sử dụng lớp này từ bộ điều khiển của mình thông qua phương thức policy_scope

def index
  @posts = policy_scope(Post)
end

def show
  @post = policy_scope(Post).find(params[:id])
end

Giống như một phương thức ủy quyền, bạn cũng có thể ghi đè lớp phạm vi chính sách

def index
  # publication_class => Post
  @publications = policy_scope(publication_class, policy_scope_class: PublicationPolicy::Scope)
end

Trong trường hợp này, nó là một phím tắt để thực hiện:

def index
  @publications = PublicationPolicy::Scope.new(current_user, Post).resolve
end

Bạn có thể và được khuyến khích sử dụng phương pháp này trong view

<% policy_scope(@user.posts).each do |post| %>
  <p><%= link_to post.title, post_path(post) %></p>
<% end %>

Đảm bảo Policies và Scope được sử dụng

Khi bạn đang phát triển một ứng dụng với Pundit, bạn có thể dễ dàng quên cho phép một số hành động. Vì Pundit khuyến khích bạn thêm lệnh gọi ủy quyền theo cách thủ công vào mỗi hành động của bộ điều khiển, nên bạn rất dễ bỏ lỡ một lệnh gọi Rất may, Pundit có một tính năng tiện dụng nhắc nhở bạn trong trường hợp bạn quên. Pundit theo dõi xem bạn có quyền ở bất kì đâu trong các action của controller Pundit cũng thêm một phương pháp vào controller của bạn được gọi là verify_authorized. Phương thức này sẽ đưa ra một ngoại lệ nếu authorize chưa được gọi. Bạn nên chạy phương thức này trong một after_action_hook để đảm bảo rằng bạn không quên ủy quyền cho action. Ví dụ

class ApplicationController < ActionController::Base
  include Pundit
  after_action :verify_authorized
end

Tương tự như vậy, Pundit cũng thêm verify_policy_scoped vào controller của bạn. Điều này sẽ đưa ra một ngoại lệ tương tự như verify_authorized. Tuy nhiên, nó sẽ theo dõi nếu policy_scope được sử dụng thay vì authorize. Diều này hữu ích cho các action của controller như là index để tìm các bộ sưu tập với một scope và không ủy quyền cho các cá thể riêng lẻ.

class ApplicationController < ActionController::Base
  include Pundit
  after_action :verify_authorized, except: :index
  after_action :verify_policy_scoped, only: :index
end

Cơ chế xác minh này chỉ tồn tại để hỗ trợ bạn trong khi phát triển ứng dụng của mình, vì vậy bạn đừng quên gọi authorize. Nó không phải là một số loại cơ chế an toàn dự phòng hoặc cơ chế ủy quyền. Bạn có thể xóa bộ lọc này mà không ảnh hưởng đến cách ứng dụng của bạn hoạt động.

Pundit sẽ hoạt động tốt mà không cần sử dụng đến verify_authorized và verify_policy_scoped.

Xác minh điều kiện

Nếu bạn đang sử dụng verify_authorized trong controller của mình nhưng cần bỏ qua xác minh có điều kiện , bạn có thể sử dụng skip_authorization. Để bỏ qua verify_policy_scoped, hãy sử dụng skip_policy_scope. Những điều này hữu ích trong những trường hợp bạn không muốn tắt xác minh cho toàn bộ action, nhưng có một số trường hợp không cho phép

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

Chỉ định thủ công các Policy Class

Đôi khi bạn có thể muốn khai báo rõ ràng chính sách nào sẽ sử dụng cho một lớp nhất định, thay vì để Pundit suy luận nó. Điều này có thể được thực hiện như vậy :


class Post
  def self.policy_class
    PostablePolicy
  end
end

Ngoài ra, bạn có thể khai báo một instance method:

class Post
  def policy_class
    PostablePolicy
  end
end

Closed systems

Trong nhiều ứng dụng chỉ những người dùng thực sự đã đăng nhập mới thực sự có thể làm bất cứ điều gì. Nếu bạn đang xây dựng một hệ thống như vậy , việc kiểm tra một người dùng trong policy có phải là nil không. Ngoài các policy, bạn có thể kiểm tra thêm dựa vào scope. Nên xác định một bộ lọc chuyển hướng người dùng chưa được xác thực đến trang đăng nhập. Nếu bạn đã xác định ApplicationPolicy, bạn có thể raise ra một ngoại lệ nếu bằng cách nào đó một người dùng chưa được xác thực vượt qua được .

class ApplicationPolicy
  def initialize(user, record)
    raise Pundit::NotAuthorizedError, "must be logged in" unless user
    @user   = user
    @record = record
  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
  end
end

NilClassPolicy

Để hỗ trợ một đối tượng null, bạn muốn triển khai một NilClassPolicy. Điều này có thể hữu ích khi bạn muốn mở rộng ApplicationPolicy của mình để cho phép dung sai. Ví dụ , associations là nil

class NilClassPolicy < ApplicationPolicy
  class Scope < Scope
    def resolve
      raise Pundit::NotDefinedError, "Cannot scope NilClass"
    end
  end

  def show?
    false # Nobody can see nothing
  end
end

Rescuing a denied Authorization in Rails

Pundit raise một Pundit::NotAuthorizedError bạn có thể rescue_from trong ApplicationController. Bạn có thể tùy chỉnh method user_not_authorized trong mọi controller

class ApplicationController < ActionController::Base
  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

Ngoài ra bạn có thể xử lý toàn cục Pundit::NotAuthorizedError's bằng cách xử lý chúng dưới dạng lỗi 403 và cung cấp trang lỗi 403. Thêm phần sau vào application.rb

config.action_dispatch.rescue_responses["Pundit::NotAuthorizedError"] = :forbidden

Tùy chỉnh error messages

Creating custom error messages NotAuthorizedErrors cung cấp thông tin về truy vấn nào (vd: create?). Bản ghi nào (instance of Post) và chính sách nào (instance của PostPolicy) đã gây ra lỗi Một cách để sử dụng các thuộc tính query, record và policy là kết nối chúng với I18n để tạo thông báo lỗi. Đây là cách bạn có thể làm điều đó

class ApplicationController < ActionController::Base
 rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

 private

 def user_not_authorized(exception)
   policy_name = exception.policy.class.to_s.underscore

   flash[:error] = t "#{policy_name}.#{exception.query}", scope: "pundit", default: :default
   redirect_to(request.referrer || root_path)
 end
end
en:
 pundit:
   default: 'You cannot perform this action.'
   post_policy:
     update?: 'You cannot edit this post!'
     create?: 'You cannot create posts!'

Nhiều error messages cho mỗi policy action

Nếu có nhiều lý do khiến ủy quyền bị từ chối, bạn có thể hiển thị các thông báo khác nhau bằng cách raise ra các exception trong policy :

Trong class policy raise Pundit::NotAuthorizedError với thông báo lỗi tùy chỉnh hoặc I18n

class ProjectPolicy < ApplicationPolicy
  def create?
    if user.has_paid_subscription?
      if user.project_limit_reached?
        raise Pundit::NotAuthorizedError, reason: 'user.project_limit_reached'
      else
        true
      end
    else
      raise Pundit::NotAuthorizedError, reason: 'user.paid_subscription_required'
    end
  end
end

Sau đó, bạn có thể nhận được thông báo lỗi này trong exception handler:

rescue_from Pundit::NotAuthorizedError do |e|
  message = e.reason ? I18n.t("pundit.errors.#{e.reason}") : e.message
  flash[:error] = message, scope: "pundit", default: :default
  redirect_to(request.referrer || root_path)
end
en:
  pundit:
    errors:
      user:
        paid_subscription_required: 'Paid subscription is required'
        project_limit_reached: 'Project limit is reached'

Tùy chỉnh Pundit user

Trong một số trường hợp controller của bạn có thể không có quyền truy cập vào current_user, hoặc current_user của bạn không phải là phương thức mà Pundit nện gọi. Đơn giản chỉ cần xác định một method trong controller được gọi là pundit_user

def pundit_user
  User.find_by_other_means
end

Policy Namespacing

Trong một số trường hợp nếu có nhiều policy phục vụ các ngữ cảnh khác nhau cho một resource. Một ví dụ điển hình về điều này là trường hợp User policy khác với Admin policy . Để ủy quyền với namespaced policy , chuyển namespace vào một helper authorize trong một mảng :

authorize(post)                   # => sẽ tìm kiếm một PostPolicy
authorize([:admin, post])         # =>sẽ tìm kiếm một Admin::PostPolicy
authorize([:foo, :bar, post])     # => sẽ tìm kiếm một  Foo::Bar::PostPolicy

policy_scope(Post)                # => sẽ tìm kiếm một  PostPolicy::Scope
policy_scope([:admin, Post])      # => sẽ tìm kiếm một Admin::PostPolicy::Scope
policy_scope([:foo, :bar, Post])  # => sẽ tìm kiếm một  Foo::Bar::PostPolicy::Scope

Nếu bạn đang sử dụng namespace policy cho một số view như Admin view. Có thể hữu ích khi ghi đè policy_scope và cho phép những authorized helper trong AdminController của bạn để tự động áp dụng namespacing :

class AdminController < ApplicationController
  def policy_scope(scope)
    super([:admin, scope])
  end

  def authorize(record, query = nil)
    super([:admin, record], query)
  end
end

class Admin::PostController < AdminController
  def index
    policy_scope(Post)
  end

  def show
    post = authorize Post.find(params[:id])
  end
end

Rspec

Policy Specs

Pundit bao gồm một DSL nhỏ để viết các bài kiểm tra diễn đạt cho các chính sách của bạn trong RSpec. Require pundit / rspec trong spec_helper.rb của bạn:

require "pundit/rspec"

Sau đó đưa policy spec vào spec/policies, và làm như sau :

describe PostPolicy do
  subject { described_class }

  permissions :update?, :edit? do
    it "denies access if post is published" do
      expect(subject).not_to permit(User.new(admin: false), Post.new(published: true))
    end

    it "grants access if post is published and user is an admin" do
      expect(subject).to permit(User.new(admin: true), Post.new(published: true))
    end

    it "grants access if post is unpublished" do
      expect(subject).to permit(User.new(admin: false), Post.new(published: false))
    end
  end
end

Scope Specs

Pundit không cung cấp DSL để test scope . Chỉ cần kiểm tra nó như một class Ruby thông thường

Kết luận

Mong rằng vói bài viết này bạn có thể hiểu những điều cơ bản và cách sử dụng đợn giản nhất về gem Pundit

Tham khảo

https://crypt.codemancers.com/posts/2018-07-29-leveraging-pundit/ https://itzone.com.vn/vi/article/management-role-in-rails-app-with-gem-pundit/ https://github.com/varvet/pundit#creating-custom-error-messages


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí