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
| Concept | What it is | Example | Stored as |
|---|---|---|---|
| Permission | a resource you protect (the obj) | Sale · SaleOrder · SaleOrder.refund | a row in the permission catalog |
| Action | a verb in the lattice (the act) | manage ⊃ write ⊃ read | g5 lattice edges - defined once, global |
| Grant | binds role → resource + action + domain | Sale : manage @ Organizer | a 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):
| Axis | Question | Matches when… |
|---|---|---|
| WHO | does the user hold the role? | role assigned (directly or inherited) |
| WHERE | is the active merchant in scope? | grant is system-wide, a joined merchant, or the merchant's organizer |
| WHAT | is the resource covered? | the code equals or sits under the grant's resource |
| HOW | is 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).
| Module | Owner | Cashier | Employee | Guest |
|---|---|---|---|---|
| Sale (orders, POS, kitchen, customers) | full | full | edit | - |
| Commerce (products, catalog, merchant setup) | full | view | view | - |
| Inventory (stock, materials, purchasing) | full | view | view | - |
| Payment (take/refund payments) | full | edit | - | - |
| Finance (accounts, transactions) | full | view | - | - |
| Invoice (e-invoice issuance) | full | run | - | 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) | full | view | view | view |
| 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/PolicyDefinitionare 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.
| Tier | Covers | Means | Typical grantee |
|---|---|---|---|
manage | everything below | "full control of this area" | Owner / Manager |
write | create · 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 verbs | fine-grained |
Route → base action (how a route declares what it needs):
| Operation kind | Base action |
|---|---|
find · findById · findOne · count | read |
create · createAggregate | create |
updateById · updateBy | update |
deleteById · deleteBy | delete |
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
g4edges - seeded per package (97 edges, one per subject; verified against the DB).
| Module | Subjects |
|---|---|
| Commerce | Merchant · Organizer · Product · ProductOption · ProductOptionValue · ProductVariant · ProductVariantOption · Category · SaleChannel · Device · Configuration · Setting · ReceiptTemplate · DiscriminationType · AllocationLayout · AllocationUnit · AllocationZone |
| Sale | SaleOrder · SaleOrderItem · SaleCheck · PosSession · KitchenStation · KitchenTicket · KitchenTicketItem · Reservation · PointTransaction · AllocationUsage · Customer ¹ · SalesReport · PurchaseReport |
| Inventory | InventoryItem · InventoryStock · InventoryLocation · InventoryIdentifier · InventoryTicket · InventoryTicketItem · InventoryTracking · Material · MaterialIdentifier · MaterialRecipe · ProductionOrder · PurchaseOrder · UnitOfMeasure · Vendor · VendorItem |
| Finance | FinanceAccount · FinanceCategory · FinanceTransaction · FinanceVoucher · PaymentIntegration |
| Payment | Payment · PaymentAttempt · PaymentResult · Transaction · TransactionItem · WebhookConfig |
| Pricing | Fare · FareSet · Cost · Rule · Promotion · PromotionMethod · Simulation · Tax · TaxSet · TaxType |
| Invoice | Invoice · InvoiceConfigMapping · InvoiceOnboarding · InvoiceProvider · InvoiceProviderConfig · InvoiceRequest · InvoiceVnAddress · MerchantInvoiceProfile · TaxInfo |
| Taxation | TaxGroup · TaxGroupItem · VnAdministrativeUnit · VnProvince · VnWard |
| Ledger | Ledger · MerchantLedgerConfig |
| Licensing | License · Activation · Policy · PolicyFeature |
| Identity | User · Role · Permission · PolicyDefinition · Employee · Customer ¹ · UserConfiguration · UserIdentifier |
| Outreach | Inquiry · Subscriber |
| Signal | WebSocketClient |
¹
Customeris referenced by both Sale and Identity - it stays one subject, rolled under Sale (decided), so a cashier'sSale:managecovers customer/loyalty operations.
4. Domain scopes in detail
A grant scoped to a parent domain applies to every child (broadest first):
| Grant domain | Applies in | Membership needed? | Used by |
|---|---|---|---|
SYSTEM_WIDE | everywhere | no | Super Admin / Admin / Operator |
Organizer_<id> | every merchant under that organizer | no (cascades by the tree) | Owner / HQ management |
ANY_MEMBER | every merchant the user has joined | yes (membership) | multi-merchant staff |
Merchant_<id> | that one merchant | (member, typically) | single-merchant staff |
- HQ sees all merchants → management roles are granted at
Organizerscope; 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.
| Role | Grants (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 |
| Customer | own-order reads only |
| Guest | Licensing : 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:
| code | subject | method | action | kind |
|---|---|---|---|---|
Sale | Sale | - | - | module node |
SaleOrder | SaleOrder | - | - | subject node |
SaleOrder.find | SaleOrder | find | read | operation |
SaleOrder.count | SaleOrder | count | read | operation |
SaleOrder.create | SaleOrder | create | create | operation |
SaleOrder.updateById | SaleOrder | updateById | update | operation |
SaleOrder.deleteById | SaleOrder | deleteById | delete | operation |
SaleOrder.refund | SaleOrder | refund | execute | operation |
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.
parentIdis only an optional single-parent hint (and isnulltoday). The module→subject relationship the enforcer actually uses is aresource_inherits(g4) edge inPolicyDefinition, andg4is many-to-many - a subject can roll up to several modules. e.g.Customercan sit under bothSaleandIdentityby adding two edges, so a grant on either module covers it. (We chose to rollCustomerunderSalefor 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):
| variant | subject (type:id) | target (type:id) | action | effect |
|---|---|---|---|---|
grant | Role : cashier | Permission : SaleOrder.find | read | allow |
grant | Role : cashier | Permission : SaleOrder.count | read | allow |
grant | Role : cashier | Permission : SaleOrder.create | create | allow |
| … | … | … (× every op × every subject) | … | … |
Now (coarse, live) - Cashier gets one coarse grant per subject:
| variant | subject (type:id) | target (type:id) | action | effect | domain |
|---|---|---|---|---|---|
grant | Role : cashier | Permission : SaleOrder | manage | allow | null → ANY_MEMBER |
…plus the shared edges, declared once (not per role):
| variant | subject → target | meaning |
|---|---|---|
action_inherits (g5) | manage→read/write · write→create/update/delete | action lattice |
resource_inherits (g4) | Sale → SaleOrder · Sale → Customer | module ⊃ subject - many-to-many capable; live data rolls each subject under exactly one module (Customer → Sale) |
domain_inherits (g3) | Merchant_7 → Organizer_9 | HQ sees all branches |
assign_role | User_1 → Role cashier | who holds the role |
join_domain | User_1 → Merchant_7 | merchant membership |
Walkthrough - enforce(User_1, Merchant_7, "SaleOrder.find", read)
assign_role: User_1 holds cashier ✓- cashier has grant
SaleOrder : manage✓ objectMatch("SaleOrder.find", "SaleOrder")✓ - operation ⊂ subject is automatic (dotted prefix)g5:manage ⊃ read✓join_domain: User_1 ∈ Merchant_7 ✓- → ALLOW - with no
SaleOrder.findgrant row.
Operation ⊂ subject is free (dotted match). Module ⊃ subject (
Sale ⊃ SaleOrder) is not automatic - it needs theresource_inheritsedge. So the cheapest coarse grant sits at subject level (SaleOrder); grant at module level (Sale) only when you also add theg4edge.
7. Worked examples
| # | Request | Grant held | Decision | Why |
|---|---|---|---|---|
| 1 | Cashier reads SaleOrder.refund in Merchant_7 | Sale : manage @ ANY_MEMBER | ✅ | manage ⊃ read; SaleOrder.refund ⊂ Sale; member of Merchant_7 |
| 2 | Owner updates Product in Merchant_8 | * : manage @ Organizer_9 | ✅ | manage ⊃ update; Product ⊂ *; Merchant_8 ⊂ Organizer_9 |
| 3 | Employee deletes a SaleOrder | Sale : write @ ANY_MEMBER | ✅ | write ⊃ delete; SaleOrder ⊂ Sale |
| 4 | Employee deletes a Product | Commerce : read @ ANY_MEMBER | ❌ | read does not cover delete |
| 5 | Cashier of org A acts in a merchant of org B | Sale : manage @ ANY_MEMBER | ❌ | not 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| Query | Type | Default | Effect |
|---|---|---|---|
q | string | - | 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 |
modules | CSV | all | restrict to module codes, e.g. Sale,Commerce |
withPermissions | 'true'|'false' | false | also return the underlying operation Permission rows (the Advanced panel) |
Two behaviours are always on, not switchable:
| Rule | Meaning |
|---|---|
| Scoped to your ceiling | you 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 hidden | Permission / 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:
{ "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 -executeappears 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) andqfilters 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.
| Operation | Subject | Tier (action) | Does |
|---|---|---|---|
findMerchantTargets · countMerchantTargets | Merchant | read | list who (users/roles) is assigned to a merchant |
manageMerchantTargets | Merchant | execute | assign / unassign merchant membership |
findOrganizerTargets · countOrganizerTargets | Organizer | read | list organizer assignees |
manageOrganizerTargets | Organizer | execute | assign / unassign organizer membership |
- Owner holds these automatically -
Commerce : manage(§5) coversMerchant/Organizer(they roll up to Commerce viag4). - Delegate: grant a custom role
Merchant/Organizeratread(view membership) orexecute/manage(edit) - that's why those subjects now expose anexecutetier in the picker. - Raw
PolicyDefinitionstays 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.
| Step | What happens |
|---|---|
| group | selected ops → per subject |
| tier per subject | a single leaf → that tier (read/write/execute); ≥2 leaves → manage |
| module roll-up | every grantable code under a module selected at the same tier → one module : tier grant |
| persist | set-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 coarsee.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 }Related Pages
- Permission Matrix - every permission (with description) + the effective-access matrix per role (generated)
- URD: Permissions - the
HIERfeature requirements & acceptance - PRD: Resource, Action & Domain Hierarchy
- Developer: RBAC · Casbin Authorization