Skip to content

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.

Variantsubject → targetdomain meaning
grantRole|User → Permissiongrant scope: SYSTEM_WIDE / ANY_MEMBER / <Type>_<id> (null ⇒ ANY_MEMBER)
assign_roleUser → Rolescope the role to a domain (null ⇒ *, every domain)
join_domainUser → Merchant|Organizermembership; powers ANY_MEMBER grants (no domain column)
role_inheritsRole → Roleshared g, domain *
domain_inheritsdomain → domaing3 nesting (Merchant ⊂ Organizer)
resource_inheritsresource → resourceg4 (obj nesting)
action_inheritsaction → actiong5 (action lattice)
merchant_roleRole ↔ Merchantnot 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):

TokenMeaning
Ua user id
MA, MB, MCmerchant ids
Oan organizer id
R_OWNER, R_EMP, R_GUESTrole ids (500_organizer-owner, 100_employee, 001_guest)
P_FIND, P_DELETEpermission ids (Product.find read / Product.deleteById delete)

action on a grant must equal the permission's own action (Product.findread, Product.deleteByIddelete), 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.

variantsubject_typesubject_idtarget_typetarget_iddomainactioneffect
grantRoleR_OWNERPermissionP_FINDNULLANY_MEMBERreadallow

Emitted (only when a user actually holds R_OWNER):

p, Role_R_OWNER, ANY_MEMBER, Product.find, read, allow

Matches 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.

variantsubject_typesubject_idtarget_typetarget_iddomain
join_domainUserUMerchantMA-

Emitted:

g2, User_U, Merchant_MA

Feeds 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.

variantsubject_typesubject_idtarget_typetarget_iddomain
assign_roleUserURoleR_OWNERMA

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 (no g line in MB)

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:

variantsubject_typesubject_idtarget_typetarget_iddomain
join_domainUserUMerchantMA-
join_domainUserUMerchantMB-
assign_roleUserURoleR_OWNERNULL*

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, allow

ALLOW 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.

variantsubject_typesubject_idtarget_typetarget_iddomain
domain_inheritsMerchantMAOrganizerO-
domain_inheritsMerchantMBOrganizerO-
assign_roleUserURoleR_OWNERNULL*
grantRoleR_OWNERPermissionP_FINDO

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, allow

ALLOW 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).

variantsubject_typesubject_idtarget_typetarget_iddomainaction
assign_roleUserURoleR_GUESTNULL*-
grantRoleR_GUESTPermission(Organizer.onBoarding)SYSTEM_WIDEcreate

Emitted:

g, User_U, Role_R_GUEST, *
p, Role_R_GUEST, SYSTEM_WIDE, Organizer.onBoarding, create, allow

ALLOW 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):

variantsubject_typesubject_idtarget_typetarget_iddomainactioneffect
grantUserUPermissionP_FINDMAreadallow
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 = NULLANY_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_WIDEp, 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.

variantsubject_typesubject_idtarget_typetarget_iddomainactioneffect
grantUserUPermissionP_DELETEMAdeletedeny
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

SituationRow(s)Result
ANY_MEMBER grant, NO membershipCase 1 grant but no Case 2 join_domaingrant resolves to no g2 line → no access. Common bug.
SYSTEM_WIDE on a tenant role (owner/employee)grant … domain=SYSTEM_WIDEmatches every domain system-wide → tenant isolation broken. Use ANY_MEMBER for tenant roles.
merchant_role used to scope enforcementmerchant_role Role↔Merchantnot read by the adapter → zero effect on enforcement (UI metadata only).
action mismatchgrant action=read for a *.deleteById (delete) permission, no g5 edgematcher needs g5(r.act, p.act) → never matches → no grant. Set action = the permission's action (or add an action_inherits edge).
Soft-deleted grantdeleted_at setexcluded (deleted_at filter).
Soft-deleted Role or Permissionthe row points to a deleted role/permexcluded → grant disappears.
Duplicate rowstwo identical edgesde-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

sql
-- 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

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