spree_api 5.4.3 → 5.5.0.rc2

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 (149) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +36 -0
  3. data/app/controllers/concerns/spree/api/v3/admin/auth_cookies.rb +62 -0
  4. data/app/controllers/concerns/spree/api/v3/admin/role_grant_guard.rb +52 -0
  5. data/app/controllers/concerns/spree/api/v3/admin/subclassed_resource.rb +149 -0
  6. data/app/controllers/concerns/spree/api/v3/admin_authentication.rb +54 -0
  7. data/app/controllers/concerns/spree/api/v3/bulk_operations.rb +103 -0
  8. data/app/controllers/concerns/spree/api/v3/channel_resolution.rb +60 -0
  9. data/app/controllers/concerns/spree/api/v3/error_handler.rb +4 -0
  10. data/app/controllers/concerns/spree/api/v3/params_normalizer.rb +84 -0
  11. data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +104 -0
  12. data/app/controllers/concerns/spree/api/v3/store/search_provider_support.rb +35 -1
  13. data/app/controllers/spree/api/v3/admin/admin_users_controller.rb +109 -0
  14. data/app/controllers/spree/api/v3/admin/allowed_origins_controller.rb +25 -0
  15. data/app/controllers/spree/api/v3/admin/api_keys_controller.rb +84 -0
  16. data/app/controllers/spree/api/v3/admin/auth_controller.rb +134 -0
  17. data/app/controllers/spree/api/v3/admin/base_controller.rb +3 -17
  18. data/app/controllers/spree/api/v3/admin/categories_controller.rb +25 -0
  19. data/app/controllers/spree/api/v3/admin/channels_controller.rb +65 -0
  20. data/app/controllers/spree/api/v3/admin/countries_controller.rb +38 -0
  21. data/app/controllers/spree/api/v3/admin/coupon_codes_controller.rb +33 -0
  22. data/app/controllers/spree/api/v3/admin/custom_field_definitions_controller.rb +34 -0
  23. data/app/controllers/spree/api/v3/admin/custom_fields_controller.rb +129 -0
  24. data/app/controllers/spree/api/v3/admin/customer_groups_controller.rb +31 -0
  25. data/app/controllers/spree/api/v3/admin/customers/addresses_controller.rb +83 -0
  26. data/app/controllers/spree/api/v3/admin/customers/base_controller.rb +33 -0
  27. data/app/controllers/spree/api/v3/admin/customers/credit_cards_controller.rb +25 -0
  28. data/app/controllers/spree/api/v3/admin/customers/store_credits_controller.rb +92 -0
  29. data/app/controllers/spree/api/v3/admin/customers_controller.rb +119 -0
  30. data/app/controllers/spree/api/v3/admin/dashboard_controller.rb +44 -0
  31. data/app/controllers/spree/api/v3/admin/direct_uploads_controller.rb +40 -0
  32. data/app/controllers/spree/api/v3/admin/exports_controller.rb +136 -0
  33. data/app/controllers/spree/api/v3/admin/gift_card_batches_controller.rb +31 -0
  34. data/app/controllers/spree/api/v3/admin/gift_cards_controller.rb +33 -0
  35. data/app/controllers/spree/api/v3/admin/invitation_acceptances_controller.rb +138 -0
  36. data/app/controllers/spree/api/v3/admin/invitations_controller.rb +81 -0
  37. data/app/controllers/spree/api/v3/admin/markets_controller.rb +42 -0
  38. data/app/controllers/spree/api/v3/admin/me_controller.rb +69 -0
  39. data/app/controllers/spree/api/v3/admin/media_controller.rb +119 -0
  40. data/app/controllers/spree/api/v3/admin/option_types_controller.rb +34 -0
  41. data/app/controllers/spree/api/v3/admin/orders/adjustments_controller.rb +27 -0
  42. data/app/controllers/spree/api/v3/admin/orders/base_controller.rb +31 -0
  43. data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +104 -0
  44. data/app/controllers/spree/api/v3/admin/orders/gift_cards_controller.rb +79 -0
  45. data/app/controllers/spree/api/v3/admin/orders/items_controller.rb +92 -0
  46. data/app/controllers/spree/api/v3/admin/orders/payments_controller.rb +90 -0
  47. data/app/controllers/spree/api/v3/admin/orders/refunds_controller.rb +53 -0
  48. data/app/controllers/spree/api/v3/admin/orders/store_credits_controller.rb +59 -0
  49. data/app/controllers/spree/api/v3/admin/orders_controller.rb +190 -0
  50. data/app/controllers/spree/api/v3/admin/payment_methods_controller.rb +73 -0
  51. data/app/controllers/spree/api/v3/admin/price_lists_controller.rb +179 -0
  52. data/app/controllers/spree/api/v3/admin/prices_controller.rb +157 -0
  53. data/app/controllers/spree/api/v3/admin/products/variants_controller.rb +48 -0
  54. data/app/controllers/spree/api/v3/admin/products_controller.rb +237 -0
  55. data/app/controllers/spree/api/v3/admin/promotion_actions_controller.rb +78 -0
  56. data/app/controllers/spree/api/v3/admin/promotion_rules_controller.rb +56 -0
  57. data/app/controllers/spree/api/v3/admin/promotions_controller.rb +78 -0
  58. data/app/controllers/spree/api/v3/admin/resource_controller.rb +29 -11
  59. data/app/controllers/spree/api/v3/admin/roles_controller.rb +29 -0
  60. data/app/controllers/spree/api/v3/admin/stock_items_controller.rb +35 -0
  61. data/app/controllers/spree/api/v3/admin/stock_locations_controller.rb +36 -0
  62. data/app/controllers/spree/api/v3/admin/stock_reservations_controller.rb +29 -0
  63. data/app/controllers/spree/api/v3/admin/stock_transfers_controller.rb +75 -0
  64. data/app/controllers/spree/api/v3/admin/store_controller.rb +53 -0
  65. data/app/controllers/spree/api/v3/admin/store_credit_categories_controller.rb +21 -0
  66. data/app/controllers/spree/api/v3/admin/tags_controller.rb +51 -0
  67. data/app/controllers/spree/api/v3/admin/tax_categories_controller.rb +21 -0
  68. data/app/controllers/spree/api/v3/admin/variants_controller.rb +33 -0
  69. data/app/controllers/spree/api/v3/admin/webhook_deliveries_controller.rb +49 -0
  70. data/app/controllers/spree/api/v3/admin/webhook_endpoints_controller.rb +75 -0
  71. data/app/controllers/spree/api/v3/resource_controller.rb +117 -8
  72. data/app/controllers/spree/api/v3/store/auth_controller.rb +8 -28
  73. data/app/controllers/spree/api/v3/store/base_controller.rb +6 -0
  74. data/app/controllers/spree/api/v3/store/carts_controller.rb +1 -0
  75. data/app/controllers/spree/api/v3/store/customers_controller.rb +6 -0
  76. data/app/controllers/spree/api/v3/store/newsletter_subscribers_controller.rb +77 -0
  77. data/app/controllers/spree/api/v3/store/products/filters_controller.rb +2 -2
  78. data/app/controllers/spree/api/v3/store/products_controller.rb +4 -3
  79. data/app/controllers/spree/api/v3/store/resource_controller.rb +10 -2
  80. data/app/jobs/spree/webhook_delivery_job.rb +5 -0
  81. data/app/models/spree/api_key_ability.rb +16 -0
  82. data/app/serializers/spree/api/v3/admin/address_serializer.rb +2 -6
  83. data/app/serializers/spree/api/v3/admin/adjustment_serializer.rb +3 -15
  84. data/app/serializers/spree/api/v3/admin/admin_user_serializer.rb +19 -3
  85. data/app/serializers/spree/api/v3/admin/allowed_origin_serializer.rb +2 -6
  86. data/app/serializers/spree/api/v3/admin/api_key_serializer.rb +42 -0
  87. data/app/serializers/spree/api/v3/admin/category_serializer.rb +4 -3
  88. data/app/serializers/spree/api/v3/admin/channel_serializer.rb +15 -0
  89. data/app/serializers/spree/api/v3/admin/country_serializer.rb +1 -1
  90. data/app/serializers/spree/api/v3/admin/coupon_code_serializer.rb +30 -0
  91. data/app/serializers/spree/api/v3/admin/credit_card_serializer.rb +4 -2
  92. data/app/serializers/spree/api/v3/admin/custom_field_definition_serializer.rb +21 -0
  93. data/app/serializers/spree/api/v3/admin/custom_field_serializer.rb +8 -3
  94. data/app/serializers/spree/api/v3/admin/customer_group_serializer.rb +27 -0
  95. data/app/serializers/spree/api/v3/admin/customer_serializer.rb +58 -2
  96. data/app/serializers/spree/api/v3/admin/dashboard_analytics_serializer.rb +143 -0
  97. data/app/serializers/spree/api/v3/admin/export_serializer.rb +40 -0
  98. data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +2 -6
  99. data/app/serializers/spree/api/v3/admin/{asset_serializer.rb → gift_card_batch_serializer.rb} +1 -1
  100. data/app/serializers/spree/api/v3/admin/gift_card_serializer.rb +39 -4
  101. data/app/serializers/spree/api/v3/admin/invitation_serializer.rb +64 -0
  102. data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +4 -16
  103. data/app/serializers/spree/api/v3/admin/media_serializer.rb +24 -2
  104. data/app/serializers/spree/api/v3/admin/option_type_serializer.rb +4 -1
  105. data/app/serializers/spree/api/v3/admin/option_value_serializer.rb +4 -1
  106. data/app/serializers/spree/api/v3/admin/order_serializer.rb +21 -6
  107. data/app/serializers/spree/api/v3/admin/payment_method_serializer.rb +11 -2
  108. data/app/serializers/spree/api/v3/admin/payment_serializer.rb +2 -6
  109. data/app/serializers/spree/api/v3/admin/payment_source_serializer.rb +4 -1
  110. data/app/serializers/spree/api/v3/admin/price_list_serializer.rb +51 -0
  111. data/app/serializers/spree/api/v3/admin/price_rule_serializer.rb +55 -0
  112. data/app/serializers/spree/api/v3/admin/price_serializer.rb +4 -0
  113. data/app/serializers/spree/api/v3/admin/product_publication_serializer.rb +11 -0
  114. data/app/serializers/spree/api/v3/admin/product_serializer.rb +34 -10
  115. data/app/serializers/spree/api/v3/admin/promotion_action_serializer.rb +71 -0
  116. data/app/serializers/spree/api/v3/admin/promotion_rule_serializer.rb +85 -0
  117. data/app/serializers/spree/api/v3/admin/promotion_serializer.rb +41 -0
  118. data/app/serializers/spree/api/v3/admin/refund_serializer.rb +4 -2
  119. data/app/serializers/spree/api/v3/admin/role_serializer.rb +17 -0
  120. data/app/serializers/spree/api/v3/admin/stock_item_serializer.rb +16 -1
  121. data/app/serializers/spree/api/v3/admin/stock_location_serializer.rb +11 -2
  122. data/app/serializers/spree/api/v3/admin/stock_reservation_serializer.rb +46 -0
  123. data/app/serializers/spree/api/v3/admin/stock_transfer_serializer.rb +37 -0
  124. data/app/serializers/spree/api/v3/admin/store_credit_category_serializer.rb +19 -0
  125. data/app/serializers/spree/api/v3/admin/store_credit_serializer.rb +11 -5
  126. data/app/serializers/spree/api/v3/admin/store_serializer.rb +55 -0
  127. data/app/serializers/spree/api/v3/admin/tax_category_serializer.rb +4 -2
  128. data/app/serializers/spree/api/v3/admin/variant_serializer.rb +37 -6
  129. data/app/serializers/spree/api/v3/admin/webhook_delivery_serializer.rb +45 -0
  130. data/app/serializers/spree/api/v3/admin/webhook_endpoint_serializer.rb +69 -0
  131. data/app/serializers/spree/api/v3/channel_serializer.rb +14 -0
  132. data/app/serializers/spree/api/v3/custom_field_serializer.rb +9 -10
  133. data/app/serializers/spree/api/v3/customer_serializer.rb +5 -0
  134. data/app/serializers/spree/api/v3/market_serializer.rb +2 -1
  135. data/app/serializers/spree/api/v3/media_serializer.rb +8 -6
  136. data/app/serializers/spree/api/v3/order_serializer.rb +6 -1
  137. data/app/serializers/spree/api/v3/payment_method_serializer.rb +11 -2
  138. data/app/serializers/spree/api/v3/product_publication_serializer.rb +22 -0
  139. data/app/serializers/spree/api/v3/product_serializer.rb +6 -1
  140. data/app/serializers/spree/api/v3/stock_reservation_serializer.rb +10 -0
  141. data/config/locales/en.yml +2 -0
  142. data/config/routes.rb +235 -1
  143. data/lib/spree/api/configuration.rb +2 -2
  144. data/lib/spree/api/dependencies.rb +25 -1
  145. data/lib/spree/api/openapi/path_sorter.rb +126 -0
  146. data/lib/spree/api/openapi/schema_helper.rb +185 -6
  147. data/lib/spree/api/testing_support/v3/base.rb +28 -0
  148. metadata +98 -8
  149. data/app/serializers/spree/api/v3/admin/shipping_category_serializer.rb +0 -14
