Files
WRNexusJS/packages/authz/README.md
T
ClintchizandClaude Opus 5 41b6e2ed2b fix(authz): freeze catalog values after boot; correct compile-time-check claims
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>
2026-08-05 02:10:24 +05:30

305 lines
12 KiB
Markdown

# @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
```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<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
```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<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`](../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).
```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<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
```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);
}
```