022.Phân quyền Rails với Pundit.

Thach
Written by Thach on
022.Phân quyền Rails với Pundit.

Cũng là một gem dùng để phân quyền cho Rails, sinh sau đẻ muộn hơn Cancancan. Nhưng hiện tại được Pundit có nhiều :star2: github hơn.

1. Setup bài toán đơn giản

Bài toán như sau, chúng ta có 2 loại user là writer, editor.

  • Writer có thể create, edit, update và delete các bài post của chính mình. Ngoài ra thì còn có thể xem các bài post của các writer khác.
  • Editor thì có thể edit, update, view và delete bất cứ bài post nào khi chưa publish, nhưng không thể tự tạo bài post mới.
# add role cho bảng users
class AddRoleToUser < ActiveRecord::Migration[7.0]
  def change
    add_column :users, :role, :integer, default: 0
  end
end
# add role vào model
class User < ApplicationRecord
  # previous code ....
  # ...........

  enum role: %i[writer editor]
  after_initialize :set_default_role, if: :new_record?
  # set default role to user if not set
  def set_default_role
    self.role ||= :writer
  end
end

2. Set quyền cho user

gem 'pundit'
bundle install
bundle exec rails g pundit:install #lệnh này sẽ tạo ra file app/policies/application_policy.rb

Bước tiếp theo là thêm module Pundit::Authorization vào trong application_controller.rb

# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  include Pundit::Authorization
end

Và giờ tới phần hay nhất, chúng ta sẽ tạo một file policy cho mỗi model tài nguyên tương ứng, ở đây là post.

bundle exec rails g pundit:policy post

Các bạn sẽ nhận được một file như thế này.

# app/policies/post_policy.rb

class PostPolicy < ApplicationPolicy
  class Scope < Scope
    # NOTE: Be explicit about which records you allow access to!
    # def resolve
    #   scope.all
    # end
  end
end

Với bài toán ban đầu, chúng ta sẽ set quyền sơ bộ cho writer như sau.

# app/policies/post_policy.rb

class PostPolicy < ApplicationPolicy
  ...
  def create?
    @user.writer? # a writer is able to create a post
  end

  def edit?
    @user.writer? # a writer is able to edit a post
  end

  def update?
    @user.writer? # a writer can update a post
  end

  def delete?
    @user.writer? # a writer can delete a post
  end
end

Cũng giống như Cancancan, policy này sẽ chưa hoạt động nếu chưa được gọi trong controller.

# app/controllers/post_controller.rb
class PostsController < ApplicationController
  # ....
  def create
    @post = current_user.posts.new(post_params)
    authorize @post

    respond_to do |format|
        if @post.save
        format.html { redirect_to post_url(@post), notice: 'Post was successfully created.' }
        format.json { render :show, status: :created, location: @post }
        else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @post.errors, status: :unprocessable_entity }
        end
    end
  end

  def publish
    authorize @post, :update?
  end
end

Nếu mọi chuyện suôn sẻ thì nếu đăng nhập với tài khoản writer lúc này, các bạn có thể tạo post mới. Còn nếu đăng nhập với tài khoản editor thì sẽ gặp lỗi dưới đây.

Pundit default error

3. Scope

Trong bài toán ban đầu, writter có toàn quyền với post của mình, còn những quyền của editor thì chỉ giới hạn với những post chưa publish mà thôi. Đây là lúc áp dụng scope của Pundit, nó cũng giống với với scope của ActiveRecord.

# app/policies/post_policy.rb

class PostPolicy < ApplicationPolicy
  class Scope < Scope
    def resolve
      if user.editor?
        # an editor can only access posts in "draft" status
        scope.where(published: false)
      else
        # can access a post if they are the author
        scope.where(user: user)
      end
    end
  end
  def show?
    @user.writer? || @user.editor?
  end
  def create?
    @user.writer?
  end
  def edit?
    @user.writer? || @user.editor?
  end
  def update?
    @user.writer? || @user.editor?
  end
  def delete?
    @user.writer?
  end
end
# app/controllers/posts_controller.rb

class PostsController < ApplicationController
  # ...
  def index
    @posts = policy_scope(Post)
  end
  # GET /posts/1 or /posts/1.json
  def show
    @post = policy_scope(Post).find(params[:id])
  end
end

Vẫn còn một điểm chưa được giải quyết trong bài toán ban đầu, đó là writer toàn quyền với các post của mình, và view được các post của người khác. Mình chưa biết làm sao để có được nhiều hơn một scope cho một user với một model. Cái này đành chờ đội phát triển người ta làm vậy, xem thêm tại đây

4. Customize error message

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include Pundit::Authorization

  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

  private

  def user_not_authorized
    flash[:alert] = "You are not authorized to perform this action."
    redirect_back(fallback_location: root_path)
  end
end

5. Kiểm tra quyền của user

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

6. Using Pundit with Rails’ Strong Parameters

Đội phát triển của Pundit xem chừng cũng khá có tâm nghĩ cho người dùng nên mới đẻ ra cái tính năng này, đó là khả năng phân quyền tới cấp độ thuộc tính của model. Và họ làm việc đó qua params. Giả như trường hợp bạn muốn editor có thể thay đổi các trường title, bodysummary còn writer thì không thì các bạn có thể làm như sau:

# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
  # ...

  def permitted_attributes
    if user.editor?
      [:title, :body, :summary]
    else
      [:title, :body]
    end
  end
end

Và gọi ra ở controller như sau:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  # ...

  private

  def post_params
    params.require(:post).permit(policy(@post).permitted_attributes)
  end
end

Nghĩ tới cảnh phải define post_params, update_params … trong từng controller, thì đội phát triển có cho phép chúng ta khai báo params trong policy.

# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
  def permitted_attributes_for_create
    [:title, :body]
  end

  def permitted_attributes_for_edit
    [:body]
  end
end

7. Customize error message

Cũng giống như Cancancan, hàm authorize của Pundit sẽ mặc định nhận current_user, và để tùy biến lại, các bạn có thể dùng hàm pundit_user trong controller.

def pundit_user
  User.find_by_other_means
end

Hoặc là rescue sẵn cho trường hợp không tìm thấy current_user.

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

8. Tổng kết

Có thể thấy Pundit có những tính năng tương tự với Cancancan, ngoài ra thì việc tổ chức file có chút ưu điểm hơn khi Cancancan mang mọi thứ vào trong một file Ability, còn Pundit thì tách thành nhiều file Policy để dễ quản lý, cũng như thuận tiện trong trường hợp logic trở nên phức tạp.

Bài viết có tham khảo từ Appsignal.

Comments

comments powered by Disqus