Skip to content

Admin abilities and policies

Map the shared admin ability catalog to host-owned policies, roles, and audit records.

The 1.3.0-0 prerelease adds the admin authorization seams to @adocommercekit/core. They are the vocabulary every admin surface shares: @adocommercekit/admin-api enforces them at the HTTP boundary, @adocommercekit/admin uses them to hide forbidden actions, and the audit trail records them with every mutation.

The engine never guesses what an “admin user” is. Core ships the catalog and a deny-by-default policy per resource group; the host maps abilities onto its own user and role model.

import { commerceAbilities, CommerceOrderPolicy } from '@adocommercekit/core/policies'

@adonisjs/bouncer is an optional peer dependency. Install it only when you use this subpath:

pnpm add @adonisjs/bouncer

The catalog

Ability names are frozen at first publication. Adding a name is a minor change; renaming one is breaking.

Resource group Abilities
Products commerce.products.view, commerce.products.manage
Categories commerce.categories.manage
Orders commerce.orders.view, commerce.orders.transition, commerce.orders.cancel, commerce.orders.adjust
Payments commerce.payments.view, commerce.payments.capture, commerce.payments.refund, commerce.payments.void
Shipments commerce.shipments.manage
Inventory commerce.inventory.view, commerce.inventory.adjust
Customers commerce.customers.view, commerce.customers.anonymize
Metrics commerce.metrics.view
Audit commerce.audit.view

commerceAbilityGroups exposes the same table at runtime, and commercePolicyAbilities maps each shipped policy method to the ability it authorizes. isCommerceAbility(value) narrows an untrusted string.

Shipped policies

One class per resource group, every method denying by default:

  • CommerceProductPolicyview, manage
  • CommerceCategoryPolicymanage
  • CommerceOrderPolicyview, transition, cancel, adjust
  • CommercePaymentPolicyview, capture, refund, void
  • CommerceShipmentPolicymanage
  • CommerceInventoryPolicyview, adjust
  • CommerceCustomerPolicyview, anonymize
  • CommerceMetricsPolicyview
  • CommerceAuditPolicyview

Every method returns AuthorizationResponse.deny('Missing ability <name>', 403) until you override it. An unmapped ability is a 403, never a pass.

Mapping abilities onto your user model

Extend the shipped class in the host and override only the methods your roles grant.

// app/policies/commerce/order_policy.ts
import { CommerceOrderPolicy } from '@adocommercekit/core/policies'
import { AuthorizationResponse } from '@adonisjs/bouncer'
import type User from '#models/user'
import type { Order } from '@adocommercekit/core/models'

export default class OrderPolicy extends CommerceOrderPolicy {
  view(actor: User) {
    return actor.hasRole('support') || actor.hasRole('ops')
      ? AuthorizationResponse.allow()
      : super.view(actor)
  }

  transition(actor: User, order?: Order) {
    if (!actor.hasRole('ops')) return super.transition(actor, order)
    return AuthorizationResponse.allow()
  }

  cancel(actor: User, order?: Order) {
    // Cancelling moves money. Keep it with the owners of the refund flow.
    return actor.hasRole('finance') ? AuthorizationResponse.allow() : super.cancel(actor, order)
  }
}

Two rules keep this maintainable:

  1. Always fall back to super. A missing branch then denies with the ability name in the message instead of returning undefined.
  2. Never widen a group. Granting commerce.payments.view to support staff must not grant commerce.payments.refund; that is why the catalog splits reads from money movement.

Where enforcement happens

Policies are checked at the HTTP and Inertia boundary. Services stay policy-free — they are also called by jobs and Ace commands, where there is no actor.

@adocommercekit/admin-api publishes that boundary. Its commerceAdminAbility middleware resolves an ability to the { policy, method } pair adminAbilityTarget() reports, instantiates your subclass from app/policies/commerce/main.ts, and runs the method before the controller body. A denial is 403 E_COMMERCE_FORBIDDEN with the ability name in details.ability, and an ability with no mapped method is a denial too — never a pass. See the Admin REST API reference.

commerce:doctor fails while commerceAdminRoles() still returns nothing, so an unmapped installation is reported rather than discovered by an operator.

Every admin-facing service method carries its ability in its doc-block:

/** @ability commerce.orders.view */
async list(filter: AdminOrderFilter = {}): Promise<AdminListPage<Order>>

Search for @ability in @adocommercekit/core to enumerate the admin-facing surface.

Admin list and metrics seams

The same train adds the read seams the admin surfaces are built on:

  • OrderService.list, CatalogService.listProducts, CustomerService.list, PaymentService.listPayments, and InventoryService.listLevels return AdminListPage<T> with keyset (cursor) pagination. limit is clamped to [1, 100] and defaults to 25. Cursors are opaque; a tampered cursor throws RangeError rather than silently resetting to page 1.
  • total comes from a probe bounded to ADMIN_TOTAL_CEILING (1,000) rows: an exact count below the ceiling, null above it. Render null as “1–25 of many”. No admin page ever pays for an unbounded COUNT(*).
  • AdminMetricsService owns the dashboard aggregates. Revenue is derived from the payment ledger (captures minus succeeded refunds), so the dashboard reconciles with the ledger and PPN exports to the cent. All amounts cross the wire as stringified minor units.

Both seams need the covering indexes published by the admin_seams migration:

node ace configure @adocommercekit/core   # publishes 011_admin_seams
node ace migration:run
node ace commerce:doctor                  # verifies inventory_levels.reorder_point exists

reorder_point is a new nullable column on inventory_levels. Set it per level to opt that level into the belowReorderPoint list filter and the dashboard’s low-stock panel; leave it null to leave the level unwatched.

Performance gate

The admin read seams carry a performance floor: every list call answers in under 250 ms and every dashboard metric in under 500 ms against the 100k-order rehearsal dataset. The playground ships the gate that proves it:

pnpm admin:perf   # seeds 100k orders with a proportional ledger, then times every seam

On PostgreSQL the gate is normative and fails closed; it also asserts that every covering index the admin_seams migration ships is present and that each documented filter/sort combination reaches the orders table through an index rather than a sequential scan. On MySQL and SQLite the same timings run as informational output — dialect-specific tuning is the host’s call.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close