mcpField
S
spree-auth-permissions

spree-auth-permissions

github.com/spree安全10104 次安装4 周前更新
代码仓库

关于 spree-auth-permissions

面向 Spree 6 的授权指南,说明员工、集成和顾客各自的授权方式。内容涵盖权限键、API 密钥范围、记录级访问控制、角色管理及扩展权限目录。

主要功能

  1. 01员工角色与权限键
  2. 02集成 API 密钥范围
  3. 03顾客所有权授权
  4. 04Admin API 授权流程
  5. 05注册扩展权限范围

适用场景

  1. 01配置店铺员工角色
  2. 02用 API 范围限制集成访问
  3. 03为 Spree 扩展添加权限

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/spree/agent-skills --skill spree-auth-permissions

安装后在 Claude Code 中输入/spree-auth-permissions确认已加载。

详细介绍

Spree 身份验证与权限

Spree 6 有三类主体,每类主体都有自己的授权方式:

主体身份验证方式授权依据
员工(Spree.admin_user_class,默认为 Spree::AdminUser)来自 POST /api/v3/admin/auth/login 的 JWT(aud: admin_api)其角色在当前商店所持有的权限键
集成(服务器到服务器)X-Spree-Api-Key 中的密钥 sk_…密钥的作用域(与角色使用相同的键)
客户(Spree.customer_class,默认为 Spree::Customer)Publishable key pk_… + 可选 JWT(aud: store_api)所有权:限定范围的查询以及 Spree::Storefront::AccessPolicy

Marketplace 卖家是第四类接口(/api/v3/seller,JWT aud: seller_api + X-Spree-Seller-Id)——参见 spree-marketplace。

此技能的所有内容都遵循以下两条规则:

  1. 权限键限制的是记录类型,而不是具体行。 write_orders 表示“可以写入订单”,而控制器的限定范围查询(current_store.orders、current_seller.products、current_user.wishlists)决定具体可以操作哪些订单。添加控制器时,必须由你负责限定查询范围。
  2. 记录状态规则应属于模型和工作流,而不是权限。“已下单的订单不能删除”必须同样约束密钥,且密钥永远不会经过角色授权。

员工角色是数据

Spree::Role 属于其管理对象(resource:后台员工对应 Spree::Store,卖家团队对应 Spree::Seller),并在 permissions(JSON 数组)中保存一组扁平的权限键。不存在权限集类,也没有 Ruby 角色定义。

  • 权限键的格式为 read_<resource> / write_<resource>。write_x 隐含 read_x(Spree.permissions.expand_keys)。
  • 授予和移除角色遵循相同的限制。 只有持有 admin 角色的人才能授予或移除 admin;其他人只能授予或移除其自身持有权限键的角色;而商店必须始终至少保留一名管理员(403“商店必须至少保留一名管理员。”)。将角色列表设为空也属于移除角色。
  • 每个商店都有一个受保护的 admin 角色,表示“此商店中的所有权限”。该角色不能重命名、编辑或删除,并且会忽略其 permissions 列表(它会获得 can :manage, :all)。
  • 角色名称在所属对象范围内唯一,因此两个商店都可以有一个名为“Manager”的角色。
  • 关联了员工或待处理邀请的角色不能删除。
  • mutable: false 会使角色在仪表盘中变为只读。
管理角色

根据商店其他部分的管理方式,选择合适的方式:

  • 仪表盘: Settings → Roles(权限选择器根据权限目录生成)。

  • Admin API: /api/v3/admin/roles(CRUD)、/api/v3/admin/permissions(可授予的权限目录)。两者都需要 read_staff / write_staff。

    spree api post /roles -d '{"name":"Support","permissions":["read_orders","read_customers"]}'
    
    await adminClient.roles.create({
      name: 'Fulfillment staff',
      permissions: ['read_orders', 'write_fulfillments', 'read_products'],
    })
    
  • 种子数据(db/seeds.rb)——角色必须有一个 resource:

    store = Spree::Store.default
    role = Spree::Role.find_or_initialize_by(name: 'support', resource: store)
    role.update!(permissions: %w[read_orders read_customers write_customers])
    
    admin = Spree.admin_user_class.find_by!(email: '[email protected]')
    admin.add_role('support', store)   # looks the role up by name + resource
    

