Phân quyền trong Rails sử dụng Pundit

I. Giới thiệu

Với mỗi ứng dụng web bất kì, phân quyền cho người dùng là việc làm không thể thiếu. Đối với các ứng dụng sử dụng ruby on rails, chúng ta có một số thư viện hỗ trợ việc phân quyền như cancancan, pundit, authorite... Những thư viện này sẽ giúp chúng ta tách hoàn toàn phần logic phân quyền ra khỏi controller hay model. Một trong những gem phân quyền được phát triển sớm và phổ biến nhất là Cancancan hay trước đây là Cancan. Đối với Cancancan toàn bộ những logic phân quyền sẽ được cho vào class có tên Ability. Tuy nhiên việc tất cả các logic phân quyền tập trung chỉ trong một class Ability sẽ làm cho file này phình to lên nhanh chóng. Một điểm không thực sự tốt nữa ở Cancancan là role của user sẽ cần được đánh giá qua từng request từ đó làm chậm đáng kể thời gian phản hồi. Gần đây, trong dự án mới mình có dịp tìm hiểu và dùng thử một gem phân quyền khác là Pundit và nhận ra nó có khá nhiều ưu điểm so với Cancancan. Hôm nay mình xin giới thiệu với các bạn về Pundit.

II. Sự khác biệt của Pundit với Cancancan

Pundit ra đời sau tuy nhiên đã phát triển nhanh chóng và có khá nhiều những ưu điểm so với Cancancan. Đầu tiên các bạn có thể xem so sánh tổng quan giữa Pundit và Cancancan tại đây.

Pundit sử gọn nhẹ hơn nhiều so với Cancancan.

Pundit sử dụng cú pháp DSL (domain specific language) đơn giản hơn so với Cancancan giúp nó thực sự dễ tìm hiểu và áp dụng.

Ngoài ra, như đã nói ở trên, đối với Cancancan toàn bộ logic về phân quyền sẽ được lưu trong file app/models/ability.rb. Còn với Pundit mỗi model bạn sẽ có một class ruby tương ứng đảm nhiệm việc phân quyền của người dùng cho model ấy. Và trong mỗi class policy ấy sẽ có các hàm tương ứng dành cho từng action của controller. Với cá nhân mình cách chia nhỏ như vậy của Pundit giúp chúng ta dễ lắm bắt và kiểm soát hơn nhiều.

III. Cài đặt và sử dụng Pundit

Chúng ta sẽ bắt đầu bằng một ứng dụng đơn giản gồm 2 model chính là User và Post như hình:

Screen Shot 2016-09-27 at 1.54.58 AM.png

1. Cài đặt Pundit

Thêm vào Gemfile

gem "pundit"

Include Pundit trong application_controller

class ApplicationController < ActionController::Base
  include Pundit
  protect_from_forgery
end

Tiếp theo chúng ta sẽ tạo một application policy và các policy cho từng model sau đó sẽ kế thừa từ application policy này

rails g pundit:install

Sau khi chạy câu lệnh generate chúng ta sẽ có một thư mục app/policies/. Ở đây sẽ chứa toàn bộ các class phân quyền Policy. Còn đây là file app/policies/application_policy.rb được sinh ra

class ApplicationPolicy
  attr_reader :user, :record

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

  def index?
    false
  end

  def show?
    scope.where(:id => record.id).exists?
  end

  def create?
    false
  end

  def new?
    create?
  end

  # [...]
  # some stuff omitted

  class Scope
    # [...]
  end
end

Đến đây ta có thể thấy:

  • Các class Policy được đặt tên dưới dạng ModelNamePolicy.
  • Tham số đầu tiên là user. Trong controller, Pundit sẽ sử dụng curent_user để truyền vào.
  • Tham số thứ 2 sẽ là một model object mà chúng ta cần phải check quyền.
  • Policy class đã implements các phương thức update?, new?... Các phương thức này sẽ tương ứng với các action của controller

2. Phân quyền cho model

Giờ là lúc chúng ta bắt đầu phân quyền cho model Post. Giả sử chỉ có user với role là admin được quyền xoá post, ta sẽ có: policies/post_policy.rb


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

Tương tự với các method khác của class policy, method destroy? ở đây cũng trả về true hoặc false để xác định quyền của user với model.

Và với định nghĩa như trên, ở controller ta sẽ check quyền bằng phương thức `

authorize

post_controller.rb`


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

Nếu muốn check quyền khi ở một action không giống với policy action thì ta có thể truyền trực tiếp tên của action policy cần được check:


def publish
  authorize @post, :update?
end

Ở đây hàm authorized có thể diễn đạt đầy đủ thành:


raise "not authorized" unless PostPolicy.new(current_user, @post).update?

Khi user không có quyền thực hiện action, chúng ta cần xử lí exception của Pundit trong application_controller application_controller.rb


[...]
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

private

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

config/locales/en.yml


en:
 pundit:
   default: 'You cannot perform this action.'

Sau khi sử lí ở controller, ta cần kiểm tra cả ở view để ẩn hiện các link phù hợp với từng action. views/posts/index.html.erb


<% if policy(post).destroy? %>
  <td><%= link_to 'Destroy', post, method: :delete, data: { confirm: 'Are you sure?' } %></td>
<% end %>

3. Sử dụng Pundit với scope

Đôi khi bạn cần hiển thị một list các record tương ứng với một user nhất định nào đấy. Và Pundit cung cấp tính năng scope cho phép bạn thực hiện việc này dễ dàng.


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
end

Giờ bạn có thể dùng class này ở controller thông qua method policy_scope post_controller.rb


def index
  @posts = policy_scope(Post)
end

views/posts/index.html.erb


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

III. Kết luận

Trên đây mình đã giới thiệu với các bạn về gem Pundit, một sự lựa chọn đáng cân nhắc nếu bạn đang tìm kiếm giải pháp phân quyền cho ứng dụng Rails của mình.

Nguồn tham khảo

https://github.com/elabs/pundit https://www.sitepoint.com/straightforward-rails-authorization-with-pundit/