casbin-client
v1.0.0
Published
Lightweight client for Casbin
Maintainers
Readme
casbin-client 
casbin-client is a library which facilitates manipulation, management, and storage of user permissions in a frontend application for the purposes of authorization. It supports various access control policies, like RBAC, ABAC, ACL, etc.
It is primarily a library for Casbin and strives to be a more modern and polymorphic alternative to the official Casbin.js client library; it is a complete rewrite from the ground up, sharing zero code with its predecessor. It can and will work without any dependencies, however, so having any knowledge of Casbin is entierly optional.
// simple
const user = { can: authorizer(() => permissions) };
user.can('read', 'data');
// with caching
const user = createAuthorizer(() => permissions, { store: sessionStorage });
// and/or promises
const user = createAuthorizer(Promise.resolve(permissions), { store: sessionStorage });
user.can('read', 'data');
// with full casbin model and policy parsing
const user = createAuthorizer(() => (
fromPolicySource(policy, { parseExpression })
), { store: sessionStorage });
user.can('read', 'data');| Feature | casbin-client | casbin.js |
|---------|-----------------|-------------|
| 🌟 Modern tech-stack and dev practices | ✅ TypeScript, DI, FP | 🥀 Babel, OOP |
| 🏝️ Less external dependencies | ✅ core/model/policy are dependency-free; only the parser uses subscript | 🥀 Mandatory axios, babel, casbin-core |
| 💻 Ergonomic development experience | ✅ Import and use how you like | 🥀 Use in compliance with assumptions hidden in source code |
| 🪄 Support for various runtime modes | ✅ Supports both regular (sync) and async modes | 🥀 Every method is async |
| 🪶 Lightweight and tree-shakeable | ✅ 0.5KB↔8KB, take what you need | 🥀 90KB+, no tree-shaking |
| 🔌 Extendable | ✅ Pluginable at every step | 🥀 Depend on implementation details |
| 🤝 Type-safe | ✅ Use typed policies to enforce type safety | 🥀 Untyped strings only |
| 🌐 Environment-independent | ✅ Works in any modern JS environment | 🥀 CommonJS build only |
| ⚙️ Reliability | ✅ | 🥀 No tests... |
| 🔃 More to come... | | |
Install
npm i casbin-client
# or
bun add casbin-clientUse
Basics
At the centrepoint is the concept of an Authorizer - a singleton that looks at users' permissions and decides if the "user" can or cannot do certain actions:
import { createAuthorizer } from 'casbin-client';
const permissions = {
read: ['data']
};
const user = createAuthorizer(() => permissions);
if (user.can('read', 'data')) {
console.log('Yay, we can read data!');
}
//...createAuthorizer takes a simple Permissions factory as its primary argument and provides a semantic interface to read from it.
It never modifies or tampers with the original object, acting like a simple view on it.
If permissions need changing, simply update them:
//...
if (!user.can('read', 'users')) {
console.log('Oops, wrong permissions!');
}
permissions.read = ['data', 'users'];
if (user.can('read', 'users')) {
console.log('Yay, we can read users!');
}And that's the basics!
Note
Checks fan out with AND semantics -can(['read', 'write'], 'data')istrueonly if every action is permitted. Empty arrays are treated as a denial (can('read', []) === false) rather than vacuously allowed, so a degenerate input never accidentally opens a gate.
Modules
There are 7 isolated modules:
casbin-client/core- the tiny "core" of the package with a single purpose - to create an authorizercasbin-client- exports a multi-purpose factory for advanced usescasbin-client/model- parser for Casbin modelscasbin-client/policy- parser for Casbin policiescasbin-client/parser- parser for Casbin expressionscasbin-client/functions- built-in pattern matchers (keyMatch,globMatch, …)casbin-client/react- React bindings: hook, typed Provider/context,<Can>gate
Each module is independent from others, and thus very has little effect on the final bundle size of your application.
authorizer
As covered in the basics section, casbin-client exports a simple createAuthorizer function, with some helper types.
But what if even this is too much?
Enter, casbin-client/core:
import { authorizer, type Permissions } from 'casbin-client/core';
const permissions = {
read: ['data', 'users'] as const
} satisfies Permissions; // enables full autocomplete
const can = authorizer(() => permissions);
if (can('read', 'data')) {
console.log('Yay, we can read data!');
}
// Logs "Yay, we can read data!"
It accepts a simple AuthorizerOptions object as its second argument:
import { type AuthorizerOptions } from 'casbin-client/core';
const options = {
fallback: (action, object) => object !== 'database' && action !== 'delete',
// A fallback function to resolve missing permissions
matchAction: (action, source) => source?.[action],
// A matcher for actions in permissions (default value)
matchObject: (object, objects) => objects?.includes(object),
// A matcher for objects in actions (default value)
};
const can = authorizer(() => permissions, options);
if (can('delete', 'database')) {
console.log('We are doomed!');
} else {
console.log('Phew, we are safe.');
}
// Logs "Phew, we are safe."createAuthorizer
This is a much more versatile factory function.
It allows automatic caching using the Storage API and working with promises.
createAuthorizer also accepts two arguments:
- a
Permissionsobject:import { type Permissions } from 'casbin-client'; const permissions: Permissions = { read: ['data', 'users'] }; - and customization options (optional)
import { type SyncAuthorizerOptions } from 'casbin-client'; const options: SyncAuthorizerOptions = { store: sessionStorage, // A `WebStorage`-shaped object (e.g. `sessionStorage`) to cache permissions in key: 'auth', // A unique key to store the permissions in the store fallback: (action, object) => object !== 'database' && action !== 'delete', // A fallback function to resolve missing permissions onError: (error, context) => console.warn(`[casbin-client] ${context}`, error), // Reports recoverable errors (corrupt cache, misconfiguration); the library always fails safe };
And allows for simple permission checking:
import { createAuthorizer } from 'casbin-client';
const user = createAuthorizer(() => permissions, options);
if (user.can('delete', 'database')) {
console.log('We are doomed!');
} else {
console.log('Phew, we are safe.');
}
// Logs "Phew, we are safe."Note
The
.canmethod always re-runs the permission factory!
In reactive UI-frameworks it is advised to wrap its calls with a computed primitive, likeuseMemoorcomputed.
Async mode
Note
This mode is not for usage with reactive UI frameworks like
react,solid, orvue.
In the context of reactive data in UI components, it's better to usecreateAuthorizerin combination with reactive primitives likeuseQuery,createResource, orcomputed.The "Async mode" is for the case when there's no way to use a reactive primitive and the execution context is synchronous.
createAuthorizer makes it easy to work with promises, because the permissions factory can also be a promise:
const permissionsUrl = 'https://raw.githubusercontent.com/Raiondesu/casbin-client/refs/heads/main/examples/permissions.json';
const remotePermissions = fetch(permissionsUrl).then(r => r.json());createAuthorizer simply treats the promise as a factory:
const user = createAuthorizer(remotePermissions, options);
// ...
// some time later in a file far far away
if (user.can('read', 'data')) {
console.log('Yay, we can read data!');
}In the context of a single function this is, of course, not possible, so the promise is proxied and can be awaited separately:
await user.remote;
if (user.can('read', 'data')) {
console.log('Yay, we can read data!');
}Typing
Both authorizer and createAuthorizer accept a generic parameter, which can be automatically inferred from permissions:
type MyPermissions = {
read: ['data']
};
const permissions: any = {
read: ['data']
};
const auth = createAuthorizer<MyPermissions>(() => permissions);
// Full autocomplete and type checking!
auth.can('read', 'data');casbin-client/model
Allows to parse and use a Casbin model.
import { parseModel } from 'casbin-client/model';
const model = `
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = r.obj == p.obj && r.act == p.act && g(r.sub, p.sub)
`;
const parsed = parseModel(model);
console.log(parsed.matchers.m({
r: { sub: 'alice', act: 'read', obj: 'data' },
p: { sub: 'reader', act: 'read', obj: 'data' },
g: (r, p) => 'alice' === r && 'reader' === p,
...parsed.matchers,
...parsed.policyEffect
}));
//> truecasbin-client/policy
Allows to parse and use a Casbin model with a Casbin policy.
This module implements the most essential sub-set of read-only features from casbin-core.
See the list of missing features to gauge if this is useful for your project.
import { createAuthorizer } from 'casbin-client';
import { fromPolicySource } from 'casbin-client/policy';
import { parseExpression } from 'casbin-client/parser';
const model = `
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = r.obj == p.obj && r.act == p.act && g(r.sub, p.sub)
`;
// Result from `CasbinJsGetUserPermission` or otherwise manually loaded
const policy = {
g: [
["g", "alice", "reader"],
["g", "alice", "writer"],
["g", "bob", "reader"],
["g", "cathy", "admin"],
],
m: model,
p: [
["p", "reader", "data", "read"],
["p", "writer", "data", "write"],
["p", "admin", "data", "delete"],
]
};
// Note that this is a costly function to call
const permissions = fromPolicySource(policy);
const user = createAuthorizer(() => permissions);
if (user.can('read', 'data')) {
console.log('Yay, we can read data!');
}
const alicePermissions = fromPolicySource(policy, {
request: ['r', 'alice'],
parseExpression, // required to filter by subject - without it, filtering fails closed (denies)
});
const alice = createAuthorizer(() => alicePermissions);
if (alice.can('read', 'data')) {
console.log('Yay, alice can read data!');
}
if (!alice.can('delete', 'data')) {
console.log('Nope, alice cannot delete data!');
}casbin-client/parser
This module uses subscript's sandboxed justin evaluator, with Casbin's in array-membership semantics added on top.
import { parseExpression } from 'casbin-client/parser';
const run = parseExpression('"a" in b && b.a() === true');
console.log(run({ b: { a: () => true } }));
//> trueIt can be passed into model and policy parsers as options, in order to enable complete Casbin experience in JS:
const reader = fromPolicySource(policy, {
request: ['r', 'bob'],
parseExpression,
});
const bob = createAuthorizer(() => reader);
if (bob.can.read('data')) {
console.log('Yeah, Bob can read');
}casbin-client/functions
Casbin's built-in pattern matchers - keyMatch, keyMatch2, regexMatch, globMatch - for path-style resources like /data/*.
They are available by default inside policy matchers, so a model can use them as-is:
const model = `
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[matchers]
m = r.sub == p.sub && keyMatch(r.obj, p.obj) && r.act == p.act
`;For the common frontend case - holding permissions that contain patterns and checking concrete paths against them - use byPattern to turn any matcher into a matchObject:
import { authorizer } from 'casbin-client/core';
import { byPattern, keyMatch } from 'casbin-client/functions';
const can = authorizer(() => ({ read: ['/data/*'] }), {
matchObject: byPattern(keyMatch),
});
can('read', '/data/123'); //> true
can('read', '/other'); //> falseYou can supply or override functions via the functions option of fromPolicySource:
fromPolicySource(policy, { request: ['r', 'alice'], parseExpression, functions: { keyMatch: myKeyMatch } });casbin-client/react
First-class React bindings. react is an optional peer dependency (>=18). The integration is data-source agnostic - you own fetching/shaping and pass the resolved policy + a loading flag; it handles memoization, the loading-vs-denied distinction, and SSR hydration.
Note
Three states are kept distinct: loading / allowed / denied.isLoadingis a separate field, never folded intocan()- so a pending policy denies by default (secure), and you branch onisLoadingfirst to show a placeholder instead of a flash of "denied".
Server vs. client
casbin-client/react is a set of Client Component bindings - they use hooks, so the module carries a 'use client' directive. There are two doors into the library:
- Server-side authorization (Server Components, route handlers, middleware, edge, SSR loaders): use the core -
import { createAuthorizer } from 'casbin-client'. It is pure, dependency-free, carries no directive, and runs anywhere.import { createAuthorizer } from 'casbin-client'; const { can } = createAuthorizer(() => permissions); // any server or client context - Client-component gating (interactive UI that needs hooks/state): use
casbin-client/react.
You cannot use these hooks inside a pure Server Component - but that is what the core is for.
The headless hook
useAuthorizer(permissions, options?) needs no Provider - the truest drop-in:
'use client';
import { useAuthorizer, byPattern, globMatch } from 'casbin-client/react';
import { useQuery } from '@tanstack/react-query'; // or SWR / RTK / raw fetch — anything
function DataPanel() {
const { data, isLoading } = useQuery({ queryKey: ['perms'], queryFn: fetchPermissions });
const { can, isReady } = useAuthorizer(data, {
isLoading,
matchObject: byPattern(globMatch), // patterns: `/data/*` matches `/data/123` at check time
});
if (!isReady) return <Skeleton />; // loading !== denied
return can('read', '/data/42') ? <Data /> : <NoAccess />;
}The typed factory (app-wide gating)
createAuthorizerContext<Permissions>() bakes your Permissions type in once, so can.read('data') autocomplete survives the Provider boundary. Returns a typed { AuthorizerProvider, useAuthorizer, useCan, Can }:
// acl.ts — declare the scope once
import { createAuthorizerContext, byPattern, globMatch } from 'casbin-client/react';
import type { Permissions, AuthorizerOptions } from 'casbin-client/react';
export type AppPermissions = Permissions<'read' | 'write' | 'delete', string>;
// Hoist options to module scope for a stable identity (so the authorizer doesn't rebuild).
export const aclOptions: AuthorizerOptions<AppPermissions> = { matchObject: byPattern(globMatch) };
export const { AuthorizerProvider, useCan, useAuthorizer, Can } =
createAuthorizerContext<AppPermissions>();// providers.tsx — you own the data layer
'use client';
import { AuthorizerProvider, aclOptions } from './acl';
export function AclProvider({ children }) {
const { data, isLoading } = useQuery({ queryKey: ['perms'], queryFn: fetchPermissions });
return (
<AuthorizerProvider permissions={data} isLoading={isLoading} options={aclOptions}>
{children}
</AuthorizerProvider>
);
}// any component — hook or declarative gate
import { useCan, Can } from './acl';
const can = useCan();
can.delete('/users/42'); // curried + narrowed
<Can action="read" object="/billing" loading={<Skeleton />} fallback={<NoAccess />}>
<BillingPanel />
</Can>Migrating from an ad-hoc wrapper
| Hand-rolled wrapper | casbin-client/react |
|---|---|
| useMemo(() => createAuthorizer(...), [items]) | useAuthorizer(permissions, options) (memo built in) |
| useState + useEffect hydration flag | built in via useSyncExternalStore (mismatch-proof) |
| isLoading = !hydrated \|\| query.isLoading | pass isLoading; hydration is folded in |
| new RegExp('^' + obj.replace('*', '.*') + '$') | byPattern(globMatch) (tested; no first-*-only bug) |
| coupled to a specific data hook | pass permissions + isLoading from any source |
Note
createAuthorizer's factory re-runs on every check, so the hook memoizes the authorizer on thepermissionsreference. Pass the upstream data reference directly and memoize any reshape on the source (not inline in render), or the authorizer rebuilds every render.
Async permissions
The Promise-based mode of createAuthorizer is not for React (it can't notify React of resolution). Resolve the promise in your data layer (react-query/SWR/useState) and feed the resolved value into useAuthorizer.
Why
Casbin is amazing for dynamic and polymorphic control of user access. But the official client-side library left a lot to be desired. Being a de-facto extension on the casbin-core package for Node.js, it brings in a lot of unneeded dependencies and wraps them in an API that is awkward to use in a modern JS ecosystem.
Roadmap / TODO list
- [x] Process simple policies (
{ write: ['data'], read: ['data'] }) - [x] Custom storage or DB providers for caching
- [x] Simple integration with any network/query client
- [x] Ability to check user permissions using policies and model matchers
- [x] Ability to parse permissions from policies without the baggage of matchers and effects
- [x] Reliable error reporting (via the
onErrorreporter) - [x] Transitive RBAC role inheritance (
g(alice, admin)+g(admin, super)⇒g(alice, super)) - [x] Policy effects (allow / deny-override)
- [x] React integration (
casbin-client/react); other frameworks to come - [ ] Generate ambient types from policy csv or permissions json
- [ ] Parse permissions at the type level from policy source
- [x] Support for complex pattern-matching (
/data/*,keyMatch(...)) - [ ] Support for internal
eval(...)and other built-in functions - [ ] Support for custom matcher contexts
- [x] Full test coverage
Feel like something's missing? Submit an issue!
Wanna help? Fork and submit a PR!
Security notice
casbin-client/parser is built on subscript's sandboxed justin evaluator. Matcher and effect expressions cannot reach the Function constructor, prototypes, or JS globals, so they cannot execute arbitrary code - the eval()-class risk of earlier versions (and of casbin.js) is closed.
One caveat remains, so prefer trusted model text where you can: there is no evaluation timeout, and an expression may call methods on the context values you pass it (e.g. someString.repeat(1e9)), so a hostile expression can still cause heavy computation (a DoS) on the main thread - it just can't run arbitrary code or read outside its context.
The defaults are otherwise fail-closed: without parseExpression, request filtering denies rather than grants, and unparseable input is reported through onError and skipped.
Contributing
Prerequisites:
bun:curl -fsSL https://bun.sh/install | bashpowershell -c "irm bun.sh/install.ps1 | iex"
To install dependencies:
bun installBuild
bun run buildTest
bun run test