PolicyDefinition cookbook - storing grants by Role / Merchant / direct Permission
Concrete PolicyDefinition row samples for every case (not just the happy path), with the Casbin lines the ScopedCasbinAdapter emits and the resulting enforce decision. For the runtime model see Casbin Authorization.
What the enforcer reads
PolicyDefinition columns that matter: variant, subject_type, subject_id, target_type, target_id, domain, action, effect, deleted_at.
| Variant | subject → target | domain meaning |
|---|---|---|
grant | Role|User → Permission | grant scope: SYSTEM_WIDE / ANY_MEMBER / <Type>_<id> (null ⇒ ANY_MEMBER) |
assign_role | User → Role | scope the role to a domain (null ⇒ *, every domain) |
join_domain | User → Merchant|Organizer | membership; powers ANY_MEMBER grants (no domain column) |
role_inherits | Role → Role | shared g, domain * |
domain_inherits | domain → domain | g3 nesting (Merchant ⊂ Organizer) |
resource_inherits | resource → resource | g4 (obj nesting) |
action_inherits | action → action | g5 (action lattice) |
merchant_role | Role ↔ Merchant | not read by the adapter (@nx/core UI metadata) |
A grant's domain is one of the three scope forms. The adapter wraps a typed domain into <Type>_<id> and passes the scope literals (SYSTEM_WIDE / ANY_MEMBER) through raw.
Fixtures used below
Symbolic ids (replace with the real id values):
| Token | Meaning |
|---|---|
U | a user id |
MA, MB, MC | merchant ids |
O | an organizer id |
R_OWNER, R_EMP, R_GUEST | role ids (500_organizer-owner, 100_employee, 001_guest) |
P_FIND, P_DELETE | permission ids (Product.find read / Product.deleteById delete) |
action on a grant must equal the permission's own action (Product.find → read, Product.deleteById → delete), unless an action_inherits (g5) edge bridges them. effect defaults to allow.
Case 1 - Grant a permission to a role (manage by Role)
Goal: role R_OWNER can Product.find. A null domain defaults to ANY_MEMBER.
| variant | subject_type | subject_id | target_type | target_id | domain | action | effect |
|---|---|---|---|---|---|---|---|
| grant | Role | R_OWNER | Permission | P_FIND | NULL ⇒ ANY_MEMBER | read | allow |
Emitted (only when a user actually holds R_OWNER):
p, Role_R_OWNER, ANY_MEMBER, Product.find, read, allowMatches only in domains the user joined (g2). To revoke, soft-delete this row.
Case 2 - Make a user a member of a merchant
Goal: U belongs to merchant MA. Needed for ANY_MEMBER grants (Case 1/4) and for data-scoping queries.
| variant | subject_type | subject_id | target_type | target_id | domain |
|---|---|---|---|---|---|
| join_domain | User | U | Merchant | MA | - |
Emitted:
g2, User_U, Merchant_MAFeeds merchantIds = [MA] and satisfies the ANY_MEMBER clause g2(r.sub, r.dom) in MA.
Case 3 - Assign a role to a user, scoped to ONE merchant (manage by Merchant)
Goal: U is owner only in MA.
| variant | subject_type | subject_id | target_type | target_id | domain |
|---|---|---|---|---|---|
| assign_role | User | U | Role | R_OWNER | MA |
Emitted (with the Case 1 grant + Case 2 membership):
g2, User_U, Merchant_MA
g, User_U, Role_R_OWNER, Merchant_MA
p, Role_R_OWNER, ANY_MEMBER, Product.find, read, allow- enforce
(User_U, Merchant_MA, Product.find, read)→ ALLOW - enforce
(User_U, Merchant_MB, Product.find, read)→ DENY (nogline inMB)
Case 4 - Assign a role across ALL the user's merchants
Goal: U is owner in every merchant they belong to.
Rows: memberships (Case 2) for each merchant + ONE *-domain role assignment:
| variant | subject_type | subject_id | target_type | target_id | domain |
|---|---|---|---|---|---|
| join_domain | User | U | Merchant | MA | - |
| join_domain | User | U | Merchant | MB | - |
| assign_role | User | U | Role | R_OWNER | NULL ⇒ * |
Emitted:
g2, User_U, Merchant_MA
g2, User_U, Merchant_MB
g, User_U, Role_R_OWNER, *
p, Role_R_OWNER, ANY_MEMBER, Product.find, read, allowALLOW in MA & MB; DENY in MC. (The * role domain matches anywhere; the ANY_MEMBER grant then narrows it to the user's actual memberships.)
Case 5 - Organizer-scoped grant cascading to its merchants (domain nesting)
Goal: U is owner across organizer O's merchants without enumerating them, via a <Type>_<id> grant + domain_inherits.
| variant | subject_type | subject_id | target_type | target_id | domain |
|---|---|---|---|---|---|
| domain_inherits | Merchant | MA | Organizer | O | - |
| domain_inherits | Merchant | MB | Organizer | O | - |
| assign_role | User | U | Role | R_OWNER | NULL ⇒ * |
| grant | Role | R_OWNER | Permission | P_FIND | O |
Emitted:
g3, Merchant_MA, Organizer_O
g3, Merchant_MB, Organizer_O
g, User_U, Role_R_OWNER, *
p, Role_R_OWNER, Organizer_O, Product.find, read, allowALLOW in MA & MB (both nested under O, so g3(r.dom, Organizer_O) holds); DENY in MC (not nested under O). No join_domain membership is needed because the grant scope is a concrete <Type>_<id>, not ANY_MEMBER.
Case 6 - Global role (guest), pre-merchant
Goal: U can onboard before having any merchant. The guest grant uses SYSTEM_WIDE, so it matches every domain regardless of membership (the assignment's domain may be NULL).
| variant | subject_type | subject_id | target_type | target_id | domain | action |
|---|---|---|---|---|---|---|
| assign_role | User | U | Role | R_GUEST | NULL ⇒ * | - |
| grant | Role | R_GUEST | Permission | (Organizer.onBoarding) | SYSTEM_WIDE | create |
Emitted:
g, User_U, Role_R_GUEST, *
p, Role_R_GUEST, SYSTEM_WIDE, Organizer.onBoarding, create, allowALLOW for Organizer.onBoarding in ANY domain - including the pre-merchant placeholder Merchant_00000000-0000-0000-0000-000000000000.
Case 7 - Direct permission to a user (bypass roles)
A grant whose subject is a User (not a Role). The matcher's reflexive g(User_U, User_U, dom) self-link makes the direct grant match.
7a. Scoped to one merchant (<Type>_<id> scope):
| variant | subject_type | subject_id | target_type | target_id | domain | action | effect |
|---|---|---|---|---|---|---|---|
| grant | User | U | Permission | P_FIND | MA | read | allow |
→ p, User_U, Merchant_MA, Product.find, read, allow - ALLOW in MA only (via g3 self-link | |||||||
g3(Merchant_MA, Merchant_MA)). |
7b. Across all the user's merchants: domain = NULL ⇒ ANY_MEMBER + memberships (Case 2) → p, User_U, ANY_MEMBER, Product.find, read, allow - ALLOW in every joined merchant.
7c. System-wide direct grant: domain = SYSTEM_WIDE → p, User_U, SYSTEM_WIDE, Product.find, read, allow
- ALLOW in any domain (use sparingly).
Case 8 - Explicit DENY (override an allow)
Goal: even though R_OWNER allows Product.deleteById, deny it for user U.
| variant | subject_type | subject_id | target_type | target_id | domain | action | effect |
|---|---|---|---|---|---|---|---|
| grant | User | U | Permission | P_DELETE | MA | delete | deny |
→ p, User_U, Merchant_MA, Product.deleteById, delete, deny. The default-DENY effector | |||||||
some(allow) && !some(deny) means any matching deny wins → DENY in MA. |
Pitfalls & non-happy cases
| Situation | Row(s) | Result |
|---|---|---|
ANY_MEMBER grant, NO membership | Case 1 grant but no Case 2 join_domain | grant resolves to no g2 line → no access. Common bug. |
SYSTEM_WIDE on a tenant role (owner/employee) | grant … domain=SYSTEM_WIDE | matches every domain system-wide → tenant isolation broken. Use ANY_MEMBER for tenant roles. |
merchant_role used to scope enforcement | merchant_role Role↔Merchant | not read by the adapter → zero effect on enforcement (UI metadata only). |
action mismatch | grant action=read for a *.deleteById (delete) permission, no g5 edge | matcher needs g5(r.act, p.act) → never matches → no grant. Set action = the permission's action (or add an action_inherits edge). |
| Soft-deleted grant | deleted_at set | excluded (deleted_at filter). |
| Soft-deleted Role or Permission | the row points to a deleted role/perm | excluded → grant disappears. |
| Duplicate rows | two identical edges | de-duplicated in-memory → harmless. |
| super-admin / admin / operator | (none needed) | always-allow bypass - any PolicyDefinition for them is ignored at enforce time. |
| customer | (none) | no backend grants. |
Quick INSERT (psql) examples
-- Case 1: grant Product.find to owner role (ANY_MEMBER ⇒ domain NULL)
INSERT INTO identity."PolicyDefinition"
(id, variant, subject_type, subject_id, target_type, target_id, action, effect, domain)
VALUES (gen_random_uuid()::text, 'grant', 'Role', '<R_OWNER>', 'Permission', '<P_FIND>', 'read', 'allow', NULL);
-- Case 3: assign owner to user U scoped to merchant MA (+ membership)
INSERT INTO identity."PolicyDefinition"
(id, variant, subject_type, subject_id, target_type, target_id, domain)
VALUES
(gen_random_uuid()::text, 'join_domain', 'User', '<U>', 'Merchant', '<MA>', NULL),
(gen_random_uuid()::text, 'assign_role', 'User', '<U>', 'Role', '<R_OWNER>', '<MA>');Prefer the policy-definition service/API over raw SQL in app flows - these INSERTs are for understanding/debugging.
See also
- Casbin Authorization - the model + adapter
- RBAC & Policy Definitions - roles, API, business rules
- Permission Matrix - current grants per role