Pundit 如何为同一未授权操作返回 403 或 402?
结论
不要在 user_not_authorized 中重复判断会员身份、资源归属等业务条件。Policy 识别到资源可以通过付费解锁时,抛出专用异常;controller 再根据异常类型返回 402 或 403。
这样,访问规则集中在 ItemPolicy 中,API 基类只负责处理 HTTP 响应。
定义专用异常
付费异常可以继承 Pundit::NotAuthorizedError,从而保留 Pundit 异常中的 policy、record、query 等上下文:
class PaymentRequiredError < Pundit::NotAuthorizedError
end
如果项目使用 Rails 自动加载,可以将文件放在类似下面的位置:
app/errors/payment_required_error.rb
该目录是否会被自动加载,取决于项目的 Rails 配置。
在 policy 中区分拒绝原因
普通的访问禁止仍返回 false。只有在用户付费后才能访问时,才抛出专用异常:
class ItemPolicy < ApplicationPolicy
def show?
return true if record.public?
return false unless record.band.members.include?(user)
return true if user.premium?
raise PaymentRequiredError.new(
query: :show?,
record: record,
policy: self
)
end
end
这段规则对应四种情况:
- 资源公开时,允许访问。
- 用户不是该 band 的成员时,按普通未授权处理,由 Pundit 返回
403。 - 用户是成员但没有 premium 权限时,抛出
PaymentRequiredError,返回402。 - 用户是 premium 用户时,允许访问。
authorize @item 无需改动:
class Api::V1::ItemController < Api::V1::BaseController
def show
@item = Item.find(params[:id])
authorize @item
end
end
在 API 基类中映射状态码
为了不依赖多个 rescue_from 声明的匹配顺序,可以统一捕获 Pundit::NotAuthorizedError,再根据异常类型返回对应的响应:
class Api::V1::BaseController < ActionController::API
include Pundit::Authorization
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
private
def user_not_authorized(exception)
if exception.is_a?(PaymentRequiredError)
render json: {
error: {
code: "payment_required",
message: "Payment is required to access this item"
}
}, status: :payment_required
else
render json: {
error: {
code: "access_denied",
message: "Access denied"
}
}, status: :forbidden
end
end
end
Rails/Rack 通常支持 :payment_required。如果担心当前依赖版本无法识别这个符号,也可以直接使用数字状态码:
status: 402
也可以为两类异常分别注册处理器:
class Api::V1::BaseController < ActionController::API
include Pundit::Authorization
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
rescue_from PaymentRequiredError, with: :payment_required
private
def payment_required(_exception)
render json: {
error: {
code: "payment_required",
message: "Payment is required to access this item"
}
}, status: 402
end
def user_not_authorized(_exception)
render json: {
error: {
code: "access_denied",
message: "Access denied"
}
}, status: :forbidden
end
end
为什么判断应放在 policy
资源是否公开、用户是否属于某个 band、用户是否拥有 premium 权限,都属于授权规则,应由 policy 判断。Controller 只需把授权结果转换成 HTTP 响应。
如果在异常处理器中再次写入相同条件:
if exception.record.band.members.include?(current_user) &&
!current_user.premium?
授权规则就会同时存在于 policy 和 controller 中。以后条件发生变化,很容易漏改其中一处,最终导致实际授权结果与返回的状态码不一致。
不希望在 policy 中抛异常时的方案
有些项目要求 policy 谓词始终返回布尔值。遇到这种约束,可以先在 policy 中计算统一的授权结果,再由 show? 和异常处理器读取:
class ItemPolicy < ApplicationPolicy
def show?
access_result == :allowed
end
def payment_required?
access_result == :payment_required
end
private
def access_result
@access_result ||= begin
if record.public?
:allowed
elsif !record.band.members.include?(user)
:forbidden
elsif user.premium?
:allowed
else
:payment_required
end
end
end
end
异常处理器可以通过 Pundit 提供的 policy 实例判断拒绝原因:
def user_not_authorized(exception)
if exception.policy.respond_to?(:payment_required?) &&
exception.policy.payment_required?
render json: {
error: {
code: "payment_required",
message: "Payment is required to access this item"
}
}, status: 402
else
render json: {
error: {
code: "access_denied",
message: "Access denied"
}
}, status: :forbidden
end
end
这种写法不会重复业务条件,但要求相关 policy 实现额外接口。如果 API 中只有少数付费解锁场景,使用专用异常通常更直接。如果许多 policy 都需要表达多种拒绝原因,统一的授权结果对象或状态接口会更便于扩展。
注意事项
HTTP 为 402 Payment Required 保留了状态码,但长期以来并没有为它定义统一、通用的应用语义。内部 API 或已经与客户端约定好语义的 API 可以使用 402。如果 API 面向不受控的第三方客户端,更常见的做法是返回 403,同时在响应体中提供稳定的业务错误码:
{
"error": {
"code": "payment_required",
"message": "Payment is required to access this item"
}
}
无论最终使用 402 还是 403,客户端都应优先根据 error.code 执行业务逻辑,不要解析错误文案。
备注:内容仅供参考。
