spree_api 5.5.0.rc2 → 5.5.0.rc3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/spree/api/v3/admin/role_grant_guard.rb +83 -25
  3. data/app/controllers/concerns/spree/api/v3/admin_authentication.rb +24 -2
  4. data/app/controllers/spree/api/v3/admin/admin_users_controller.rb +4 -1
  5. data/app/controllers/spree/api/v3/admin/api_keys_controller.rb +32 -7
  6. data/app/controllers/spree/api/v3/admin/channels_controller.rb +4 -6
  7. data/app/controllers/spree/api/v3/admin/coupon_codes_controller.rb +2 -2
  8. data/app/controllers/spree/api/v3/admin/custom_fields_controller.rb +1 -1
  9. data/app/controllers/spree/api/v3/admin/customers/credit_cards_controller.rb +7 -0
  10. data/app/controllers/spree/api/v3/admin/customers/store_credits_controller.rb +6 -0
  11. data/app/controllers/spree/api/v3/admin/me_controller.rb +17 -0
  12. data/app/controllers/spree/api/v3/admin/media_controller.rb +1 -1
  13. data/app/controllers/spree/api/v3/admin/orders/adjustments_controller.rb +0 -2
  14. data/app/controllers/spree/api/v3/admin/orders/base_controller.rb +2 -0
  15. data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +5 -3
  16. data/app/controllers/spree/api/v3/admin/orders/gift_cards_controller.rb +0 -2
  17. data/app/controllers/spree/api/v3/admin/orders/items_controller.rb +0 -2
  18. data/app/controllers/spree/api/v3/admin/orders/payments_controller.rb +1 -1
  19. data/app/controllers/spree/api/v3/admin/orders/refunds_controller.rb +3 -3
  20. data/app/controllers/spree/api/v3/admin/orders/store_credits_controller.rb +0 -2
  21. data/app/controllers/spree/api/v3/admin/payment_methods_controller.rb +2 -4
  22. data/app/controllers/spree/api/v3/admin/prices_controller.rb +22 -22
  23. data/app/controllers/spree/api/v3/admin/products/variants_controller.rb +1 -1
  24. data/app/controllers/spree/api/v3/admin/promotion_actions_controller.rb +2 -2
  25. data/app/controllers/spree/api/v3/admin/promotion_rules_controller.rb +2 -2
  26. data/app/controllers/spree/api/v3/admin/stock_items_controller.rb +7 -0
  27. data/app/controllers/spree/api/v3/admin/stock_locations_controller.rb +6 -0
  28. data/app/controllers/spree/api/v3/admin/stock_transfers_controller.rb +4 -3
  29. data/app/controllers/spree/api/v3/admin/tags_controller.rb +60 -10
  30. data/config/routes.rb +4 -1
  31. metadata +6 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a94767b03984d4ebb7c65b91c9512aac96e909f9d2308f06ed1e96e53f2a3580
4
- data.tar.gz: 8c0f0201d44ca50071cd273c932b497fcafd2f954f7d9e213772f4fe16595ab1
3
+ metadata.gz: 782979132fc5d443901d5df5881143bec024d695856ae2a321574004ae83ac1a
4
+ data.tar.gz: c8b368f528c51d9bb5772bdde7d743043c7bc5b85046e560219549254c42f6ca
5
5
  SHA512:
6
- metadata.gz: b1ae1474848c3bd7fc3bea51374c1a523b4bf08e94d75f3ea82c60fa1ce43b812dccf2c37aae01c2225e9d3281c7d16868dd7b28431f30ad177913fbb085b7aa
7
- data.tar.gz: 1830848629f174cf25342d0c3bc584aba47ea4365b890b623bf6df8a44bfed3bc6566e95cd264517e79379d8569ce66baf18ffd2be4186143a265a5ef81d1ef1
6
+ metadata.gz: a846e352c895c3df11ed61ee931b31ecea07f5d8183021bdd5aa5711907a7a199de928402601cebdab7ac3bb2ed241b1d4a8a2c692d759ffcac15b749df7ed3b
7
+ data.tar.gz: 626baa65dd040b25cd6cb8cf7308e601668012933a3a7e7f0d4e5e8cb0888e7424db6924e003f80f39278dfb762ae79435e5f078f8823a506db53c3692878a19
@@ -2,49 +2,107 @@ module Spree
2
2
  module Api
3
3
  module V3
4
4
  module Admin
