Skip to main content

Product permissions reference

Every write action on a product is guarded by two independent layers of route middleware, and there is no ProductPolicy class — authorization is entirely route-middleware based, not gate- or policy-based. This page is the lookup for both layers: the granular permission keys (products.create, products.update, …) and the scope.rw:products scope gate that sits in front of them.

Use it to answer "which permission does this button need?" and "why did this token get a 403?".

The two gate layers

Every product route passes through these in order. A request must clear both to succeed.

LayerMiddlewareWhat it checksApplies to
1 — Scopescope.rw:productsThe whole products route group requires the products scope. For a GET/HEAD/OPTIONS request it resolves to products:read; for any state-changing verb (POST/PUT/PATCH/DELETE) it resolves to products:write.Every route in the products group
2 — Permissionpermission:products.{action}The granular capability for the specific write action (create, update, archive, delete, import, export, manage_variants).Only the write routes that declare it

Layer 1 is method-aware: one scope.rw:products declaration on the group gates the entire CRUD surface. Read routes (search, lookups, inventory panels, attribute reads) only ever need products:read and carry no permission key — so a user who can view a product may still be blocked from editing it by layer 2.

Who is exempt from the scope gate

The scope check (layer 1) only applies to real, persisted Personal Access Tokens (PATs). It's bypassed for:

  • First-party session auth — the SPA/browser session (a transient, session-equivalent token) passes straight through.
  • Integration-instance tokens — 3PL, channel partner, and MCP tokens authenticate as an integration instance and skip scope checks.
  • Test tokensSanctum::actingAs() mocks in the test suite.

A PAT that lacks the required scope gets a 403 with a body like:

{
"message": "Token is missing the required scope: products:write",
"required_scope": "products:write"
}

A read-only token (holds products:read but not products:write) can list and view products but is blocked at layer 1 on every mutation — before the permission key is ever evaluated.

Permission keys

These are the granular capability keys checked by layer 2. Assign them to users through Settings → Users & permissions.

Permission keyGrantsNotes
products.createCreate a new productGates the store route and the Add product button.
products.updateEdit product details, tags, pricing, taxonomy, shipping, blemished settings, and set the default financial line typeThe broadest key — most inline edit controls on the product detail page check it. Also required to create a blemished product.
products.archiveArchive and unarchive a product (single or bulk)One key covers both directions — see the edge case below.
products.deletePermanently delete a product (single or bulk)Distinct from products.archive.
products.importImport products from a spreadsheet (preview, validate, and run)Gates the import wizard and its template/field endpoints.
products.exportExport the products list to a spreadsheetGates the export and download endpoints.
products.manage_variantsEdit a product's attributes/variations and a kit's or bundle's componentsSeparate from products.update — a user can edit details but not restructure variants/components, or vice-versa.
inventory.adjustAdjust on-hand inventory and manage holds from the product pageAn inventory key, gated by scope.rw:inventory (not scope.rw:products). Surfaced on the product page but it authorizes inventory writes.

Action → permission map

The authoritative mapping of each product write action to the permission key that guards it (layer 2), on top of the scope.rw:products scope gate (layer 1). Where an action lives under the inventory scope, that's called out.

ActionPermission keyScope gate
Create a productproducts.createscope.rw:products
Update a product's detailsproducts.updatescope.rw:products
Set default financial line typeproducts.updatescope.rw:products
Bulk-edit productsproducts.updatescope.rw:products
Archive a productproducts.archivescope.rw:products
Unarchive a productproducts.archivescope.rw:products
Bulk archive / unarchiveproducts.archivescope.rw:products
Delete a productproducts.deletescope.rw:products
Bulk delete productsproducts.deletescope.rw:products
Import products (preview / validate / run)products.importscope.rw:products
Export products (export / download)products.exportscope.rw:products
Update product attributes / variationsproducts.manage_variantsscope.rw:products
Delete product attributesproducts.manage_variantsscope.rw:products
Edit kit / bundle componentsproducts.manage_variantsscope.rw:products
Adjust inventory from the product pageinventory.adjustscope.rw:inventory
Manage inventory holds from the product pageinventory.adjustscope.rw:inventory

Read actions — search, barcode lookup, show-by-SKU, inventory panels, supplier/component/bundle/attribute reads, deletability checks — carry no permission key. They require only products:read (satisfied by scope.rw:products on a GET).

Edge cases and gotchas

These are the behaviors that surprise people. Read them before assigning roles.

  • Archive and unarchive share one key. Both directions require products.archive — there is no separate "unarchive" permission. Grant products.archive and a user can both archive and restore products. See Archive, unarchive & delete a product.
  • Delete is separate from archive. products.delete is a distinct key. A common role is "can archive but can't permanently delete": grant products.archive, withhold products.delete. Deletability is also checked server-side — a product with movement history can be archived but not deleted regardless of permission. See Archive, unarchive & delete a product.
  • Editing details ≠ managing variants. products.update covers detail fields, tags, pricing, taxonomy, and shipping; products.manage_variants covers the attribute/variation matrix and kit/bundle component structure. They're independent, so you can let a user correct a description without letting them re-shape a variant matrix. See Set a product's categories & attributes and Build bundles & kits in the Workshop.
  • inventory.adjust is an inventory permission, not a products one. The Adjust Inventory action and the Holds tab appear on the product page, but they write inventory and are gated by scope.rw:inventory + inventory.adjust. Granting product permissions alone doesn't let a user adjust stock — see the Inventory guide for the inventory permission set.
  • No ProductPolicy. There is no Laravel policy or gate for products. All authorization is the route middleware described above. Don't look for authorize() calls or a ProductPolicy — they don't exist.
  • Creating a blemished product needs products.update. The Create Blemished Product action is gated on products.update (plus the product being a standard or kit type), not on a dedicated key. See Create a blemished product.

How the UI reflects permissions

The frontend hides or disables controls the current user can't use, but this is cosmetic — the back end middleware is the real gate. Even if a button were shown, the API would reject an unauthorized request with a 403.

The SPA resolves each check through a can('key') helper with these rules:

  1. If the tenant's granular_permissions feature flag is OFF, every check returns true — nothing is permission-gated, matching pre-feature behavior.
  2. If the user is an admin, every check returns true.
  3. Otherwise, the check passes only if the user's permission set contains the exact key.

Given that, the UI behaves as follows:

  • Buttons and menu items are conditionally hidden or disabled per permission. For example, the products list only shows Import with products.import, Export with products.export, Add product with products.create, and the bulk Delete action with products.delete.
  • The Actions menu on the product detail page only appears if at least one of its actions is permitted — it renders when the user can adjust inventory (and the product carries inventory), or can create a blemished product (products.update), or the product is a kit (assemble/disassemble links). If none apply, the menu isn't shown at all.
  • The Adjust Inventory item requires inventory.adjust to appear at all. When the user has that permission but the product's type doesn't carry inventory (only standard, kit, blemished, and manufactured types do), the item still renders but is disabled, with a tooltip reading {type} products don't carry inventory. So the item's visibility is gated by the permission, while its enabled/disabled state is gated by the product type.

Before you begin (assigning permissions)

  • Product permissions are managed under Settings → Users & permissions. Assign the keys above to roles or users.
  • If your tenant has the granular_permissions feature off, every user effectively has all product permissions — enabling the feature is what starts enforcing them.
  • Admin users bypass all product permission checks in the UI.
  • For API access via a Personal Access Token, the token must additionally carry the products:read and/or products:write scope — a token without the right scope is rejected at layer 1 regardless of the user's permissions.

Next steps

Last verified: