Back to Spree

Admin API Key Scopes

docs/plans/5.5-admin-api-key-scopes.md

5.5.012.3 KB
Original Source

Admin API Key Scopes

Status: Shipped in 5.5 Target: Spree 5.5 Depends on: 6.0-admin-api.md Author: Damian Last updated: 2026-05-20

Summary

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.

Key Decisions (do not deviate without discussion)

  • Scope vocabulary is read_<resource> / write_<resource> per top-level admin resource (~15 scopes). write_* implies read_*.
  • Convenience aliases 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.
  • Scopes apply to API keys only. JWT admin users keep CanCanCan-based authorization (they have Spree::Roles; that surface is unchanged).
  • No backward compatibility for legacy admin keys — Admin API hasn't shipped to production yet; all secret keys created from this point require explicit scopes.
  • Resource granularity, not action granularity. No separate cancel_orders, approve_orders, etc. — write_orders covers all writes including state transitions and member actions.
  • Action mapping: index/showread_<resource>, every other action (including custom member actions like cancel/approve) → write_<resource>.
  • Scope is declared per controller via scoped_resource :name, not auto-derived from controller name. Lets nested controllers map to a different scope than their parent (e.g., Orders::PaymentsControllerpayments).

Design Details

Storage

ruby
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.

ruby
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 vocabulary

ScopeEndpoints
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_allevery read_* scope
write_allevery 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)

Enforcement

A ScopedAuthorization concern is included in Spree::Api::V3::Admin::BaseController:

ruby
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:

ruby
class Spree::Api::V3::Admin::OrdersController < ResourceController
  scoped_resource :orders
end

For nested controllers under /orders/:id/payments, declare separately:

ruby
class Spree::Api::V3::Admin::Orders::PaymentsController < ResourceController
  scoped_resource :payments
end

Interaction with CanCanCan

  • API key requests: 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.
  • JWT user requests: scope check is a no-op (@current_api_key is nil); CanCanCan runs unchanged.

current_ability for API keys

ruby
def 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.).

UI: API key creation

In the existing API keys admin, when minting a secret key:

  • Show a checkbox tree of resources, with read/write columns
  • "Select all read" / "Select all write" / "Full admin (write_all)" quick presets
  • Display scope descriptions inline ("Read orders — view orders, line items, status")
  • Validate at least one scope is selected before save
  • Stored as the explicit array, not aliases

SDK and OpenAPI documentation

  • OpenAPI spec gains a security declaration listing the required scope per endpoint (rswag security [api_key: [scopes...]] syntax).
  • SDK doesn't need to know about scopes — the server enforces them and returns 403 access_denied_scope_required with the missing scope name in the error message.

Error response

json
{
  "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.

Migration Path

  1. Add scopes column to spree_api_keys (default []).
  2. Add KNOWN_SCOPES and has_scope? to Spree::ApiKey.
  3. Add ScopedAuthorization concern; include in Admin::BaseController.
  4. Update current_ability resolution to fall back to @current_api_key.created_by.
  5. Declare scoped_resource on every admin controller (one line each, ~25 controllers).
  6. Update API key admin UI to expose scope checkboxes.
  7. Add a controller spec for each admin controller covering: (a) request with required scope succeeds, (b) request without required scope returns 403, (c) read_all / write_all aliases work.
  8. Update docs/api-reference/admin-api/authentication.mdx with scope reference.

No data migration needed — there are no production admin API keys to migrate.

Constraints on Current Work

Work in progress that should account for this plan:

  • Any new admin controller must call scoped_resource :name after this plan ships. Reviewers should reject admin PRs that don't.
  • Don't add per-action scopes (cancel_orders, etc.) without revisiting this plan. Resource granularity is the agreed default.
  • Don't add scope checks to JWT-authenticated paths. Scopes are an API-key concept only.
  • OpenAPI specs added before this ships should expect a future security enrichment — don't manually duplicate scope info in descriptions.

Open Questions

  • Should 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.
  • Scopes in OAuth flows? Not in scope for 5.5. If we build a public app marketplace later, OAuth-issued tokens would carry scopes the same way; the KNOWN_SCOPES enum would be reused.
  • Audit log of scope-denied requests? Useful for app developers debugging permission errors. Could ride on the existing Spree::EventBus pattern; out of scope for the first cut.
  • What about webhook subscriptions? Listed as read_webhooks / write_webhooks but the webhook admin endpoints don't exist yet. Reserve the name; implement when those endpoints land.

References