Files
WRNexusJSDoc/app/pages/packages/authz.wrn
T

593 lines
44 KiB
Plaintext

page wrnexusauthz {
seo {
title = "@wrnexus/authz"
description = "Role, permission, policy, and authorization guards."
}
view {
<div class="docs-shell">
<a href="#main" class="skip-link">Skip to content</a>
<header class="topbar">
<a class="brand" href="/"><span>W</span> WRNexusJS</a>
<nav aria-label="Primary"><a href="/getting-started">Get started</a><a href="/packages">Packages</a><a href="https://component.wrnexusjs.dev/">Components</a><a href="/language">Language</a><a href="/architecture">Architecture</a></nav>
<div class="topbar-actions"><a class="preview-pill" href="/access">Private preview · v0.8.7</a><button data-wire-theme-toggle class="theme-button" aria-label="Toggle color theme" title="Toggle color theme">◐</button></div>
</header>
<div class="mobile-doc-nav"><details><summary>Browse documentation</summary><nav><a href="/getting-started">Get started</a><a href="/packages">Packages</a><a href="https://component.wrnexusjs.dev/">Components</a><a href="/language">Language</a><a href="/architecture">Architecture</a><a href="/tutorial">Tutorial</a><a href="/guides/project-structure">Guides</a><a href="/examples">Examples</a><a href="/search">Search</a></nav></details></div>
<main id="main" class="portal-main docs-layout docs-layout--navigation"><aside class="docs-section-menu" aria-label="Documentation sections"><nav>
<section><strong>Get started</strong><a href="/getting-started">Quick start</a><a href="/tutorial">Tutorial</a><a href="/guides/full-stack-auth-demo">Auth dashboard demo</a><a href="/guides/configuration-and-profiles">Configuration</a></section>
<section><strong>Build</strong><a href="/guides/pages-and-components">Pages and components</a><a href="/guides/routing">Routing</a><a href="/guides/server-data">Server data</a><a href="/guides/forms-and-validation">Forms and validation</a><a href="/guides/database">Database</a></section>
<section><strong>Auth and security</strong><a href="/guides/authentication">Authentication</a><a href="/guides/authorization">Authorization</a><a href="/guides/security">Application security</a><a href="/security">Security policy</a></section>
<section><strong>Reference</strong><a href="/packages">Packages overview</a><a href="/packages/cli">CLI</a><a href="/language">.wrn language</a><a href="https://component.wrnexusjs.dev/">Components</a><a href="/examples">Examples</a></section>
<section class="package-menu"><strong>Packages</strong><a href="/packages/ai">@wrnexus/ai</a><a href="/packages/auth">@wrnexus/auth</a><a href="/packages/authz">@wrnexus/authz</a><a href="/packages/benchmark">@wrnexus/benchmark</a><a href="/packages/cache">@wrnexus/cache</a><a href="/packages/captcha">@wrnexus/captcha</a><a href="/packages/cli">@wrnexus/cli</a><a href="/packages/compiler">@wrnexus/compiler</a><a href="/packages/content">@wrnexus/content</a><a href="/packages/core">@wrnexus/core</a><a href="/packages/csr">@wrnexus/csr</a><a href="/packages/db">@wrnexus/db</a><a href="/packages/dev-server">@wrnexus/dev-server</a><a href="/packages/dev-toolbar">@wrnexus/dev-toolbar</a><a href="/packages/encryption">@wrnexus/encryption</a><a href="/packages/graphql">@wrnexus/graphql</a><a href="/packages/helpers">@wrnexus/helpers</a><a href="/packages/i18n">@wrnexus/i18n</a><a href="/packages/identity">@wrnexus/identity</a><a href="/packages/image">@wrnexus/image</a><a href="/packages/jwt">@wrnexus/jwt</a><a href="/packages/language-server">@wrnexus/language-server</a><a href="/packages/mcp">@wrnexus/mcp</a><a href="/packages/mobile">@wrnexus/mobile</a><a href="/packages/native">@wrnexus/native</a><a href="/packages/oauth">@wrnexus/oauth</a><a href="/packages/observability">@wrnexus/observability</a><a href="/packages/playground">@wrnexus/playground</a><a href="/packages/plugin">@wrnexus/plugin</a><a href="/packages/pubsub">@wrnexus/pubsub</a><a href="/packages/pwa">@wrnexus/pwa</a><a href="/packages/queue">@wrnexus/queue</a><a href="/packages/reactive">@wrnexus/reactive</a><a href="/packages/realtime">@wrnexus/realtime</a><a href="/packages/router">@wrnexus/router</a><a href="/packages/security">@wrnexus/security</a><a href="/packages/ssr">@wrnexus/ssr</a><a href="/packages/store">@wrnexus/store</a><a href="/packages/styles">@wrnexus/styles</a><a href="/packages/syntax">@wrnexus/syntax</a><a href="/packages/test">@wrnexus/test</a><a href="/packages/tracking">@wrnexus/tracking</a><a href="/packages/typecheck">@wrnexus/typecheck</a><a href="/packages/ui">@wrnexus/ui</a><a href="/packages/uploader">@wrnexus/uploader</a><a href="/packages/validation">@wrnexus/validation</a></section>
</nav></aside><div class="docs-reading-column">
<article class="documentation prose standalone package-document"><nav class="breadcrumbs" aria-label="Breadcrumb"><a href="/">Home</a><span>/</span><a href="/packages">Packages</a><span>/</span><span aria-current="page">@wrnexus/authz</span></nav><section class="doc-intro"><span class="eyebrow">Security · Package reference</span><h1>@wrnexus/authz</h1><p>Role, permission, policy, and authorization guards.</p><div class="doc-meta"><span>v0.8.7</span><span>Private registry</span><span>Security</span></div><section id="access" class="access-callout"><h2>Install the package</h2><p>After WorkRoot approves private registry access, install the release-aligned package:</p><pre><code>bun add @wrnexus/authz@0.8.7</code><button type="button" class="copy-button" aria-label="Copy installation command">Copy</button></pre><p><a href="/access">Request preview access</a>. Never put registry tokens in source control.</p></section></section><section id="guide"><blockquote>Composable authorization for WRNexusJS — role-based (RBAC), policy-based (PBAC), and attribute-based (ABAC) access control that reduces to a boolean check plus an <code>authorize()</code> guard.</blockquote>
<p>Part of the <strong>WRNexusJS</strong> framework — an SSR-first, Bun-native full-stack web framework.</p>
<h3 id="overview">Overview</h3>
<p><code>@wrnexus/authz</code> 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 <code>boolean | Promise&lt;boolean&gt;</code> decision. Wrap any decision in a <code>Middleware</code> guard (<code>authorize</code>, <code>requireRole</code>, <code>requirePermission</code>) to protect WRNexusJS 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 <code>@wrnexus/core</code> by reading <code>ctx.user</code> as the authorization subject.</p>
<pre data-language="bash"><code>bun add @wrnexus/authz</code></pre>
<blockquote>Private package — the machine must be authenticated to the <code>wrnexus</code> npm org</blockquote>
<blockquote>(a read token in <code>~/.npmrc</code>). Requires <strong>Bun</strong> (Node is not supported).</blockquote>
<h3 id="api">API</h3>
<p>The package has a single entry point (<code>@wrnexus/authz</code>) exporting the following.</p>
<h4 id="types">Types</h4>
<div class="table-wrap"><table>
<thead><tr><th>Symbol</th><th>Description</th></tr></thead>
<tbody><tr><td><code>Subject</code></td><td>The authorized principal: <code>&#123; id?: string; roles?: string[]; [attribute: string]: unknown &#125;</code>.</td></tr><tr><td><code>Rbac</code></td><td>An RBAC checker: <code>&#123; can(subject, permission): boolean; permissionsFor(roles): Set&lt;string&gt; &#125;</code>.</td></tr><tr><td><code>Policy&lt;S = Subject, R = unknown&gt;</code></td><td>A predicate `(subject: S, resource?: R) =&gt; boolean \</td><td>Promise&lt;boolean&gt;`.</td></tr></tbody></table></div>
<h4 id="rbac">RBAC</h4>
<h4 id="definerbac-roles-record-string-string-rbac"><code>defineRbac(roles: Record&lt;string, string[]&gt;): Rbac</code></h4>
<p>Builds an RBAC checker from a role → permissions map. Supported permission forms:</p>
<ul>
<li><code>&quot;*&quot;</code> — grants every permission.</li>
<li><code>&quot;ns:*&quot;</code> — namespace wildcard (e.g. <code>&quot;post:*&quot;</code> grants <code>&quot;post:write&quot;</code>).</li>
<li><code>&quot;role:&lt;name&gt;&quot;</code> — inherits all permissions of another role (resolved recursively, cycle-safe).</li>
</ul>
<p>The returned <code>Rbac</code> provides:</p>
<ul>
<li><code>can(subject, permission)</code> — <code>true</code> if any of <code>subject.roles</code> grants <code>permission</code> (honouring <code>*</code> and namespace wildcards). Returns <code>false</code> when the subject has no roles.</li>
<li><code>permissionsFor(roles)</code> — the resolved <code>Set&lt;string&gt;</code> of all permissions granted to a set of roles.</li>
</ul>
<h4 id="hasrole-subject-subject-undefined-required-string-boolean"><code>hasRole(subject: Subject | undefined, ...required: string[]): boolean</code></h4>
<p><code>true</code> if the subject holds <strong>all</strong> of the given roles.</p>
<h4 id="pbac-abac-combinators">PBAC / ABAC combinators</h4>
<ul>
<li><code>any&lt;S, R&gt;(...policies: Policy&lt;S, R&gt;[]): Policy&lt;S, R&gt;</code> — allow if <strong>any</strong> policy passes (OR); awaits async policies.</li>
<li><code>all&lt;S, R&gt;(...policies: Policy&lt;S, R&gt;[]): Policy&lt;S, R&gt;</code> — allow only if <strong>all</strong> policies pass (AND); awaits async policies.</li>
<li><code>attr&lt;S extends Subject&gt;(name: string, match: unknown | ((value: unknown) =&gt; boolean)): Policy&lt;S&gt;</code> — ABAC helper that allows when <code>subject[name]</code> equals <code>match</code>, or when <code>match</code> is a function, when <code>match(value)</code> is truthy.</li>
</ul>
<h4 id="guards-middleware">Guards (middleware)</h4>
<p>Each guard returns a <code>@wrnexus/core</code> <code>Middleware</code>. A denied request short-circuits with <code>Response.json(&#123; ok: false, error: &quot;Forbidden&quot; &#125;, &#123; status: 403 &#125;)</code>.</p>
<ul>
<li><code>authorize(policy: (ctx: Context) =&gt; boolean | Promise&lt;boolean&gt;): Middleware</code> — runs <code>policy</code> against the request <code>Context</code>; calls <code>next()</code> when it resolves truthy, otherwise returns 403.</li>
<li><code>requireRole(...roles: string[]): Middleware</code> — allows when <code>ctx.user</code> holds <strong>any</strong> of the listed roles.</li>
<li><code>requirePermission(rbac: Rbac, permission: string): Middleware</code> — allows when <code>rbac.can(ctx.user, permission)</code> is <code>true</code>.</li>
</ul>
<h3 id="usage">Usage</h3>
<h4 id="rbac-2">RBAC</h4>
<pre data-language="ts"><code>import &#123; defineRbac, hasRole &#125; from &quot;@wrnexus/authz&quot;;
const rbac = defineRbac(&#123;
admin: [&quot;*&quot;],
editor: [&quot;post:read&quot;, &quot;post:write&quot;],
viewer: [&quot;post:read&quot;],
// role inheritance: lead gets everything an editor has, plus post:publish
lead: [&quot;role:editor&quot;, &quot;post:publish&quot;],
&#125;);
const user = &#123; id: &quot;u1&quot;, roles: [&quot;editor&quot;] &#125;;
rbac.can(user, &quot;post:write&quot;); // true
rbac.can(user, &quot;post:delete&quot;); // false
rbac.permissionsFor([&quot;lead&quot;]); // Set &#123; &quot;post:read&quot;, &quot;post:write&quot;, &quot;post:publish&quot; &#125;
hasRole(user, &quot;editor&quot;); // true</code></pre>
<h4 id="guarding-routes">Guarding routes</h4>
<pre data-language="ts"><code>import &#123; authorize, requireRole, requirePermission, defineRbac &#125; from &quot;@wrnexus/authz&quot;;
const rbac = defineRbac(&#123; admin: [&quot;*&quot;], editor: [&quot;post:read&quot;, &quot;post:write&quot;] &#125;);
// Only admins or editors
app.get(&quot;/dashboard&quot;, requireRole(&quot;admin&quot;, &quot;editor&quot;), handler);
// Requires a specific permission
app.post(&quot;/posts&quot;, requirePermission(rbac, &quot;post:write&quot;), handler);
// Arbitrary policy over the request context
app.delete(
&quot;/posts/:id&quot;,
authorize((ctx) =&gt; hasRole(ctx.user, &quot;admin&quot;)),
handler,
);</code></pre>
<h4 id="pbac-abac-policies">PBAC / ABAC policies</h4>
<pre data-language="ts"><code>import &#123; any, all, attr, authorize, type Policy &#125; from &quot;@wrnexus/authz&quot;;
interface User &#123;
id: string;
department?: string;
roles?: string[];
&#125;
interface Post &#123;
authorId: string;
&#125;
// Ownership policy (subject + resource)
const ownsPost: Policy&lt;User, Post&gt; = (u, post) =&gt; u.id === post?.authorId;
// ABAC: attribute equality, or a predicate
const inEngineering = attr&lt;User&gt;(&quot;department&quot;, &quot;engineering&quot;);
const isVerified = attr&lt;User&gt;(&quot;verified&quot;, (v) =&gt; 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(
&quot;/posts/:id&quot;,
authorize((ctx) =&gt; canEdit(ctx.user as User, loadPost(ctx))),
handler,
);</code></pre>
<h3 id="requirements-notes">Requirements / Notes</h3>
<ul>
<li><strong>Bun-only</strong> — like the rest of WRNexusJS, this package targets the Bun runtime; Node is not supported.</li>
<li>Works with [<code>@wrnexus/core</code>](../core) — the guards return <code>Middleware</code> and read the subject from <code>ctx.user</code> on the request <code>Context</code>. Both types are imported from <code>@wrnexus/core</code>.</li>
<li>Policy combinators (<code>any</code>, <code>all</code>) and <code>authorize</code> are async-aware, so policies may return a <code>Promise&lt;boolean&gt;</code> (e.g. for a database ownership check).</li>
</ul>
<h3 id="declaring-permissions">Declaring permissions</h3>
<p>The RBAC/PBAC/ABAC surface above is the low-level toolkit. On top of it sits a declarative <strong>registry + catalog + store + engine</strong>: permissions, roles, and policies are declared once in code, merged into a frozen catalog at boot, and resolved per-request against a pluggable <code>PermissionStore</code> that holds who has what.</p>
<p>Put declarations in <code>app/authz/&lt;name&gt;.ts</code>; they are discovered automatically and merged (conflicting declarations of the same permission/role/policy across files fail the boot loudly, naming both source files).</p>
<pre data-language="ts"><code>import &#123; defineAuthz, owner &#125; from &quot;@wrnexus/authz&quot;;
export default defineAuthz(&#123;
permissions: &#123;
&quot;post:read&quot;: &#123; title: &quot;View posts&quot;, public: true &#125;,
&quot;post:delete&quot;: &#123; title: &quot;Delete posts&quot;, risk: &quot;high&quot; &#125;,
&#125;,
// &quot;post:*&quot; 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. &quot;post:read&quot;, &quot;post:delete&quot;).
roles: &#123; editor: [&quot;post:*&quot;], admin: [&quot;role:editor&quot;] &#125;,
policies: &#123; ownsPost: owner(&quot;id&quot;, &quot;authorId&quot;) &#125;,
bindings: &#123; &quot;post:delete&quot;: [&quot;ownsPost&quot;] &#125;,
&#125;);</code></pre>
<p><code>public: true</code> 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 <code>notBanned</code> policy on a public <code>post:preview</code> permission).</p>
<h3 id="checking-permissions">Checking permissions</h3>
<p>Register <code>authzMiddleware</code> once, in <code>app/middleware/</code>, with the merged catalog and a <code>PermissionStore</code>. Like every other <code>app/middleware/*.ts</code> file, the registration is an eager, module-scope call — the same shape as <code>authzMiddleware(&#123; catalog, store &#125;)</code> requires — so it must run after the catalog has been populated. Both the dev server and <code>wrnexus build</code>'s generated production entry guarantee <code>getAuthzCatalog()</code> is populated before any app middleware module evaluates. Name the file so it sorts after whatever middleware sets <code>ctx.user</code> (middleware runs in alphabetical filename order — <code>authz.ts</code> after <code>auth.ts</code>, for instance).</p>
<pre data-language="ts"><code>// app/middleware/authz.ts
import &#123; authzMiddleware, getAuthzCatalog &#125; from &quot;@wrnexus/authz&quot;;
import &#123; dbPermissionStore &#125; from &quot;@wrnexus/authz/db&quot;;
import &#123; getDb &#125; from &quot;@wrnexus/db&quot;;
export default authzMiddleware(&#123; catalog: getAuthzCatalog(), store: dbPermissionStore(getDb()) &#125;);</code></pre>
<blockquote><strong><code>subject.id</code> must be a non-empty string.</strong> The engine denies (and logs to</blockquote>
<blockquote>stderr) whenever <code>ctx.user.id</code> is present but not a non-empty string — this</blockquote>
<blockquote>includes the common case of an integer primary key. Coerce it before it</blockquote>
<blockquote>reaches <code>ctx.user</code>, e.g. <code>user.id = String(row.id)</code>, or every request for</blockquote>
<blockquote>that user denies with &quot;Invalid subject&quot; instead of resolving normally.</blockquote>
<blockquote><code>owner()</code> (the built-in ownership policy) compares subject and resource ids</blockquote>
<blockquote>with <code>Object.is</code>, so both sides must be the same type too — <code>owner()</code> on a</blockquote>
<blockquote>numeric <code>resource.authorId</code> against a stringified <code>subject.id</code> never</blockquote>
<blockquote>matches even when they represent &quot;the same&quot; id.</blockquote>
<p>There is no per-route <code>middleware</code> export — <code>app/middleware/*.ts</code> 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 <code>app/middleware/captcha-login.ts</code> in the auth showcase, which branches on method + path the same way):</p>
<pre data-language="ts"><code>// app/middleware/protect-posts.ts
import type &#123; Context, Next &#125; from &quot;@wrnexus/core&quot;;
import &#123; guardPermission &#125; from &quot;@wrnexus/authz&quot;;
const guardPostWrite = guardPermission(&quot;post:write&quot;);
export default function protectPosts(ctx: Context, next: Next) &#123;
return ctx.url.pathname.startsWith(&quot;/api/posts&quot;) &amp;&amp; ctx.req.method !== &quot;GET&quot;
? guardPostWrite(ctx, next)
: next();
&#125;</code></pre>
<p>Or check inline inside a route handler with the free function <code>can()</code>:</p>
<pre data-language="ts"><code>// app/api/posts/[id].ts
import type &#123; Context &#125; from &quot;@wrnexus/core&quot;;
import &#123; can &#125; from &quot;@wrnexus/authz&quot;;
export const DELETE = async (ctx: Context) =&gt; &#123;
const post = &#123; id: &quot;1&quot;, authorId: &quot;alice&quot; &#125;; // load your own resource here
if (!(await can(ctx, &quot;post:delete&quot;, post))) &#123;
return Response.json(&#123; ok: false, error: &quot;Forbidden&quot; &#125;, &#123; status: 403 &#125;);
&#125;
return Response.json(&#123; ok: true &#125;);
&#125;;</code></pre>
<p><code>can()</code> is a free function taking <code>ctx</code>, not <code>ctx.can</code> — <code>@wrnexus/core</code> must not depend on <code>@wrnexus/authz</code>, so the per-request resolver lives in <code>ctx.locals</code> instead, reached through <code>can()</code> / <code>decideFor()</code> / <code>guardPermission()</code> / <code>filterCan()</code>. Calling any of them before <code>authzMiddleware</code> has run for that request throws a <code>WRN-AUTHZ-SETUP</code> error naming the missing registration, rather than silently denying.</p>
<p>See <code>examples/auth-showcase/app/authz/showcase.ts</code> and <code>examples/auth-showcase/app/middleware/authz.ts</code> for a complete, runnable version of this wiring.</p>
<h3 id="precedence">Precedence</h3>
<p>1. An explicit deny wins over everything, including <code>*</code> — and honours the same namespace-wildcard matching as grants (denying <code>post:*</code> blocks <code>post:comment:delete</code>, not just <code>post:*</code> itself). 2. A bound policy can veto a permission a role grants, and runs even for a <code>public: true</code> permission — including for an anonymous caller. 3. Otherwise the permission must be held via a role or an explicit grant. 4. Default deny.</p>
<p>Every failure — an unknown permission (outside strict/dev mode), a store outage, a thrown policy — denies rather than throwing through to the caller.</p>
<p><code>permissionsFor()</code> (on the resolver returned by <code>createAuthzResolver</code>) is a coarse hint for hiding UI (e.g. a menu section), <strong>never authoritative</strong>. A <code>Set&lt;string&gt;</code> cannot represent &quot;granted <code>post:*</code> except <code>post:delete</code>&quot;, so a narrow deny beneath a broad grant is invisible to it — the set still contains <code>post:*</code> while <code>can()</code> / <code>decide()</code> correctly refuse <code>post:delete</code>. Gate real actions with <code>can()</code>, <code>decideFor()</code>, or <code>filterCan()</code>; never by matching against <code>permissionsFor()</code>'s result.</p>
<h3 id="cli">CLI</h3>
<pre data-language="bash"><code>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</code></pre>
<p><code>wrnexus authz generate</code>'s output is a plain <code>Permission | Role</code> string-literal union — <code>can()</code>, <code>guardPermission()</code>, and <code>decideFor()</code> all take a bare <code>string</code> and nothing reads this file automatically, so import it to type your own helpers/constants against the registered catalog, e.g.:</p>
<pre data-language="ts"><code>import type &#123; Permission &#125; from &quot;app/authz/permissions.gen.ts&quot;;
function guard(permission: Permission) &#123;
return guardPermission(permission);
&#125;</code></pre></section><section id="api" class="api"><h2>Complete TypeScript API</h2><p>Generated from the exact installed package declarations.</p><pre data-language="typescript"><code>import &#123; Context, Middleware &#125; from '@wrnexus/core';
/**
* Validate and freeze one authorization declaration. Called from
* `app/authz/&lt;name&gt;.ts` as the module's default export.
*/
declare function defineAuthz(module: AuthzModule): AuthzModule;
interface CatalogSource &#123;
/** File or package that declared this module, used in conflict messages. */
source: string;
module: AuthzModule;
&#125;
declare function emptyCatalog(): AuthzCatalog;
declare function mergeCatalogs(sources: CatalogSource[]): AuthzCatalog;
/**
* A process-wide authorization catalog registry, mirroring `@wrnexus/db`'s
* `client.ts` (`setDb`/`getDb`/`hasDb`). It exists for the same reason: app
* middleware runs at module-eval time — `app/middleware/*.ts` registers
* `authzMiddleware(&#123; catalog, store, ... &#125;)` itself, an EAGER call (the same
* shape as `logger.ts`'s `export default requestLogger(&#123;...&#125;)`), and it needs
* the merged catalog *then*, before its own module body finishes running.
* Passing it through `ctx` does not work at that point, so the framework
* loads and merges every `app/authz/*.ts` declaration and stashes it here
* before any other module can observe it:
*
* - dev: `startServer` calls `loadAppAuthzCatalog` + `setAuthzCatalog`
* before middleware is resolved.
* - prod (the normal `wrnexus build` output): the generated entry statically
* imports a small `.authz-setup.ts` module FIRST — before any page, API,
* or middleware import — which calls `setAuthzCatalog` at ITS OWN module
* scope. ES modules evaluate every static import before the importing
* module's body runs, and evaluate sibling imports in declaration order,
* so import position is evaluation order: this guarantees the catalog
* exists before app middleware's own module body (which may read it
* eagerly) ever evaluates. `createProductionHandlers` (`prod.ts`) then
* repeats the merge as an idempotent second pass, mainly so a caller who
* bypasses the generated entry and invokes it directly still gets a
* catalog — for THAT path specifically, an eager module-scope read in
* middleware is only safe if the caller sets the catalog before importing
* the middleware itself, since no generated `.authz-setup.ts` runs first.
*
* The framework never installs `authzMiddleware` itself — the app always
* chooses its own store and registers the middleware; this registry only
* makes the merged catalog reachable when it does.
*/
/** Set the process-wide authorization catalog (called by the framework at boot). */
declare function setAuthzCatalog(next: AuthzCatalog): AuthzCatalog;
/** The process-wide authorization catalog. Throws if it hasn't been set. */
declare function getAuthzCatalog(): AuthzCatalog;
/** Whether the process-wide authorization catalog has been set. */
declare function hasAuthzCatalog(): boolean;
interface AuthzAuditEvent &#123;
subjectId?: string;
scope?: AuthzScope;
permission: string;
allowed: boolean;
reason?: string;
policy?: string;
/** Epoch milliseconds. */
at: number;
&#125;
interface AuthzAuditSink &#123;
record(event: AuthzAuditEvent): void | Promise&lt;void&gt;;
&#125;
interface MemoryAuditSink extends AuthzAuditSink &#123;
events: AuthzAuditEvent[];
clear(): void;
&#125;
declare function memoryAuditSink(): MemoryAuditSink;
declare function consoleAuditSink(): AuthzAuditSink;
/** Record without ever letting a sink failure escape into the request path. */
declare function safeRecord(sink: AuthzAuditSink | undefined, event: AuthzAuditEvent): void;
interface AuthzResolverOptions &#123;
catalog: AuthzCatalog;
store: PermissionStore;
audit?: AuthzAuditSink;
/**
* Throw on an unregistered permission instead of denying. Defaults to true
* outside production, so typos surface during development.
*/
strict?: boolean;
/** Record allows as well as denies. Off by default to bound write volume. */
auditAllows?: boolean;
&#125;
interface DecideInput &#123;
subject: &#123;
id?: string;
[key: string]: unknown;
&#125; | null | undefined;
permission: string;
resource?: unknown;
scope?: AuthzScope;
&#125;
interface AuthzResolver &#123;
/**
* Effective permissions with denied entries removed — for coarse gating such
* as hiding a menu section.
*
* NOT authoritative. A set of strings cannot express &quot;everything under
* `post:*` except `post:delete`&quot;, so a narrow deny beneath a broad grant is
* not representable here: the set still contains `post:*` while `decide()`
* correctly refuses `post:delete`. Gate individual actions with `decide()`
* (or `can()` / `filterCan()`), never by matching against this set.
*/
permissionsFor(subjectId: string, scope?: AuthzScope): Promise&lt;Set&lt;string&gt;&gt;;
decide(input: DecideInput): Promise&lt;AuthorizationDecision&gt;;
&#125;
/** Expand roles into their granted entries, following `role:` and stopping on cycles. */
declare function expandRoles(catalog: AuthzCatalog, roles: readonly string[]): Set&lt;string&gt;;
/**
* Exact match, root wildcard, or a namespace wildcard at any depth.
*
* Do NOT gate access by matching against `permissionsFor()`'s result — that set
* cannot represent a narrow deny beneath a broad grant, so the composition
* returns true where `decide()` refuses. Use `decide()` / `can()` instead.
*/
declare function permissionMatches(granted: Set&lt;string&gt;, permission: string): boolean;
/**
* True if any entry in the deny list covers `permission`. Denies honour the
* same depth-aware wildcards as grants, so denying &quot;post:*&quot; blocks
* post:comment:delete rather than being accepted and silently doing nothing.
*/
declare function deniedBy(denies: readonly string[], permission: string): boolean;
declare function createAuthzResolver(options: AuthzResolverOptions): AuthzResolver;
/**
* `can` is deliberately not a Context member: @wrnexus/core must not depend on
* @wrnexus/authz. The per-request resolver lives here instead.
*/
declare const AUTHZ_LOCALS_KEY = &quot;_authz&quot;;
/** Install the per-request resolver. Register early, after sessionAuth. */
declare function authzMiddleware(options: AuthzResolverOptions): Middleware;
/**
* Object resources are memoised by identity (`byRef`), never by serialising
* their contents — serialisation is what let unrelated resources collide
* (same `id` shape, circular references, BigInt fields, throwing getters all
* funnelled into one bucket). Symbols are memoised by identity too (`bySymbol`)
* since `String(symbol)` collapses distinct symbols with the same description.
* Primitive/absent resources are memoised by a
* `[scope, permission, typeof, String(value)]` tuple, with `-0` rendered
* distinctly from `0` since `String(-0) === &quot;0&quot;` would otherwise merge them.
*
* Subject and scope are both part of the key. A request that reassigns
* ctx.user (impersonation, step-up auth, session revocation) or ctx.tenant
* must not be served the previous principal's verdict from the memo.
*/
declare function decideFor(ctx: Context, permission: string, resource?: unknown): Promise&lt;AuthorizationDecision&gt;;
declare function can(ctx: Context, permission: string, resource?: unknown): Promise&lt;boolean&gt;;
interface GuardOptions &#123;
/** Load the resource a bound policy needs. */
getResource?: (ctx: Context) =&gt; unknown;
/** Include reason and policy name in the 403 body. Off by default. */
exposeReason?: boolean;
/** Redirect page requests here instead of returning 403. Ignored for JSON/API requests and for any non-local target. */
redirectTo?: string;
&#125;
/**
* Guard a route on a registered permission. Named `guardPermission` because
* `requirePermission(rbac, permission)` already exists with a different shape.
*/
declare function guardPermission(permission: string, options?: GuardOptions): Middleware;
/** Keep only the items the current subject may act on. */
declare function filterCan&lt;T&gt;(ctx: Context, permission: string, items: readonly T[]): Promise&lt;T[]&gt;;
/**
* Emit `Permission`/`Role` string-literal unions from the registered catalog.
*
* This does NOT make `can(ctx, &quot;post:wrtie&quot;)` a type error — `can()`,
* `guardPermission()`, and `decideFor()` all take a bare `string`, and
* nothing in the framework consumes this generated file automatically.
* Import the unions yourself to type your OWN helpers/constants, e.g.
* `const PERM: Permission = &quot;post:write&quot;` or a typed wrapper around `can()`.
*/
declare function generatePermissionTypes(catalog: AuthzCatalog): string;
/**
* @wrnexus/authz — authorization: role-based (RBAC), policy-based (PBAC), and
* attribute-based (ABAC). Compose freely; all three reduce to a boolean check
* plus an `authorize()` guard middleware.
*
* const rbac = defineRbac(&#123; admin: [&quot;*&quot;], editor: [&quot;post:read&quot;, &quot;post:write&quot;] &#125;);
* rbac.can(user, &quot;post:write&quot;);
*
* // PBAC/ABAC: a policy is a predicate over subject + resource + attributes
* const ownsPost: Policy&lt;User, Post&gt; = (u, post) =&gt; u.id === post.authorId;
* authorize((ctx) =&gt; ownsPost(ctx.user, resource)) // middleware
*/
interface Subject &#123;
id?: string;
roles?: string[];
[attribute: string]: unknown;
&#125;
interface Rbac &#123;
/** True if any of the subject's roles grants `permission` (supports &quot;*&quot; and &quot;ns:*&quot;). */
can(subject: Subject | undefined, permission: string): boolean;
/** All permissions granted to a set of roles. */
permissionsFor(roles: string[]): Set&lt;string&gt;;
&#125;
/** Build an RBAC checker from a role → permissions map. */
declare function defineRbac(roles: Record&lt;string, string[]&gt;): Rbac;
/** True if the subject has ALL of the given roles. */
declare function hasRole(subject: Subject | undefined, ...required: string[]): boolean;
/** A policy predicate: subject (+ optional resource/attributes) → allowed. */
type Policy&lt;S = Subject, R = unknown&gt; = (subject: S, resource?: R) =&gt; boolean | Promise&lt;boolean&gt;;
/** Combine policies: allow if ANY passes (OR). */
declare function any&lt;S, R&gt;(...policies: Policy&lt;S, R&gt;[]): Policy&lt;S, R&gt;;
/** Combine policies: allow only if ALL pass (AND). */
declare function all&lt;S, R&gt;(...policies: Policy&lt;S, R&gt;[]): Policy&lt;S, R&gt;;
/** ABAC helper: allow when an attribute matches (equality or predicate). */
declare function attr&lt;S extends Subject&gt;(name: string, match: unknown | ((value: unknown) =&gt; boolean)): Policy&lt;S&gt;;
/** Guard a route with a policy over `ctx` (reads `ctx.user` as the subject). */
declare function authorize(policy: (ctx: Context) =&gt; boolean | Promise&lt;boolean&gt;): Middleware;
/** Guard requiring one of the given roles. */
declare function requireRole(...roles: string[]): Middleware;
/** Guard requiring an RBAC permission. */
declare function requirePermission(rbac: Rbac, permission: string): Middleware;
interface AuthorizationDecision &#123;
allowed: boolean;
reason?: string;
policy?: string;
metadata?: Record&lt;string, unknown&gt;;
&#125;
type DecisionPolicy&lt;S = Subject, R = unknown&gt; = (subject: S, resource?: R) =&gt; AuthorizationDecision | Promise&lt;AuthorizationDecision&gt;;
declare function allow(reason?: string, metadata?: Record&lt;string, unknown&gt;): AuthorizationDecision;
declare function deny(reason?: string, metadata?: Record&lt;string, unknown&gt;): AuthorizationDecision;
declare function decision&lt;S, R&gt;(name: string, policy: Policy&lt;S, R&gt;, denial?: string): DecisionPolicy&lt;S, R&gt;;
declare function owner&lt;SubjectType extends Subject, Resource extends Record&lt;string, unknown&gt;&gt;(subjectKey?: keyof SubjectType, resourceKey?: keyof Resource | string): DecisionPolicy&lt;SubjectType, Resource&gt;;
declare function anyDecision&lt;S, R&gt;(...policies: DecisionPolicy&lt;S, R&gt;[]): DecisionPolicy&lt;S, R&gt;;
declare function allDecisions&lt;S, R&gt;(...policies: DecisionPolicy&lt;S, R&gt;[]): DecisionPolicy&lt;S, R&gt;;
interface AuthorizeDecisionOptions &#123;
/**
* Include `reason` and `policy` in the 403 body. Off by default: policy
* names describe internal authorization structure and should not reach an
* unauthenticated caller.
*/
exposeReason?: boolean;
&#125;
declare function authorizeDecision(evaluate: (ctx: Context) =&gt; AuthorizationDecision | Promise&lt;AuthorizationDecision&gt;, options?: AuthorizeDecisionOptions): Middleware;
declare function filterAuthorized&lt;S, R&gt;(subject: S, values: readonly R[], policy: Policy&lt;S, R&gt;): Promise&lt;R[]&gt;;
/** Narrows an assignment to a tenant. Absent means a global assignment. */
interface AuthzScope &#123;
tenantId?: string;
&#125;
interface PermissionMeta &#123;
title?: string;
description?: string;
risk?: &quot;low&quot; | &quot;medium&quot; | &quot;high&quot;;
/** Granted to anonymous subjects. Every other permission denies without a user. */
public?: boolean;
&#125;
interface AttributeMeta &#123;
description?: string;
&#125;
/** One `app/authz/&lt;name&gt;.ts` declaration. */
interface AuthzModule &#123;
permissions?: Record&lt;string, PermissionMeta&gt;;
roles?: Record&lt;string, string[]&gt;;
policies?: Record&lt;string, DecisionPolicy&lt;never, never&gt;&gt;;
attributes?: Record&lt;string, AttributeMeta&gt;;
/** permission id -&gt; policy names that must pass for it. */
bindings?: Record&lt;string, string[]&gt;;
&#125;
/** The merged, frozen view of every declaration in the app. */
interface AuthzCatalog &#123;
permissions: ReadonlyMap&lt;string, PermissionMeta&gt;;
roles: ReadonlyMap&lt;string, readonly string[]&gt;;
policies: ReadonlyMap&lt;string, DecisionPolicy&lt;never, never&gt;&gt;;
attributes: ReadonlyMap&lt;string, AttributeMeta&gt;;
bindings: ReadonlyMap&lt;string, readonly string[]&gt;;
&#125;
interface SubjectAssignments &#123;
roles: string[];
/** Explicit allows, bypassing roles. */
grants: string[];
/** Explicit denies. Win over everything, including &quot;*&quot;. */
denies: string[];
&#125;
type GrantEffect = &quot;allow&quot; | &quot;deny&quot;;
interface PermissionStore &#123;
assignmentsFor(subjectId: string, scope?: AuthzScope): Promise&lt;SubjectAssignments&gt;;
assignRole(subjectId: string, role: string, scope?: AuthzScope): Promise&lt;void&gt;;
revokeRole(subjectId: string, role: string, scope?: AuthzScope): Promise&lt;void&gt;;
grant(subjectId: string, permission: string, effect: GrantEffect, scope?: AuthzScope): Promise&lt;void&gt;;
revokeGrant(subjectId: string, permission: string, scope?: AuthzScope): Promise&lt;void&gt;;
listSubjects(scope?: AuthzScope): Promise&lt;string[]&gt;;
&#125;
/**
* Global assignments are stored under the empty-string scope key. An OMITTED
* scope means global; an explicitly EMPTY or non-string tenantId is refused,
* because an empty string is indistinguishable from global (and would let a
* caller who controls the tenant id read and write global assignments), and a
* non-string value (e.g. `null` from a JSON body or a nullable column) would
* otherwise flow through un-normalised and leave the adapters disagreeing
* about what happened.
*/
declare function scopeKey(scope?: AuthzScope): string;
declare function memoryPermissionStore(): PermissionStore;
interface CachedPermissionStore extends PermissionStore &#123;
/** Drop one subject. Call after changing roles out of band. */
invalidate(subjectId: string, scope?: AuthzScope): void;
invalidateAll(): void;
/** Cached entry count, for tests and diagnostics. */
size(): number;
&#125;
interface CacheOptions &#123;
ttlMs?: number;
max?: number;
&#125;
/**
* Caches assignment reads. Writes through this decorator invalidate the
* affected subject immediately; changes made directly against the inner store
* need an explicit `invalidate()` call rather than waiting out the TTL.
*/
declare function cachedPermissionStore(inner: PermissionStore, options?: CacheOptions): CachedPermissionStore;
export &#123; AUTHZ_LOCALS_KEY, type AttributeMeta, type AuthorizationDecision, type AuthorizeDecisionOptions, type AuthzAuditEvent, type AuthzAuditSink, type AuthzCatalog, type AuthzModule, type AuthzResolver, type AuthzResolverOptions, type AuthzScope, type CacheOptions, type CachedPermissionStore, type CatalogSource, type DecideInput, type DecisionPolicy, type GrantEffect, type GuardOptions, type MemoryAuditSink, type PermissionMeta, type PermissionStore, type Policy, type Rbac, type Subject, type SubjectAssignments, all, allDecisions, allow, any, anyDecision, attr, authorize, authorizeDecision, authzMiddleware, cachedPermissionStore, can, consoleAuditSink, createAuthzResolver, decideFor, decision, defineAuthz, defineRbac, deniedBy, deny, emptyCatalog, expandRoles, filterAuthorized, filterCan, generatePermissionTypes, getAuthzCatalog, guardPermission, hasAuthzCatalog, hasRole, memoryAuditSink, memoryPermissionStore, mergeCatalogs, owner, permissionMatches, requirePermission, requireRole, safeRecord, scopeKey, setAuthzCatalog &#125;;
</code></pre></section><section id="examples" class="examples"><h2>Examples</h2><p>Copy-ready examples from the installed package documentation.</p><div class="example-grid"><article class="example-card"><h3>RBAC</h3><pre data-language="ts"><code>import &#123; defineRbac, hasRole &#125; from &quot;@wrnexus/authz&quot;;
const rbac = defineRbac(&#123;
admin: [&quot;*&quot;],
editor: [&quot;post:read&quot;, &quot;post:write&quot;],
viewer: [&quot;post:read&quot;],
// role inheritance: lead gets everything an editor has, plus post:publish
lead: [&quot;role:editor&quot;, &quot;post:publish&quot;],
&#125;);
const user = &#123; id: &quot;u1&quot;, roles: [&quot;editor&quot;] &#125;;
rbac.can(user, &quot;post:write&quot;); // true
rbac.can(user, &quot;post:delete&quot;); // false
rbac.permissionsFor([&quot;lead&quot;]); // Set &#123; &quot;post:read&quot;, &quot;post:write&quot;, &quot;post:publish&quot; &#125;
hasRole(user, &quot;editor&quot;); // true</code></pre></article><article class="example-card"><h3>Guarding routes</h3><pre data-language="ts"><code>import &#123; authorize, requireRole, requirePermission, defineRbac &#125; from &quot;@wrnexus/authz&quot;;
const rbac = defineRbac(&#123; admin: [&quot;*&quot;], editor: [&quot;post:read&quot;, &quot;post:write&quot;] &#125;);
// Only admins or editors
app.get(&quot;/dashboard&quot;, requireRole(&quot;admin&quot;, &quot;editor&quot;), handler);
// Requires a specific permission
app.post(&quot;/posts&quot;, requirePermission(rbac, &quot;post:write&quot;), handler);
// Arbitrary policy over the request context
app.delete(
&quot;/posts/:id&quot;,
authorize((ctx) =&gt; hasRole(ctx.user, &quot;admin&quot;)),
handler,
);</code></pre></article><article class="example-card"><h3>PBAC / ABAC policies</h3><pre data-language="ts"><code>import &#123; any, all, attr, authorize, type Policy &#125; from &quot;@wrnexus/authz&quot;;
interface User &#123;
id: string;
department?: string;
roles?: string[];
&#125;
interface Post &#123;
authorId: string;
&#125;
// Ownership policy (subject + resource)
const ownsPost: Policy&lt;User, Post&gt; = (u, post) =&gt; u.id === post?.authorId;
// ABAC: attribute equality, or a predicate
const inEngineering = attr&lt;User&gt;(&quot;department&quot;, &quot;engineering&quot;);
const isVerified = attr&lt;User&gt;(&quot;verified&quot;, (v) =&gt; 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(
&quot;/posts/:id&quot;,
authorize((ctx) =&gt; canEdit(ctx.user as User, loadPost(ctx))),
handler,
);</code></pre></article></div></section></article></div>
<aside class="on-this-page"><h2>On this page</h2><nav><a class="toc-level-2" href="#guide">Guide</a><a class="toc-level-3" href="#overview">Overview</a><a class="toc-level-3" href="#api">API</a><a class="toc-level-4" href="#types">Types</a><a class="toc-level-4" href="#rbac">RBAC</a><a class="toc-level-4" href="#definerbac-roles-record-string-string-rbac">defineRbac(roles: Record&lt;string, string[]&gt;): Rbac</a><a class="toc-level-4" href="#hasrole-subject-subject-undefined-required-string-boolean">hasRole(subject: Subject | undefined, ...required: string[]): boolean</a><a class="toc-level-4" href="#pbac-abac-combinators">PBAC / ABAC combinators</a><a class="toc-level-4" href="#guards-middleware">Guards (middleware)</a><a class="toc-level-3" href="#usage">Usage</a><a class="toc-level-4" href="#rbac-2">RBAC</a><a class="toc-level-4" href="#guarding-routes">Guarding routes</a><a class="toc-level-4" href="#pbac-abac-policies">PBAC / ABAC policies</a><a class="toc-level-3" href="#requirements-notes">Requirements / Notes</a><a class="toc-level-3" href="#declaring-permissions">Declaring permissions</a><a class="toc-level-3" href="#checking-permissions">Checking permissions</a><a class="toc-level-3" href="#precedence">Precedence</a><a class="toc-level-3" href="#cli">CLI</a><a class="toc-level-2" href="#api">Complete API</a><a class="toc-level-2" href="#examples">Examples</a></nav></aside>
</main>
<footer><div class="footer-brand"><span class="footer-mark" aria-hidden="true">W</span><p><strong>WRNexusJS 0.8.7</strong><span>Complete API documentation generated from installed package declarations.</span></p></div><nav aria-label="Footer"><a href="/packages">All packages</a><a href="/getting-started">Get started</a><a href="/security">Security</a><a href="/support">Support</a><a href="/llms.txt">AI guide</a></nav><p class="footer-meta">Private Developer Preview · Bun-native</p></footer>
</div>
}
}