113 lines
3.7 KiB
TypeScript
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));
|
|
}
|