关于 spree-auth-permissions
面向 Spree 6 的授权指南,说明员工、集成和顾客各自的授权方式。内容涵盖权限键、API 密钥范围、记录级访问控制、角色管理及扩展权限目录。
主要功能
- 01员工角色与权限键
- 02集成 API 密钥范围
- 03顾客所有权授权
- 04Admin API 授权流程
- 05注册扩展权限范围
适用场景
- 01配置店铺员工角色
- 02用 API 范围限制集成访问
- 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。
此技能的所有内容都遵循以下两条规则:
- 权限键限制的是记录类型,而不是具体行。
write_orders表示“可以写入订单”,而控制器的限定范围查询(current_store.orders、current_seller.products、current_user.wishlists)决定具体可以操作哪些订单。添加控制器时,必须由你负责限定查询范围。 - 记录状态规则应属于模型和工作流,而不是权限。“已下单的订单不能删除”必须同样约束密钥,且密钥永远不会经过角色授权。
员工角色是数据
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,因此检查会默认拒绝。每个请求都会执行以下步骤:
- 根据操作确定权限:
index、show以及控制器read_actions中的操作映射为read_,其余操作(包括cancel等自定义成员操作)映射为write_。 - **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" } } } - **JWT staff:**检查用户在当前店铺上的扩展角色键(
Spree::Ability#permission_keys),否则返回 403,并带有"details": { "required_permission": "write_orders" }。admin角色拥有所有权限。 - 通过门禁后,权限键会编译为 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。访问权限由所有权决定,并通过两种方式强制执行:
- 限定范围的查询。 每个店面控制器都通过店面和所有者读取数据,例如
storefront_access_policy.scope(Spree::Wishlist.for_store(current_store))。 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,并返回 403access_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 API | Spree.store_authentication_strategies | Spree.customer_class | store_api | 在响应正文中 |
| Admin API | Spree.admin_authentication_strategies | Spree.admin_user_class | admin_api | 位于 /api/v3/admin/auth 的 HttpOnly Cookie |
| Seller API | Spree.seller_authentication_strategies | Spree.admin_user_class | seller_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 权限门控)。