未知键,或不可授予该角色受众的键,都会导致验证失败——无法保存持有 write_seller_profile 的商店角色。

邀请员工:前往 Settings → Users → Invite,或使用 adminClient.invitations.create({ email, role_id })(会发布 invitation.created,从而发送电子邮件)。role_id 为必填项——邀请不会默认分配管理员角色——且调用者必须有权授予该角色。邀请列表不包含接受链接(链接中包含用于创建账户的令牌):需要时可通过 adminClient.invitations.acceptanceLink(id) → GET /api/v3/admin/invitations/:id/acceptance_link 获取;这需要 write_staff 以及授予邀请所指定角色的权限(卖家:sellers.invitations.acceptanceLink(sellerId, id) / Seller API invitations.acceptanceLink(id))。重新发送邀请会轮换令牌,因此之前的链接将失效。在本地创建管理员:spree user create(会在默认商店中分配 admin 角色)。

权限目录

按选择器中的分组方式列出的所有核心键,见 spree-api-v3/references/scopes.md。运行时可通过 GET /api/v3/admin/permissions 发现这些键;密钥的作用域来自 Spree::ApiKey.known_scopes(所有可授予员工的键,以及 read_all / write_all)。

值得注意:

  • 与资金相关的区域是独立资源:payments、refunds、gift_cards、store_credits 不包含在 orders 中。“可以查看订单但不能退款”无需特殊处理。
  • 员工管理使用 read_staff / write_staff(/admin_users、/invitations、/roles)。
  • api_keys 和 webhooks 因为敏感性而从 settings 中单独划出。持有 write_api_keys 的调用方只能创建其自身已持有作用域的密钥。
  • 自定义字段的值遵循其父资源(write_products 涵盖产品自定义字段)。定义属于 settings。
  • 导出没有专属作用域。每种导出类型都需要其导出资源的读取键。导入需要写入键。
  • read_dashboard 没有对应的写入键。
为扩展注册作用域
# config/initializers/spree.rb (or your engine's initializer)
Spree.permissions.register_scope(:reviews, group: :catalog, resources: -> {
  [SpreeReviews::Review]
})

签名:register_scope(name, group:, resources:, write: true, audiences: [:store], read_only_for: [])。

  • group 决定选择器中的分区::analytics、:orders、:catalog、:customers、:sellers、:loyalty、:marketing、:settings、:access,或自定义分区。行按注册顺序显示。
  • resources 是一个返回 CanCanCan 主体(类或符号)的 lambda。它会延迟解析,因此加载顺序无关紧要。
  • write: false 会注册只读作用域(仅有 read_<name>)。
  • audiences 列出角色可以持有这些键的所有受众。默认值为 [:store](员工)。%i[store seller] 也会向商城卖家角色开放该作用域。如果不包含 :store,该作用域便不会出现在员工选择器中,也不会包含在 read_all / write_all 中,且无法用于创建密钥。
  • read_only_for: %i[seller] 会让该受众只能使用读取键。
  • 名称 all 为保留名称(会引发 ArgumentError)。重新注册某个名称会替换现有注册;Spree.permissions.unregister_scope(:name) 可移除该作用域。
  • 切勿向其他受众开放 settings、staff 或 api_keys。

在 spree.permissions_catalog.resources.<name>.label / .description 下本地化选择器标签。

然后在 Admin API 控制器上声明该作用域:

module Spree
  module Api
    module V3
      module Admin
        class ReviewsController < ResourceController
          scoped_resource :reviews        # index/show → read_reviews; everything else → write_reviews

          protected

          def model_class = SpreeReviews::Review
          def serializer_class = SpreeReviews::Api::V3::Admin::ReviewSerializer
          def resource_permitted_attributes = %i[rating body status]
        end
      end
    end
  end
end

