feat(authz): add request middleware, can(), and guardPermission

Installs a per-request authz resolver via authzMiddleware and exposes
can()/decideFor()/guardPermission()/filterCan() as free functions (not
Context members, so @wrnexus/core stays free of an authz dependency).
All four route through resolver.decide(), never permissionsFor(), so
resource-scoped policy denials can't be bypassed via the coarse
permission set. Per-request results are memoised keyed on (permission,
resource) to avoid re-hitting the store within a request without
leaking one resource's verdict onto another.
This commit is contained in:
2026-08-04 18:24:40 +05:30
parent cd82bec414
commit 984c6236d3
2 changed files with 260 additions and 0 deletions
+118
View File
@@ -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<string, Promise<AuthorizationDecision>>;
}
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}::<unserialisable>`;
}
}
export function decideFor(
ctx: Context,
permission: string,
resource?: unknown,
): Promise<AuthorizationDecision> {
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<boolean> {
return (await decideFor(ctx, permission, resource)).allowed;
}
export interface GuardOptions {
/** Load the resource a bound policy needs. */
getResource?: (ctx: Context) => unknown | Promise<unknown>;
/** 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<T>(
ctx: Context,
permission: string,
items: readonly T[],
): Promise<T[]> {
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);
}