Files
WRNexusJS/packages/authz
Clintchiz fd5e2b7128 test(authz): end-to-end integration coverage, worked example, and docs
Task 15 of the authz permissions plan: proves db store + cache + catalog +
middleware + audit compose correctly, wires a real (non-dangling) example
into auth-showcase, and documents the declaration/registration/precedence
surface in the package README.
2026-08-05 01:22:13 +05:30
..

@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 wrnexus npm 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)true if any of subject.roles grants permission (honouring * and namespace wildcards). Returns false when the subject has no roles.
  • permissionsFor(roles) — the resolved Set<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 when subject[name] equals match, or when match is a function, when match(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 — runs policy against the request Context; calls next() when it resolves truthy, otherwise returns 403.
  • requireRole(...roles: string[]): Middleware — allows when ctx.user holds any of the listed roles.
  • requirePermission(rbac: Rbac, permission: string): Middleware — allows when rbac.can(ctx.user, permission) is true.

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 return Middleware and read the subject from ctx.user on the request Context. Both types are imported from @wrnexus/core.
  • Policy combinators (any, all) and authorize are async-aware, so policies may return a Promise<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()) });

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

  1. An explicit deny wins over everything, including * — and honours the same namespace-wildcard matching as grants (denying post:* blocks post:comment:delete, not just post:* itself).
  2. A bound policy can veto a permission a role grants, and runs even for a public: true permission — including for an anonymous caller.
  3. Otherwise the permission must be held via a role or an explicit grant.
  4. 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