PAYATHON 2026

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 执行业务逻辑,不要解析错误文案。

备注:内容仅供参考。