142 lines
4.7 KiB
TypeScript
142 lines
4.7 KiB
TypeScript
/**
|
|
* @wrnexus/authz — authorization: role-based (RBAC), policy-based (PBAC), and
|
|
* attribute-based (ABAC). Compose freely; all three reduce to a boolean check
|
|
* plus an `authorize()` guard middleware.
|
|
*
|
|
* const rbac = defineRbac({ admin: ["*"], editor: ["post:read", "post:write"] });
|
|
* rbac.can(user, "post:write");
|
|
*
|
|
* // PBAC/ABAC: a policy is a predicate over subject + resource + attributes
|
|
* const ownsPost: Policy<User, Post> = (u, post) => u.id === post.authorId;
|
|
* authorize((ctx) => ownsPost(ctx.user, resource)) // middleware
|
|
*/
|
|
|
|
import type { Context, Middleware } from "@wrnexus/core";
|
|
|
|
export interface Subject {
|
|
id?: string;
|
|
roles?: string[];
|
|
[attribute: string]: unknown;
|
|
}
|
|
|
|
// --- RBAC ------------------------------------------------------------------
|
|
|
|
export interface Rbac {
|
|
/** True if any of the subject's roles grants `permission` (supports "*" and "ns:*"). */
|
|
can(subject: Subject | undefined, permission: string): boolean;
|
|
/** All permissions granted to a set of roles. */
|
|
permissionsFor(roles: string[]): Set<string>;
|
|
}
|
|
|
|
/** Build an RBAC checker from a role → permissions map. */
|
|
export function defineRbac(roles: Record<string, string[]>): Rbac {
|
|
const grants = (role: string, seen = new Set<string>()): string[] => {
|
|
if (seen.has(role)) return [];
|
|
seen.add(role);
|
|
const out: string[] = [];
|
|
for (const p of roles[role] ?? []) {
|
|
// A permission that names another role (prefixed "role:") inherits it.
|
|
if (p.startsWith("role:")) out.push(...grants(p.slice(5), seen));
|
|
else out.push(p);
|
|
}
|
|
return out;
|
|
};
|
|
const permissionsFor = (subjectRoles: string[]): Set<string> => {
|
|
const set = new Set<string>();
|
|
for (const r of subjectRoles) for (const p of grants(r)) set.add(p);
|
|
return set;
|
|
};
|
|
return {
|
|
permissionsFor,
|
|
can(subject, permission) {
|
|
if (!subject?.roles?.length) return false;
|
|
const perms = permissionsFor(subject.roles);
|
|
if (perms.has("*") || perms.has(permission)) return true;
|
|
// Namespace wildcards: "post:*" grants "post:write".
|
|
const ns = permission.includes(":")
|
|
? permission.slice(0, permission.indexOf(":")) + ":*"
|
|
: null;
|
|
return ns ? perms.has(ns) : false;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** True if the subject has ALL of the given roles. */
|
|
export function hasRole(subject: Subject | undefined, ...required: string[]): boolean {
|
|
const roles = new Set(subject?.roles ?? []);
|
|
return required.every((r) => roles.has(r));
|
|
}
|
|
|
|
// --- PBAC / ABAC -----------------------------------------------------------
|
|
|
|
/** A policy predicate: subject (+ optional resource/attributes) → allowed. */
|
|
export type Policy<S = Subject, R = unknown> = (
|
|
subject: S,
|
|
resource?: R,
|
|
) => boolean | Promise<boolean>;
|
|
|
|
/** Combine policies: allow if ANY passes (OR). */
|
|
export function any<S, R>(...policies: Policy<S, R>[]): Policy<S, R> {
|
|
return async (s, r) => {
|
|
for (const p of policies) if (await p(s, r)) return true;
|
|
return false;
|
|
};
|
|
}
|
|
|
|
/** Combine policies: allow only if ALL pass (AND). */
|
|
export function all<S, R>(...policies: Policy<S, R>[]): Policy<S, R> {
|
|
return async (s, r) => {
|
|
for (const p of policies) if (!(await p(s, r))) return false;
|
|
return true;
|
|
};
|
|
}
|
|
|
|
/** ABAC helper: allow when an attribute matches (equality or predicate). */
|
|
export function attr<S extends Subject>(
|
|
name: string,
|
|
match: unknown | ((value: unknown) => boolean),
|
|
): Policy<S> {
|
|
return (subject) => {
|
|
const value = subject?.[name];
|
|
return typeof match === "function"
|
|
? (match as (v: unknown) => boolean)(value)
|
|
: value === match;
|
|
};
|
|
}
|
|
|
|
// --- Guards (middleware) ---------------------------------------------------
|
|
|
|
function forbidden(): Response {
|
|
return Response.json({ ok: false, error: "Forbidden" }, { status: 403 });
|
|
}
|
|
|
|
/** Guard a route with a policy over `ctx` (reads `ctx.user` as the subject). */
|
|
export function authorize(policy: (ctx: Context) => boolean | Promise<boolean>): Middleware {
|
|
return async (ctx, next) => ((await policy(ctx)) ? next() : forbidden());
|
|
}
|
|
|
|
/** Guard requiring one of the given roles. */
|
|
export function requireRole(...roles: string[]): Middleware {
|
|
return authorize((ctx) => {
|
|
const subject = ctx.user as Subject | undefined;
|
|
const have = new Set(subject?.roles ?? []);
|
|
return roles.some((r) => have.has(r));
|
|
});
|
|
}
|
|
|
|
/** Guard requiring an RBAC permission. */
|
|
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";
|