spree_api 5.5.0.rc1 → 5.5.0.rc2

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 (28) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +17 -0
  3. data/app/controllers/concerns/spree/api/v3/admin/role_grant_guard.rb +52 -0
  4. data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +19 -3
  5. data/app/controllers/spree/api/v3/admin/admin_users_controller.rb +19 -7
  6. data/app/controllers/spree/api/v3/admin/api_keys_controller.rb +30 -1
  7. data/app/controllers/spree/api/v3/admin/custom_field_definitions_controller.rb +1 -1
  8. data/app/controllers/spree/api/v3/admin/custom_fields_controller.rb +25 -4
  9. data/app/controllers/spree/api/v3/admin/customers/addresses_controller.rb +1 -6
  10. data/app/controllers/spree/api/v3/admin/customers/base_controller.rb +33 -0
  11. data/app/controllers/spree/api/v3/admin/customers/credit_cards_controller.rb +1 -7
  12. data/app/controllers/spree/api/v3/admin/customers/store_credits_controller.rb +4 -5
  13. data/app/controllers/spree/api/v3/admin/exports_controller.rb +48 -1
  14. data/app/controllers/spree/api/v3/admin/invitations_controller.rb +11 -0
  15. data/app/controllers/spree/api/v3/admin/orders/base_controller.rb +6 -1
  16. data/app/controllers/spree/api/v3/admin/price_lists_controller.rb +24 -1
  17. data/app/controllers/spree/api/v3/admin/prices_controller.rb +28 -0
  18. data/app/controllers/spree/api/v3/admin/stock_items_controller.rb +1 -1
  19. data/app/controllers/spree/api/v3/admin/stock_locations_controller.rb +1 -1
  20. data/app/controllers/spree/api/v3/admin/stock_reservations_controller.rb +1 -1
  21. data/app/controllers/spree/api/v3/admin/stock_transfers_controller.rb +1 -1
  22. data/app/controllers/spree/api/v3/admin/variants_controller.rb +1 -1
  23. data/app/controllers/spree/api/v3/admin/webhook_deliveries_controller.rb +1 -1
  24. data/app/controllers/spree/api/v3/admin/webhook_endpoints_controller.rb +1 -1
  25. data/app/controllers/spree/api/v3/resource_controller.rb +43 -16
  26. data/app/controllers/spree/api/v3/store/products_controller.rb +1 -0
  27. data/lib/spree/api/testing_support/v3/base.rb +28 -0
  28. metadata +8 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34c425ef2a24c388679efdce7af49e3a00df8ec545d54571d11623b35ca0a130
4
- data.tar.gz: eda87fa654dbf4789c82ea441a4ddcf66971d91323904ad71ca68ef0d30292a5
3
+ metadata.gz: a94767b03984d4ebb7c65b91c9512aac96e909f9d2308f06ed1e96e53f2a3580
4
+ data.tar.gz: 8c0f0201d44ca50071cd273c932b497fcafd2f954f7d9e213772f4fe16595ab1
5
5
  SHA512:
6
- metadata.gz: 2c89bedcfd4322921097213e93c2cfb8308c0dbbd960b848c21d625d42b85cf6f845311b56dd02b06e6c392fb38680796cfa1f8e74dc52d135cb870e13c062ac
7
- data.tar.gz: f55d6b06ad6db697192f6b1678833062c3fbb3ad05d429c523a5dc06896b87fa2c8ab2b66300041eb8bf07befb2b2bc42eda2f569cb6f8760984c5626a6ea2d6
6
+ metadata.gz: b1ae1474848c3bd7fc3bea51374c1a523b4bf08e94d75f3ea82c60fa1ce43b812dccf2c37aae01c2225e9d3281c7d16868dd7b28431f30ad177913fbb085b7aa
7
+ data.tar.gz: 1830848629f174cf25342d0c3bc584aba47ea4365b890b623bf6df8a44bfed3bc6566e95cd264517e79379d8569ce66baf18ffd2be4186143a265a5ef81d1ef1
data/Rakefile CHANGED
@@ -45,6 +45,23 @@ namespace :rswag do
45
45
  puts "Reordered paths by tag → #{path}"
46
46
  end
47
47
  end
