Files
WRNexusJS/packages/authz/src/advanced.ts
T

113 lines
3.7 KiB
TypeScript

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<string, unknown>;
}
export type DecisionPolicy<S = Subject, R = unknown> = (
subject: S,
resource?: R,
) => AuthorizationDecision | Promise<AuthorizationDecision>;
export function allow(reason?: string, metadata?: Record<string, unknown>): AuthorizationDecision {
return { allowed: true, reason, metadata };
}
export function deny(
reason = "Forbidden",
metadata?: Record<string, unknown>,
): AuthorizationDecision {
return { allowed: false, reason, metadata };
}
export function decision<S, R>(
name: string,
policy: Policy<S, R>,
denial = "Policy denied access",
): DecisionPolicy<S, R> {
return async (subject, resource) => {
const allowed = await policy(subject, resource);
return {
allowed,
reason: allowed ? undefined : denial,
policy: name,
};
};
}
export function owner<SubjectType extends Subject, Resource extends Record<string, unknown>>(
subjectKey: keyof SubjectType = "id",
resourceKey: keyof Resource | string = "userId",
): DecisionPolicy<SubjectType, Resource> {
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<S, R>(...policies: DecisionPolicy<S, R>[]): DecisionPolicy<S, R> {
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<S, R>(...policies: DecisionPolicy<S, R>[]): DecisionPolicy<S, R> {
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<AuthorizationDecision>,
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<S, R>(
subject: S,
values: readonly R[],
policy: Policy<S, R>,
): Promise<R[]> {
return Promise.all(
values.map(async (value) => ({ value, allowed: await policy(subject, value) })),
).then((results) => results.filter((result) => result.allowed).map((result) => result.value));
}