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.
282 lines
11 KiB
Markdown
282 lines
11 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()) });
|
|
```
|
|
|
|
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
|
|
```
|