docs/plans/5.5-admin-api-key-scopes.md
Status: Shipped in 5.5
Target: Spree 5.5
Depends on: 6.0-admin-api.md
Author: Damian
Last updated: 2026-05-20
Today the Admin API authenticates via either a secret API key or an admin JWT, and uses CanCanCan abilities for authorization on both. CanCanCan resolves abilities from a current_user — it works for JWT-authenticated admin users but is a poor fit for API keys, which represent integrations / apps, not users.
This plan introduces scopes on Spree::ApiKey so secret keys can carry their own permissions independent of any user, mirroring how Shopify access scopes and Saleor app permissions work. JWT admin users continue to use CanCanCan unchanged.
read_<resource> / write_<resource> per top-level admin resource (~15 scopes). write_* implies read_*.read_all and write_all for full read or full read+write. Aliases expand at check time, not at storage time, so admins can re-toggle later without rewriting stored values.Spree::Roles; that surface is unchanged).cancel_orders, approve_orders, etc. — write_orders covers all writes including state transitions and member actions.index/show → read_<resource>, every other action (including custom member actions like cancel/approve) → write_<resource>.scoped_resource :name, not auto-derived from controller name. Lets nested controllers map to a different scope than their parent (e.g., Orders::PaymentsController → payments).class AddScopesToSpreeApiKeys < ActiveRecord::Migration[7.2]
def change
add_column :spree_api_keys, :scopes, :text, default: '[]', null: false
end
end
Cross-DB compatible (text column with JSON-serialized array). Default [] so a key with no explicit scopes can do nothing on the Admin API — fail closed.
class Spree::ApiKey < Spree.base_class
serialize :scopes, type: Array, coder: JSON
validates :scopes, presence: true, if: :secret?
validate :scopes_must_be_known, if: :secret?
KNOWN_SCOPES = %w[
read_orders write_orders
read_products write_products
read_promotions write_promotions
read_customers write_customers
read_payments write_payments
read_fulfillments write_fulfillments
read_refunds write_refunds
read_gift_cards write_gift_cards
read_store_credits write_store_credits
read_stock write_stock
read_categories write_categories
read_settings write_settings
read_webhooks write_webhooks
read_api_keys write_api_keys
read_dashboard
read_all write_all
].freeze
def has_scope?(scope)
return true if scopes.include?(scope)
return true if scope.start_with?('read_') && scopes.include?("write_#{scope.delete_prefix('read_')}")
return true if scopes.include?('write_all')
return true if scope.start_with?('read_') && scopes.include?('read_all')
false
end
private
def scopes_must_be_known
invalid = scopes - KNOWN_SCOPES
errors.add(:scopes, "contains unknown scopes: #{invalid.join(', ')}") if invalid.any?
end
end
| Scope | Endpoints |
|---|---|
read_orders / write_orders | /orders/*, /orders/:id/items (line items roll into orders) |
read_products / write_products | /products/*, /variants/*, /option_types/*, /prices/*, /media/* |
read_customers / write_customers | /customers/*, /customers/:id/addresses, /customers/:id/credit_cards (PII rides along) |
read_payments / write_payments | /orders/:id/payments, /payments/* |
read_fulfillments / write_fulfillments | /orders/:id/fulfillments |
read_refunds / write_refunds | /orders/:id/refunds |
read_gift_cards / write_gift_cards | /orders/:id/gift_cards, future /gift_cards/* |
read_store_credits / write_store_credits | /customers/:id/store_credits, /orders/:id/store_credits |
read_promotions / write_promotions | /promotions/* (rules, actions, coupon codes nested) |
read_stock / write_stock | /stock_locations/*, /stock_items/*, /stock_transfers/*, /stock_reservations/* — the full inventory surface |
read_categories / write_categories | /categories/* |
| (exports — no dedicated scope) | /exports/* is gated by the read scope of the exported resource (Spree::Export.required_scope, derived from the class name with per-subclass overrides). An export is a bulk read; a standalone exports scope would let a key exfiltrate data it couldn't read directly. Index is filtered to readable types |
read_settings / write_settings | /payment_methods, /markets, /countries, /tax_categories, /stores, /channels, /store_credit_categories, staff (/admin_users, /invitations, /roles), /allowed_origins, /custom_field_definitions/* (schema config, consolidated — no dedicated scope) |
read_webhooks / write_webhooks | /webhook_endpoints/* + nested deliveries — split from settings because webhook endpoints exfiltrate event payloads (orders, customers) to arbitrary URLs |
read_api_keys / write_api_keys | /api_keys/* — credential administration, deliberately NOT under settings; pairs with the create-time anti-amplification guard (a key cannot mint scopes beyond its own). Industry norm (Shopify/Stripe/BigCommerce) is to exclude credential management from scopes entirely; the dedicated pair + guard is the grantable middle ground |
read_dashboard | /dashboard/* (analytics; no write counterpart) |
read_all | every read_* scope |
write_all | every read_* and write_* scope (full admin) |
Heuristic for splitting: add a separate scope when a partner integration realistically wants one without the other. "Analytics tool reads orders but not customers" → split. "Order management writes line items" → no split.
Endpoints intentionally not scope-gated:
/auth/* — login/refresh; pre-auth/me — returns the calling user's permissions; no scope needed/tags — read-only enum, low-risk autocomplete/direct_uploads — pre-signed URL helper (already auth-gated; storage is the scope boundary)A ScopedAuthorization concern is included in Spree::Api::V3::Admin::BaseController:
module Spree::Api::V3::ScopedAuthorization
extend ActiveSupport::Concern
class_methods do
def scoped_resource(name)
class_attribute :_scoped_resource, instance_accessor: false
self._scoped_resource = name.to_sym
end
end
included do
before_action :authorize_api_key_scope!
end
private
def authorize_api_key_scope!
return unless @current_api_key # JWT user → CanCanCan handles it
return unless self.class.respond_to?(:_scoped_resource) && self.class._scoped_resource
required = "#{action_kind}_#{self.class._scoped_resource}"
return if @current_api_key.has_scope?(required)
raise CanCan::AccessDenied.new("API key lacks scope: #{required}")
end
def action_kind
%w[index show].include?(action_name) ? 'read' : 'write'
end
end
Each admin controller declares its scope:
class Spree::Api::V3::Admin::OrdersController < ResourceController
scoped_resource :orders
end
For nested controllers under /orders/:id/payments, declare separately:
class Spree::Api::V3::Admin::Orders::PaymentsController < ResourceController
scoped_resource :payments
end
authorize_api_key_scope! runs first. If the API key has the scope, the request proceeds. CanCanCan still runs but its abilities resolve from the API key's created_by user (so per-record rules — store scoping, soft-deleted resources — still apply). When created_by is nil, ability is a no-op (everything allowed) since the scope check has already gated the resource.@current_api_key is nil); CanCanCan runs unchanged.current_ability for API keysdef current_ability
@current_ability ||= Spree::Ability.new(ability_user, ability_options)
end
def ability_user
return current_user if current_user
return @current_api_key&.created_by if @current_api_key&.created_by
nil
end
Spree::Ability.new(nil, store: store) returns a guest ability — which is fine because the scope check has already authorized the action at the resource level. CanCanCan just provides per-record filtering (store scoping etc.).
In the existing API keys admin, when minting a secret key:
security declaration listing the required scope per endpoint (rswag security [api_key: [scopes...]] syntax).403 access_denied_scope_required with the missing scope name in the error message.{
"error": {
"code": "access_denied",
"message": "API key lacks scope: write_orders"
}
}
Status: 403. New error code variant missing_scope (optional) inside details: { required_scope: 'write_orders' } for clients that want to handle it programmatically.
scopes column to spree_api_keys (default []).KNOWN_SCOPES and has_scope? to Spree::ApiKey.ScopedAuthorization concern; include in Admin::BaseController.current_ability resolution to fall back to @current_api_key.created_by.scoped_resource on every admin controller (one line each, ~25 controllers).read_all / write_all aliases work.docs/api-reference/admin-api/authentication.mdx with scope reference.No data migration needed — there are no production admin API keys to migrate.
Work in progress that should account for this plan:
scoped_resource :name after this plan ships. Reviewers should reject admin PRs that don't.cancel_orders, etc.) without revisiting this plan. Resource granularity is the agreed default.security enrichment — don't manually duplicate scope info in descriptions.read_dashboard be split further? Currently lumps all analytics endpoints together. If we add per-team dashboards (revenue, fulfillment, customers), we may want read_revenue_dashboard etc. Defer until there's a real partner request.KNOWN_SCOPES enum would be reused.Spree::EventBus pattern; out of scope for the first cut.read_webhooks / write_webhooks but the webhook admin endpoints don't exist yet. Reserve the name; implement when those endpoints land.6.0-admin-api.md — Admin REST API endpoint inventory