DocsWorking on the code
Permissions: enforcement
On this page12 sections
- 1Data model
- 2The endpoint does not authenticate for you
- 3Permission slugs
- 4Resolver patterns
- 5The “no channel at all” admin
- 6What’s enforced today
- 7Refunds and credit memos
- 8Inventory modes
- 9Plugin / microservice URL rewrites
- 10What’s NOT scope-aware (intentionally, today)
- 11Known gaps / deferred
- 12Adding a new resource
Skelet’s admin permission model has three axes: who (admin user)
holds what (permission slug like orders.view) at what scope
(any combination of channel + locale, or “any”). This doc is the
contract for resolver authors.
Configuring roles rather than writing guards? permissions.md is the
operator’s guide, and carries the generated slug reference.
Data model
All grants live in admin_role_permission_scopes:
(role_id, permission_id, channel_id NULL, locale TEXT NULL)
Each row is one grant. A role with three permissions across two
channels has six rows, and NULL in either scope column means “any”.
permissions.md has what the scopes mean and
how they stack; what matters when you are reading the SQL:
- A role can grant different permissions on different channels -
e.g. one role granting
catalog.manageon EU but onlycatalog.viewon US. This is a single-table redesign; the earlier “all permissions inherit all scopes” model forced two separate roles. - Locale and channel scope are independent columns. A translator role
can grant
cms.managewith(channel=NULL, locale='de'), which works on every channel that carries German content. - There’s no
all_channelsboolean to look for. “Global for slug X” is derived from “at least one scope row exists for slug X with both columns NULL”. - Superusers never reach the table at all -
RequireChannelPermissionand friends short-circuit on the claim before consulting a checker.
The endpoint does not authenticate for you
auth.Middleware is non-blocking on purpose: it parses the JWT, injects
claims, and calls the next handler either way. /api/admin/graphql has no
blanket “must be signed in” gate in front of it - the resolver is the gate.
The consequence is worth stating plainly: a Query or Mutation resolver with no guard answers anonymous callers. Not “any admin” - anyone who can reach the endpoint. There is no second line of defence behind the resolver.
TestEveryAdminResolverIsGated (graph/admin/authz_coverage_test.go) walks
the package and fails if any exported queryResolver / mutationResolver
method neither guards itself nor calls a helper that does. Resolvers that are
genuinely public (login, the password-reset pair, token refresh, the dev-mode
ones, plugin-delegated fields whose gate lives in the plugin, and the
self-scoped chat history) are listed in publicResolvers with a
reason each. A second test fails on stale entries, so an allowlisted resolver
that later grows a guard has to leave the list.
If you add a resolver, the test tells you which of the two things you forgot.
The same endpoint serves integration tokens, so a resolver also has to decide whether a
token may use it. A permission guard already lets one through. When a resolver needs to
know who is calling, use auth.RequireActor - an admin or a token, with
AdminID() / IntegrationID() for the columns that record an author. Only when the thing is
genuinely a person’s, use auth.RequireAdmin and add the resolver to humanOnly in
graph/admin/actor_coverage_test.go with the reason. That test fails on a bare
auth.RequireAuth, on an unlisted RequireAdmin, on a stale entry, and on
claims, _ := auth.AdminFromContext(ctx), which hands a token caller a nil to dereference.
Permission slugs
Resources register their own slugs in their register.go (or domain init).
Convention is <resource>.view, <resource>.manage, plus extras like
reviews.moderate. Slugs are global identifiers - the scope (global vs.
channel vs. locale) is attached on the role assignment, not the slug.
See apps/dashboard/src/features/admins/roles/RoleEditPage.tsx for the
UI that creates these assignments.
Resolver patterns
1. Mutations that touch a single channel
Always know the channelID up-front (from the input). Use
RequireChannelPermission:
func (r *mutationResolver) UpdateOrder(ctx context.Context, input model.UpdateOrderInput) (*model.Order, error) {
channelID, _ := uuid.Parse(input.ChannelID)
if err := auth.RequireChannelPermission(ctx, r.AdminSvc, "orders.manage", channelID, nil); err != nil {
return nil, err
}
// …mutate…
}
For locale-specific mutations (e.g. editing the German translation of a
CMS page), pass the locale too: RequireChannelPermission(..., &locale).
2. Single-resource queries (Order, Customer, Promotion, etc.)
Same as mutations - once you’ve loaded the row from the service, gate on its channel ID before returning it:
order, err := r.OrderSvc.Get(ctx, id)
if err != nil { return nil, err }
if err := auth.RequireChannelPermission(ctx, r.AdminSvc, "orders.view", order.ChannelID, nil); err != nil {
return nil, err
}
return toOrderModel(order), nil
A scoped admin asking by ID for a channel they can’t access gets
FORBIDDEN, not a 404 - keep the distinction.
3. List queries (orders, reviews, promotions, etc.)
Load the permitted-channel set once, fetch the full list, filter:
perm, err := auth.LoadPermittedChannels(ctx, r.AdminSvc, "orders.view")
if err != nil { return nil, err }
orders, err := r.OrderSvc.List(ctx)
if err != nil { return nil, err }
visible := auth.FilterByChannel(orders, perm, func(o order.Order) uuid.UUID { return o.ChannelID })
The FilterByChannel generic is a no-op when perm.Bypass() is true
(superuser, global-scoped role) so the cost on the unscoped path is one
slice copy avoided.
When the underlying repo supports WHERE channel_id = ANY(...), prefer
passing perm.IDs() into the query - fewer rows scanned. Most repos
don’t today; the post-filter is fine until they grow that surface.
IDs() nil vs empty is load-bearing. It returns nil only on bypass,
meaning “no channel filter”, and a non-nil empty slice for an admin permitted
on zero channels, meaning “match nothing”. Callers key off exactly that (see
reviews.resolvers.go). Collapsing the empty case to nil - the sort of
thing an innocuous var out []uuid.UUID refactor does - would silently show a
zero-channel admin every channel. TestPermittedChannels_IDsNilOnlyOnBypass
pins it.
4. Nested field resolvers (Product.listings, Category.listings, etc.)
Per-channel collections inside a parent object leak the same way a list query would. Filter them in the field resolver:
func (r *productResolver) Listings(ctx context.Context, obj *model.Product) ([]*model.ProductChannelListing, error) {
perm, err := auth.LoadPermittedChannels(ctx, r.AdminSvc, "catalog.view")
if err != nil { return nil, err }
return auth.FilterByChannel(obj.Listings, perm, func(l *model.ProductChannelListing) uuid.UUID {
id, _ := uuid.Parse(l.ChannelID)
return id
}), nil
}
If the parent resolver already loaded the array eagerly, the field resolver just filters in place - no extra DB roundtrip.
5. Operations whose scope depends on their target
Some entry points are global or channel-scoped depending on what they were asked to do - an export of every channel versus one channel’s, a promotion rule with a null channel versus a channel’s own. Two helpers cover this so each caller doesn’t re-derive the rule:
RequireGlobalOrChannelPermission(ctx, checker, slug, *uuid.UUID)- a nil channel demands the global grant, otherwise it gates on that channel. This is the one for nullable-channel columns (promotion rules).RequireScopedOrGlobal(ctx, svc, slug, channelID, global bool)- same idea with an explicitglobalflag, for operations whose blast radius is the whole installation when untargeted. Import/export uses it, and the GraphQL and HTTP surfaces share the one implementation so they can’t drift.
6. Global-only resources (currencies, countries, locales, admin users)
These are not channel-scoped - they live above the channel layer. Use
RequireGlobalPermission or plain RequirePermission. A scoped admin
without any global slug grant cannot manage them, which is the intended
behaviour.
7. Product-global rows shared across listing channels
Content translations, attribute values, media, downloads, subscription
terms, product options, their values and option rules, bundle options and selections,
grouped members, kit components, variant structure - one row, rendered by every channel that lists
the product. A plain RequirePermission here would let a Channel-A-scoped
admin edit data that shows on Channel B. Gate these with
RequireAllListedChannelsPermission: the admin needs the slug on every
channel currently listing the product (or a global grant). Pass the locale
for locale-bound rows (content); pass "" for non-localized rows, where a
grant of any locale scope counts per channel. In graph/admin the
requireCatalogManageOnAllListingChannels helper wraps the listings
lookup for the catalog mutations.
When the mutation only takes a child row’s id (an option, a value, a bundle
selection, a grouped member, a kit component), call the matching
requireCatalogManageFor<Child> helper in catalog_product_authz.go. Each
resolves child to owning product and then calls the gate above. Do it right
after the id parse: a gate that reads the row after the write is not a gate.
Watch which id is the owner. BundleSelection.ProductID is the product being
selected into the bundle, not the bundle itself (that one comes through
OptionID), while KitComponent.ProductID is the owning kit and
ComponentProductID is the reference.
Per-channel option surcharges (setProductOptionChannelPrice,
setProductOptionValueChannelPrice) take the same gate, and the channel they
name has to be one the product is listed on - so a channel-scoped admin can
only ever price a channel they already hold.
Configurator templates sit on both sides of this line. A template is shared
config like a product type - one edit reaches every product using it, on any
channel - so editing it, its options, values and rules takes a global
catalog.manage (requireGlobal); the option child-row helpers branch on
whether the row belongs to a template. Using a template on a product,
dropping it or detaching it is a product edit, so those take the product
gate above, once per product named. A product type’s template list
(setProductTypeOptionTemplates) reaches every simple and configurable
product of the type on every channel, so it takes the global gate too.
graph/admin/catalog_product_scope_test.go enforces this: every mutation in
the product-row resolver files has to reach a channel-aware gate, so a bare
RequirePermission fails the build. Product types are deliberately out of
scope, being global config with no listings of their own.
The “no channel at all” admin
An admin whose roles grant orders.view only on Channel A will:
- See Channel A in the channel picker (the channels list filters too).
- See orders only from Channel A.
- Get
FORBIDDENon any mutation against Channel B. - See a Product detail page with no Listings rows for channels they can’t access (the field filter strips them).
An admin with zero assignments granting a slug sees an empty list / no records / no mutations available. That’s correct - they shouldn’t be able to act on anything.
What’s enforced today
| Resource | List/Query | Single get | Mutation | Notes |
|---|---|---|---|---|
| Channels | ✅ filtered | - | ✅ via channels.manage per id | channels.resolvers.go |
| Catalog mutations | n/a | n/a | ✅ via RequireChannelPermission | Per-channel listing + product listing setters |
| Product-global mutations (content, attributes, media, downloads, terms, options, bundle/grouped/kit structure, children, variant mode) | n/a | n/a | ✅ all-listing-channels gate | See pattern 7; CSV import and create/update/delete product require a global grant |
| Product.listings (nested) | n/a | ✅ filtered in renderProductListings | n/a | Strips listings for channels the admin can’t see |
| Customers | ✅ filtered | ✅ load-then-gate | ✅ via requireCustomerManage helper | customer_helpers.go |
| Orders | ✅ filtered | ✅ load-then-gate | ✅ via adminTransitionOrderStatus | Status transitions check order’s channel |
| Reviews | ✅ filtered (TotalCount drops to filtered count) | ✅ load-then-gate | ✅ via requireReviewModerate | |
| Promotions | ✅ filtered (rules with nil ChannelID are global) | ✅ via RequireGlobalOrChannelPermission | ✅ on Create/Update/Delete, both old + new channel checked on update | |
| Blog channel listings | n/a | n/a | ✅ Set/DeleteBlogPostChannelListing | The post itself is global; listings are per-channel |
| CMS channel listings | n/a | n/a | ✅ Set/DeleteCMSPageChannelListing | Same as blog |
| Menus | n/a | n/a | ✅ SetMenuStores checks every assigned channel | Uses catalog.manage slug |
| URL rewrites | - | - | ✅ pre-existing per-channel gate | |
| Webhooks | - | - | ✅ pre-existing per-channel gate | |
| Inventory low-stock list | ✅ filters via location’s channel assignment (when applicable) | n/a | ✅ RequireChannelPermission on per-channel ops | Inventory locations are global; channel↔location bindings are gated |
| Tax rules | ✅ filtered (cross-channel view) / gated (single channel) | ✅ load-then-gate | ✅ on the rule’s own channel | Every rule carries a channel_id, so nothing here is global |
| Shipping zones, methods, boxes, carriers | ✅ gated per channel | ✅ load-then-gate | ✅ per channel; a method inherits its zone’s | requireZoneScope / requireMethodScope in shipping_helpers.go |
| Category.listings (nested) | n/a | ✅ filtered in the Category.listings field resolver | n/a | Matches Product.listings; wired via resolver: true in gqlgen.admin.yml |
| Credit memos | ✅ orders.view (per order) | ✅ orders.view on the memo’s order | ✅ orders.refund to issue | creditableQuantities needs orders.refund - it only feeds the issue form |
| Blog taxonomy (categories, tags, authors) | ✅ blog.view | ✅ blog.view | ✅ blog.manage | Global entities. Authors carry an email, so the reads are gated too |
| Review questions | ✅ reviews.view | ✅ reviews.view | ✅ reviews.manage | Global entities (assigned to product types) |
| Promotion condition/action catalogs | ✅ promotions.view | n/a | n/a | Static registry metadata |
Feature + mode flags (serverFeatures, aiStatus, inventoryMode), resolveUrl | ✅ RequireActor | n/a | n/a | Every signed-in caller needs these for nav and page chrome, so authentication rather than a slug |
Refunds and credit memos
Every refund writes a credit memo: a numbered record of what was credited, which lines, and where the money went. Two ways one is born:
- Settling an RMA.
FinalizeReturnRefundwrites the memo in the same transaction as the money, so the two can’t disagree. One memo per return, enforced by a unique index, which makes a replay a no-op. issueCreditMemo, with no return behind it. Goodwill, a pricing correction, a shipping-only credit. Gated byorders.refundon the order’s channel.
Three money paths, picked from the order and the requested target:
| Situation | What happens |
|---|---|
| Gateway order, refund to original | The gateway’s refund API, keyed on the ledger row’s idempotency key |
| Any order, refund to gift card | A new gift card is issued - no gateway involved |
| Order with no gateway handle, refund to original | Recorded against the ledger with no provider call - an offline refund |
The ledger is not gateway-only. RecordOfflinePayment writes a
capture row with provider_slug = 'offline', so cash on delivery, bank
transfer, purchase orders and pickup carry the same ledger shape a card order
does. Without that row Captured would stay 0 forever on those orders and every
refund guard would read them as “nothing was ever collected”, making a refund
impossible - the RMA would park in received with no way out.
The refundable ceiling is per-target, not one gateway number (refundCeiling
in internal/order/returns.go): gateway refunds clamp to captured-minus-
refunded, gift-card refunds clamp to the order total minus refunds, because
issuing store value is not gateway money. Clamping the gift-card path to the
gateway is what made a gift-card-paid order un-refundable even back to a gift
card.
Cancelling and refunding compensate differently
Both publish an event that other subsystems react to, and they carry different sets of reversals. Getting this backwards pays the customer twice.
| Tenders consumed (gift card, store credit, a plugin’s credit line) | Rewards granted (loyalty, referral) | |
|---|---|---|
order.cancelled | Restored - the order is void, nothing was paid for | Revoked |
order.refunded | Left alone - the refund itself already returns the money | Revoked |
OrderRefunded.MerchandiseFraction is the cumulative share of
subtotal - discount credited across every memo, so reversals revoke toward a
target rather than by one memo: successive partial refunds each take their
share, and a replayed at-least-once delivery takes nothing. It is merchandise,
not money, because that is what loyalty earns on - a shipping-only or
goodwill-adjustment memo refunds real money and correctly revokes zero points.
Referral keys off FullyRefunded instead: a referral reward is all-or-nothing,
so a partial return on a genuine order must not cost the referrer their payout.
Inventory modes
Inventory has two modes, switched via sales/inventory/flat_mode:
- MULTI_LOCATION (default): per-product
inventory_items, per-locationinventory_levels, theinventory_reservationsledger. Salability = (sum physical at channel’s locations) - net reservations. When the on-hand quantity drops is a second setting,sales/inventory/deduction_step:fulfillment(default) holds stock as a reservation at placement and deducts when a shipment is created,placementdeducts immediately. Salable stock reads the same either way - the difference is what a per-location quantity means. - FLAT: an external ERP is the source of truth.
Each
inventory_itemsrow has a singlequantitycolumn; locations, levels, reservations are dormant. Salability =item.quantity(per-product, no channel dimension). Order placement does NOT decrement. The ERP pushes updates via thesyncProductInventory/syncProductInventoryBatchadmin mutations.
Both modes gate writes on inventory.manage (no separate .sync
slug). The flat-mode mutations are permitted in either mode so the
audit trail (last_synced_at, last_synced_source) stays
consistent. Switching modes does not migrate data - inventory_levels
rows survive a flip and become live again on switch-back.
Plugin / microservice URL rewrites
Skelet’s url_rewrites table is the single source of truth for
“pretty path → entity” mappings. The eight core entity types
(product, category, collection, blog_post, blog_category,
blog_tag, blog_author, cms_page) call urlrewrite.Service
in-process from their domain services. Tier 1 plugins and external
microservices that own their own URL-addressable entities (e.g. a
loyalty-program service) can hook into the same machinery via the
admin GraphQL mutations:
syncCanonicalUrlRewrite(input: { entityType, entityId, requestPath })- upsert the canonical row. Called on create + slug change.
createUrlRewriteRedirect(input: { entityType, entityId, fromPath, redirectType })- 301/302 from the old path to the new canonical. Called on slug change immediately after the canonical sync.
deleteUrlRewritesForEntity(entityType, entityId)- drops every rewrite for the entity. Called on entity delete.
All three are gated by the urlrewrite.manage permission slug.
The integration that authenticates the microservice (created via the
admin’s Integrations page) needs that slug granted to its
auto-generated role. Skelet does not call back into the microservice
- the plugin owns its data; Skelet only owns the path-to-id mapping.
Storefront routing for plugin entities
When the storefront’s catch-all calls route(path) and the matched
rewrite has an entity_type Skelet doesn’t recognize, the response
returns:
kind: PLUGINpluginKind: "<the entity_type slug>"entityId: "<uuid>"
The storefront switches on pluginKind to pick a renderer. The
renderer is expected to fetch entity data itself - either from
Skelet’s GraphQL surface (if the plugin extends it) or directly from
the microservice. Unknown pluginKind values render as 404.
Slug naming
Plugin entity_type slugs should be lowercase, snake_case, and
namespaced where collisions are likely (e.g. loyalty_program, not
program). The DB enforces no closed list - collisions across
plugins are the operator’s problem to avoid.
What’s NOT scope-aware (intentionally, today)
- Global resources: currencies, countries, locales, product types, attributes, admin users, API tokens, integrations/apps. They live above the channel layer; permission scope is global.
- Inventory locations themselves (warehouses are global - their assignments to channels are gated via
channels.manage). - Media library (assets are not per-channel).
- Blog/CMS authoring (creating an author, tag, category, post body) - global. The per-channel publication state is gated separately via the listing setters above.
Known gaps / deferred
These are spots where channel-aware gating would tighten things further but aren’t done yet. Add as needed.
- Order list pagination total: when scope filters out part of a fetched page, the offset can leave gaps. Acceptable until a repo-level channel filter lands.
customers.managegroup-level mutations (Create/Update/Delete CustomerGroup, SetDefaultCustomerGroup) are stillRequirePermission(not channel-scoped) because customer groups are global entities. Confirm this is the intent before changing.hasStoretreats a check with no locale as satisfied by a locale-scoped grant, so a(EU, de)translator passes a non-locale-specific(EU, -)mutation check. That’s the SQL’s behaviour, mirrored deliberately, andTestGrantSnapshot_HasStorepins it - but it means locale scope only narrows resolvers that actually pass a locale. This is the one open decision in this doc: tightening it would make locale-scoped roles genuinely narrower, at the cost of breaking any role that relies on the current widening.
When in doubt: if the resource has a channel_id column or a per-channel
junction table, it should respect the scope. If it doesn’t, it’s a global
resource and uses RequireGlobalPermission / RequirePermission.
Adding a new resource
- Register the slugs in your package’s
register.go:permission.Register(permission.Definition{Group: "myresource", Slug: "myresource.view", ...}) permission.Register(permission.Definition{Group: "myresource", Slug: "myresource.manage", ...}) - In every list resolver:
LoadPermittedChannels+FilterByChannel. - In every single-fetch resolver: load the row, then
RequireChannelPermissionagainst itschannel_id. - In every mutation:
RequireChannelPermissionagainst the input’schannel_id. - In nested field resolvers that iterate per-channel children: filter
with
FilterByChannel. - Add a row to the “What’s enforced today” list in this doc.
- Run
skelet go test ./graph/admin/ -run TestEveryAdminResolverIsGated. It fails on any entry point you left ungated, which is the whole check in one line.
