ADR-0002. RBAC via Casbin + PolicyDefinition edge table
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-04 |
| Deciders | Phat Nguyen |
| Supersedes | - |
Context
- We need a flexible RBAC model that supports:
- System fixed roles (SUPER_ADMIN, ADMIN, OPERATOR, OWNER, CASHIER, EMPLOYEE, CUSTOMER, GUEST)
- Custom merchant roles
- Permission grants (Role → Permission)
- Scoping (User can be SCOPED_OWNER for organizer X but EMPLOYEE for organizer Y)
- Two natural approaches:
- Triple tables: User_Role, Role_Permission, plus separate scoping
- Single edge table with
subject+targetpolymorphism
Decision
Use a single PolicyDefinition edge table whose variant selects one of eight edges. The ScopedCasbinAdapter (@venizia/ignis) filters a principal's rows and emits the matching Casbin line for the scoped-RBAC model CASBIN_RBAC_DOMAIN_SCOPED_MODEL.
| Variant | Subject → Target | Casbin | Purpose |
|---|---|---|---|
grant | Role|User → Permission | p | "this subject may <action> <resource>" |
assign_role | User → Role | g | "user has role" (domain-scoped; null ⇒ every domain) |
join_domain | User → Merchant|Organizer | g2 | membership; powers ANY_MEMBER grants |
role_inherits | Role → Role | g | role DAG |
domain_inherits | domain → domain | g3 | Merchant ⊂ Organizer / Branch ⊂ Company |
resource_inherits | resource → resource | g4 | obj hierarchy |
action_inherits | action → action | g5 | manage ⊃ read/write/execute |
merchant_role | Role ↔ Merchant | - | @nx/core UI metadata (not read by the adapter) |
A grant's domain carries the scope - SYSTEM_WIDE, ANY_MEMBER, or a concrete <Type>_<id> (which also covers g3-nested children). Effect is default-DENY with an explicit deny override. Per-edge changes take effect on the next enforcer cache expiry.
Consequences
| Pros | Cons |
|---|---|
| Single table for all RBAC edges; uniform query | Polymorphic - no DB-level FK to subject/target |
ANY_MEMBER / <Type>_<id> domain scopes let one user be different roles per merchant/organizer | Casbin's evaluation cost grows with edge count |
| Custom merchant roles are first-class (not hard-coded) | Permission-check latency requires Redis caching of the per-user line set |
Resource/action/domain hierarchies (g3/g4/g5) keep grants coarse - one module grant cascades to every subject/operation/child-domain | Audit/diff of "who can do X" requires graph traversal |
Alternatives Considered
| Option | Pros | Cons | Why rejected |
|---|---|---|---|
| Triple tables (UserRole, RolePermission) | Clear semantics | Two more migrations + parallel write paths | Worse maintenance |
| Single jsonb permission set per User | Fast read | No history; no group inheritance; impossible to query "who has X" | Wrong primitive |
| Spicedb / external authz | Industry standard | Operational overhead; vendor latency | Premature for our scale |
References
core/src/models/schemas/identity/policy-definition/schema.ts@nx/corecommon/policy-variant.ts(PolicyVariants)services/policy-definition/- 4 specialized writers@venizia/ignis.../enforcers/models/rbac-domain.model.ts(CASBIN_RBAC_DOMAIN_SCOPED_MODEL)