@@ -0,0 +1,104 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ # Per-resource scope check for Admin API requests authenticated via API key.
5
+ # JWT-authenticated admin users bypass this and rely on CanCanCan abilities.
6
+ #
7
+ # Controllers declare their scope:
8
+ #
9
+ # class Spree::Api::V3::Admin::OrdersController < ResourceController
10
+ # scoped_resource :orders
11
+ # end
12
+ #
13
+ # The before_action maps the action to a `read_*` (index/show) or `write_*`
14
+ # (everything else, including custom member actions) scope and verifies the
15
+ # API key carries it.
16
+ #
17
+ # See docs/plans/5.5-admin-api-key-scopes.md.
18
+ module ScopedAuthorization
19
+ extend ActiveSupport::Concern
20
+
21
+ READ_ACTIONS = %w[index show].freeze
22
+
23
+ class MissingScopedResource < StandardError
24
+ def initialize(controller_class)
25
+ super("#{controller_class} must declare `scoped_resource :name` " \
26
+ '(or `skip_scope_check!` for endpoints exempt from scope checks).')
27
+ end
28
+ end
29
+
30
+ class_methods do
31
+ def scoped_resource(name)
32
+ self._scoped_resource = name.to_sym
33
+ self._scope_check_skipped = false
34
+ end
35
+
36
+ # Opt out of scope checks — for the whole controller (auth, me,
37
+ # tags, etc.) or for specific actions (`only: :index`) when an
38
+ # action authorizes another way, e.g. by filtering its collection
39
+ # per-type (exports).
40
+ def skip_scope_check!(only: nil)
41
+ if only
42
+ self._scope_check_skipped_actions = Array(only).map(&:to_s)
43
+ else
44
+ self._scope_check_skipped = true
45
+ end
46
+ end
47
+ end
48
+
49
+ included do
50
+ class_attribute :_scoped_resource, instance_accessor: false
51
+ class_attribute :_scope_check_skipped, instance_accessor: false, default: false
52
+ class_attribute :_scope_check_skipped_actions, instance_accessor: false
53
+ before_action :authorize_api_key_scope!
54
+ end
55
+
56
+ private
57
+
58
+ def authorize_api_key_scope!
59
+ return unless current_api_key
60
+ return if self.class._scope_check_skipped
61
+ return if self.class._scope_check_skipped_actions&.include?(action_name)
62
+
63
+ resource = scoped_resource_name
64
+ # Fail closed: a controller authenticated by API key MUST declare
65
+ # either `scoped_resource :name`, override `scoped_resource_name`,
66
+ # or `skip_scope_check!`.
67
+ raise MissingScopedResource, self.class unless resource
68
+
69
+ required = "#{action_kind}_#{resource}"
70
+ return if current_api_key.has_scope?(required)
71
+
72
+ render_error(
73
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
74
+ message: "API key lacks scope: #{required}",
75
+ status: :forbidden,
76
+ details: { required_scope: required }
77
+ )
78
+ end
79
+
80
+ # The resource name used in scope strings (`read_<name>` / `write_<name>`).
81
+ # Defaults to the class-level `scoped_resource :name` declaration.
82
+ # Override in controllers that resolve scope at request time (e.g. the
83
+ # nested-on-many-parents `CustomFieldsController` returns the parent's
84
+ # route segment).
85
+ def scoped_resource_name
86
+ self.class._scoped_resource
87
+ end
88
+
89
+ # Override in controllers with non-REST custom actions (e.g. dashboard
90
+ # `analytics` should map to a read).
91
+ def action_kind
92
+ READ_ACTIONS.include?(action_name) ? 'read' : 'write'
93
+ end
94
+
95
+ # True when authorization derives from the API key's scopes rather
96
+ # than a JWT admin's CanCanCan ability. Mirrors the credential
97
+ # precedence in AdminAuthentication#current_ability (JWT user wins).
98
+ def scope_limited_principal?
99
+ current_api_key.present? && current_user.blank?
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -5,6 +5,16 @@ module Spree
5
5
  module SearchProviderSupport
