frozenMap only blocked the Map's own mutators, so
catalog.roles.get("editor").push("*") escalated a role to a full wildcard
past an error string claiming the catalog is frozen after boot; the same
applied to permission/attribute metadata objects and binding arrays.
mergeCatalogs now stores frozen copies of each, so the original declaring
module's objects are never mutated either.
Also corrects two docstrings (codegen.ts, the design doc) that claimed
`wrnexus authz generate`'s output makes a permission typo a type error —
can(), guardPermission(), and decideFor() all take a bare string and nothing
consumes the generated union automatically. Documents what it actually is:
a Permission/Role union to type your own helpers/constants against. Also
adds a README note on the subject.id contract (must be a non-empty string;
owner() compares with Object.is).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
@wrnexus/authz
Composable authorization for WrNexus — role-based (RBAC), policy-based (PBAC), and attribute-based (ABAC) access control that reduces to a boolean check plus an
authorize()guard.
Part of the WrNexus framework — an SSR-first, Bun-native full-stack web framework.
Overview
@wrnexus/authz is a small, server-side authorization toolkit. It gives you three
interchangeable models — RBAC (roles → permissions), PBAC (policy predicates), and
ABAC (attribute matchers) — that all collapse to a boolean | Promise<boolean> decision.
Wrap any decision in a Middleware guard (authorize, requireRole, requirePermission)
to protect WrNexus routes. Reach for it whenever a route or action needs to be gated on who
the user is, what roles they hold, or attributes of the user and the resource. It plugs into
@wrnexus/core by reading ctx.user as the authorization subject.
Installation
bun add @wrnexus/authz
Private package — the machine must be authenticated to the
wrnexusnpm org (a read token in~/.npmrc). Requires Bun (Node is not supported).
API
The package has a single entry point (@wrnexus/authz) exporting the following.
Types
| Symbol | Description |
|---|---|
Subject |
The authorized principal: { id?: string; roles?: string[]; [attribute: string]: unknown }. |
Rbac |
An RBAC checker: { can(subject, permission): boolean; permissionsFor(roles): Set<string> }. |
Policy<S = Subject, R = unknown> |
A predicate (subject: S, resource?: R) => boolean | Promise<boolean>. |
RBAC
defineRbac(roles: Record<string, string[]>): Rbac
Builds an RBAC checker from a role → permissions map. Supported permission forms:
"*"— grants every permission."ns:*"— namespace wildcard (e.g."post:*"grants"post:write")."role:<name>"— inherits all permissions of another role (resolved recursively, cycle-safe).
The returned Rbac provides:
can(subject, permission)—trueif any ofsubject.rolesgrantspermission(honouring*and namespace wildcards). Returnsfalsewhen the subject has no roles.permissionsFor(roles)— the resolvedSet<string>of all permissions granted to a set of roles.
hasRole(subject: Subject | undefined, ...required: string[]): boolean
true if the subject holds all of the given roles.
PBAC / ABAC combinators
any<S, R>(...policies: Policy<S, R>[]): Policy<S, R>— allow if any policy passes (OR); awaits async policies.all<S, R>(...policies: Policy<S, R>[]): Policy<S, R>— allow only if all policies pass (AND); awaits async policies.attr<S extends Subject>(name: string, match: unknown | ((value: unknown) => boolean)): Policy<S>— ABAC helper that allows whensubject[name]equalsmatch, or whenmatchis a function, whenmatch(value)is truthy.
Guards (middleware)
Each guard returns a @wrnexus/core Middleware. A denied request short-circuits with
Response.json({ ok: false, error: "Forbidden" }, { status: 403 }).
authorize(policy: (ctx: Context) => boolean | Promise<boolean>): Middleware— runspolicyagainst the requestContext; callsnext()when it resolves truthy, otherwise returns 403.requireRole(...roles: string[]): Middleware— allows whenctx.userholds any of the listed roles.requirePermission(rbac: Rbac, permission: string): Middleware— allows whenrbac.can(ctx.user, permission)istrue.
Usage
RBAC
import { defineRbac, hasRole } from "@wrnexus/authz";
const rbac = defineRbac({
admin: ["*"],
editor: ["post:read", "post:write"],
viewer: ["post:read"],
// role inheritance: lead gets everything an editor has, plus post:publish
lead: ["role:editor", "post:publish"],
});
const user = { id: "u1", roles: ["editor"] };
rbac.can(user, "post:write"); // true
rbac.can(user, "post:delete"); // false
rbac.permissionsFor(["lead"]); // Set { "post:read", "post:write", "post:publish" }
hasRole(user, "editor"); // true
Guarding routes
import { authorize, requireRole, requirePermission, defineRbac } from "@wrnexus/authz";
const rbac = defineRbac({ admin: ["*"], editor: ["post:read", "post:write"] });
// Only admins or editors
app.get("/dashboard", requireRole("admin", "editor"), handler);
// Requires a specific permission
app.post("/posts", requirePermission(rbac, "post:write"), handler);
// Arbitrary policy over the request context
app.delete(
"/posts/:id",
authorize((ctx) => hasRole(ctx.user, "admin")),
handler,
);
PBAC / ABAC policies
import { any, all, attr, authorize, type Policy } from "@wrnexus/authz";
interface User {
id: string;
department?: string;
roles?: string[];
}
interface Post {
authorId: string;
}
// Ownership policy (subject + resource)
const ownsPost: Policy<User, Post> = (u, post) => u.id === post?.authorId;
// ABAC: attribute equality, or a predicate
const inEngineering = attr<User>("department", "engineering");
const isVerified = attr<User>("verified", (v) => v === true);
// Compose: allow if the user owns the post OR is in engineering AND verified
const canEdit = any(ownsPost, all(inEngineering, isVerified));
app.put(
"/posts/:id",
authorize((ctx) => canEdit(ctx.user as User, loadPost(ctx))),
handler,
);
Requirements / Notes
- Bun-only — like the rest of WrNexus, this package targets the Bun runtime; Node is not supported.
- Works with
@wrnexus/core— the guards returnMiddlewareand read the subject fromctx.useron the requestContext. Both types are imported from@wrnexus/core. - Policy combinators (
any,all) andauthorizeare async-aware, so policies may return aPromise<boolean>(e.g. for a database ownership check).
Declaring permissions
The RBAC/PBAC/ABAC surface above is the low-level toolkit. On top of it sits a
declarative registry + catalog + store + engine: permissions, roles, and
policies are declared once in code, merged into a frozen catalog at boot, and
resolved per-request against a pluggable PermissionStore that holds who has
what.
Put declarations in app/authz/<name>.ts; they are discovered automatically
and merged (conflicting declarations of the same permission/role/policy across
files fail the boot loudly, naming both source files).
import { defineAuthz, owner } from "@wrnexus/authz";
export default defineAuthz({
permissions: {
"post:read": { title: "View posts", public: true },
"post:delete": { title: "Delete posts", risk: "high" },
},
// "post:*" is a namespace wildcard grant, valid inside a role's list — it is
// not itself a registered permission, so it can only ever grant permissions
// that ARE declared above (e.g. "post:read", "post:delete").
roles: { editor: ["post:*"], admin: ["role:editor"] },
policies: { ownsPost: owner("id", "authorId") },
bindings: { "post:delete": ["ownsPost"] },
});
public: true means anonymous callers may hold the permission — but any
policy bound to it still runs, and can still veto the anonymous caller (e.g. a
notBanned policy on a public post:preview permission).
Checking permissions
Register authzMiddleware once, in app/middleware/, with the merged
catalog and a PermissionStore. Like every other app/middleware/*.ts file,
the registration is an eager, module-scope call — the same shape as
authzMiddleware({ catalog, store }) requires — so it must run after the
catalog has been populated. Both the dev server and wrnexus build's
generated production entry guarantee getAuthzCatalog() is populated before
any app middleware module evaluates. Name the file so it sorts after whatever
middleware sets ctx.user (middleware runs in alphabetical filename order —
authz.ts after auth.ts, for instance).
// app/middleware/authz.ts
import { authzMiddleware, getAuthzCatalog } from "@wrnexus/authz";
import { dbPermissionStore } from "@wrnexus/authz/db";
import { getDb } from "@wrnexus/db";
export default authzMiddleware({ catalog: getAuthzCatalog(), store: dbPermissionStore(getDb()) });
subject.idmust be a non-empty string. The engine denies (and logs to stderr) wheneverctx.user.idis present but not a non-empty string — this includes the common case of an integer primary key. Coerce it before it reachesctx.user, e.g.user.id = String(row.id), or every request for that user denies with "Invalid subject" instead of resolving normally.owner()(the built-in ownership policy) compares subject and resource ids withObject.is, so both sides must be the same type too —owner()on a numericresource.authorIdagainst a stringifiedsubject.idnever matches even when they represent "the same" id.
There is no per-route middleware export — app/middleware/*.ts is the only
place middleware is registered. To gate part of the app, branch on the
request the same way any other conditional middleware does (compare
app/middleware/captcha-login.ts in the auth showcase, which branches on
method + path the same way):
// app/middleware/protect-posts.ts
import type { Context, Next } from "@wrnexus/core";
import { guardPermission } from "@wrnexus/authz";
const guardPostWrite = guardPermission("post:write");
export default function protectPosts(ctx: Context, next: Next) {
return ctx.url.pathname.startsWith("/api/posts") && ctx.req.method !== "GET"
? guardPostWrite(ctx, next)
: next();
}
Or check inline inside a route handler with the free function can():
// app/api/posts/[id].ts
import type { Context } from "@wrnexus/core";
import { can } from "@wrnexus/authz";
export const DELETE = async (ctx: Context) => {
const post = { id: "1", authorId: "alice" }; // load your own resource here
if (!(await can(ctx, "post:delete", post))) {
return Response.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
return Response.json({ ok: true });
};
can() is a free function taking ctx, not ctx.can — @wrnexus/core must
not depend on @wrnexus/authz, so the per-request resolver lives in
ctx.locals instead, reached through can() / decideFor() /
guardPermission() / filterCan(). Calling any of them before
authzMiddleware has run for that request throws a WRN-AUTHZ-SETUP error
naming the missing registration, rather than silently denying.
See examples/auth-showcase/app/authz/showcase.ts and
examples/auth-showcase/app/middleware/authz.ts for a complete, runnable
version of this wiring.
Precedence
- An explicit deny wins over everything, including
*— and honours the same namespace-wildcard matching as grants (denyingpost:*blockspost:comment:delete, not justpost:*itself). - A bound policy can veto a permission a role grants, and runs even for a
public: truepermission — including for an anonymous caller. - Otherwise the permission must be held via a role or an explicit grant.
- Default deny.
Every failure — an unknown permission (outside strict/dev mode), a store outage, a thrown policy — denies rather than throwing through to the caller.
permissionsFor() (on the resolver returned by createAuthzResolver) is a
coarse hint for hiding UI (e.g. a menu section), never authoritative. A
Set<string> cannot represent "granted post:* except post:delete", so a
narrow deny beneath a broad grant is invisible to it — the set still contains
post:* while can() / decide() correctly refuse post:delete. Gate real
actions with can(), decideFor(), or filterCan(); never by matching
against permissionsFor()'s result.
CLI
wrnexus authz list # every registered permission, role, and policy
wrnexus authz generate # app/authz/permissions.gen.ts type unions
wrnexus authz init # scaffold the assignment-table migration
wrnexus authz generate's output is a plain Permission | Role string-literal
union — can(), guardPermission(), and decideFor() all take a bare
string and nothing reads this file automatically, so import it to type your
own helpers/constants against the registered catalog, e.g.:
import type { Permission } from "app/authz/permissions.gen.ts";
function guard(permission: Permission) {
return guardPermission(permission);
}