test(authz): end-to-end integration coverage, worked example, and docs
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.
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineAuthz } from "@wrnexus/authz";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `app/authz/<name>.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"] },
|
||||||
|
});
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/auth": "workspace:*",
|
"@wrnexus/auth": "workspace:*",
|
||||||
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/captcha": "workspace:*",
|
"@wrnexus/captcha": "workspace:*",
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
"@wrnexus/validation": "workspace:*"
|
"@wrnexus/validation": "workspace:*"
|
||||||
|
|||||||
@@ -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("data-schema='{schema}'");
|
||||||
expect(forgotPassword).toContain("novalidate");
|
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<string, string>;
|
||||||
|
};
|
||||||
|
expect(manifest.dependencies?.["@wrnexus/authz"]).toBe("workspace:*");
|
||||||
|
});
|
||||||
|
|||||||
@@ -149,3 +149,133 @@ app.put(
|
|||||||
- **Bun-only** — like the rest of WrNexus, this package targets the Bun runtime; Node is not supported.
|
- **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`.
|
- 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).
|
- 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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user