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.
- 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/concerns/spree/api/v3/store/search_provider_support.rb +8 -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/store_credits_controller.rb +13 -1
- data/app/controllers/spree/api/v3/store/carts_controller.rb +4 -7
- data/app/request_schemas/spree/api/v3/admin/email_template_preview_request_schema.rb +4 -0
- data/app/request_schemas/spree/api/v3/store/address_request_schema.rb +2 -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/price_history_serializer.rb +1 -0
- data/app/serializers/spree/api/v3/admin/price_serializer.rb +2 -0
- 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/app/serializers/spree/api/v3/cart_serializer.rb +5 -1
- data/app/serializers/spree/api/v3/price_history_serializer.rb +6 -1
- data/app/serializers/spree/api/v3/price_serializer.rb +11 -1
- data/app/serializers/spree/api/v3/seller/price_history_serializer.rb +1 -0
- data/app/serializers/spree/api/v3/seller/price_serializer.rb +2 -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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 50559a5f0c0305cee4fbf43e8d145078d0f976ffeed8a40be46f19e4d1cca64d
|
|
4
|
+
data.tar.gz: '00688dd0b79f087aeee42271fd8678eacbaca3626115770fa29043cbbe3e3182'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d56fd3fd92f8bdd5257da98f842c366aa4ad324fcb5ef64c8cf44f007011144813da32af273828604db8cc2ce237fc17df51d633de6132594b41ab3002923c4d
|
|
7
|
+
data.tar.gz: 34e4d178767f8feccad3b96dbffb9ec02727bce30cdf106a19b9fca97c3af8f44272cf06b4a5facef4864ff3216860538c1a742cc2a1acc26a1589ea1c12b3d9
|
|
@@ -60,12 +60,17 @@ module Spree
|
|
|
60
60
|
|
|
61
61
|
# An unsaved subject and body are previewed only when sent, so an
|
|
62
62
|
# empty body still previews as empty. `branding` previews unsaved
|
|
63
|
-
# colors and font
|
|
63
|
+
# colors and font, `mailer_logo_signed_id` an unsaved logo (null
|
|
64
|
+
# removes it, as it does when saving the store).
|
|
64
65
|
def preview_arguments
|
|
65
66
|
arguments = params.permit(:record_id, :subject, :body).to_h.symbolize_keys
|
|
66
67
|
arguments[:locale] = language
|
|
67
68
|
arguments[:email_key] = params[:email_key].to_s.tr('.', '/').presence
|
|
68
69
|
arguments[:branding] = params.fetch(:branding, {}).permit(*Spree::Emails::Branding.attribute_names).to_h
|
|
70
|
+
if params.key?(:mailer_logo_signed_id)
|
|
71
|
+
signed_id = params[:mailer_logo_signed_id].presence&.to_s
|
|
72
|
+
arguments.merge!(mailer_logo_signed_id: signed_id, mailer_logo_cleared: signed_id.nil?)
|
|
73
|
+
end
|
|
69
74
|
arguments
|
|
70
75
|
end
|
|
71
76
|
|
|
@@ -3,22 +3,31 @@ module Spree
|
|
|
3
3
|
module V3
|
|
4
4
|
module Admin
|
|
5
5
|
# Per-member authorization for the reporting vocabulary. Metrics,
|
|
6
|
-
# dimensions and counters all declare a `
|
|
7
|
-
#
|
|
8
|
-
# secret keys), so one predicate answers for every member and every
|
|
6
|
+
# dimensions and counters all declare a `key_scope` paired with a
|
|
7
|
+
# `subject`, so one predicate answers for every member and every
|
|
9
8
|
# surface that lists them — the schema, a query, the home screen's
|
|
10
9
|
# counters — filters by the same rule.
|
|
10
|
+
#
|
|
11
|
+
# Asked of the credential rather than of its owner. Branching on
|
|
12
|
+
# `current_api_key` measured an OAuth grant against the approving
|
|
13
|
+
# staff member's whole role, because a token authenticates as that
|
|
14
|
+
# person and carries no key: a grant for `read_reports` alone could
|
|
15
|
+
# group a report by customer and read names, emails and spend.
|
|
16
|
+
# `holds_permission?` is the narrowed answer for all three
|
|
17
|
+
# principals — a key's scopes, a grant's, or the role when someone is
|
|
18
|
+
# acting directly at the dashboard.
|
|
11
19
|
module ReportingAuthorization
|
|
12
20
|
extend ActiveSupport::Concern
|
|
13
21
|
|
|
14
22
|
private
|
|
15
23
|
|
|
16
24
|
def member_allowed?(member)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
25
|
+
return false unless member.key_scope.blank? || holds_permission?(member.key_scope)
|
|
26
|
+
|
|
27
|
+
# The permission says whether this kind of data is in scope at
|
|
28
|
+
# all; a host app's record-level rule can still narrow which of
|
|
29
|
+
# it this person sees.
|
|
30
|
+
member.subject.nil? || can?(:read, member.subject.call)
|
|
22
31
|
end
|
|
23
32
|
end
|
|
24
33
|
end
|
|
@@ -60,6 +60,13 @@ module Spree
|
|
|
60
60
|
def resolve_admin_store
|
|
61
61
|
return secret_api_key.store if secret_api_key
|
|
62
62
|
|
|
63
|
+
# An OAuth client is registered for one store and consented to on
|
|
64
|
+
# that store, so its token names the tenancy as surely as a secret
|
|
65
|
+
# key does. Reading the header instead would let a client name
|
|
66
|
+
# another merchant's store on a grant that never covered it.
|
|
67
|
+
oauth_store = respond_to?(:admin_oauth_token, true) && admin_oauth_token&.application&.store
|
|
68
|
+
return oauth_store if oauth_store
|
|
69
|
+
|
|
63
70
|
if store_id_header
|
|
64
71
|
Spree::Store.find_by_prefix_id(store_id_header)
|
|
65
72
|
else
|
|
@@ -50,6 +50,11 @@ module Spree
|
|
|
50
50
|
return true
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# Then an OAuth token, which carries both the admin who granted it
|
|
54
|
+
# and the store its client was registered for, so neither a JWT's
|
|
55
|
+
# store-membership check nor a key's store selection applies.
|
|
56
|
+
return true if authenticate_admin_oauth_token
|
|
57
|
+
|
|
53
58
|
# Fall back to JWT authentication, then bind the admin to the store
|
|
54
59
|
# they hold a role on (the token itself is store-agnostic).
|
|
55
60
|
return false unless require_authentication!
|
|
@@ -59,6 +64,62 @@ module Spree
|
|
|
59
64
|
|
|
60
65
|
private
|
|
61
66
|
|
|
67
|
+
# An OAuth bearer token, accepted only when it was issued for this
|
|
68
|
+
# API. A token names the resources it may be used against (RFC 8707),
|
|
69
|
+
# and a client asking for one endpoint must not receive a credential
|
|
70
|
+
# good across the rest — so a token naming only the MCP endpoint is
|
|
71
|
+
# refused here, and the MCP controller refuses one naming only this.
|
|
72
|
+
#
|
|
73
|
+
# @return [Boolean] true when the request is now authenticated
|
|
74
|
+
def authenticate_admin_oauth_token
|
|
75
|
+
token = admin_oauth_token
|
|
76
|
+
return false if token.nil?
|
|
77
|
+
|
|
78
|
+
owner = token.resource_owner
|
|
79
|
+
return false unless owner.is_a?(Spree.admin_user_class)
|
|
80
|
+
|
|
81
|
+
@current_api_key = nil
|
|
82
|
+
@current_user = owner
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [Spree::OauthAccessToken, nil]
|
|
87
|
+
def admin_oauth_token
|
|
88
|
+
return @admin_oauth_token if defined?(@admin_oauth_token)
|
|
89
|
+
|
|
90
|
+
@admin_oauth_token = begin
|
|
91
|
+
token = Spree::OauthAccessToken.by_token(oauth_bearer_value)
|
|
92
|
+
token if token&.accessible? && admin_audience_matches?(token)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Split rather than matched: a regex with `\s+(.+)` backtracks on a
|
|
97
|
+
# header of many spaces, and this one is attacker-supplied on an
|
|
98
|
+
# unauthenticated request.
|
|
99
|
+
#
|
|
100
|
+
# @return [String, nil]
|
|
101
|
+
def oauth_bearer_value
|
|
102
|
+
scheme, value = request.headers['Authorization'].to_s.split(' ', 2)
|
|
103
|
+
return unless scheme&.casecmp?('Bearer')
|
|
104
|
+
|
|
105
|
+
value.to_s.strip.presence
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Compared by path, because the origin a client was given varies with
|
|
109
|
+
# how the installation is reached — a tunnel, a proxy, a custom
|
|
110
|
+
# domain — while the path it names does not. An unbound token is
|
|
111
|
+
# refused rather than trusted.
|
|
112
|
+
def admin_audience_matches?(token)
|
|
113
|
+
expected = Spree::Api::Oauth.resources[:admin]
|
|
114
|
+
return false if expected.blank?
|
|
115
|
+
|
|
116
|
+
token.resource.to_s.split.any? do |indicator|
|
|
117
|
+
URI.parse(indicator).path == expected
|
|
118
|
+
rescue URI::InvalidURIError
|
|
119
|
+
false
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
62
123
|
# Rejects an authenticated JWT admin who has no role on +current_store+.
|
|
63
124
|
# API-key principals are already store-bound and skip this check.
|
|
64
125
|
def require_store_membership!
|
|
@@ -90,6 +90,11 @@ module Spree
|
|
|
90
90
|
# Request errors
|
|
91
91
|
request_too_large: 'request_too_large',
|
|
92
92
|
|
|
93
|
+
# Tax engine errors — the same two codes the completion workflow
|
|
94
|
+
# reports, so a client sees one vocabulary wherever tax failed.
|
|
95
|
+
tax_provider_unavailable: 'tax_provider_unavailable',
|
|
96
|
+
tax_calculation_refused: 'tax_calculation_refused',
|
|
97
|
+
|
|
93
98
|
# General errors
|
|
94
99
|
processing_error: 'processing_error',
|
|
95
100
|
invalid_request: 'invalid_request'
|
|
@@ -101,6 +106,12 @@ module Spree
|
|
|
101
106
|
rescue_from CanCan::AccessDenied, with: :handle_access_denied
|
|
102
107
|
rescue_from Spree::Storefront::AccessDenied, with: :handle_access_denied
|
|
103
108
|
rescue_from Spree::Core::GatewayError, with: :handle_gateway_error
|
|
109
|
+
# The backstop for every path that prices tax. Cart updates and
|
|
110
|
+
# completion answer for themselves, with a message shaped to what the
|
|
111
|
+
# customer was doing; anything else — adding an item, changing a
|
|
112
|
+
# quantity, a recalculation — reaches here rather than escaping as a
|
|
113
|
+
# 500 with an HTML body that a storefront cannot tell from a bug.
|
|
114
|
+
rescue_from Spree::Tax::ProviderError, with: :handle_tax_provider_error
|
|
104
115
|
rescue_from ActionController::ParameterMissing, with: :handle_parameter_missing
|
|
105
116
|
rescue_from ActiveRecord::RecordInvalid, with: :handle_record_invalid
|
|
106
117
|
rescue_from Spree::Preferences::InvalidPreferences, with: :handle_invalid_preferences
|
|
@@ -171,6 +182,25 @@ module Spree
|
|
|
171
182
|
end
|
|
172
183
|
end
|
|
173
184
|
|
|
185
|
+
# Renders a failed workflow or service result. A tax engine failure the
|
|
186
|
+
# workflow caught itself (so it could roll the cart back) answers as
|
|
187
|
+
# handle_tax_provider_error would have: 503 for an outage, the one
|
|
188
|
+
# status the SDK retries, and 422 for a refusal. Anything else is
|
|
189
|
+
# rendered with +code+.
|
|
190
|
+
#
|
|
191
|
+
# @param result [Spree::ServiceModule::Result]
|
|
192
|
+
# @param code [String] the error code for any other failure
|
|
193
|
+
def render_result_failure(result, code:)
|
|
194
|
+
case result.error.code
|
|
195
|
+
when ERROR_CODES[:tax_provider_unavailable]
|
|
196
|
+
render_error(code: ERROR_CODES[:tax_provider_unavailable], message: result.error.to_s, status: :service_unavailable)
|
|
197
|
+
when ERROR_CODES[:tax_calculation_refused]
|
|
198
|
+
render_error(code: ERROR_CODES[:tax_calculation_refused], message: result.error.to_s, status: :unprocessable_content)
|
|
199
|
+
else
|
|
200
|
+
render_service_error(result.error, code: code)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
174
204
|
# Legacy support - redirect to new error handling
|
|
175
205
|
def render_errors(errors, status = :unprocessable_content)
|
|
176
206
|
code = infer_error_code(errors, status)
|
|
@@ -235,6 +265,25 @@ module Spree
|
|
|
235
265
|
)
|
|
236
266
|
end
|
|
237
267
|
|
|
268
|
+
# Refused and unavailable are different instructions to the customer, so
|
|
269
|
+
# they keep different codes: one names something to fix, the other is
|
|
270
|
+
# worth retrying. Avalara's own wording is passed through for a refusal
|
|
271
|
+
# because it names the problem better than we can; an outage says only
|
|
272
|
+
# that, since its detail is about our endpoint and belongs in the error
|
|
273
|
+
# tracker.
|
|
274
|
+
def handle_tax_provider_error(exception)
|
|
275
|
+
Rails.error.report(exception, context: error_context, source: 'spree.api.v3')
|
|
276
|
+
|
|
277
|
+
refused = exception.is_a?(Spree::Tax::CalculationRefused)
|
|
278
|
+
render_error(
|
|
279
|
+
code: refused ? ERROR_CODES[:tax_calculation_refused] : ERROR_CODES[:tax_provider_unavailable],
|
|
280
|
+
message: refused ? exception.message.to_s : I18n.t('spree.cart_line_item.tax_unavailable'),
|
|
281
|
+
# A refusal is the request's fault, an outage is not. 503 is also
|
|
282
|
+
# the one the SDK retries — it retries on status, not on our code.
|
|
283
|
+
status: refused ? :unprocessable_content : :service_unavailable
|
|
284
|
+
)
|
|
285
|
+
end
|
|
286
|
+
|
|
238
287
|
def handle_parameter_missing(exception)
|
|
239
288
|
Rails.error.report(exception, context: error_context, source: 'spree.api.v3')
|
|
240
289
|
render_error(
|
|
@@ -27,7 +27,9 @@ module Spree
|
|
|
27
27
|
ActionController::Parameters.new(hash).permit!
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
# @param owner [Class, nil] the model whose associations name these
|
|
31
|
+
# keys, so `variant_id` is checked against `Spree::Variant`
|
|
32
|
+
def resolve_prefixed_ids(hash, owner = (model_class if respond_to?(:model_class, true)))
|
|
31
33
|
hash.each_with_object({}.with_indifferent_access) do |(key, value), result|
|
|
32
34
|
result[key] = case
|
|
33
35
|
# Preferences declare which of their values are ids
|
|
@@ -36,19 +38,49 @@ module Spree
|
|
|
36
38
|
when key.to_s == 'preferences'
|
|
37
39
|
value
|
|
38
40
|
when key.to_s.end_with?('_id') && prefixed_id?(value)
|
|
39
|
-
decode_prefixed_id(value)
|
|
41
|
+
decode_prefixed_id(value, key, owner)
|
|
40
42
|
when key.to_s.end_with?('_ids') && value.is_a?(Array)
|
|
41
|
-
value.map { |v| prefixed_id?(v) ? decode_prefixed_id(v) : v }
|
|
43
|
+
value.map { |v| prefixed_id?(v) ? decode_prefixed_id(v, key, owner) : v }
|
|
42
44
|
when value.is_a?(Hash)
|
|
43
|
-
resolve_prefixed_ids(value)
|
|
45
|
+
resolve_prefixed_ids(value, nested_owner(key, owner))
|
|
44
46
|
when value.is_a?(Array)
|
|
45
|
-
|
|
47
|
+
nested = nested_owner(key, owner)
|
|
48
|
+
value.map { |v| v.is_a?(Hash) ? resolve_prefixed_ids(v, nested) : v }
|
|
46
49
|
else
|
|
47
50
|
value
|
|
48
51
|
end
|
|
49
52
|
end
|
|
50
53
|
end
|
|
51
54
|
|
|
55
|
+
# The model a nested hash or array of hashes describes, so the keys
|
|
56
|
+
# inside it are checked against the right associations: an order's
|
|
57
|
+
# `items` are line items, whose `variant_id` is a variant.
|
|
58
|
+
#
|
|
59
|
+
# A payload name is a public contract and does not always match the
|
|
60
|
+
# association — `items` is `line_items` — so a controller whose
|
|
61
|
+
# names differ declares the mapping in {nested_param_models}.
|
|
62
|
+
def nested_owner(key, owner)
|
|
63
|
+
return if owner.nil?
|
|
64
|
+
|
|
65
|
+
declared = nested_param_models[key.to_s]
|
|
66
|
+
return declared if declared
|
|
67
|
+
|
|
68
|
+
name = key.to_s
|
|
69
|
+
reflection = owner.try(:reflect_on_association, name) ||
|
|
70
|
+
owner.try(:reflect_on_association, name.singularize)
|
|
71
|
+
reflection&.klass
|
|
72
|
+
rescue StandardError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Payload key => the model its rows describe, for the keys that do not
|
|
77
|
+
# name an association of their own.
|
|
78
|
+
#
|
|
79
|
+
# @return [Hash{String => Class}]
|
|
80
|
+
def nested_param_models
|
|
81
|
+
{}
|
|
82
|
+
end
|
|
83
|
+
|
|
52
84
|
def normalize_nested_attributes(hash, klass = model_class)
|
|
53
85
|
return hash unless klass.respond_to?(:nested_attributes_options)
|
|
54
86
|
|
|
@@ -83,8 +115,45 @@ module Spree
|
|
|
83
115
|
Spree::PrefixedId.prefixed_id?(value)
|
|
84
116
|
end
|
|
85
117
|
|
|
86
|
-
|
|
87
|
-
|
|
118
|
+
# A prefixed id is decoded only when its prefix is the one the column
|
|
119
|
+
# expects. The sqid alphabet is shared, so `prod_` and `variant_`
|
|
120
|
+
# decode to the same integers: without this an order asked for a
|
|
121
|
+
# product answered 201 and held a line item for whatever variant
|
|
122
|
+
# happened to carry that number.
|
|
123
|
+
#
|
|
124
|
+
# An id of the wrong kind is left as it was. The association then
|
|
125
|
+
# fails to find it, which is a 404 or a validation error naming the
|
|
126
|
+
# field — the same answer any other unknown id gets, rather than a
|
|
127
|
+
# quiet write of the wrong record.
|
|
128
|
+
def decode_prefixed_id(prefixed_id_string, key = nil, owner = nil)
|
|
129
|
+
target = association_class(key, owner)
|
|
130
|
+
|
|
131
|
+
decoded = if target.respond_to?(:decode_own_prefixed_id)
|
|
132
|
+
target.decode_own_prefixed_id(prefixed_id_string)
|
|
133
|
+
else
|
|
134
|
+
Spree::PrefixedId.decode_prefixed_id(prefixed_id_string)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
decoded || prefixed_id_string
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# What `variant_id` points at, read from the owner's own association
|
|
141
|
+
# rather than guessed from the name — `reason_id` is a different class
|
|
142
|
+
# on a cancellation than on a refund.
|
|
143
|
+
def association_class(key, owner)
|
|
144
|
+
return if key.nil? || owner.nil?
|
|
145
|
+
|
|
146
|
+
name = key.to_s.sub(/_ids?\z/, '')
|
|
147
|
+
# `category_ids` names `has_many :categories`, so the singular form
|
|
148
|
+
# finds nothing and the id went through unchecked — the same hole
|
|
149
|
+
# the singular `_id` case had.
|
|
150
|
+
reflection = owner.try(:reflect_on_association, name) ||
|
|
151
|
+
owner.try(:reflect_on_association, name.pluralize)
|
|
152
|
+
return if reflection.nil? || reflection.polymorphic?
|
|
153
|
+
|
|
154
|
+
reflection.klass
|
|
155
|
+
rescue StandardError
|
|
156
|
+
nil
|
|
88
157
|
end
|
|
89
158
|
end
|
|
90
159
|
end
|
|
@@ -98,7 +98,17 @@ module Spree
|
|
|
98
98
|
# A `jwt_only` skip exempts the JWT/publishable principal we now know
|
|
99
99
|
# this is (no secret key resolved) — shell data the dashboard reads
|
|
100
100
|
# without a specific scope.
|
|
101
|
-
|
|
101
|
+
#
|
|
102
|
+
# Not a delegated credential, though. The exemption exists because
|
|
103
|
+
# a signed-in staff member is already bounded by their role, and an
|
|
104
|
+
# OAuth grant is bounded by something narrower that this would skip
|
|
105
|
+
# — it carries no API key, so it was taking the human's exemption
|
|
106
|
+
# and reading settings its scopes never covered.
|
|
107
|
+
if scope_check_skipped_for_action? && self.class._scope_check_skip_jwt_only
|
|
108
|
+
return if current_oauth_token.blank?
|
|
109
|
+
|
|
110
|
+
return authorize_staff_permission!
|
|
111
|
+
end
|
|
102
112
|
|
|
103
113
|
# JWT staff pass through the same key gate as secret keys: their
|
|
104
114
|
# roles' catalog keys play the role scopes play for keys, so both
|
|
@@ -142,7 +152,7 @@ module Spree
|
|
|
142
152
|
# fallback, host-registered types) defer to CanCanCan's `authorize!`
|
|
143
153
|
# — the caller stays gated per subject, just not by key.
|
|
144
154
|
return unless Spree.permissions.key?(required)
|
|
145
|
-
return if
|
|
155
|
+
return if staff_permission_keys.include?(required)
|
|
146
156
|
|
|
147
157
|
Rails.logger.info do
|
|
148
158
|
"[Spree] Access denied: user=#{current_user.id} required=#{required} " \
|
|
@@ -216,7 +226,54 @@ module Spree
|
|
|
216
226
|
ability = current_ability
|
|
217
227
|
return true unless ability.respond_to?(:permission_keys)
|
|
218
228
|
|
|
219
|
-
|
|
229
|
+
staff_permission_keys.include?(key.to_s)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# What a staff principal may do here, as catalog keys.
|
|
233
|
+
#
|
|
234
|
+
# Usually their roles' keys. When the request arrived on a delegated
|
|
235
|
+
# credential — an OAuth token a staff member approved for an agent or
|
|
236
|
+
# an app — the grant's scopes narrow that set: the person consented to
|
|
237
|
+
# part of their authority, not all of it. The grant can only ever
|
|
238
|
+
# subtract, so approving a client never widens what its owner holds.
|
|
239
|
+
#
|
|
240
|
+
# @return [Array<String>]
|
|
241
|
+
def staff_permission_keys
|
|
242
|
+
@staff_permission_keys ||= begin
|
|
243
|
+
ability = current_ability
|
|
244
|
+
held = ability.respond_to?(:permission_keys) ? ability.permission_keys.map(&:to_s) : []
|
|
245
|
+
granted = delegated_permission_keys
|
|
246
|
+
granted.nil? ? held : held & granted
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# The scopes of the delegated credential this request arrived on, or
|
|
251
|
+
# nil when the staff member is acting directly (a dashboard session).
|
|
252
|
+
#
|
|
253
|
+
# Controllers that accept an OAuth token override
|
|
254
|
+
# `current_oauth_token`; everything else inherits nil and is unchanged.
|
|
255
|
+
#
|
|
256
|
+
# @return [Array<String>, nil]
|
|
257
|
+
def delegated_permission_keys
|
|
258
|
+
token = current_oauth_token
|
|
259
|
+
return nil if token.blank?
|
|
260
|
+
|
|
261
|
+
Spree.permissions.expand_keys(token.scopes.to_a)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# The delegated credential this request arrived on.
|
|
265
|
+
#
|
|
266
|
+
# Resolved here rather than overridden per controller: this module is
|
|
267
|
+
# included after the authentication concerns, so an override in one of
|
|
268
|
+
# them loses to this definition and the narrowing silently stops
|
|
269
|
+
# happening — which is how a token scoped to `read_products` once
|
|
270
|
+
# created one.
|
|
271
|
+
#
|
|
272
|
+
# @return [Spree::OauthAccessToken, nil]
|
|
273
|
+
def current_oauth_token
|
|
274
|
+
return nil unless respond_to?(:admin_oauth_token, true)
|
|
275
|
+
|
|
276
|
+
admin_oauth_token
|
|
220
277
|
end
|
|
221
278
|
end
|
|
222
279
|
end
|
|
@@ -18,12 +18,11 @@ module Spree
|
|
|
18
18
|
private
|
|
19
19
|
|
|
20
20
|
def search_query
|
|
21
|
-
|
|
21
|
+
search_params['search']
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def search_filters
|
|
25
|
-
q =
|
|
26
|
-
q = q.to_h if q.respond_to?(:to_h) && !q.is_a?(Hash)
|
|
25
|
+
q = search_params
|
|
27
26
|
# Decode Stripe-style prefixed IDs in `*_id_in`/`id_eq`/etc. so
|
|
28
27
|
# SPA + storefront filters can pass `prod_…` keys; the search
|
|
29
28
|
# provider hands the filter hash straight to Ransack on the
|
|
@@ -31,6 +30,12 @@ module Spree
|
|
|
31
30
|
decode_prefixed_id_predicates(q.except('search')).presence
|
|
32
31
|
end
|
|
33
32
|
|
|
33
|
+
# A `q` sent as anything but a hash (`q=foo`) filters nothing.
|
|
34
|
+
def search_params
|
|
35
|
+
q = params[:q]
|
|
36
|
+
q.respond_to?(:to_unsafe_h) ? q.to_unsafe_h : {}
|
|
37
|
+
end
|
|
38
|
+
|
|
34
39
|
def search_provider
|
|
35
40
|
@search_provider ||= Spree.search_provider.constantize.new(current_store)
|
|
36
41
|
end
|
|
@@ -12,9 +12,8 @@ module Spree
|
|
|
12
12
|
request_params :tax_exemption_certificate do |body|
|
|
13
13
|
body.attribute :certificate_number, :string, required: true, on: :create,
|
|
14
14
|
example: 'DE-RESALE-7'
|
|
15
|
-
body.attribute :
|
|
16
|
-
|
|
17
|
-
description: "Becomes the tax provider's entity use code."
|
|
15
|
+
body.attribute :tax_exemption_reason_id, :id, required: true, on: :create,
|
|
16
|
+
description: "One of the store's active tax exemption reasons. The tax provider receives its `code`."
|
|
18
17
|
body.attribute :issuing_authority, :string, nullable: true, example: 'Finanzamt Berlin'
|
|
19
18
|
body.attribute :issued_at, :datetime, nullable: true
|
|
20
19
|
body.attribute :expires_at, :datetime, nullable: true
|
|
@@ -100,11 +99,18 @@ module Spree
|
|
|
100
99
|
end
|
|
101
100
|
|
|
102
101
|
def collection_includes
|
|
103
|
-
[{ document_attachment: :blob }]
|
|
102
|
+
[:tax_exemption_reason, { document_attachment: :blob }]
|
|
104
103
|
end
|
|
105
104
|
|
|
105
|
+
# Read through the store, so another store's reason is a 404
|
|
106
|
+
# rather than a certificate pointing across stores.
|
|
106
107
|
def permitted_params
|
|
107
|
-
params.permit(*declared_permitted_attributes)
|
|
108
|
+
attributes = params.permit(*declared_permitted_attributes)
|
|
109
|
+
return attributes if attributes[:tax_exemption_reason_id].blank?
|
|
110
|
+
|
|
111
|
+
attributes.except(:tax_exemption_reason_id).merge(
|
|
112
|
+
tax_exemption_reason: current_store.tax_exemption_reasons.find_by_prefix_id!(attributes[:tax_exemption_reason_id])
|
|
113
|
+
)
|
|
108
114
|
end
|
|
109
115
|
|
|
110
116
|
def build_resource
|
|
@@ -75,6 +75,9 @@ module Spree
|
|
|
75
75
|
|
|
76
76
|
@resource = existing
|
|
77
77
|
authorize_resource!(@resource, :update)
|
|
78
|
+
# This runs before a nested controller's own parent check, and
|
|
79
|
+
# rendering here ends the request before it would run.
|
|
80
|
+
authorize_parent!(@parent) if @parent.present?
|
|
78
81
|
normalize_upsert_params!
|
|
79
82
|
update
|
|
80
83
|
end
|
|
@@ -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
|
|
@@ -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
|
|