5
- # Shared guard preventing role-assignment privilege escalation. Staff
6
- # role grants (via admin_users#update and invitations#create) must not
7
- # let a caller hand out the `admin` super-role unless they already hold
8
- # it on the current store. API-key principals have no human identity to
9
- # bound the grant, so they can never grant the admin role.
5
+ # Shared guard for staff role grants (admin_users#update and
6
+ # invitations#create). A grant is rejected when, in order:
10
7
  #
11
- # Without this, any principal able to write staff (`write_settings`
12
- # scope, or a `UserManagement`/`RoleManagement` JWT role) could promote
13
- # an account to store super-admin. See the 2026-06 admin API security
14
- # review (Vulns 2-4).
8
+ # 1. (opt-in) the caller can't `:create` a Spree::RoleUser i.e. lacks
9
+ # the RoleManagement permission set;
10
+ # 2. it includes the literal `admin` role and the caller does not hold
11
+ # it on the current store;
12
+ # 3. it includes any role whose permission sets exceed the caller's own
13
+ # (catches SuperUser-equivalent custom roles the name check misses).
14
+ #
15
+ # API-key principals hold no roles, so they can grant only roles that
16
+ # activate no permission sets.
15
17
  module RoleGrantGuard
16
18
  extend ActiveSupport::Concern
17
19
 
18
20
  private
19
21
 
20
22
  # @param role_ids [Array<Integer,String>] resolved (decoded) role ids
23
+ # @param require_role_management [Boolean] also require CanCanCan
24
+ # authority to create a RoleUser. Set by `admin_users#update`, whose
25
+ # own authorization is only `:update` on the user; the invitations
26
+ # flow leaves it off (its gate is `manage Invitation`).
21
27
  # @return [Boolean] true if the request was rejected (caller should return)
22
- def reject_unauthorized_role_grant!(role_ids)
28
+ def reject_unauthorized_role_grant!(role_ids, require_role_management: false)
23
29
  return false if role_ids.blank?
24
30
 
25
- admin_role_id = Spree::Role.admin.pick(:id)
26
- return false if admin_role_id.blank?
27
- return false unless role_ids.map(&:to_s).include?(admin_role_id.to_s)
31
+ # The management gate runs whenever a role mutation is attempted —
32
+ # before resolving ids — so passing unknown ids can't slip the
33
+ # reconciliation (which would still remove the user's current roles)
34
+ # past a caller without role-management authority.
35
+ return true if require_role_management && reject_without_role_management!
36
+
37
+ roles = Spree::Role.where(id: role_ids.map(&:to_s)).to_a
38
+ return false if roles.empty?
39
+
40
+ return true if reject_admin_role_grant!(roles)
41
+ return true if reject_privilege_escalating_grant!(roles)
42
+
43
+ false
44
+ end
45
+
46
+ # For API-key principals CanCanCan is permissive (ScopedAuthorization is
47
+ # their gate), so this only constrains JWT admins.
48
+ def reject_without_role_management!
49
+ return false if scope_limited_principal?
50
+ return false if can?(:create, Spree::RoleUser)
51
+
52
+ deny_role_grant!('You are not authorized to assign roles.')
53
+ end
54
+
55
+ def reject_admin_role_grant!(roles)
56
+ return false unless roles.any? { |role| role.name == Spree::Role::ADMIN_ROLE }
28
57
  return false if caller_holds_admin_role?
29
58
 
59
+ deny_role_grant!('You cannot grant the admin role.')
60
+ end
61
+
62
+ # A caller holding the admin role bounds nothing; the literal admin role
63
+ # is already gated by reject_admin_role_grant!.
64
+ def reject_privilege_escalating_grant!(roles)
65
+ return false if caller_holds_admin_role?
66
+
67
+ caller_sets = Spree.permissions.permission_sets_for_roles(caller_role_names)
68
+ escalating = roles.reject { |role| grantable_within?(role, caller_sets) }
69
+ return false if escalating.empty?
70
+
71
+ deny_role_grant!("You cannot grant roles beyond your own privileges: #{escalating.map(&:name).join(', ')}")
72
+ end
73
+
74
+ # A role is grantable when every permission set it activates is one the
75
+ # caller already holds.
76
+ def grantable_within?(role, caller_sets)
77
+ (Spree.permissions.permission_sets_for(role.name) - caller_sets).empty?
78
+ end
79
+
80
+ # The caller's store-scoped role names, fetched once per request.
81
+ def caller_role_names
82
+ return @caller_role_names if defined?(@caller_role_names)
83
+
84
+ user = try_spree_current_user
85
+ @caller_role_names =
86
+ if user.respond_to?(:role_users)
87
+ user.role_users.where(resource: current_store).joins(:role).
88
+ pluck("#{Spree::Role.table_name}.name")
89
+ else
90
+ []
91
+ end
92
+ end
93
+
94
+ def caller_holds_admin_role?
95
+ caller_role_names.include?(Spree::Role::ADMIN_ROLE)
96
+ end
97
+
98
+ def deny_role_grant!(message)
30
99
  render_error(
31
100
  code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
32
- message: 'You cannot grant the admin role.',
101
+ message: message,
33
102
  status: :forbidden
34
103
  )