6
6
  extend ActiveSupport::Concern
7
7
 
8
+ # Matches `*_id_in`/`id_eq`/etc. and the bare `id_in`/`id_eq` on
9
+ # the resource's primary key. Mirrors the regex on
10
+ # `ResourceController` — duplicated here because `FiltersController`
11
+ # extends `Store::BaseController`, not `ResourceController`, and
12
+ # would otherwise NoMethodError on `decode_prefixed_id_predicates`.
13
+ # Requires a Ransack-predicate suffix so we don't match scope
14
+ # names like `with_option_value_ids` (which handle their own
15
+ # prefix decoding).
16
+ RANSACK_ID_PREDICATE_RE = /(?:\A|_)id(?:s)?_(?:eq|not_eq|in|not_in|lt|lteq|gt|gteq)\z/.freeze
17
+
8
18
  private
9
19
 
10
20
  def search_query
@@ -14,12 +24,36 @@ module Spree
14
24
  def search_filters
15
25
  q = params[:q]&.to_unsafe_h || params[:q] || {}
16
26
  q = q.to_h if q.respond_to?(:to_h) && !q.is_a?(Hash)
17
- q.except('search').presence
27
+ # Decode Stripe-style prefixed IDs in `*_id_in`/`id_eq`/etc. so
28
+ # SPA + storefront filters can pass `prod_…` keys; the search
29
+ # provider hands the filter hash straight to Ransack on the
30
+ # underlying scope, which expects raw integer IDs.
31
+ decode_prefixed_id_predicates(q.except('search')).presence
18
32
  end
