如果你來自 Express.js 的世界,你可能習慣了在每個路由中間件裡手動檢查權限。在 Spring Boot 中,你會使用 @PreAuthorize
註解來宣告式地控制存取。Python FastAPI 則透過 Depends 和 Security 來注入權限檢查。今天我們要探討的是 Rails 如何用完全不同的思維——將授權邏輯封裝成可測試、可重用的政策物件。
昨天我們實作了 JWT 認證系統,解決了「你是誰」的問題。今天要解決的是「你能做什麼」——這個看似簡單,實則充滿挑戰的問題。在 LMS 系統中,一個使用者可能同時是某門課的學生、另一門課的助教、還是第三門課的講師。這種動態的、基於上下文的權限管理,正是今天要深入探討的核心。
這個知識點是構建 LMS 系統的關鍵基礎。沒有精確的權限控制,我們無法確保課程資料的安全、作業提交的公平、成績查看的隱私。更重要的是,良好的授權設計能讓系統擴展變得容易——當我們需要添加新角色(如訪客旁聽、企業培訓管理員)時,不需要改動既有的業務邏輯。
Rails 在授權這個議題上經歷了有趣的演進。早期的 Rails 應用傾向於在控制器中直接寫權限檢查:
# Rails 早期的做法 - 直接在控制器中檢查
class CoursesController < ApplicationController
def edit
@course = Course.find(params[:id])
# 權限邏輯散落在控制器中
unless current_user.admin? || @course.instructor == current_user
redirect_to root_path, alert: "你沒有權限編輯此課程"
end
end
end
這種做法的問題顯而易見:權限邏輯與業務邏輯混雜、難以測試、容易遺漏。隨著應用規模增長,維護成本急遽上升。
讓我們對比不同框架和 Rails 現代方案的授權策略:
框架/方案 | 設計理念 | 實作方式 | 優劣權衡 |
---|---|---|---|
Express + 手動檢查 | 完全控制 | 在路由或中間件中編寫檢查邏輯 | 靈活但容易重複和遺漏 |
Spring Security | 宣告式配置 | 使用註解和配置文件 | 強大但學習曲線陡峭 |
Django Guardian | 物件級權限 | 資料庫儲存權限關係 | 精細但可能有效能問題 |
Rails + Pundit | 政策物件模式 | 獨立的政策類別封裝權限邏輯 | 清晰、可測試、易維護 |
Rails + CanCanCan | 能力集中定義 | 單一檔案定義所有權限 | 簡單直觀但可能過於集中 |
在深入實作前,我們需要理解三種主要的授權模型:
RBAC(Role-Based Access Control)基於角色的存取控制:
ABAC(Attribute-Based Access Control)基於屬性的存取控制:
PBAC(Policy-Based Access Control)基於政策的存取控制:
首先,我們為 LMS 設計一個靈活的角色系統:
# app/models/role.rb
class Role < ApplicationRecord
# 角色是課程範圍內的,不是全域的
belongs_to :user
belongs_to :course
# 使用 enum 定義角色類型,方便查詢和理解
enum role_type: {
student: 0, # 學生:可以查看課程內容、提交作業
teaching_assistant: 1, # 助教:可以批改作業、管理討論區
instructor: 2, # 講師:完全控制課程
observer: 3 # 旁聽:只能查看,不能參與
}
# 確保同一使用者在同一課程只有一個角色
validates :user_id, uniqueness: { scope: :course_id }
# 角色權限映射 - 定義每個角色能做什麼
PERMISSIONS = {
student: %i[view_content submit_assignment join_discussion],
teaching_assistant: %i[view_content grade_assignment moderate_discussion view_analytics],
instructor: %i[manage_course manage_content manage_users view_analytics export_data],
observer: %i[view_content]
}.freeze
def can?(action)
PERMISSIONS[role_type.to_sym]&.include?(action)
end
end
# app/models/user.rb
class User < ApplicationRecord
has_many :roles, dependent: :destroy
has_many :courses, through: :roles
# 快速檢查使用者在特定課程的角色
def role_in_course(course)
roles.find_by(course: course)
end
# 檢查是否為系統管理員(全域角色)
def admin?
admin == true
end
# 取得使用者作為講師的所有課程
def teaching_courses
courses.joins(:roles).where(roles: { role_type: 'instructor' })
end
# 取得使用者作為學生的所有課程
def enrolled_courses
courses.joins(:roles).where(roles: { role_type: 'student' })
end
end
# app/models/course.rb
class Course < ApplicationRecord
has_many :roles, dependent: :destroy
has_many :users, through: :roles
# 取得特定角色的使用者
def instructors
users.joins(:roles).where(roles: { role_type: 'instructor' })
end
def students
users.joins(:roles).where(roles: { role_type: 'student' })
end
def teaching_assistants
users.joins(:roles).where(roles: { role_type: 'teaching_assistant' })
end
end
現在讓我們使用 Pundit 來實作政策物件:
# Gemfile
gem 'pundit'
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
include Pundit::Authorization
# 全域的授權失敗處理
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
private
def user_not_authorized(exception)
# 提供詳細的錯誤訊息幫助除錯(生產環境應該更謹慎)
policy_name = exception.policy.class.to_s.underscore
render json: {
error: '你沒有執行此操作的權限',
details: {
policy: policy_name,
action: exception.query,
message: exception.message
}
}, status: :forbidden
end
end
# app/policies/application_policy.rb
class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record)
@user = user
@record = record
end
# 預設都是拒絕 - 安全第一
def index?
false
end
def show?
false
end
def create?
false
end
def update?
false
end
def destroy?
false
end
# Scope 用於過濾使用者能看到的記錄
class Scope
def initialize(user, scope)
@user = user
@scope = scope
end
def resolve
raise NotImplementedError, "You must define #resolve in #{self.class}"
end
private
attr_reader :user, :scope
end
end
# app/policies/course_policy.rb
class CoursePolicy < ApplicationPolicy
# 誰可以查看課程列表
def index?
true # 所有登入使用者都可以瀏覽課程
end
# 誰可以查看課程詳情
def show?
# 公開課程所有人可看,私密課程需要是課程成員
record.public? || user_enrolled?
end
# 誰可以創建課程
def create?
# 只有被授權為講師的使用者可以創建課程
user.instructor_authorized? || user.admin?
end
# 誰可以更新課程
def update?
user_is_instructor? || user.admin?
end
# 誰可以刪除課程
def destroy?
user_is_instructor? && record.can_be_deleted? || user.admin?
end
# 自定義動作:發布課程
def publish?
user_is_instructor? && record.draft?
end
# 自定義動作:查看課程分析
def view_analytics?
user_is_instructor? || user_is_teaching_assistant?
end
# Scope:過濾使用者能看到的課程
class Scope < ApplicationPolicy::Scope
def resolve
if user.admin?
scope.all
else
# 使用者可以看到:公開課程 + 自己參與的課程
scope.left_joins(:roles)
.where('courses.public = ? OR roles.user_id = ?', true, user.id)
.distinct
end
end
end
private
def user_enrolled?
record.users.exists?(user.id)
end
def user_is_instructor?
role = user.role_in_course(record)
role&.instructor?
end
def user_is_teaching_assistant?
role = user.role_in_course(record)
role&.teaching_assistant?
end
end
LMS 中的資源經常是巢狀的(課程 > 章節 > 課時),我們需要處理這種階層權限:
# app/policies/lesson_policy.rb
class LessonPolicy < ApplicationPolicy
# 課時的權限繼承自課程
def show?
# 先檢查課程權限
return false unless Pundit.policy(user, record.chapter.course).show?
# 再檢查課時特定的條件
# 例如:某些課時需要完成前置課時才能查看
if record.has_prerequisites?
user_completed_prerequisites?
else
true
end
end
def update?
# 只有課程講師可以編輯課時
course = record.chapter.course
role = user.role_in_course(course)
role&.instructor?
end
# 學生可以標記課時為完成
def mark_as_complete?
course = record.chapter.course
role = user.role_in_course(course)
role&.student?
end
private
def user_completed_prerequisites?
record.prerequisites.all? do |prerequisite|
user.completed_lessons.exists?(prerequisite.id)
end
end
end
# app/policies/assignment_policy.rb
class AssignmentPolicy < ApplicationPolicy
def show?
# 課程成員可以查看作業
user_enrolled_in_course?
end
def submit?
# 只有學生可以提交作業
role = user.role_in_course(record.course)
role&.student? && record.accepting_submissions?
end
def grade?
# 講師和助教可以批改作業
role = user.role_in_course(record.course)
(role&.instructor? || role&.teaching_assistant?) && record.submitted?
end
# 查看提交記錄的權限依角色而定
def view_submissions?
role = user.role_in_course(record.course)
case role&.role_type
when 'student'
# 學生只能看自己的提交
false
when 'teaching_assistant', 'instructor'
# 助教和講師可以看所有提交
true
else
false
end
end
private
def user_enrolled_in_course?
record.course.users.exists?(user.id)
end
end
# app/controllers/api/v1/courses_controller.rb
module Api
module V1
class CoursesController < ApplicationController
before_action :authenticate_user! # 來自昨天的 JWT 認證
before_action :set_course, only: [:show, :update, :destroy, :publish, :analytics]
def index
# 使用 policy_scope 自動過濾使用者能看到的課程
@courses = policy_scope(Course)
.includes(:instructors, :roles)
.page(params[:page])
render json: @courses
end
def show
# authorize 會自動調用 CoursePolicy#show?
authorize @course
# 根據使用者角色返回不同詳細程度的資訊
render json: @course,
serializer: course_serializer_for_user,
include: serializer_includes
end
def create
@course = Course.new(course_params)
# 檢查使用者是否有創建課程的權限
authorize @course
if @course.save
# 自動將創建者設為講師
@course.roles.create!(
user: current_user,
role_type: 'instructor'
)
render json: @course, status: :created
else
render json: { errors: @course.errors.full_messages },
status: :unprocessable_entity
end
end
def update
authorize @course
if @course.update(course_params)
render json: @course
else
render json: { errors: @course.errors.full_messages },
status: :unprocessable_entity
end
end
def destroy
authorize @course
@course.destroy
head :no_content
end
# 自定義動作:發布課程
def publish
authorize @course, :publish?
if @course.publish!
render json: { message: '課程已成功發布', course: @course }
else
render json: { errors: @course.errors.full_messages },
status: :unprocessable_entity
end
end
# 自定義動作:查看分析
def analytics
authorize @course, :view_analytics?
analytics_data = CourseAnalyticsService.new(@course).generate
render json: analytics_data
end
private
def set_course
@course = Course.find(params[:id])
end
def course_params
# 根據使用者角色允許不同的參數
if current_user.admin?
params.require(:course).permit!
else
params.require(:course).permit(
:title, :description, :syllabus, :public,
:start_date, :end_date, :enrollment_limit
)
end
end
def course_serializer_for_user
role = current_user.role_in_course(@course)
case role&.role_type
when 'instructor'
CourseInstructorSerializer
when 'teaching_assistant'
CourseTeachingAssistantSerializer
when 'student'
CourseStudentSerializer
else
CoursePublicSerializer
end
end
def serializer_includes
role = current_user.role_in_course(@course)
case role&.role_type
when 'instructor'
['chapters.lessons', 'students', 'assignments', 'analytics']
when 'teaching_assistant'
['chapters.lessons', 'students', 'assignments']
when 'student'
['chapters.lessons', 'assignments.user_submission']
else
['chapters.lessons']
end
end
end
end
end
# app/controllers/api/v1/assignments_controller.rb
module Api
module V1
class AssignmentsController < ApplicationController
before_action :authenticate_user!
before_action :set_course
before_action :set_assignment, only: [:show, :submit, :submissions]
def show
authorize @assignment
# 學生看到自己的提交狀態,講師看到統計資訊
render json: @assignment,
serializer: assignment_serializer_for_user
end
def submit
authorize @assignment, :submit?
submission = @assignment.submissions.build(
user: current_user,
content: params[:content],
submitted_at: Time.current
)
if submission.save
# 觸發自動批改(如果是選擇題)
AutoGradeJob.perform_later(submission) if @assignment.auto_gradable?
render json: submission, status: :created
else
render json: { errors: submission.errors.full_messages },
status: :unprocessable_entity
end
end
def submissions
# 檢查是否有查看所有提交的權限
authorize @assignment, :view_submissions?
@submissions = @assignment.submissions
.includes(:user, :grades)
.page(params[:page])
render json: @submissions
end
private
def set_course
@course = Course.find(params[:course_id])
end
def set_assignment
@assignment = @course.assignments.find(params[:id])
end
def assignment_serializer_for_user
role = current_user.role_in_course(@course)
case role&.role_type
when 'instructor', 'teaching_assistant'
AssignmentInstructorSerializer
when 'student'
AssignmentStudentSerializer
else
AssignmentPublicSerializer
end
end
end
end
end
# app/policies/exam_policy.rb
class ExamPolicy < ApplicationPolicy
def take?
# 學生只能在考試時間內參加考試
return false unless user_is_student?
# 檢查時間窗口
now = Time.current
return false unless now.between?(record.start_time, record.end_time)
# 檢查是否已經參加過
return false if user.exam_attempts.exists?(exam: record)
# 檢查是否有特殊安排(如延長時間)
special_arrangement = record.special_arrangements.find_by(user: user)
if special_arrangement
return now.between?(
special_arrangement.start_time,
special_arrangement.end_time
)
end
true
end
def review?
# 考試結束後才能查看
return false unless Time.current > record.end_time
# 學生只能查看自己的答案
if user_is_student?
user.exam_attempts.exists?(exam: record)
else
# 講師和助教可以查看所有答案
user_is_instructor? || user_is_teaching_assistant?
end
end
private
def user_is_student?
role = user.role_in_course(record.course)
role&.student?
end
def user_is_instructor?
role = user.role_in_course(record.course)
role&.instructor?
end
def user_is_teaching_assistant?
role = user.role_in_course(record.course)
role&.teaching_assistant?
end
end
# app/models/permission_delegation.rb
class PermissionDelegation < ApplicationRecord
belongs_to :delegator, class_name: 'User'
belongs_to :delegate, class_name: 'User'
belongs_to :course
# 使用 JSONB 儲存委派的具體權限
# permissions: ['grade_assignments', 'moderate_discussions']
validates :delegator_id, uniqueness: {
scope: [:delegate_id, :course_id]
}
validate :delegator_must_be_instructor
validate :expiry_date_in_future
scope :active, -> { where('expiry_date > ?', Time.current) }
private
def delegator_must_be_instructor
unless delegator.role_in_course(course)&.instructor?
errors.add(:delegator, '必須是課程講師才能委派權限')
end
end
def expiry_date_in_future
if expiry_date.present? && expiry_date <= Time.current
errors.add(:expiry_date, '必須是未來的日期')
end
end
end
# 修改政策以支援委派權限
class CoursePolicy < ApplicationPolicy
def grade_assignments?
# 原本的權限檢查
return true if user_is_instructor? || user_is_teaching_assistant?
# 檢查是否有委派權限
has_delegated_permission?('grade_assignments')
end
private
def has_delegated_permission?(permission)
PermissionDelegation
.active
.where(delegate: user, course: record)
.where('permissions @> ?', [permission].to_json)
.exists?
end
end
# app/models/concerns/cacheable_permissions.rb
module CacheablePermissions
extend ActiveSupport::Concern
included do
# 當角色變更時清除快取
after_save :clear_permission_cache
after_destroy :clear_permission_cache
end
def can_perform?(action, resource)
cache_key = "permissions/#{user_id}/#{resource.class.name}/#{resource.id}/#{action}"
Rails.cache.fetch(cache_key, expires_in: 1.hour) do
policy = Pundit.policy(user, resource)
policy.public_send("#{action}?")
end
end
private
def clear_permission_cache
# 清除相關的權限快取
Rails.cache.delete_matched("permissions/#{user_id}/*")
end
end
# app/controllers/concerns/efficient_authorization.rb
module EfficientAuthorization
extend ActiveSupport::Concern
included do
# 批量預載入權限檢查所需的關聯
def preload_authorization_data
return unless current_user
# 預載入使用者的所有角色
current_user.roles.includes(:course).load
# 預載入活躍的權限委派
current_user.received_delegations
.active
.includes(:course)
.load
end
end
# 批量授權檢查
def authorize_collection(records, action = nil)
action ||= "#{action_name}?"
records.select do |record|
policy = Pundit.policy(current_user, record)
policy.public_send(action)
end
end
end
# app/models/course.rb
class Course < ApplicationRecord
# 使用 scope 在資料庫層級過濾
scope :visible_to, ->(user) {
if user.admin?
all
else
left_joins(:roles)
.where(
'courses.public = :is_public OR roles.user_id = :user_id',
is_public: true,
user_id: user.id
)
.distinct
end
}
scope :manageable_by, ->(user) {
if user.admin?
all
else
joins(:roles)
.where(
roles: {
user_id: user.id,
role_type: ['instructor', 'teaching_assistant']
}
)
.distinct
end
}
end
# 在控制器中使用
class CoursesController < ApplicationController
def index
# 直接在資料庫層級過濾,避免 N+1 查詢
@courses = Course.visible_to(current_user)
.includes(:instructors, :categories)
.page(params[:page])
end
def manageable
@courses = Course.manageable_by(current_user)
.includes(:students, :assignments)
.page(params[:page])
end
end
# spec/policies/course_policy_spec.rb
require 'rails_helper'
RSpec.describe CoursePolicy do
subject { described_class.new(user, course) }
let(:course) { create(:course) }
context '當使用者是學生時' do
let(:user) { create(:user) }
let!(:role) { create(:role, user: user, course: course, role_type: 'student') }
it { is_expected.to permit_action(:show) }
it { is_expected.to forbid_action(:update) }
it { is_expected.to forbid_action(:destroy) }
it { is_expected.to forbid_action(:publish) }
it { is_expected.to forbid_action(:view_analytics) }
end
context '當使用者是助教時' do
let(:user) { create(:user) }
let!(:role) { create(:role, user: user, course: course, role_type: 'teaching_assistant') }
it { is_expected.to permit_action(:show) }
it { is_expected.to forbid_action(:update) }
it { is_expected.to forbid_action(:destroy) }
it { is_expected.to permit_action(:view_analytics) }
end
context '當使用者是講師時' do
let(:user) { create(:user) }
let!(:role) { create(:role, user: user, course: course, role_type: 'instructor') }
it { is_expected.to permit_actions([:show, :update, :view_analytics]) }
context '當課程可以刪除時' do
before { allow(course).to receive(:can_be_deleted?).and_return(true) }
it { is_expected.to permit_action(:destroy) }
end
context '當課程是草稿狀態時' do
before { allow(course).to receive(:draft?).and_return(true) }
it { is_expected.to permit_action(:publish) }
end
end
describe '範圍過濾' do
let(:user) { create(:user) }
let!(:public_course) { create(:course, public: true) }
let!(:private_course) { create(:course, public: false) }
let!(:enrolled_course) { create(:course, public: false) }
let!(:enrollment) { create(:role, user: user, course: enrolled_course) }
it '返回使用者可見的課程' do
scope = CoursePolicy::Scope.new(user, Course).resolve
expect(scope).to include(public_course, enrolled_course)
expect(scope).not_to include(private_course)
end
end
end
# spec/requests/api/v1/courses_spec.rb
require 'rails_helper'
RSpec.describe 'Courses API', type: :request do
let(:instructor) { create(:user) }
let(:student) { create(:user) }
let(:course) { create(:course) }
before do
create(:role, user: instructor, course: course, role_type: 'instructor')
create(:role, user: student, course: course, role_type: 'student')
end
describe 'PUT /api/v1/courses/:id' do
context '當使用者是講師時' do
before { sign_in(instructor) } # 假設有 sign_in helper
it '允許更新課程' do
put "/api/v1/courses/#{course.id}",
params: { course: { title: '新標題' } }
expect(response).to have_http_status(:ok)
expect(course.reload.title).to eq('新標題')
end
end
context '當使用者是學生時' do
before { sign_in(student) }
it '拒絕更新課程' do
put "/api/v1/courses/#{course.id}",
params: { course: { title: '新標題' } }
expect(response).to have_http_status(:forbidden)
expect(response.body).to include('沒有執行此操作的權限')
end
end
end
end
練習目標: 實作一個課程討論區的完整權限系統
讓我們一起建立一個真實的討論區系統。這個練習會幫助你理解如何在實際場景中應用今天學到的授權概念。討論區是 LMS 中最複雜的權限場景之一,因為它涉及多種角色、動態內容和隱私考量。
需求詳解:
討論區需要支援以下功能,每個功能都有特定的權限要求:
解答與實作指南:
首先,我們建立資料模型。注意這裡的設計決策:我們使用 anonymous
欄位來標記匿名文章,而不是清空作者資訊,這樣可以保留追溯能力:
# app/models/discussion_post.rb
class DiscussionPost < ApplicationRecord
belongs_to :course
belongs_to :author, class_name: 'User'
belongs_to :parent, class_name: 'DiscussionPost', optional: true
has_many :replies, class_name: 'DiscussionPost', foreign_key: 'parent_id'
# 使用 enum 管理文章狀態,這比多個布林欄位更清晰
enum status: {
active: 0,
locked: 1, # 鎖定:可看不可回
hidden: 2, # 隱藏:只有作者和管理員可見
deleted: 3 # 軟刪除
}
# 這些 scope 讓我們能輕鬆過濾不同狀態的文章
scope :visible, -> { where.not(status: [:hidden, :deleted]) }
scope :pinned_first, -> { order(pinned: :desc, created_at: :desc) }
# 判斷是否還在可編輯時間窗口內
def editable_by_author?
created_at > 30.minutes.ago && active?
end
# 匿名顯示的作者名稱
def display_author_name(viewer)
return author.name if !anonymous?
# 只有講師和助教能看穿匿名
role = viewer.role_in_course(course)
if role&.instructor? || role&.teaching_assistant?
"#{author.name} (匿名發文)"
else
"匿名同學 ##{anonymous_identifier}"
end
end
private
# 為每個匿名文章生成一個會話內的固定標識
def anonymous_identifier
# 使用文章 ID 的雜湊值生成一個看起來隨機但固定的數字
Digest::SHA256.hexdigest("#{id}-#{course_id}")[0..5].to_i(16) % 10000
end
end
接下來是關鍵的政策物件。注意我們如何處理不同角色的權限差異,以及時間相關的權限邏輯:
# app/policies/discussion_post_policy.rb
class DiscussionPostPolicy < ApplicationPolicy
# 查看權限:課程成員都可以看,但要過濾隱藏的內容
def show?
return false unless user_enrolled_in_course?
# 隱藏的文章只有作者和管理員能看
if record.hidden?
is_author? || is_course_staff?
else
true
end
end
# 創建權限:所有課程成員都可以發文
def create?
user_enrolled_in_course? && course_allows_discussion?
end
# 更新權限:這裡的邏輯最複雜
def update?
return false unless user_enrolled_in_course?
# 管理員隨時可以編輯
return true if is_instructor?
# 助教可以編輯元資料(置頂、鎖定)但不能改內容
return true if is_teaching_assistant? && !content_changed?
# 作者只能在時間窗口內編輯自己的文章
is_author? && record.editable_by_author?
end
# 刪除權限:注意這是軟刪除
def destroy?
# 講師可以刪除任何文章
return true if is_instructor?
# 助教可以刪除違規內容
return true if is_teaching_assistant?
# 作者可以刪除自己的文章(時間窗口內)
is_author? && record.editable_by_author?
end
# 置頂權限:只有課程管理員
def pin?
is_instructor? || is_teaching_assistant?
end
# 鎖定權限:防止進一步回覆
def lock?
is_instructor? || is_teaching_assistant?
end
# 查看真實作者身份
def reveal_author?
is_instructor? || is_teaching_assistant?
end
# 回覆權限:要考慮文章是否被鎖定
def reply?
return false unless user_enrolled_in_course?
return false if record.locked?
# 課程可能暫時關閉討論功能
course_allows_discussion?
end
# Scope:根據角色過濾可見的文章
class Scope < ApplicationPolicy::Scope
def resolve
role = user.role_in_course(scope.first&.course)
if role&.instructor? || role&.teaching_assistant?
# 管理員看到所有文章,包括隱藏的
scope.all
elsif role
# 一般成員看到公開文章和自己的文章
scope.where(status: [:active, :locked])
.or(scope.where(author: user))
else
# 非課程成員看不到任何文章
scope.none
end
end
end
private
def user_enrolled_in_course?
@enrolled ||= record.course.users.exists?(user.id)
end
def is_author?
record.author_id == user.id
end
def is_instructor?
@role ||= user.role_in_course(record.course)
@role&.instructor?
end
def is_teaching_assistant?
@role ||= user.role_in_course(record.course)
@role&.teaching_assistant?
end
def is_course_staff?
is_instructor? || is_teaching_assistant?
end
def course_allows_discussion?
# 課程可能暫時關閉討論功能(如考試期間)
record.course.discussion_enabled?
end
def content_changed?
# 檢查是否嘗試修改文章內容(而非只是元資料)
return false unless record.changed?
(record.changed_attributes.keys & ['title', 'content', 'anonymous']).any?
end
end
現在實作控制器,注意我們如何優雅地處理不同的權限場景:
# app/controllers/api/v1/discussion_posts_controller.rb
module Api
module V1
class DiscussionPostsController < ApplicationController
before_action :authenticate_user!
before_action :set_course
before_action :set_post, only: [:show, :update, :destroy, :pin, :lock, :reply]
def index
# 使用 policy_scope 自動過濾可見文章
@posts = policy_scope(@course.discussion_posts)
.includes(:author, :replies)
.pinned_first
.page(params[:page])
# 根據權限決定是否顯示作者真實身份
render json: @posts,
each_serializer: DiscussionPostSerializer,
current_user: current_user
end
def create
@post = @course.discussion_posts.build(post_params)
@post.author = current_user
authorize @post
if @post.save
# 如果是回覆,通知原作者
notify_author_of_reply if @post.parent_id.present?
render json: @post,
serializer: DiscussionPostSerializer,
current_user: current_user,
status: :created
else
render json: { errors: @post.errors.full_messages },
status: :unprocessable_entity
end
end
def update
authorize @post
# 助教只能更新元資料
permitted_params = if policy(@post).is_course_staff? && !policy(@post).is_instructor?
post_metadata_params
else
post_params
end
if @post.update(permitted_params)
render json: @post,
serializer: DiscussionPostSerializer,
current_user: current_user
else
render json: { errors: @post.errors.full_messages },
status: :unprocessable_entity
end
end
def destroy
authorize @post
# 軟刪除而非真的刪除,保留審計記錄
@post.update!(
status: 'deleted',
deleted_by: current_user,
deleted_at: Time.current
)
render json: { message: '文章已刪除' }
end
def pin
authorize @post, :pin?
# 同一時間只能有有限數量的置頂文章
if @post.pinned?
@post.update!(pinned: false)
message = '已取消置頂'
else
# 如果超過置頂上限,取消最舊的置頂
if @course.discussion_posts.where(pinned: true).count >= 3
oldest_pinned = @course.discussion_posts
.where(pinned: true)
.order(:updated_at)
.first
oldest_pinned.update!(pinned: false)
end
@post.update!(pinned: true)
message = '文章已置頂'
end
render json: { message: message, post: @post }
end
def lock
authorize @post, :lock?
@post.update!(status: @post.locked? ? 'active' : 'locked')
message = @post.locked? ? '文章已鎖定' : '文章已解鎖'
render json: { message: message, post: @post }
end
private
def set_course
@course = Course.find(params[:course_id])
end
def set_post
@post = @course.discussion_posts.find(params[:id])
end
def post_params
params.require(:discussion_post).permit(
:title, :content, :anonymous, :parent_id
)
end
def post_metadata_params
params.require(:discussion_post).permit(:pinned, :status)
end
def notify_author_of_reply
return if @post.parent.author == current_user
DiscussionReplyNotificationJob.perform_later(
@post.parent.author,
@post
)
end
end
end
end
# app/serializers/discussion_post_serializer.rb
class DiscussionPostSerializer < ActiveModel::Serializer
attributes :id, :title, :content, :created_at, :updated_at,
:pinned, :status, :replies_count, :author_name,
:editable, :deletable, :can_pin, :can_lock
def author_name
# 使用模型方法處理匿名邏輯
object.display_author_name(current_user)
end
def editable
# 告訴前端這篇文章是否可編輯
Pundit.policy(current_user, object).update?
end
def deletable
Pundit.policy(current_user, object).destroy?
end
def can_pin
Pundit.policy(current_user, object).pin?
end
def can_lock
Pundit.policy(current_user, object).lock?
end
def replies_count
object.replies.visible.count
end
end
挑戰目標: 實作一個支援協作的小組作業系統
小組作業是 LMS 中最複雜的功能之一,因為它涉及動態的群組成員關係、階層式的權限(組長 vs 組員)、以及時間相關的狀態變化。讓我們一起解決這個挑戰。
需求詳解:
這個系統需要處理以下複雜場景:
解答與實作指南:
首先建立資料模型。注意我們如何處理小組成員的動態關係:
# app/models/group_assignment.rb
class GroupAssignment < ApplicationRecord
belongs_to :course
belongs_to :assignment
has_many :groups, dependent: :destroy
# 配置小組限制
validates :min_members, :max_members, presence: true
validates :min_members, numericality: { greater_than: 0 }
validates :max_members, numericality: { greater_than_or_equal_to: :min_members }
enum formation_method: {
self_organized: 0, # 學生自由組隊
instructor_assigned: 1, # 講師分配
hybrid: 2 # 混合模式:學生先自由組隊,講師補充分配
}
# 組隊階段控制
def formation_period_active?
return false if formation_deadline.blank?
Time.current <= formation_deadline
end
# 是否允許學生自行組隊
def allows_self_organization?
self_organized? || hybrid?
end
end
# app/models/group.rb
class Group < ApplicationRecord
belongs_to :group_assignment
has_many :group_memberships, dependent: :destroy
has_many :members, through: :group_memberships, source: :user
has_one :group_submission
has_many :group_work_versions, dependent: :destroy
# 使用 Redis 儲存編輯鎖,避免同時編輯衝突
def editing_locked_by
Rails.cache.read("group_edit_lock_#{id}")
end
def lock_for_editing(user, duration = 5.minutes)
Rails.cache.write(
"group_edit_lock_#{id}",
user.id,
expires_in: duration
)
end
def unlock_editing
Rails.cache.delete("group_edit_lock_#{id}")
end
# 檢查小組是否符合人數要求
def valid_size?
size = members.count
size >= group_assignment.min_members &&
size <= group_assignment.max_members
end
# 小組是否已提交作業
def submitted?
group_submission.present? && group_submission.submitted?
end
# 取得目前的組長
def leader
leader_membership = group_memberships.find_by(role: 'leader')
leader_membership&.user
end
# 自動儲存版本
def create_version!(content, edited_by)
group_work_versions.create!(
content: content,
edited_by: edited_by,
version_number: next_version_number
)
end
private
def next_version_number
(group_work_versions.maximum(:version_number) || 0) + 1
end
end
# app/models/group_membership.rb
class GroupMembership < ApplicationRecord
belongs_to :group
belongs_to :user
enum role: {
member: 0,
leader: 1
}
enum status: {
active: 0,
left: 1, # 主動離開
removed: 2 # 被移除
}
# 確保每組只有一個組長
validate :only_one_leader_per_group, if: :leader?
# 記錄加入和離開時間
scope :current, -> { where(status: 'active') }
scope :past, -> { where(status: ['left', 'removed']) }
# 記錄成員變更歷史
after_update :log_membership_change, if: :saved_change_to_status?
private
def only_one_leader_per_group
existing_leader = group.group_memberships
.where(role: 'leader', status: 'active')
.where.not(id: id)
.exists?
errors.add(:role, '每組只能有一個組長') if existing_leader
end
def log_membership_change
GroupMembershipLog.create!(
group: group,
user: user,
action: status,
performed_by: Current.user, # 需要設置 Current.user
performed_at: Time.current
)
end
end
實作複雜的小組權限政策:
# app/policies/group_policy.rb
class GroupPolicy < ApplicationPolicy
# 查看小組資訊
def show?
# 小組成員、講師、助教可以查看
is_member? || is_course_staff?
end
# 創建小組(學生自由組隊)
def create?
return false unless group_assignment.allows_self_organization?
return false unless group_assignment.formation_period_active?
# 學生且尚未加入其他小組
is_student? && !already_in_group?
end
# 加入小組
def join?
return false unless group_assignment.allows_self_organization?
return false unless group_assignment.formation_period_active?
return false if record.members.count >= group_assignment.max_members
is_student? && !already_in_group?
end
# 離開小組
def leave?
return false unless group_assignment.formation_period_active?
return false if record.submitted? # 提交後不能離開
# 組長不能離開,除非先轉讓組長身份
return false if is_leader? && record.members.current.count > 1
is_member?
end
# 編輯作業內容
def edit_content?
return false if record.submitted? # 提交後鎖定
return false if record.editing_locked_by &&
record.editing_locked_by != user.id
is_active_member?
end
# 提交作業(只有組長)
def submit?
return false if record.submitted?
return false unless record.valid_size?
return false if past_deadline?
is_leader?
end
# 轉讓組長
def transfer_leadership?
is_leader? && !record.submitted?
end
# 移除成員(組長權限)
def remove_member?
return false if record.submitted?
is_leader? || is_instructor?
end
# 查看所有小組(助教和講師)
def view_all_groups?
is_course_staff?
end
# 強制分配成員(講師權限)
def assign_members?
is_instructor? && group_assignment.formation_period_active?
end
private
def group_assignment
@group_assignment ||= record.group_assignment
end
def is_member?
record.group_memberships.current.exists?(user: user)
end
def is_active_member?
record.group_memberships.active.exists?(user: user)
end
def is_leader?
record.group_memberships.active.exists?(user: user, role: 'leader')
end
def already_in_group?
group_assignment.groups
.joins(:group_memberships)
.where(group_memberships: { user: user, status: 'active' })
.exists?
end
def is_student?
role = user.role_in_course(group_assignment.course)
role&.student?
end
def is_instructor?
role = user.role_in_course(group_assignment.course)
role&.instructor?
end
def is_course_staff?
role = user.role_in_course(group_assignment.course)
role&.instructor? || role&.teaching_assistant?
end
def past_deadline?
group_assignment.assignment.deadline < Time.current
end
end
# app/policies/group_work_version_policy.rb
class GroupWorkVersionPolicy < ApplicationPolicy
# 查看版本歷史
def index?
# 小組成員和課程管理員可以查看版本歷史
group = record.first&.group
return false unless group
GroupPolicy.new(user, group).show?
end
# 回復到特定版本
def restore?
group = record.group
return false if group.submitted?
# 只有小組成員可以回復版本
group.members.current.exists?(user.id)
end
# 查看版本差異
def diff?
GroupPolicy.new(user, record.group).show?
end
end
控制器實作,處理各種小組操作:
# app/controllers/api/v1/groups_controller.rb
module Api
module V1
class GroupsController < ApplicationController
before_action :authenticate_user!
before_action :set_group_assignment
before_action :set_group, only: [:show, :join, :leave, :edit_content,
:submit, :transfer_leadership, :remove_member]
def index
if policy(@group_assignment).view_all_groups?
# 管理員看到所有小組
@groups = @group_assignment.groups
.includes(:members, :group_submission)
else
# 學生只看到自己的小組
@groups = @group_assignment.groups
.joins(:group_memberships)
.where(group_memberships: {
user: current_user,
status: 'active'
})
end
render json: @groups, each_serializer: GroupSerializer
end
def create
@group = @group_assignment.groups.build(group_params)
authorize @group
ActiveRecord::Base.transaction do
@group.save!
# 創建者自動成為組長
@group.group_memberships.create!(
user: current_user,
role: 'leader',
status: 'active'
)
# 如果有邀請成員,發送邀請
if params[:invited_user_ids].present?
send_invitations(params[:invited_user_ids])
end
end
render json: @group, status: :created
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages },
status: :unprocessable_entity
end
def join
authorize @group
ActiveRecord::Base.transaction do
membership = @group.group_memberships.create!(
user: current_user,
role: 'member',
status: 'active'
)
# 通知其他成員
notify_members_of_new_member
end
render json: @group
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages },
status: :unprocessable_entity
end
def leave
authorize @group
membership = @group.group_memberships.find_by(user: current_user)
ActiveRecord::Base.transaction do
# 如果是最後一個成員,解散小組
if @group.members.current.count == 1
@group.destroy!
render json: { message: '小組已解散' }
else
membership.update!(status: 'left', left_at: Time.current)
# 如果是組長離開,自動轉讓給最早加入的成員
if membership.leader?
new_leader = @group.group_memberships
.active
.where.not(id: membership.id)
.order(:created_at)
.first
new_leader.update!(role: 'leader')
end
render json: { message: '已離開小組' }
end
end
end
def edit_content
authorize @group
# 實作編輯鎖機制,避免同時編輯
if @group.editing_locked_by && @group.editing_locked_by != current_user.id
locker = User.find(@group.editing_locked_by)
render json: {
error: "#{locker.name} 正在編輯,請稍後再試"
}, status: :conflict
return
end
# 取得編輯鎖
@group.lock_for_editing(current_user)
# 自動儲存為新版本
version = @group.create_version!(
params[:content],
current_user
)
# 更新當前內容
@group.update!(current_content: params[:content])
# 釋放編輯鎖
@group.unlock_editing
render json: {
message: '內容已儲存',
version: version,
group: @group
}
end
def submit
authorize @group
ActiveRecord::Base.transaction do
# 創建提交記錄
submission = @group.create_group_submission!(
submitted_by: current_user,
submitted_at: Time.current,
content: @group.current_content,
members_snapshot: @group.members.current.pluck(:id, :name)
)
# 鎖定小組,防止進一步修改
@group.update!(locked: true)
# 通知所有成員
notify_submission
render json: {
message: '作業已提交',
submission: submission
}
end
rescue => e
render json: { error: e.message }, status: :unprocessable_entity
end
def transfer_leadership
authorize @group
new_leader = @group.members.find(params[:new_leader_id])
ActiveRecord::Base.transaction do
# 移除舊組長角色
@group.group_memberships.find_by(role: 'leader')
.update!(role: 'member')
# 設置新組長
@group.group_memberships.find_by(user: new_leader)
.update!(role: 'leader')
end
render json: {
message: "組長已轉讓給 #{new_leader.name}",
group: @group
}
end
private
def set_group_assignment
@group_assignment = GroupAssignment.find(params[:group_assignment_id])
end
def set_group
@group = @group_assignment.groups.find(params[:id])
end
def group_params
params.require(:group).permit(:name, :description)
end
def send_invitations(user_ids)
users = User.where(id: user_ids)
users.each do |user|
GroupInvitationMailer.invite(user, @group, current_user).deliver_later
end
end
def notify_members_of_new_member
@group.members.where.not(id: current_user.id).each do |member|
GroupNotificationJob.perform_later(
member,
"#{current_user.name} 加入了你的小組"
)
end
end
def notify_submission
@group.members.each do |member|
SubmissionNotificationJob.perform_later(
member,
@group,
current_user
)
end
end
end
end
end
關鍵決策點解析:
在實作這個系統時,我們做了幾個重要的設計決策。首先是編輯鎖機制,這解決了多人同時編輯的衝突問題。我們使用 Redis 而非資料庫來儲存鎖定狀態,因為鎖是暫時的,且需要自動過期功能。
其次是版本控制系統,每次編輯都會創建新版本,這不僅提供了歷史追蹤,也讓我們能在出現問題時回復到之前的版本。這對小組作業特別重要,因為一個成員的錯誤修改可能影響整個小組。
最後是成員變更的處理,我們選擇軟刪除(將狀態改為 'left' 或 'removed')而非真的刪除記錄,這樣可以保留完整的歷史記錄,對於成績爭議的處理非常重要。
測試要點:
# spec/policies/group_policy_spec.rb
RSpec.describe GroupPolicy do
let(:group_assignment) { create(:group_assignment) }
let(:group) { create(:group, group_assignment: group_assignment) }
describe '小組編輯權限' do
context '當多人同時編輯時' do
let(:member1) { create(:user) }
let(:member2) { create(:user) }
before do
group.members << [member1, member2]
group.lock_for_editing(member1)
end
it '第二個成員無法編輯' do
policy = described_class.new(member2, group)
expect(policy.edit_content?).to be_falsey
end
it '鎖定過期後可以編輯' do
travel_to 6.minutes.from_now do
policy = described_class.new(member2, group)
expect(policy.edit_content?).to be_truthy
end
end
end
end
end
這兩個練習涵蓋了授權系統的核心概念,從簡單的角色權限到複雜的上下文相關權限。透過實作這些系統,你應該能深刻理解如何在 Rails 中設計和實作精細的權限控制。記住,好的授權系統不只是能工作,更要易於理解、測試和維護。
current_user
是連接兩者的橋樑知識層面:
思維層面:
實踐層面:
完成今天的學習後,你應該能夠:
深入閱讀:
相關 Gem:
action_policy
- 更強大但更複雜的授權方案rolify
- 角色管理的輔助工具jwt
- 配合昨天的 JWT 認證使用明天我們將探討 API 版本控制與向後相容。如果說今天學習的是保護 API 的安全,那明天就是確保 API 的永續發展。當你的 LMS API 被眾多前端應用依賴時,如何優雅地演進而不破壞既有功能?準備好了嗎?讓我們繼續這段旅程。