Skip to content

Permission Guide

The complete permission model - read top to bottom. §1 is the one-screen map; §2-8 are the precise detail. To look up an exact permission code or a role's full effective set, use the generated Permission Matrix.

Three things - never confuse them

ConceptWhat it isExampleStored as
Permissiona resource you protect (the obj)Sale · SaleOrder · SaleOrder.refunda row in the permission catalog
Actiona verb in the lattice (the act)managewritereadg5 lattice edges - defined once, global
Grantbinds role → resource + action + domainSale : manage @ Organizera PolicyDefinition row

You define permissions (resources) + the action lattice once; you assign grants per role. Sale:manage is a grant, not a permission - that's why a role needs a handful of grants instead of hundreds of permissions.

1. The model on one screen

How a request is decided

Every request is (who · where · what · how) and is allowed only when all four axes match (an explicit deny always wins):

AxisQuestionMatches when…
WHOdoes the user hold the role?role assigned (directly or inherited)
WHEREis the active merchant in scope?grant is system-wide, a joined merchant, or the merchant's organizer
WHATis the resource covered?the code equals or sits under the grant's resource
HOWis the action covered?the granted action equals or is broader

The three axes - one grant covers everything beneath it

HOW - action lattice

 

WHAT - resource tree

 

WHERE - domain tree

Grant high on an axis = broad (Sale : manage @ Organizer = everything, everywhere in the brand); low = precise (SaleOrder.refund : execute @ Merchant_7).

Who can do what - at a glance

full = manage · edit = create/update/delete · view = read · run = execute · - = none. Scope: Owner = whole organization (all merchants); Cashier/Employee = their assigned merchant(s).

ModuleOwnerCashierEmployeeGuest
Sale (orders, POS, kitchen, customers)fullfulledit-
Commerce (products, catalog, merchant setup)fullviewview-
Inventory (stock, materials, purchasing)fullviewview-
Payment (take/refund payments)fulledit--
Finance (accounts, transactions)fullview--
Invoice (e-invoice issuance)fullrun-view ¹
Pricing (fare/rule/tax engine)full---
Taxation (tax groups)full---
Ledger (gov tax forms)full---
Licensing (plans, activation)full--view
Identity (users, roles, permissions)full ²---
Taxation Vn* reference (provinces/wards)fullviewviewview
Outreach (leads, newsletter)full---
Signal (realtime)full---