spree:api_resource 生成器(参见 spree-resource)会生成两部分:Admin 控制器中的 scoped_resource :reviews,以及追加到 config/initializers/spree.rb 的对应 register_scope 行(--permission-group 用于选择分组,默认为 catalog),此外还会在 config/locales/spree_reviews.en.yml 中添加标签。手写控制器也需要这两部分——如果有 scoped_resource 却没有已注册的作用域,则只有 read_all / write_all 密钥和 admin 角色可以访问该端点,其他员工会被拒绝,并且无法创建带有 read_reviews 的密钥。

门禁的工作方式(Admin API)

每个 Admin API 控制器都会声明 scoped_resource :name。通过其他方式进行授权的控制器会调用 skip_scope_check!,并可选地使用 only: 或 jwt_only: true。既未声明资源范围、也未跳过检查的控制器在收到请求时会触发 MissingScopedResource,因此检查会默认拒绝。每个请求都会执行以下步骤:

  1. 根据操作确定权限:index、show 以及控制器 read_actions 中的操作映射为 read_,其余操作(包括 cancel 等自定义成员操作)映射为 write_。
  2. **Secret key:**检查 current_api_key.has_scope?("write_orders"),否则返回 403:
    { "error": { "code": "access_denied", "message": "API key lacks scope: write_orders",
                 "details": { "required_scope": "write_orders" } } }
    
  3. **JWT staff:**检查用户在当前店铺上的扩展角色键(Spree::Ability#permission_keys),否则返回 403,并带有 "details": { "required_permission": "write_orders" }。admin 角色拥有所有权限。
  4. 通过门禁后,权限键会编译为 CanCanCan 规则,用于记录级检查。403 响应中没有 required_permission,表示用户拥有该权限键,但记录级规则拒绝了此操作。

如果请求同时携带 JWT 和 secret key,则以 JWT 为准,并应用用户角色。用户在请求的店铺没有任何角色时,即使令牌有效也会被拒绝。

**嵌入数据同样受门禁保护。**端点会根据自身权限键进行门禁检查,但除非调用方拥有相应的读取权限键,否则会移除通过 ?expand= 加载其他类型记录的片段(orders→read_orders、payments/payment_splits→read_payments、customer(s)→read_customers、gift_card(s)→read_gift_cards、store_credit(s)→read_store_credits);对于不具备 read_gift_cards 权限的调用方,礼品卡代码也会被遮蔽。在你自己的 Admin 控制器中,序列化受其他权限保护的数据前,请使用 holds_permission?('read_x')(它对 JWT staff 和 secret keys 都会遵循相同的门禁规则)。

GET /api/v3/admin/me 会返回 permission_keys(扁平列表)以及 CanCanCan 规则转储,仪表板会使用它进行 UI 门禁控制。**在 UI 中隐藏按钮不等于授权。**只有 API 门禁负责执行授权。

Secret key 细节
  • 创建密钥后,其权限范围(scope)不可更改。要更改集成可执行的操作,请生成新密钥并撤销旧密钥。
  • Publishable key 可绑定到一个 channel(channel_id,同样不可更改)。绑定的密钥会在服务器上设置请求的 channel,并拒绝与之冲突的 X-Spree-Channel 标头。
  • 生成密钥时,请使用最小权限范围:spree api-key create --type secret --scopes read_orders,write_fulfillments。
关于 Spree::Ability 和 ability_class

CanCanCan 仍会在密钥门禁之后用于记录级规则。所有 staff ability——Admin API 上的 JWT staff、Seller API 上的卖家,以及导入和导出操作——都由 Spree.ability_class(Spree::Dependencies.ability_class,默认为 Spree::Ability)构建。如果需要实现权限目录无法表达的规则,可以替换它:

# app/models/my_app/ability.rb
module MyApp
  class Ability < Spree::Ability
    def initialize(user, options = {})   # keep this signature
      super                              # options[:store]; options[:resource] = the seller on the Seller API
      cannot :destroy, Spree::Order      # add can/cannot rules AFTER super
    end
  end
end

# config/initializers/spree.rb
Spree::Dependencies.ability_class = 'MyApp::Ability'

以下内容不会因此改变:

  • **密钥门禁先执行。**自定义 ability 可以拒绝密钥允许访问的记录(返回不含 required_permission 的 403),但无法让缺少权限键的请求通过门禁。
  • **Secret API keys 不会查询它。**它们使用 Spree::ApiKeyAbility,只依据权限范围(scopes)进行门禁检查。
  • 没有卖家的 GET /api/v3/seller/me 响应始终由核心 Spree::Ability 构建,因此你添加的规则不会泄露到该响应中。
  • Store API——客户完全不通过 CanCanCan 进行授权(见下文)。

要缩小列表返回的范围,修改受限查询通常比添加 ability 规则更简单。状态规则(例如“不能退款已取消的订单”)应放在模型或工作流中。

店面授权(Store API)

客户永远不会持有角色,Store API 也不使用 CanCanCan。访问权限由所有权决定,并通过两种方式强制执行:

  1. 限定范围的查询。 每个店面控制器都通过店面和所有者读取数据,例如 storefront_access_policy.scope(Spree::Wishlist.for_store(current_store))。
  2. Spree::Storefront::AccessPolicy,一个提供 readable?(record, token:)、writable?(record, token:) 和 scope(relation, token:) 的统一对象:
    • 默认情况下,调用者拥有该记录(record.customer_id == user.id)。访客不拥有任何内容。
    • 购物车、订单和订单组:JWT 持有者 或 持有访客令牌(X-Spree-Token)的人是所有者。购物车或订单完成后,拒绝写入。
    • 公司(B2B):在节点或其任一祖先节点上具有任职关系的成员。
    • 拒绝访问时会引发 Spree::Storefront::AccessDenied,并返回 403 access_denied。

对于自定义的客户所有资源,请遵循核心模式:

class Spree::Api::V3::Store::SavedListsController < Spree::Api::V3::Store::ResourceController
  prepend_before_action :require_authentication!

  protected

  def scope
    storefront_access_policy.scope(MyApp::SavedList.for_store(current_store))
  end

  def model_class = MyApp::SavedList
  def serializer_class = MyApp::Api::V3::SavedListSerializer
  def resource_permitted_attributes = %i[name]
end

只有当访问范围需要超出所有者时(例如查看公司的全部采购记录、共享愿望清单),才替换策略。继承该策略并调用 super:

# app/models/my_app/company_access_policy.rb
class MyApp::CompanyAccessPolicy < Spree::Storefront::AccessPolicy
  def scope(base, token: nil)
    return base.where(company_id: approver_company_ids) if base.klass <= Spree::Order && approver?
    super
  end
end

# config/initializers/spree.rb
Spree::Dependencies.storefront_access_policy_class = 'MyApp::CompanyAccessPolicy'

不要将该策略用于审批要求或支出限额等操作否决。这些属于结账工作流的 validate 钩子(参见 spree-workflows、spree-b2b)。/customers/me/* 按设计仅限所有者访问。

身份验证策略(登录提供方)

每个界面都可以单独配置登录方式。POST …/auth/login 的 provider 字段会从注册表中选择一种策略。若省略该字段,则使用 email(密码)策略。

界面注册表用户类JWT aud刷新令牌
Store APISpree.store_authentication_strategiesSpree.customer_classstore_api在响应正文中
Admin APISpree.admin_authentication_strategiesSpree.admin_user_classadmin_api位于 /api/v3/admin/auth 的 HttpOnly Cookie
Seller APISpree.seller_authentication_strategiesSpree.admin_user_classseller_api位于 /api/v3/seller/auth 的 HttpOnly Cookie

每个界面只接受自己的受众,因此客户令牌无法重放到 Admin API。Spree 会签发 HS256 JWT,其中 iss: spree。签名密钥依次从 Spree::Api::Config[:jwt_secret_key](环境变量 SPREE_JWT_SECRET_KEY)、credentials 中的 jwt_secret_key、JWT_SECRET_KEY 和 secret_key_base 解析。有效期:客户 JWT 为 1 小时(jwt_expiration),管理员 JWT 为 5 分钟(admin_jwt_expiration),刷新令牌为 30 天。

内置 OIDC(员工 SSO)

Spree::Authentication::Strategies::OidcStrategy 是一种重定向策略,可与任何 OpenID Connect 发行方配合使用(Entra ID、Okta、Google Workspace、Keycloak、Auth0):

# config/initializers/spree.rb
Rails.application.config.after_initialize do
  Spree.admin_authentication_strategies.add(
    :entra,
    Spree::Authentication::Strategies::OidcStrategy.configure(
      issuer: "https://login.microsoftonline.com/#{ENV['ENTRA_TENANT_ID']}/v2.0",
      client_id: ENV['ENTRA_CLIENT_ID'],
      client_secret: ENV['ENTRA_CLIENT_SECRET'],
      redirect_uri: 'https://shop.example.com/api/v3/admin/auth/callback/entra',
      label: 'Microsoft Entra ID'
    )
  )
  # Optional: SSO-only staff login
  # Spree.admin_authentication_strategies.remove(:email)
end

控制面板从 GET /api/v3/admin/auth/providers 获取提供方列表,并通过 GET /api/v3/admin/auth/callback/:provider 完成登录。绝不会自动创建员工账户。 已知身份可以登录。若现有账户的电子邮件与已验证的电子邮件声明匹配,则会关联该身份。其他所有人都会被拒绝。除非 IdP 的目录拥有这些电子邮件地址,否则请保持 trust_unverified_email: false。

自定义策略(第三方 JWT、Firebase、Cognito,……)
# app/models/my_app/auth/external_jwt_strategy.rb
class MyApp::Auth::ExternalJwtStrategy < Spree::Authentication::Strategies::BaseStrategy
  def provider = 'external_idp'

  def authenticate
    payload = verify_with_jwks(params[:token])   # pin algorithms, verify iss + aud
    user = find_or_create_user_from_oauth(
      provider: provider,
      uid: payload.fetch('sub'),
      info: { email: payload['email'], first_name: payload['given_name'], last_name: payload['family_name'] }
    )
    success(user)
  rescue JWT::DecodeError, KeyError
    failure(Spree.t('api.unauthorized'))
  end

  private

  # Yours to implement — BaseStrategy has no JWT helper. Uses the `jwt` gem.
  def verify_with_jwks(token)
    jwks = JWT::JWK::Set.new(MyApp::Auth.fetch_jwks) # cache the issuer's JWKS
    JWT.decode(token, nil, true, algorithms: ['RS256'], jwks: jwks,
               iss: ENV['IDP_ISSUER'], verify_iss: true,
               aud: ENV['IDP_AUDIENCE'], verify_aud: true).first
  end
end

# config/initializers/spree.rb
Rails.application.config.after_initialize do
  Spree.store_authentication_strategies.add(:external_idp, MyApp::Auth::ExternalJwtStrategy)
end
  • BaseStrategy 为你提供 params、request_env、user_class、success(user)、failure(msg)、find_user_by_email 和 find_or_create_user_from_oauth(provider:, uid:, info:, tokens: {})。最后一个方法通过 Spree::UserIdentity 将 provider + uid 映射到用户。
  • 密码类策略需实现 authenticate。重定向策略需定义 def self.kind = :redirect(以及用于按钮的 self.label),并实现 authorization_url(state:) 和 callback。管理后台提供了回调路由。
  • 注册表支持 add(key, klass)(会覆盖现有项,也可用于替换 :email)、remove(key)、[key] 和 keys。
  • 在 Rails.application.config.after_initialize 中注册。 Core 会在自身的 after_initialize 中重新赋值策略注册表,从而清除在初始化器顶层或 to_prepare 中添加的内容。Spree::UserIdentity 会根据商店、管理后台和卖家策略注册表的键验证 provider,因此缺少注册会导致首次登录失败。
  • 传入 tokens: 的 access_token / refresh_token 会存储在身份记录上,并且静态加密(使用 Active Record 加密,并设置 support_unencrypted_data: true,以便仍能读取加密前的记录)——但前提是已配置加密密钥,否则会以明文存储。参见 spree-security。
  • 第三方令牌只在登录时使用一次。之后客户端使用 Spree 的 JWT。不要尝试在受保护的端点上接受外部令牌。
  • 任何检查密码的代码都必须经过锁定机制。 使用 Spree::Authentication::Lockout.check(user) { user.valid_password?(pw) },返回值为 :locked / :valid / :invalid(失败次数会累计,验证成功会清除计数;限制由 Spree::Config[:max_failed_login_attempts] / [:lockout_duration] 设置)。Core 会在登录、确认当前密码以及现有账户接受邀请时使用此机制——绕过它检查密码会成为不受限制的密码猜测入口。
  • 只有在身份提供方(IdP)保证 email_verified 时,才能通过电子邮件地址关联账户。使用未经验证的电子邮件地址关联账户,会让攻击者接管账户。

自定义用户类

# config/initializers/spree.rb
Spree.customer_class = 'MyApp::Customer'     # String, never the Class
Spree.admin_user_class = 'MyApp::StaffUser'
  • 客户默认使用 Spree::Customer(表 spree_customers,ID 前缀为 cust_)。员工默认使用 Spree::AdminUser(ID 前缀为 adm_)。自定义类需分别包含 Spree::CustomerMethods / Spree::AdminUserMethods。
  • 在代码中,始终通过 Spree.customer_class / Spree.admin_user_class 访问类。Spree.user_class 是已弃用的 customer_class 别名。
  • 客户生命周期事件命名为 user.*。员工用户(Spree::AdminUser)不会发布生命周期事件。

注意事项

  • Spree::Role.find_or_create_by!(name: 'support') 会失败——角色属于某个资源(店铺或卖家)。请通过所有者创建角色:store.roles.find_or_create_by!(name: 'support').update!(permissions: %w[read_orders read_customers])。
  • Spree.permissions.assign(...) 和权限集类(Spree::PermissionSets::*)已不存在。调用 assign 会引发 PermissionSetsRemovedError。从 5.x 升级而来?请参阅 spree-upgrade-5-to-6。
  • 新建的 Admin 控制器返回 500 MissingScopedResource:声明 scoped_resource :x 或 skip_scope_check!。
  • 员工收到带有 required_permission 的 403,尽管角色“看起来没问题”:请确认该角色属于当前店铺(X-Spree-Store-Id / 主机),且权限键拼写完全正确(write_fulfillments,而不是 write_shipments)。
  • 密钥访问 /admin_users 时收到 403:该端点需要 read_staff,而它不属于 settings。
  • 自定义 Admin 控制器使用 Spree::Order.find(params[:id]) 而非按店铺限定的 scope 时,无论密钥如何,都会泄露跨店铺数据。
  • 员工密码重置(POST /api/v3/admin/auth/password_resets)仅当 redirect_url 属于控制面板自身的来源(Spree::Stores::DashboardUrl),且该账号是该店铺的员工时才会生效;否则,邮件中的链接会指向 <dashboard>/reset-password。允许的来源不会扩大此范围。
  • Admin / 卖家刷新 Cookie 在 HTTPS 下使用 SameSite=None; Secure。跨源控制面板还需要将其来源添加到 Settings → Allowed Origins(请参阅 spree-security)。

延伸阅读

  • 员工与角色:node_modules/@spree/docs/dist/developer/core-concepts/staff-roles.md
  • 权限目录与扩展范围:node_modules/@spree/docs/dist/developer/customization/permissions.md
  • 自定义登录策略:node_modules/@spree/docs/dist/developer/how-to/custom-api-authentication.md
  • 身份与 SSO:node_modules/@spree/docs/dist/developer/providers/sso.md
  • Admin API 身份验证:node_modules/@spree/docs/dist/api-reference/admin-api/authentication.md
  • 源码:spree/core/lib/spree/core/permission_configuration.rb、spree/core/lib/spree/core/permissions/default_catalog.rb、spree/api/app/controllers/concerns/spree/api/v3/scoped_authorization.rb、spree/core/app/models/spree/storefront/access_policy.rb
  • 相关技能:spree-api-v3(范围列表、错误封装)、spree-security、spree-marketplace(卖家受众)、spree-b2b(公司访问权限)、spree-dashboard(UI 权限门控)。

评论

支持 Markdown