35
104
  true
36
105
  end
37
-
38
- # Mirrors how Spree::Ability activates the super-user permission set:
39
- # admin-ness is decided by `spree_roles` membership (resource-agnostic),
40
- # not by a per-store RoleUser. API-key principals have no user and so
41
- # never count as holding the admin role.
42
- def caller_holds_admin_role?
43
- user = try_spree_current_user
44
- return false unless user.respond_to?(:spree_roles)
45
-
46
- user.spree_roles.exists?(name: Spree::Role::ADMIN_ROLE)
47
- end
48
106
  end
49
107
  end
50
108
  end
@@ -39,12 +39,34 @@ module Spree
39
39
  return true
40
40
  end
41
41
 
42
- # Fall back to JWT authentication
43
- require_authentication!
42
+ # Fall back to JWT authentication, then bind the admin to the store
43
+ # they hold a role on (the token itself is store-agnostic).
44
+ return false unless require_authentication!
45
+
46
+ require_store_membership!
44
47
  end
45
48
 
46
49
  private
47
50
 
51
+ # Rejects an authenticated JWT admin who has no role on +current_store+.
52
+ # API-key principals are already store-bound and skip this check.
53
+ def require_store_membership!
54
+ return true if current_user_member_of_store?
55
+
56
+ render_error(
57
+ code: ErrorHandler::ERROR_CODES[:access_denied],
58
+ message: 'You do not have access to this store.',
59
+ status: :forbidden
60
+ )
61
+ false
62
+ end
63
+
64
+ def current_user_member_of_store?
65
+ return false unless current_user.respond_to?(:role_users)
66
+
67
+ current_user.role_users.exists?(resource: current_store)
68
+ end
69
+
48
70
  def set_no_store_cache
49
71
  response.headers['Cache-Control'] = 'private, no-store'
50
72
  end
@@ -35,8 +35,11 @@ module Spree
35
35
 
36
36
  # `nil` when the key is absent (leave roles untouched); an array
37
37
  # (possibly empty, to clear) when the client sends `role_ids`.
38
+ # `require_role_management:` — this action only authorizes `:update`
39
+ # on the user, so role assignment must be separately gated by the
40
+ # RoleManagement permission set, not by profile-edit rights.
38
41
  role_ids = role_ids_param if params.key?(:role_ids)
39
- return if role_ids && reject_unauthorized_role_grant!(role_ids)
42
+ return if role_ids && reject_unauthorized_role_grant!(role_ids, require_role_management: true)
40
43
 
41
44
  if @resource.update(identity_params)
42
45
  apply_role_ids(role_ids) if role_ids
@@ -8,6 +8,10 @@ module Spree
8
8
  # revoke or destroy higher-privileged keys.
9
9
  scoped_resource :api_keys
10
10
 
11
+ # Introspecting the credential you authenticated with never requires
12
+ # `read_api_keys` — any key can describe itself.
13
+ skip_scope_check! only: :current
14
+
11
15
  # POST /api/v3/admin/api_keys
12
16
  # Prevents scope amplification: a key minted via a secret API key can
13
17
  # only carry scopes that key already holds. A JWT admin is governed by
@@ -40,6 +44,27 @@ module Spree
40
44
  render json: serialize_resource(@resource)
41
45
  end
42
46
 
47
+ # GET /api/v3/admin/api_keys/current
48
+ # Describes the key that authenticated this request, including its
49
+ # live scopes — so a client (e.g. the `spree api` CLI) can show the
50
+ # real, current authority instead of a stale local snapshot. Only
51
+ # secret-key principals have a single key; a JWT admin does not.
52
+ #
53
+ # This is the secret-key half of "describe the current credential";
54
+ # the JWT-admin half is GET /api/v3/admin/me (see MeController), which
55
+ # returns the user + their CanCanCan permissions.
56
+ def current
57
+ unless current_api_key
58
+ return render_error(
59
+ code: ERROR_CODES[:record_not_found],
60
+ message: Spree.t(:api_key_no_current_key),
61
+ status: :not_found
62
+ )
63
+ end
64
+
65
+ render json: serialize_resource(current_api_key)
66
+ end
67
+
43
68
  protected
44
69
 
45
70
  def model_class
