Structured RBAC
for the apps you actually ship.
Permission keys with meaning. Roles that inherit without cycles. UI mappings baked into every rule. A framework-agnostic engine with zero runtime dependencies, paired with a headless React SDK that weighs ~5 KB.
FIG.01 — permission-key anatomy · click to deny a coordinate
MODULE
Top-level product area. projects, billing, admin.
RESOURCE
What you act on. tasks, invoices, users.
FIELD
Optional. Column-level gate. revenue, salary.
ACTION
view · create · update · delete · manage.
SCOPE
all · own · team · department · self · public · admin.
why not a flat string?
Flat strings pretend authorization is a vocabulary problem. It isn't. It's a coordinate problem.
When your app has 300 permissions, read:users and view_user drift apart, middleware forgets, and the UI hardcodes its own rules. PermX fixes each permission at a point in the product — module, resource, field, action, scope — so backend checks and UI gates speak the same language, and a refactor renames it in one place.
ABAC
Too abstract. Policy language per app.
Flat RBAC
String soup. No UI contract.
Proprietary SaaS
Vendor-locked. Your rules, their DB.
PermX
Coordinates. Zero deps. Yours forever.
Five coordinates. One permission key.
Module
projectsTop-level product surface. Seeded once, referenced everywhere.
Resource
tasksThe entity or collection being accessed. The noun of every check.
Field
:revenueOptional column-level gate. Use for salaries, PII, payment data.
Action
viewFixed enum: view, create, update, delete, manage. No drift.
Scope
ownRow-level boundary: all, own, team, department, self, public, admin.
definePermissions() · literal-string types preserved
Declare your permissions once. TypeScript infers the literal key strings and hands you autocomplete across middleware, hooks, and gates. Rename a coordinate — the compiler finds every call site.
01// permissions.ts — single source of truth 02import { definePermissions, type PermissionKeyOf } from '@permx/core' 03 04export const P = definePermissions({ 05 projectsView: { module: 'projects', resource: 'tasks', action: 'view', scope: 'all' }, 06 viewSalary: { module: 'people', resource: 'employees', action: 'view', scope: 'own', field: 'salary' }, 07} as const); 08 09// → "people.employees:salary.view.own" (literal type) 10type AppPerm = PermissionKeyOf<typeof P>;
Delete string soup.
Keep the refactor.
BEFORE · flat-string rbac
-if (user.perms.includes('read:users'))-if (user.perms.includes('view_user'))-if (user.perms.includes('users.read'))-// UI layer hardcodes its own rules-if (role === 'admin' || role === 'adm')-// one rename = grep across 40 files
AFTER · permx structured keys
+auth.authorize(P.usersRead)+<Can componentId="users-table">+<CanField fieldId="salary" />+<RouteGuard routeId="/admin" />+// rename 'users' → 'members':+// every call site fails typecheck.
Three independent layers. One union.
Effective permissions = Regular Roles ∪ Subscription Plan ∪ Feature Flags. Each layer flows from its own system of record — job function, Stripe webhook, rollout toggle — without a custom policy engine to glue them together.
resolver runs on every authorize() call
ttl cache keyed by tenantId::userId
Layer 01 — Regular Roles
per-userJob-function assignments. Editor, Viewer, Admin. DFS inheritance with cycle protection, depth cap 10.
Layer 02 — Subscription
per-tenantPlan tier features. Free, Pro, Enterprise. Resolver takes tenantId and returns role ids — Stripe webhooks update a single column.
Layer 03 — Feature Flags
per-tenantGradual rollouts. Beta AI assistant, experimental UI. Same permission grammar — no separate flag SDK.
Diamond-safe DFS.
Cycles die quietly.
Role inheritance graph. Owner inherits Admin, which splits into Editor and Billing. Both Editor and Billing inherit Viewer — a diamond topology that PermX resolves with a visited-set DFS.
visited-set
Set<string> — O(n) dedupe
depth cap
10 — DoS guard, emits warning
merge strategy
Set union — order-independent
One key. Two sides of the wire.
01 — BACKEND · @permx/core/express
Protect the route.
01import { createPermXMiddleware } from '@permx/core/express' 02 03const auth = createPermXMiddleware(permx, { 04 extractUserId: (req) => req.user?.id, 05}); 06 07app.get('/projects', 08 auth.authorize(P.projectsView), 09 listProjects); 10 11// 403 auto-sent. typed permission key. 12// swap for hono / fastify — same engine.
02 — FRONTEND · @permx/react
Gate the UI.
01import { Can, CanField, RouteGuard } from '@permx/react' 02 03<Can componentId="edit-project-btn"> 04 <EditButton /> 05</Can> 06 07<CanField fieldId="salary"> 08 <SalaryInput /> 09</CanField> 10 11<RouteGuard routeId="/admin" fallback={<NoAccess />}> 12 <AdminPage /> 13</RouteGuard>
same permission key on both sides · ui mappings ship from the db · no hardcoded rules in the app
Read the spec.
Skip the sales deck.
| Capability | CASL | Casbin | Permit.io | PermX |
|---|---|---|---|---|
| Structured keys (module.resource:field.action.scope) | No | No | No | First-class grammar |
| UI mappings (routes · components · fields) | No | No | No | Baked into each permission |
| 3-layer model (roles + subscription + flags) | No | No | Partial | Union-resolved per call |
| Role inheritance · DFS + cycle guard | No | Policy-based | Managed | DFS · depth 10 |
| Framework-agnostic (Express · Hono · Fastify · Koa) | Express | Yes | SaaS only | Any HTTP layer |
| DB-agnostic (adapter pattern) | No | Yes | SaaS only | PermXDataProvider |
| React SDK — components + hooks + store | <Can> only | None | None | Full suite · ~5 KB |
| Runtime deps (core) | — | — | — | 0 |
0
runtime dependencies
core engine · mongoose + express are optional peers
~0
kilobytes react sdk
gzipped · zero runtime deps · useSyncExternalStore backed
0
test cases · green
204 core · 57 react · vitest 3 · jsdom + node
0
depth cap on DFS
diamond + cycle safe · depth warn event emitted
live · fetched from registry.npmjs.org · downloads / week · month
Objections,
addressed.
How is this different from CASL?
module.resource:field.action.scope coordinate, and ships those same coordinates to the UI as route / component / field ids. You get typed autocomplete, single-source renames, and no policy language on top.Do I need MongoDB or Express?
PermXDataProvider and you are wired.What about row-level / ABAC-style rules?
all, own, team,department, self. For arbitrary predicates, wrap the authorize call in your resolver — PermX stays out of your query builder.Is it safe to put permissions in a database?
Coming from a flat-RBAC codebase. What is the migration like?
definePermissions(). PermX will happily coexist with flat checks until you are ready to flip the switch. Most teams migrate over two sprints.Why zero runtime dependencies?
Built in the open.
By hand.
PermX is independent, MIT-licensed, and open to contributions. Issues, PRs, and architectural debates are welcome on GitHub — the core stays zero-dep on purpose, and we'd rather ship a small thing well than a big thing badly.
Sheikh Umair Bin Najeeb
@Umair-N · Co-author · Maintainer
Works across the core engine, inheritance resolver, adapters, and the React SDK. Drives the 0.x roadmap toward a stable 1.0.
core · react · adapters
Mueen Ahmed
@incmak · Co-author · Maintainer
Works across the permission grammar, three-layer resolver, DX surface, and the React SDK. Drives the 0.x roadmap toward a stable 1.0.
core · react · types
Your name here.
good first issues labelled
Docs, adapters, typing, examples. Open a draft PR — we review quickly.
Install.
Seed. Authorize.
Works with any Node.js ≥ 18. Bun-first but npm / pnpm / yarn fine. Bring your own auth — PermX only needs a userId.
BACKEND
$bun add @permx/core mongoose express
FRONTEND
$bun add @permx/react
NPM USERS
$npm install @permx/core @permx/react
quick start · three calls to first authorize()
Define permissions
One call to definePermissions(). TypeScript captures every literal key — no runtime cost.
const P = definePermissions({…})Spin up the engine
Pass your data provider (Mongo, Postgres, in-memory). The engine returns the resolver used by both server and client.
createPermXEngine({ dataProvider })Gate server + UI
Middleware on the route, <Can> in the component. Same key on both sides of the wire.
auth.authorize(P.projectsView)