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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/spree/api/v3/admin/email_template_lookup.rb +6 -1
  3. data/app/controllers/concerns/spree/api/v3/admin/reporting_authorization.rb +17 -8
  4. data/app/controllers/concerns/spree/api/v3/admin/store_context.rb +7 -0
  5. data/app/controllers/concerns/spree/api/v3/admin_authentication.rb +61 -0
  6. data/app/controllers/concerns/spree/api/v3/error_handler.rb +49 -0
  7. data/app/controllers/concerns/spree/api/v3/params_normalizer.rb +76 -7
  8. data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +60 -3
  9. data/app/controllers/spree/api/v3/admin/companies/tax_exemption_certificates_controller.rb +11 -5
  10. data/app/controllers/spree/api/v3/admin/concerns/external_references.rb +3 -0
  11. data/app/controllers/spree/api/v3/admin/exports_controller.rb +15 -3
  12. data/app/controllers/spree/api/v3/admin/imports_controller.rb +12 -3
  13. data/app/controllers/spree/api/v3/admin/markets_controller.rb +3 -1
  14. data/app/controllers/spree/api/v3/admin/me_controller.rb +21 -1
  15. data/app/controllers/spree/api/v3/admin/oauth/application_tokens_controller.rb +56 -0
  16. data/app/controllers/spree/api/v3/admin/oauth/applications_controller.rb +55 -0
  17. data/app/controllers/spree/api/v3/admin/oauth/authorizations_controller.rb +294 -0
  18. data/app/controllers/spree/api/v3/admin/order_groups_controller.rb +1 -1
  19. data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +8 -0
  20. data/app/controllers/spree/api/v3/admin/orders/items_controller.rb +1 -0
  21. data/app/controllers/spree/api/v3/admin/orders/returns_controller.rb +7 -0
  22. data/app/controllers/spree/api/v3/admin/orders_controller.rb +9 -2
  23. data/app/controllers/spree/api/v3/admin/purchase_orders_controller.rb +1 -0
  24. data/app/controllers/spree/api/v3/admin/reporting_controller.rb +20 -14
  25. data/app/controllers/spree/api/v3/admin/stock_levels_controller.rb +1 -1
  26. data/app/controllers/spree/api/v3/admin/tags_controller.rb +8 -5
  27. data/app/controllers/spree/api/v3/admin/tax_exemption_reasons_controller.rb +58 -0
  28. data/app/controllers/spree/api/v3/admin/tax_rates_controller.rb +0 -1
  29. data/app/controllers/spree/api/v3/admin/translations/batches_controller.rb +17 -11
  30. data/app/controllers/spree/api/v3/oauth/authorizations_controller.rb +61 -0
  31. data/app/controllers/spree/api/v3/oauth/metadata_controller.rb +57 -0
  32. data/app/controllers/spree/api/v3/oauth/server_metadata_controller.rb +57 -0
  33. data/app/controllers/spree/api/v3/resource_controller.rb +1 -0
  34. data/app/controllers/spree/api/v3/store/carts_controller.rb +4 -5
  35. data/app/request_schemas/spree/api/v3/admin/email_template_preview_request_schema.rb +4 -0
  36. data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +2 -0
  37. data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +2 -0
  38. data/app/serializers/spree/api/v3/admin/oauth_access_token_serializer.rb +62 -0
  39. data/app/serializers/spree/api/v3/admin/oauth_application_serializer.rb +56 -0
  40. data/app/serializers/spree/api/v3/admin/oauth_consent_request_serializer.rb +25 -0
  41. data/app/serializers/spree/api/v3/admin/oauth_protected_resource_serializer.rb +27 -0
  42. data/app/serializers/spree/api/v3/admin/oauth_redirect_serializer.rb +17 -0
  43. data/app/serializers/spree/api/v3/admin/payment_capture_event_serializer.rb +16 -0
  44. data/app/serializers/spree/api/v3/admin/payment_serializer.rb +12 -1
  45. data/app/serializers/spree/api/v3/admin/return_serializer.rb +2 -0
  46. data/app/serializers/spree/api/v3/admin/tax_exemption_certificate_serializer.rb +7 -3
  47. data/app/serializers/spree/api/v3/admin/tax_exemption_reason_serializer.rb +20 -0
  48. data/app/serializers/spree/api/v3/admin/tax_rate_serializer.rb +1 -2
  49. data/app/serializers/spree/api/v3/admin/webhook_delivery_serializer.rb +4 -1
  50. data/app/serializers/spree/api/v3/base_serializer.rb +14 -0
  51. data/config/routes.rb +61 -1
  52. data/lib/spree/api/agent_membership_map.rb +73 -0
  53. data/lib/spree/api/agent_resource_map.rb +350 -0
  54. data/lib/spree/api/agent_workflow_endpoints.rb +113 -0
  55. data/lib/spree/api/agent_write_schemas.rb +119 -0
  56. data/lib/spree/api/dependencies.rb +4 -3
  57. data/lib/spree/api/engine.rb +40 -0
  58. data/lib/spree/api/extension_fields.rb +167 -0
  59. data/lib/spree/api/oauth.rb +243 -0
  60. data/lib/spree/api.rb +8 -0
  61. data/lib/typelizer/open_enums.rb +17 -3
  62. metadata +26 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2bf002c9d8182f8a69971d92c1ba84a2fe26571baefa007570c1a6e30761bd48
4
- data.tar.gz: 78504b7007e37077c04a3b75b33644ad3e127670d7ec221888743bac8f25c9aa
3
+ metadata.gz: 50559a5f0c0305cee4fbf43e8d145078d0f976ffeed8a40be46f19e4d1cca64d
4
+ data.tar.gz: '00688dd0b79f087aeee42271fd8678eacbaca3626115770fa29043cbbe3e3182'
5
5
  SHA512:
6
- metadata.gz: ab37287b927bcbcc8c0e79ecfad9795608390098243660a2bd0b79a0c8b104b72a0f7485b1b67790b5f7b0978ea3af18349b7870b50c85edfdeb1702e1eca87a
7
- data.tar.gz: 2317c9ac3b2cd0aecdcf432d9c83dab6ec44b077b078c960ddda56b9a066a41080841779c2a1bd6838bfb2fe07c71b03d3e6b34b8ad37681df20f61bdd637c98
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 `subject` (checked for JWT
7
- # staff through CanCanCan) paired with a `key_scope` (checked for
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
- if current_api_key.present?
18
- member.key_scope.blank? || current_api_key.has_scope?(member.key_scope)
19
- else
20
- member.subject.nil? || can?(:read, member.subject.call)
21
- end
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
- def resolve_prefixed_ids(hash)
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
- value.map { |v| v.is_a?(Hash) ? resolve_prefixed_ids(v) : v }
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
- def decode_prefixed_id(prefixed_id_string)
87
- Spree::PrefixedId.decode_prefixed_id(prefixed_id_string) || prefixed_id_string
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
- return if scope_check_skipped_for_action? && self.class._scope_check_skip_jwt_only
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 ability.permission_keys.include?(required)
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
- ability.permission_keys.include?(key.to_s)
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
@@ -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 :reason_code, :string, required: true, on: :create,
16
- example: 'resale',
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
- required ? current_api_key.has_scope?("read_#{required}") : current_api_key.has_scope?('read_all')
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
- required ? current_api_key.has_scope?("write_#{required}") : current_api_key.has_scope?('write_all')
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, description: 'Display prices with tax included.'
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: serialize_permission_keys(current_ability)
109
+ permission_keys: staff_permission_keys
90
110
  }
91
111
  end
92
112