# @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` 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 ```bash 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 }`. | | `Policy` | A predicate `(subject: S, resource?: R) => boolean \| Promise`. | ### RBAC #### `defineRbac(roles: Record): 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:"` — 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` 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(...policies: Policy[]): Policy` — allow if **any** policy passes (OR); awaits async policies. - `all(...policies: Policy[]): Policy` — allow only if **all** policies pass (AND); awaits async policies. - `attr(name: string, match: unknown | ((value: unknown) => boolean)): Policy` — 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): 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 ```ts 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 ```ts 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 ```ts 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 = (u, post) => u.id === post?.authorId; // ABAC: attribute equality, or a predicate const inEngineering = attr("department", "engineering"); const isVerified = attr("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`](../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` (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/.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). ```ts 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). ```ts // 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.id` must be a non-empty string.** The engine denies (and logs to > stderr) whenever `ctx.user.id` is present but not a non-empty string — this > includes the common case of an integer primary key. Coerce it before it > reaches `ctx.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 > with `Object.is`, so both sides must be the same type too — `owner()` on a > numeric `resource.authorId` against a stringified `subject.id` never > 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): ```ts // 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()`: ```ts // 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` 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 ```bash 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.: ```ts import type { Permission } from "app/authz/permissions.gen.ts"; function guard(permission: Permission) { return guardPermission(permission); } ```