Common Requirement ของ Web Apps: Assign Specific Roles และ Permissions
หลาย Web Apps แยก Admins และ Regular Users ด้วย Simple Boolean (is_admin?) แต่ Roles/Permissions อาจ Complex มากขึ้น
Value ของ App อยู่ที่ Restrict Access Data/Actions อย่างแน่นอน อย่า Mess Up บทความนี้อธิบาย Implement Roles/Permissions ใน Basic Rails App
Do I need a gem? (ต้องใช้ Gem ไหม?)
ไม่จำเป็น หาก App เล็กอยากหลีกเลี่ยง Dependencies แต่ Alternatives มี Gems ยอดนิยม:
- Devise: Authentication + Roles Management Complex/Robust 21.7k Stars GitHub Most Popular แต่ทำมากกว่า Roles ถ้าต้องการ Robust Library
- Pundit: Simple Ruby Objects Simplest Policy Gem 7.3k Stars Most Popular Policy Gem Minimal Authorization ใกล้ Pure Ruby
- CanCan: Authorization Library Restrict Resources แต่ Abandoned Years ใช้ได้ Rails 3 เท่านั้น
- CanCanCan: Alternative CanCan Maintained 4.9k Stars Least Popular แต่ Work Well Maintained
Gems ทั้งหมดดี แต่ Build Permissions เองด้วย Plain Ruby ไมยาก ใช้ Strategy Policy Object Pattern
Policy Object Pattern
Design Pattern จัดการ Permissions/Roles ใช้ทุกครั้งที่ Check Allowed Action Encapsulate Complex Business Rules Replaceable ด้วย Policy Objects อื่นๆ Rules ต่าง External Dependencies Injected -> Clean Controller/Model Gems (Pundit, CanCan, CanCanCan) Implement Pattern นี้
Pure Policy Object Rules
- Return = Boolean
- Logic = Simple
- Inside Method เรียก Methods บน Passed Objects เท่านั้น
Implementation (การนำไปใช้)
Naming Convention: Filename Suffix _policy Class Name Suffix Policy Method Names End with ? (e.g. UsersPolicy#allowed?)
Example:
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
When to Use? (สถานการณ์ไหนควรใช้)
App มี Multiple Restricted Access/Actions เช่น Posts Create ต้อง:
- At least 1 Tag
- Only Admins/Editors Create
- Editors ต้อง Verified
Controller โดยไม่มี Policy Object (Long, Ugly, Unreadable):
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
Apply Policy Object Pattern สร้าง 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 ด้วย Policy Object (Clean):
class PostsController < ApplicationController
def create
if PostsCreationPolicy.create?(current_user, @post)
# create
end
end
end
How to Use in Rails
สร้าง Directory app/policies ใส่ Policy Classes เรียกใน Controller โดยตรง หรือ 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
How to Test Policy Objects
Test Controller Behavior ง่าย:
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
post = Post.create! valid_attributes
get edit_post_url(post)
expect(response).to have_http_status(401)
end
end
end
end
Test Policy เองง่าย เพราะ Small Methods Single Responsibility:
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
expect(described_class.create?(user, post)).to eq(false)
end
end
end
end
end
Test ว่า Object Allowed Create ในทุก Scenario
Conclusion (สรุป)
Policy Pattern Concept เล็กแต่ผลใหญ่ Apply Policy Object ทุกครั้งที่ Deal Permissions (Simple/Complex) Testing RSpec ไม่ต้อง Database Records Policies = Pure Ruby Objects Testing Simple/Fast