329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
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<boolean>;
|
|
decide(permission: string, resource?: unknown): Promise<AuthorizationDecision>;
|
|
}
|
|
|
|
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<object, Map<string, Promise<AuthorizationDecision>>>;
|
|
/** Memo for symbol resources, keyed by identity for the same reason. */
|
|
bySymbol: Map<symbol, Map<string, Promise<AuthorizationDecision>>>;
|
|
/** Memo for primitive and absent resources. */
|
|
byValue: 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,
|
|
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<AuthorizationDecision> {
|
|
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<boolean> {
|
|
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<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);
|
|
}
|