permzplus

RBAC + ABAC authorization for TypeScript — 2 KB, zero dependencies, edge-ready.
CASL-style DX. 1/10th the footprint. Trusted by 230+ developers.
The Builder (v3.2.0)
import { createPermz, PolicyEngine } from 'permzplus'
const snapshot = createPermz({ name: 'EDITOR', level: 20 })
.can('read', 'posts')
.can('write', 'posts')
.cannot('delete', 'posts')
.build()
{
"roles": [{ "name": "EDITOR", "level": 20, "permissions": ["posts:read", "posts:write"] }],
"denies": { "EDITOR": ["posts:delete"] },
"groups": {}
}
const policy = PolicyEngine.fromJSON(snapshot)
policy.can('EDITOR', 'posts:read')
policy.can('EDITOR', 'posts:delete')
Full TypeScript generics — lock down valid actions and resources at compile time:
type Action = 'read' | 'write' | 'delete'
type Resource = 'posts' | 'comments'
createPermz<Action, Resource>({ name: 'MOD', level: 30 })
.can('purge', 'posts')
Why permzplus
| Bundle size | 2 KB | 15 KB+ | 40 KB+ |
| Dependencies | 0 | 3+ | 10+ |
| Resolver | O(1) memoized | Recursive graph walk | Regex policy scan |
| Security score | 100/100 Socket | — | — |
| Edge runtime | Cloudflare Workers / Lambda@Edge | Partial | No |
| Python sync | FastAPI adapter | No | Separate SDK |
| ABAC query gen | Prisma / Mongoose / Drizzle / more | Mongo only | No |
Performance
The hot path uses a three-layer resolver:
checkCache — flat-string Map lookup for repeated subject-free calls (O(1))
- Bitwise layer — bitmask check for
read / write / delete / create without iterating the permission Set (O(1))
- Set iteration — fallback for custom actions or ABAC subject conditions
All three caches are invalidated atomically on any mutation.
vs. CASL and accesscontrol
Benchmarked with mitata on Node 22.16.0, Intel Core i7-1355U. Policy: 3 roles (VIEWER → EDITOR → ADMIN), hierarchical inheritance. Steady-state (cache warm).
| VIEWER read Post (allowed) | 10.6 ns | 12.2 ns | 447 ns |
| EDITOR write Post (allowed) | 8.4 ns | 14.4 ns | 742 ns |
| ADMIN wildcard delete (allowed) | 10.1 ns | 10.7 ns | 837 ns |
| VIEWER delete Post (denied) | 10.4 ns | 12.4 ns | 580 ns |
| 1,000,000 ops — total time | 11.9 ms | 14.8 ms | 1,690 ms |
| Throughput | ~84M ops/sec | ~67M ops/sec | ~590K ops/sec |
permzplus is 1.1–1.7× faster than CASL and 42–89× faster than accesscontrol across all scenarios, while offering hierarchical RBAC, ABAC conditions, audit logging, and query generation that neither library provides.
How it's this fast
The hot path is a two-level Map lookup — zero string allocation, zero regex:
checkCache.get(role)?.get(permission) → return boolean
Cache entries are only written on the first call per (role, permission) pair (a cache miss). Every subsequent call costs exactly two hash-map lookups and a branch — nothing else is touched.
Bundle size
| Raw (minified) | 19.9 KB | ~55 KB | ~35 KB |
| Gzip | 5.9 KB | ~15 KB | ~10 KB |
| Dependencies | 0 | 3+ | 5+ |
Run the benchmark yourself: pnpm bench:compare
Source: bench/benchmark.ts
Installation
npm install permzplus
pnpm add permzplus
Core API
Fluent Builder
import { createPermz, PolicyEngine } from 'permzplus'
const snapshot = createPermz({ name: 'ADMIN', level: 99 })
.can('read', 'posts')
.can('write', 'posts')
.can('delete', 'posts')
.build()
const policy = PolicyEngine.fromJSON(snapshot)
Declarative (classic)
import { defineAbility } from 'permzplus'
const policy = defineAbility(({ role }) => {
role('SUPER_ADMIN', 3, (can) => {
can('*')
})
role('ORG_ADMIN', 2, (can, cannot) => {
can('sites:*', 'templates:*', 'users:read')
cannot('billing:delete')
})
role('MEMBER', 1, (can) => {
can('content:read', 'content:create', 'posts:read', 'posts:edit')
})
})
policy.can('ORG_ADMIN', 'sites:create')
policy.can('ORG_ADMIN', 'content:read')
policy.can('MEMBER', 'billing:delete')
policy.safeCan('', 'content:read')
ABAC — Attribute-Based Conditions
Object conditions (serializable)
MongoDB-style operators. Works with can() and with accessibleBy() for query generation.
policy.defineRule('MEMBER', 'posts:read', { status: 'published' })
policy.defineRule('MEMBER', 'posts:edit', { authorId: '{{user.id}}' })
policy.can('MEMBER', 'posts:read', { status: 'published' }, { user: { id: 'u1' } })
policy.can('MEMBER', 'posts:read', { status: 'draft' }, { user: { id: 'u1' } })
Function conditions
policy.defineRule('MEMBER', 'posts:edit',
(post, ctx) => post.authorId === ctx?.userId && post.status !== 'locked'
)
policy.can('MEMBER', 'posts:edit', post, { userId: 'u1' })
Possession Macros
Use {{dot.path}} in object conditions to inject runtime context values without writing a function. The path is resolved against the context object passed to can().
policy.defineRule('MEMBER', 'posts:edit', { authorId: '{{user.id}}' })
policy.defineRule('MEMBER', 'comments:edit', { authorId: '{{user.id}}', tenantId: '{{tenant.id}}' })
Mixed strings work too: "org-{{tenant.id}}" → "org-acme".
Supported Operators
Built-in: $eq $ne $gt $gte $lt $lte $in $nin $exists $regex $and $or $nor
Time operators: $after $before $between
policy.defineRule('MODERATOR', 'posts:delete', {
status: { $in: ['flagged', 'spam'] },
reportCount: { $gte: 3 },
})
policy.defineRule('MEMBER', 'events:rsvp', {
startsAt: { $after: new Date() },
})
Custom Operators
import { registerOperator } from 'permzplus'
registerOperator('$startsWith', (fieldValue, operand) =>
typeof fieldValue === 'string' && fieldValue.startsWith(operand as string)
)
policy.defineRule('ADMIN', 'files:read', { path: { $startsWith: '/public/' } })
Per-Request Context
const ctx = policy.createContext('MEMBER', { userId: req.user.id })
ctx.can('posts:edit', post)
ctx.cannot('posts:delete', post)
ctx.assert('posts:edit', post)
Field-Level Permissions
policy.addRole({
name: 'EDITOR',
level: 2,
permissions: ['post.title:edit', 'post.body:edit', 'post.status:read'],
})
policy.permittedFieldsOf('EDITOR', 'post', 'edit')
policy.permittedFieldsOf('EDITOR', 'post', 'read')
Query Builder
accessibleBy() converts ABAC rules into database WHERE clauses — derive access filters directly from your policy.
import { accessibleBy } from 'permzplus/query'
const { permitted, unrestricted, conditions } = accessibleBy(policy, 'MEMBER', 'posts:read')
const posts = await prisma.post.findMany({
where: !permitted ? { id: 'never' } : unrestricted ? {} : { OR: conditions },
})
const posts = await Post.find(unrestricted ? {} : { $or: conditions })
Multi-Role Merge
import { mergeAccessible } from 'permzplus/query'
const access = mergeAccessible(
accessibleBy(policy, 'MEMBER', 'posts:read'),
accessibleBy(policy, 'MODERATOR', 'posts:read'),
)
permitted | boolean | Role has this permission at all |
unrestricted | boolean | Permitted with no conditions — return all records |
conditions | object[] | MongoDB-style OR filter conditions |
Permission Groups
Reuse sets of permissions across roles. Compose groups with @ref syntax — cycle detection is built in.
const policy = defineAbility(({ role, group }) => {
group('content-viewer', ['posts:read', 'comments:read'])
group('content-editor', ['@content-viewer', 'posts:write', 'comments:write'])
role('MEMBER', 1, (can) => can('#content-viewer'))
role('EDITOR', 2, (can) => can('#content-editor'))
})
Delegation & Impersonation
Temporarily elevate or transfer permissions — optionally scoped to a subset of the delegator's rules.
const delegated = policy.delegate('ADMIN', 'temp-user-id')
const scoped = policy.delegate('ADMIN', 'temp-user-id', ['posts:read', 'posts:write'])
Expiring Role Assignments
policy.assignRole('MEMBER', userId, {
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
})
Audit Logging
import { InMemoryAuditLogger } from 'permzplus'
const audit = new InMemoryAuditLogger()
const policy = new PolicyEngine({ audit })
policy.grantTo('MEMBER', 'posts:create')
audit.getEvents()
audit.forUser('u1')
audit.forRole('MEMBER')
audit.since(new Date('2025-01-01'))
audit.query({
action: 'permission.grant',
role: 'MEMBER',
since: new Date('2025-01-01'),
order: 'desc',
limit: 50,
})
Import / Export
const snapshot = policy.toJSON()
const policy = PolicyEngine.fromJSON(snapshot)
const csv = policy.toCSV()
const policy = await PolicyEngine.fromCSV(csvString)
await PolicyEngine.fromBulkJSON(jsonArray)
Standalone Validator
import { validate } from 'permzplus/validator'
const issues = validate(snapshot)
GraphQL
import { withPermission } from 'permzplus/adapters/graphql'
const resolvers = {
Mutation: {
deletePost: withPermission(policy, 'posts:delete', async (_, args, ctx) => {
return deletePost(args.id)
}),
},
}
tRPC
import { trpcPermission } from 'permzplus/adapters/trpc'
const protectedProcedure = t.procedure.use(trpcPermission(policy, 'posts:write'))
Framework Adapters
import { expressGuard } from 'permzplus/guard'
app.delete('/posts/:id', expressGuard(policy, 'posts:delete'), handler)
import { FastifyPermzPlugin } from 'permzplus/adapters/fastify'
fastify.register(FastifyPermzPlugin, { policy })
import { honoPermzMiddleware } from 'permzplus/adapters/hono'
app.use('/admin/*', honoPermzMiddleware(policy, 'admin:panel'))
import { PermzGuard, RequirePermission } from 'permzplus/adapters/nest'
@UseGuards(PermzGuard)
@RequirePermission('posts:delete')
async deletePost() { ... }
Database Adapters
import { PrismaAdapter } from 'permzplus/adapters/prisma'
import { MongooseAdapter } from 'permzplus/adapters/mongoose'
import { DrizzleAdapter } from 'permzplus/adapters/drizzle'
import { FirebaseAdapter } from 'permzplus/adapters/firebase'
import { SupabaseAdapter } from 'permzplus/adapters/supabase'
import { RedisAdapter } from 'permzplus/adapters/redis'
import { TypeORMAdapter } from 'permzplus/adapters/typeorm'
import { KnexAdapter } from 'permzplus/adapters/knex'
import { SequelizeAdapter } from 'permzplus/adapters/sequelize'
const policy = await PolicyEngine.fromAdapter(new PrismaAdapter(prisma))
React
import { PermissionProvider, useAbility, Can } from 'permzplus/react'
function App() {
return (
<PermissionProvider engine={policy} role={user.role ?? ''}>
<Dashboard />
</PermissionProvider>
)
}
function EditButton({ post }) {
const ability = useAbility()
if (!ability.can('posts:edit', () => post.authorId === userId)) return null
return <button>Edit</button>
}
function Toolbar() {
return <Can I="delete" a="post"><DeleteButton /></Can>
}
Vue
import { providePermissions, usePermission } from 'permzplus/vue'
providePermissions(policy, user.role)
const canDelete = usePermission('posts:delete')
Full-Stack — TypeScript + Python
permzplus is the only permissions library with a first-party FastAPI adapter. Define once, enforce everywhere.
const policy = defineAbility(({ role }) => {
role('MEMBER', 1, (can) => can('posts:read', 'posts:edit'))
role('ADMIN', 2, (can) => can('*'))
})
from permzplus_fastapi import PolicyEngine, require_permission
policy = PolicyEngine(roles=[
{"name": "MEMBER", "level": 1, "permissions": ["posts:read", "posts:edit"]},
{"name": "ADMIN", "level": 2, "permissions": ["*"]},
])
@app.get("/posts")
async def get_posts(user = Depends(require_permission(policy, "posts:read"))):
...
Social Proof
- 230+ developers using permzplus in production
- 100/100 Socket.dev security score — zero dependencies, zero supply chain risk
- 136 weekly downloads and growing
Spread the Word
If permzplus saves you time, help others find it:
- Star the repo! on GitHub — it helps with discoverability
- Share it! with your team, in Discord servers, or on Twitter/X
- Write about it! — blog posts, dev.to articles, or Stack Overflow answers go a long way
- Open issues or PRs! — feedback and contributions make the library better for everyone
The project is solo-maintained (by a 12 year old). Every mention helps.
License
MIT