19
33
 
20
34
  def search_provider
21
35
  @search_provider ||= Spree.search_provider.constantize.new(current_store)
22
36
  end
37
+
38
+ def decode_prefixed_id_predicates(hash)
39
+ return hash unless hash.is_a?(Hash)
40
+
41
+ hash.each_with_object({}) do |(key, value), result|
42
+ result[key] = if ransack_id_predicate?(key)
43
+ Array(value).map { |v| Spree::PrefixedId.prefixed_id?(v) ? Spree::PrefixedId.decode_prefixed_id(v) || v : v }.then { |arr|
44
+ value.is_a?(Array) ? arr : arr.first
45
+ }
46
+ elsif value.is_a?(Hash)
47
+ decode_prefixed_id_predicates(value)
48
+ else
49
+ value
50
+ end
51
+ end
52
+ end
53
+
54
+ def ransack_id_predicate?(key)
55
+ RANSACK_ID_PREDICATE_RE.match?(key.to_s)
56
+ end
23
57
  end
24
58
  end
25
59
  end
@@ -0,0 +1,109 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ # Manages staff for the current store. "Staff" = admin users with at
6
+ # least one `Spree::RoleUser` whose `resource` is the current store.
7
+ # The legacy controller hard-deletes the global account on destroy;
8
+ # this v3 endpoint instead removes the per-store `RoleUser` rows so
9
+ # the user keeps their account (and access to other stores).
10
+ class AdminUsersController < ResourceController
11
+ include Spree::Api::V3::Admin::RoleGrantGuard
12
+
13
+ scoped_resource :settings
14
+
15
+ # POST is not exposed — staff are created via invitations.
16
+ def create
17
+ head :method_not_allowed
18
+ end
19
+
20
+ # DELETE /api/v3/admin/admin_users/:id
21
+ # Removes role assignments for the current store rather than deleting
22
+ # the account globally. The user keeps access to any other stores.
23
+ def destroy
24
+ authorize!(:destroy, @resource)
25
+ @resource.role_users.where(resource: current_store).destroy_all
26
+ head :no_content
27
+ end
28
+
29
+ # PATCH allows updating identity fields and replacing the user's
30
+ # roles for this store. `role_ids` accepts prefixed IDs and is
31
+ # applied via `add_role`/`remove_role` so the change is scoped to
32
+ # `current_store` and never touches other-store assignments.
33
+ def update
34
+ authorize!(:update, @resource)
35
+
36
+ # `nil` when the key is absent (leave roles untouched); an array
37
+ # (possibly empty, to clear) when the client sends `role_ids`.
38
+ role_ids = role_ids_param if params.key?(:role_ids)
39
+ return if role_ids && reject_unauthorized_role_grant!(role_ids)
40
+
41
+ if @resource.update(identity_params)
42
+ apply_role_ids(role_ids) if role_ids
43
+ render json: serialize_resource(@resource)
44
+ else
45
+ render_validation_error(@resource.errors)
46
+ end
47
+ end
48
+
49
+ protected
50
+
51
+ def model_class
52
+ Spree.admin_user_class
53
+ end
54
+
55
+ def serializer_class
56
+ Spree.api.admin_admin_user_serializer
57
+ end
58
+
59
+ def collection_includes
60
+ [{ role_users: :role }]
61
+ end
62
+
63
+ # Restrict to users with a role assignment on the current store.
64
+ # `accessible_by` enforces CanCanCan on top. Deduplication happens via
65
+ # an ID subquery rather than `SELECT DISTINCT *` — the users table is
66
+ # host-app-defined and may contain `json` columns, which Postgres
67
+ # cannot compare for equality.
68
+ def scope
69
+ staff_ids = model_class.
70
+ joins(:role_users).
71
+ where(Spree::RoleUser.table_name => { resource: current_store }).
72
+ select(:id)
73
+
74
+ model_class.
75
+ where(id: staff_ids).
76
+ accessible_by(current_ability, :show)
77
+ end
78
+
79
+ private
80
+
81
+ def identity_params
82
+ params.permit(:first_name, :last_name)
83
+ end
84
+
85
+ def role_ids_param
86
+ ids = Array(params[:role_ids])
87
+ ids.map { |id| Spree::PrefixedId.prefixed_id?(id) ? Spree::PrefixedId.decode_prefixed_id(id) : id }.compact
88
+ end
89
+
90
+ # Reconcile the user's roles on this store to match `desired_role_ids`.
91
+ # Adds missing assignments and removes extras — no-op for unchanged.
92
+ def apply_role_ids(desired_role_ids)
93
+ current = @resource.role_users.where(resource: current_store).pluck(:role_id).map(&:to_s)
94
+ target = desired_role_ids.map(&:to_s)
95
+
96
+ (target - current).each do |role_id|
97
+ role = Spree::Role.find_by(id: role_id)
98
+ @resource.role_users.find_or_create_by!(role: role, resource: current_store) if role
99
+ end
100
+
101
+ (current - target).each do |role_id|
102
+ @resource.role_users.where(role_id: role_id, resource: current_store).destroy_all
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,25 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ class AllowedOriginsController < ResourceController
6
+ scoped_resource :settings
7
+
8
+ protected
9
+
10
+ def model_class
11
+ Spree::AllowedOrigin
12
+ end
13
+
14
+ def serializer_class
15
+ Spree.api.admin_allowed_origin_serializer
16
+ end
17
+
18
+ def permitted_params
19
+ params.permit(:origin)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,84 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ class ApiKeysController < ResourceController
6
+ # Dedicated scope — key management is credential-administration, not
7
+ # store configuration. A `write_settings` key must not be able to
8
+ # revoke or destroy higher-privileged keys.
9
+ scoped_resource :api_keys
10
+
11
+ # POST /api/v3/admin/api_keys
12
+ # Prevents scope amplification: a key minted via a secret API key can
13
+ # only carry scopes that key already holds. A JWT admin is governed by
14
+ # CanCanCan (not scopes) and may grant any valid scope — so when a JWT
15
+ # user authenticated the request, `current_ability` ignores the API key
16
+ # (see AdminAuthentication#current_ability) and we skip the scope cap
17
+ # too, even if an `X-Spree-Api-Key` header was also sent.
18
+ def create
19
+ if scope_limited_principal? && (excess = requested_scopes.reject { |s| current_api_key.has_scope?(s) }).any?
20
+ return render_error(
21
+ code: ERROR_CODES[:access_denied],
22
+ message: "Cannot grant scopes beyond your own: #{excess.join(', ')}",
23
+ status: :forbidden,
24
+ details: { excess_scopes: excess }
25
+ )
26
+ end
27
+
28
+ super
29
+ end
30
+
31
+ # PATCH /api/v3/admin/api_keys/:id/revoke
32
+ # Marks the key revoked rather than deleting it — the row stays so
33
+ # audit logs and `created_by`/`revoked_by` remain queryable. Hard
34
+ # deletion is available via `destroy` for cleanup.
35
+ def revoke
36
+ @resource = find_resource
37
+ authorize!(:update, @resource)
38
+
39
+ @resource.revoke!(try_spree_current_user)
40
+ render json: serialize_resource(@resource)
41
+ end
42
+
43
+ protected
44
+
45
+ def model_class
46
+ Spree::ApiKey
47
+ end
48
+
49
+ def serializer_class
50
+ Spree.api.admin_api_key_serializer
51
+ end
52
+
53
+ def scope
54
+ current_store.api_keys.accessible_by(current_ability, :show)
55
+ end
56
+
57
+ # Stamp the creating user; key generation (token, prefix, digest)
58
+ # happens in `before_validation :generate_token` on the model.
59
+ def build_resource
60
+ scope.new(permitted_params).tap do |key|
61
+ key.created_by = try_spree_current_user
62
+ end
63
+ end
64
+
65
+ # `key_type` is set on create only — flipping a publishable key to a
66
+ # secret one (or vice versa) would invalidate every consumer of the
67
+ # plaintext token. We strip it on update so that's impossible to do
68
+ # by accident through the API.
69
+ def permitted_params
70
+ attrs = params.permit(:name, :key_type, scopes: [])
71
+ attrs.delete(:key_type) if action_name == 'update'
72
+ attrs
73
+ end
74
+
75
+ private
76
+
77
+ def requested_scopes
78
+ Array(params[:scopes]).map(&:to_s).reject(&:blank?)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,134 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ class AuthController < Admin::BaseController
6
+ include Spree::Api::V3::Admin::AuthCookies
7
+
8
+ skip_scope_check!
9
+
10
+ rate_limit to: Spree::Api::Config[:rate_limit_login], within: Spree::Api::Config[:rate_limit_window].seconds, store: Rails.cache, only: :create, with: RATE_LIMIT_RESPONSE
11
+ rate_limit to: Spree::Api::Config[:rate_limit_refresh], within: Spree::Api::Config[:rate_limit_window].seconds, store: Rails.cache, only: :refresh, with: RATE_LIMIT_RESPONSE
12
+
13
+ skip_before_action :authenticate_admin!, only: [:create, :refresh, :logout]
14
+
15
+ # POST /api/v3/admin/auth/login
16
+ def create
17
+ strategy = authentication_strategy
18
+ return unless strategy
19
+
20
+ result = strategy.authenticate
21
+
22
+ if result.success?
23
+ user = result.value
24
+ refresh_token = Spree::RefreshToken.create_for(user, request_env: request_env_for_token)
25
+ set_refresh_cookie(refresh_token)
26
+ render json: auth_response(user)
27
+ else
28
+ render_error(
29
+ code: ERROR_CODES[:authentication_failed],
30
+ message: result.error,
31
+ status: :unauthorized
32
+ )
33
+ end
34
+ end
35
+
36
+ # POST /api/v3/admin/auth/refresh
37
+ def refresh
38
+ refresh_token_value = refresh_token_from_cookie
39
+
40
+ if refresh_token_value.blank?
41
+ return render_error(
42
+ code: ERROR_CODES[:invalid_refresh_token],
43
+ message: 'Refresh token cookie missing',
44
+ status: :unauthorized
45
+ )
46
+ end
47
+
48
+ refresh_token = Spree::RefreshToken.active.find_by(token: refresh_token_value)
49
+
50
+ if refresh_token.nil?
51
+ clear_refresh_cookie
52
+ return render_error(
53
+ code: ERROR_CODES[:invalid_refresh_token],
54
+ message: 'Invalid or expired refresh token',
55
+ status: :unauthorized
56
+ )
57
+ end
58
+
59
+ user = refresh_token.user
60
+ new_refresh_token = refresh_token.rotate!(request_env: request_env_for_token)
61
+ set_refresh_cookie(new_refresh_token)
62
+
63
+ render json: auth_response(user)
64
+ end
65
+
66
+ # POST /api/v3/admin/auth/logout
67
+ def logout
68
+ refresh_token_value = refresh_token_from_cookie
69
+ Spree::RefreshToken.active.find_by(token: refresh_token_value)&.destroy if refresh_token_value.present?
70
+
71
+ clear_refresh_cookie
72
+ head :no_content
73
+ end
74
+
75
+ private
76
+
77
+ def authentication_strategy
78
+ provider = params[:provider].presence || 'email'
79
+ strategy_class = Spree.admin_authentication_strategies[provider]
80
+
81
+ unless strategy_class
82
+ render_error(
83
+ code: ERROR_CODES[:invalid_provider],
84
+ message: "Unsupported authentication provider: #{provider}",
85
+ status: :bad_request
86
+ )
87
+ return nil
88
+ end
89
+
90
+ strategy_class.new(
91
+ params: params,
92
+ request_env: request.headers.env,
93
+ user_class: Spree.admin_user_class
94
+ )
95
+ end
96
+
97
+ def serializer_params
98
+ {
99
+ store: current_store,
100
+ locale: current_locale,
101
+ currency: current_currency,
102
+ user: current_user,
103
+ includes: []
104
+ }
105
+ end
106
+
107
+ def auth_response(user)
108
+ {
109
+ token: generate_jwt(user, audience: JWT_AUDIENCE_ADMIN),
110
+ user: admin_user_serializer.new(user, params: serializer_params).to_h
111
+ }
112
+ end
113
+
114
+ def request_env_for_token
115
+ {
116
+ ip_address: request.remote_ip,
117
+ user_agent: request.user_agent&.truncate(255)
118
+ }
119
+ end
120
+
121
+ def admin_user_serializer
122
+ Spree.api.admin_admin_user_serializer
123
+ end
124
+
125
+ # Admin tokens have higher blast radius than customer tokens, so they get a
126
+ # shorter TTL (5 min by default) — overrides the storefront default (1h).
127
+ def jwt_expiration
128
+ Spree::Api::Config[:admin_jwt_expiration]
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -3,24 +3,10 @@ module Spree
3
3
  module V3
