spree_api 6.0.0.rc2 → 6.0.0.rc4

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/spree/api/v3/admin/email_template_lookup.rb +6 -1
  3. data/app/controllers/concerns/spree/api/v3/admin/reporting_authorization.rb +17 -8
  4. data/app/controllers/concerns/spree/api/v3/admin/store_context.rb +7 -0
  5. data/app/controllers/concerns/spree/api/v3/admin_authentication.rb +61 -0
  6. data/app/controllers/concerns/spree/api/v3/error_handler.rb +49 -0
  7. data/app/controllers/concerns/spree/api/v3/params_normalizer.rb +76 -7
  8. data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +60 -3
  9. data/app/controllers/concerns/spree/api/v3/store/search_provider_support.rb +8 -3
  10. data/app/controllers/spree/api/v3/admin/companies/tax_exemption_certificates_controller.rb +11 -5
  11. data/app/controllers/spree/api/v3/admin/concerns/external_references.rb +3 -0
  12. data/app/controllers/spree/api/v3/admin/exports_controller.rb +15 -3
  13. data/app/controllers/spree/api/v3/admin/imports_controller.rb +12 -3
  14. data/app/controllers/spree/api/v3/admin/markets_controller.rb +3 -1
  15. data/app/controllers/spree/api/v3/admin/me_controller.rb +21 -1
  16. data/app/controllers/spree/api/v3/admin/oauth/application_tokens_controller.rb +56 -0
  17. data/app/controllers/spree/api/v3/admin/oauth/applications_controller.rb +55 -0
  18. data/app/controllers/spree/api/v3/admin/oauth/authorizations_controller.rb +294 -0
  19. data/app/controllers/spree/api/v3/admin/order_groups_controller.rb +1 -1
  20. data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +8 -0
  21. data/app/controllers/spree/api/v3/admin/orders/items_controller.rb +1 -0
  22. data/app/controllers/spree/api/v3/admin/orders/returns_controller.rb +7 -0
  23. data/app/controllers/spree/api/v3/admin/orders_controller.rb +9 -2
  24. data/app/controllers/spree/api/v3/admin/purchase_orders_controller.rb +1 -0
  25. data/app/controllers/spree/api/v3/admin/reporting_controller.rb +20 -14
  26. data/app/controllers/spree/api/v3/admin/stock_levels_controller.rb +1 -1
  27. data/app/controllers/spree/api/v3/admin/tags_controller.rb +8 -5
  28. data/app/controllers/spree/api/v3/admin/tax_exemption_reasons_controller.rb +58 -0
  29. data/app/controllers/spree/api/v3/admin/tax_rates_controller.rb +0 -1
  30. data/app/controllers/spree/api/v3/admin/translations/batches_controller.rb +17 -11
  31. data/app/controllers/spree/api/v3/oauth/authorizations_controller.rb +61 -0
  32. data/app/controllers/spree/api/v3/oauth/metadata_controller.rb +57 -0
  33. data/app/controllers/spree/api/v3/oauth/server_metadata_controller.rb +57 -0
  34. data/app/controllers/spree/api/v3/resource_controller.rb +1 -0
  35. data/app/controllers/spree/api/v3/store/carts/store_credits_controller.rb +13 -1
  36. data/app/controllers/spree/api/v3/store/carts_controller.rb +4 -7
  37. data/app/request_schemas/spree/api/v3/admin/email_template_preview_request_schema.rb +4 -0
  38. data/app/request_schemas/spree/api/v3/store/address_request_schema.rb +2 -0
  39. data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +2 -0
  40. data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +2 -0
  41. data/app/serializers/spree/api/v3/admin/oauth_access_token_serializer.rb +62 -0
  42. data/app/serializers/spree/api/v3/admin/oauth_application_serializer.rb +56 -0
  43. data/app/serializers/spree/api/v3/admin/oauth_consent_request_serializer.rb +25 -0
  44. data/app/serializers/spree/api/v3/admin/oauth_protected_resource_serializer.rb +27 -0
  45. data/app/serializers/spree/api/v3/admin/oauth_redirect_serializer.rb +17 -0
  46. data/app/serializers/spree/api/v3/admin/payment_capture_event_serializer.rb +16 -0
  47. data/app/serializers/spree/api/v3/admin/payment_serializer.rb +12 -1
  48. data/app/serializers/spree/api/v3/admin/price_history_serializer.rb +1 -0
  49. data/app/serializers/spree/api/v3/admin/price_serializer.rb +2 -0
  50. data/app/serializers/spree/api/v3/admin/return_serializer.rb +2 -0
  51. data/app/serializers/spree/api/v3/admin/tax_exemption_certificate_serializer.rb +7 -3
  52. data/app/serializers/spree/api/v3/admin/tax_exemption_reason_serializer.rb +20 -0
  53. data/app/serializers/spree/api/v3/admin/tax_rate_serializer.rb +1 -2
  54. data/app/serializers/spree/api/v3/admin/webhook_delivery_serializer.rb +4 -1
  55. data/app/serializers/spree/api/v3/base_serializer.rb +14 -0
  56. data/app/serializers/spree/api/v3/cart_serializer.rb +5 -1
  57. data/app/serializers/spree/api/v3/price_history_serializer.rb +6 -1
  58. data/app/serializers/spree/api/v3/price_serializer.rb +11 -1
  59. data/app/serializers/spree/api/v3/seller/price_history_serializer.rb +1 -0
  60. data/app/serializers/spree/api/v3/seller/price_serializer.rb +2 -0
  61. data/config/routes.rb +61 -1
  62. data/lib/spree/api/agent_membership_map.rb +73 -0
  63. data/lib/spree/api/agent_resource_map.rb +350 -0
  64. data/lib/spree/api/agent_workflow_endpoints.rb +113 -0
  65. data/lib/spree/api/agent_write_schemas.rb +119 -0
  66. data/lib/spree/api/dependencies.rb +4 -3
  67. data/lib/spree/api/engine.rb +40 -0
  68. data/lib/spree/api/extension_fields.rb +167 -0
  69. data/lib/spree/api/oauth.rb +243 -0
  70. data/lib/spree/api.rb +8 -0
  71. data/lib/typelizer/open_enums.rb +17 -3
  72. metadata +26 -6
@@ -0,0 +1,56 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ module Oauth
6
+ # A connected client's live tokens — one per person who connected it.
7
+ #
8
+ # A store registers a client once and every staff member connects to
9
+ # that same registration, so the application row says that Claude is
10
+ # connected while these say by whom. Revoking one cuts off that
11
+ # person and leaves their colleagues working; deleting the
12
+ # application removes the registration itself.
13
+ class ApplicationTokensController < Spree::Api::V3::Admin::ResourceController
14
+ scoped_resource :oauth_applications
15
+
16
+ # Revoking rather than deleting: the row records who held access,
17
+ # and Doorkeeper treats a revoked token as gone on the next call.
18
+ def destroy
19
+ @resource.revoke
20
+ head :no_content
21
+ end
22
+
23
+ protected
24
+
25
+ def model_class
26
+ Spree::OauthAccessToken
27
+ end
28
+
29
+ def serializer_class
30
+ Spree.api.admin_oauth_access_token_serializer
31
+ end
32
+
33
+ # Live means unrevoked, not unexpired: an access token lasts
34
+ # hours while the refresh token beside it lasts far longer, so a
35
+ # person whose access has lapsed can mint another at will.
36
+ # Hiding them would hide a connection from the screen that exists
37
+ # to cut it off.
38
+ def scope
39
+ application.live_access_tokens.order(created_at: :desc)
40
+ end
41
+
42
+ def collection_includes
43
+ [:resource_owner]
44
+ end
45
+
46
+ # Nested under its application, so tenancy comes from the parent
47
+ # rather than from the token's own store column.
48
+ def application
49
+ @application ||= current_store.oauth_applications.find_by_prefix_id!(params[:application_id])
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,55 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ module Oauth
6
+ # Connected applications, so a merchant can see which agents and
7
+ # apps hold access to the store and take it away.
8
+ #
9
+ # Revoking is the off switch and has to be immediate: it revokes
10
+ # every live token and grant rather than deleting the registration,
11
+ # so a client that still holds a token stops working on its next
12
+ # call.
13
+ class ApplicationsController < Spree::Api::V3::Admin::ResourceController
14
+ # Its own permission rather than store configuration: a standing
15
+ # credential to the back office is not the same kind of thing as
16
+ # a delivery zone.
17
+ scoped_resource :oauth_applications
18
+
19
+ # `confidential` is not settable: these are hosted connectors that
20
+ # cannot keep a secret, which is why PKCE is mandatory. Neither is
21
+ # `scopes` — what a client may do is granted by the merchant at
22
+ # consent, not fixed at registration.
23
+ request_params :oauth_application do |body|
24
+ body.attribute :name, :string, required: true, example: 'Claude'
25
+ body.attribute :redirect_uri, :string,
26
+ description: 'Where the authorization server sends the browser back to.',
27
+ example: 'https://claude.ai/api/mcp/auth_callback'
28
+ end
29
+
30
+ protected
31
+
32
+ def model_class
33
+ Spree::OauthApplication
34
+ end
35
+
36
+ def serializer_class
37
+ Spree.api.admin_oauth_application_serializer
38
+ end
39
+
40
+ def scope
41
+ super.order(:name)
42
+ end
43
+
44
+ # The serializer reads each application's scopes, last
45
+ # authorization and who approved it from the live tokens.
46
+ def collection_includes
47
+ { live_access_tokens: :resource_owner }
48
+ end
49
+
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,294 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ module Oauth
6
+ # The consent step, as JSON for the dashboard to render.
7
+ #
8
+ # `show` describes what a client is asking for so the dashboard can
9
+ # draw a consent screen in its own design system, with the admin
10
+ # already signed in. `create` grants it and answers with the
11
+ # redirect the browser should follow; `destroy` denies it.
12
+ #
13
+ # Doorkeeper's own controller is not used: it renders HTML and runs
14
+ # its own login, and the dashboard already knows who is signed in.
15
+ class AuthorizationsController < Spree::Api::V3::Admin::BaseController
16
+ # Consent is a person's decision about their own authority, and a
17
+ # secret key has no human behind it, so there is no scope that
18
+ # could make this callable with one — `require_signed_in_admin!`
19
+ # refuses a key outright. Skipped for every principal rather than
20
+ # JWT-only, because leaving the key path in the scope machinery
21
+ # asks this controller for a `scoped_resource` it cannot
22
+ # meaningfully name.
23
+ skip_scope_check!
24
+
25
+ # Raised when Doorkeeper refuses the bounded pre-authorization.
26
+ # An exception rather than a render, because the refusal happens
27
+ # inside a helper the action reads a value from — returning there
28
+ # would hand the action a rendered body to call methods on.
29
+ class InvalidAuthorization < StandardError; end
30
+
31
+ rescue_from InvalidAuthorization do |error|
32
+ render_error(
33
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:resource_invalid],
34
+ message: error.message,
35
+ status: :unprocessable_content
36
+ )
37
+ end
38
+
39
+ before_action :require_signed_in_admin!
40
+ before_action :require_connect_permission!
41
+ before_action :require_client_of_this_store!
42
+ before_action :load_pre_authorization
43
+ before_action :require_something_to_grant!, only: :create
44
+
45
+ def show
46
+ request = Spree::OauthConsentRequest.new(
47
+ client_name: client_name,
48
+ scopes: requested_permissions,
49
+ # What the merchant may hand over, which is the intersection
50
+ # of what the client asked for and what they themselves hold.
51
+ # A grant can only ever narrow their own authority, so
52
+ # offering more than they have would be offering a refusal.
53
+ grantable_scopes: grantable_scopes,
54
+ resource: requested_resource,
55
+ redirect_uri: @pre_auth.redirect_uri
56
+ )
57
+
58
+ render json: Spree.api.admin_oauth_consent_request_serializer.new(request).to_h
59
+ end
60
+
61
+ # The merchant may approve less than the client asked for, and
62
+ # never more than they themselves hold.
63
+ #
64
+ # The ceiling is applied here rather than trusted from the
65
+ # request. Doorkeeper validates a requested scope against the
66
+ # catalog, not against the person, so a crafted POST naming
67
+ # `write_settings` would otherwise be recorded verbatim on a
68
+ # staffer who holds only `read_products` — and a grant is a
69
+ # standing ceiling, so it would widen on its own the day that
70
+ # person is promoted.
71
+ def create
72
+ render_redirect(granted_code_request.authorize.redirect_uri)
73
+ end
74
+
75
+ def destroy
76
+ render_redirect(code_request.deny.redirect_uri)
77
+ end
78
+
79
+ private
80
+
81
+ def render_redirect(uri)
82
+ redirect = Spree::OauthRedirect.new(redirect_uri: uri)
83
+
84
+ render json: Spree.api.admin_oauth_redirect_serializer.new(redirect).to_h
85
+ end
86
+
87
+ # A secret key authenticates but is not a person; `current_actor`
88
+ # returns the key in that case, and a grant must belong to a user.
89
+ # Connecting an agent is store configuration, like the screen that
90
+ # lists what is connected and the button that revokes one.
91
+ #
92
+ # Narrowing a grant to the approver's own permissions bounds what
93
+ # the agent may do; it does not answer who may hand a third party
94
+ # a standing credential at all. A staffer who cannot revoke an
95
+ # agent should not be able to connect one.
96
+ def require_connect_permission!
97
+ # Narrowed too: connecting an agent is a write a delegated
98
+ # credential must hold in its own scopes, not merely inherit.
99
+ return if holds_permission?('write_oauth_applications') &&
100
+ current_ability.can?(:update, Spree::OauthApplication)
101
+
102
+ render_error(
103
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
104
+ message: 'You do not have permission to connect an agent to this store.',
105
+ status: :forbidden
106
+ )
107
+ end
108
+
109
+ def require_signed_in_admin!
110
+ return if current_actor.is_a?(Spree.admin_user_class)
111
+
112
+ render_error(
113
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
114
+ message: 'Only a signed-in admin user can authorize an application.',
115
+ status: :forbidden
116
+ )
117
+ end
118
+
119
+ # Doorkeeper resolves a client id globally, so without this an
120
+ # admin of one store could consent to another store's registered
121
+ # client. Reading it through the store's own association turns
122
+ # that into a 404, the same defence every other admin lookup
123
+ # uses.
124
+ def require_client_of_this_store!
125
+ return if current_store.oauth_applications.exists?(uid: params[:client_id])
126
+
127
+ render_error(
128
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:record_not_found],
129
+ message: 'Unknown application.',
130
+ status: :not_found
131
+ )
132
+ end
133
+
134
+ def load_pre_authorization
135
+ @pre_auth = ::Doorkeeper::OAuth::PreAuthorization.new(
136
+ ::Doorkeeper.config,
137
+ pre_authorization_params,
138
+ current_actor
139
+ )
140
+
141
+ return if @pre_auth.authorizable?
142
+
143
+ render_error(
144
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:resource_invalid],
145
+ message: @pre_auth.error_response.body[:error_description].to_s,
146
+ status: :unprocessable_content
147
+ )
148
+ end
149
+
150
+ # What this person can actually grant: the requested scopes minus
151
+ # anything their own role does not carry.
152
+ #
153
+ # @return [Array<String>]
154
+ # Every individual permission the merchant could hand over, which
155
+ # is what the consent screen draws a row for.
156
+ def grantable_scopes
157
+ requested = Spree.permissions.expand_keys(@pre_auth.scopes.to_a)
158
+
159
+ # `staff_permission_keys` rather than the role's own keys: when
160
+ # the request arrived on a delegated credential it is already
161
+ # narrowed to that credential's scopes. Reading the role
162
+ # directly let an agent holding `read_products` approve itself
163
+ # a grant carrying everything its owner can do.
164
+ (requested & staff_permission_keys).sort
165
+ end
166
+
167
+
168
+
169
+
170
+ # Symbol keys: PreAuthorization reads them that way, and a hash of
171
+ # strings silently produces an unauthorizable request.
172
+ def pre_authorization_params
173
+ params.permit(
174
+ :client_id, :redirect_uri, :response_type, :state, :scope,
175
+ :code_challenge, :code_challenge_method, :response_mode
176
+ ).to_h.symbolize_keys.
177
+ merge(scope: known_scopes).
178
+ # The audience the grant is written with. Resolved rather
179
+ # than carried through raw, so a client that names nothing —
180
+ # the indicator is optional and most omit it — still gets a
181
+ # grant bound to the Admin API instead of to nothing.
182
+ merge(resource: requested_resource)
183
+ end
184
+
185
+ # The requested scopes this server knows, with the rest dropped.
186
+ #
187
+ # RFC 6749 §3.3 lets an authorization server ignore part of a
188
+ # requested scope, and the consent screen narrows to what the
189
+ # approver holds regardless. Refusing the whole request instead
190
+ # fails a client that cached an older catalogue — and the MCP
191
+ # spec tells a client to ask for everything it was once told
192
+ # about, so a scope we stop offering would lock out every client
193
+ # that discovered us before.
194
+ def known_scopes
195
+ accepted = Spree::Api::Oauth.staff_scope_keys.map(&:to_s)
196
+
197
+ params[:scope].to_s.split.select { |scope| accepted.include?(scope) }.join(' ')
198
+ end
199
+
200
+ # The audiences the grant is bound to.
201
+ #
202
+ # Every grant is bound to the Admin API, whatever the client
203
+ # asked for. The MCP endpoint lives inside that API and
204
+ # authenticates the same way, so one audience serves both — and
205
+ # a tool call, which reaches data by dispatching into the Admin
206
+ # API with this same token, is not left carrying a credential
207
+ # bound to a path the Admin API does not serve.
208
+ #
209
+ # The indicator a client sends is honoured for its origin only:
210
+ # a tunnel, a proxy or a custom domain has to appear in the
211
+ # audience as the client knows it.
212
+ def requested_resource
213
+ requested = Array(params[:resource]).flat_map { |value| value.to_s.split }
214
+
215
+ Spree::Api::Oauth.resource_identifier(:admin, origin_of(requested.first))
216
+ end
217
+
218
+ # The scheme and host the client was given — a tunnel, a proxy, a
219
+ # custom domain — so both audiences name the same origin.
220
+ def origin_of(indicator)
221
+ uri = URI.parse(indicator.to_s)
222
+ return if uri.host.blank?
223
+
224
+ "#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless uri.default_port == uri.port}"
225
+ rescue URI::InvalidURIError
226
+ nil
227
+ end
228
+
229
+ def path_of(value)
230
+ URI.parse(value.to_s).path
231
+ rescue URI::InvalidURIError
232
+ nil
233
+ end
234
+
235
+ # Every capability the requested scopes imply, so a merchant sees
236
+ # what they are actually granting rather than only the shorthand
237
+ # the client named.
238
+ def requested_permissions
239
+ Spree.permissions.expand_keys(@pre_auth.scopes.to_a).sort
240
+ end
241
+
242
+ def client_name
243
+ @pre_auth.client&.application&.name.presence || 'An application'
244
+ end
245
+
246
+ # Doorkeeper's own request object builds both redirects. Hand-
247
+ # rolling them means getting out-of-band clients, fragment
248
+ # response mode and the RFC 9207 issuer parameter right by hand,
249
+ # which is exactly the work the gem is here to do.
250
+ def code_request
251
+ @code_request ||= ::Doorkeeper::OAuth::CodeRequest.new(@pre_auth, current_actor)
252
+ end
253
+
254
+ # The same thing, over a pre-authorization whose scope has been
255
+ # cut to what this person can actually hand over. Rebuilt rather
256
+ # than mutated, because the scope is read in several places on
257
+ # the way to the grant.
258
+ # A grant that carries nothing is not a grant. It happens when the
259
+ # merchant clears every box, or when the client asks only for
260
+ # permissions this person does not hold, and it would otherwise
261
+ # mint a token that authorizes nothing while the client reports a
262
+ # successful connection and the merchant sees the application
263
+ # listed as connected.
264
+ def require_something_to_grant!
265
+ return if grantable_scopes.any?
266
+
267
+ render_error(
268
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:resource_invalid],
269
+ message: 'Select at least one permission to grant.',
270
+ status: :unprocessable_content
271
+ )
272
+ end
273
+
274
+ def granted_code_request
275
+ bounded = ::Doorkeeper::OAuth::PreAuthorization.new(
276
+ ::Doorkeeper.config,
277
+ pre_authorization_params.merge(scope: grantable_scopes.join(' ')),
278
+ current_actor
279
+ )
280
+
281
+ # Validation is what resolves `client`, and the grant is written
282
+ # from `pre_auth.client.id` — an unvalidated pre-authorization
283
+ # carries a nil client and fails deep inside the gem.
284
+ raise InvalidAuthorization, bounded.error_response.body[:error_description].to_s unless
285
+ bounded.authorizable?
286
+
287
+ ::Doorkeeper::OAuth::CodeRequest.new(bounded, current_actor)
288
+ end
289
+ end
290
+ end
291
+ end
292
+ end
293
+ end
294
+ end
@@ -32,7 +32,7 @@ module Spree
32
32
  def collection_includes
33
33
  [
34
34
  :customer, :ship_address, :bill_address,
35
- { payments: %i[payment_method source] },
35
+ { payments: %i[payment_method source capture_events] },
36
36
  { orders: %i[market gift_card] }
37
37
  ]
38
38
  end
@@ -15,6 +15,7 @@ module Spree
15
15
  scoped_resource :fulfillments
16
16
 
17
17
  request_params :fulfillment do |body|
18
+ body.external_references
18
19
  body.attribute :stock_location_id, :id,
19
20
  required: true, on: :create,
20
21
  description: "The location the fulfillment ships from. On update, moving it re-prices the fulfillment against that location's rates."
@@ -140,6 +141,13 @@ module Spree
140
141
  params.permit(*permitted_attributes)
141
142
  end
142
143
 
144
+ # A replayed create runs the update action, which takes neither the
145
+ # units to move nor how the parcel was registered: those only shape
146
+ # a new fulfillment.
147
+ def normalize_upsert_params!
148
+ %i[items source_fulfillment_id delivery_method_id cost status metadata].each { |key| params.delete(key) }
149
+ end
150
+
143
151
  private
144
152
 
145
153
  # Forcing past a stock or payment guard is the operator's call.
@@ -5,6 +5,7 @@ module Spree
5
5
  module Orders
6
6
  class ItemsController < BaseController
7
7
  request_params :line_item do |body|
8
+ body.external_references
8
9
  body.attribute :variant_id, :id, required: true, only: :create
9
10
  body.attribute :quantity, :integer, example: 1, description: 'Defaults to 1 when adding an item.'
10
11
  body.attribute :price, :decimal,
@@ -20,6 +20,7 @@ module Spree
20
20
  scoped_resource :orders
21
21
 
22
22
  request_params :return do |body|
23
+ body.external_references
23
24
  body.attribute :items, :array,
24
25
  required: true, on: :create,
25
26
  only: :create,
@@ -89,6 +90,12 @@ module Spree
89
90
  params.permit(*permitted_attributes)
90
91
  end
91
92
 
93
+ # A replayed create runs the update action, and the units coming
94
+ # back are fixed once the return exists.
95
+ def normalize_upsert_params!
96
+ params.delete(:items)
97
+ end
98
+
92
99
  private
93
100
 
94
101
  # The ids are resolved through the store rather than assigned raw,
@@ -295,8 +295,8 @@ module Spree
295
295
  # reads it — otherwise a page of orders costs a query per
296
296
  # fulfillment for a field that is nil on every parcel order.
297
297
  [:customer, :channel, :seller, :external_references, :cancel_reason,
298
- :market, { fulfillments: :selected_delivery_rate },
299
- { line_items: [{ variant: :prices }, { price_list: :catalog }] },
298
+ :market, { fulfillments: %i[selected_delivery_rate external_references] },
299
+ { line_items: [:external_references, { variant: :prices }, { price_list: :catalog }] },
300
300
  { po_document_attachment: :blob }]
301
301
  end
302
302
 
@@ -333,6 +333,13 @@ module Spree
333
333
  resolve_company_param(normalize_params(params.permit(*request_params_schema.permit_list(:create))))
334
334
  end
335
335
 
336
+ # `items` is the public name for line items, so the normalizer needs
337
+ # telling which model the rows describe — otherwise a `prod_` id in
338
+ # `variant_id` is not recognised as the wrong kind.
339
+ def nested_param_models
340
+ { 'items' => Spree::LineItem }
341
+ end
342
+
336
343
  # Named rather than read from the action, since an upsert reaches
337
344
  # update from the create action.
338
345
  def order_update_params
@@ -47,6 +47,7 @@ module Spree
47
47
  item.attribute :variant_id, :id, required: true, example: 'variant_1234567890'
48
48
  item.attribute :quantity_ordered, :integer, required: true, example: 100
49
49
  item.attribute :unit_cost, :decimal,
50
+ required: true,
50
51
  example: '12.50',
51
52
  description: 'What the merchant agreed to pay per unit.'
52
53
  end
@@ -87,27 +87,33 @@ module Spree
87
87
  params.to_unsafe_h.slice(*QUERY_KEYS)
88
88
  end
89
89
 
90
- # Both credential types are authorized per referenced member:
91
- # JWT admins via CanCanCan (`required_subjects`), secret keys via
92
- # their scopes (`required_key_scopes` on top of the `read_reports`
93
- # endpoint gate) — a key without `read_products` cannot query the
94
- # product dimension any more than a limited staff role can.
90
+ # Every referenced member is authorized against what this
91
+ # credential holds, on top of the `read_reports` endpoint gate: a
92
+ # caller without `read_products` cannot query the product dimension
93
+ # whatever its principal type.
94
+ #
95
+ # Asked through `holds_permission?` rather than of the ability,
96
+ # because an OAuth grant authenticates as the staff member who
97
+ # approved it and carries no API key. Measured against their role,
98
+ # a grant for `read_reports` alone could group by customer and read
99
+ # names, emails and spend — data the merchant never consented to.
95
100
  def authorize_reporting_members!(reporting_query)
96
- if current_api_key.present?
97
- missing = reporting_query.required_key_scopes.reject { |scope| current_api_key.has_scope?(scope) }
98
- return if missing.empty?
101
+ missing = reporting_query.required_key_scopes.reject { |scope| holds_permission?(scope) }
99
102
 
100
- render_error(
103
+ if missing.any?
104
+ return render_error(
101
105
  code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
102
- message: "API key lacks scope: #{missing.join(', ')}",
106
+ message: "Missing permission: #{missing.join(', ')}",
103
107
  status: :forbidden,
104
108
  details: { required_scopes: missing }
105
109
  )
106
- else
107
- authorize!(:read, :reports)
108
- forbidden = reporting_query.unreadable_subjects(current_ability).first
109
- raise CanCan::AccessDenied.new(nil, :read, forbidden) if forbidden
110
110
  end
111
+
112
+ # Then the record-level rules, which a host app uses to narrow
113
+ # what a role sees within data it may read at all.
114
+ authorize!(:read, :reports)
115
+ forbidden = reporting_query.unreadable_subjects(current_ability).first
116
+ raise CanCan::AccessDenied.new(nil, :read, forbidden) if forbidden
111
117
  end
112
118
 
113
119
  # An unknown member carries what it was and what was valid instead.
@@ -72,7 +72,7 @@ module Spree
72
72
  )
73
73
  end
74
74
 
75
- result = Spree.stock_level_bulk_upsert_service.call(rows: rows)
75
+ result = Spree.stock_level_bulk_upsert_workflow.call(store: current_store, rows: rows)
76
76
  render json: result.value
77
77
  end
78
78
 
@@ -82,18 +82,21 @@ module Spree
82
82
  end
83
83
 
84
84
  # Per-type scope check for API-key principals: a key listing product
85
- # tags needs `read_products`, order tags `read_orders`, etc. JWT
86
- # admins are gated by store membership + CanCanCan, not scopes.
85
+ # tags needs `read_products`, order tags `read_orders`, etc.
86
+ #
87
+ # Asked of the credential, not of its kind: an OAuth grant carries
88
+ # no API key, so a check that began `return unless current_api_key`
89
+ # skipped itself entirely and handed an agent every order and
90
+ # customer tag the approver could see.
87
91
  def authorize_taggable_scope!
88
- return unless current_api_key
89
92
  return unless taggable_type
90
93
 
91
94
  required = self.class.scope_for_taggable_type.fetch(taggable_type, 'read_all')
92
- return if current_api_key.has_scope?(required)
95
+ return if holds_permission?(required)
93
96
 
94
97
  render_error(
95
98
  code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
96
- message: "API key lacks scope: #{required}",
99
+ message: "Missing permission: #{required}",
97
100
  status: :forbidden,
98
101
  details: { required_scope: required }
99
102
  )
@@ -0,0 +1,58 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ class TaxExemptionReasonsController < ResourceController
6
+ scoped_resource :settings
7
+
8
+ request_params :tax_exemption_reason do |body|
9
+ body.include_attributes Spree::Api::V3::Admin::ReasonRequestSchema
10
+ body.attribute :code, :string, required: true, on: :create, example: 'RESALE',
11
+ description: 'What the tax provider receives for every certificate holding this reason — for Avalara, an entity use code such as `G`. ' \
12
+ 'Saved in capitals. Changing it changes what those certificates send from then on.'
13
+ body.attribute :metadata, :hash
14
+ end
15
+
16
+ protected
17
+
18
+ def model_class
19
+ Spree::TaxExemptionReason
20
+ end
21
+
22
+ def serializer_class
23
+ Spree.api.admin_tax_exemption_reason_serializer
24
+ end
25
+
26
+ def permitted_params
27
+ params.permit(*permitted_attributes)
28
+ end
29
+
30
+ # The certificate form picks from this list, so staff who manage
31
+ # customers read it without the settings key. Writing stays a
32
+ # settings matter.
33
+ def scoped_resource_name
34
+ reads_through_customers? ? :customers : super
35
+ end
36
+
37
+ def scope
38
+ reads_through_customers? ? current_store.tax_exemption_reasons : super
39
+ end
40
+
41
+ def authorize_resource!(resource = @resource, action = action_name.to_sym)
42
+ return authorize!(:show, Spree::TaxExemptionCertificate) if reads_through_customers?
43
+
44
+ super
45
+ end
46
+
47
+ def reads_through_customers?
48
+ return @reads_through_customers if defined?(@reads_through_customers)
49
+
50
+ @reads_through_customers = action_kind == 'read' &&
51
+ !holds_permission?('read_settings') &&
52
+ holds_permission?('read_customers')
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -13,7 +13,6 @@ module Spree
13
13
  example: '0.19',
14
14
  description: 'The rate as a fraction. Send this or `rate_percent`.'
15
15
  body.attribute :rate_percent, :decimal, example: '19', description: 'The rate as a percentage.'
16
- body.attribute :included_in_price, :boolean
17
16
  body.attribute :show_rate_in_label, :boolean
18
17
  body.attribute :tax_category_id, :id, required: true, on: :create
19
18
  body.attribute :country_code, :country,