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

#Edgevariantsubject → targetdomain meaning
1User → RolegroupUserRolescope the role to a merchant
2User → MerchantgroupUserMerchant(membership; domain unused)
3Role → PermissionpolicyRolePermissionignored (always emitted as *)
4User → PermissionpolicyUserPermissionscope the direct grant to a merchant

domain holds a raw merchant id, NULL, or * (the adapter wraps a merchant id into Merchant_<id> and leaves * raw). Edges Role → Merchant / Role → Organizer are not read by the adapter — do not use them to scope.

Fixtures used below

Symbolic ids (replace with the real UUID/snowflake values):

TokenMeaning
Ua user id
MA, MB, MCmerchant ids
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). effect defaults to allow.


Case 1 — Grant a permission to a role (manage by Role)

Goal: role R_OWNER can Product.find. Domain is irrelevant for role grants.

variantsubject_typesubject_idtarget_typetarget_iddomainactioneffect
policyRoleR_OWNERPermissionP_FINDNULLreadallow

Emitted (only when a user actually holds R_OWNER in ≥1 domain):

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

One row per (role, permission). To revoke, soft-delete this row.


Case 2 — Make a user a member of a merchant

Goal: U belongs to merchant MA. Needed for NULL-domain role projection (Case 4) and for data-scoping queries.

variantsubject_typesubject_idtarget_typetarget_iddomainactioneffect
groupUserUMerchantMANULLNULLNULL

Feeds merchantIds = [MA]. Emits no line by itself; combines with Case 4.


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_iddomainactioneffect
groupUserURoleR_OWNERMANULLNULL

Emitted (assuming Case 1 grant exists):

g, User_U, Role_R_OWNER, Merchant_MA
p, Role_R_OWNER, *, 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 NULL-domain role assignment:

variantsubject_typesubject_idtarget_typetarget_iddomain
groupUserUMerchantMANULL
groupUserUMerchantMBNULL
groupUserURoleR_OWNERNULL

Emitted:

g, User_U, Role_R_OWNER, Merchant_MA
g, User_U, Role_R_OWNER, Merchant_MB
p, Role_R_OWNER, *, Product.find, read, allow

ALLOW in MA & MB; DENY in MC. (NULL role domain = "project onto the user's merchant memberships".)


Case 5 — Global role (guest), pre-merchant

Goal: U can onboard before having any merchant. Guest is a global role (GLOBAL_ROLE_IDENTIFIERS), so the adapter forces domain * regardless of the assignment's domain (NULL is fine).

variantsubject_typesubject_idtarget_typetarget_iddomain
groupUserURoleR_GUESTNULL
policyRoleR_GUESTPermission(Organizer.onBoarding)NULL

Emitted:

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

ALLOW for Organizer.onBoarding in ANY domain — including the pre-merchant placeholder Merchant_00000000-0000-0000-0000-000000000000.


Case 6 — Direct permission to a user (bypass roles)

6a. Scoped to one merchant:

variantsubject_typesubject_idtarget_typetarget_iddomainactioneffect
policyUserUPermissionP_FINDMAreadallow
p, User_U, Merchant_MA, Product.find, read, allow — ALLOW in MA only (matches via the
reflexive g(User_U, User_U, dom) + exact keyMatch).

6b. Across all the user's merchants: domain = NULL + memberships (Case 2) → one p, User_U, Merchant_<m>, … per membership.

6c. Global direct grant: domain = *p, User_U, *, Product.find, read, allow — ALLOW in any domain (use sparingly).


Case 7 — Explicit DENY (override an allow)

Goal: even though R_OWNER allows Product.deleteById, deny it for user U.

variantsubject_typesubject_idtarget_typetarget_iddomainactioneffect
policyUserUPermissionP_DELETEMAdeletedeny
p, User_U, Merchant_MA, Product.deleteById, delete, deny. The policy effect
some(allow) && !some(deny) means any matching deny wins → DENY in MA.

Pitfalls & non-happy cases

SituationRow(s)Result
NULL role domain, NO membershipCase 4 role row but no Case 2 rowsrole resolves to 0 domains → no g-line → no access. Common bug.
* on a non-global role (owner/employee)group User→Role domain=*role matches every merchant system-wide → tenant isolation broken. Never do this.
domain on a Role→Permission rowpolicy Role→Permission domain=MAdomain ignored — emitted as *. Does NOT scope. Store NULL.
Role→Merchant / Role→Organizer scope edgegroup Role→Merchantnot read by the adapter → zero effect on enforcement.
Direct perm NULL domain, no membershipCase 6 with domain NULL, no Case 2direct grant projects onto nothing → no effect.
action mismatchgrant action=read for a *.deleteById (delete) permissionmatcher needs r.act == p.act → never matches → effectively no grant. Set action = the permission's action.
Soft-deleted grantdeleted_at setexcluded (deletedAt IS NULL / INNER JOIN).
Soft-deleted Role or Permissionthe row points to a deleted role/permINNER JOIN drops the edge → grant disappears.
Duplicate rows (same domain)two identical assignmentsde-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 permissions.

Quick INSERT (psql) examples

sql
-- Case 1: grant Product.find to owner role
INSERT INTO identity."PolicyDefinition"
  (id, variant, subject_type, subject_id, target_type, target_id, action, effect, domain)
VALUES (gen_random_uuid()::text, 'policy', '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, 'group', 'User', '<U>', 'Merchant', '<MA>', NULL),
  (gen_random_uuid()::text, 'group', '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.