Files
WRNexusJS/packages/authz
Clintchiz b7f3507b59 fix(authz): close memo cross-authorization and guard hardening gaps
Fix round 1 for Task 7, addressing review findings against the brief's
own memoKey design (now superseded per plan amendment cc8085bc):

- C1: memoKey's String(id) + JSON.stringify-with-catch cross-authorized
  distinct resources whenever their ids stringified the same (numeric
  vs string ids, object-shaped ids) or whenever JSON.stringify threw
  (circular references, BigInt fields, throwing getters all shared one
  "<unserialisable>" bucket, so the first verdict computed for any of
  them became the cached verdict for all of them in that request).
- C2: filterCan inherited the same bypass, returning rows the subject
  could not act on.
- Replaced serialisation-based memoization with identity-based
  memoization: object resources are memoised in a WeakMap keyed by the
  resource reference itself (never serialised), primitives/absent
  resources in a Map keyed by [scope, permission, typeof, String(value)]
  so 7 and "7" can never collide.
- I1: scope is now read from ctx.tenant at decision time (currentScope),
  not captured once at middleware-install time, so a tenant switch
  mid-request is honoured on the next check.
- I2/M1: guardPermission's redirectTo now only fires for non-JSON/API
  requests (replicated wantsJson check, since authz may only import
  core as types) and only for a validated local path (isLocalPath),
  closing an open-redirect and a JSON-caller-follows-303 gap.
- I3: getResource is now wrapped in try/catch; a throw denies with the
  standard opaque 403 body instead of propagating the loader's error
  (e.g. a SQL string) to the client.
- Added cache-control: private, no-store to both the 303 and 403
  responses.

Added 11 regression tests. C1/C2 revert-checked: temporarily restored
the old memoKey design and confirmed the four collision tests fail
against it before restoring the fix.
2026-08-04 18:41:18 +05:30
..
2026-08-04 12:19:09 +05:30
2026-07-12 15:55:18 +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).