@@ -62,14 +87,14 @@ module Spree
62
87
  end
63
88
  end
64
89
 
65
- # `key_type` is set on create only flipping a publishable key to a
66
- # secret one (or vice versa) would invalidate every consumer of the
67
- # plaintext token. We strip it on update so that's impossible to do
68
- # by accident through the API.
90
+ # `key_type` and `scopes` are create-only (scope immutability lives on
91
+ # Spree::ApiKey); update is limited to the human-facing `name`. Stripping
92
+ # them here keeps them out of mass assignment so a rename returns 200
93
+ # rather than 422.
69
94
  def permitted_params
70
- attrs = params.permit(:name, :key_type, scopes: [])
71
- attrs.delete(:key_type) if action_name == 'update'
72
- attrs
95
+ return params.permit(:name) if action_name == 'update'
96
+
97
+ params.permit(:name, :key_type, scopes: [])
73
98
  end
74
99
 
75
100
  private
@@ -49,14 +49,12 @@ module Spree
49
49
 
50
50
  private
51
51
 
52
- # Deliberately not scoped through `for_store` this endpoint is the
53
- # path by which a product *enters* the store, so requiring an
54
- # existing publication would block first-time onboarding. The
55
- # cross-store guard runs one level up: `find_resource` already
56
- # restricted the channel to `current_store`.
52
+ # Scoped to the current store: a product can only be published to a
53
+ # channel of the store that owns it (products are single-owner via
54
+ # `belongs_to :store`). Foreign product IDs are silently dropped.
57
55
  def scoped_product_ids
58
56
  ids = decode_prefixed_ids(params[:product_ids])
59
- Spree::Product.accessible_by(current_ability, :update).where(id: ids).ids
57
+ current_store.products.accessible_by(current_ability, :update).where(id: ids).ids
60
58
  end
61
59
  end
62
60
  end
@@ -19,8 +19,8 @@ module Spree
19
19
  end
20
20
 
21
21
  def set_parent
22
- @parent = Spree::Promotion.accessible_by(current_ability, :show)
23
- .find_by_prefix_id!(params[:promotion_id])
22
+ @parent = current_store.promotions.accessible_by(current_ability, :show)
23
+ .find_by_prefix_id!(params[:promotion_id])
24
24
  end
25
25
 
26
26
  def parent_association
@@ -54,7 +54,7 @@ module Spree
54
54
  raise ActiveRecord::RecordNotFound, 'Parent resource not found' unless parent_lookup
55
55
 
56
56
  @parent = parent_relation.find_by_prefix_id!(parent_lookup.value)
57
- authorize!(:show, @parent)
57
+ authorize!(:update, @parent)
58
58
  end
59
59
 
60
60
  # Resolves the parent within the current store so a token for one
@@ -10,6 +10,13 @@ module Spree
10
10
  :credit_cards
11
11
  end
12
12
 
13
+ # Customers are global; a saved card belongs to the store-scoped
14
+ # payment method it was created against, so the nested collection is
15
+ # bound to the current store's payment methods.
16
+ def scope
17
+ @parent.credit_cards.where(payment_method_id: current_store.payment_methods.select(:id))
18
+ end
19
+
13
20
  def model_class
14
21
  Spree::CreditCard
15
22
  end
@@ -67,6 +67,12 @@ module Spree
67
67
  :store_credits
68
68
  end
69
69
 
70
+ # Customers are global; store credits belong to a store, so the
71
+ # nested collection is bound to the current store.
72
+ def scope
73
+ @parent.store_credits.for_store(current_store)
74
+ end
75
+
70
76
  def model_class
71
77
  Spree::StoreCredit
72
78
  end
@@ -11,7 +11,24 @@ module Spree
11
11
  # the permissions list to decide which UI elements to show or hide.
12
12
  # The actual authorization check is still enforced server-side by
13
13
  # CanCanCan — the SPA list is purely for UX.
14
+ #
15
+ # This is the JWT-admin half of "describe the current credential"; the
16
+ # secret-key half is GET /api/v3/admin/api_keys/current (see
17
+ # ApiKeysController#current), which returns the key + its scopes.
18
+ #
19
+ # A request authenticated by a secret API key has no Spree user to
20
+ # describe, so it gets a 404 pointing at the key endpoint rather than
21
+ # a 500 from serializing a nil user — mirroring how #current 404s for
22
+ # a JWT principal that has no single key.
14
23
  def show
