@zudojs/permissions
v1.5.0
Published
Generic authorization engine with RBAC, ABAC, resource authorization, wildcards, role hierarchy, policies, and abilities.
Maintainers
Readme
@zudojs/permissions
Generic authorization engine with RBAC, ABAC, resource authorization, wildcards, role hierarchy, policies, and abilities.
Documentation: zudojs.oyinlola.site/docs/packages-permissions · For AI agents: Markdown version, llms.txt
Installation
npm install @zudojs/permissionsQuick Start
import {
createPermissionEngine,
createPermissionActor,
} from "@zudojs/permissions";
const engine = createPermissionEngine({
roles: [
{ name: "reader", permissions: ["post:read"] },
{ name: "editor", permissions: ["post:update"], inherits: ["reader"] },
{ name: "admin", permissions: ["*:*"] },
],
});
const actor = createPermissionActor("user_1", { roles: ["editor"] });
await engine.can(actor, "post:update"); // true
await engine.can(actor, "post:read"); // true — inherited from "reader"
await engine.can(actor, "user:delete"); // falseroles is an array of RoleDefinition, and every check takes the actor,
not a role name. Malformed configuration is rejected at construction: a grant
like "post" (no action) could never match, so it is an error rather than a
silent no-op.
Permissions
Permissions are resource:action. Two wildcard forms are supported, and they
mean the same thing everywhere — in a grant, a deny, a rule, or a policy:
| Pattern | Matches |
| ---------------- | ----------------------------------------- |
| post:read | exactly that |
| post:* | every action on post |
| *:read | read on every resource |
| *:* | everything |
| billing.*:read | billing:read, billing.invoice:read, … |
A partial wildcard such as post*:read is rejected: the matcher has no
meaning for it, so accepting one would register a grant that never matches.
Denies
deniedPermissions is matched with the same rules as grants, so a wildcard
deny works:
const restricted = createPermissionActor("user_2", {
roles: ["admin"],
deniedPermissions: ["*:delete", "billing.*:read"],
});
await engine.can(restricted, "post:delete"); // false
await engine.can(restricted, "post:read"); // trueAn explicit deny short-circuits before any rule or policy runs.
Rules and ABAC
A PermissionRule carries an optional condition, and the rule applies
only when that condition holds. This is what makes "owners may edit their own
posts" expressible:
import { isOwner, tenantIsolation, allOf } from "@zudojs/permissions";
const engine = createPermissionEngine({
roles,
rules: [
{
name: "own-posts",
effect: "allow",
resource: "post",
action: "update",
condition: isOwner(), // compares resource.ownerId to actor.id
},
{
name: "locked-posts",
effect: "deny",
resource: "post",
action: "update",
condition: (context) => Boolean((context.resource as Post).locked),
},
],
});
await engine.can(actor, "post:update", { ownerId: "user_1" }); // true
await engine.can(actor, "post:update", { ownerId: "user_9" }); // falseConditions may be async. A condition that cannot be evaluated fails closed, and what "closed" means depends on the rule's effect:
- an allow whose condition throws (or that has a condition but no context) does not apply;
- a deny whose condition throws applies. In the example above,
locked-poststhrows when no resource is passed, and the check is denied — treating the deny as unmet would let the role's allow win. The error is reported throughonError, and the decision is not cached.
A deny also bears on a wildcard check: can(actor, "post:*") is refused by a
deny on post:delete, whether it comes from deniedPermissions, a deny rule,
or a policy registered for post:delete.
Combining. The default is deny-overrides: any applicable deny wins,
whatever its priority. Pass algorithm: "priority" for highest-priority-wins,
where a tie still goes to deny.
Combinators: allOf, anyOf, not, always, never, isOwner,
tenantIsolation, metadataEquals, resourceEquals.
Request metadata
Conditions read request-scoped facts from context.metadata, supplied per
check:
import { createContextManager, getDefaultStorage } from "@zudojs/tenancy";
const tenancy = createContextManager({ storage: getDefaultStorage() });
await engine.can(actor, "invoice:read", invoice, {
// The *verified* tenant — resolved and trust-checked by @zudojs/tenancy.
// `requireCurrentTenant()` is a method on the context manager; it throws
// when no tenant context is active, rather than returning undefined.
metadata: { tenantId: tenancy.requireCurrentTenant().id },
});tenantIsolation() compares that value against the resource's tenant, and
denies when either is missing. It is only as good as the value you pass: fill
tenantId from a source the client cannot choose (a verified token claim, or
the tenant @zudojs/tenancy resolved), never from a request header — a
caller would set the header to the resource's tenant and pass.
Enforce it as a deny rule, not(tenantIsolation()):
import { createPermissionEngine, not, tenantIsolation } from "@zudojs/permissions";
const engine = createPermissionEngine({
roles: [{ name: "accountant", permissions: ["invoice:read"] }],
rules: [
{
name: "tenant-isolation",
effect: "deny",
resource: "invoice",
action: "*",
condition: not(tenantIsolation()), // other tenant, or no tenant → deny
},
],
});A conditional allow rule with tenantIsolation() only adds a way in: a
role that grants invoice:read allows the check whatever the condition says,
so that role reads every tenant's invoices. The deny form refuses cross-tenant
access even for roles that hold the permission, and no policy can override it.
Policies
A policy is a named, prioritised hook that runs alongside the rules. By default it is an extra condition on top of RBAC/ABAC: it can take access away, never hand it out.
const engine = createPermissionEngine({
roles: [{ name: "staff", permissions: ["task:*"] }],
policyTimeout: 250,
policies: [
{
name: "business-hours",
permissions: ["task:*"], // wildcards work here too
priority: 10, // higher runs first
cacheable: false, // depends on the clock, so never cache it
evaluate: () =>
isBusinessHours()
? { allowed: true } // "no objection" — the role still has to grant
: { allowed: false, reason: "outside_business_hours" },
},
],
});
await engine.can(staff, "task:delete"); // true in hours, false after
await engine.can(guest, "task:delete"); // false: no role grants itWarning — a policy's
allowed: trueis not a grant. Before 1.4 it was: an allowing policy granted the permission even to an actor with no roles, so the "business-hours" policy above handedtask:deleteto everyone during office hours. A policy now only constrains, unless it opts in witheffect: "grant".
Policies run highest priority first and stop at the first denial. A policy
that throws, or exceeds policyTimeout, denies. A denying policy always wins.
An allowing policy grants nothing by itself: the actor's roles, direct
permissions or rules must still grant the permission.
Policies that grant
A policy that establishes the right on its own — ownership is the usual one —
says so with effect: "grant":
const engine = createPermissionEngine({
roles,
policies: [
{
name: "author-can-edit",
permissions: ["post:update"],
effect: "grant", // an allow here grants, even with no role
evaluate: ({ actor, resource }) => ({
allowed: (resource as { authorId?: string })?.authorId === actor.id,
}),
},
],
});
await engine.can(author, "post:update", post); // true: the policy grants
await engine.can(editor, "post:update", post); // true: the role grants
await engine.can(viewer, "post:update", post); // false: neither doesA granting policy grants when it allows and abstains when it does not
(including when it throws or times out): it adds access, and never takes away
what roles, direct permissions or rules grant. So the policy returns only the
ownership test — no || actorHasRole(actor, "editor") is needed to keep
editors working. In 1.4.0 a granting policy's allowed: false denied, so an
ownership policy locked out every editor who was not the author. To deny, use
an explicit deny rule, deniedPermissions, or a constraining policy.
A granting policy still never overrides a denial — from another policy, an
explicit deny, or a deny rule that applied. Only the exact value "grant"
grants; a typo constrains.
createPermissionEngine({ defaultPolicyEffect: "grant" }) restores the
pre-1.4 behaviour, unchanged, for every policy that sets no effect: an
allowing policy grants and a denying one denies. It exists for backward
compatibility — prefer marking the individual policies with effect: "grant",
which abstain instead of denying even under that engine default.
policyTimeout: 0 means "expire immediately", not "no timeout" — omit it to
disable.
Registries
Registries can be handed straight to the engine, and stay live:
import {
createRoleRegistry,
createPolicyRegistry,
createPermissionRegistry,
} from "@zudojs/permissions";
const roles = createRoleRegistry();
roles.define({ name: "reader", permissions: ["post:read"] });
roles.define({ name: "staff", permissions: [], inherits: ["reader"] });
const policies = createPolicyRegistry();
const engine = createPermissionEngine({ roles, policies });
roles.define({ name: "auditor", permissions: ["audit:read"] });
roles.remove("reader");
// both take effect on the next check: the engine subscribes to the registry
policies.define({
name: "lockdown",
permissions: ["*:*"],
evaluate: () => ({ allowed: false }),
});
// enforced by the next check — through the engine or an existing Ability
roles.require("auditor"); // throws RoleNotFoundError when unregisteredAll three registries reject a duplicate name — re-registering a role or a
policy is an authorization rule disappearing without a trace. Pass
{ allowOverride: true } when replacement is what you mean.
The engine subscribes to a role or policy registry it is given. Every
define, remove and clear discards the memoized roles and every cached
decision, so revoking a role is not undone by a cache entry written before
the revocation. engine.invalidateRoles() does the same for a custom source.
validateConfiguration (default true) rejects a pattern that could never
match wherever it is written — a role grant, a role's rules, a static rule, or
a policy's permissions — because a malformed pattern in a deny or a lockdown
policy would fail open in silence. Arrays are checked at construction; a
registry checks on define (InvalidRoleError / InvalidPermissionError),
and a custom source is checked when the engine reads it.
A permission registry records descriptions and implications:
const permissions = createPermissionRegistry();
permissions.define("post:admin", { implies: ["post:write"] });
permissions.define("post:write", { implies: ["post:read"] });
const engine = createPermissionEngine({
roles,
expandImplied: permissions,
});
// An actor granted post:admin now passes post:read.Pass the registry itself rather than a closure over it. The engine subscribes
to it, so permissions.remove("post:admin") — or redefining it without the
implication — drops the decisions that were cached while it stood. A bare
(permission) => permissions.expandImplied(permission) still works, but it
cannot announce a change, so an engine given one caches no decisions at all.
Caching
import { createMemoryPermissionCache } from "@zudojs/permissions";
const engine = createPermissionEngine({
roles,
cache: createMemoryPermissionCache({
defaultTtlMs: 30_000,
maxEntries: 5_000,
}),
cacheTtlMs: 30_000,
});
await engine.can(actor, "post:read", post); // evaluated
await engine.can(actor, "post:read", post); // cached
await engine.can(actor, "post:read", post, { skipCache: true });
await engine.invalidateActor("user_1");Keys include the actor id, a digest of everything else the actor carries
(roles, permissions, type, any other field a condition may read), the
permission and the resource id (from resource.id, or options.resourceId).
The same user id with different roles — an admin token in one tenant and a
viewer token in another, or a demoted token — never shares a decision. A |
inside an actor or resource id is escaped, so two different checks can never
share a key either.
A check is cached only when the key can describe it completely:
- a resource with no
idand nooptions.resourceIdis not cached, since the key would collapse to actor + permission and an allow for one object would answer for the next; - a check carrying
metadatais not cached, because conditions such astenantIsolation()read the tenant from there and it is not part of the key; - an actor carrying something the digest cannot describe (a function, a class
instance, a
Map) is not cached; - a decision produced by a policy marked
cacheable: false, or forced by a condition that threw, is not stored; - an engine with a
roleResolverorpermissionResolverand noresolverCacheKeycaches nothing: the resolver reads state the key cannot describe, so an entry would outlive a grant withdrawn upstream; - an engine whose
expandImpliedis a bare function rather than acreatePermissionRegistry()caches nothing, for the same reason: a revoked implication cannot announce itself; - a TTL of
0or less means "do not cache".
deniedPermissions is evaluated before the cache is consulted, so a deny
added to the actor takes effect immediately rather than waiting for a cached
allow to expire. A malformed entry there can never match, so it is reported
through onError instead of being dropped in silence.
External sources
const engine = createPermissionEngine({
roles,
roleResolver: { resolveRoles: (actor) => db.rolesFor(actor.id) },
permissionResolver: { resolvePermissions: (actor) => db.rulesFor(actor.id) },
});A resolver that fails is reported through onError and the check continues
fail-closed, rather than throwing out of the authorization path.
A resolver reads authorization state the engine does not own and cannot see
change, and none of it is in the decision-cache key. A resolver-backed
engine therefore caches nothing unless you describe that state with
resolverCacheKey:
const engine = createPermissionEngine({
roles,
cache: createMemoryPermissionCache(),
permissionResolver: { resolvePermissions: (actor) => db.rulesFor(actor.id) },
// Anything that changes when the resolver's answer could change.
resolverCacheKey: (actor) => db.grantsVersionFor(actor.id),
});Return undefined for an actor whose state you cannot describe, and that
actor's decisions stay uncached.
Failure behaviour
Every failure denies:
| Situation | Result |
| ----------------------------- | ------------------------------------------------------ |
| Unknown role on the actor | denied; reported to onError; other roles still apply |
| Malformed permission string | denied, reason: "invalid_permission" |
| Allow condition throws | the allow does not apply |
| Deny condition throws | denied, reason: "rule_deny"; reported to onError |
| Malformed rule/policy pattern | rejected at construction or define |
| A deny rule applies | denied, reason: "rule_deny", even if a policy allows |
| Policy throws or times out | denied, reason: "policy_error:<name>" |
| Role inheritance cycle | denied; reported to onError |
| Role source throws | denied; reported to onError |
| signal aborted | throws AuthorizationAbortedError |
A value that is not an Error — a policy or resolver that throws a string —
reaches onError wrapped in PolicyError or PermissionResolverError, with
the original as cause.
Cancellation is the one case that throws, because the caller asked for the work to stop.
Abilities
const ability = engine.createAbility(actor);
await ability.can("post:update", post);
await ability.cannot("post:delete");
await ability.check("post:update", post); // full decision
await ability.authorize("post:update", post); // throws PermissionDeniedError
await ability.explain("post:update", post);Explain
explain runs the same evaluation as check and collects the steps, so the
trace always describes the decision that was made:
const { allowed, steps, decision } = await engine.explain(
actor,
"post:update",
post,
);
steps.forEach((step) => console.log(step.type, step.detail, step.matched));The trace lists the grants that bear on this decision, not every permission the actor holds.
Audit events
import { createPermissionEventEmitter } from "@zudojs/permissions";
const emitter = createPermissionEventEmitter({
onHandlerError: (error) => logger.error({ error }, "audit sink failed"),
});
emitter.on((event) => auditLog.write(event));
const engine = createPermissionEngine({ roles, emitter });Every check emits — allowed, denied, and the ones that throw — including
explain() on the engine and on an Ability, which make the same real
decision. A handler that throws cannot break authorization, but it is reported
rather than swallowed.
HTTP middleware
import { authorize, createActorMiddleware } from "@zudojs/permissions";
const guard = authorize(engine, "post:update", {
extractActor: (context) => context.state.get("auth:user"),
// May be async: it is awaited, and a loader that rejects denies (403).
extractResource: (context) => loadPost(context.request.getParam?.("id")),
// The tenant @zudojs/tenancy resolved and trust-checked — never a header.
extractMetadata: (context) => ({
tenantId: context.state.get<{ tenantId: string }>("tenancy:context")
?.tenantId,
}),
onError: (error, source) => logger.warn({ error, source }, "guard denied"),
});- The guard extracts the actor itself when one is not already in state, so it works without a separate actor middleware.
- No actor → 401 with
WWW-Authenticate. Actor but not permitted → 403. The refusal is aGuardResponse(createGuardResponsefrom@zudojs/middleware), which@zudojs/httpsends with that status, body and headers. It used to be a plain{ status, body, headers }object, which a route middleware's return ignored: the handler did not run, but the client got200. - The 403 body carries
decision.publicReason, never the internal reason:policy_error:<name>names your policies and does not belong in a response. PassdeniedResponseto shape the body — it receives the real decision. context.signalis forwarded, so a client disconnect stops policy evaluation.extractResourceis awaited. A loader that throws or rejects answers 403 (reason: "resource_error") and reports throughonError; it never lets the request through.createRequirePermissionsMiddleware(engine, permissions, { mode })checks several permissions, short-circuiting on the first that decides the outcome. An empty list denies in either mode.
A resource that does not exist
By default a guard checks the permission even when extractResource returns
undefined or null, with no resource. Rules and policies that read the
resource see nothing, so the answer comes from the rest of the model. A role
that grants post:update lets the request through, and the handler still has
to answer 404. A resource-owner rule denies with 403. onMissingResource
moves that answer into the guard:
const guard = authorize(engine, "post:update", {
extractActor: (context) => context.state.get("auth:user"),
extractResource: (context) => posts.find(context.request.getParam?.("id")),
onMissingResource: "notFound", // 404 when the loader returns undefined/null
notFoundResponse: () => ({ error: "Not Found" }), // optional body
});| onMissingResource | A missing resource answers |
| ------------------- | -------------------------- |
| "check" (default) | whatever the engine decides with no resource, as before |
| "forbid" | 403, without evaluating |
| "notFound" | 404, without evaluating |
The option applies only to a guard that has an extractResource. An
unauthenticated request still gets 401 first, and a loader that throws still
gets 403. The guard records RESOURCE_NOT_FOUND_DECISION
(reason: "resource_not_found") under permissions:decision.
createRequirePermissionsMiddleware takes the same two options. The 404 body
is built by createNotFoundResponse.
The trade-off. A 404 hides whether a resource exists only when every
answer is consistent. With "notFound", an authenticated caller who lacks the
permission gets 404 for an id that does not exist and 403 for one that does,
so the two statuses confirm which ids exist. To conceal existence from callers
who are not authorised, every route over the resource must answer the same
way, and a denial on an existing resource must also answer 404. These guards
do not do that for you. Use "notFound" to take the lookup-and-404 out of the
handler. On its own it does not conceal anything.
The middleware composes with the real @zudojs/http pipeline without
depending on it: the HTTP types are mirrored structurally (headers, params and
query may be plain objects, as @zudojs/http provides them, or maps), and
HttpMiddleware is assignable to @zudojs/http's own HttpMiddleware — pass
a guard straight to a route, no as never:
router.put("/posts/:id", updatePost, { middleware: [guard] });A middleware of your own typed as this package's HttpMiddleware answers a
refusal with createGuardResponse({ status, body }); returning a plain
{ status, body, headers } object is no longer typed, because
@zudojs/http never sent it as a response.
Errors
PermissionError is the base. PermissionDeniedError carries a caller-safe
message; the actor id, reason and policy live on error.details for the log,
not in the exposed metadata.
PermissionNotFoundError · DuplicatePermissionError · RoleNotFoundError ·
DuplicateRoleError · DuplicatePolicyError · InvalidPermissionError ·
InvalidRoleError ·
CircularRoleInheritanceError · PolicyError · PolicyTimeoutError ·
PermissionResolverError · AuthorizationAbortedError
Use Cases
- API authorization
- Multi-tenant access control
- Admin panel permissions
- Fine-grained resource policies
