first commit
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
# @wrnexus/authz
|
||||
|
||||
> Composable authorization for WrNexus — role-based (RBAC), policy-based (PBAC), and attribute-based (ABAC) access control that reduces to a boolean check plus an `authorize()` guard.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/authz` is a small, server-side authorization toolkit. It gives you three
|
||||
interchangeable models — RBAC (roles → permissions), PBAC (policy predicates), and
|
||||
ABAC (attribute matchers) — that all collapse to a `boolean | Promise<boolean>` decision.
|
||||
Wrap any decision in a `Middleware` guard (`authorize`, `requireRole`, `requirePermission`)
|
||||
to protect WrNexus routes. Reach for it whenever a route or action needs to be gated on who
|
||||
the user is, what roles they hold, or attributes of the user and the resource. It plugs into
|
||||
`@wrnexus/core` by reading `ctx.user` as the authorization subject.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/authz
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
The package has a single entry point (`@wrnexus/authz`) exporting the following.
|
||||
|
||||
### Types
|
||||
|
||||
| Symbol | Description |
|
||||
| ---------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `Subject` | The authorized principal: `{ id?: string; roles?: string[]; [attribute: string]: unknown }`. |
|
||||
| `Rbac` | An RBAC checker: `{ can(subject, permission): boolean; permissionsFor(roles): Set<string> }`. |
|
||||
| `Policy<S = Subject, R = unknown>` | A predicate `(subject: S, resource?: R) => boolean \| Promise<boolean>`. |
|
||||
|
||||
### RBAC
|
||||
|
||||
#### `defineRbac(roles: Record<string, string[]>): Rbac`
|
||||
|
||||
Builds an RBAC checker from a role → permissions map. Supported permission forms:
|
||||
|
||||
- `"*"` — grants every permission.
|
||||
- `"ns:*"` — namespace wildcard (e.g. `"post:*"` grants `"post:write"`).
|
||||
- `"role:<name>"` — inherits all permissions of another role (resolved recursively, cycle-safe).
|
||||
|
||||
The returned `Rbac` provides:
|
||||
|
||||
- `can(subject, permission)` — `true` if any of `subject.roles` grants `permission` (honouring `*` and namespace wildcards). Returns `false` when the subject has no roles.
|
||||
- `permissionsFor(roles)` — the resolved `Set<string>` of all permissions granted to a set of roles.
|
||||
|
||||
#### `hasRole(subject: Subject | undefined, ...required: string[]): boolean`
|
||||
|
||||
`true` if the subject holds **all** of the given roles.
|
||||
|
||||
### PBAC / ABAC combinators
|
||||
|
||||
- `any<S, R>(...policies: Policy<S, R>[]): Policy<S, R>` — allow if **any** policy passes (OR); awaits async policies.
|
||||
- `all<S, R>(...policies: Policy<S, R>[]): Policy<S, R>` — allow only if **all** policies pass (AND); awaits async policies.
|
||||
- `attr<S extends Subject>(name: string, match: unknown | ((value: unknown) => boolean)): Policy<S>` — ABAC helper that allows when `subject[name]` equals `match`, or when `match` is a function, when `match(value)` is truthy.
|
||||
|
||||
### Guards (middleware)
|
||||
|
||||
Each guard returns a `@wrnexus/core` `Middleware`. A denied request short-circuits with
|
||||
`Response.json({ ok: false, error: "Forbidden" }, { status: 403 })`.
|
||||
|
||||
- `authorize(policy: (ctx: Context) => boolean | Promise<boolean>): Middleware` — runs `policy` against the request `Context`; calls `next()` when it resolves truthy, otherwise returns 403.
|
||||
- `requireRole(...roles: string[]): Middleware` — allows when `ctx.user` holds **any** of the listed roles.
|
||||
- `requirePermission(rbac: Rbac, permission: string): Middleware` — allows when `rbac.can(ctx.user, permission)` is `true`.
|
||||
|
||||
## Usage
|
||||
|
||||
### RBAC
|
||||
|
||||
```ts
|
||||
import { defineRbac, hasRole } from "@wrnexus/authz";
|
||||
|
||||
const rbac = defineRbac({
|
||||
admin: ["*"],
|
||||
editor: ["post:read", "post:write"],
|
||||
viewer: ["post:read"],
|
||||
// role inheritance: lead gets everything an editor has, plus post:publish
|
||||
lead: ["role:editor", "post:publish"],
|
||||
});
|
||||
|
||||
const user = { id: "u1", roles: ["editor"] };
|
||||
|
||||
rbac.can(user, "post:write"); // true
|
||||
rbac.can(user, "post:delete"); // false
|
||||
rbac.permissionsFor(["lead"]); // Set { "post:read", "post:write", "post:publish" }
|
||||
hasRole(user, "editor"); // true
|
||||
```
|
||||
|
||||
### Guarding routes
|
||||
|
||||
```ts
|
||||
import { authorize, requireRole, requirePermission, defineRbac } from "@wrnexus/authz";
|
||||
|
||||
const rbac = defineRbac({ admin: ["*"], editor: ["post:read", "post:write"] });
|
||||
|
||||
// Only admins or editors
|
||||
app.get("/dashboard", requireRole("admin", "editor"), handler);
|
||||
|
||||
// Requires a specific permission
|
||||
app.post("/posts", requirePermission(rbac, "post:write"), handler);
|
||||
|
||||
// Arbitrary policy over the request context
|
||||
app.delete(
|
||||
"/posts/:id",
|
||||
authorize((ctx) => hasRole(ctx.user, "admin")),
|
||||
handler,
|
||||
);
|
||||
```
|
||||
|
||||
### PBAC / ABAC policies
|
||||
|
||||
```ts
|
||||
import { any, all, attr, authorize, type Policy } from "@wrnexus/authz";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
department?: string;
|
||||
roles?: string[];
|
||||
}
|
||||
interface Post {
|
||||
authorId: string;
|
||||
}
|
||||
|
||||
// Ownership policy (subject + resource)
|
||||
const ownsPost: Policy<User, Post> = (u, post) => u.id === post?.authorId;
|
||||
|
||||
// ABAC: attribute equality, or a predicate
|
||||
const inEngineering = attr<User>("department", "engineering");
|
||||
const isVerified = attr<User>("verified", (v) => v === true);
|
||||
|
||||
// Compose: allow if the user owns the post OR is in engineering AND verified
|
||||
const canEdit = any(ownsPost, all(inEngineering, isVerified));
|
||||
|
||||
app.put(
|
||||
"/posts/:id",
|
||||
authorize((ctx) => canEdit(ctx.user as User, loadPost(ctx))),
|
||||
handler,
|
||||
);
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only** — like the rest of WrNexus, this package targets the Bun runtime; Node is not supported.
|
||||
- Works with [`@wrnexus/core`](../core) — the guards return `Middleware` and read the subject from `ctx.user` on the request `Context`. Both types are imported from `@wrnexus/core`.
|
||||
- Policy combinators (`any`, `all`) and `authorize` are async-aware, so policies may return a `Promise<boolean>` (e.g. for a database ownership check).
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.2.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @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));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { createContext } from "@wrnexus/core";
|
||||
import {
|
||||
defineRbac,
|
||||
hasRole,
|
||||
authorize,
|
||||
requireRole,
|
||||
requirePermission,
|
||||
any,
|
||||
all,
|
||||
attr,
|
||||
type Policy,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const rbac = defineRbac({
|
||||
admin: ["*"],
|
||||
editor: ["post:read", "post:write"],
|
||||
viewer: ["post:read"],
|
||||
moderator: ["role:editor", "comment:delete"], // inherits editor
|
||||
});
|
||||
|
||||
test("RBAC: roles, wildcards, namespaces, inheritance", () => {
|
||||
expect(rbac.can({ roles: ["viewer"] }, "post:read")).toBe(true);
|
||||
expect(rbac.can({ roles: ["viewer"] }, "post:write")).toBe(false);
|
||||
expect(rbac.can({ roles: ["admin"] }, "anything:goes")).toBe(true); // "*"
|
||||
expect(rbac.can({ roles: ["moderator"] }, "post:write")).toBe(true); // inherited from editor
|
||||
expect(rbac.can({ roles: ["moderator"] }, "comment:delete")).toBe(true);
|
||||
expect(rbac.can(undefined, "post:read")).toBe(false);
|
||||
expect(defineRbac({ ed: ["post:*"] }).can({ roles: ["ed"] }, "post:write")).toBe(true); // ns wildcard
|
||||
});
|
||||
|
||||
test("hasRole", () => {
|
||||
expect(hasRole({ roles: ["a", "b"] }, "a")).toBe(true);
|
||||
expect(hasRole({ roles: ["a"] }, "a", "b")).toBe(false);
|
||||
});
|
||||
|
||||
interface User extends Record<string, unknown> {
|
||||
id?: string;
|
||||
roles?: string[];
|
||||
tenant?: string;
|
||||
}
|
||||
interface Post {
|
||||
authorId: string;
|
||||
}
|
||||
|
||||
test("PBAC/ABAC: policies compose (any/all) + attribute match", async () => {
|
||||
const ownsPost: Policy<User, Post> = (u, post) => u.id === post?.authorId;
|
||||
const isAdmin: Policy<User> = (u) => (u.roles ?? []).includes("admin");
|
||||
const canEdit = any(ownsPost, isAdmin);
|
||||
|
||||
expect(await canEdit({ id: "u1" }, { authorId: "u1" })).toBe(true); // owner
|
||||
expect(await canEdit({ id: "u2", roles: ["admin"] }, { authorId: "u1" })).toBe(true); // admin
|
||||
expect(await canEdit({ id: "u2" }, { authorId: "u1" })).toBe(false);
|
||||
|
||||
const sameTenant = all(isAdmin, attr<User>("tenant", "acme"));
|
||||
expect(await sameTenant({ roles: ["admin"], tenant: "acme" })).toBe(true);
|
||||
expect(await sameTenant({ roles: ["admin"], tenant: "other" })).toBe(false);
|
||||
});
|
||||
|
||||
function ctx(user?: unknown) {
|
||||
const url = new URL("http://x/admin");
|
||||
const c = createContext(new Request(url), url);
|
||||
c.user = user;
|
||||
return c;
|
||||
}
|
||||
|
||||
test("guards: authorize / requireRole / requirePermission", async () => {
|
||||
const ok = () => new Response("ok");
|
||||
expect((await requireRole("admin")(ctx({ roles: ["admin"] }), ok)).status).toBe(200);
|
||||
expect((await requireRole("admin")(ctx({ roles: ["viewer"] }), ok)).status).toBe(403);
|
||||
expect((await requirePermission(rbac, "post:write")(ctx({ roles: ["editor"] }), ok)).status).toBe(
|
||||
200,
|
||||
);
|
||||
expect((await requirePermission(rbac, "post:write")(ctx({ roles: ["viewer"] }), ok)).status).toBe(
|
||||
403,
|
||||
);
|
||||
expect(
|
||||
(await authorize((c) => (c.user as User)?.id === "u1")(ctx({ id: "u1" }), ok)).status,
|
||||
).toBe(200);
|
||||
});
|
||||
Reference in New Issue
Block a user