Authorization Domain Scoping
Rule (2026-06): every authorized route is evaluated in a domain (
<type>_<id>). By default that domain is the merchant from thex-merchant-idheader. 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
| Question | Spec field | Example |
|---|---|---|
| Who? | subject (user) | the authenticated user |
| What? | action + resource | update on Organizer.updateById |
| Where? | domain | Organizer_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.ts → domainTypes).
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:
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:
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-000000000000is theSYSTEM_MERCHANT_IDsentinel (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:
- Per-route
spec.domain- declarative{ from, key, type }or a resolver method. Wins when present. - Global
domainResolver- the merchant default from §2. 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 withauthorize: { 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:
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:
| Controller | Route(s) | Param | Scope today | Status |
|---|---|---|---|---|
commerce · organizer.controller.ts | updateById, deleteById | id | Organizer_<id> | ✅ Fixed (per-route domain) |
commerce · organizer.controller.ts | find, findById, count, findOne | - | n/a | ✅ authorize: { skip: true } (reads, authenticate-only) |
commerce · organizer.controller.ts | create, 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.ts | FIND / COUNT / MANAGE_ORGANIZER_TARGETS | organizerId | Organizer_<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
identitymembership routes also enforce org ownership in the service layer (_validateMembershipAuthorityreads the JWTorganizers[]). With the domain now correctly scoped, that service check is defense-in-depth on top of the Casbin decision, not the only gate.
findByIdexception (commerce/organizer): left onauthorize: { skip: true }deliberately. Its:idparam is polymorphic -findByIdentifieraccepts a UUID or a slug - but the domain is resolved from the raw param before the handler runs, so a slug would resolve toOrganizer_<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-idis 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.