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.
- checksums.yaml +4 -4
- data/Rakefile +17 -0
- data/app/controllers/concerns/spree/api/v3/admin/role_grant_guard.rb +52 -0
- data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +19 -3
- data/app/controllers/spree/api/v3/admin/admin_users_controller.rb +19 -7
- data/app/controllers/spree/api/v3/admin/api_keys_controller.rb +30 -1
- data/app/controllers/spree/api/v3/admin/custom_field_definitions_controller.rb +1 -1
- data/app/controllers/spree/api/v3/admin/custom_fields_controller.rb +25 -4
- data/app/controllers/spree/api/v3/admin/customers/addresses_controller.rb +1 -6
- data/app/controllers/spree/api/v3/admin/customers/base_controller.rb +33 -0
- data/app/controllers/spree/api/v3/admin/customers/credit_cards_controller.rb +1 -7
- data/app/controllers/spree/api/v3/admin/customers/store_credits_controller.rb +4 -5
- data/app/controllers/spree/api/v3/admin/exports_controller.rb +48 -1
- data/app/controllers/spree/api/v3/admin/invitations_controller.rb +11 -0
- data/app/controllers/spree/api/v3/admin/orders/base_controller.rb +6 -1
- data/app/controllers/spree/api/v3/admin/price_lists_controller.rb +24 -1
- data/app/controllers/spree/api/v3/admin/prices_controller.rb +28 -0
- data/app/controllers/spree/api/v3/admin/stock_items_controller.rb +1 -1
- data/app/controllers/spree/api/v3/admin/stock_locations_controller.rb +1 -1
- data/app/controllers/spree/api/v3/admin/stock_reservations_controller.rb +1 -1
- data/app/controllers/spree/api/v3/admin/stock_transfers_controller.rb +1 -1
- data/app/controllers/spree/api/v3/admin/variants_controller.rb +1 -1
- data/app/controllers/spree/api/v3/admin/webhook_deliveries_controller.rb +1 -1
- data/app/controllers/spree/api/v3/admin/webhook_endpoints_controller.rb +1 -1
- data/app/controllers/spree/api/v3/resource_controller.rb +43 -16
- data/app/controllers/spree/api/v3/store/products_controller.rb +1 -0
- data/lib/spree/api/testing_support/v3/base.rb +28 -0
- metadata +8 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a94767b03984d4ebb7c65b91c9512aac96e909f9d2308f06ed1e96e53f2a3580
|
|
4
|
+
data.tar.gz: 8c0f0201d44ca50071cd273c932b497fcafd2f954f7d9e213772f4fe16595ab1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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(
|
|
63
|
-
|
|
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
|
-
|
|
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 :
|
|
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 =
|
|
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
|
|
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 <
|
|
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 <
|
|
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 <
|
|
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
|
-
|
|
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
|
-
|
|
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
|
# 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 :
|
|
9
|
+
scoped_resource :stock
|
|
10
10
|
|
|
11
11
|
def create
|
|
12
12
|
authorize!(:create, model_class)
|
|
@@ -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 :
|
|
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 :
|
|
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.
|
|
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-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|