Skip to content

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

PieceFileRole
Casbin modelcore/src/security/casbin-model.tsThe request/policy/matcher definition
Policy adaptercore/src/security/application-casbin-adapter.tsLoads a user's policy from PolicyDefinition into Casbin
Active-merchant resolvercore/src/utilities/request.utility.tsReads x-merchant-id → request domain
Enforcer wiringcore/src/application/{verifier,issuer}.tsnormalizePayloadFn, cache connection, alwaysAllowRoles
Enforcer (framework)@venizia/ignis CasbinAuthorizationEnforcerRuns enforce() per request, caches per user
Cache rediscore/src/components/cache-redis.component.tsBacks 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:

ini
[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
TokenMeaning
subSubject — User_<id> or Role_<id>
domDomain — Merchant_<id> on a membership; * (global) on a role-permission policy
objResource — the permission code, e.g. Organizer.onBoarding
actAction — read / create / update / delete / execute
eftEffect — allow (default) / deny

The scalable design — domain lives on the membership, not on every permission:

  1. g = _, _, _ is domain-aware. A membership says "user has role IN merchant d". This is where per-merchant scoping lives, so the adapter emits one g-line per merchant the user holds the role in.
  2. Role permissions are domain-agnostic. The adapter emits p, Role, *, code, actone 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 size memberships + permissions (linear), not their product.
  3. Global roles (e.g. 001_guest) use a single membership g, User, Role, *. A domain matching function (keyMatch) is registered on the g role manager via ICasbinEnforcerOptions.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-admin 999, admin 900, operator 600) bypass enforcement entirely — see verifier.ts / issuer.ts. They never hit the domain logic. resolveActiveMerchant returns undefined for them.
  • resolveActiveMerchant (request.utility.ts) reads the x-merchant-id header 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.

AspectContract
FormatA merchant UUID, 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 (added in verifier.ts CORS config).
Pre-merchant placeholderBefore 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 rolessuper-admin / admin / operator ignore the header — resolveActiveMerchant returns undefined and enforcement is skipped.
HQ ownerWhen 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:

CaseMeaningEmitted 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 onlyMerchant_<id>
NULL domainGlobal assignment → projected onto every merchant the user belongs to (their memberships)one line per member merchant

⚠️ "Global role" and "NULL domain" are not the same. A global role (GLOBAL_ROLE_IDENTIFIERS in AppFixedRoles) is truly merchant-agnostic and emits *. A NULL domain 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.

VariantSubject → TargetCasbin line
groupUser → Merchant(membership; feeds NULL-domain projection)
groupUser → Roleg, User_<u>, Role_<r>
policyRole → Permissionp, Role_<r>, <domain>, <code>, <act>, <eft>
policyUser → Permissionp, 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:

  1. queryUserPolicies — three focused, parallel (Promise.all) queries:
    • queryMerchantMembershipsgroup rows to MerchantmerchantIds[].
    • queryRoleAssignmentsgroup rows to Role, INNER JOIN Role (drops soft-deleted) → { roleId, domain, identifier }[].
    • queryDirectPoliciespolicy rows to Permission, INNER JOINPermission (drops soft-deleted) → direct user grants.
  2. queryRolePermissions — permissions granted to the user's roles.
  3. 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).
  4. buildGroupPolicyLines — resolve each role's domains: a global role (AppFixedRoles.isGlobalRole) → *; explicit domain → that merchant; NULL → the user's memberships. Emit one domain-less g line per role, and one p line per (role-perm × domain). The wildcard * is passed through raw via toDomainVerb.
  5. 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

RoleIdentifierEnforcement
Super Admin / Admin / Operator999_* / 900_* / 600_*alwaysAllow bypass — skip Casbin
Owner500_organizer-ownerper-merchant domain (+ HQ expansion)
Cashier110_cashierper-merchant domain
Employee100_employeeper-merchant domain
Guest001_guestglobal (*) — pre-merchant onboarding
Customer010_customerno 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, because configureAuthorization() wires the enforcer during preConfigurebefore components boot. The client uses lazyConnect; CacheRedisComponent warms the connection later.
  • getAuthorizationRedisConnection() resolves that helper for the enforcer; returns undefined (→ 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-000000000000 before 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 *Permissions catalog, no Permission row 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 *Permissions const 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-permissions migration that needs RoleRepository / PolicyDefinitionRepository / PermissionRepository will throw "RoleRepository is not bounded" and leave zero grants if the service's application.ts doesn't this.repository(...) all three. The route then 403s for every role even though the permission row exists. (Hit on pricing — fixed by binding the three repos in pricing/src/application.ts.)
  • Soft-deletes are dropped by the adapter's INNER JOINs (role/permission) and deletedAt IS NULL filters.

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