¹ Guest sees only invoice onboarding (public). ² Owner manages Users / Employees / Roles but not the RBAC catalog itself (Permission / PolicyDefinition are system-only - enforced by a deny on Owner's *:manage).

Super Admin · Admin · Operator bypass all checks (full everywhere). Customer holds no backend permissions (own data only).


2. Actions in detail

A broader action automatically satisfies any narrower request. Routes always request a base action; grants may use any tier.

TierCoversMeansTypical grantee
manageeverything below"full control of this area"Owner / Manager
writecreate · update · delete"edit, not necessarily run actions"Editor
read- (leaf)"view only"Viewer / Cashier (reference data)
execute- (leaf)run a non-CRUD op (refund, issue, close…)per-operation
create·update·delete- (leaves)the individual write verbsfine-grained

Route → base action (how a route declares what it needs):

Operation kindBase action
find · findById · findOne · countread
create · createAggregatecreate
updateById · updateByupdate
deleteById · deleteBydelete
custom op (refund, issue, close, launchpad…)execute

3. Resource tree in detail

*                         ← super-admin only
└── <Module>              e.g. Sale, Commerce, Inventory      (module roll-up: explicit edge)
    └── <Subject>         e.g. SaleOrder                       (subject)
        └── <Subject>.<op> e.g. SaleOrder.refund               (operation: automatic, dotted)
  • Operation ⊂ Subject - free, by dotted code. Subject ⊂ Module and child ⊂ parent (SaleOrderItem ⊂ SaleOrder) - one declared edge each.

The resource tree mirrors the backend modules. Operations live under their subject (not enumerated here - see the Matrix). The module roll-ups below are live g4 edges - seeded per package (97 edges, one per subject; verified against the DB).

ModuleSubjects
CommerceMerchant · Organizer · Product · ProductOption · ProductOptionValue · ProductVariant · ProductVariantOption · Category · SaleChannel · Device · Configuration · Setting · ReceiptTemplate · DiscriminationType · AllocationLayout · AllocationUnit · AllocationZone
SaleSaleOrder · SaleOrderItem · SaleCheck · PosSession · KitchenStation · KitchenTicket · KitchenTicketItem · Reservation · PointTransaction · AllocationUsage · Customer ¹ · SalesReport · PurchaseReport
InventoryInventoryItem · InventoryStock · InventoryLocation · InventoryIdentifier · InventoryTicket · InventoryTicketItem · InventoryTracking · Material · MaterialIdentifier · MaterialRecipe · ProductionOrder · PurchaseOrder · UnitOfMeasure · Vendor · VendorItem
FinanceFinanceAccount · FinanceCategory · FinanceTransaction · FinanceVoucher · PaymentIntegration
PaymentPayment · PaymentAttempt · PaymentResult · Transaction · TransactionItem · WebhookConfig
PricingFare · FareSet · Cost · Rule · Promotion · PromotionMethod · Simulation · Tax · TaxSet · TaxType
InvoiceInvoice · InvoiceConfigMapping · InvoiceOnboarding · InvoiceProvider · InvoiceProviderConfig · InvoiceRequest · InvoiceVnAddress · MerchantInvoiceProfile · TaxInfo
TaxationTaxGroup · TaxGroupItem · VnAdministrativeUnit · VnProvince · VnWard
LedgerLedger · MerchantLedgerConfig
LicensingLicense · Activation · Policy · PolicyFeature
IdentityUser · Role · Permission · PolicyDefinition · Employee · Customer ¹ · UserConfiguration · UserIdentifier
OutreachInquiry · Subscriber
SignalWebSocketClient

¹ Customer is referenced by both Sale and Identity - it stays one subject, rolled under Sale (decided), so a cashier's Sale:manage covers customer/loyalty operations.

4. Domain scopes in detail

A grant scoped to a parent domain applies to every child (broadest first):

Grant domainApplies inMembership needed?Used by
SYSTEM_WIDEeverywherenoSuper Admin / Admin / Operator
Organizer_<id>every merchant under that organizerno (cascades by the tree)Owner / HQ management
ANY_MEMBERevery merchant the user has joinedyes (membership)multi-merchant staff
Merchant_<id>that one merchant(member, typically)single-merchant staff
  • HQ sees all merchants → management roles are granted at Organizer scope; the domain tree cascades them to every merchant - replacing today's per-merchant membership backfill.

5. Role → grant matrix

Coarse grants - a few rows per role instead of hundreds. * = all modules. See the resolved per-operation view in the Permission Matrix.

RoleGrants (resource : action @ domain)
Super Admin / Admin / Operator* : manage @ SYSTEM_WIDE (enforcement-bypassed today)
Owner (500_organizer-owner)* : manage @ Organizer · except Permission & PolicyDefinition (deny - system-only)
Cashier (110_cashier)Sale : manage · Customer : manage · Commerce : read · Inventory : read · Finance : read · Payment : write · Invoice : execute - all @ ANY_MEMBER
Employee (100_employee)Sale : write · Customer : read · Commerce : read · Inventory : read - all @ ANY_MEMBER
Customerown-order reads only
GuestLicensing : read · InvoiceOnboarding : read @ SYSTEM_WIDE

Vn* reference data (provinces / wards) is readable by every role (public reference, no RBAC gate).

6. Data model: Permission vs PolicyDefinition

Everything lives in two tables. Permission is the resource catalog (it never shrinks - every endpoint has a row). PolicyDefinition holds grants, role/merchant assignments, and the hierarchy edges - this is where the hierarchy collapses hundreds of flat grants into a few.

Permission - the resource catalog

Columns: code (unique, <subject>.<method>) · subject · method · action · scope · parentId · name/description (i18n). Every route registers one row (via crudPermissions + custom defs). The redesign keeps all of them and adds the parent subject and module nodes so coarse grants have something to target:

codesubjectmethodactionkind
SaleSale--module node
SaleOrderSaleOrder--subject node
SaleOrder.findSaleOrderfindreadoperation
SaleOrder.countSaleOrdercountreadoperation
SaleOrder.createSaleOrdercreatecreateoperation
SaleOrder.updateByIdSaleOrderupdateByIdupdateoperation
SaleOrder.deleteByIdSaleOrderdeleteByIddeleteoperation
SaleOrder.refundSaleOrderrefundexecuteoperation

SaleOrder.find, SaleOrder.count, … always exist - they're how routes are guarded and how a fine-grained grant can target a single op. The redesign doesn't remove them; it just stops creating a grant per op.

Parents are not a single column. parentId is only an optional single-parent hint (and is null today). The module→subject relationship the enforcer actually uses is a resource_inherits (g4) edge in PolicyDefinition, and g4 is many-to-many - a subject can roll up to several modules. e.g. Customer can sit under both Sale and Identity by adding two edges, so a grant on either module covers it. (We chose to roll Customer under Sale for the default grants, but the model allows more.)

PolicyDefinition - grants, assignments, hierarchy

Columns: variant · subjectType/subjectId · targetType/targetId · action · effect · domain.

Before (flat) - Cashier got one grant per operation (the line explosion, now retired):

variantsubject (type:id)target (type:id)actioneffect
grantRole : cashierPermission : SaleOrder.findreadallow
grantRole : cashierPermission : SaleOrder.countreadallow
grantRole : cashierPermission : SaleOrder.createcreateallow
… (× every op × every subject)

Now (coarse, live) - Cashier gets one coarse grant per subject:

variantsubject (type:id)target (type:id)actioneffectdomain
grantRole : cashierPermission : SaleOrdermanageallownullANY_MEMBER

…plus the shared edges, declared once (not per role):

variantsubject → targetmeaning
action_inherits (g5)manageread/write · writecreate/update/deleteaction lattice
resource_inherits (g4)Sale → SaleOrder · Sale → Customermodule ⊃ subject - many-to-many capable; live data rolls each subject under exactly one module (CustomerSale)
domain_inherits (g3)Merchant_7Organizer_9HQ sees all branches
assign_roleUser_1 → Role cashierwho holds the role
join_domainUser_1Merchant_7merchant membership

Walkthrough - enforce(User_1, Merchant_7, "SaleOrder.find", read)

  1. assign_role: User_1 holds cashier
  2. cashier has grant SaleOrder : manage
  3. objectMatch("SaleOrder.find", "SaleOrder") ✓ - operation ⊂ subject is automatic (dotted prefix)
  4. g5: manage ⊃ read
  5. join_domain: User_1 ∈ Merchant_7 ✓
  6. ALLOW - with no SaleOrder.find grant row.

Operation ⊂ subject is free (dotted match). Module ⊃ subject (Sale ⊃ SaleOrder) is not automatic - it needs the resource_inherits edge. So the cheapest coarse grant sits at subject level (SaleOrder); grant at module level (Sale) only when you also add the g4 edge.

7. Worked examples

#RequestGrant heldDecisionWhy
1Cashier reads SaleOrder.refund in Merchant_7Sale : manage @ ANY_MEMBERmanage ⊃ read; SaleOrder.refund ⊂ Sale; member of Merchant_7
2Owner updates Product in Merchant_8* : manage @ Organizer_9manage ⊃ update; Product ⊂ *; Merchant_8 ⊂ Organizer_9
3Employee deletes a SaleOrderSale : write @ ANY_MEMBERwrite ⊃ delete; SaleOrder ⊂ Sale
4Employee deletes a ProductCommerce : read @ ANY_MEMBERread does not cover delete
5Cashier of org A acts in a merchant of org BSale : manage @ ANY_MEMBERnot a member of org B's merchant

8. Grantable resources API (role picker)

The Create / Edit Role screen needs the catalog as a tree with the tiers each row may be granted. This read turns the two tables (§6) into module → subject → permissions, each node carrying its actionable tiers (§2) - already capped to what you may grant.

GET /v1/api/identity/permissions/grantable-resources
Header  x-merchant-id: <merchantId>   ← required for non-bypass callers (Owner / Manager); 403 without it
QueryTypeDefaultEffect
qstring-case-insensitive substring over code (= subject.method), name, description - refund finds Payment.refund. A node is kept if it or a child op matches; with withPermissions, a node that matches by name shows all its ops, otherwise only the matching ops
modulesCSVallrestrict to module codes, e.g. Sale,Commerce
withPermissions'true'|'false'falsealso return the underlying operation Permission rows (the Advanced panel)

Two behaviours are always on, not switchable:

RuleMeaning
Scoped to your ceilingyou only see tiers you may actually grant - a bypass role (§1) sees everything; everyone else is capped to their own grants, so the picker can never offer a tier that 403s on save
System subjects hiddenPermission / PolicyDefinition never appear (the RBAC catalog is system-only - §1 ²)

Response - { data, count }, nested at every level; name is the full i18n object; permissions.data is populated only when withPermissions=true:

jsonc
{ "count": 13, "data": [ {
  "code": "Payment",
  "tiers": ["read","write","execute","manage"],          // segmented buttons to enable (+ None)
  "permissions": { "data": [ /* full Permission rows, e.g. Payment.refund */ ], "count": 5 },
  "subjects": { "data": [
    { "code": "Transaction", "tiers": ["read","write","manage"], "permissions": { "data": [], "count": 8 } }
  ], "count": 5 }
} ] }

tiers = the node's real operations (§2) your ceiling - execute appears only where an execute-op exists (e.g. Payment); a report subject shows [read, manage]. The catalog is small & bounded, so the full list is returned (no pagination) and q filters in-process.

Save the chosen tiers back through the grant facade (…/roles/{id}/targets/permissions) - coarse grants, exactly the matrix in §5. See PRD: Policy-Definition Grants.

Membership management is grantable (Merchant / Organizer targets)

Managing who is assigned to a Merchant / Organizer is a facade over PolicyDefinition, but it does not sit on the system-only PolicyDefinition subject - it lives on the Merchant / Organizer subjects (Commerce module), so it appears in this catalog and can be granted & delegated.

OperationSubjectTier (action)Does
findMerchantTargets · countMerchantTargetsMerchantreadlist who (users/roles) is assigned to a merchant
manageMerchantTargetsMerchantexecuteassign / unassign merchant membership
findOrganizerTargets · countOrganizerTargetsOrganizerreadlist organizer assignees
manageOrganizerTargetsOrganizerexecuteassign / unassign organizer membership
  • Owner holds these automatically - Commerce : manage (§5) covers Merchant/Organizer (they roll up to Commerce via g4).
  • Delegate: grant a custom role Merchant/Organizer at read (view membership) or execute/manage (edit) - that's why those subjects now expose an execute tier in the picker.
  • Raw PolicyDefinition stays fully system-only; only this membership facade is grantable.

Saving a role - flat op ids collapse to coarse

The picker may still submit a flat list of operation permissionIds (one per ticked leaf). The backend collapses them to the minimal coarse grant set before persisting - so a role never accumulates one grant per op (the line explosion §6 retired). The FE does not change: keep sending permissionIds.

StepWhat happens
groupselected ops → per subject
tier per subjecta single leaf → that tier (read/write/execute); ≥2 leaves → manage
module roll-upevery grantable code under a module selected at the same tier → one module : tier grant
persistset-replace reconcile against the role's current grants - add new, update changed tier, revoke removed, skip unchanged → no duplicate/redundant rows; capped to the caller's ceiling (no over-grant)
POST /roles · PATCH /roles/{id}   body: { …, permissionIds: [ …op ids… ] }   → stored coarse

e.g. 165 op ids (all Inventory + a few Commerce reads + Payment.refund + two SaleOrder ops) → 5 grants: Inventory:manage (roll-up), SaleOrder:manage, Payment:execute, Product:read, Category:read.

Preview - turn op ids into the coarse targets without saving (the FE can show "this will be saved as…"):

POST /v1/api/identity/permissions/resolve-grants   (Permission:read + x-merchant-id)
  body  { "permissionIds": [ … ] }
  →     { "data": [ { "id": "<node id>", "tier": "manage" }, … ], "count": n }

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