import type { Context, Middleware } from "@wrnexus/core"; import type { AuthorizationDecision } from "./advanced.ts"; import { safeRecord, type AuthzAuditSink } from "./audit.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"; export interface AuthorizationResponses { forbidden(decision?: AuthorizationDecision, options?: { exposeReason?: boolean }): Response; notFoundOrForbidden(options?: { mayDiscover?: boolean; decision?: AuthorizationDecision; exposeReason?: boolean; }): Response; } export interface RequestAuthorization extends AuthorizationResponses { can(permission: string, resource?: unknown): Promise; decide(permission: string, resource?: unknown): Promise; } declare module "@wrnexus/core" { interface Context { /** Installed by authzMiddleware for handlers that prefer context-local authorization. */ authz?: RequestAuthorization; } } interface RequestAuthz { resolver: AuthzResolver; /** * Same sink `decide()` records through. Stashed here too so a denial that * never reaches the resolver (e.g. `guardPermission`'s `getResource` * throwing) can still be audited, instead of vanishing from the trail. */ audit: AuthzAuditSink | undefined; /** Memo for object resources, keyed by identity so two rows never collide. */ byRef: WeakMap>>; /** Memo for symbol resources, keyed by identity for the same reason. */ bySymbol: Map>>; /** Memo for primitive and absent resources. */ byValue: 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, audit: options.audit, byRef: new WeakMap(), bySymbol: new Map(), byValue: new Map(), }; ctx.locals[AUTHZ_LOCALS_KEY] = request; ctx.authz = { can: (permission, resource) => can(ctx, permission, resource), decide: (permission, resource) => decideFor(ctx, permission, resource), forbidden: (decision, responseOptions) => forbiddenResponse(decision, responseOptions?.exposeReason), notFoundOrForbidden: (responseOptions = {}) => responseOptions.mayDiscover ? Response.json( { ok: false, error: "Not Found" }, { status: 404, headers: NO_STORE_HEADERS }, ) : forbiddenResponse(responseOptions.decision, responseOptions.exposeReason), }; return next(); }; } function forbiddenResponse(decision?: AuthorizationDecision, exposeReason = false): Response { return Response.json( exposeReason ? { ok: false, error: "Forbidden", reason: decision?.reason, policy: decision?.policy } : { ok: false, error: "Forbidden" }, { status: 403, headers: NO_STORE_HEADERS }, ); } export function getRequestAuthorization(ctx: Context): RequestAuthorization { if (!ctx.authz) { throw new Error("WRN-AUTHZ-SETUP: authzMiddleware() is not registered for this request."); } return ctx.authz; } /** * Read the tenant from the context at decision time, not at middleware time: * a request that switches tenant mid-flight must not keep the old scope. */ function currentScope(ctx: Context): AuthzScope | undefined { const tenantId = ctx.tenant?.id; return typeof tenantId === "string" && tenantId !== "" ? { tenantId } : undefined; } /** * Same normalization `decide()` applies before handing a subject id to the * audit sink: a non-empty string, or undefined (never a raw non-string id * leaking into an audit record). */ function subjectIdOf(ctx: Context): string | undefined { const rawId = (ctx.user as { id?: unknown } | null | undefined)?.id; return typeof rawId === "string" && rawId !== "" ? rawId : undefined; } /** * Object resources are memoised by identity (`byRef`), never by serialising * their contents — serialisation is what let unrelated resources collide * (same `id` shape, circular references, BigInt fields, throwing getters all * funnelled into one bucket). Symbols are memoised by identity too (`bySymbol`) * since `String(symbol)` collapses distinct symbols with the same description. * Primitive/absent resources are memoised by a * `[scope, permission, typeof, String(value)]` tuple, with `-0` rendered * distinctly from `0` since `String(-0) === "0"` would otherwise merge them. * * Subject and scope are both part of the key. A request that reassigns * ctx.user (impersonation, step-up auth, session revocation) or ctx.tenant * must not be served the previous principal's verdict from the memo. */ export function decideFor( ctx: Context, permission: string, resource?: unknown, ): Promise { const request = readAuthz(ctx); const scope = currentScope(ctx); const subjectId = (ctx.user as { id?: unknown } | null | undefined)?.id; const key = JSON.stringify([ scope?.tenantId ?? "", permission, typeof subjectId, String(subjectId), ]); const run = () => request.resolver.decide({ subject: ctx.user as { id?: string } | null | undefined, permission, resource, scope, }); // Symbols carry identity that String() erases, so they memo by identity too. // They are held in a plain Map rather than the WeakMap: the memo is discarded // with the request, so there is nothing to leak. if (typeof resource === "symbol") { let perSymbol = request.bySymbol.get(resource); if (!perSymbol) request.bySymbol.set(resource, (perSymbol = new Map())); const cached = perSymbol.get(key); if (cached) return cached; const pending = run(); perSymbol.set(key, pending); return pending; } const isObjectResource = resource !== null && resource !== undefined && (typeof resource === "object" || typeof resource === "function"); if (isObjectResource) { const resourceObject = resource as object; let inner = request.byRef.get(resourceObject); if (!inner) { inner = new Map(); request.byRef.set(resourceObject, inner); } const cached = inner.get(key); if (cached) return cached; const pending = run(); inner.set(key, pending); return pending; } const rendered = Object.is(resource, -0) ? "-0" : String(resource); const valueKey = JSON.stringify([key, typeof resource, rendered]); const cached = request.byValue.get(valueKey); if (cached) return cached; const pending = run(); request.byValue.set(valueKey, pending); return pending; } export async function can(ctx: Context, permission: string, resource?: unknown): Promise { return (await decideFor(ctx, permission, resource)).allowed; } /** * Replicates `packages/core/src/auth.ts`'s `wantsJson` (not imported: authz * may only pull TYPES from @wrnexus/core, never runtime code). */ function wantsJson(ctx: Context): boolean { if (ctx.url.pathname.startsWith("/api/")) return true; const accept = ctx.req.headers.get("accept") ?? ""; return accept.includes("application/json") && !accept.includes("text/html"); } /** * Refuse anything but a same-origin, same-app path: no scheme/host * (`https://evil.example.com/...`), no protocol-relative target (`//evil...` * is host-relative in a browser, not path-relative), no backslashes (some * user agents treat `\` as `/`, which can smuggle a host past a naive * `startsWith("/")` check), and no control characters (CR/LF header/response * splitting, etc). Written as a codepoint loop rather than a control-char * regex literal, which tooling in this repo mangles. */ function isLocalPath(target: string): boolean { if (!target.startsWith("/")) return false; if (target.startsWith("//")) return false; if (target.includes("\\")) return false; for (const ch of target) { const code = ch.codePointAt(0) ?? 0; if (code < 0x20 || code === 0x7f) return false; } return true; } /** * Header values must be Latin-1, so a localized path would otherwise throw * inside `new Response` and 500 on a denial path. Encode ONLY the codepoints * that cannot be sent: encodeURI would also escape "%", corrupting a target * that already carries a percent-encoded return path. */ function headerSafePath(value: string): string { let out = ""; for (const character of value) { out += character.codePointAt(0)! <= 0x7f ? character : encodeURIComponent(character); } return out; } const NO_STORE_HEADERS = { "cache-control": "private, no-store" } as const; export interface GuardOptions { /** Load the resource a bound policy needs. */ getResource?: (ctx: Context) => unknown; /** Include reason and policy name in the 403 body. Off by default. */ exposeReason?: boolean; /** Redirect page requests here instead of returning 403. Ignored for JSON/API requests and for any non-local target. */ 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) => { let resource: unknown; if (options.getResource) { try { resource = await options.getResource(ctx); } catch (error) { console.error(`[wrnexus:authz] getResource threw for '${permission}'; denying`, error); // This denial never reaches decideFor()/decide()/finish() — the // resource load failed before there was anything to decide — so // without recording here it would be invisible to the audit trail: // an attacker probing ids that make the loader throw gets a clean // 403 stream no operator can see. Keep the response body opaque // (no loader message), same as every other guardPermission denial. const { audit } = readAuthz(ctx); safeRecord(audit, { subjectId: subjectIdOf(ctx), scope: currentScope(ctx), permission, allowed: false, reason: "Resource unavailable", at: Date.now(), }); return forbiddenResponse(); } } const result = await decideFor(ctx, permission, resource); if (result.allowed) return next(); if (options.redirectTo && !wantsJson(ctx)) { if (isLocalPath(options.redirectTo)) { return new Response(null, { status: 303, // headerSafePath, not encodeURI: a non-ASCII local path (e.g. a // localized login route) is valid config but not a valid raw // header value, while encodeURI would also mangle a target that // already carries a percent-encoded return path. headers: { location: headerSafePath(options.redirectTo), ...NO_STORE_HEADERS }, }); } // JSON.stringify, not string interpolation: this branch exists precisely // for targets containing CR/LF, which must not reach the log verbatim. console.error( `[wrnexus:authz] guardPermission redirectTo ${JSON.stringify(options.redirectTo)} is not a local path; falling back to 403`, ); } return forbiddenResponse(result, options.exposeReason); }; } /** 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); }