spree_api 6.0.0.rc3 → 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.
- 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/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 +60 -3
- 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/exports_controller.rb +15 -3
- 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 +9 -2
- 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/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/oauth/authorizations_controller.rb +61 -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 +1 -0
- data/app/controllers/spree/api/v3/store/carts_controller.rb +4 -5
- data/app/request_schemas/spree/api/v3/admin/email_template_preview_request_schema.rb +4 -0
- 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/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/return_serializer.rb +2 -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_rate_serializer.rb +1 -2
- data/app/serializers/spree/api/v3/admin/webhook_delivery_serializer.rb +4 -1
- data/app/serializers/spree/api/v3/base_serializer.rb +14 -0
- data/config/routes.rb +61 -1
- 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 +119 -0
- data/lib/spree/api/dependencies.rb +4 -3
- data/lib/spree/api/engine.rb +40 -0
- data/lib/spree/api/extension_fields.rb +167 -0
- data/lib/spree/api/oauth.rb +243 -0
- data/lib/spree/api.rb +8 -0
- data/lib/typelizer/open_enums.rb +17 -3
- 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
|
|
@@ -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:
|
|
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
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
103
|
+
if missing.any?
|
|
104
|
+
return render_error(
|
|
101
105
|
code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
|
|
102
|
-
message: "
|
|
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.
|
|
@@ -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.
|
|
86
|
-
#
|
|
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
|
|
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: "
|
|
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,
|