Skip to content

Authorization Domain Scoping

Rule (2026-06): every authorized route is evaluated in a domain (<type>_<id>). By default that domain is the merchant from the x-merchant-id header. Routes that operate on an organizer (not a merchant) must declare a per-route domain, or the check resolves to the wrong scope and returns a false 403.

1. The model: every check answers three questions

QuestionSpec fieldExample
Who?subject (user)the authenticated user
What?action + resourceupdate on Organizer.updateById
Where?domainOrganizer_e734… vs Merchant_00000000…

A grant is never absolute - it is always scoped to a domain. A user may be OWNER inside Organizer_e734… yet have no rights inside Merchant_abc…. The "where" is the domain, written <type>_<id> (e.g. Organizer_e734…, Merchant_abc…).

The only two domain types registered are Merchant and Organizer (packages/core/src/application/base.tsdomainTypes).

2. Where the merchant default comes from

The domain resolver is wired once, at application bootstrap in configureAuthorization() (issuer.ts / verifier.ts), and applies to every route of every service:

ts
this.bind(AuthorizeBindingKeys.OPTIONS).toValue({
  defaultDecision: AuthorizationDecisions.DENY,
  alwaysAllowRoles: [SUPER_ADMIN, ADMIN, OPERATOR],
  domainResolver: this.getMerchantScopedDomainResolver(), // ← global default
});

getMerchantScopedDomainResolver() (base.ts) reads the header and produces a merchant domain:

ts
const merchantId = context.req.header('x-merchant-id'); // ACTIVE_MERCHANT_HEADER
if (!merchantId) return null;
return { type: Merchant.AUTHORIZATION_SUBJECT!, id: merchantId }; // → "Merchant_<id>"

Why merchant is the default: the platform's physical multi-tenant unit is the Merchant (MST / e-invoice / wallet). ~95% of resources (products, inventory, sales, finance) belong to one merchant, so merchant scope is correct for almost everything. Organizer (org-level branding, above merchant) is the rare exception.

00000000-0000-0000-0000-000000000000 is the SYSTEM_MERCHANT_ID sentinel (packages/core/src/utilities/request.utility.ts) - "no merchant selected". The FE sends it on org-level screens that have no merchant context. It still resolves to a domain (Merchant_00000000…) that matches no real grant.

3. How the domain is resolved (precedence)

resolveRequestDomain() (packages/core/src/components/auth/authorize/common/resolve-request-domain.ts) picks the domain in this order:

  1. Per-route spec.domain - declarative { from, key, type } or a resolver method. Wins when present.
  2. Global domainResolver - the merchant default from §2.
  3. SYSTEM_WIDE - nothing matched.

Both the request side (resolveRequestDomain) and the grant-storage side (AuthorizationPolicyBuilder.serializeDomain) serialize a typed domain identically as [type, id].join('_'). Same formula → tokens match when (and only when) the route resolves the same scope the grant was stored under.

4. The gotcha: org-level routes silently inherit merchant scope

A route is mis-scoped when all of these hold:

  • it has authorize (not { skip: true }), and
  • the caller is not an always-allow role (SUPER_ADMIN / ADMIN / OPERATOR), and
  • it operates on an organizer (grant lives in Organizer_<id>), but
  • it does not declare a per-route domain → falls through to the merchant resolver.

Result: the request asks Casbin for the permission in Merchant_<header>, while the user's grant lives in Organizer_<id>no match → false 403, even though the user genuinely owns the organizer.

Reads on the OrganizerController (find/findById/count/findOne) sidestep this with authorize: { skip: true } (authenticate-only) - a pre-existing workaround. The write routes did not, which is how the gap surfaced.

5. The fix: declare a per-route organizer domain

For a route addressing an existing organizer by path param, scope it to the organizer instead of the merchant:

ts
updateById: {
  request: { body: UpdateByIdOrganizerRequest, headers: MerchantScopedRequestHeaders },
  authorize: {
    action: AuthorizationActions.UPDATE,
    resource: OrganizerPermissions.UPDATE_BY_ID.code,
    domain: { from: 'param', key: 'id', type: Organizer.AUTHORIZATION_SUBJECT! }, // ← override
  },
},

from: 'param' reads the id from the URL (/organizers/{id}), type: 'Organizer' → resolved domain Organizer_<id>, which matches the OWNER's per-org grant. The global merchant default is untouched (still correct for every other route).

6. Audit - org-level routes (2026-06-25)

The definitive marker of an org-level route is a permission with subject: Organizer.AUTHORIZATION_SUBJECT. Exactly two controllers carry them:

ControllerRoute(s)ParamScope todayStatus
commerce · organizer.controller.tsupdateById, deleteByIdidOrganizer_<id>Fixed (per-route domain)
commerce · organizer.controller.tsfind, findById, count, findOne-n/aauthorize: { skip: true } (reads, authenticate-only)
commerce · organizer.controller.tscreate, onBoarding, aggregate- (creating a new org)Merchant_<header>⚠️ Separate concern - create-authority, no organizer id to scope to; verify independently
identity · policy-definition/organizer/definitions.tsFIND / COUNT / MANAGE_ORGANIZER_TARGETSorganizerIdOrganizer_<organizerId>Fixed 2026-06-25 (per-route domain; organizerId is UUID-only, no slug fallback)

No other package defines an Organizer-subject permission, and besides the routes fixed above, no route declares a per-route domain. Everything else is merchant-scoped by design and works with the global default.

The identity membership routes also enforce org ownership in the service layer (_validateMembershipAuthority reads the JWT organizers[]). With the domain now correctly scoped, that service check is defense-in-depth on top of the Casbin decision, not the only gate.

findById exception (commerce/organizer): left on authorize: { skip: true } deliberately. Its :id param is polymorphic - findByIdentifier accepts a UUID or a slug - but the domain is resolved from the raw param before the handler runs, so a slug would resolve to Organizer_<slug> and never match the UUID-keyed grant (false 403). Scoping it would require a resolver method that canonicalizes slug→UUID; not worth it for a read, so it stays authenticate-only.

7. Checklist for new authorized routes

  • Merchant-scoped resource (the default, ~95%): do nothing - the global resolver + x-merchant-id is correct.
  • Organizer-scoped resource addressing an existing org by param: add domain: { from: 'param', key: '<idParam>', type: Organizer.AUTHORIZATION_SUBJECT! }.
  • No domain at all (pure user/system level): use authorize: { skip: true } (authenticate-only) and document why, mirroring the Organizer reads.
  • Never rely on the merchant default for an org-level resource - it fails closed (403) the moment the caller isn't an always-allow admin.

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.