Casbin Authorization (runtime deep-dive) v1.1.0
How a request is authorized at runtime: the Casbin model, the per-merchant domain 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 | File | Role |
|---|---|---|
| Casbin model | core/src/security/casbin-model.ts | The request/policy/matcher definition |
| Policy adapter | core/src/security/application-casbin-adapter.ts | Loads a user's policy from PolicyDefinition into Casbin |
| Active-merchant resolver | core/src/utilities/request.utility.ts | Reads x-merchant-id → request domain |
| Enforcer wiring | core/src/application/{verifier,issuer}.ts | normalizePayloadFn, cache connection, alwaysAllowRoles |
| Enforcer (framework) | @venizia/ignis CasbinAuthorizationEnforcer | Runs enforce() per request, caches per user |
| Cache redis | core/src/components/cache-redis.component.ts | Backs the enforcer policy cache |
Identity is the JWKS issuer (IssuerApplication); every other service is a verifier (VerifierApplication). Both wire the same Casbin model + adapter.
The model
core/src/security/casbin-model.ts:
[request_definition]
r = sub, dom, obj, act
[policy_definition]
p = sub, dom, obj, act, eft
[role_definition]
g = _, _, _
[policy_effect]
e = some(where (p.eft == allow)) && !some(where (p.eft == deny))
[matchers]
m = g(r.sub, p.sub, r.dom) && keyMatch(r.dom, p.dom) && r.obj == p.obj && r.act == p.act| Token | Meaning |
|---|---|
sub | Subject — User_<id> or Role_<id> |
dom | Domain — Merchant_<id> on a membership; * (global) on a role-permission policy |
obj | Resource — the permission code, e.g. Organizer.onBoarding |
act | Action — read / create / update / delete / execute |
eft | Effect — allow (default) / deny |
The scalable design — domain lives on the membership, not on every permission:
g = _, _, _is domain-aware. A membership says "user has role IN merchantd". This is where per-merchant scoping lives, so the adapter emits one g-line per merchant the user holds the role in.- Role permissions are domain-agnostic. The adapter emits
p, Role, *, code, act— one line per permission, never multiplied by domains.keyMatch(r.dom, "*")(built-in matcher fn) accepts any request domain; the g-lines constrain where the user actually holds the role. This keeps the per-user policy sizememberships + permissions(linear), not their product. - Global roles (e.g.
001_guest) use a single membershipg, User, Role, *. A domain matching function (keyMatch) is registered on thegrole manager viaICasbinEnforcerOptions.domainMatching(verifier/issuer), so the*membership matches any request domain.
A request is allowed iff at least one allow policy matches and no deny matches.
Request → decision flow
alwaysAllowRoles(super-admin999,admin900,operator600) bypass enforcement entirely — seeverifier.ts/issuer.ts. They never hit the domain logic.resolveActiveMerchantreturnsundefinedfor them.resolveActiveMerchant(request.utility.ts) reads thex-merchant-idheader and returns it as the active merchant id. Membership validation is currently commented out, so the raw header value is used.normalizePayloadFn(in verifier/issuer) builds the Casbin request:domain = activeMerchantId ? Merchant_<id> : undefined.
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 and only *-domain (global) grants can match.
| Aspect | Contract |
|---|---|
| Format | A merchant UUID, 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 (added in verifier.ts CORS config). |
| Pre-merchant placeholder | Before a merchant is selected the client sends 00000000-0000-0000-0000-000000000000. This matches no real merchant domain — only global (*) grants apply, which is why onboarding/reference routes need the guest role. |
| Bypass roles | super-admin / admin / operator ignore the header — resolveActiveMerchant returns undefined and enforcement is skipped. |
| HQ owner | When the header points at an organizer's HQ merchant, an Owner grant is expanded to every merchant of that organizer (see HQ-expansion). |
Flow: resolveActiveMerchant (request.utility.ts) reads the header → normalizePayloadFn turns it into the Casbin request domain Merchant_<id> (or undefined when absent/bypass). A wrong or foreign merchant id yields no matching grants → 403 (this is the isolation guarantee, not a bug).
Domain semantics
The domain column on a PolicyDefinition row decides where a grant/assignment applies. The adapter translates it as follows:
The emitted domain depends on the role and the row's domain column:
| Case | Meaning | Emitted policy domain |
|---|---|---|
Role ∈ GLOBAL_ROLE_IDENTIFIERS (e.g. 001_guest) | Global role → applies in any domain, regardless of the assignment's domain | * |
Merchant_<id> (explicit domain) | That merchant only | Merchant_<id> |
NULL domain | Global assignment → projected onto every merchant the user belongs to (their memberships) | one line per member merchant |
⚠️ "Global role" and "
NULLdomain" are not the same. A global role (GLOBAL_ROLE_IDENTIFIERSinAppFixedRoles) is truly merchant-agnostic and emits*. ANULLdomain on a normal role (owner/employee) means "all the user's merchants" (tenant scoping). Marking owner/employee global would break isolation — that is why globalness is an explicit, code-defined allow-list, not a NULL default.
PolicyDefinition → Casbin lines
PolicyDefinition is the single edge table. The adapter reads a user's rows and emits Casbin lines.
| Variant | Subject → Target | Casbin line |
|---|---|---|
group | User → Merchant | (membership; feeds NULL-domain projection) |
group | User → Role | g, User_<u>, Role_<r> |
policy | Role → Permission | p, Role_<r>, <domain>, <code>, <act>, <eft> |
policy | User → Permission | p, User_<u>, <domain>, <code>, <act>, <eft> (direct) |
The adapter
ApplicationCasbinAdapter.loadFilteredPolicy(model, filter) runs once per user (then the enforcer caches it ~5 min). Steps:
queryUserPolicies— three focused, parallel (Promise.all) queries:queryMerchantMemberships—grouprows toMerchant→merchantIds[].queryRoleAssignments—grouprows toRole, INNER JOINRole(drops soft-deleted) →{ roleId, domain, identifier }[].queryDirectPolicies—policyrows toPermission, INNER JOINPermission(drops soft-deleted) → direct user grants.
queryRolePermissions— permissions granted to the user's roles.- HQ-owner expansion (
queryHqOwnerOrgMerchants) — if the user owns an organizer's headquarter merchant, expand the owner role onto every merchant of that organizer (incl. newly created ones). buildGroupPolicyLines— resolve each role's domains: a global role (AppFixedRoles.isGlobalRole) →*; explicit domain → that merchant; NULL → the user's memberships. Emit one domain-lessgline per role, and onepline per (role-perm × domain). The wildcard*is passed through raw viatoDomainVerb.buildDirectPolicyLines— same domain semantics for direct user permissions.
Example emitted lines
Owner at merchant A (assignment domain = A), with Material.find granted:
g, User_u1, Role_owner
p, Role_owner, Merchant_A, Material.find, read, allow→ request (User_u1, Merchant_A, Material.find, read) = allow; request (User_u1, Merchant_B, …) = deny (no Merchant_B line, no *).
Guest (global role 001_guest, any assignment domain), with Organizer.onBoarding granted:
g, User_u1, Role_guest
p, Role_guest, *, Organizer.onBoarding, create, allow→ request (User_u1, Merchant_<anything>, Organizer.onBoarding, create) = allow (works even with the pre-merchant placeholder x-merchant-id). The * comes from the role being in GLOBAL_ROLE_IDENTIFIERS, not from the assignment's domain column.
Roles & bypass
| Role | Identifier | Enforcement |
|---|---|---|
| Super Admin / Admin / Operator | 999_* / 900_* / 600_* | alwaysAllow bypass — skip Casbin |
| Owner | 500_organizer-owner | per-merchant domain (+ HQ expansion) |
| Cashier | 110_cashier | per-merchant domain |
| Employee | 100_employee | per-merchant domain |
| Guest | 001_guest | global (*) — pre-merchant onboarding |
| Customer | 010_customer | no backend permissions |
alwaysAllowRoles is configured in verifier.ts / issuer.ts via the AuthorizeComponent. Fixed roles live in AppFixedRoles (core/src/models/schemas/identity/role/constants.ts).
Enforcer caching
The enforcer caches each user's loaded policy (default ~5 min TTL, key casbin:<principalType>:<userId>). Backed by Redis when available, else in-memory.
useCacheRedis({ bindingKey })(base application) eagerly creates + binds the redis helper, becauseconfigureAuthorization()wires the enforcer duringpreConfigure— before components boot. The client useslazyConnect;CacheRedisComponentwarms the connection later.getAuthorizationRedisConnection()resolves that helper for the enforcer; returnsundefined(→ in-memory) if no cache redis was registered.
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. Only*-domain (global) grants match there — this is why onboarding/reference endpoints need the guest role or an authenticate-only route. - A permission must be seeded to be grantable. If a route enforces a code that is not in the module's
*Permissionscatalog, noPermissionrow exists, so the grant can't be created and the route returns 403 for everyone (non-bypass). Two common ways this catalog gap appears:- Sub-module not aggregated. A
*Permissionsconst exists for the sub-feature (e.g.SalesReportPermissions,AllocationUsagePermissions,ReservationPermissions) but is never spread into the module catalog (SalePermissions). The seeder walks only the aggregated catalog, so those codes are never inserted → 403. - Grant migration silently no-ops. A
*-seed-role-permissionsmigration that needsRoleRepository/PolicyDefinitionRepository/PermissionRepositorywill throw"RoleRepository is not bounded"and leave zero grants if the service'sapplication.tsdoesn'tthis.repository(...)all three. The route then 403s for every role even though the permission row exists. (Hit onpricing— fixed by binding the three repos inpricing/src/application.ts.)
- Sub-module not aggregated. A
- Soft-deletes are dropped by the adapter's INNER JOINs (role/permission) and
deletedAt IS NULLfilters.
Related
- RBAC & Policy Definitions — data model, roles, API
- ADR-0002 Casbin via PolicyDefinition
- Permission Matrix — current grants per role