import type { Context, Middleware } from "@wrnexus/core"; import type { Policy, Subject } from "./index.ts"; export interface AuthorizationDecision { allowed: boolean; reason?: string; policy?: string; metadata?: Record; } export type DecisionPolicy = ( subject: S, resource?: R, ) => AuthorizationDecision | Promise; export function allow(reason?: string, metadata?: Record): AuthorizationDecision { return { allowed: true, reason, metadata }; } export function deny( reason = "Forbidden", metadata?: Record, ): AuthorizationDecision { return { allowed: false, reason, metadata }; } export function decision( name: string, policy: Policy, denial = "Policy denied access", ): DecisionPolicy { return async (subject, resource) => { const allowed = await policy(subject, resource); return { allowed, reason: allowed ? undefined : denial, policy: name, }; }; } export function owner>( subjectKey: keyof SubjectType = "id", resourceKey: keyof Resource | string = "userId", ): DecisionPolicy { return (subject, resource) => { const subjectValue = subject?.[subjectKey]; const resourceValue = resource?.[resourceKey as keyof Resource]; // An absent id on either side must never satisfy ownership. if (subjectValue === undefined || subjectValue === null) return deny("resource ownership required"); if (resourceValue === undefined || resourceValue === null) return deny("resource ownership required"); return Object.is(subjectValue, resourceValue) ? allow("resource owner") : deny("resource ownership required"); }; } export function anyDecision(...policies: DecisionPolicy[]): DecisionPolicy { return async (subject, resource) => { const denied: AuthorizationDecision[] = []; for (const policy of policies) { const result = await policy(subject, resource); if (result.allowed) return result; denied.push(result); } return deny( denied .map((item) => item.reason) .filter(Boolean) .join("; ") || "No policy allowed access", ); }; } export function allDecisions(...policies: DecisionPolicy[]): DecisionPolicy { return async (subject, resource) => { for (const policy of policies) { const result = await policy(subject, resource); if (!result.allowed) return result; } return allow("all policies passed"); }; } export interface AuthorizeDecisionOptions { /** * Include `reason` and `policy` in the 403 body. Off by default: policy * names describe internal authorization structure and should not reach an * unauthenticated caller. */ exposeReason?: boolean; } export function authorizeDecision( evaluate: (ctx: Context) => AuthorizationDecision | Promise, options: AuthorizeDecisionOptions = {}, ): Middleware { return async (ctx, next) => { const result = await evaluate(ctx); if (result.allowed) return next(); return Response.json( options.exposeReason ? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy } : { ok: false, error: "Forbidden" }, { status: 403 }, ); }; } export function filterAuthorized( subject: S, values: readonly R[], policy: Policy, ): Promise { return Promise.all( values.map(async (value) => ({ value, allowed: await policy(subject, value) })), ).then((results) => results.filter((result) => result.allowed).map((result) => result.value)); }