Skip to content

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

PieceWhereRole
Casbin model@venizia/ignis .../enforcers/models/rbac-domain.model.tsCASBIN_RBAC_DOMAIN_SCOPED_MODEL - request/policy/matcher definition
Policy adapter@venizia/ignis ScopedCasbinAdapterFiltered: 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}.tsalwaysAllowRoles, domainResolver, adapter + cached
Enforcer (framework)@venizia/ignis CasbinAuthorizationEnforcerRuns 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):

ini
[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)
RelationCasbinEdge variantMeaning
g_, _, _assign_role + role_inheritsUser→Role / Role→Role, domain-aware (the sub axis)
g2_, _join_domainUser is a member of a domain (the dom axis)
g3_, _domain_inheritsdomain nesting, e.g. Merchant ⊂ Organizer
g4_, _resource_inheritsresource(obj) nesting, e.g. OrderItem ⊂ Order
g5_, _action_inheritsaction lattice: manage ⊃ read/write/execute
TokenMeaning
subSubject - User_<id> or Role_<id>
domRequest domain - Merchant_<id> (from x-merchant-id) or none
objObject - the permission/resource code, e.g. commerce.product.create
actAction - read / write / execute / manage / create / update / delete
eftEffect - 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(...)):

ScopeMatchesUsed by
SYSTEM_WIDEevery domain, bypassing membershipguest onboarding
ANY_MEMBERevery 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_WIDE and ANY_MEMBER are not the same. SYSTEM_WIDE is truly merchant-agnostic - use it only for the global guest tier. ANY_MEMBER is tenant-scoped: it applies only where the user actually holds a join_domain membership. Putting a tenant role on SYSTEM_WIDE would break isolation, which is why it is an explicit, code-defined choice (seed-role-grants.ts), never a default.

Request → decision flow

  • alwaysAllowRoles (super-admin 999, admin 900, operator 600) bypass enforcement entirely - configured in verifier.ts / issuer.ts. They never hit the domain logic.
  • domainResolver (getMerchantScopedDomainResolver) reads the x-merchant-id header and returns { type: 'Merchant', id }, which becomes the request domain Merchant_<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.

AspectContract
FormatA merchant id, e.g. d01b061a-46a8-4f35-9954-747efade2f3f.
Who sends itThe client (web/mobile) sets it from the currently-selected merchant; the API gateway forwards it unchanged.
CORSMust be in the Access-Control-Allow-Headers allow-list, or browsers strip it.
Pre-merchant placeholderBefore 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 rolessuper-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.

VariantSubject → TargetCasbin line
grantRole|User → Permissionp, <Role|User>_<id>, <SYSTEM_WIDE|ANY_MEMBER|Type_id>, <objectCode>, <action>, <allow|deny>
assign_roleUser → Roleg, User_<id>, Role_<id>, <domain|*> (null ⇒ *)
join_domainUser → Merchant|Organizerg2, User_<id>, <Type>_<domainId>
role_inheritsRole → Roleg, Role_<child>, Role_<parent>, *
domain_inheritsdomain → domaing3, <Type>_<childId>, <Type>_<parentId>
resource_inheritsresource → resourceg4, <childCode>, <parentCode>
action_inheritsaction → actiong5, <childAction>, <parentAction>
merchant_roleRole ↔ Merchantnot 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:

  1. Per-user edges (vary per principal): grant, assign_role, join_domain. The join_domain rows are restricted to the configured domainTypes (Merchant, Organizer) and become g2 lines, e.g. g2, User_u1, Merchant_7.
  2. 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, allow

Global 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

RoleIdentifierEnforcement
Super Admin / Admin / Operator999_* / 900_* / 600_*alwaysAllow bypass - skip Casbin
Owner500_organizer-ownerANY_MEMBER grants + manage
Cashier110_cashierANY_MEMBER grants, narrower action per module
Employee100_employeeANY_MEMBER grants, narrower action per module
Guest001_guestSYSTEM_WIDE - pre-merchant onboarding
Customer010_customerno 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 cached config is wired in verifier.ts / issuer.ts from getAuthorizationRedisConnection(). 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-000000000000 before a merchant is selected. Only SYSTEM_WIDE grants match there - this is why onboarding/reference endpoints need the guest role or an authenticate-only route.
  • ANY_MEMBER grant with no membership. A tenant grant (ANY_MEMBER) matches only where the user holds a join_domain (g2) edge for the active merchant. Missing the membership → no g2 → 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 *Permissions catalog isn't aggregated, the node row is never inserted, so the grant can't resolve and the route 403s for everyone (non-bypass).
  • merchant_role does not enforce. It is @nx/core UI 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_at filter.
  • Grants are stored coarse; reads return the node, not the leaves. A manage on the commerce module is one row (grant → commerce), not one row per operation. So GET …/roles/{id}/targets/permissions returns that single node by default - the fine-grained ops it confers (via g4 resource + g5 action lattice) are resolved by the enforcer, not stored. Pass ?expand=true to have the API resolve the node back into the full effective permission list (PermissionService.expandGrants, the inverse of resolveGrants).

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