48
+
49
+ # Keep the CLI's bundled offline spec (@spree/cli's `admin-spec.json` /
50
+ # `resource-paths.json`) in sync with the freshly generated admin.yaml,
51
+ # so `spree api endpoints`/`schema` never drift from the published docs.
52
+ # Best-effort: skipped with a notice when the JS toolchain isn't present
53
+ # (this is a Ruby/doc task and shouldn't hard-fail on a missing runtime).
54
+ bundler = File.expand_path('../../packages/cli/scripts/bundle-spec.mjs', __dir__)
55
+ if File.exist?(bundler) && system('node', '--version', out: File::NULL, err: File::NULL)
56
+ cli_dir = File.expand_path('../../packages/cli', __dir__)
57
+ if system('node', bundler, chdir: cli_dir)
58
+ puts 'Regenerated CLI offline spec → packages/cli/src/generated/'
59
+ else
60
+ warn 'WARNING: failed to regenerate CLI offline spec (bundle-spec.mjs); run `pnpm --filter @spree/cli build` to resync.'
61
+ end
62
+ else
63
+ puts 'Skipping CLI offline spec regeneration (node/bundle-spec.mjs unavailable).'
64
+ end
48
65
  end
49
66
  end
50
67
  end
@@ -0,0 +1,52 @@
1
+ module Spree
2
+ module Api
3
+ module V3
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.
10
+ #
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).
15
+ module RoleGrantGuard
16
+ extend ActiveSupport::Concern
17
+
18
+ private
19
+
20
+ # @param role_ids [Array<Integer,String>] resolved (decoded) role ids
21
+ # @return [Boolean] true if the request was rejected (caller should return)
22
+ def reject_unauthorized_role_grant!(role_ids)
23
+ return false if role_ids.blank?
24
+
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)
28
+ return false if caller_holds_admin_role?
29
+
30
+ render_error(
31
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
32
+ message: 'You cannot grant the admin role.',
33
+ status: :forbidden
34
+ )
35
+ true
36
+ 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
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -33,15 +33,23 @@ module Spree
33
33
  self._scope_check_skipped = false
34
34
  end
35
35
 
36
- # Opt out of scope checks (auth, me, tags, direct uploads, etc).
37
- def skip_scope_check!
38
- self._scope_check_skipped = true
36
+ # Opt out of scope checks — for the whole controller (auth, me,
37
+ # tags, etc.) or for specific actions (`only: :index`) when an
38
+ # action authorizes another way, e.g. by filtering its collection
39
+ # per-type (exports).
40
+ def skip_scope_check!(only: nil)
41
+ if only
42
+ self._scope_check_skipped_actions = Array(only).map(&:to_s)
43
+ else
44
+ self._scope_check_skipped = true
45
+ end
39
46
  end
40
47
  end
41
48
 
42
49
  included do
43
50
  class_attribute :_scoped_resource, instance_accessor: false
44
51
  class_attribute :_scope_check_skipped, instance_accessor: false, default: false
52
+ class_attribute :_scope_check_skipped_actions, instance_accessor: false
45
53
  before_action :authorize_api_key_scope!
46
54
  end
47
55
 
@@ -50,6 +58,7 @@ module Spree
50
58
  def authorize_api_key_scope!
51
59
  return unless current_api_key
52
60
  return if self.class._scope_check_skipped
61
+ return if self.class._scope_check_skipped_actions&.include?(action_name)
53
62
 
54
63
  resource = scoped_resource_name
55
64
  # Fail closed: a controller authenticated by API key MUST declare
@@ -82,6 +91,13 @@ module Spree
82
91
  def action_kind
83
92
  READ_ACTIONS.include?(action_name) ? 'read' : 'write'
84
93
  end
94
+
95
+ # True when authorization derives from the API key's scopes rather
96
+ # than a JWT admin's CanCanCan ability. Mirrors the credential
97
+ # precedence in AdminAuthentication#current_ability (JWT user wins).
98
+ def scope_limited_principal?
99
+ current_api_key.present? && current_user.blank?
100
+ end
85
101
  end
86
102
  end
87
103
  end
@@ -8,6 +8,8 @@ module Spree
8
8
  # this v3 endpoint instead removes the per-store `RoleUser` rows so
9
9
  # the user keeps their account (and access to other stores).
10
10
  class AdminUsersController < ResourceController
11
+ include Spree::Api::V3::Admin::RoleGrantGuard
12
+
11
13
  scoped_resource :settings
12
14
 
13
15
  # POST is not exposed — staff are created via invitations.
@@ -31,9 +33,13 @@ module Spree
31
33
  def update
32
34
  authorize!(:update, @resource)
33
35
 
