Casbin Authorization (runtime deep-dive) v1.1.0
How a request is authorized at runtime: the scoped-RBAC Casbin model, the per-request filtered adapter, the request → decision flow, role bypass, and enforcer caching.
Scope. This page documents the implementation. For the policy data model (PolicyDefinition, roles, API) see RBAC & Policy Definitions; for the decision record see ADR-0002; for the current per-role grant snapshot see the Permission Matrix.
Pieces involved
| Piece | Where | Role |
|---|---|---|
| Casbin model | @venizia/ignis .../enforcers/models/rbac-domain.model.ts | CASBIN_RBAC_DOMAIN_SCOPED_MODEL - request/policy/matcher definition |
| Policy adapter | @venizia/ignis ScopedCasbinAdapter | Filtered: loads ONE principal's PolicyDefinition edges per request |
| Adapter entities | @nx/core application/base.ts (getScopedCasbinEntities) | domainTypes = ['Merchant', 'Organizer'], soft-delete on deleted_at |
| Active-merchant resolver | @nx/core application/base.ts (getMerchantScopedDomainResolver) | Reads x-merchant-id → request domain Merchant_<id> |
| Enforcer wiring | @nx/core application/{verifier,issuer}.ts | alwaysAllowRoles, domainResolver, adapter + cached |
| Enforcer (framework) | @venizia/ignis CasbinAuthorizationEnforcer | Runs enforce() per request, caches the per-user line set |
Identity is the JWKS issuer (IssuerApplication); every other service is a verifier (VerifierApplication). Both wire the same model + ScopedCasbinAdapter.
The model
CASBIN_RBAC_DOMAIN_SCOPED_MODEL (@venizia/ignis .../enforcers/models/rbac-domain.model.ts):
[request_definition]
r = sub, dom, obj, act
[policy_definition]
p = sub, dom, obj, act, eft
[role_definition]
g = _, _, _
g2 = _, _
g3 = _, _
g4 = _, _
g5 = _, _
[policy_effect]
e = some(where (p.eft == allow)) && !some(where (p.eft == deny))
[matchers]
m = g(r.sub, p.sub, r.dom) && (p.dom == "SYSTEM_WIDE" || (p.dom == "ANY_MEMBER" && g2(r.sub, r.dom)) || g3(r.dom, p.dom)) && (objectMatch(r.obj, p.obj) || g4(r.obj, p.obj)) && g5(r.act, p.act)| Relation | Casbin | Edge variant | Meaning |
|---|---|---|---|
g | _, _, _ | assign_role + role_inherits | User→Role / Role→Role, domain-aware (the sub axis) |
g2 | _, _ | join_domain | User is a member of a domain (the dom axis) |
g3 | _, _ | domain_inherits | domain nesting, e.g. Merchant ⊂ Organizer |
g4 | _, _ | resource_inherits | resource(obj) nesting, e.g. OrderItem ⊂ Order |
g5 | _, _ | action_inherits | action lattice: manage ⊃ read/write/execute |
| Token | Meaning |
|---|---|
sub | Subject - User_<id> or Role_<id> |
dom | Request domain - Merchant_<id> (from x-merchant-id) or none |
obj | Object - the permission/resource code, e.g. commerce.product.create |
act | Action - read / write / execute / manage / create / update / delete |
eft | Effect - allow (default) / deny |
Effect is default-DENY (casbin's allow-and-deny): a request needs a matching allowand no matching deny, so an explicit deny overrides any allow.
A grant's domain scope
The dom on a p (grant) line decides where the grant applies. It is one of three forms, all handled by the domain clause (p.dom == "SYSTEM_WIDE" || (p.dom == "ANY_MEMBER" && g2(...)) || g3(...)):
| Scope | Matches | Used by |
|---|---|---|
SYSTEM_WIDE | every domain, bypassing membership | guest onboarding |
ANY_MEMBER | every domain the subject joined via g2 (join_domain) | owner / cashier / employee (tenant roles) |
<Type>_<id> | that domain, plus any g3-nested child (domain_inherits) | a grant pinned to one merchant or one organizer |
⚠️
SYSTEM_WIDEandANY_MEMBERare not the same.SYSTEM_WIDEis truly merchant-agnostic - use it only for the global guest tier.ANY_MEMBERis tenant-scoped: it applies only where the user actually holds ajoin_domainmembership. Putting a tenant role onSYSTEM_WIDEwould break isolation, which is why it is an explicit, code-defined choice (seed-role-grants.ts), never a default.
Request → decision flow
alwaysAllowRoles(super-admin999,admin900,operator600) bypass enforcement entirely - configured inverifier.ts/issuer.ts. They never hit the domain logic.domainResolver(getMerchantScopedDomainResolver) reads thex-merchant-idheader and returns{ type: 'Merchant', id }, which becomes the request domainMerchant_<id>; absent header →null.
The x-merchant-id header
Every authenticated request to a verifier service should carry x-merchant-id - it selects the active merchant domain the request is enforced against. Without it, the request has no merchant domain, so only SYSTEM_WIDE grants can match.
| Aspect | Contract |
|---|---|
| Format | A merchant id, e.g. d01b061a-46a8-4f35-9954-747efade2f3f. |
| Who sends it | The client (web/mobile) sets it from the currently-selected merchant; the API gateway forwards it unchanged. |
| CORS | Must be in the Access-Control-Allow-Headers allow-list, or browsers strip it. |
| Pre-merchant placeholder | Before a merchant is selected the client sends SYSTEM_MERCHANT_ID (00000000-0000-0000-0000-000000000000). This matches no real merchant domain - only SYSTEM_WIDE grants apply, which is why onboarding/reference routes need the guest role. |
| Bypass roles | super-admin / admin / operator ignore the header - enforcement is skipped. |
A wrong or foreign merchant id yields no matching grants → 403 (this is the isolation guarantee, not a bug).
PolicyDefinition → Casbin lines
PolicyDefinition is the single edge table. The ScopedCasbinAdapter filters a principal's rows and emits the matching Casbin line.
| Variant | Subject → Target | Casbin line |
|---|---|---|
grant | Role|User → Permission | p, <Role|User>_<id>, <SYSTEM_WIDE|ANY_MEMBER|Type_id>, <objectCode>, <action>, <allow|deny> |
assign_role | User → Role | g, User_<id>, Role_<id>, <domain|*> (null ⇒ *) |
join_domain | User → Merchant|Organizer | g2, User_<id>, <Type>_<domainId> |
role_inherits | Role → Role | g, Role_<child>, Role_<parent>, * |
domain_inherits | domain → domain | g3, <Type>_<childId>, <Type>_<parentId> |
resource_inherits | resource → resource | g4, <childCode>, <parentCode> |
action_inherits | action → action | g5, <childAction>, <parentAction> |
merchant_role | Role ↔ Merchant | not read - @nx/core UI metadata only |
The adapter
ScopedCasbinAdapter.loadFilteredPolicy(model, filter) is Casbin's filtered-load entry point: it builds the full line set for one principal and hands it to the enforcer. The enforcer caches that line set per user in Redis, so this only runs on a cache MISS.
It loads, in one parallel wave:
- Per-user edges (vary per principal):
grant,assign_role,join_domain. Thejoin_domainrows are restricted to the configureddomainTypes(Merchant,Organizer) and becomeg2lines, e.g.g2, User_u1, Merchant_7. - Global structural edges (shared by all principals):
role_inherits(g),domain_inherits(g3),resource_inherits(g4),action_inherits(g5).
Defaults when emitting a grant: a null effect becomes allow; a null domain becomes ANY_MEMBER. A null assign_role domain becomes * (every domain). Soft-deleted rows are excluded (deleted_at filter).
Example emitted lines
Owner (manage on the commerce module), member of merchant A:
g, User_u1, Role_owner, *
g2, User_u1, Merchant_A
p, Role_owner, ANY_MEMBER, commerce, manage, allowGlobal structural edges (seeded once, shared by all principals):
g4, commerce.product, commerce (resource_inherits)
g5, read, manage (action_inherits)→ request (User_u1, Merchant_A, commerce.product, read) = allow (member of A satisfies ANY_MEMBER; read ⊂ manage; commerce.product ⊂ commerce); request (User_u1, Merchant_B, …) = deny (u1 never joined B).
Guest (read on licensing, SYSTEM_WIDE):
g, User_u2, Role_guest, *
p, Role_guest, SYSTEM_WIDE, licensing, read, allow→ request (User_u2, <any merchant, incl. the pre-merchant placeholder>, licensing, read) = allow. SYSTEM_WIDE matches every domain with no membership required.
Roles & bypass
| Role | Identifier | Enforcement |
|---|---|---|
| Super Admin / Admin / Operator | 999_* / 900_* / 600_* | alwaysAllow bypass - skip Casbin |
| Owner | 500_organizer-owner | ANY_MEMBER grants + manage |
| Cashier | 110_cashier | ANY_MEMBER grants, narrower action per module |
| Employee | 100_employee | ANY_MEMBER grants, narrower action per module |
| Guest | 001_guest | SYSTEM_WIDE - pre-merchant onboarding |
| Customer | 010_customer | no backend grants |
alwaysAllowRoles is configured in verifier.ts / issuer.ts. Fixed roles live in AppFixedRoles; the coarse role→module grant map lives in seed-role-grants.ts (COARSE_MODULE_GRANTS).
Enforcer caching
The enforcer caches each user's loaded line set with the CasbinEnforcerCachedDrivers.REDIS driver (expiresIn 5 min, key casbin:<principalType>:<userId>).
- The
cachedconfig is wired inverifier.ts/issuer.tsfromgetAuthorizationRedisConnection(). When no authorization redis connection is registered,cached: { use: false }- the enforcer runs without a cache.
Effect: permission/role changes take effect on the next cache expiry (~5 min) or next sign-in - they are not instant.
Gotchas
- Pre-merchant requests. The client sends
x-merchant-id: 00000000-0000-0000-0000-000000000000before a merchant is selected. OnlySYSTEM_WIDEgrants match there - this is why onboarding/reference endpoints need the guest role or an authenticate-only route. ANY_MEMBERgrant with no membership. A tenant grant (ANY_MEMBER) matches only where the user holds ajoin_domain(g2) edge for the active merchant. Missing the membership → nog2→ no match → 403 even though the grant exists. Common bug.- A permission node must be seeded to be grantable. Coarse grants target a module/subject resource node; if a module's
*Permissionscatalog isn't aggregated, the node row is never inserted, so the grant can't resolve and the route 403s for everyone (non-bypass). merchant_roledoes not enforce. It is@nx/coreUI metadata (which roles are available in a merchant) - the adapter never reads it, so it has zero effect on a decision.- Soft-deletes are excluded by the adapter's
deleted_atfilter. - Grants are stored coarse; reads return the node, not the leaves. A
manageon thecommercemodule is one row (grant → commerce), not one row per operation. SoGET …/roles/{id}/targets/permissionsreturns that single node by default - the fine-grained ops it confers (viag4resource +g5action lattice) are resolved by the enforcer, not stored. Pass?expand=trueto have the API resolve the node back into the full effective permission list (PermissionService.expandGrants, the inverse ofresolveGrants).
Related
- RBAC & Policy Definitions - data model, roles, API
- PolicyDefinition cookbook - concrete rows per case
- ADR-0002 Casbin via PolicyDefinition
- Permission Matrix - current grants per role