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.
| # | Edge | variant | subject → target | domain meaning |
|---|---|---|---|---|
| 1 | User → Role | group | User → Role | scope the role to a merchant |
| 2 | User → Merchant | group | User → Merchant | (membership; domain unused) |
| 3 | Role → Permission | policy | Role → Permission | ignored (always emitted as *) |
| 4 | User → Permission | policy | User → Permission | scope 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):
| Token | Meaning |
|---|---|
U | a user id |
MA, MB, MC | merchant ids |
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). 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.
| variant | subject_type | subject_id | target_type | target_id | domain | action | effect |
|---|---|---|---|---|---|---|---|
| policy | Role | R_OWNER | Permission | P_FIND | NULL | read | allow |
Emitted (only when a user actually holds R_OWNER in ≥1 domain):
p, Role_R_OWNER, *, Product.find, read, allowOne 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.
| variant | subject_type | subject_id | target_type | target_id | domain | action | effect |
|---|---|---|---|---|---|---|---|
| group | User | U | Merchant | MA | NULL | NULL | NULL |
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.
| variant | subject_type | subject_id | target_type | target_id | domain | action | effect |
|---|---|---|---|---|---|---|---|
| group | User | U | Role | R_OWNER | MA | NULL | NULL |
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:
| variant | subject_type | subject_id | target_type | target_id | domain |
|---|---|---|---|---|---|
| group | User | U | Merchant | MA | NULL |
| group | User | U | Merchant | MB | NULL |
| group | User | U | Role | R_OWNER | NULL |
Emitted:
g, User_U, Role_R_OWNER, Merchant_MA
g, User_U, Role_R_OWNER, Merchant_MB
p, Role_R_OWNER, *, Product.find, read, allowALLOW 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).
| variant | subject_type | subject_id | target_type | target_id | domain |
|---|---|---|---|---|---|
| group | User | U | Role | R_GUEST | NULL |
| policy | Role | R_GUEST | Permission | (Organizer.onBoarding) | NULL |
Emitted:
g, User_U, Role_R_GUEST, *
p, Role_R_GUEST, *, Organizer.onBoarding, create, allowALLOW 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:
| variant | subject_type | subject_id | target_type | target_id | domain | action | effect |
|---|---|---|---|---|---|---|---|
| policy | User | U | Permission | P_FIND | MA | read | allow |
→ 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.
| variant | subject_type | subject_id | target_type | target_id | domain | action | effect |
|---|---|---|---|---|---|---|---|
| policy | User | U | Permission | P_DELETE | MA | delete | deny |
→ 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
| Situation | Row(s) | Result |
|---|---|---|
| NULL role domain, NO membership | Case 4 role row but no Case 2 rows | role 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 row | policy Role→Permission domain=MA | domain ignored — emitted as *. Does NOT scope. Store NULL. |
| Role→Merchant / Role→Organizer scope edge | group Role→Merchant | not read by the adapter → zero effect on enforcement. |
| Direct perm NULL domain, no membership | Case 6 with domain NULL, no Case 2 | direct grant projects onto nothing → no effect. |
action mismatch | grant action=read for a *.deleteById (delete) permission | matcher needs r.act == p.act → never matches → effectively no grant. Set action = the permission's action. |
| Soft-deleted grant | deleted_at set | excluded (deletedAt IS NULL / INNER JOIN). |
| Soft-deleted Role or Permission | the row points to a deleted role/perm | INNER JOIN drops the edge → grant disappears. |
| Duplicate rows (same domain) | two identical assignments | 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 permissions. |
Quick INSERT (psql) examples
-- 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
- Casbin Authorization — the model + adapter
- RBAC & Policy Definitions — roles, API, business rules
- Permission Matrix — current grants per role