release: WRNexusJS 0.4.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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) =>
|
||||
resource && Object.is(subject[subjectKey], resource[resourceKey as keyof Resource])
|
||||
? 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 function authorizeDecision(
|
||||
evaluate: (ctx: Context) => AuthorizationDecision | Promise<AuthorizationDecision>,
|
||||
): Middleware {
|
||||
return async (ctx, next) => {
|
||||
const result = await evaluate(ctx);
|
||||
if (result.allowed) return next();
|
||||
return Response.json(
|
||||
{ ok: false, error: "Forbidden", reason: result.reason, policy: result.policy },
|
||||
{ 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));
|
||||
}
|
||||
@@ -128,3 +128,14 @@ export function requireRole(...roles: string[]): Middleware {
|
||||
export function requirePermission(rbac: Rbac, permission: string): Middleware {
|
||||
return authorize((ctx) => rbac.can(ctx.user as Subject | undefined, permission));
|
||||
}
|
||||
export {
|
||||
allow,
|
||||
deny,
|
||||
decision,
|
||||
owner,
|
||||
anyDecision,
|
||||
allDecisions,
|
||||
authorizeDecision,
|
||||
filterAuthorized,
|
||||
} from "./advanced.ts";
|
||||
export type { AuthorizationDecision, DecisionPolicy } from "./advanced.ts";
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
any,
|
||||
all,
|
||||
attr,
|
||||
decision,
|
||||
type Policy,
|
||||
} from "../src/index.ts";
|
||||
|
||||
@@ -78,3 +79,17 @@ test("guards: authorize / requireRole / requirePermission", async () => {
|
||||
(await authorize((c) => (c.user as User)?.id === "u1")(ctx({ id: "u1" }), ok)).status,
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
test("explainable decisions only include denial reasons when denied", async () => {
|
||||
const policy = decision("owner", (subject: User) => subject.id === "u1");
|
||||
expect(await policy({ id: "u1" })).toEqual({
|
||||
allowed: true,
|
||||
reason: undefined,
|
||||
policy: "owner",
|
||||
});
|
||||
expect(await policy({ id: "u2" })).toEqual({
|
||||
allowed: false,
|
||||
reason: "Policy denied access",
|
||||
policy: "owner",
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user