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.
- checksums.yaml +4 -4
- data/Rakefile +36 -0
- data/app/controllers/concerns/spree/api/v3/admin/auth_cookies.rb +62 -0
- data/app/controllers/concerns/spree/api/v3/admin/role_grant_guard.rb +52 -0
- data/app/controllers/concerns/spree/api/v3/admin/subclassed_resource.rb +149 -0
- data/app/controllers/concerns/spree/api/v3/admin_authentication.rb +54 -0
- data/app/controllers/concerns/spree/api/v3/bulk_operations.rb +103 -0
- data/app/controllers/concerns/spree/api/v3/channel_resolution.rb +60 -0
- data/app/controllers/concerns/spree/api/v3/error_handler.rb +4 -0
- data/app/controllers/concerns/spree/api/v3/params_normalizer.rb +84 -0
- data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +104 -0
- data/app/controllers/concerns/spree/api/v3/store/search_provider_support.rb +35 -1
- data/app/controllers/spree/api/v3/admin/admin_users_controller.rb +109 -0
- data/app/controllers/spree/api/v3/admin/allowed_origins_controller.rb +25 -0
- data/app/controllers/spree/api/v3/admin/api_keys_controller.rb +84 -0
- data/app/controllers/spree/api/v3/admin/auth_controller.rb +134 -0
- data/app/controllers/spree/api/v3/admin/base_controller.rb +3 -17
- data/app/controllers/spree/api/v3/admin/categories_controller.rb +25 -0
- data/app/controllers/spree/api/v3/admin/channels_controller.rb +65 -0
- data/app/controllers/spree/api/v3/admin/countries_controller.rb +38 -0
- data/app/controllers/spree/api/v3/admin/coupon_codes_controller.rb +33 -0
- data/app/controllers/spree/api/v3/admin/custom_field_definitions_controller.rb +34 -0
- data/app/controllers/spree/api/v3/admin/custom_fields_controller.rb +129 -0
- data/app/controllers/spree/api/v3/admin/customer_groups_controller.rb +31 -0
- data/app/controllers/spree/api/v3/admin/customers/addresses_controller.rb +83 -0
- data/app/controllers/spree/api/v3/admin/customers/base_controller.rb +33 -0
- data/app/controllers/spree/api/v3/admin/customers/credit_cards_controller.rb +25 -0
- data/app/controllers/spree/api/v3/admin/customers/store_credits_controller.rb +92 -0
- data/app/controllers/spree/api/v3/admin/customers_controller.rb +119 -0
- data/app/controllers/spree/api/v3/admin/dashboard_controller.rb +44 -0
- data/app/controllers/spree/api/v3/admin/direct_uploads_controller.rb +40 -0
- data/app/controllers/spree/api/v3/admin/exports_controller.rb +136 -0
- data/app/controllers/spree/api/v3/admin/gift_card_batches_controller.rb +31 -0
- data/app/controllers/spree/api/v3/admin/gift_cards_controller.rb +33 -0
- data/app/controllers/spree/api/v3/admin/invitation_acceptances_controller.rb +138 -0
- data/app/controllers/spree/api/v3/admin/invitations_controller.rb +81 -0
- data/app/controllers/spree/api/v3/admin/markets_controller.rb +42 -0
- data/app/controllers/spree/api/v3/admin/me_controller.rb +69 -0
- data/app/controllers/spree/api/v3/admin/media_controller.rb +119 -0
- data/app/controllers/spree/api/v3/admin/option_types_controller.rb +34 -0
- data/app/controllers/spree/api/v3/admin/orders/adjustments_controller.rb +27 -0
- data/app/controllers/spree/api/v3/admin/orders/base_controller.rb +31 -0
- data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +104 -0
- data/app/controllers/spree/api/v3/admin/orders/gift_cards_controller.rb +79 -0
- data/app/controllers/spree/api/v3/admin/orders/items_controller.rb +92 -0
- data/app/controllers/spree/api/v3/admin/orders/payments_controller.rb +90 -0
- data/app/controllers/spree/api/v3/admin/orders/refunds_controller.rb +53 -0
- data/app/controllers/spree/api/v3/admin/orders/store_credits_controller.rb +59 -0
- data/app/controllers/spree/api/v3/admin/orders_controller.rb +190 -0
- data/app/controllers/spree/api/v3/admin/payment_methods_controller.rb +73 -0
- data/app/controllers/spree/api/v3/admin/price_lists_controller.rb +179 -0
- data/app/controllers/spree/api/v3/admin/prices_controller.rb +157 -0
- data/app/controllers/spree/api/v3/admin/products/variants_controller.rb +48 -0
- data/app/controllers/spree/api/v3/admin/products_controller.rb +237 -0
- data/app/controllers/spree/api/v3/admin/promotion_actions_controller.rb +78 -0
- data/app/controllers/spree/api/v3/admin/promotion_rules_controller.rb +56 -0
- data/app/controllers/spree/api/v3/admin/promotions_controller.rb +78 -0
- data/app/controllers/spree/api/v3/admin/resource_controller.rb +29 -11
- data/app/controllers/spree/api/v3/admin/roles_controller.rb +29 -0
- data/app/controllers/spree/api/v3/admin/stock_items_controller.rb +35 -0
- data/app/controllers/spree/api/v3/admin/stock_locations_controller.rb +36 -0
- data/app/controllers/spree/api/v3/admin/stock_reservations_controller.rb +29 -0
- data/app/controllers/spree/api/v3/admin/stock_transfers_controller.rb +75 -0
- data/app/controllers/spree/api/v3/admin/store_controller.rb +53 -0
- data/app/controllers/spree/api/v3/admin/store_credit_categories_controller.rb +21 -0
- data/app/controllers/spree/api/v3/admin/tags_controller.rb +51 -0
- data/app/controllers/spree/api/v3/admin/tax_categories_controller.rb +21 -0
- data/app/controllers/spree/api/v3/admin/variants_controller.rb +33 -0
- data/app/controllers/spree/api/v3/admin/webhook_deliveries_controller.rb +49 -0
- data/app/controllers/spree/api/v3/admin/webhook_endpoints_controller.rb +75 -0
- data/app/controllers/spree/api/v3/resource_controller.rb +117 -8
- data/app/controllers/spree/api/v3/store/auth_controller.rb +8 -28
- data/app/controllers/spree/api/v3/store/base_controller.rb +6 -0
- data/app/controllers/spree/api/v3/store/carts_controller.rb +1 -0
- data/app/controllers/spree/api/v3/store/customers_controller.rb +6 -0
- data/app/controllers/spree/api/v3/store/newsletter_subscribers_controller.rb +77 -0
- data/app/controllers/spree/api/v3/store/products/filters_controller.rb +2 -2
- data/app/controllers/spree/api/v3/store/products_controller.rb +4 -3
- data/app/controllers/spree/api/v3/store/resource_controller.rb +10 -2
- data/app/jobs/spree/webhook_delivery_job.rb +5 -0
- data/app/models/spree/api_key_ability.rb +16 -0
- data/app/serializers/spree/api/v3/admin/address_serializer.rb +2 -6
- data/app/serializers/spree/api/v3/admin/adjustment_serializer.rb +3 -15
- data/app/serializers/spree/api/v3/admin/admin_user_serializer.rb +19 -3
- data/app/serializers/spree/api/v3/admin/allowed_origin_serializer.rb +2 -6
- data/app/serializers/spree/api/v3/admin/api_key_serializer.rb +42 -0
- data/app/serializers/spree/api/v3/admin/category_serializer.rb +4 -3
- data/app/serializers/spree/api/v3/admin/channel_serializer.rb +15 -0
- data/app/serializers/spree/api/v3/admin/country_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/coupon_code_serializer.rb +30 -0
- data/app/serializers/spree/api/v3/admin/credit_card_serializer.rb +4 -2
- data/app/serializers/spree/api/v3/admin/custom_field_definition_serializer.rb +21 -0
- data/app/serializers/spree/api/v3/admin/custom_field_serializer.rb +8 -3
- data/app/serializers/spree/api/v3/admin/customer_group_serializer.rb +27 -0
- data/app/serializers/spree/api/v3/admin/customer_serializer.rb +58 -2
- data/app/serializers/spree/api/v3/admin/dashboard_analytics_serializer.rb +143 -0
- data/app/serializers/spree/api/v3/admin/export_serializer.rb +40 -0
- data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +2 -6
- data/app/serializers/spree/api/v3/admin/{asset_serializer.rb → gift_card_batch_serializer.rb} +1 -1
- data/app/serializers/spree/api/v3/admin/gift_card_serializer.rb +39 -4
- data/app/serializers/spree/api/v3/admin/invitation_serializer.rb +64 -0
- data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +4 -16
- data/app/serializers/spree/api/v3/admin/media_serializer.rb +24 -2
- data/app/serializers/spree/api/v3/admin/option_type_serializer.rb +4 -1
- data/app/serializers/spree/api/v3/admin/option_value_serializer.rb +4 -1
- data/app/serializers/spree/api/v3/admin/order_serializer.rb +21 -6
- data/app/serializers/spree/api/v3/admin/payment_method_serializer.rb +11 -2
- data/app/serializers/spree/api/v3/admin/payment_serializer.rb +2 -6
- data/app/serializers/spree/api/v3/admin/payment_source_serializer.rb +4 -1
- data/app/serializers/spree/api/v3/admin/price_list_serializer.rb +51 -0
- data/app/serializers/spree/api/v3/admin/price_rule_serializer.rb +55 -0
- data/app/serializers/spree/api/v3/admin/price_serializer.rb +4 -0
- data/app/serializers/spree/api/v3/admin/product_publication_serializer.rb +11 -0
- data/app/serializers/spree/api/v3/admin/product_serializer.rb +34 -10
- data/app/serializers/spree/api/v3/admin/promotion_action_serializer.rb +71 -0
- data/app/serializers/spree/api/v3/admin/promotion_rule_serializer.rb +85 -0
- data/app/serializers/spree/api/v3/admin/promotion_serializer.rb +41 -0
- data/app/serializers/spree/api/v3/admin/refund_serializer.rb +4 -2
- data/app/serializers/spree/api/v3/admin/role_serializer.rb +17 -0
- data/app/serializers/spree/api/v3/admin/stock_item_serializer.rb +16 -1
- data/app/serializers/spree/api/v3/admin/stock_location_serializer.rb +11 -2
- data/app/serializers/spree/api/v3/admin/stock_reservation_serializer.rb +46 -0
- data/app/serializers/spree/api/v3/admin/stock_transfer_serializer.rb +37 -0
- data/app/serializers/spree/api/v3/admin/store_credit_category_serializer.rb +19 -0
- data/app/serializers/spree/api/v3/admin/store_credit_serializer.rb +11 -5
- data/app/serializers/spree/api/v3/admin/store_serializer.rb +55 -0
- data/app/serializers/spree/api/v3/admin/tax_category_serializer.rb +4 -2
- data/app/serializers/spree/api/v3/admin/variant_serializer.rb +37 -6
- data/app/serializers/spree/api/v3/admin/webhook_delivery_serializer.rb +45 -0
- data/app/serializers/spree/api/v3/admin/webhook_endpoint_serializer.rb +69 -0
- data/app/serializers/spree/api/v3/channel_serializer.rb +14 -0
- data/app/serializers/spree/api/v3/custom_field_serializer.rb +9 -10
- data/app/serializers/spree/api/v3/customer_serializer.rb +5 -0
- data/app/serializers/spree/api/v3/market_serializer.rb +2 -1
- data/app/serializers/spree/api/v3/media_serializer.rb +8 -6
- data/app/serializers/spree/api/v3/order_serializer.rb +6 -1
- data/app/serializers/spree/api/v3/payment_method_serializer.rb +11 -2
- data/app/serializers/spree/api/v3/product_publication_serializer.rb +22 -0
- data/app/serializers/spree/api/v3/product_serializer.rb +6 -1
- data/app/serializers/spree/api/v3/stock_reservation_serializer.rb +10 -0
- data/config/locales/en.yml +2 -0
- data/config/routes.rb +235 -1
- data/lib/spree/api/configuration.rb +2 -2
- data/lib/spree/api/dependencies.rb +25 -1
- data/lib/spree/api/openapi/path_sorter.rb +126 -0
- data/lib/spree/api/openapi/schema_helper.rb +185 -6
- data/lib/spree/api/testing_support/v3/base.rb +28 -0
- metadata +98 -8
- 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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
6
|
+
include Spree::Api::V3::AdminAuthentication
|
|
7
|
+
include Spree::Api::V3::ScopedAuthorization
|
|
8
8
|
|
|
9
|
-
|
|
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
|