34
- attrs = identity_params
35
- if @resource.update(attrs)
36
- apply_role_ids(role_ids_param) if params.key?(:role_ids)
36
+ # `nil` when the key is absent (leave roles untouched); an array
37
+ # (possibly empty, to clear) when the client sends `role_ids`.
38
+ role_ids = role_ids_param if params.key?(:role_ids)
39
+ return if role_ids && reject_unauthorized_role_grant!(role_ids)
40
+
41
+ if @resource.update(identity_params)
42
+ apply_role_ids(role_ids) if role_ids
37
43
  render json: serialize_resource(@resource)
38
44
  else
39
45
  render_validation_error(@resource.errors)
@@ -55,12 +61,18 @@ module Spree
55
61
  end
56
62
 
57
63
  # Restrict to users with a role assignment on the current store.
58
- # `accessible_by` enforces CanCanCan on top.
64
+ # `accessible_by` enforces CanCanCan on top. Deduplication happens via
65
+ # an ID subquery rather than `SELECT DISTINCT *` — the users table is
66
+ # host-app-defined and may contain `json` columns, which Postgres
67
+ # cannot compare for equality.
59
68
  def scope
60
- model_class.
69
+ staff_ids = model_class.
61
70
  joins(:role_users).
62
- where(spree_role_users: { resource: current_store }).
63
- distinct.
71
+ where(Spree::RoleUser.table_name => { resource: current_store }).
72
+ select(:id)
73
+
74
+ model_class.
75
+ where(id: staff_ids).
64
76
  accessible_by(current_ability, :show)
65
77
  end
66
78
 
@@ -3,7 +3,30 @@ module Spree
3
3
  module V3
4
4
  module Admin
5
5
  class ApiKeysController < ResourceController
6
- scoped_resource :settings
6
+ # Dedicated scope — key management is credential-administration, not
7
+ # store configuration. A `write_settings` key must not be able to
8
+ # revoke or destroy higher-privileged keys.
9
+ scoped_resource :api_keys
10
+
11
+ # POST /api/v3/admin/api_keys
12
+ # Prevents scope amplification: a key minted via a secret API key can
13
+ # only carry scopes that key already holds. A JWT admin is governed by
14
+ # CanCanCan (not scopes) and may grant any valid scope — so when a JWT
15
+ # user authenticated the request, `current_ability` ignores the API key
16
+ # (see AdminAuthentication#current_ability) and we skip the scope cap
17
+ # too, even if an `X-Spree-Api-Key` header was also sent.
18
+ def create
19
+ if scope_limited_principal? && (excess = requested_scopes.reject { |s| current_api_key.has_scope?(s) }).any?
20
+ return render_error(
21
+ code: ERROR_CODES[:access_denied],
22
+ message: "Cannot grant scopes beyond your own: #{excess.join(', ')}",
23
+ status: :forbidden,
24
+ details: { excess_scopes: excess }
25
+ )
26
+ end
27
+
28
+ super
29
+ end
7
30
 
8
31
  # PATCH /api/v3/admin/api_keys/:id/revoke
9
32
  # Marks the key revoked rather than deleting it — the row stays so
@@ -48,6 +71,12 @@ module Spree
48
71
  attrs.delete(:key_type) if action_name == 'update'
49
72
  attrs
50
73
  end
74
+
75
+ private
76
+
77
+ def requested_scopes
78
+ Array(params[:scopes]).map(&:to_s).reject(&:blank?)
79
+ end
51
80
  end
52
81
  end
53
82
  end
@@ -8,7 +8,7 @@ module Spree
8
8
  # (or any other registered custom-field-bearing resource) to scope the
9
9
  # list to one parent type.
10
10
  class CustomFieldDefinitionsController < ResourceController
11
- scoped_resource :custom_field_definitions
11
+ scoped_resource :settings
12
12
 
13
13
  protected
14
14
 
@@ -53,15 +53,34 @@ module Spree
53
53
  # defensive guard against a future route nesting that doesn't.
54
54
  raise ActiveRecord::RecordNotFound, 'Parent resource not found' unless parent_lookup
55
55
 
56
- @parent = parent_lookup.klass.find_by_prefix_id!(parent_lookup.value)
56
+ @parent = parent_relation.find_by_prefix_id!(parent_lookup.value)
57
+ authorize!(:show, @parent)
57
58
  end
58
59
 
