chore(release): stage 0.8.5 package tarballs
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -149,3 +149,156 @@ app.put(
|
||||
- **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);
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user