4
4
  module Admin
5
5
  class BaseController < Spree::Api::V3::BaseController
6
- # Require secret API key for all Admin API requests
7
- before_action :authenticate_secret_key!
6
+ include Spree::Api::V3::AdminAuthentication
7
+ include Spree::Api::V3::ScopedAuthorization
8
8
 
9
- # Admin API responses must never be cached
10
- after_action :set_no_store_cache
11
-
12
- protected
13
-
14
- # Override JWT audience to require admin tokens
15
- def expected_audience
16
- JWT_AUDIENCE_ADMIN
17
- end
18
-
19
- private
20
-
21
- def set_no_store_cache
22
- response.headers['Cache-Control'] = 'private, no-store'
23
- end
9
+ before_action :authenticate_admin!
24
10
  end
25
11
  end
26
12
  end
@@ -0,0 +1,25 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ class CategoriesController < ResourceController
6
+ scoped_resource :categories
7
+
8
+ protected
9
+
10
+ def model_class
11
+ Spree::Category
12
+ end
13
+
14
+ def serializer_class
15
+ Spree.api.admin_category_serializer
16
+ end
17
+
18
+ def scope
19
+ super.where(taxonomy: current_store.taxonomies)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,65 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ class ChannelsController < ResourceController
6
+ scoped_resource :settings
7
+
8
+ # POST /api/v3/admin/channels/:id/add_products
9
+ # Body: { product_ids: [...], published_at: nil, unpublished_at: nil }
10
+ def add_products
11
+ channel = find_resource
12
+ authorize! :update, channel
13
+
14
+ count = channel.add_products(
15
+ scoped_product_ids,
16
+ published_at: params[:published_at].presence,
17
+ unpublished_at: params[:unpublished_at].presence
18
+ )
19
+ render json: { product_count: count }
20
+ end
21
+
22
+ # POST /api/v3/admin/channels/:id/remove_products
23
+ # Body: { product_ids: [...] }
24
+ def remove_products
25
+ channel = find_resource
26
+ authorize! :update, channel
27
+
28
+ removed = channel.remove_products(scoped_product_ids)
29
+ render json: { product_count: removed }
30
+ end
31
+
32
+ protected
33
+
34
+ def model_class
35
+ Spree::Channel
36
+ end
37
+
38
+ def serializer_class
39
+ Spree.api.admin_channel_serializer
40
+ end
41
+
42
+ def scope
43
+ super.for_store(current_store)
44
+ end
45
+
46
+ def permitted_params
47
+ params.permit(:name, :code, :active, :default, :preferred_order_routing_strategy)
48
+ end
49
+
50
+ private
51
+
52
+ # Deliberately not scoped through `for_store` — this endpoint is the
53
+ # path by which a product *enters* the store, so requiring an
54
+ # existing publication would block first-time onboarding. The
55
+ # cross-store guard runs one level up: `find_resource` already
56
+ # restricted the channel to `current_store`.
57
+ def scoped_product_ids
58
+ ids = decode_prefixed_ids(params[:product_ids])
59
+ Spree::Product.accessible_by(current_ability, :update).where(id: ids).ids
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,38 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ class CountriesController < ResourceController
6
+ scoped_resource :settings
7
+
8
+ # Override base index to skip pagination — there are ~250 countries
9
+ # and address-form dropdowns need them all at once. Pagy's global
10
+ # max_limit (100) prevents using the paginated path for this.
11
+ def index
12
+ authorize!(:read, model_class)
13
+ @collection = scope
14
+ render json: { data: serialize_collection(@collection), meta: { count: @collection.size } }
15
+ end
16
+
17
+ protected
18
+
19
+ def model_class
20
+ Spree::Country
21
+ end
22
+
23
+ def serializer_class
24
+ Spree.api.admin_country_serializer
25
+ end
26
+
27
+ def scope
28
+ Spree::Country.all.order(:name)
29
+ end
30
+
31
+ def find_resource
32
+ scope.find_by!(iso: params[:id].upcase)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end