diff --git a/examples/auth-showcase/app/authz/showcase.ts b/examples/auth-showcase/app/authz/showcase.ts new file mode 100644 index 00000000..31e01d39 --- /dev/null +++ b/examples/auth-showcase/app/authz/showcase.ts @@ -0,0 +1,30 @@ +import { defineAuthz } from "@wrnexus/authz"; + +/** + * `app/authz/.ts` declarations are discovered automatically and merged + * into the process-wide catalog at boot (see `app/middleware/authz.ts`, which + * registers the middleware that resolves against it). + */ +export default defineAuthz({ + permissions: { + "post:read": { title: "View posts", public: true }, + "post:write": { title: "Create and edit posts" }, + "post:delete": { title: "Delete posts", risk: "high" }, + "admin:access": { title: "Reach the admin area", risk: "high" }, + }, + roles: { + viewer: ["post:read"], + editor: ["role:viewer", "post:write"], + admin: ["role:editor", "post:delete", "admin:access"], + }, + policies: { + ownsPost: async ( + subject: { id?: string }, + resource?: { authorId?: string }, + ): Promise<{ allowed: boolean; reason?: string }> => + resource?.authorId === subject?.id + ? { allowed: true } + : { allowed: false, reason: "You are not the author" }, + }, + bindings: { "post:delete": ["ownsPost"] }, +}); diff --git a/examples/auth-showcase/app/middleware/authz.ts b/examples/auth-showcase/app/middleware/authz.ts new file mode 100644 index 00000000..dbbc862b --- /dev/null +++ b/examples/auth-showcase/app/middleware/authz.ts @@ -0,0 +1,24 @@ +import { authzMiddleware, getAuthzCatalog, memoryPermissionStore } from "@wrnexus/authz"; + +/** + * Registers the per-request authorization resolver against the catalog merged + * from `app/authz/*.ts` (see `showcase.ts`). This is an eager, module-scope + * call — the same shape `authzMiddleware({...})` requires — so it must run + * after `getAuthzCatalog()` has been populated. Both the dev server and + * `wrnexus build`'s generated production entry guarantee that happens before + * any app middleware module evaluates. + * + * Middleware runs in alphabetical filename order, so `authz.ts` runs after + * `auth.ts`, which hydrates `ctx.user` from the session. Route handlers and + * pages can then call `can(ctx, "post:write")` or guard a route with + * `guardPermission("post:delete")`. + * + * A real deployment would swap `memoryPermissionStore()` for + * `dbPermissionStore(getDb())` from `@wrnexus/authz/db` so role and grant + * assignments survive a restart; the showcase keeps everything in memory so + * it stays dependency-free. + */ +export default authzMiddleware({ + catalog: getAuthzCatalog(), + store: memoryPermissionStore(), +}); diff --git a/examples/auth-showcase/package.json b/examples/auth-showcase/package.json index de256ea7..2c455272 100644 --- a/examples/auth-showcase/package.json +++ b/examples/auth-showcase/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@wrnexus/auth": "workspace:*", + "@wrnexus/authz": "workspace:*", "@wrnexus/captcha": "workspace:*", "@wrnexus/core": "workspace:*", "@wrnexus/validation": "workspace:*" diff --git a/examples/auth-showcase/test/showcase.test.ts b/examples/auth-showcase/test/showcase.test.ts index c6f91653..af0a8328 100644 --- a/examples/auth-showcase/test/showcase.test.ts +++ b/examples/auth-showcase/test/showcase.test.ts @@ -91,3 +91,23 @@ test("package auth schemas are shared by browser forms and API handlers", () => expect(forgotPassword).toContain("data-schema='{schema}'"); expect(forgotPassword).toContain("novalidate"); }); + +test("authz is wired with a real declaration and a registered middleware, not a dangling file", () => { + expect(existsSync(join(root, "app", "authz", "showcase.ts"))).toBe(true); + expect(existsSync(join(root, "app", "middleware", "authz.ts"))).toBe(true); + + const declaration = read(root, "app", "authz", "showcase.ts"); + expect(declaration).toContain("defineAuthz"); + expect(declaration).toContain('"post:read": { title: "View posts", public: true }'); + expect(declaration).toContain("bindings:"); + + const middleware = read(root, "app", "middleware", "authz.ts"); + expect(middleware).toContain("authzMiddleware"); + expect(middleware).toContain("getAuthzCatalog()"); + expect(middleware).toContain("memoryPermissionStore()"); + + const manifest = JSON.parse(read(root, "package.json")) as { + dependencies?: Record; + }; + expect(manifest.dependencies?.["@wrnexus/authz"]).toBe("workspace:*"); +}); diff --git a/packages/authz/README.md b/packages/authz/README.md index f0102ea5..207ae431 100644 --- a/packages/authz/README.md +++ b/packages/authz/README.md @@ -149,3 +149,133 @@ 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` (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()) }); +``` + +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 +``` diff --git a/packages/authz/test/integration.test.ts b/packages/authz/test/integration.test.ts new file mode 100644 index 00000000..e442b61b --- /dev/null +++ b/packages/authz/test/integration.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "bun:test"; +import type { Context } from "@wrnexus/core"; +import { createDb } from "@wrnexus/db"; +import { sqlite } from "@wrnexus/db/sqlite"; +import { dbPermissionStore, ensureAuthzTables } from "../src/db.ts"; +import { + authzMiddleware, + can, + cachedPermissionStore, + defineAuthz, + guardPermission, + memoryAuditSink, + mergeCatalogs, +} from "../src/index.ts"; + +// Exercises the full composition end to end: db-backed store -> cache +// decorator -> merged catalog -> per-request middleware -> can()/guardPermission() +// -> audit sink. Each piece already has unit coverage elsewhere; this file is +// only about the seams between them. +const catalog = mergeCatalogs([ + { + source: "showcase.ts", + module: defineAuthz({ + permissions: { + "post:read": { public: true }, + "post:write": {}, + "post:delete": { risk: "high" }, + }, + roles: { editor: ["post:write"], admin: ["role:editor", "post:delete"] }, + policies: { + ownsPost: async (s: { id?: string }, r?: { authorId?: string }) => + r?.authorId === s?.id ? { allowed: true } : { allowed: false, reason: "not owner" }, + }, + bindings: { "post:delete": ["ownsPost"] }, + }), + }, +]); + +function makeCtx(user: unknown, tenantId?: string): Context { + return { + user, + tenant: tenantId ? { id: tenantId } : undefined, + locals: {}, + url: new URL("http://localhost/"), + req: new Request("http://localhost/"), + } as unknown as Context; +} + +describe("end-to-end authorization", () => { + test("db store, cache, catalog, middleware, and audit compose", async () => { + const db = createDb(sqlite(":memory:")); + await ensureAuthzTables(db, "sqlite"); + const store = cachedPermissionStore(dbPermissionStore(db), { ttlMs: 1_000 }); + const audit = memoryAuditSink(); + await store.assignRole("alice", "admin", { tenantId: "acme" }); + + const alice = makeCtx({ id: "alice" }, "acme"); + await authzMiddleware({ catalog, store, audit, strict: true })( + alice, + async () => new Response("ok"), + ); + + expect(await can(alice, "post:write")).toBe(true); + expect(await can(alice, "post:delete", { id: 1, authorId: "alice" })).toBe(true); + expect(await can(alice, "post:delete", { id: 2, authorId: "bob" })).toBe(false); + + // Wrong tenant: the admin role was scoped to acme. + const elsewhere = makeCtx({ id: "alice" }, "other"); + await authzMiddleware({ catalog, store, strict: true })( + elsewhere, + async () => new Response("ok"), + ); + expect(await can(elsewhere, "post:write")).toBe(false); + + // Anonymous can still read, because post:read is public. + const guest = makeCtx(null); + await authzMiddleware({ catalog, store, strict: true })(guest, async () => new Response("ok")); + expect(await can(guest, "post:read")).toBe(true); + expect(await can(guest, "post:write")).toBe(false); + + // Only denials were audited, and only alice's requests used the resolver + // that was wired to this audit sink. + expect(audit.events.length).toBeGreaterThan(0); + expect(audit.events.every((event) => !event.allowed)).toBe(true); + }); + + test("revoking a role takes effect immediately through the cache", async () => { + const db = createDb(sqlite(":memory:")); + await ensureAuthzTables(db, "sqlite"); + const store = cachedPermissionStore(dbPermissionStore(db), { ttlMs: 60_000 }); + await store.assignRole("bob", "editor"); + + const before = makeCtx({ id: "bob" }); + await authzMiddleware({ catalog, store, strict: true })(before, async () => new Response("ok")); + expect(await can(before, "post:write")).toBe(true); + + await store.revokeRole("bob", "editor"); + + const after = makeCtx({ id: "bob" }); + await authzMiddleware({ catalog, store, strict: true })(after, async () => new Response("ok")); + expect(await can(after, "post:write")).toBe(false); + }); + + test("guardPermission returns an opaque 403", async () => { + const db = createDb(sqlite(":memory:")); + await ensureAuthzTables(db, "sqlite"); + const ctx = makeCtx({ id: "carol" }); + await authzMiddleware({ catalog, store: dbPermissionStore(db), strict: true })( + ctx, + async () => new Response("ok"), + ); + const res = await guardPermission("post:write")(ctx, async () => new Response("passed")); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ ok: false, error: "Forbidden" }); + }); + + test("a public permission still runs its bound policy, including for an anonymous caller", async () => { + const publicPolicyCatalog = mergeCatalogs([ + { + source: "public-policy.ts", + module: defineAuthz({ + permissions: { "post:preview": { public: true } }, + policies: { + notBanned: async (_s: { id?: string } | null | undefined, r?: { banned?: boolean }) => + r?.banned ? { allowed: false, reason: "resource banned" } : { allowed: true }, + }, + bindings: { "post:preview": ["notBanned"] }, + }), + }, + ]); + const db = createDb(sqlite(":memory:")); + await ensureAuthzTables(db, "sqlite"); + const store = dbPermissionStore(db); + + const guest = makeCtx(null); + await authzMiddleware({ catalog: publicPolicyCatalog, store, strict: true })( + guest, + async () => new Response("ok"), + ); + expect(await can(guest, "post:preview", { banned: false })).toBe(true); + expect(await can(guest, "post:preview", { banned: true })).toBe(false); + }); +});