diff --git a/packages/authz/src/middleware.ts b/packages/authz/src/middleware.ts new file mode 100644 index 00000000..d5ba9e13 --- /dev/null +++ b/packages/authz/src/middleware.ts @@ -0,0 +1,118 @@ +import type { Context, Middleware } from "@wrnexus/core"; +import type { AuthorizationDecision } from "./advanced.ts"; +import { createAuthzResolver, type AuthzResolver, type AuthzResolverOptions } from "./engine.ts"; +import type { AuthzScope } from "./types.ts"; + +/** + * `can` is deliberately not a Context member: @wrnexus/core must not depend on + * @wrnexus/authz. The per-request resolver lives here instead. + */ +export const AUTHZ_LOCALS_KEY = "_authz"; + +interface RequestAuthz { + resolver: AuthzResolver; + scope?: AuthzScope; + memo: Map>; +} + +function readAuthz(ctx: Context): RequestAuthz { + const value = ctx.locals[AUTHZ_LOCALS_KEY] as RequestAuthz | undefined; + if (!value) { + throw new Error( + "WRN-AUTHZ-SETUP: authzMiddleware() is not registered for this request. " + + "Add it to app/middleware before calling can()/guardPermission().", + ); + } + return value; +} + +/** Install the per-request resolver. Register early, after sessionAuth. */ +export function authzMiddleware(options: AuthzResolverOptions): Middleware { + const resolver = createAuthzResolver(options); + return (ctx, next) => { + const request: RequestAuthz = { + resolver, + scope: ctx.tenant?.id ? { tenantId: ctx.tenant.id } : undefined, + memo: new Map(), + }; + ctx.locals[AUTHZ_LOCALS_KEY] = request; + return next(); + }; +} + +/** Stable memo key. Resources without an id fall back to their JSON shape. */ +function memoKey(permission: string, resource: unknown): string { + if (resource === undefined) return permission; + const id = (resource as { id?: unknown })?.id; + if (id !== undefined && id !== null) return `${permission}::${String(id)}`; + try { + return `${permission}::${JSON.stringify(resource)}`; + } catch { + return `${permission}::`; + } +} + +export function decideFor( + ctx: Context, + permission: string, + resource?: unknown, +): Promise { + const request = readAuthz(ctx); + const key = memoKey(permission, resource); + const cached = request.memo.get(key); + if (cached) return cached; + const pending = request.resolver.decide({ + subject: ctx.user as { id?: string } | null | undefined, + permission, + resource, + scope: request.scope, + }); + request.memo.set(key, pending); + return pending; +} + +export async function can(ctx: Context, permission: string, resource?: unknown): Promise { + return (await decideFor(ctx, permission, resource)).allowed; +} + +export interface GuardOptions { + /** Load the resource a bound policy needs. */ + getResource?: (ctx: Context) => unknown | Promise; + /** Include reason and policy name in the 403 body. Off by default. */ + exposeReason?: boolean; + /** Redirect page requests here instead of returning 403. */ + redirectTo?: string; +} + +/** + * Guard a route on a registered permission. Named `guardPermission` because + * `requirePermission(rbac, permission)` already exists with a different shape. + */ +export function guardPermission(permission: string, options: GuardOptions = {}): Middleware { + return async (ctx, next) => { + const resource = options.getResource ? await options.getResource(ctx) : undefined; + const result = await decideFor(ctx, permission, resource); + if (result.allowed) return next(); + if (options.redirectTo) { + return new Response(null, { status: 303, headers: { location: options.redirectTo } }); + } + return Response.json( + options.exposeReason + ? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy } + : { ok: false, error: "Forbidden" }, + { status: 403 }, + ); + }; +} + +/** Keep only the items the current subject may act on. */ +export async function filterCan( + ctx: Context, + permission: string, + items: readonly T[], +): Promise { + const verdicts = await Promise.all( + items.map(async (item) => ({ item, allowed: await can(ctx, permission, item) })), + ); + return verdicts.filter((entry) => entry.allowed).map((entry) => entry.item); +} diff --git a/packages/authz/test/middleware.test.ts b/packages/authz/test/middleware.test.ts new file mode 100644 index 00000000..546c1527 --- /dev/null +++ b/packages/authz/test/middleware.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test"; +import type { Context } from "@wrnexus/core"; +import { defineAuthz } from "../src/registry.ts"; +import { mergeCatalogs } from "../src/catalog.ts"; +import { memoryPermissionStore } from "../src/store.ts"; +import { authzMiddleware, can, filterCan, guardPermission } from "../src/middleware.ts"; + +const catalog = mergeCatalogs([ + { + source: "t.ts", + module: defineAuthz({ + permissions: { "post:read": { public: true }, "post:write": {}, "post:delete": {} }, + roles: { editor: ["post:write"] }, + policies: { + ownsPost: async (s: { id?: string }, r?: { authorId?: string }) => + r?.authorId === s?.id ? { allowed: true } : { allowed: false, reason: "not owner" }, + }, + bindings: { "post:delete": ["ownsPost"] }, + }), + }, +]); + +/** Minimal Context stand-in; the middleware only touches user, tenant, locals. */ +function makeCtx(user: unknown, tenantId?: string): Context { + return { + user, + tenant: tenantId ? { id: tenantId } : undefined, + locals: {}, + url: new URL("http://localhost/x"), + req: new Request("http://localhost/x"), + } as unknown as Context; +} + +const withMiddleware = async (ctx: Context, store = memoryPermissionStore()) => { + await authzMiddleware({ catalog, store, strict: false })(ctx, async () => new Response("ok")); + return store; +}; + +describe("authzMiddleware + can", () => { + test("can() resolves through the middleware-installed resolver", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.assignRole("u1", "editor"); + await withMiddleware(ctx, store); + expect(await can(ctx, "post:write")).toBe(true); + expect(await can(ctx, "post:delete", { authorId: "u1" })).toBe(false); + }); + + test("can() throws a clear setup error without the middleware", async () => { + const ctx = makeCtx({ id: "u1" }); + await expect(can(ctx, "post:read")).rejects.toThrow(/authzMiddleware/); + }); + + test("results are memoised per request", async () => { + const inner = memoryPermissionStore(); + let reads = 0; + const counting = { + ...inner, + assignmentsFor: (id: string, scope?: { tenantId?: string }) => { + reads++; + return inner.assignmentsFor(id, scope); + }, + }; + const ctx = makeCtx({ id: "u1" }); + await authzMiddleware({ catalog, store: counting, strict: false })( + ctx, + async () => new Response("ok"), + ); + await can(ctx, "post:write"); + await can(ctx, "post:write"); + expect(reads).toBe(1); + }); + + test("memoisation keys on the resource, not just the permission", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.grant("u1", "post:delete", "allow"); + await withMiddleware(ctx, store); + expect(await can(ctx, "post:delete", { authorId: "u1" })).toBe(true); + expect(await can(ctx, "post:delete", { authorId: "other" })).toBe(false); + }); + + test("the tenant on the context becomes the scope", async () => { + const ctx = makeCtx({ id: "u1" }, "t1"); + const store = memoryPermissionStore(); + await store.assignRole("u1", "editor", { tenantId: "t1" }); + await withMiddleware(ctx, store); + expect(await can(ctx, "post:write")).toBe(true); + }); +}); + +describe("guardPermission", () => { + test("calls next when allowed", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.assignRole("u1", "editor"); + await withMiddleware(ctx, store); + const res = await guardPermission("post:write")(ctx, async () => new Response("passed")); + expect(await res.text()).toBe("passed"); + }); + + test("returns 403 without leaking the reason by default", async () => { + const ctx = makeCtx({ id: "u1" }); + await withMiddleware(ctx); + const res = await guardPermission("post:write")(ctx, async () => new Response("passed")); + expect(res.status).toBe(403); + const body = (await res.json()) as Record; + expect(body).toEqual({ ok: false, error: "Forbidden" }); + }); + + test("exposeReason opts into diagnostics", async () => { + const ctx = makeCtx({ id: "u1" }); + await withMiddleware(ctx); + const res = await guardPermission("post:write", { exposeReason: true })( + ctx, + async () => new Response("passed"), + ); + const body = (await res.json()) as Record; + expect(body.reason).toBe("Missing permission"); + }); + + test("getResource feeds the bound policy", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.grant("u1", "post:delete", "allow"); + await withMiddleware(ctx, store); + const guard = guardPermission("post:delete", { getResource: () => ({ authorId: "u1" }) }); + const res = await guard(ctx, async () => new Response("passed")); + expect(await res.text()).toBe("passed"); + }); +}); + +describe("filterCan", () => { + test("keeps only the items the subject may act on", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.grant("u1", "post:delete", "allow"); + await withMiddleware(ctx, store); + const posts = [{ authorId: "u1" }, { authorId: "other" }, { authorId: "u1" }]; + expect(await filterCan(ctx, "post:delete", posts)).toHaveLength(2); + }); +});