Yêu cầu phổ biến của ứng dụng web là khả năng gán roles và permissions cụ thể.
Nhiều loại ứng dụng web phân biệt admin và user thường trong cung cấp truy cập hạn chế. Thường thực hiện bằng boolean đơn giản xác định user có phải admin. Tuy nhiên, roles và permissions có thể phức tạp hơn nhiều.
Giá trị ứng dụng nằm ở việc hạn chế truy cập dữ liệu và hành động. Chắc chắn không muốn làm sai. Bài này giải thích cách implement roles và permissions trong ứng dụng Ruby on Rails cơ bản.
Có cần gem quản lý permissions?
Không, không cần gem, đặc biệt nếu app nhỏ và muốn tránh thêm dependency. Tuy nhiên, nếu tìm alternative, đây là các gem phổ biến xử lý roles và permissions:
Devise: Gem authentication và roles management, giải pháp phức tạp và robust. Với 21.7k stars GitHub, repo phổ biến nhất bài này, nhưng làm nhiều hơn roles management. Được biết như authentication solution, chỉ áp dụng nếu cần library rất robust.
Pundit: Gem dùng simple Ruby objects, có lẽ policy gem đơn giản nhất chúng ta cover. Dễ dùng, authorization tối thiểu, giống pure Ruby. Với 7.3k stars GitHub, hiện là policy gem phổ biến nhất.
CanCan: Authorization library hạn chế resource user được truy cập. Tuy nhiên, CanCan bị abandon nhiều năm và chỉ chạy Rails 3 và trước.
CanCanCan: Authorization library khác cho Ruby và Rails. Là alternative cho CanCan và hiện được maintain. Với 4.9k stars GitHub, ít phổ biến nhất, nhưng chạy tốt và maintain tốt.
Tất cả gem này tuyệt vời, nhưng không quá khó tự build permissions bằng plain Ruby. Tôi sẽ chỉ cách quản lý permissions không gem, dùng strategy gọi là policy object pattern.
Policy Object Pattern
Policy Object là design pattern xử lý permissions và roles. Dùng mỗi khi phải check xem gì hoặc ai được phép thực hiện hành động. Nó encapsulates business rules phức tạp và dễ replace bằng policy objects khác với rules khác. Tất cả external dependencies được inject vào policy object, encapsulate logic kiểm tra permission, kết quả controller và model sạch. Gem như Pundit, Cancan, Cancancan implement pattern này.
Pure policy object rules
- Return phải là boolean value
- Logic phải đơn giản
- Bên trong method, chỉ gọi methods trên objects được truyền vào
Triển khai
Bắt đầu với naming convention; filename có suffix _policy và class và policy ở cuối. Trong method, tên luôn kết thúc bằng ? (ví dụ: UsersPolicy#allowed?).
Ví dụ code:
class UsersPolicy
def initialize(user)
@user = user
end
def allowed?
admin? || editor?
end
def editor?
@user.where(editor: true)
end
def admin?
@user.where(admin: true)
end
end
Trong scenario nào nên dùng?
Khi app có nhiều loại restricted access và restricted actions. Ví dụ, posts có thể tạo với:
- ít nhất một tag,
- hạn chế chỉ admin và editor tạo, và
- yêu cầu editor cần verified.
Ví dụ controller không policy object:
class PostsController < ApplicationController
def create
if @post.tag_ids.size > 0 && (current_user.role == 'admin' || (current_user.role == 'editor' && current_user.verified_email))
# create
end
end
end
Vì điều kiện check dài, xấu, khó đọc, nên áp dụng policy object pattern.
Bắt đầu tạo PostsCreationPolicy.
class PostsCreationPolicy
attr_reader :user, :post
def initialize(user, post)
@user = user
@post = post
end
def self.create?(user, post)
new(user, post).create?
end
def create?
with_tags? && author_is_allowed?
end
private
def with_tags?
post.tag_ids.size > 0
end
def author_is_allowed?
is_admin? || editor_is_verified?
end
def is_admin?
user.role == 'admin'
end
def editor_is_verified?
user.role == 'editor' && user.verified_email
end
end
Controller với policy object trông như:
class PostsController < ApplicationController
def create
if PostsCreationPolicy.create?(current_user, @post)
# create
end
end
end
Cách dùng policy objects trong Rails
Tạo thư mục policy trong app /policies và đặt tất cả policy classes đó. Khi cần gọi controller, làm trực tiếp trong action hoặc dùng before_action:
class PostsController < ApplicationController
before_action :authorized?, only: [:edit, :create, :update, :destroy]
def authorized?
unless ::PostsCreationPolicy.create?(current_user, @post)
render :file => "public/404.html", :status => :unauthorized
end
end
end
Cách test policy objects
Dễ dàng test behavior trong controller:
require 'rails_helper'
RSpec.describe "/posts", type: :request do
describe "when user is not allowed" do
let(:user_not_allowed) { create(:user, admin: false, editor: false) }
let(:tag) { create(:tag) }
let(:valid_attributes) { attributes_for(:post, tag_id: tag.id) }
before do
sign_in user_not_allowed
end
describe "GET /index" do
it "return code 401" do
diet = Post.create! valid_attributes
get edit_post_url(post)
expect(response).to have_http_status(401)
end
end
end
end
Test policy cũng đơn giản; có nhiều method nhỏ chỉ một trách nhiệm.
require 'rails_helper'
RSpec.describe PostsCreationPolicy do
describe "when user is not allowed" do
let(:user) { create(:user, editor: false, admin: false) }
let(:user_editor) { create(:user, editor: true, email: verified) }
let(:tag) { create(:tag) }
let(:post) { create(:post, tag_id: tag.id) }
describe ".create?" do
context "when user is allowed" do
it "creates a new post" do
expect(described_class.create?(user_editor, post)).to eq(true)
end
end
context "when user is not allowed" do
it "does not create a new post" do
expected(described_class.create?(user, post)).to eq(false)
end
end
end
# ...more test cases
end
end
Test xem object có được phép tạo trong mọi scenario.
Kết luận
Khái niệm policy pattern nhỏ nhưng tạo kết quả lớn. Cân nhắc áp dụng policy object mỗi khi xử lý permissions đơn giản hoặc phức tạp. Khi test với RSpec, không cần database records; policies là pure Ruby objects, test đơn giản và nhanh.