60
+ # Resolves the parent within the current store so a token for one
61
+ # store can't read or write custom fields on another store's records.
62
+ # Users are intentionally global in Spree (`User.for_store` is a
63
+ # no-op), so the store boundary for customer parents is enforced by
64
+ # the `authorize!(:show, @parent)` ability check above.
65
+ def parent_relation
66
+ klass = parent_lookup.klass
67
+ klass.respond_to?(:for_store) ? klass.for_store(current_store) : klass
68
+ end
69
+
70
+ # Product-family parents have no scope pair of their own — their
71
+ # custom fields are catalog data, gated by `products` like the rest
72
+ # of the variant/option-type surface.
73
+ SCOPE_OVERRIDES = { 'option_type' => :products, 'variant' => :products }.freeze
74
+
59
75
  # Per-parent scope check: a key holding `write_products` may write a
60
76
  # product's custom fields, `write_orders` may write an order's, etc.
61
77
  # Resolves the parent at request time rather than via the static
62
78
  # `scoped_resource` declaration.
63
79
  def scoped_resource_name
64
- parent_lookup&.segment&.pluralize&.to_sym
80
+ segment = parent_lookup&.segment
81
+ return unless segment
82
+
83
+ SCOPE_OVERRIDES[segment] || segment.pluralize.to_sym
65
84
  end
66
85
 
67
86
  # `custom_field_definition_id` is an alias_attribute on Spree::CustomField;
@@ -81,11 +100,13 @@ module Spree
81
100
  # Stores class names (not class objects) so the map survives dev-mode
82
101
  # code reloads — `enabled_resources` is captured at boot and its
83
102
  # class references go stale. Aliases `'customer'` because the route
84
- # uses `customer_id` while user_class.model_name.element is `'user'`.
103
+ # uses `customer_id` while user_class.model_name.element is `'user'`,
104
+ # and `'category'` because the routes expose taxons as categories
105
+ # (5.5 rename) while the model's element is still `'taxon'`.
85
106
  def parent_route_map
86
107
  @parent_route_map ||= Spree.metafields.enabled_resources.each_with_object({}) do |klass, m|
87
108
  m[klass.model_name.element.to_s] = klass.name
88
- end.merge('customer' => Spree.user_class.name)
109
+ end.merge('customer' => Spree.user_class.name, 'category' => 'Spree::Taxon')
89
110
  end
90
111
 
91
112
  # Returns the first segment whose `<segment>_id` is present in params,
@@ -3,8 +3,7 @@ module Spree
3
3
  module V3
4
4
  module Admin
5
5
  module Customers
6
- class AddressesController < ResourceController
7
- scoped_resource :customers
6
+ class AddressesController < BaseController
8
7
 
9
8
  # POST /api/v3/admin/customers/:customer_id/addresses
10
9
  def create
@@ -46,10 +45,6 @@ module Spree
46
45
 
47
46
  protected
48
47
 
49
- def set_parent
50
- @parent = Spree.user_class.find_by_prefix_id!(params[:customer_id])
51
- end
52
-
53
48
  def parent_association
54
49
  :addresses
55
50
  end
@@ -0,0 +1,33 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ module Customers
6
+ # Shared base for resources nested under a customer
7
+ # (`/admin/customers/:customer_id/...`). Resolves the parent customer
8
+ # and authorizes it per action (`:show` for reads, `:update` for
9
+ # writes) so a role that can only view a customer can't mutate its
10
+ # nested collections. Mirrors `Orders::BaseController`.
11
+ class BaseController < ResourceController
12
+ scoped_resource :customers
13
+
14
+ protected
15
+
16
+ # Resolve the customer through the ability-scoped relation, using
17
+ # the action-appropriate ability on the parent (`:show` for reads,
18
+ # `:update` for writes — see `parent_ability_action`). A customer
19
+ # the caller can't access for the requested action is filtered out
20
+ # and 404s, rather than leaking its existence as a 403. Users are
21
+ # global in Spree (`User.for_store` is a no-op), so the ability is
22
+ # the only boundary here.
23
+ def set_parent
24
+ @parent = Spree.user_class.
25
+ accessible_by(current_ability, parent_ability_action).
26
+ find_by_prefix_id!(params[:customer_id])
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -3,15 +3,9 @@ module Spree
3
3
  module V3
4
4
  module Admin
5
5
  module Customers
6
- class CreditCardsController < ResourceController
7
- scoped_resource :customers
8
-
6
+ class CreditCardsController < BaseController
9
7
  protected
10
8
 