24
+ unless current_user
25
+ return render_error(
26
+ code: ERROR_CODES[:record_not_found],
27
+ message: Spree.t(:me_no_current_user),
28
+ status: :not_found
29
+ )
30
+ end
31
+
15
32
  render json: {
16
33
  user: admin_user_serializer.new(current_user, params: serializer_params).to_h,
17
34
  permissions: serialize_permissions(current_ability)
@@ -34,7 +34,7 @@ module Spree
34
34
 
35
35
  def set_parent
36
36
  @product = current_store.products.find_by_prefix_id!(params[:product_id])
37
- authorize!(:show, @product)
37
+ authorize!(:update, @product)
38
38
 
39
39
  @parent = if params[:variant_id].present?
40
40
  @product.variants_including_master.find_by_prefix_id!(params[:variant_id])
@@ -4,8 +4,6 @@ module Spree
4
4
  module Admin
5
5
  module Orders
6
6
  class AdjustmentsController < BaseController
7
- scoped_resource :orders
8
-
9
7
  protected
10
8
 
11
9
  def model_class
@@ -8,6 +8,8 @@ module Spree
8
8
 
9
9
  before_action :authorize_order_access!
10
10
 
11
+ scoped_resource :orders
12
+
11
13
  protected
12
14
 
13
15
  def set_parent
@@ -57,11 +57,11 @@ module Spree
57
57
  # PATCH /api/v3/admin/orders/:order_id/fulfillments/:id/split
58
58
  def split
59
59
  with_order_lock do
60
- variant = Spree::Variant.find_by_prefix_id!(params[:variant_id])
60
+ variant = current_store.variants.find_by_prefix_id!(params[:variant_id])
61
61
  quantity = params[:quantity].to_i
62
62
 
63
63
  stock_location = if params[:stock_location_id].present?
64
- Spree::StockLocation.find_by_prefix_id!(params[:stock_location_id])
64
+ Spree::StockLocation.accessible_by(current_ability, :show).find_by_prefix_id!(params[:stock_location_id])
65
65
  else
66
66
  @resource.stock_location
67
67
  end
@@ -93,8 +93,10 @@ module Spree
93
93
  :shipments
94
94
  end
95
95
 
96
+ # State changes go through the dedicated `fulfill`/`cancel`/`resume`
97
+ # member actions, not mass assignment.
96
98
  def permitted_params
97
- params.permit(:tracking, :selected_shipping_rate_id, :stock_location_id, :state)
99
+ params.permit(:tracking, :selected_shipping_rate_id, :stock_location_id)
98
100
  end
99
101
  end
100
102
  end
@@ -4,8 +4,6 @@ module Spree
4
4
  module Admin
5
5
  module Orders
6
6
  class GiftCardsController < BaseController
7
- scoped_resource :gift_cards
8
-
9
7
  skip_before_action :set_resource, raise: false
10
8
 
11
9
  # POST /api/v3/admin/orders/:order_id/gift_cards
@@ -4,8 +4,6 @@ module Spree
4
4
  module Admin
5
5
  module Orders
6
6
  class ItemsController < BaseController
7
- scoped_resource :orders
8
-
9
7
  # POST /api/v3/admin/orders/:order_id/items
10
8
  def create
11
9
  with_order_lock do
@@ -15,7 +15,7 @@ module Spree
15
15
  # The source must belong to the customer assigned to the order.
16
16
  def create
17
17
  with_order_lock do
18
- payment_method = Spree::PaymentMethod.find_by_prefix_id!(params[:payment_method_id])
18
+ payment_method = current_store.payment_methods.accessible_by(current_ability, :show).find_by_prefix_id!(params[:payment_method_id])
19
19
  @resource = @parent.payments.build(
20
20
  amount: params[:amount] || @parent.order_total_after_store_credit,
21
21
  payment_method: payment_method
@@ -9,9 +9,9 @@ module Spree
9
9
  # POST /api/v3/admin/orders/:order_id/refunds
10
10
  def create
11
11
  with_order_lock do
12
- payment = @parent.payments.find_by_prefix_id!(params[:payment_id])
13
- reason = Spree::RefundReason.find_by_prefix_id!(params[:refund_reason_id]) if params[:refund_reason_id].present?
14
- reason ||= Spree::RefundReason.first
12
+ payment = @parent.payments.accessible_by(current_ability, :update).find_by_prefix_id!(params[:payment_id])
13
+ reason = Spree::RefundReason.accessible_by(current_ability, :show).find_by_prefix_id!(params[:refund_reason_id]) if params[:refund_reason_id].present?
14
+ reason ||= Spree::RefundReason.accessible_by(current_ability, :show).first
15
15
 
16
16
  @resource = payment.refunds.build(
17
17
  amount: params[:amount],
@@ -4,8 +4,6 @@ module Spree
4
4
  module Admin
5
5
  module Orders
6
6
  class StoreCreditsController < BaseController
7
- scoped_resource :store_credits
8
-
9
7
  skip_before_action :set_resource, raise: false
10
8
 
11
9
  # POST /api/v3/admin/orders/:order_id/store_credits
@@ -60,11 +60,9 @@ module Spree
60
60
 
61
61
  private
62
62
 
63
- # New payment methods get scoped to the current store automatically.
63
+ # New payment methods are created through the current store.
64
64
  def build_subclassed_resource(klass, attrs)
65
- resource = klass.new(attrs)
66
- resource.stores = [current_store] if resource.stores.empty?
67
- resource
65
+ current_store.payment_methods.build(attrs.merge(type: klass.sti_name))
68
66
  end
69
67
  end
70
68
  end
@@ -35,7 +35,13 @@ module Spree
35
35
  )
36
36
  end
37
37
 
38
- foreign = foreign_rows(rows)
38
+ store_variant_ids = store_variants.where(id: rows.map { |r| r[:variant_id] }).ids.map(&:to_s).to_set
39
+ store_price_list_ids = store_price_lists.where(id: rows.filter_map { |r| r[:price_list_id] }).ids.map(&:to_s).to_set
40
+ foreign = rows.each_with_index.filter_map do |row, idx|
41
+ variant_ok = store_variant_ids.include?(row[:variant_id].to_s)
42
+ price_list_ok = row[:price_list_id].blank? || store_price_list_ids.include?(row[:price_list_id].to_s)
43
+ { index: idx } unless variant_ok && price_list_ok
44
+ end
39
45
  if foreign.any?
40
46
  return render_error(
41
47
  code: 'invalid_prices',
@@ -91,14 +97,26 @@ module Spree
91
97
  false
92
98
  end
93
99
 
100
+ # Resolves variant_id / price_list_id through the current store's
101
+ # scopes so a foreign or unknown id 404s instead of binding the
102
+ # price to another store's record.
94
103
  def permitted_params
95
- normalize_params(
96
- params.permit(:variant_id, :currency, :amount, :compare_at_amount, :price_list_id)
97
- )
104
+ permitted = params.permit(:variant_id, :currency, :amount, :compare_at_amount, :price_list_id)
105
+ permitted[:variant_id] = store_variants.find_by_prefix_id!(permitted[:variant_id]).id if permitted[:variant_id].present?
106
+ permitted[:price_list_id] = store_price_lists.find_by_prefix_id!(permitted[:price_list_id]).id if permitted[:price_list_id].present?
107
+ permitted
98
108
  end
99
109
 
100
110
  private
101
111
 
112
+ def store_variants
113
+ current_store.variants.accessible_by(current_ability, :update)
114
+ end
115
+
116
+ def store_price_lists
117
+ current_store.price_lists.accessible_by(current_ability, :update)
118
+ end
119
+
102
120
  def bulk_record_count_key
103
121
  :price_count
104
122
  end
@@ -132,24 +150,6 @@ module Spree
132
150
 
133
151
  Spree::PrefixedId.prefixed_id?(value) ? Spree::PrefixedId.decode_prefixed_id(value) : value
134
152
  end
135
-
136
- # Rejects rows whose variant or price list belongs to another store.
137
- # `Spree::Prices::BulkUpsert` writes rows keyed on the raw variant_id
138
- # with no ownership check, so the store boundary is enforced here.
139
- def foreign_rows(rows)
140
- variant_ids = rows.map { |r| r[:variant_id] }.compact.uniq
141
- price_list_ids = rows.map { |r| r[:price_list_id] }.compact.uniq
142
-
143
- store_variant_ids = current_store.variants.where(id: variant_ids).pluck(:id).map(&:to_s).to_set
144
- store_price_list_ids = Spree::PriceList.for_store(current_store).where(id: price_list_ids).pluck(:id).map(&:to_s).to_set
145
-
146
- rows.each_with_index.filter_map do |row, idx|
147
- variant_ok = store_variant_ids.include?(row[:variant_id].to_s)
148
- price_list_ok = row[:price_list_id].blank? || store_price_list_ids.include?(row[:price_list_id].to_s)
149
-
150
- { index: idx } unless variant_ok && price_list_ok
151
- end
152
- end
153
153
  end
154
154
  end
155
155
  end
@@ -31,7 +31,7 @@ module Spree
31
31
 
32
32
  def permitted_params
33
33
  params.permit(
34
- :sku, :barcode, :price, :compare_at_price,
34
+ :sku, :barcode,
35
35
  :cost_price, :cost_currency,
36
36
  :weight, :height, :width, :depth, :weight_unit, :dimensions_unit,
37
37
  :track_inventory, :tax_category_id, :position,
@@ -58,8 +58,8 @@ module Spree
58
58
  def set_parent
59
59
  return if %w[types calculators].include?(action_name)
60
60
 
61
- @parent = Spree::Promotion.accessible_by(current_ability, :show)
62
- .find_by_prefix_id!(params[:promotion_id])
61
+ @parent = current_store.promotions.accessible_by(current_ability, :update)
62
+ .find_by_prefix_id!(params[:promotion_id])
63
63
  end
64
64
 
65
65
  def parent_association
@@ -36,8 +36,8 @@ module Spree
36
36
  def set_parent
37
37
  return if action_name == 'types'
38
38
 
39
- @parent = Spree::Promotion.accessible_by(current_ability, :show)
40
- .find_by_prefix_id!(params[:promotion_id])
39
+ @parent = current_store.promotions.accessible_by(current_ability, :update)
40
+ .find_by_prefix_id!(params[:promotion_id])
41
41
  end
42
42
 
43
43
  def parent_association
@@ -28,6 +28,13 @@ module Spree
28
28
  def apply_collection_sort(collection)
29
29
  collection.order(Spree::StockItem.arel_table[:id].asc)
30
30
  end
31
+
32
+ # Stock items are auto-created against a (variant, stock_location)
33
+ # pair and never re-pointed, so update only touches the count and
34
+ # backorder flag — not the variant or location FKs.
35
+ def permitted_params
36
+ params.permit(:count_on_hand, :backorderable, metadata: {})
37
+ end
31
38
  end
32
39
  end
33
40
  end
@@ -15,6 +15,12 @@ module Spree
15
15
  Spree.api.admin_stock_location_serializer
16
16
  end
17
17
 
18
+ # Stock locations are shared across stores, so writes are store-wide
19
+ # administration: reads need `read_stock`, writes need `write_settings`.
20
+ def scoped_resource_name
21
+ read_actions.include?(action_name) ? :stock : :settings
22
+ end
23
+
18
24
  def scope
19
25
  super.order_default
20
26
  end
@@ -11,9 +11,10 @@ module Spree
11
11
  def create
12
12
  authorize!(:create, model_class)
13
13
 
14
- destination = Spree::StockLocation.find_by_prefix_id!(params[:destination_location_id])
14
+ stock_locations = Spree::StockLocation.accessible_by(current_ability, :show)
15
+ destination = stock_locations.find_by_prefix_id!(params[:destination_location_id])
15
16
  source = params[:source_location_id].present? ?
16
- Spree::StockLocation.find_by_prefix_id!(params[:source_location_id]) : nil
17
+ stock_locations.find_by_prefix_id!(params[:source_location_id]) : nil
17
18
 
18
19
  variants_map = build_variants_map
19
20
  if variants_map.empty?
@@ -63,7 +64,7 @@ module Spree
63
64
  hash[decoded.to_i] = entry[:quantity].to_i if decoded
64
65
  end
65
66
 
66
- Spree::Variant.where(id: quantities_by_id.keys).each_with_object({}) do |variant, acc|
67
+ current_store.variants.accessible_by(current_ability, :update).where(id: quantities_by_id.keys).each_with_object({}) do |variant, acc|
67
68
  quantity = quantities_by_id[variant.id]
68
69
  acc[variant] = quantity if quantity&.positive?
69
70
  end
@@ -3,24 +3,32 @@ module Spree
3
3
  module V3
4
4
  module Admin
5
5
  class TagsController < BaseController
6
+ # The required scope depends on the requested `taggable_type`, so it's
7
+ # resolved per request rather than via a static `scoped_resource`.
8
+ # `authorize_taggable_scope!` enforces it for API-key principals.
6
9
  skip_scope_check!
7
10
 
11
+ before_action :authorize_taggable_scope!, only: :index
12
+
8
13
  MAX_RESULTS = 50
9
14
 
15
+ # Maps a taggable type to the read scope an API key must hold to
16
+ # enumerate its tag vocabulary. Types absent from this map require
17
+ # `read_all`.
18
+ def self.scope_for_taggable_type
19
+ {
20
+ 'Spree::Product' => 'read_products',
21
+ 'Spree::Order' => 'read_orders',
22
+ Spree.user_class.to_s => 'read_customers'
23
+ }
24
+ end
25
+
10
26
  def index
11
- taggable_type = params[:taggable_type].to_s
12
- unless allowed_taggable_types.include?(taggable_type)
13
- render_error(
14
- code: 'invalid_taggable_type',
15
- message: "taggable_type must be one of #{allowed_taggable_types.join(', ')}",
16
- status: :unprocessable_content
17
- )
18
- return
19
- end
27
+ return unless valid_taggable_type?
20
28
 
21
29
  scope = ActsAsTaggableOn::Tag.
22
30
  joins(:taggings).
23
- where(ActsAsTaggableOn.taggings_table => { taggable_type: taggable_type, context: 'tags' }).
31
+ where(ActsAsTaggableOn.taggings_table => taggings_conditions).
24
32
  distinct.
25
33
  order(:name).
26
34
  limit(MAX_RESULTS)
@@ -37,6 +45,48 @@ module Spree
37
45
 
38
46
  private
39
47
 
48
+ def taggable_type
49
+ params[:taggable_type].to_s
50
+ end
51
+
52
+ def valid_taggable_type?
53
+ return true if allowed_taggable_types.include?(taggable_type)
54
+
55
+ render_error(
56
+ code: 'invalid_taggable_type',
57
+ message: "taggable_type must be one of #{allowed_taggable_types.join(', ')}",
58
+ status: :unprocessable_content
59
+ )
60
+ false
61
+ end
62
+
63
+ # Tagging filter. `Spree::Order` carries a `tenant` (store_id) column
64
+ # via `acts_as_taggable_tenant`, so its tag vocabulary is bounded to
65
+ # the current store; other taggables fall back to the type filter.
66
+ def taggings_conditions
67
+ conditions = { taggable_type: taggable_type, context: 'tags' }
68
+ conditions[:tenant] = current_store.id.to_s if taggable_type == 'Spree::Order'
69
+ conditions
70
+ end
71
+
72
+ # Per-type scope check for API-key principals: a key listing product
73
+ # tags needs `read_products`, order tags `read_orders`, etc. JWT
74
+ # admins are gated by store membership + CanCanCan, not scopes.
75
+ def authorize_taggable_scope!
76
+ return unless current_api_key
77
+ return unless allowed_taggable_types.include?(taggable_type)
78
+
79
+ required = self.class.scope_for_taggable_type.fetch(taggable_type, 'read_all')
80
+ return if current_api_key.has_scope?(required)
81
+
82
+ render_error(
83
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
84
+ message: "API key lacks scope: #{required}",
85
+ status: :forbidden,
86
+ details: { required_scope: required }
87
+ )
88
+ end
89
+
40
90
  # Sourced from `Spree.taggable_types` (registered in
41
91
  # `Spree::Core::Engine`'s after_initialize block). Apps extend the
42
92
  # list in an initializer without overriding this controller:
data/config/routes.rb CHANGED
@@ -135,7 +135,10 @@ Spree::Core::Engine.add_routes do
135
135
  patch :resend
136
136
  end
137
137
  end
138
- resources :api_keys, only: [:index, :show, :create, :destroy] do
138
+ resources :api_keys, only: [:index, :show, :create, :update, :destroy] do
139
+ collection do
140
+ get :current
141
+ end
139
142
  member do
140
143
  patch :revoke
141
144
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.5.0.rc2
4
+ version: 5.5.0.rc3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vendo Connect Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-15 00:00:00.000000000 Z
11
+ date: 2026-06-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rswag-specs
@@ -72,14 +72,14 @@ dependencies:
72
72
  requirements:
73
73
  - - '='
74
74
  - !ruby/object:Gem::Version
75
- version: 5.5.0.rc2
75
+ version: 5.5.0.rc3
76
76
  type: :runtime
77
77
  prerelease: false
78
78
  version_requirements: !ruby/object:Gem::Requirement
79
79
  requirements:
80
80
  - - '='
81
81
  - !ruby/object:Gem::Version
82
- version: 5.5.0.rc2
82
+ version: 5.5.0.rc3
83
83
  description: Spree's API
84
84
  email:
85
85
  - hello@spreecommerce.org
@@ -353,9 +353,9 @@ licenses:
353
353
  - BSD-3-Clause
354
354
  metadata:
355
355
  bug_tracker_uri: https://github.com/spree/spree/issues
356
- changelog_uri: https://github.com/spree/spree/releases/tag/v5.5.0.rc2
356
+ changelog_uri: https://github.com/spree/spree/releases/tag/v5.5.0.rc3
357
357
  documentation_uri: https://docs.spreecommerce.org/
358
- source_code_uri: https://github.com/spree/spree/tree/v5.5.0.rc2
358
+ source_code_uri: https://github.com/spree/spree/tree/v5.5.0.rc3
359
359
  post_install_message:
360
360
  rdoc_options: []
361
361
  require_paths: