spree_api 6.0.0.rc3 → 6.0.0.rc5
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/app/controllers/concerns/spree/api/v3/admin/email_template_lookup.rb +6 -1
- data/app/controllers/concerns/spree/api/v3/admin/reporting_authorization.rb +17 -8
- data/app/controllers/concerns/spree/api/v3/admin/store_context.rb +7 -0
- data/app/controllers/concerns/spree/api/v3/admin_authentication.rb +61 -0
- data/app/controllers/concerns/spree/api/v3/allowed_redirect_url.rb +27 -0
- data/app/controllers/concerns/spree/api/v3/cart_resolvable.rb +8 -2
- data/app/controllers/concerns/spree/api/v3/error_handler.rb +49 -0
- data/app/controllers/concerns/spree/api/v3/params_normalizer.rb +76 -7
- data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +66 -5
- data/app/controllers/spree/api/v3/admin/companies/addresses_controller.rb +7 -1
- data/app/controllers/spree/api/v3/admin/companies/tax_exemption_certificates_controller.rb +11 -5
- data/app/controllers/spree/api/v3/admin/concerns/external_references.rb +3 -0
- data/app/controllers/spree/api/v3/admin/custom_fields_controller.rb +30 -8
- data/app/controllers/spree/api/v3/admin/customers/addresses_controller.rb +4 -0
- data/app/controllers/spree/api/v3/admin/customers_controller.rb +1 -6
- data/app/controllers/spree/api/v3/admin/exports_controller.rb +15 -3
- data/app/controllers/spree/api/v3/admin/gift_cards_controller.rb +1 -8
- data/app/controllers/spree/api/v3/admin/imports_controller.rb +12 -3
- data/app/controllers/spree/api/v3/admin/markets_controller.rb +3 -1
- data/app/controllers/spree/api/v3/admin/me_controller.rb +21 -1
- data/app/controllers/spree/api/v3/admin/oauth/application_tokens_controller.rb +56 -0
- data/app/controllers/spree/api/v3/admin/oauth/applications_controller.rb +55 -0
- data/app/controllers/spree/api/v3/admin/oauth/authorizations_controller.rb +294 -0
- data/app/controllers/spree/api/v3/admin/order_groups_controller.rb +1 -1
- data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +8 -0
- data/app/controllers/spree/api/v3/admin/orders/items_controller.rb +1 -0
- data/app/controllers/spree/api/v3/admin/orders/returns_controller.rb +7 -0
- data/app/controllers/spree/api/v3/admin/orders_controller.rb +20 -7
- data/app/controllers/spree/api/v3/admin/package_types_controller.rb +2 -3
- data/app/controllers/spree/api/v3/admin/products/variants_controller.rb +3 -4
- data/app/controllers/spree/api/v3/admin/products_controller.rb +5 -3
- data/app/controllers/spree/api/v3/admin/purchase_orders_controller.rb +1 -0
- data/app/controllers/spree/api/v3/admin/reporting_controller.rb +20 -14
- data/app/controllers/spree/api/v3/admin/stock_levels_controller.rb +1 -1
- data/app/controllers/spree/api/v3/admin/store_controller.rb +1 -1
- data/app/controllers/spree/api/v3/admin/tags_controller.rb +8 -5
- data/app/controllers/spree/api/v3/admin/tax_exemption_reasons_controller.rb +58 -0
- data/app/controllers/spree/api/v3/admin/tax_rates_controller.rb +0 -1
- data/app/controllers/spree/api/v3/admin/translations/batches_controller.rb +17 -11
- data/app/controllers/spree/api/v3/admin/variants_controller.rb +96 -1
- data/app/controllers/spree/api/v3/oauth/authorizations_controller.rb +72 -0
- data/app/controllers/spree/api/v3/oauth/metadata_controller.rb +57 -0
- data/app/controllers/spree/api/v3/oauth/server_metadata_controller.rb +57 -0
- data/app/controllers/spree/api/v3/resource_controller.rb +36 -5
- data/app/controllers/spree/api/v3/seller/package_types_controller.rb +2 -2
- data/app/controllers/spree/api/v3/seller/products_controller.rb +7 -61
- data/app/controllers/spree/api/v3/seller/resource_controller.rb +2 -6
- data/app/controllers/spree/api/v3/store/carts/addresses_controller.rb +53 -0
- data/app/controllers/spree/api/v3/store/carts_controller.rb +7 -8
- data/app/controllers/spree/api/v3/store/companies/addresses_controller.rb +4 -0
- data/app/controllers/spree/api/v3/store/customer/addresses_controller.rb +4 -0
- data/app/controllers/spree/api/v3/store/customer/data_requests_controller.rb +19 -1
- data/app/controllers/spree/api/v3/store/customer/password_resets_controller.rb +3 -14
- data/app/controllers/spree/api/v3/store/data_request_confirmations_controller.rb +23 -0
- data/app/controllers/spree/api/v3/store/data_requests_controller.rb +56 -0
- data/app/controllers/spree/api/v3/store/newsletter_subscribers_controller.rb +2 -11
- data/app/request_schemas/spree/api/v3/admin/email_template_preview_request_schema.rb +4 -0
- data/app/request_schemas/spree/api/v3/admin/variant_request_schema.rb +21 -0
- data/app/request_schemas/spree/api/v3/seller/price_request_schema.rb +20 -0
- data/app/request_schemas/spree/api/v3/seller/variant_request_schema.rb +19 -0
- data/app/request_schemas/spree/api/v3/variant_request_schema.rb +8 -13
- data/app/serializers/spree/api/v3/address_owner_serializer.rb +27 -0
- data/app/serializers/spree/api/v3/address_serializer.rb +5 -1
- data/app/serializers/spree/api/v3/admin/address_owner_serializer.rb +10 -0
- data/app/serializers/spree/api/v3/admin/address_serializer.rb +1 -10
- data/app/serializers/spree/api/v3/admin/api_key_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/catalog_assignment_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/catalog_price_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/collection_rule_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/collection_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/commission_line_serializer.rb +2 -2
- data/app/serializers/spree/api/v3/admin/commission_rate_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/consent_record_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/email_template_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +2 -0
- data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +2 -0
- data/app/serializers/spree/api/v3/admin/oauth_access_token_serializer.rb +62 -0
- data/app/serializers/spree/api/v3/admin/oauth_application_serializer.rb +56 -0
- data/app/serializers/spree/api/v3/admin/oauth_consent_request_serializer.rb +25 -0
- data/app/serializers/spree/api/v3/admin/oauth_protected_resource_serializer.rb +27 -0
- data/app/serializers/spree/api/v3/admin/oauth_redirect_serializer.rb +17 -0
- data/app/serializers/spree/api/v3/admin/order_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/package_type_serializer.rb +3 -3
- data/app/serializers/spree/api/v3/admin/payment_capture_event_serializer.rb +16 -0
- data/app/serializers/spree/api/v3/admin/payment_serializer.rb +12 -1
- data/app/serializers/spree/api/v3/admin/permission_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/price_list_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/purchase_order_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/return_serializer.rb +2 -0
- data/app/serializers/spree/api/v3/admin/seller_requirement_status_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/seller_serializer.rb +2 -2
- data/app/serializers/spree/api/v3/admin/seller_transfer_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/shipping_label_serializer.rb +3 -3
- data/app/serializers/spree/api/v3/admin/stock_location_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/stock_receipt_item_serializer.rb +2 -2
- data/app/serializers/spree/api/v3/admin/stock_receipt_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/stock_transfer_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/store_serializer.rb +1 -0
- data/app/serializers/spree/api/v3/admin/tax_exemption_certificate_serializer.rb +7 -3
- data/app/serializers/spree/api/v3/admin/tax_exemption_reason_serializer.rb +20 -0
- data/app/serializers/spree/api/v3/admin/tax_identifier_serializer.rb +2 -2
- data/app/serializers/spree/api/v3/admin/tax_line_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/tax_rate_serializer.rb +1 -2
- data/app/serializers/spree/api/v3/admin/variant_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/webhook_delivery_serializer.rb +4 -1
- data/app/serializers/spree/api/v3/base_serializer.rb +41 -5
- data/app/serializers/spree/api/v3/claim_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/collection_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/company_invitation_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/company_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/data_request_confirmation_requested_event_serializer.rb +28 -0
- data/app/serializers/spree/api/v3/data_request_event_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/data_request_serializer.rb +8 -4
- data/app/serializers/spree/api/v3/delivery_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/delivery_zone_member_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/discount_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/file_upload_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/fulfillment_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/media_event_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/media_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/option_type_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/order_group_serializer.rb +2 -2
- data/app/serializers/spree/api/v3/order_serializer.rb +4 -2
- data/app/serializers/spree/api/v3/payment_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/product_filter_availability_option_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/product_filter_option_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/seller/address_serializer.rb +3 -2
- data/app/serializers/spree/api/v3/seller/order_serializer.rb +3 -3
- data/app/serializers/spree/api/v3/seller/package_type_serializer.rb +3 -3
- data/app/serializers/spree/api/v3/seller/profile_serializer.rb +2 -2
- data/app/serializers/spree/api/v3/seller/requirement_status_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/seller/tax_identifier_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/seller/variant_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/shipping_label_event_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/stock_movement_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/stock_receipt_event_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/variant_serializer.rb +2 -2
- data/app/services/spree/api/v3/filter_table.rb +22 -3
- data/app/services/spree/api/v3/request_schema.rb +64 -17
- data/config/locales/en.yml +1 -0
- data/config/routes.rb +80 -12
- data/lib/spree/api/agent_membership_map.rb +73 -0
- data/lib/spree/api/agent_resource_map.rb +350 -0
- data/lib/spree/api/agent_workflow_endpoints.rb +113 -0
- data/lib/spree/api/agent_write_schemas.rb +131 -0
- data/lib/spree/api/dependencies.rb +5 -4
- data/lib/spree/api/engine.rb +40 -0
- data/lib/spree/api/enum_types.rb +118 -0
- data/lib/spree/api/extension_fields.rb +167 -0
- data/lib/spree/api/oauth.rb +243 -0
- data/lib/spree/api/openapi/filter_documentation.rb +8 -1
- data/lib/spree/api/typelizer_generation.rb +5 -0
- data/lib/spree/api.rb +8 -0
- data/lib/typelizer/open_enums.rb +48 -10
- metadata +37 -6
|
@@ -75,10 +75,16 @@ module Spree
|
|
|
75
75
|
[:user, { attachment_attachment: :blob }]
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
# Narrowed to the kinds this caller may read.
|
|
79
|
+
#
|
|
80
|
+
# Filtered for every principal that carries a ceiling, not only a
|
|
81
|
+
# secret key: an OAuth token resolves as a staff user with no key,
|
|
82
|
+
# so testing for one let an agent list every export in the store —
|
|
83
|
+
# including that customer and gift-card exports exist — whatever the
|
|
84
|
+
# merchant consented to. A staff member on a narrow role was
|
|
85
|
+
# unfiltered for the same reason.
|
|
78
86
|
def scope
|
|
79
87
|
collection = super
|
|
80
|
-
return collection unless scope_limited_principal?
|
|
81
|
-
|
|
82
88
|
collection.where(type: readable_export_types)
|
|
83
89
|
end
|
|
84
90
|
|
|
@@ -144,10 +150,16 @@ module Spree
|
|
|
144
150
|
action_name == 'create' ? resolve_export_type(params[:type]) : find_resource.class
|
|
145
151
|
end
|
|
146
152
|
|
|
153
|
+
# `read_all` is not a catalog key, so it is asked of a secret key
|
|
154
|
+
# directly; every shipped export declares a required scope, and an
|
|
155
|
+
# extension type that declares none is readable only by a key that
|
|
156
|
+
# holds everything.
|
|
147
157
|
def readable_export_types
|
|
148
158
|
Spree::Export.available_types.select do |type|
|
|
149
159
|
required = type.required_scope
|
|
150
|
-
|
|
160
|
+
next current_api_key&.has_scope?('read_all') if required.blank?
|
|
161
|
+
|
|
162
|
+
holds_permission?("read_#{required}")
|
|
151
163
|
end.map(&:to_s)
|
|
152
164
|
end
|
|
153
165
|
end
|
|
@@ -59,14 +59,7 @@ module Spree
|
|
|
59
59
|
return unless params.key?(:customer_id)
|
|
60
60
|
return @customer = nil if params[:customer_id].blank?
|
|
61
61
|
|
|
62
|
-
unless holds_permission?('read_customers')
|
|
63
|
-
return render_error(
|
|
64
|
-
code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
|
|
65
|
-
message: 'Missing permission: read_customers',
|
|
66
|
-
status: :forbidden,
|
|
67
|
-
details: { required_permission: 'read_customers' }
|
|
68
|
-
)
|
|
69
|
-
end
|
|
62
|
+
return render_missing_permission('read_customers') unless holds_permission?('read_customers')
|
|
70
63
|
|
|
71
64
|
@customer = Spree.customer_class.
|
|
72
65
|
accessible_by(current_ability, :show).
|
|
@@ -247,10 +247,15 @@ module Spree
|
|
|
247
247
|
[:user, :mappings, { attachment_attachment: :blob }]
|
|
248
248
|
end
|
|
249
249
|
|
|
250
|
+
# Narrowed to the kinds this caller may write.
|
|
251
|
+
#
|
|
252
|
+
# Filtered for every principal that carries a ceiling, not only a
|
|
253
|
+
# secret key — see the matching note on Admin::ExportsController.
|
|
254
|
+
# It matters more here: an import row carries `sample_row`, a real
|
|
255
|
+
# line from the uploaded file, so an unfiltered list handed a
|
|
256
|
+
# read-only agent live customer data rather than only metadata.
|
|
250
257
|
def scope
|
|
251
258
|
collection = super
|
|
252
|
-
return collection unless scope_limited_principal?
|
|
253
|
-
|
|
254
259
|
collection.where(type: writable_import_types)
|
|
255
260
|
end
|
|
256
261
|
|
|
@@ -363,10 +368,14 @@ module Spree
|
|
|
363
368
|
end
|
|
364
369
|
end
|
|
365
370
|
|
|
371
|
+
# `write_all` is not a catalog key, so it is asked of a secret key
|
|
372
|
+
# directly; every shipped import declares a required scope.
|
|
366
373
|
def writable_import_types
|
|
367
374
|
Spree::Import.available_types.select do |type|
|
|
368
375
|
required = type.required_scope
|
|
369
|
-
|
|
376
|
+
next current_api_key&.has_scope?('write_all') if required.blank?
|
|
377
|
+
|
|
378
|
+
holds_permission?("write_#{required}")
|
|
370
379
|
end.map(&:to_s)
|
|
371
380
|
end
|
|
372
381
|
|
|
@@ -26,7 +26,9 @@ module Spree
|
|
|
26
26
|
required: true, on: :create,
|
|
27
27
|
example: 'de',
|
|
28
28
|
description: 'Locale used as the market default. Always part of `supported_locales`.'
|
|
29
|
-
body.attribute :tax_inclusive, :boolean,
|
|
29
|
+
body.attribute :tax_inclusive, :boolean,
|
|
30
|
+
description: 'Whether the prices entered for this market include tax. True charges every customer the ' \
|
|
31
|
+
'price as entered, with their tax inside it; false adds tax at checkout.'
|
|
30
32
|
body.attribute :tax_provider, :string,
|
|
31
33
|
nullable: true,
|
|
32
34
|
example: 'internal',
|
|
@@ -45,6 +45,8 @@ module Spree
|
|
|
45
45
|
# *other* users. `avatar_signed_id` takes a signed id from
|
|
46
46
|
# POST /files to set the photo, or `null` to remove it.
|
|
47
47
|
def update
|
|
48
|
+
return refuse_delegated_write if current_oauth_token.present?
|
|
49
|
+
|
|
48
50
|
if current_user.update(permitted_params)
|
|
49
51
|
render json: me_response
|
|
50
52
|
else
|
|
@@ -54,6 +56,20 @@ module Spree
|
|
|
54
56
|
|
|
55
57
|
private
|
|
56
58
|
|
|
59
|
+
# A grant acts *for* a staff member, not *as* one. This action is
|
|
60
|
+
# unguarded because it was only ever the person at the keyboard
|
|
61
|
+
# editing their own profile — but a delegated credential reaches it
|
|
62
|
+
# too, and the name it could rewrite is the one audit trails show
|
|
63
|
+
# as the actor. There is no catalog permission for "edit my own
|
|
64
|
+
# profile", so the answer is that an agent has no business here.
|
|
65
|
+
def refuse_delegated_write
|
|
66
|
+
render_error(
|
|
67
|
+
code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
|
|
68
|
+
message: 'A connected agent cannot change the profile of the person who connected it.',
|
|
69
|
+
status: :forbidden
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
57
73
|
# A request authenticated by a secret API key has no Spree user to
|
|
58
74
|
# describe/update, so it gets a 404 pointing at the key endpoint
|
|
59
75
|
# rather than a 500 from a nil user — mirroring ApiKeysController#current.
|
|
@@ -82,11 +98,15 @@ module Spree
|
|
|
82
98
|
params.permit(*declared_permitted_attributes)
|
|
83
99
|
end
|
|
84
100
|
|
|
101
|
+
# What this credential may do, which is not always what its owner
|
|
102
|
+
# may do: a grant narrows the approver's role, so reporting the
|
|
103
|
+
# role would both disclose more than was handed over and mislead a
|
|
104
|
+
# client that reads /me to decide what to attempt.
|
|
85
105
|
def me_response
|
|
86
106
|
{
|
|
87
107
|
user: admin_user_serializer.new(current_user, params: serializer_params).to_h,
|
|
88
108
|
permissions: serialize_permissions(current_ability),
|
|
89
|
-
permission_keys:
|
|
109
|
+
permission_keys: staff_permission_keys
|
|
90
110
|
}
|
|
91
111
|
end
|
|
92
112
|
|
|
@@ -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
|
|
@@ -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,
|