11
- def set_parent
12
- @parent = Spree.user_class.find_by_prefix_id!(params[:customer_id])
13
- end
14
-
15
9
  def parent_association
16
10
  :credit_cards
17
11
  end
@@ -3,7 +3,10 @@ module Spree
3
3
  module V3
4
4
  module Admin
5
5
  module Customers
6
- class StoreCreditsController < ResourceController
6
+ class StoreCreditsController < BaseController
7
+ # Store credits gate on their own scope rather than the parent's
8
+ # `:customers`, so a store-credit integration key doesn't need
9
+ # broad customer access.
7
10
  scoped_resource :store_credits
8
11
 
9
12
  # POST /api/v3/admin/customers/:customer_id/store_credits
@@ -60,10 +63,6 @@ module Spree
60
63
 
61
64
  protected
62
65
 
63
- def set_parent
64
- @parent = Spree.user_class.find_by_prefix_id!(params[:customer_id])
65
- end
66
-
67
66
  def parent_association
68
67
  :store_credits
69
68
  end
@@ -3,10 +3,18 @@ module Spree
3
3
  module V3
4
4
  module Admin
5
5
  # See `docs/plans/5.5-admin-spa-csv-export.md`.
6
+ #
7
+ # There is no standalone exports scope: an export is a bulk read of
8
+ # the records it contains, so each export type is gated by the read
9
+ # scope of the exported resource (Spree::Exports::Customers =>
10
+ # `read_customers`; see Spree::Export.required_scope), and the index
11
+ # is filtered to the types the key can read.
6
12
  class ExportsController < ResourceController
7
13
  include ActiveStorage::SetCurrent
8
14
 
9
- scoped_resource :exports
15
+ # The index spans many export types — `scope` filters it to the
16
+ # readable ones instead of gating on a single scope.
17
+ skip_scope_check! only: :index
10
18
 
11
19
  # We stream the CSV inline rather than redirecting to ActiveStorage's
12
20
  # signed-URL endpoint because the SPA's Vite proxy only forwards
@@ -47,6 +55,32 @@ module Spree
47
55
  [:user, { attachment_attachment: :blob }]
48
56
  end
49
57
 
58
+ def scope
59
+ collection = super
60
+ return collection unless scope_limited_principal?
61
+
62
+ collection.where(type: readable_export_types)
63
+ end
64
+
65
+ # Loaded by both the scope gate (before_action) and the member
66
+ # actions — memoize so the record is fetched once.
67
+ def find_resource
68
+ @find_resource ||= super
69
+ end
70
+
71
+ # Exports never mutate commerce data; creating or downloading one
72
+ # is a bulk read, so every action maps to the read-level scope.
73
+ def action_kind
74
+ 'read'
75
+ end
76
+
77
+ # Unresolvable types (blank/unknown `type` on create) fall back to
78
+ # `:all`, so only `read_all`/`write_all` keys reach the model's own
79
+ # validation.
80
+ def scoped_resource_name
81
+ export_class&.required_scope || :all
82
+ end
83
+
50
84
  def build_resource
51
85
  klass = resolve_export_type(permitted_params[:type]) || Spree::Export
52
86
  attrs = permitted_params.except(:type).merge(
@@ -82,6 +116,19 @@ module Spree
82
116
  target = Spree::Export.available_types.map(&:to_s).find { |t| t == name.to_s }
83
117
  target&.constantize
84
118
  end
119
+
120
+ private
121
+
122
+ def export_class
123
+ action_name == 'create' ? resolve_export_type(params[:type]) : find_resource.class
124
+ end
125
+
126
+ def readable_export_types
127
+ Spree::Export.available_types.select do |type|
128
+ required = type.required_scope
129
+ required ? current_api_key.has_scope?("read_#{required}") : current_api_key.has_scope?('read_all')
130
+ end.map(&:to_s)
131
+ end
85
132
  end
86
133
  end
87
134
  end
@@ -7,8 +7,19 @@ module Spree
7
7
  # via the invitation's `after_accept` callback and the invitee
8
8
  # becomes a member of the staff list for this store.
9
9
  class InvitationsController < ResourceController
10
+ include Spree::Api::V3::Admin::RoleGrantGuard
11
+
10
12
  scoped_resource :settings
11
13
 
14
+ # POST /api/v3/admin/invitations
15
+ # Guards against inviting a new staff member straight into the admin
16
+ # super-role unless the inviter already holds it.
17
+ def create
18
+ return if reject_unauthorized_role_grant!(Array(permitted_params[:role_id]))
19
+
20
+ super
21
+ end
22
+
12
23
  # PATCH /api/v3/admin/invitations/:id/resend
13
24
  # Issues a fresh token + email for an existing pending invitation.
14
25
  # The model's `resend!` is responsible for resetting `expires_at`
@@ -15,8 +15,13 @@ module Spree
15
15
  @order = @parent
16
16
  end
17
17
 
18
+ # Read actions require only :show on the parent order; every write
19
+ # (create/update/destroy and custom member actions like capture,
20
+ # void, fulfill, split, apply gift card / store credit) requires
21
+ # :update, so a read-only role can't mutate an order it can view.
22
+ # Subclasses with custom read-only actions extend +read_actions+.
18
23
  def authorize_order_access!
19
- authorize!(:show, @parent)
24
+ authorize_parent!(@parent)
20
25
  end
21
26
  end
22
27
  end
@@ -105,7 +105,7 @@ module Spree
105
105
  end
106
106
 
107
107
  def permitted_params
108
- normalize_params(
108
+ attrs = normalize_params(
109
109
  params.permit(
110
110
  :name, :description, :position,
111
111
  :starts_at, :ends_at, :match_policy,
@@ -114,6 +114,29 @@ module Spree
114
114
  prices: [:id, :variant_id, :currency, :amount, :compare_at_amount]
115
115
  )
116
116
  )
117
+ reject_foreign_membership(attrs)
118
+ end
119
+
120
+ # The PriceList model setters (`product_ids=`, `prices=`) resolve
121
+ # member ids with no store scoping, so a list in this store could
122
+ # otherwise be populated with another store's products/variants.
123
+ # Drop any id that isn't in the current store before assignment.
124
+ def reject_foreign_membership(attrs)
125
+ if attrs[:product_ids].present?
126
+ store_product_ids = current_store.products.where(id: attrs[:product_ids]).pluck(:id).map(&:to_s).to_set
127
+ attrs[:product_ids] = Array(attrs[:product_ids]).select { |id| store_product_ids.include?(id.to_s) }
128
+ end
129
+
130
+ if attrs[:prices].present?
131
+ incoming = Array(attrs[:prices])
132
+ store_variant_ids = current_store.variants.where(id: incoming.map { |r| r[:variant_id] }.compact).
133
+ pluck(:id).map(&:to_s).to_set
134
+ attrs[:prices] = incoming.select do |row|
135
+ row[:variant_id].blank? || store_variant_ids.include?(row[:variant_id].to_s)
136
+ end
137
+ end
138
+
139
+ attrs
117
140
  end
118
141
 
119
142
  private
@@ -35,6 +35,16 @@ module Spree
35
35
  )
36
36
  end
37
37
 
38
+ foreign = foreign_rows(rows)
39
+ if foreign.any?
40
+ return render_error(
41
+ code: 'invalid_prices',
42
+ message: 'Each row must reference a variant and price list in the current store.',
43
+ status: :unprocessable_content,
44
+ details: { rows: foreign }
45
+ )
46
+ end
47
+
38
48
  result = Spree::Prices::BulkUpsert.call(rows: rows)
39
49
  render json: result.value
40
50
  end
@@ -122,6 +132,24 @@ module Spree
122
132
 
123
133
  Spree::PrefixedId.prefixed_id?(value) ? Spree::PrefixedId.decode_prefixed_id(value) : value
124
134
  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
125
153
  end
126
154
  end
127
155
  end
@@ -6,7 +6,7 @@ module Spree
6
6
  # location, so there's deliberately no `create` route — use the
7
7
  # variants / stock-locations endpoints for that flow.
8
8
  class StockItemsController < ResourceController
9
- scoped_resource :settings
9
+ scoped_resource :stock
10
10
 
11
11
  protected
12
12
 
@@ -3,7 +3,7 @@ module Spree
3
3
  module V3
4
4
  module Admin
5
5
  class StockLocationsController < ResourceController
6
- scoped_resource :settings
6
+ scoped_resource :stock
7
7
 
8
8
  protected
9
9
 
@@ -16,7 +16,7 @@ module Spree
16
16
  end
17
17
 
18
18
  def scope
19
- Spree::StockReservation.for_store(current_store)
19
+ Spree::StockReservation.for_store(current_store).accessible_by(current_ability, ability_action_for_request)
20
20
  end
21
21
 
22
22
  def collection_includes
@@ -6,7 +6,7 @@ module Spree
6
6
  # for receives. Pass `source_location_id` for transfers; omit it to
7
7
  # record an external receive.
8
8
  class StockTransfersController < ResourceController
9
- scoped_resource :settings
9
+ scoped_resource :stock
10
10
 
11
11
  def create
12
12
  authorize!(:create, model_class)
@@ -16,7 +16,7 @@ module Spree
16
16
  end
17
17
 
18
18
  def scope
19
- current_store.variants.eligible
19
+ current_store.variants.eligible.accessible_by(current_ability, ability_action_for_request)
20
20
  end
21
21
 
22
22
  def scope_includes
@@ -6,7 +6,7 @@ module Spree
6
6
  # context of their endpoint (the delivery log on the endpoint detail
7
7
  # page) and never accessed by ID at the top level.
8
8
  class WebhookDeliveriesController < ResourceController
9
- scoped_resource :settings
9
+ scoped_resource :webhooks
10
10
 
11
11
  # POST /api/v3/admin/webhook_endpoints/:webhook_endpoint_id/deliveries/:id/redeliver
12
12
  #
@@ -6,7 +6,7 @@ module Spree
6
6
  # endpoint-scoped actions the legacy admin had (send_test, enable,
7
7
  # disable).
8
8
  class WebhookEndpointsController < ResourceController
9
- scoped_resource :settings
9
+ scoped_resource :webhooks
10
10
 
11
11
  # POST /api/v3/admin/webhook_endpoints/:id/send_test
12
12
  #
@@ -261,27 +261,54 @@ module Spree
261
261
  else
262
262
  model_class.for_store(current_store)
263
263
  end
264
- unless @parent.present?
265
- action_name = case request.method
266
- when 'GET', 'HEAD'
267
- :show
268
- when 'POST'
269
- :create
270
- when 'PATCH', 'PUT'
271
- :update
272
- when 'DELETE'
273
- :destroy
274
- else
275
- raise ActionController::MethodNotAllowed, request.method
276
- end
277
-
278
- base_scope = base_scope.accessible_by(current_ability, action_name)
279
- end
264
+ base_scope = base_scope.accessible_by(current_ability, ability_action_for_request) unless @parent.present?
280
265
  base_scope = base_scope.includes(scope_includes) if scope_includes.any?
281
266
  base_scope = base_scope.preload_associations_lazily
282
267
  model_class.include?(Spree::TranslatableResource) ? base_scope.i18n : base_scope
283
268
  end
284
269
 
270
+ # Action names treated as reads. Override in subclasses with custom
271
+ # read-only member/collection actions (e.g. add `analytics`, `types`)
272
+ # so they map to the `:show` ability instead of a write.
273
+ def read_actions
274
+ %w[index show]
275
+ end
276
+
277
+ # Maps the current request to the CanCanCan action used to scope the
278
+ # collection. Read actions (see +read_actions+) map to `:show`; every
279
+ # other request maps by HTTP method. Exposed so controllers that
280
+ # override +scope+ can keep the same `accessible_by` action as the
281
+ # base implementation.
282
+ def ability_action_for_request
283
+ return :show if read_actions.include?(action_name)
284
+
285
+ case request.method
286
+ when 'GET', 'HEAD' then :show
287
+ when 'POST' then :create
288
+ when 'PATCH', 'PUT' then :update
289
+ when 'DELETE' then :destroy
290
+ else
291
+ raise ActionController::MethodNotAllowed, request.method
292
+ end
293
+ end
294
+
295
+ # The ability action a nested resource needs on its PARENT: read
296
+ # actions (see +read_actions+) need only `:show`; every write needs
297
+ # `:update`, since mutating a nested collection is an update to the
298
+ # parent (not a create/destroy of it). Distinct from
299
+ # +ability_action_for_request+, which maps POST/DELETE to
300
+ # `:create`/`:destroy` for the resource itself.
301
+ def parent_ability_action
302
+ read_actions.include?(action_name) ? :show : :update
303
+ end
304
+
305
+ # Authorizes the parent resource for nested controllers: a role that
306
+ # can view a parent can't mutate its nested collection. Call from
307
+ # +set_parent+ after loading the parent.
308
+ def authorize_parent!(parent)
309
+ authorize!(parent_ability_action, parent)
310
+ end
311
+
285
312
  # Override to specify the association name on @parent
286
313
  # Defaults to controller_name (e.g., 'wished_items' for WishlistItemsController)
287
314
  def parent_association
@@ -35,6 +35,7 @@ module Spree
35
35
  # these scopes are not automatically picked by ar_lazy_preload gem and we need to explicitly include them
36
36
  def scope_includes
37
37
  [
38
+ product_publications: [],
38
39
  primary_media: [attachment_attachment: :blob],
39
40
  master: [:prices, stock_items: [:stock_location, :active_stock_reservations]],
40
41
  variants: [:prices, stock_items: [:stock_location, :active_stock_reservations]]
@@ -70,6 +70,34 @@ shared_context 'API v3 Admin authenticated' do
70
70
  let(:headers) { bearer_headers }
71
71
  end
72
72
 
73
+ # Authenticates as an admin whose ability is restricted to a single custom
74
+ # permission set, so authorization specs can assert what a limited role can and
75
+ # cannot do. Define `custom_permission_set` (a Spree::PermissionSets::Base
76
+ # subclass) in the including example. The global permission registry is saved
77
+ # and restored around each example so it doesn't leak into other specs.
78
+ shared_context 'API v3 Admin with custom permissions' do
79
+ include_context 'API v3 Admin'
80
+
81
+ around do |example|
82
+ saved = Spree.permissions.dup
83
+ Spree.permissions.reset!
84
+ example.run
85
+ ensure
86
+ Spree.permissions.replace(saved)
87
+ end
88
+
89
+ let(:custom_role) { create(:role, name: 'limited') }
90
+ let(:custom_admin) { create(:admin_user, :without_admin_role) }
91
+ let(:headers) do
92
+ { 'Authorization' => "Bearer #{Spree::Api::V3::TestingSupport.generate_jwt(custom_admin, audience: Spree::Api::V3::JwtAuthentication::JWT_AUDIENCE_ADMIN)}" }
93
+ end
94
+
95
+ before do
96
+ custom_admin.spree_roles << custom_role
97
+ Spree.permissions.assign(:limited, custom_permission_set)
98
+ end
99
+ end
100
+
73
101
  # Shared examples for common response patterns
74
102
  shared_examples 'returns 200 OK' do
75
103
  it 'returns 200 status' do
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.rc1
4
+ version: 5.5.0.rc2
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-10 00:00:00.000000000 Z
11
+ date: 2026-06-15 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.rc1
75
+ version: 5.5.0.rc2
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.rc1
82
+ version: 5.5.0.rc2
83
83
  description: Spree's API
84
84
  email:
85
85
  - hello@spreecommerce.org
@@ -90,6 +90,7 @@ files:
90
90
  - README.md
91
91
  - Rakefile
92
92
  - app/controllers/concerns/spree/api/v3/admin/auth_cookies.rb
93
+ - app/controllers/concerns/spree/api/v3/admin/role_grant_guard.rb
93
94
  - app/controllers/concerns/spree/api/v3/admin/subclassed_resource.rb
94
95
  - app/controllers/concerns/spree/api/v3/admin_authentication.rb
95
96
  - app/controllers/concerns/spree/api/v3/api_key_authentication.rb
@@ -121,6 +122,7 @@ files:
121
122
  - app/controllers/spree/api/v3/admin/custom_fields_controller.rb
122
123
  - app/controllers/spree/api/v3/admin/customer_groups_controller.rb
123
124
  - app/controllers/spree/api/v3/admin/customers/addresses_controller.rb
125
+ - app/controllers/spree/api/v3/admin/customers/base_controller.rb
124
126
  - app/controllers/spree/api/v3/admin/customers/credit_cards_controller.rb
125
127
  - app/controllers/spree/api/v3/admin/customers/store_credits_controller.rb
126
128
  - app/controllers/spree/api/v3/admin/customers_controller.rb
@@ -351,9 +353,9 @@ licenses:
351
353
  - BSD-3-Clause
352
354
  metadata:
353
355
  bug_tracker_uri: https://github.com/spree/spree/issues
354
- changelog_uri: https://github.com/spree/spree/releases/tag/v5.5.0.rc1
356
+ changelog_uri: https://github.com/spree/spree/releases/tag/v5.5.0.rc2
355
357
  documentation_uri: https://docs.spreecommerce.org/
356
- source_code_uri: https://github.com/spree/spree/tree/v5.5.0.rc1
358
+ source_code_uri: https://github.com/spree/spree/tree/v5.5.0.rc2
357
359
  post_install_message:
358
360
  rdoc_options: []
359
361
  require_paths: