The middleware's per-request memo keyed resources by String(resource.id) with
an unserialisable fallback that shared one bucket. Six demonstrated cases
cross-authorized: {id:1} vs the primitive 1; {id:7} vs {id:"7"}; object ids;
and every circular / BigInt / throwing-getter row collapsing together so the
first verdict in a request became the verdict for all of them. filterCan
returned 3 of 3 rows where 1 was permitted - it leaked, rather than denied.
Object resources now memo by identity through a WeakMap; primitives key on
JSON-encoded [scope, permission, typeof, value] so 7 and "7" stay distinct
and a tenant id containing the separator cannot collide.
Scope is also read at decision time rather than frozen when the middleware
runs, and is part of the memo key, so switching tenant mid-request no longer
returns the previous tenant's verdict.
guardPermission additionally: denies instead of 500ing when getResource
throws (and no longer leaks the loader's message), skips redirectTo for API
requests using the same rule requireAuth applies, refuses a non-local
redirect target, and sets cache-control: private, no-store.
Adds eleven regression tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
121 KiB
Permissions System Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Extend @wrnexus/authz so permissions, roles, policies and attributes are declared in code and discoverable, while role assignments live in a pluggable store.
Architecture: A registry (defineAuthz) declares what exists; a catalog merges declarations and freezes at boot; a store (PermissionStore) holds who-has-what; an engine resolves a subject to effective permissions and returns an AuthorizationDecision. The decision primitives already in advanced.ts are the evaluation layer and are not replaced.
Tech Stack: TypeScript, Bun (bun:test), @wrnexus/core (Context/Middleware types only), @wrnexus/db (Db interface, migrations).
Global Constraints
- Every
@wrnexus/*package is version0.8.4. Do not change versions. - Zero runtime npm dependencies. Use only Bun/WebCrypto/node: builtins.
@wrnexus/coreMUST NOT import@wrnexus/authz.can()stays offContext; the resolver lives inctx.locals._authz.@wrnexus/authzmay import types only from@wrnexus/core(import type { Context, Middleware }).- Existing exports of
@wrnexus/authzkeep working unchanged, with ONE approved exception: Task 8 changes the default 403 body ofauthorizeDecisionto stop disclosing policy internals. That break is intentional and ruled on; everything else is additive. - Framework-owned tables use the
_wrn_prefix (matching_wrn_tenant,_wrn_cursor). The spec wrotewrn_authz_assignment; use_wrn_authz_assignmentand_wrn_authz_grant. requirePermissionis already exported with signature(rbac: Rbac, permission: string). Do not change it. The new resource-aware guard is namedguardPermission.- Every failure path denies. Never fail open.
- After any change to
packages/authz/src/index.tsexports, regenerate the API baseline withbun run generate:public-api. - Full gate before declaring done:
bun run check:production. - Test files live in
packages/<pkg>/test/*.test.tsand useimport { describe, expect, test } from "bun:test".
File Structure
Created:
| File | Responsibility |
|---|---|
packages/authz/src/types.ts |
Shared types: AuthzScope, PermissionMeta, AuthzModule, AuthzCatalog, SubjectAssignments |
packages/authz/src/registry.ts |
defineAuthz() — validate and freeze one declaration module |
packages/authz/src/catalog.ts |
mergeCatalogs() — merge modules, detect conflicts, freeze |
packages/authz/src/store.ts |
PermissionStore interface, memoryPermissionStore(), cachedPermissionStore() |
packages/authz/src/audit.ts |
AuthzAuditSink, memoryAuditSink(), consoleAuditSink() |
packages/authz/src/engine.ts |
createAuthzResolver() — effective permissions, precedence, fail-closed |
packages/authz/src/middleware.ts |
authzMiddleware(), can(), decideFor(), guardPermission() |
packages/authz/src/db.ts |
dbPermissionStore(db) — subpath export @wrnexus/authz/db |
packages/authz/src/migrations.ts |
authzMigrationSql(dialect) — DDL for the two tables |
packages/authz/src/codegen.ts |
generatePermissionTypes(catalog) — emits the Permission/Role unions |
packages/authz/test/store-conformance.ts |
Shared suite both store adapters must pass (not a .test.ts) |
packages/cli/src/authz.ts |
runAuthzCommand(root, sub, args) for list / init / generate |
Modified:
| File | Change |
|---|---|
packages/authz/src/index.ts |
Re-export the new surface |
packages/authz/src/advanced.ts |
authorizeDecision gains { exposeReason }, defaulting to off |
packages/authz/package.json |
Add ./db subpath export |
packages/router/src/index.ts |
Discover app/authz/*.{ts,js} into router.authz |
packages/cli/src/index.ts |
Dispatch case "authz" |
docs/public-api-0.8.json |
Regenerated baseline |
Task 1: Types and registry
Files:
- Create:
packages/authz/src/types.ts - Create:
packages/authz/src/registry.ts - Test:
packages/authz/test/registry.test.ts
Interfaces:
-
Consumes:
DecisionPolicyfrom./advanced.ts -
Produces:
AuthzScope,PermissionMeta,AuthzModule,AuthzCatalog,SubjectAssignments,defineAuthz(module: AuthzModule): AuthzModule -
Step 1: Write the failing test
Create packages/authz/test/registry.test.ts:
import { describe, expect, test } from "bun:test";
import { defineAuthz } from "../src/registry.ts";
describe("defineAuthz", () => {
test("returns a frozen module", () => {
const mod = defineAuthz({
permissions: { "post:read": { title: "View posts" } },
roles: { editor: ["post:*"] },
});
expect(Object.isFrozen(mod)).toBe(true);
expect(mod.permissions!["post:read"]!.title).toBe("View posts");
expect(mod.roles!.editor).toEqual(["post:*"]);
});
test("defaults missing sections to empty objects", () => {
const mod = defineAuthz({});
expect(mod.permissions).toEqual({});
expect(mod.roles).toEqual({});
expect(mod.policies).toEqual({});
expect(mod.attributes).toEqual({});
expect(mod.bindings).toEqual({});
});
test("rejects a permission id that is not colon-namespaced lowercase", () => {
expect(() => defineAuthz({ permissions: { "Post Read": {} } })).toThrow(/permission id/i);
expect(() => defineAuthz({ permissions: { "post:*": {} } })).toThrow(/wildcard/i);
});
test("rejects a role granting an unknown-shaped entry", () => {
expect(() => defineAuthz({ roles: { editor: [""] } })).toThrow(/role 'editor'/i);
});
test("rejects a binding naming a policy that is not declared", () => {
expect(() =>
defineAuthz({
permissions: { "post:write": {} },
bindings: { "post:write": ["missingPolicy"] },
}),
).toThrow(/missingPolicy/);
});
});
- Step 2: Run test to verify it fails
Run: bun test packages/authz/test/registry.test.ts
Expected: FAIL — cannot resolve ../src/registry.ts
- Step 3: Write the types
Create packages/authz/src/types.ts:
import type { DecisionPolicy } from "./advanced.ts";
/** Narrows an assignment to a tenant. Absent means a global assignment. */
export interface AuthzScope {
tenantId?: string;
}
export interface PermissionMeta {
title?: string;
description?: string;
risk?: "low" | "medium" | "high";
/** Granted to anonymous subjects. Every other permission denies without a user. */
public?: boolean;
}
export interface AttributeMeta {
description?: string;
}
/** One `app/authz/<name>.ts` declaration. */
export interface AuthzModule {
permissions?: Record<string, PermissionMeta>;
roles?: Record<string, string[]>;
policies?: Record<string, DecisionPolicy<never, never>>;
attributes?: Record<string, AttributeMeta>;
/** permission id -> policy names that must pass for it. */
bindings?: Record<string, string[]>;
}
/** The merged, frozen view of every declaration in the app. */
export interface AuthzCatalog {
permissions: ReadonlyMap<string, PermissionMeta>;
roles: ReadonlyMap<string, readonly string[]>;
policies: ReadonlyMap<string, DecisionPolicy<never, never>>;
attributes: ReadonlyMap<string, AttributeMeta>;
bindings: ReadonlyMap<string, readonly string[]>;
}
export interface SubjectAssignments {
roles: string[];
/** Explicit allows, bypassing roles. */
grants: string[];
/** Explicit denies. Win over everything, including "*". */
denies: string[];
}
- Step 4: Write the registry
Create packages/authz/src/registry.ts:
import type { AuthzModule } from "./types.ts";
const PERMISSION_ID = /^[a-z0-9]+(?::[a-z0-9-]+)+$/;
/**
* Validate and freeze one authorization declaration. Called from
* `app/authz/<name>.ts` as the module's default export.
*/
export function defineAuthz(module: AuthzModule): AuthzModule {
const permissions = module.permissions ?? {};
const roles = module.roles ?? {};
const policies = module.policies ?? {};
const attributes = module.attributes ?? {};
const bindings = module.bindings ?? {};
for (const id of Object.keys(permissions)) {
if (id.includes("*")) {
throw new Error(
`WRN-AUTHZ-DECL: permission id '${id}' must not contain a wildcard; wildcards belong in roles.`,
);
}
if (!PERMISSION_ID.test(id)) {
throw new Error(
`WRN-AUTHZ-DECL: permission id '${id}' must be lowercase colon-namespaced, e.g. 'post:read'.`,
);
}
}
for (const [role, grants] of Object.entries(roles)) {
for (const grant of grants) {
if (typeof grant !== "string" || !grant.trim()) {
throw new Error(
`WRN-AUTHZ-DECL: role '${role}' grants an empty entry; expected a permission, 'ns:*', or 'role:<name>'.`,
);
}
}
}
for (const [permission, names] of Object.entries(bindings)) {
for (const name of names) {
if (!(name in policies)) {
throw new Error(
`WRN-AUTHZ-DECL: binding for '${permission}' names policy '${name}', which is not declared in the same module.`,
);
}
}
}
return Object.freeze({ permissions, roles, policies, attributes, bindings });
}
- Step 5: Run test to verify it passes
Run: bun test packages/authz/test/registry.test.ts
Expected: PASS, 5 tests
- Step 6: Commit
git add packages/authz/src/types.ts packages/authz/src/registry.ts packages/authz/test/registry.test.ts
git commit -m "feat(authz): add defineAuthz declaration registry"
Task 2: Catalog merge and conflict detection
Files:
- Create:
packages/authz/src/catalog.ts - Test:
packages/authz/test/catalog.test.ts
Interfaces:
-
Consumes:
AuthzModule,AuthzCatalogfrom./types.ts;defineAuthzfrom./registry.ts -
Produces:
mergeCatalogs(sources: CatalogSource[]): AuthzCatalog,interface CatalogSource { source: string; module: AuthzModule },emptyCatalog(): AuthzCatalog -
Step 1: Write the failing test
Create packages/authz/test/catalog.test.ts:
import { describe, expect, test } from "bun:test";
import { defineAuthz } from "../src/registry.ts";
import { emptyCatalog, mergeCatalogs } from "../src/catalog.ts";
describe("mergeCatalogs", () => {
test("merges disjoint modules", () => {
const catalog = mergeCatalogs([
{ source: "a.ts", module: defineAuthz({ permissions: { "post:read": {} } }) },
{ source: "b.ts", module: defineAuthz({ permissions: { "user:read": {} } }) },
]);
expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "user:read"]);
});
test("re-declaring a permission with deep-equal metadata is a no-op", () => {
const meta = { title: "View posts", risk: "low" as const };
const catalog = mergeCatalogs([
{ source: "a.ts", module: defineAuthz({ permissions: { "post:read": meta } }) },
{ source: "b.ts", module: defineAuthz({ permissions: { "post:read": { ...meta } } }) },
]);
expect(catalog.permissions.size).toBe(1);
});
test("conflicting metadata is a boot error naming both files", () => {
expect(() =>
mergeCatalogs([
{ source: "a.ts", module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }) },
{ source: "b.ts", module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }) },
]),
).toThrow(/a\.ts.*b\.ts|b\.ts.*a\.ts/s);
});
test("conflicting role definitions are a boot error", () => {
expect(() =>
mergeCatalogs([
{ source: "a.ts", module: defineAuthz({ roles: { editor: ["post:read"] } }) },
{ source: "b.ts", module: defineAuthz({ roles: { editor: ["post:write"] } }) },
]),
).toThrow(/editor/);
});
test("bindings for the same permission union across modules", () => {
const p1 = defineAuthz({
permissions: { "post:write": {} },
policies: { ownsPost: async () => ({ allowed: true }) },
bindings: { "post:write": ["ownsPost"] },
});
const p2 = defineAuthz({
policies: { notLocked: async () => ({ allowed: true }) },
bindings: { "post:write": ["notLocked"] },
});
const catalog = mergeCatalogs([
{ source: "a.ts", module: p1 },
{ source: "b.ts", module: p2 },
]);
expect([...catalog.bindings.get("post:write")!].sort()).toEqual(["notLocked", "ownsPost"]);
});
test("a binding referencing a policy no module declares is a boot error", () => {
expect(() =>
mergeCatalogs([
{
source: "a.ts",
module: { permissions: { "post:write": {} }, bindings: { "post:write": ["ghost"] } },
},
]),
).toThrow(/ghost/);
});
test("the merged catalog is frozen", () => {
const catalog = mergeCatalogs([]);
expect(() => (catalog.permissions as Map<string, never>).set("x:y", {} as never)).toThrow();
});
test("emptyCatalog has no entries", () => {
expect(emptyCatalog().permissions.size).toBe(0);
});
});
- Step 2: Run test to verify it fails
Run: bun test packages/authz/test/catalog.test.ts
Expected: FAIL — cannot resolve ../src/catalog.ts
- Step 3: Write the implementation
Create packages/authz/src/catalog.ts:
import type { AttributeMeta, AuthzCatalog, AuthzModule, PermissionMeta } from "./types.ts";
import type { DecisionPolicy } from "./advanced.ts";
export interface CatalogSource {
/** File or package that declared this module, used in conflict messages. */
source: string;
module: AuthzModule;
}
/** Structural equality for declaration metadata. Key order is irrelevant. */
function deepEqual(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
if (Array.isArray(a) !== Array.isArray(b)) return false;
const left = a as Record<string, unknown>;
const right = b as Record<string, unknown>;
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
for (const key of keys) if (!deepEqual(left[key], right[key])) return false;
return true;
}
/** A frozen Map that throws on mutation, so the catalog cannot drift after boot. */
function frozenMap<V>(entries: Iterable<[string, V]>): ReadonlyMap<string, V> {
const map = new Map(entries);
const reject = () => {
throw new Error("WRN-AUTHZ-FROZEN: the authorization catalog is frozen after boot.");
};
map.set = reject as never;
map.delete = reject as never;
map.clear = reject as never;
return map;
}
export function emptyCatalog(): AuthzCatalog {
return {
permissions: frozenMap<PermissionMeta>([]),
roles: frozenMap<readonly string[]>([]),
policies: frozenMap<DecisionPolicy<never, never>>([]),
attributes: frozenMap<AttributeMeta>([]),
bindings: frozenMap<readonly string[]>([]),
};
}
export function mergeCatalogs(sources: CatalogSource[]): AuthzCatalog {
const permissions = new Map<string, PermissionMeta>();
const roles = new Map<string, readonly string[]>();
const policies = new Map<string, DecisionPolicy<never, never>>();
const attributes = new Map<string, AttributeMeta>();
const bindings = new Map<string, Set<string>>();
const origin = new Map<string, string>();
const claim = (
kind: string,
key: string,
source: string,
existingValue: unknown,
value: unknown,
) => {
const previous = origin.get(`${kind}:${key}`);
if (previous === undefined) {
origin.set(`${kind}:${key}`, source);
return;
}
if (!deepEqual(existingValue, value)) {
throw new Error(
`WRN-AUTHZ-CONFLICT: ${kind} '${key}' is declared differently in ${previous} and ${source}.`,
);
}
};
for (const { source, module } of sources) {
for (const [id, meta] of Object.entries(module.permissions ?? {})) {
claim("permission", id, source, permissions.get(id), meta);
permissions.set(id, meta);
}
for (const [name, grants] of Object.entries(module.roles ?? {})) {
claim("role", name, source, roles.get(name), grants);
roles.set(name, grants);
}
for (const [name, policy] of Object.entries(module.policies ?? {})) {
// Two closures are never deep-equal, so identity is the only sane test.
const existing = policies.get(name);
if (existing && existing !== policy) {
throw new Error(
`WRN-AUTHZ-CONFLICT: policy '${name}' is declared differently in ${origin.get(`policy:${name}`)} and ${source}.`,
);
}
origin.set(`policy:${name}`, source);
policies.set(name, policy);
}
for (const [name, meta] of Object.entries(module.attributes ?? {})) {
claim("attribute", name, source, attributes.get(name), meta);
attributes.set(name, meta);
}
for (const [permission, names] of Object.entries(module.bindings ?? {})) {
const set = bindings.get(permission) ?? new Set<string>();
for (const name of names) set.add(name);
bindings.set(permission, set);
}
}
for (const [permission, names] of bindings) {
for (const name of names) {
if (!policies.has(name)) {
throw new Error(
`WRN-AUTHZ-CONFLICT: binding for '${permission}' names policy '${name}', which no module declares.`,
);
}
}
}
return {
permissions: frozenMap(permissions),
roles: frozenMap(roles),
policies: frozenMap(policies),
attributes: frozenMap(attributes),
bindings: frozenMap([...bindings].map(([k, v]) => [k, [...v]] as [string, readonly string[]])),
};
}
- Step 4: Run test to verify it passes
Run: bun test packages/authz/test/catalog.test.ts
Expected: PASS, 8 tests
- Step 5: Commit
git add packages/authz/src/catalog.ts packages/authz/test/catalog.test.ts
git commit -m "feat(authz): merge declaration modules into a frozen catalog"
Task 3: PermissionStore interface, memory adapter, conformance suite
Files:
- Create:
packages/authz/src/store.ts - Create:
packages/authz/test/store-conformance.ts - Test:
packages/authz/test/store-memory.test.ts
Interfaces:
-
Consumes:
AuthzScope,SubjectAssignmentsfrom./types.ts -
Produces:
PermissionStore,memoryPermissionStore(): PermissionStore,runStoreConformance(name: string, makeStore: () => Promise<PermissionStore>) -
Step 1: Write the conformance suite
Create packages/authz/test/store-conformance.ts. This is imported by adapter tests; it has no .test.ts suffix so Bun does not run it directly.
import { beforeEach, describe, expect, test } from "bun:test";
import type { PermissionStore } from "../src/store.ts";
/**
* Every PermissionStore adapter must pass this suite, so the memory and db
* implementations cannot drift apart.
*/
export function runStoreConformance(name: string, makeStore: () => Promise<PermissionStore>): void {
describe(`PermissionStore conformance: ${name}`, () => {
let store: PermissionStore;
beforeEach(async () => {
store = await makeStore();
});
test("an unknown subject has empty assignments", async () => {
expect(await store.assignmentsFor("nobody")).toEqual({
roles: [],
grants: [],
denies: [],
});
});
test("assignRole then assignmentsFor round-trips", async () => {
await store.assignRole("u1", "editor");
expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
});
test("assignRole is idempotent", async () => {
await store.assignRole("u1", "editor");
await store.assignRole("u1", "editor");
expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
});
test("revokeRole removes only that role", async () => {
await store.assignRole("u1", "editor");
await store.assignRole("u1", "admin");
await store.revokeRole("u1", "editor");
expect((await store.assignmentsFor("u1")).roles).toEqual(["admin"]);
});
test("revoking a role that was never assigned is a no-op", async () => {
await store.revokeRole("u1", "ghost");
expect((await store.assignmentsFor("u1")).roles).toEqual([]);
});
test("scoped assignments do not leak across tenants", async () => {
await store.assignRole("u1", "editor", { tenantId: "t1" });
expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]);
expect((await store.assignmentsFor("u1", { tenantId: "t2" })).roles).toEqual([]);
});
test("a global assignment is visible inside every tenant", async () => {
await store.assignRole("u1", "superadmin");
expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["superadmin"]);
});
test("global and scoped roles union within a tenant", async () => {
await store.assignRole("u1", "viewer");
await store.assignRole("u1", "editor", { tenantId: "t1" });
expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles.sort()).toEqual([
"editor",
"viewer",
]);
});
test("grant with allow and deny land in the right buckets", async () => {
await store.grant("u1", "post:write", "allow");
await store.grant("u1", "post:delete", "deny");
const assignments = await store.assignmentsFor("u1");
expect(assignments.grants).toEqual(["post:write"]);
expect(assignments.denies).toEqual(["post:delete"]);
});
test("re-granting the same permission replaces its effect", async () => {
await store.grant("u1", "post:write", "allow");
await store.grant("u1", "post:write", "deny");
const assignments = await store.assignmentsFor("u1");
expect(assignments.grants).toEqual([]);
expect(assignments.denies).toEqual(["post:write"]);
});
test("revokeGrant removes the permission entirely", async () => {
await store.grant("u1", "post:write", "allow");
await store.revokeGrant("u1", "post:write");
expect((await store.assignmentsFor("u1")).grants).toEqual([]);
});
test("listSubjects returns everyone with an assignment in scope", async () => {
await store.assignRole("u1", "editor", { tenantId: "t1" });
await store.assignRole("u2", "editor", { tenantId: "t1" });
await store.assignRole("u3", "editor", { tenantId: "t2" });
expect((await store.listSubjects({ tenantId: "t1" })).sort()).toEqual(["u1", "u2"]);
});
test("listSubjects with no scope returns global assignees only", async () => {
await store.assignRole("g1", "viewer");
await store.assignRole("s1", "editor", { tenantId: "t1" });
expect(await store.listSubjects()).toEqual(["g1"]);
});
});
}
- Step 2: Write the memory adapter test
Create packages/authz/test/store-memory.test.ts:
import { memoryPermissionStore } from "../src/store.ts";
import { runStoreConformance } from "./store-conformance.ts";
runStoreConformance("memory", async () => memoryPermissionStore());
- Step 3: Run test to verify it fails
Run: bun test packages/authz/test/store-memory.test.ts
Expected: FAIL — cannot resolve ../src/store.ts
- Step 4: Write the implementation
Create packages/authz/src/store.ts:
import type { AuthzScope, SubjectAssignments } from "./types.ts";
export type GrantEffect = "allow" | "deny";
export interface PermissionStore {
assignmentsFor(subjectId: string, scope?: AuthzScope): Promise<SubjectAssignments>;
assignRole(subjectId: string, role: string, scope?: AuthzScope): Promise<void>;
revokeRole(subjectId: string, role: string, scope?: AuthzScope): Promise<void>;
grant(
subjectId: string,
permission: string,
effect: GrantEffect,
scope?: AuthzScope,
): Promise<void>;
revokeGrant(subjectId: string, permission: string, scope?: AuthzScope): Promise<void>;
listSubjects(scope?: AuthzScope): Promise<string[]>;
}
/** Global assignments are stored under the empty-string scope key. */
export function scopeKey(scope?: AuthzScope): string {
return scope?.tenantId ?? "";
}
interface Row {
subjectId: string;
scope: string;
}
interface RoleRow extends Row {
role: string;
}
interface GrantRow extends Row {
permission: string;
effect: GrantEffect;
}
export function memoryPermissionStore(): PermissionStore {
const roles: RoleRow[] = [];
const grants: GrantRow[] = [];
// A request inside tenant t sees global assignments plus t's own.
const visible = (row: Row, key: string) => row.scope === "" || row.scope === key;
return {
async assignmentsFor(subjectId, scope) {
const key = scopeKey(scope);
const mine = (row: Row) => row.subjectId === subjectId && visible(row, key);
const matched = grants.filter(mine);
return {
roles: roles.filter(mine).map((row) => row.role),
grants: matched.filter((row) => row.effect === "allow").map((row) => row.permission),
denies: matched.filter((row) => row.effect === "deny").map((row) => row.permission),
};
},
async assignRole(subjectId, role, scope) {
const key = scopeKey(scope);
if (roles.some((r) => r.subjectId === subjectId && r.scope === key && r.role === role))
return;
roles.push({ subjectId, scope: key, role });
},
async revokeRole(subjectId, role, scope) {
const key = scopeKey(scope);
const at = roles.findIndex(
(r) => r.subjectId === subjectId && r.scope === key && r.role === role,
);
if (at !== -1) roles.splice(at, 1);
},
async grant(subjectId, permission, effect, scope) {
const key = scopeKey(scope);
const at = grants.findIndex(
(g) => g.subjectId === subjectId && g.scope === key && g.permission === permission,
);
if (at !== -1) grants.splice(at, 1);
grants.push({ subjectId, scope: key, permission, effect });
},
async revokeGrant(subjectId, permission, scope) {
const key = scopeKey(scope);
const at = grants.findIndex(
(g) => g.subjectId === subjectId && g.scope === key && g.permission === permission,
);
if (at !== -1) grants.splice(at, 1);
},
async listSubjects(scope) {
const key = scopeKey(scope);
const ids = new Set<string>();
for (const row of roles) if (row.scope === key) ids.add(row.subjectId);
for (const row of grants) if (row.scope === key) ids.add(row.subjectId);
return [...ids];
},
};
}
- Step 5: Run test to verify it passes
Run: bun test packages/authz/test/store-memory.test.ts
Expected: PASS, 13 tests
- Step 6: Commit
git add packages/authz/src/store.ts packages/authz/test/store-conformance.ts packages/authz/test/store-memory.test.ts
git commit -m "feat(authz): add PermissionStore contract with memory adapter and conformance suite"
Task 4: Cached store decorator
Files:
- Modify:
packages/authz/src/store.ts(append) - Test:
packages/authz/test/store-cached.test.ts
Interfaces:
-
Consumes:
PermissionStore,scopeKeyfrom./store.ts -
Produces:
cachedPermissionStore(inner: PermissionStore, options?: { ttlMs?: number; max?: number }): CachedPermissionStore,interface CachedPermissionStore extends PermissionStore { invalidate(subjectId: string, scope?: AuthzScope): void; invalidateAll(): void } -
Step 1: Write the failing test
Create packages/authz/test/store-cached.test.ts:
import { describe, expect, test } from "bun:test";
import { cachedPermissionStore, memoryPermissionStore } from "../src/store.ts";
import { runStoreConformance } from "./store-conformance.ts";
// A cache must not change observable behaviour: writes invalidate internally.
runStoreConformance("cached(memory)", async () => cachedPermissionStore(memoryPermissionStore()));
describe("cachedPermissionStore", () => {
test("serves a repeat read from cache", async () => {
const inner = memoryPermissionStore();
let reads = 0;
const counting = {
...inner,
assignmentsFor: (id: string, scope?: { tenantId?: string }) => {
reads++;
return inner.assignmentsFor(id, scope);
},
};
const store = cachedPermissionStore(counting, { ttlMs: 60_000 });
await store.assignmentsFor("u1");
await store.assignmentsFor("u1");
expect(reads).toBe(1);
});
test("a write invalidates that subject", async () => {
const store = cachedPermissionStore(memoryPermissionStore(), { ttlMs: 60_000 });
await store.assignmentsFor("u1");
await store.assignRole("u1", "editor");
expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
});
test("invalidate() drops a cached subject", async () => {
const inner = memoryPermissionStore();
const store = cachedPermissionStore(inner, { ttlMs: 60_000 });
await store.assignmentsFor("u1");
await inner.assignRole("u1", "editor"); // behind the cache's back
expect((await store.assignmentsFor("u1")).roles).toEqual([]);
store.invalidate("u1");
expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
});
test("entries expire after ttlMs", async () => {
const inner = memoryPermissionStore();
const store = cachedPermissionStore(inner, { ttlMs: 1 });
await store.assignmentsFor("u1");
await inner.assignRole("u1", "editor");
await Bun.sleep(5);
expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
});
test("cache is bounded by max", async () => {
const store = cachedPermissionStore(memoryPermissionStore(), { ttlMs: 60_000, max: 2 });
await store.assignmentsFor("a");
await store.assignmentsFor("b");
await store.assignmentsFor("c");
expect(store.size()).toBeLessThanOrEqual(2);
});
test("a global write invalidates the subject in every tenant", async () => {
const inner = memoryPermissionStore();
const store = cachedPermissionStore(inner, { ttlMs: 60_000 });
await store.assignmentsFor("u1", { tenantId: "t1" }); // warm the tenant entry
await store.assignRole("u1", "editor"); // global write
// Global roles are visible inside every tenant, so the cached t1 entry
// must not survive this write.
expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]);
});
test("cache keys cannot collide across subject/tenant boundaries", async () => {
const inner = memoryPermissionStore();
const store = cachedPermissionStore(inner, { ttlMs: 60_000 });
// Naive "scope + separator + subject" concatenation makes these two pairs
// produce the same key, serving one subject the other's permissions.
await inner.assignRole("b�c", "editor", { tenantId: "a" });
expect((await store.assignmentsFor("b�c", { tenantId: "a" })).roles).toEqual(["editor"]);
expect((await store.assignmentsFor("c", { tenantId: "a�b" })).roles).toEqual([]);
});
test("scoped and global reads cache separately", async () => {
const inner = memoryPermissionStore();
const store = cachedPermissionStore(inner, { ttlMs: 60_000 });
await inner.assignRole("u1", "editor", { tenantId: "t1" });
expect((await store.assignmentsFor("u1")).roles).toEqual([]);
expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]);
});
});
- Step 2: Run test to verify it fails
Run: bun test packages/authz/test/store-cached.test.ts
Expected: FAIL — cachedPermissionStore is not exported
- Step 3: Append the implementation to
packages/authz/src/store.ts
export interface CachedPermissionStore extends PermissionStore {
/** Drop one subject. Call after changing roles out of band. */
invalidate(subjectId: string, scope?: AuthzScope): void;
invalidateAll(): void;
/** Cached entry count, for tests and diagnostics. */
size(): number;
}
export interface CacheOptions {
ttlMs?: number;
max?: number;
}
/**
* Caches assignment reads. Writes through this decorator invalidate the
* affected subject immediately; changes made directly against the inner store
* need an explicit `invalidate()` call rather than waiting out the TTL.
*/
export function cachedPermissionStore(
inner: PermissionStore,
options: CacheOptions = {},
): CachedPermissionStore {
const ttlMs = options.ttlMs ?? 5_000;
const max = options.max ?? 1_000;
const entries = new Map<string, { at: number; value: SubjectAssignments }>();
// Subject and tenant ids are unconstrained strings, so the key must be
// unambiguous: concatenating around a separator lets ("a", "b<sep>c") and
// ("a<sep>b", "c") collide, which would serve one subject another's
// permissions. JSON encoding escapes the components.
const cacheKey = (subjectId: string, scope?: AuthzScope) =>
JSON.stringify([scopeKey(scope), subjectId]);
// Track subjects separately rather than pattern-matching key strings, so a
// global write can find every tenant entry without substring guesswork.
const bySubject = new Map<string, Set<string>>();
const drop = (subjectId: string, scope?: AuthzScope) => {
// A global write changes what every tenant sees for that subject.
if (scopeKey(scope) === "") {
for (const key of bySubject.get(subjectId) ?? []) entries.delete(key);
bySubject.delete(subjectId);
return;
}
const key = cacheKey(subjectId, scope);
entries.delete(key);
bySubject.get(subjectId)?.delete(key);
};
return {
async assignmentsFor(subjectId, scope) {
const key = cacheKey(subjectId, scope);
const hit = entries.get(key);
if (hit && Date.now() - hit.at < ttlMs) return hit.value;
const value = await inner.assignmentsFor(subjectId, scope);
if (entries.size >= max) {
const oldest = entries.keys().next().value!;
entries.delete(oldest);
for (const keys of bySubject.values()) keys.delete(oldest);
}
entries.set(key, { at: Date.now(), value });
let keys = bySubject.get(subjectId);
if (!keys) bySubject.set(subjectId, (keys = new Set()));
keys.add(key);
return value;
},
async assignRole(subjectId, role, scope) {
await inner.assignRole(subjectId, role, scope);
drop(subjectId, scope);
},
async revokeRole(subjectId, role, scope) {
await inner.revokeRole(subjectId, role, scope);
drop(subjectId, scope);
},
async grant(subjectId, permission, effect, scope) {
await inner.grant(subjectId, permission, effect, scope);
drop(subjectId, scope);
},
async revokeGrant(subjectId, permission, scope) {
await inner.revokeGrant(subjectId, permission, scope);
drop(subjectId, scope);
},
listSubjects: (scope) => inner.listSubjects(scope),
invalidate: drop,
invalidateAll: () => {
entries.clear();
bySubject.clear();
},
size: () => entries.size,
};
}
- Step 4: Run test to verify it passes
Run: bun test packages/authz/test/store-cached.test.ts
Expected: PASS — 13 conformance tests plus 6 cache tests
- Step 5: Commit
git add packages/authz/src/store.ts packages/authz/test/store-cached.test.ts
git commit -m "feat(authz): add caching decorator for PermissionStore"
Task 5: Audit sink
Files:
- Create:
packages/authz/src/audit.ts - Test:
packages/authz/test/audit.test.ts
Interfaces:
-
Consumes:
AuthzScopefrom./types.ts -
Produces:
AuthzAuditEvent,AuthzAuditSink,memoryAuditSink(): MemoryAuditSink,consoleAuditSink(): AuthzAuditSink,safeRecord(sink, event): void -
Step 1: Write the failing test
Create packages/authz/test/audit.test.ts:
import { describe, expect, test } from "bun:test";
import { memoryAuditSink, safeRecord } from "../src/audit.ts";
describe("audit sink", () => {
test("memoryAuditSink collects events", () => {
const sink = memoryAuditSink();
sink.record({ permission: "post:read", allowed: true, at: 1 });
expect(sink.events).toHaveLength(1);
expect(sink.events[0]!.permission).toBe("post:read");
});
test("safeRecord swallows sink failures", () => {
const exploding = {
record() {
throw new Error("sink is down");
},
};
// Auditing must never break a request.
expect(() => safeRecord(exploding, { permission: "p:x", allowed: false, at: 1 })).not.toThrow();
});
test("safeRecord swallows async sink rejections", async () => {
const rejecting = { record: async () => Promise.reject(new Error("later")) };
expect(() => safeRecord(rejecting, { permission: "p:x", allowed: false, at: 1 })).not.toThrow();
await Bun.sleep(1);
});
test("safeRecord tolerates an undefined sink", () => {
expect(() => safeRecord(undefined, { permission: "p:x", allowed: true, at: 1 })).not.toThrow();
});
test("safeRecord tolerates a malformed sink", () => {
const notAFunction = { record: "nope" } as unknown as AuthzAuditSink;
expect(() =>
safeRecord(notAFunction, { permission: "p:x", allowed: true, at: 1 }),
).not.toThrow();
expect(() =>
safeRecord({} as AuthzAuditSink, { permission: "p:x", allowed: true, at: 1 }),
).not.toThrow();
});
test("memoryAuditSink.clear empties the buffer", () => {
const sink = memoryAuditSink();
sink.record({ permission: "p:x", allowed: true, at: 1 });
sink.clear();
expect(sink.events).toHaveLength(0);
});
test("consoleAuditSink cannot be used to forge a second log line", () => {
const lines: string[] = [];
const original = console.info;
console.info = (...args: unknown[]) => void lines.push(args.join(" "));
try {
consoleAuditSink().record({
subjectId: "u1\n[wrnexus:authz] allow admin:everything subject=root",
permission: "post:read",
allowed: false,
reason: "nope\r\ninjected",
at: 1,
});
} finally {
console.info = original;
}
// One event must produce exactly one line, with no embedded newlines.
expect(lines).toHaveLength(1);
expect(lines[0]).not.toContain("\n");
expect(lines[0]).not.toContain("\r");
});
});
The test file's imports must include consoleAuditSink and the AuthzAuditSink type
alongside memoryAuditSink and safeRecord.
- Step 2: Run test to verify it fails
Run: bun test packages/authz/test/audit.test.ts
Expected: FAIL — cannot resolve ../src/audit.ts
- Step 3: Write the implementation
Create packages/authz/src/audit.ts:
import type { AuthzScope } from "./types.ts";
export interface AuthzAuditEvent {
subjectId?: string;
scope?: AuthzScope;
permission: string;
allowed: boolean;
reason?: string;
policy?: string;
/** Epoch milliseconds. */
at: number;
}
export interface AuthzAuditSink {
record(event: AuthzAuditEvent): void | Promise<void>;
}
export interface MemoryAuditSink extends AuthzAuditSink {
events: AuthzAuditEvent[];
clear(): void;
}
export function memoryAuditSink(): MemoryAuditSink {
const events: AuthzAuditEvent[] = [];
return {
events,
record: (event) => void events.push(event),
clear: () => void events.splice(0, events.length),
};
}
/**
* Subject ids, tenant ids, and denial reasons trace back to request input, so
* a newline in one would forge a second audit line indistinguishable from a
* real entry. Strip CR/LF and other control characters before interpolating.
*/
function logSafe(value: string): string {
let out = "";
for (const character of value) {
const code = character.codePointAt(0)!;
// C0 + DEL, plus NEL and the Unicode line/paragraph separators, which some
// log shippers and JSON consumers also treat as line terminators.
const isLineBreaking =
code < 0x20 || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029;
out += isLineBreaking ? " " : character;
}
return out;
}
export function consoleAuditSink(): AuthzAuditSink {
return {
record(event) {
const verdict = event.allowed ? "allow" : "deny";
console.info(
`[wrnexus:authz] ${verdict} ${logSafe(event.permission)} ` +
`subject=${logSafe(event.subjectId ?? "anonymous")}` +
`${event.scope?.tenantId ? ` tenant=${logSafe(event.scope.tenantId)}` : ""}` +
`${event.reason ? ` reason=${logSafe(event.reason)}` : ""}` +
`${event.policy ? ` policy=${logSafe(event.policy)}` : ""}`,
);
},
};
}
/** Record without ever letting a sink failure escape into the request path. */
export function safeRecord(sink: AuthzAuditSink | undefined, event: AuthzAuditEvent): void {
if (!sink) return;
try {
const result = sink.record(event);
if (result instanceof Promise) {
result.catch((error) => console.warn("[wrnexus:authz] audit sink failed", error));
}
} catch (error) {
console.warn("[wrnexus:authz] audit sink failed", error);
}
}
- Step 4: Run test to verify it passes
Run: bun test packages/authz/test/audit.test.ts
Expected: PASS, 4 tests
- Step 5: Commit
git add packages/authz/src/audit.ts packages/authz/test/audit.test.ts
git commit -m "feat(authz): add pluggable authorization audit sink"
Task 6: Resolution engine
Files:
- Create:
packages/authz/src/engine.ts - Test:
packages/authz/test/engine.test.ts
Interfaces:
-
Consumes:
AuthzCatalog,AuthzScope,SubjectAssignmentsfrom./types.ts;PermissionStorefrom./store.ts;AuthzAuditSink,safeRecordfrom./audit.ts;AuthorizationDecisionfrom./advanced.ts -
Produces:
createAuthzResolver(options: AuthzResolverOptions): AuthzResolverwithAuthzResolver { permissionsFor(subjectId, scope?): Promise<Set<string>>; decide(input: DecideInput): Promise<AuthorizationDecision> },expandRoles(catalog, roles): Set<string>,permissionMatches(granted: Set<string>, permission: string): boolean -
Step 1: Write the failing test
Create packages/authz/test/engine.test.ts:
import { describe, expect, test } from "bun:test";
import { defineAuthz } from "../src/registry.ts";
import { mergeCatalogs } from "../src/catalog.ts";
import { memoryPermissionStore } from "../src/store.ts";
import { memoryAuditSink } from "../src/audit.ts";
import { createAuthzResolver, expandRoles, permissionMatches } from "../src/engine.ts";
const catalog = mergeCatalogs([
{
source: "test.ts",
module: defineAuthz({
permissions: {
"post:read": { public: true },
"post:write": {},
"post:delete": { risk: "high" },
"post:comment:delete": {},
},
roles: {
editor: ["post:*"],
moderator: ["post:comment:*"],
admin: ["role:editor", "post:delete"],
cyclic: ["role:cyclic", "post:read"],
},
policies: {
ownsPost: async (subject: { id?: string }, resource?: { authorId?: string }) =>
resource?.authorId === subject?.id
? { allowed: true }
: { allowed: false, reason: "not the author", policy: "ownsPost" },
explodes: async () => {
throw new Error("policy blew up");
},
},
bindings: { "post:write": ["ownsPost"] },
}),
},
]);
const make = (store = memoryPermissionStore(), audit = memoryAuditSink()) => ({
store,
audit,
resolver: createAuthzResolver({ catalog, store, audit, strict: false }),
});
describe("expandRoles", () => {
test("expands wildcards and role inheritance", () => {
expect([...expandRoles(catalog, ["admin"])].sort()).toEqual(["post:*", "post:delete"]);
});
test("terminates on cyclic inheritance", () => {
expect([...expandRoles(catalog, ["cyclic"])]).toEqual(["post:read"]);
});
});
describe("permissionMatches", () => {
test("matches exact, root wildcard, and every namespace depth", () => {
expect(permissionMatches(new Set(["post:read"]), "post:read")).toBe(true);
expect(permissionMatches(new Set(["*"]), "anything:at:all")).toBe(true);
expect(permissionMatches(new Set(["post:*"]), "post:comment:delete")).toBe(true);
expect(permissionMatches(new Set(["post:comment:*"]), "post:comment:delete")).toBe(true);
expect(permissionMatches(new Set(["post:comment:*"]), "post:write")).toBe(false);
});
});
describe("createAuthzResolver.decide", () => {
test("allows a public permission for an anonymous subject", async () => {
const { resolver } = make();
const result = await resolver.decide({ subject: null, permission: "post:read" });
expect(result.allowed).toBe(true);
});
test("denies a non-public permission for an anonymous subject", async () => {
const { resolver } = make();
const result = await resolver.decide({ subject: null, permission: "post:delete" });
expect(result.allowed).toBe(false);
});
test("allows via a role-derived wildcard", async () => {
const { store, resolver } = make();
await store.assignRole("u1", "moderator");
const result = await resolver.decide({
subject: { id: "u1" },
permission: "post:comment:delete",
});
expect(result.allowed).toBe(true);
});
test("an explicit deny beats a role and beats '*'", async () => {
const { store, resolver } = make();
await store.assignRole("u1", "admin");
await store.grant("u1", "post:delete", "deny");
const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" });
expect(result.allowed).toBe(false);
expect(result.reason).toMatch(/explicit deny/i);
});
test("a bound policy can deny a permission the role grants", async () => {
const { store, resolver } = make();
await store.assignRole("u1", "editor");
const denied = await resolver.decide({
subject: { id: "u1" },
permission: "post:write",
resource: { authorId: "someone-else" },
});
expect(denied.allowed).toBe(false);
expect(denied.policy).toBe("ownsPost");
const allowed = await resolver.decide({
subject: { id: "u1" },
permission: "post:write",
resource: { authorId: "u1" },
});
expect(allowed.allowed).toBe(true);
});
test("a throwing policy denies rather than escaping", async () => {
const throwing = mergeCatalogs([
{
source: "t.ts",
module: defineAuthz({
permissions: { "x:go": {} },
policies: {
explodes: async () => {
throw new Error("boom");
},
},
bindings: { "x:go": ["explodes"] },
}),
},
]);
const store = memoryPermissionStore();
await store.grant("u1", "x:go", "allow");
const resolver = createAuthzResolver({ catalog: throwing, store, strict: false });
const result = await resolver.decide({ subject: { id: "u1" }, permission: "x:go" });
expect(result.allowed).toBe(false);
});
test("a store failure denies and does not throw", async () => {
const broken = {
...memoryPermissionStore(),
assignmentsFor: async () => {
throw new Error("db down");
},
};
const resolver = createAuthzResolver({ catalog, store: broken, strict: false });
const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:read" });
expect(result.allowed).toBe(false);
});
test("an unregistered permission denies when strict is off", async () => {
const { resolver } = make();
const result = await resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" });
expect(result.allowed).toBe(false);
expect(result.reason).toMatch(/not registered/i);
});
test("an unregistered permission throws when strict is on", async () => {
const resolver = createAuthzResolver({
catalog,
store: memoryPermissionStore(),
strict: true,
});
await expect(
resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" }),
).rejects.toThrow(/ghost:perm/);
});
test("denials are audited and allows are not, by default", async () => {
const { store, audit, resolver } = make();
// moderator, NOT editor: editor holds "post:*", which legitimately grants
// post:delete, so that call would be an allow and nothing would be audited.
await store.assignRole("u1", "moderator");
await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" });
await resolver.decide({ subject: { id: "u1" }, permission: "post:read" });
expect(audit.events).toHaveLength(1);
expect(audit.events[0]!.allowed).toBe(false);
});
test("auditAllows records both verdicts", async () => {
const store = memoryPermissionStore();
const audit = memoryAuditSink();
const resolver = createAuthzResolver({
catalog,
store,
audit,
strict: false,
auditAllows: true,
});
await resolver.decide({ subject: null, permission: "post:read" });
expect(audit.events).toHaveLength(1);
expect(audit.events[0]!.allowed).toBe(true);
});
test("tenant scope selects the right assignments", async () => {
const { store, resolver } = make();
await store.assignRole("u1", "editor", { tenantId: "t1" });
const inside = await resolver.decide({
subject: { id: "u1" },
permission: "post:write",
resource: { authorId: "u1" },
scope: { tenantId: "t1" },
});
const outside = await resolver.decide({
subject: { id: "u1" },
permission: "post:write",
resource: { authorId: "u1" },
scope: { tenantId: "t2" },
});
expect(inside.allowed).toBe(true);
expect(outside.allowed).toBe(false);
});
});
describe("createAuthzResolver fail-closed regressions", () => {
const guarded = mergeCatalogs([
{
source: "guarded.ts",
module: defineAuthz({
permissions: { "feed:view": { public: true }, "x:go": {} },
policies: {
never: async () => ({ allowed: false, reason: "always no", policy: "never" }),
truthy: async () => ({ allowed: "yes" }) as never,
},
bindings: { "feed:view": ["never"] },
}),
},
]);
test("a public permission still runs its bound policies for anonymous callers", async () => {
// The least-trusted caller must not receive the weakest evaluation:
// `public` relaxes the identity requirement, never the policy requirement.
const resolver = createAuthzResolver({
catalog: guarded,
store: memoryPermissionStore(),
strict: false,
});
const anonymous = await resolver.decide({ subject: null, permission: "feed:view" });
expect(anonymous.allowed).toBe(false);
expect(anonymous.policy).toBe("never");
});
test("a policy returning a truthy non-boolean denies", async () => {
const catalog = mergeCatalogs([
{
source: "t.ts",
module: defineAuthz({
permissions: { "x:go": {} },
policies: { truthy: async () => ({ allowed: "yes" }) as never },
bindings: { "x:go": ["truthy"] },
}),
},
]);
const store = memoryPermissionStore();
await store.grant("u1", "x:go", "allow");
const resolver = createAuthzResolver({ catalog, store, strict: false });
expect((await resolver.decide({ subject: { id: "u1" }, permission: "x:go" })).allowed).toBe(
false,
);
});
test("a binding naming a policy the catalog lacks denies rather than skipping", async () => {
// Hand-built catalog: mergeCatalogs would reject this, but the resolver
// accepts any AuthzCatalog and must not grant what the policy guarded.
const broken = {
permissions: new Map([["x:go", {}]]),
roles: new Map(),
policies: new Map(),
attributes: new Map(),
bindings: new Map([["x:go", ["ghost"]]]),
} as unknown as Parameters<typeof createAuthzResolver>[0]["catalog"];
const store = memoryPermissionStore();
await store.grant("u1", "x:go", "allow");
const resolver = createAuthzResolver({ catalog: broken, store, strict: false });
expect((await resolver.decide({ subject: { id: "u1" }, permission: "x:go" })).allowed).toBe(
false,
);
});
test("a wildcard deny blocks the whole namespace", async () => {
const { store, resolver } = make();
await store.assignRole("u1", "admin");
await store.grant("u1", "post:*", "deny");
expect(
(await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" })).allowed,
).toBe(false);
});
test("permissionsFor omits denied permissions", async () => {
const { store, resolver } = make();
await store.assignRole("u1", "editor");
await store.grant("u1", "post:*", "deny");
const effective = await resolver.permissionsFor("u1");
// The obvious composition must agree with decide().
expect(permissionMatches(effective, "post:write")).toBe(false);
});
test("a non-string or empty subject id denies instead of falling back to anonymous", async () => {
const { resolver } = make();
for (const id of [0, "", null, 123, {}]) {
const result = await resolver.decide({
subject: { id } as never,
permission: "post:read", // public — must still not be reached this way
});
if (id === null) continue; // null is genuinely anonymous
expect(result.allowed).toBe(false);
}
});
});
- Step 2: Run test to verify it fails
Run: bun test packages/authz/test/engine.test.ts
Expected: FAIL — cannot resolve ../src/engine.ts
- Step 3: Write the implementation
Create packages/authz/src/engine.ts:
import type { AuthorizationDecision } from "./advanced.ts";
import { safeRecord, type AuthzAuditSink } from "./audit.ts";
import type { PermissionStore } from "./store.ts";
import type { AuthzCatalog, AuthzScope } from "./types.ts";
export interface AuthzResolverOptions {
catalog: AuthzCatalog;
store: PermissionStore;
audit?: AuthzAuditSink;
/**
* Throw on an unregistered permission instead of denying. Defaults to true
* outside production, so typos surface during development.
*/
strict?: boolean;
/** Record allows as well as denies. Off by default to bound write volume. */
auditAllows?: boolean;
}
export interface DecideInput {
subject: { id?: string; [key: string]: unknown } | null | undefined;
permission: string;
resource?: unknown;
scope?: AuthzScope;
}
export interface AuthzResolver {
/**
* Effective permissions with denied entries removed — for coarse gating such
* as hiding a menu section.
*
* NOT authoritative. A set of strings cannot express "everything under
* `post:*` except `post:delete`", so a narrow deny beneath a broad grant is
* not representable here: the set still contains `post:*` while `decide()`
* correctly refuses `post:delete`. Gate individual actions with `decide()`
* (or `can()` / `filterCan()`), never by matching against this set.
*/
permissionsFor(subjectId: string, scope?: AuthzScope): Promise<Set<string>>;
decide(input: DecideInput): Promise<AuthorizationDecision>;
}
/** Expand roles into their granted entries, following `role:` and stopping on cycles. */
export function expandRoles(catalog: AuthzCatalog, roles: readonly string[]): Set<string> {
const out = new Set<string>();
const seen = new Set<string>();
const walk = (role: string) => {
if (seen.has(role)) return;
seen.add(role);
for (const entry of catalog.roles.get(role) ?? []) {
if (entry.startsWith("role:")) walk(entry.slice(5));
else out.add(entry);
}
};
for (const role of roles) walk(role);
return out;
}
/** Exact match, root wildcard, or a namespace wildcard at any depth. */
export function permissionMatches(granted: Set<string>, permission: string): boolean {
if (granted.has("*") || granted.has(permission)) return true;
for (let at = permission.indexOf(":"); at !== -1; at = permission.indexOf(":", at + 1)) {
if (granted.has(`${permission.slice(0, at)}:*`)) return true;
}
return false;
}
/**
* True if any entry in the deny list covers `permission`. Denies honour the
* same depth-aware wildcards as grants, so denying "post:*" blocks
* post:comment:delete rather than being accepted and silently doing nothing.
*/
export function deniedBy(denies: readonly string[], permission: string): boolean {
return denies.length ? permissionMatches(new Set(denies), permission) : false;
}
function isProduction(): boolean {
return (process.env.NODE_ENV ?? "development") === "production";
}
export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolver {
const { catalog, store, audit } = options;
const strict = options.strict ?? !isProduction();
/**
* Single source of truth for "what does this subject hold?". Returns the raw
* assignments alongside the effective set, because `decide` reports on the
* deny that blocked it. Do NOT duplicate this logic in either caller.
*/
const loadEffective = async (subjectId: string, scope?: AuthzScope) => {
const assignments = await store.assignmentsFor(subjectId, scope);
const granted = expandRoles(catalog, assignments.roles);
for (const grant of assignments.grants) granted.add(grant);
return { assignments, granted };
};
/**
* Effective permissions, denies already removed. Callers compose this with
* `permissionMatches` to gate menus and admin UI, so it must not report a
* permission that `decide` would refuse.
*/
const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise<Set<string>> => {
const { assignments, granted } = await loadEffective(subjectId, scope);
if (!assignments.denies.length) return granted;
// Hoist the deny set: rebuilding it per entry makes this O(grants x denies)
// allocations on a per-request path whose input size an operator controls.
const denySet = new Set(assignments.denies);
const effective = new Set<string>();
for (const entry of granted) {
// A wildcard grant survives only if nothing denies it outright.
if (!permissionMatches(denySet, entry)) effective.add(entry);
}
return effective;
};
const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => {
if (!result.allowed || options.auditAllows) {
safeRecord(audit, {
subjectId: typeof input.subject?.id === "string" ? input.subject.id : undefined,
scope: input.scope,
permission: input.permission,
allowed: result.allowed,
reason: result.reason,
policy: result.policy,
at: Date.now(),
});
}
return result;
};
/**
* Run every policy bound to a permission. Returns a denial, or null to allow.
* Anonymous callers run this too: `public` relaxes the identity requirement,
* never the policy requirement.
*/
const runPolicies = async (
input: DecideInput,
permission: string,
): Promise<AuthorizationDecision | null> => {
for (const name of catalog.bindings.get(permission) ?? []) {
const policy = catalog.policies.get(name);
if (!policy) {
// A binding naming a policy the catalog lacks must deny, not skip:
// silently ignoring it would grant whatever the policy guarded.
console.error(
`[wrnexus:authz] binding for '${permission}' names unknown policy '${name}'; denying`,
);
return { allowed: false, reason: "Policy unavailable", policy: name };
}
try {
const verdict = await (
policy as unknown as (
s: unknown,
r: unknown,
) => AuthorizationDecision | Promise<AuthorizationDecision>
)(input.subject, input.resource);
// Identity check, not truthiness: {allowed: "yes"} must not grant.
if (verdict?.allowed !== true) {
return {
allowed: false,
reason: verdict?.reason ?? "Policy denied access",
policy: verdict?.policy ?? name,
};
}
} catch (error) {
console.error(`[wrnexus:authz] policy '${name}' threw; denying`, error);
return { allowed: false, reason: "Policy error", policy: name };
}
}
return null;
};
return {
permissionsFor,
async decide(input) {
const { subject, permission, scope } = input;
const meta = catalog.permissions.get(permission);
if (!meta) {
if (strict) {
throw new Error(
`WRN-AUTHZ-UNKNOWN: permission '${permission}' is not registered. ` +
`Declare it with defineAuthz() in app/authz/.`,
);
}
return finish(input, {
allowed: false,
reason: `Permission '${permission}' is not registered`,
});
}
// Only a non-empty string identifies a subject. A numeric id of 0 or a
// non-string id must not fall through to the anonymous path, and must
// never reach the store as a lookup key.
const rawId: unknown = subject?.id;
const subjectId = typeof rawId === "string" && rawId !== "" ? rawId : undefined;
if (rawId !== undefined && rawId !== null && subjectId === undefined) {
console.error("[wrnexus:authz] subject.id must be a non-empty string; denying");
return finish(input, { allowed: false, reason: "Invalid subject" });
}
if (!subjectId) {
if (!meta.public) {
return finish(input, { allowed: false, reason: "Authentication required" });
}
const denied = await runPolicies(input, permission);
return finish(input, denied ?? { allowed: true, reason: "public permission" });
}
let assignments;
let granted: Set<string>;
try {
({ assignments, granted } = await loadEffective(subjectId, scope));
} catch (error) {
console.error("[wrnexus:authz] permission store failed; denying", error);
return finish(input, { allowed: false, reason: "Authorization store unavailable" });
}
// 1. Explicit deny wins over everything, including "*". Wildcards are
// honoured here exactly as they are for grants, so denying "post:*"
// blocks post:delete rather than silently doing nothing.
if (deniedBy(assignments.denies, permission)) {
return finish(input, { allowed: false, reason: "explicit deny" });
}
// 2. Must hold the permission at all.
if (!meta.public && !permissionMatches(granted, permission)) {
return finish(input, { allowed: false, reason: "Missing permission" });
}
// 3. Every bound policy must pass.
const denied = await runPolicies(input, permission);
return finish(input, denied ?? { allowed: true });
},
};
}
- Step 4: Run test to verify it passes
Run: bun test packages/authz/test/engine.test.ts
Expected: PASS, 15 tests
- Step 5: Commit
git add packages/authz/src/engine.ts packages/authz/test/engine.test.ts
git commit -m "feat(authz): add resolution engine with deny-wins precedence and fail-closed errors"
Task 7: Middleware, can(), and guards
Files:
- Create:
packages/authz/src/middleware.ts - Test:
packages/authz/test/middleware.test.ts
Interfaces:
-
Consumes:
createAuthzResolver,AuthzResolverOptions,AuthzResolverfrom./engine.ts;Context,Middlewaretypes from@wrnexus/core -
Produces:
AUTHZ_LOCALS_KEY,authzMiddleware(options: AuthzResolverOptions): Middleware,decideFor(ctx, permission, resource?): Promise<AuthorizationDecision>,can(ctx, permission, resource?): Promise<boolean>,guardPermission(permission, getResource?): Middleware,filterCan<T>(ctx, permission, items): Promise<T[]> -
Step 1: Write the failing test
Create packages/authz/test/middleware.test.ts:
import { describe, expect, test } from "bun:test";
import type { Context } from "@wrnexus/core";
import { defineAuthz } from "../src/registry.ts";
import { mergeCatalogs } from "../src/catalog.ts";
import { memoryPermissionStore } from "../src/store.ts";
import { authzMiddleware, can, filterCan, guardPermission } from "../src/middleware.ts";
const catalog = mergeCatalogs([
{
source: "t.ts",
module: defineAuthz({
permissions: { "post:read": { public: true }, "post:write": {}, "post:delete": {} },
roles: { editor: ["post:write"] },
policies: {
ownsPost: async (s: { id?: string }, r?: { authorId?: string }) =>
r?.authorId === s?.id ? { allowed: true } : { allowed: false, reason: "not owner" },
},
bindings: { "post:delete": ["ownsPost"] },
}),
},
]);
/** Minimal Context stand-in; the middleware only touches user, tenant, locals. */
function makeCtx(user: unknown, tenantId?: string): Context {
return {
user,
tenant: tenantId ? { id: tenantId } : undefined,
locals: {},
url: new URL("http://localhost/x"),
req: new Request("http://localhost/x"),
} as unknown as Context;
}
const withMiddleware = async (ctx: Context, store = memoryPermissionStore()) => {
await authzMiddleware({ catalog, store, strict: false })(ctx, async () => new Response("ok"));
return store;
};
describe("authzMiddleware + can", () => {
test("can() resolves through the middleware-installed resolver", async () => {
const ctx = makeCtx({ id: "u1" });
const store = memoryPermissionStore();
await store.assignRole("u1", "editor");
await withMiddleware(ctx, store);
expect(await can(ctx, "post:write")).toBe(true);
expect(await can(ctx, "post:delete", { authorId: "u1" })).toBe(false);
});
test("can() throws a clear setup error without the middleware", async () => {
const ctx = makeCtx({ id: "u1" });
await expect(can(ctx, "post:read")).rejects.toThrow(/authzMiddleware/);
});
test("results are memoised per request", async () => {
const inner = memoryPermissionStore();
let reads = 0;
const counting = {
...inner,
assignmentsFor: (id: string, scope?: { tenantId?: string }) => {
reads++;
return inner.assignmentsFor(id, scope);
},
};
const ctx = makeCtx({ id: "u1" });
await authzMiddleware({ catalog, store: counting, strict: false })(
ctx,
async () => new Response("ok"),
);
await can(ctx, "post:write");
await can(ctx, "post:write");
expect(reads).toBe(1);
});
test("memoisation keys on the resource, not just the permission", async () => {
const ctx = makeCtx({ id: "u1" });
const store = memoryPermissionStore();
await store.grant("u1", "post:delete", "allow");
await withMiddleware(ctx, store);
expect(await can(ctx, "post:delete", { authorId: "u1" })).toBe(true);
expect(await can(ctx, "post:delete", { authorId: "other" })).toBe(false);
});
test("the tenant on the context becomes the scope", async () => {
const ctx = makeCtx({ id: "u1" }, "t1");
const store = memoryPermissionStore();
await store.assignRole("u1", "editor", { tenantId: "t1" });
await withMiddleware(ctx, store);
expect(await can(ctx, "post:write")).toBe(true);
});
});
describe("guardPermission", () => {
test("calls next when allowed", async () => {
const ctx = makeCtx({ id: "u1" });
const store = memoryPermissionStore();
await store.assignRole("u1", "editor");
await withMiddleware(ctx, store);
const res = await guardPermission("post:write")(ctx, async () => new Response("passed"));
expect(await res.text()).toBe("passed");
});
test("returns 403 without leaking the reason by default", async () => {
const ctx = makeCtx({ id: "u1" });
await withMiddleware(ctx);
const res = await guardPermission("post:write")(ctx, async () => new Response("passed"));
expect(res.status).toBe(403);
const body = (await res.json()) as Record<string, unknown>;
expect(body).toEqual({ ok: false, error: "Forbidden" });
});
test("exposeReason opts into diagnostics", async () => {
const ctx = makeCtx({ id: "u1" });
await withMiddleware(ctx);
const res = await guardPermission("post:write", { exposeReason: true })(
ctx,
async () => new Response("passed"),
);
const body = (await res.json()) as Record<string, unknown>;
expect(body.reason).toBe("Missing permission");
});
test("getResource feeds the bound policy", async () => {
const ctx = makeCtx({ id: "u1" });
const store = memoryPermissionStore();
await store.grant("u1", "post:delete", "allow");
await withMiddleware(ctx, store);
const guard = guardPermission("post:delete", { getResource: () => ({ authorId: "u1" }) });
const res = await guard(ctx, async () => new Response("passed"));
expect(await res.text()).toBe("passed");
});
});
describe("filterCan", () => {
test("keeps only the items the subject may act on", async () => {
const ctx = makeCtx({ id: "u1" });
const store = memoryPermissionStore();
await store.grant("u1", "post:delete", "allow");
await withMiddleware(ctx, store);
const posts = [{ authorId: "u1" }, { authorId: "other" }, { authorId: "u1" }];
expect(await filterCan(ctx, "post:delete", posts)).toHaveLength(2);
});
test("does not leak rows the memo cannot serialise", async () => {
const ctx = makeCtx({ id: "u1" });
const store = memoryPermissionStore();
await store.grant("u1", "post:delete", "allow");
await withMiddleware(ctx, store);
// BigInt columns and circular references are ordinary in ORM rows. A memo
// that serialises resources funnels all of these into one shared key and
// returns the first verdict for every later row.
const circular: Record<string, unknown> = { authorId: "other" };
circular.self = circular;
const rows = [{ authorId: "u1", views: 10n }, { authorId: "other", views: 11n }, circular];
expect(await filterCan(ctx, "post:delete", rows)).toEqual([rows[0]]);
});
test("returns an empty array for no items", async () => {
const ctx = makeCtx({ id: "u1" });
await withMiddleware(ctx);
expect(await filterCan(ctx, "post:delete", [])).toEqual([]);
});
});
describe("per-request memo isolation", () => {
test("distinct resources are never cross-authorized", async () => {
const ctx = makeCtx({ id: "u1" });
const store = memoryPermissionStore();
await store.grant("u1", "post:delete", "allow");
await withMiddleware(ctx, store);
// Same id, different owner; object ids; primitives of different type.
expect(await can(ctx, "post:delete", { id: 7, authorId: "u1" })).toBe(true);
expect(await can(ctx, "post:delete", { id: "7", authorId: "other" })).toBe(false);
expect(await can(ctx, "post:delete", { id: { t: "A" }, authorId: "u1" })).toBe(true);
expect(await can(ctx, "post:delete", { id: { t: "B" }, authorId: "other" })).toBe(false);
});
test("a changed row is not authorized against the stale copy", async () => {
const ctx = makeCtx({ id: "u1" });
const store = memoryPermissionStore();
await store.grant("u1", "post:delete", "allow");
await withMiddleware(ctx, store);
expect(await can(ctx, "post:delete", { id: "p1", authorId: "u1" })).toBe(true);
expect(await can(ctx, "post:delete", { id: "p1", authorId: "someone-else" })).toBe(false);
});
test("switching tenant mid-request re-evaluates", async () => {
const ctx = makeCtx({ id: "u1" }, "t1");
const store = memoryPermissionStore();
await store.assignRole("u1", "editor", { tenantId: "t1" });
await withMiddleware(ctx, store);
expect(await can(ctx, "post:write")).toBe(true);
(ctx as { tenant?: { id: string } }).tenant = { id: "t2" };
// Scope is read at decision time, so the t1 grant must not carry over.
expect(await can(ctx, "post:write")).toBe(false);
});
});
describe("guardPermission hardening", () => {
test("throws the setup error rather than calling next", async () => {
const ctx = makeCtx({ id: "u1" }); // no authzMiddleware
let reached = false;
await expect(
guardPermission("post:write")(ctx, async () => {
reached = true;
return new Response("passed");
}),
).rejects.toThrow(/authzMiddleware/);
expect(reached).toBe(false);
});
test("a throwing getResource denies instead of 500ing", async () => {
const ctx = makeCtx({ id: "u1" });
await withMiddleware(ctx);
const guard = guardPermission("post:delete", {
getResource: () => {
throw new Error("SELECT * FROM posts WHERE id=$1 failed");
},
});
const res = await guard(ctx, async () => new Response("passed"));
expect(res.status).toBe(403);
const body = await res.text();
expect(body).not.toContain("SELECT");
});
test("redirectTo applies to page requests but not API requests", async () => {
const page = makeCtx({ id: "u1" });
await withMiddleware(page);
const redirected = await guardPermission("post:write", { redirectTo: "/login" })(
page,
async () => new Response("passed"),
);
expect(redirected.status).toBe(303);
const api = makeCtx({ id: "u1" });
(api as { url: URL }).url = new URL("http://localhost/api/posts");
await withMiddleware(api);
const json = await guardPermission("post:write", { redirectTo: "/login" })(
api,
async () => new Response("passed"),
);
// An API caller must see the denial, not follow a redirect into a 200.
expect(json.status).toBe(403);
});
test("an off-site redirectTo is refused", async () => {
const ctx = makeCtx({ id: "u1" });
await withMiddleware(ctx);
for (const target of ["https://evil.example.com/harvest", "//evil.example.com"]) {
const res = await guardPermission("post:write", { redirectTo: target })(
ctx,
async () => new Response("passed"),
);
expect(res.status).toBe(403);
}
});
});
- Step 2: Run test to verify it fails
Run: bun test packages/authz/test/middleware.test.ts
Expected: FAIL — cannot resolve ../src/middleware.ts
- Step 3: Write the implementation
Create packages/authz/src/middleware.ts:
import type { Context, Middleware } from "@wrnexus/core";
import type { AuthorizationDecision } from "./advanced.ts";
import { createAuthzResolver, type AuthzResolver, type AuthzResolverOptions } from "./engine.ts";
import type { AuthzScope } from "./types.ts";
/**
* `can` is deliberately not a Context member: @wrnexus/core must not depend on
* @wrnexus/authz. The per-request resolver lives here instead.
*/
export const AUTHZ_LOCALS_KEY = "_authz";
interface RequestAuthz {
resolver: AuthzResolver;
/** Memo for object resources, keyed by identity so two rows never collide. */
byRef: WeakMap<object, Map<string, Promise<AuthorizationDecision>>>;
/** Memo for primitive and absent resources. */
byValue: Map<string, Promise<AuthorizationDecision>>;
}
function readAuthz(ctx: Context): RequestAuthz {
const value = ctx.locals[AUTHZ_LOCALS_KEY] as RequestAuthz | undefined;
if (!value) {
throw new Error(
"WRN-AUTHZ-SETUP: authzMiddleware() is not registered for this request. " +
"Add it to app/middleware before calling can()/guardPermission().",
);
}
return value;
}
/**
* Read the tenant from the context at decision time, not at middleware time:
* a request that switches tenant mid-flight must not keep the old scope.
*/
function currentScope(ctx: Context): AuthzScope | undefined {
const tenantId = ctx.tenant?.id;
return typeof tenantId === "string" && tenantId !== "" ? { tenantId } : undefined;
}
/** Install the per-request resolver. Register after sessionAuth and tenantMiddleware. */
export function authzMiddleware(options: AuthzResolverOptions): Middleware {
const resolver = createAuthzResolver(options);
return (ctx, next) => {
ctx.locals[AUTHZ_LOCALS_KEY] = {
resolver,
byRef: new WeakMap(),
byValue: new Map(),
} satisfies RequestAuthz;
return next();
};
}
export function decideFor(
ctx: Context,
permission: string,
resource?: unknown,
): Promise<AuthorizationDecision> {
const request = readAuthz(ctx);
const scope = currentScope(ctx);
// Scope is part of the key: the same permission decides differently per tenant.
// JSON-encoded so a tenant id containing the separator cannot collide.
const key = JSON.stringify([scope?.tenantId ?? "", permission]);
const run = () =>
request.resolver.decide({
subject: ctx.user as { id?: string } | null | undefined,
permission,
resource,
scope,
});
// Object resources memo by IDENTITY. Serialising them would let two distinct
// rows share a key and cross-authorize, and unserialisable ones (circular
// refs, BigInt fields, throwing getters) would all collapse into one bucket.
if (resource !== null && (typeof resource === "object" || typeof resource === "function")) {
let perResource = request.byRef.get(resource as object);
if (!perResource) request.byRef.set(resource as object, (perResource = new Map()));
const cached = perResource.get(key);
if (cached) return cached;
const pending = run();
perResource.set(key, pending);
return pending;
}
// typeof is part of the key so 7 and "7" are not the same resource.
const valueKey = JSON.stringify([key, typeof resource, String(resource)]);
const cached = request.byValue.get(valueKey);
if (cached) return cached;
const pending = run();
request.byValue.set(valueKey, pending);
return pending;
}
export async function can(ctx: Context, permission: string, resource?: unknown): Promise<boolean> {
return (await decideFor(ctx, permission, resource)).allowed;
}
export interface GuardOptions {
/** Load the resource a bound policy needs. */
getResource?: (ctx: Context) => unknown;
/** Include reason and policy name in the 403 body. Off by default. */
exposeReason?: boolean;
/** Redirect page requests here instead of returning 403. Must be a local path. */
redirectTo?: string;
}
/** Same rule requireAuth uses, replicated because authz may only import TYPES from core. */
function wantsJson(ctx: Context): boolean {
if (ctx.url.pathname.startsWith("/api/")) return true;
const accept = ctx.req.headers.get("accept") ?? "";
return accept.includes("application/json") && !accept.includes("text/html");
}
/** Reject anything that could navigate off-site or inject a header. */
function isLocalPath(value: string): boolean {
if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return false;
for (const character of value) {
const code = character.codePointAt(0)!;
if (code < 0x20 || code === 0x7f) return false;
}
return true;
}
/**
* Guard a route on a registered permission. Named `guardPermission` because
* `requirePermission(rbac, permission)` already exists with a different shape.
*/
export function guardPermission(permission: string, options: GuardOptions = {}): Middleware {
return async (ctx, next) => {
let resource: unknown;
if (options.getResource) {
try {
resource = await options.getResource(ctx);
} catch (error) {
// Loading the resource failed, so the policy cannot be evaluated. Deny
// rather than 500 — and never leak the loader's message to the client.
console.error(`[wrnexus:authz] getResource for '${permission}' threw; denying`, error);
return Response.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
}
const result = await decideFor(ctx, permission, resource);
if (result.allowed) return next();
if (options.redirectTo && !wantsJson(ctx)) {
if (!isLocalPath(options.redirectTo)) {
console.error(
`[wrnexus:authz] redirectTo must be a local path, got '${options.redirectTo}'; denying`,
);
} else {
return new Response(null, {
status: 303,
headers: { location: options.redirectTo, "cache-control": "private, no-store" },
});
}
}
return Response.json(
options.exposeReason
? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy }
: { ok: false, error: "Forbidden" },
{ status: 403, headers: { "cache-control": "private, no-store" } },
);
};
}
/** Keep only the items the current subject may act on. */
export async function filterCan<T>(
ctx: Context,
permission: string,
items: readonly T[],
): Promise<T[]> {
const verdicts = await Promise.all(
items.map(async (item) => ({ item, allowed: await can(ctx, permission, item) })),
);
return verdicts.filter((entry) => entry.allowed).map((entry) => entry.item);
}
- Step 4: Run test to verify it passes
Run: bun test packages/authz/test/middleware.test.ts
Expected: PASS, 10 tests
- Step 5: Commit
git add packages/authz/src/middleware.ts packages/authz/test/middleware.test.ts
git commit -m "feat(authz): add request middleware, can(), and guardPermission"
Task 8: Stop authorizeDecision leaking policy internals
Files:
- Modify:
packages/authz/src/advanced.ts:72-83 - Test:
packages/authz/test/authz.test.ts(append)
Interfaces:
-
Consumes:
AuthorizationDecisionfrom./advanced.ts -
Produces:
authorizeDecision(evaluate, options?: { exposeReason?: boolean }): Middleware— behaviour change, body is now{ ok: false, error: "Forbidden" }unless opted in -
Step 1: Write the failing test
Append to packages/authz/test/authz.test.ts:
describe("authorizeDecision disclosure", () => {
const ctx = { user: { id: "u1" } } as unknown as import("@wrnexus/core").Context;
const denier = async () => ({ allowed: false, reason: "secret internal rule", policy: "isVip" });
test("does not leak reason or policy by default", async () => {
const res = await authorizeDecision(denier)(ctx, async () => new Response("ok"));
expect(res.status).toBe(403);
expect(await res.json()).toEqual({ ok: false, error: "Forbidden" });
});
test("exposeReason opts back in", async () => {
const res = await authorizeDecision(denier, { exposeReason: true })(
ctx,
async () => new Response("ok"),
);
const body = (await res.json()) as Record<string, unknown>;
expect(body.reason).toBe("secret internal rule");
expect(body.policy).toBe("isVip");
});
test("still calls next when allowed", async () => {
const res = await authorizeDecision(async () => ({ allowed: true }))(
ctx,
async () => new Response("passed"),
);
expect(await res.text()).toBe("passed");
});
});
Add authorizeDecision to the file's existing import from ../src/index.ts if it is not already imported.
- Step 2: Run test to verify it fails
Run: bun test packages/authz/test/authz.test.ts
Expected: FAIL — the default response still contains reason
- Step 3: Modify
packages/authz/src/advanced.ts
Replace the authorizeDecision function with:
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 },
);
};
}
- Step 4: Run test to verify it passes
Run: bun test packages/authz/test/authz.test.ts
Expected: PASS
- Step 5: Commit
git add packages/authz/src/advanced.ts packages/authz/test/authz.test.ts
git commit -m "fix(authz): stop authorizeDecision leaking policy names in 403 bodies"
Task 9: Export the new surface
Files:
- Modify:
packages/authz/src/index.ts(append to the existing re-export block) - Modify:
docs/public-api-0.8.json(regenerated) - Test:
packages/authz/test/exports.test.ts
Interfaces:
-
Consumes: everything from Tasks 1-8
-
Produces: the public
@wrnexus/authzsurface -
Step 1: Write the failing test
Create packages/authz/test/exports.test.ts:
import { describe, expect, test } from "bun:test";
import * as authz from "../src/index.ts";
describe("@wrnexus/authz exports", () => {
test("keeps the pre-existing surface", () => {
for (const name of [
"defineRbac",
"hasRole",
"any",
"all",
"attr",
"authorize",
"requireRole",
"requirePermission",
"allow",
"deny",
"decision",
"owner",
"anyDecision",
"allDecisions",
"authorizeDecision",
"filterAuthorized",
]) {
expect(typeof (authz as Record<string, unknown>)[name]).toBe("function");
}
});
test("adds the registry, store, engine, and middleware surface", () => {
for (const name of [
"defineAuthz",
"mergeCatalogs",
"emptyCatalog",
"memoryPermissionStore",
"cachedPermissionStore",
"memoryAuditSink",
"consoleAuditSink",
"createAuthzResolver",
"expandRoles",
"permissionMatches",
"authzMiddleware",
"can",
"decideFor",
"guardPermission",
"filterCan",
]) {
expect(typeof (authz as Record<string, unknown>)[name]).toBe("function");
}
});
});
- Step 2: Run test to verify it fails
Run: bun test packages/authz/test/exports.test.ts
Expected: FAIL — defineAuthz is undefined
- Step 3: Append to
packages/authz/src/index.ts
export { defineAuthz } from "./registry.ts";
export { mergeCatalogs, emptyCatalog } from "./catalog.ts";
export type { CatalogSource } from "./catalog.ts";
export { memoryPermissionStore, cachedPermissionStore, scopeKey } from "./store.ts";
export type { PermissionStore, CachedPermissionStore, CacheOptions, GrantEffect } from "./store.ts";
export { memoryAuditSink, consoleAuditSink, safeRecord } from "./audit.ts";
export type { AuthzAuditEvent, AuthzAuditSink, MemoryAuditSink } from "./audit.ts";
export { createAuthzResolver, expandRoles, permissionMatches } from "./engine.ts";
export type { AuthzResolver, AuthzResolverOptions, DecideInput } from "./engine.ts";
export {
authzMiddleware,
can,
decideFor,
guardPermission,
filterCan,
AUTHZ_LOCALS_KEY,
} from "./middleware.ts";
export type { GuardOptions } from "./middleware.ts";
export type {
AuthzScope,
AuthzCatalog,
AuthzModule,
AttributeMeta,
PermissionMeta,
SubjectAssignments,
} from "./types.ts";
export type { AuthorizeDecisionOptions } from "./advanced.ts";
- Step 4: Run tests and regenerate the API baseline
Run: bun test packages/authz && bun run generate:public-api && bun run check:public-api
Expected: tests PASS; baseline regenerates; check reports a match
- Step 5: Commit
git add packages/authz/src/index.ts packages/authz/test/exports.test.ts docs/public-api-0.8.json
git commit -m "feat(authz): export registry, store, engine, and middleware surface"
Task 10: Router discovery of app/authz
Files:
- Modify:
packages/router/src/index.ts:273-294(alongside the existing schema scan) - Test:
packages/router/test/authz-discovery.test.ts
Interfaces:
-
Consumes:
scanDir,isSafeIslandName,ComponentRefalready inpackages/router/src/index.ts -
Produces:
Router.authz: ComponentRef[] -
Step 1: Write the failing test
Create packages/router/test/authz-discovery.test.ts:
import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildRouter } from "../src/index.ts";
function appWithAuthz(files: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-"));
const dir = join(root, "app", "authz");
mkdirSync(dir, { recursive: true });
mkdirSync(join(root, "app", "pages"), { recursive: true });
for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body, "utf8");
return join(root, "app");
}
describe("app/authz discovery", () => {
test("collects .ts and .js declarations by filename", () => {
const appDir = appWithAuthz({
"blog.ts": "export default {};",
"billing.js": "export default {};",
});
const router = buildRouter(appDir);
expect(router.authz.map((entry) => entry.name).sort()).toEqual(["billing", "blog"]);
});
test("ignores non-module files", () => {
const appDir = appWithAuthz({ "blog.ts": "export default {};", "notes.md": "# hi" });
expect(buildRouter(appDir).authz.map((entry) => entry.name)).toEqual(["blog"]);
});
test("skips unsafe names", () => {
const appDir = appWithAuthz({
"ok.ts": "export default {};",
"bad name!.ts": "export default {};",
});
expect(buildRouter(appDir).authz.map((entry) => entry.name)).toEqual(["ok"]);
});
test("an app with no authz directory yields an empty list", () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-none-"));
mkdirSync(join(root, "app", "pages"), { recursive: true });
expect(buildRouter(join(root, "app")).authz).toEqual([]);
});
});
- Step 2: Run test to verify it fails
Run: bun test packages/router/test/authz-discovery.test.ts
Expected: FAIL — router.authz is undefined
- Step 3: Modify
packages/router/src/index.ts
Add to the Router interface, next to schemas:
/** Authorization declarations (`app/authz/<name>.ts`) merged into the catalog. */
authz: ComponentRef[];
Add the scan immediately after the existing schemas loop:
// Authorization declarations: app/authz/<name>.{ts,js}, each default-exporting
// a defineAuthz() module. Merged into the catalog at boot.
const authz: ComponentRef[] = [];
for (const f of scanDir(join(appDir, "authz"))) {
if (!/\.(ts|js)$/.test(f.file)) continue;
const name = basename(f.file).replace(/\.(ts|js)$/, "");
if (!isSafeIslandName(name)) {
console.warn(`[wrnexus] skipping authz declaration with unsafe name: ${name}`);
continue;
}
authz.push({ name, file: f.file });
}
Add authz, to the returned object, next to schemas,.
- Step 4: Run test to verify it passes
Run: bun test packages/router
Expected: PASS — the new file plus existing router tests
- Step 5: Commit
git add packages/router/src/index.ts packages/router/test/authz-discovery.test.ts
git commit -m "feat(router): discover app/authz declarations"
Task 11: Database store adapter
Files:
- Create:
packages/authz/src/migrations.ts - Create:
packages/authz/src/db.ts - Modify:
packages/authz/package.json(add./dbexport) - Test:
packages/authz/test/store-db.test.ts
Interfaces:
-
Consumes:
PermissionStore,scopeKeyfrom./store.ts;Dbtype from@wrnexus/db;Dialectfrom@wrnexus/db -
Produces:
authzMigrationSql(dialect: Dialect): { up: string; down: string },dbPermissionStore(db: Db): PermissionStore,ensureAuthzTables(db: Db, dialect?: Dialect): Promise<void> -
Step 1: Write the failing test
Create packages/authz/test/store-db.test.ts:
import { createDb } from "@wrnexus/db";
import { sqlite } from "@wrnexus/db/sqlite";
import { dbPermissionStore, ensureAuthzTables } from "../src/db.ts";
import { runStoreConformance } from "./store-conformance.ts";
// The db adapter must satisfy exactly the same contract as the memory one.
runStoreConformance("sqlite", async () => {
const db = createDb(sqlite(":memory:"));
await ensureAuthzTables(db, "sqlite");
return dbPermissionStore(db);
});
- Step 2: Run test to verify it fails
Run: bun test packages/authz/test/store-db.test.ts
Expected: FAIL — cannot resolve ../src/db.ts
- Step 3: Write the migration SQL
Create packages/authz/src/migrations.ts:
import type { Dialect } from "@wrnexus/db";
/**
* DDL for the two assignment tables. `scope` holds a tenant id, or the empty
* string for a global assignment, so the unique constraints work on every
* dialect (NULL is not comparable in a UNIQUE index).
*/
export function authzMigrationSql(dialect: Dialect): { up: string; down: string } {
const id =
dialect === "postgres"
? "SERIAL PRIMARY KEY"
: dialect === "mysql"
? "INT AUTO_INCREMENT PRIMARY KEY"
: "INTEGER PRIMARY KEY AUTOINCREMENT";
const timestamp = dialect === "sqlite" ? "TEXT" : "TIMESTAMP";
const now = dialect === "sqlite" ? "CURRENT_TIMESTAMP" : "CURRENT_TIMESTAMP";
const up = `CREATE TABLE IF NOT EXISTS _wrn_authz_assignment (
id ${id},
subject_id VARCHAR(255) NOT NULL,
scope VARCHAR(255) NOT NULL DEFAULT '',
role VARCHAR(255) NOT NULL,
granted_by VARCHAR(255),
created_at ${timestamp} NOT NULL DEFAULT ${now},
CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role)
);
CREATE TABLE IF NOT EXISTS _wrn_authz_grant (
id ${id},
subject_id VARCHAR(255) NOT NULL,
scope VARCHAR(255) NOT NULL DEFAULT '',
permission VARCHAR(255) NOT NULL,
effect VARCHAR(16) NOT NULL,
granted_by VARCHAR(255),
created_at ${timestamp} NOT NULL DEFAULT ${now},
CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission)
);`;
const down = `DROP TABLE IF EXISTS _wrn_authz_grant;
DROP TABLE IF EXISTS _wrn_authz_assignment;`;
return { up, down };
}
- Step 4: Write the adapter
Create packages/authz/src/db.ts:
import type { Db, Dialect } from "@wrnexus/db";
import { authzMigrationSql } from "./migrations.ts";
import { scopeKey, type GrantEffect, type PermissionStore } from "./store.ts";
import type { AuthzScope, SubjectAssignments } from "./types.ts";
// Re-exported so `@wrnexus/authz/db` is the single entry point for everything
// database-related, including the DDL the CLI scaffolds.
export { authzMigrationSql } from "./migrations.ts";
/** Create the tables if absent. Production apps should use a real migration. */
export async function ensureAuthzTables(db: Db, dialect: Dialect = "sqlite"): Promise<void> {
for (const statement of authzMigrationSql(dialect).up.split(";\n\n")) {
const sql = statement.trim();
if (sql) await db.exec(sql.endsWith(";") ? sql : `${sql};`);
}
}
export function dbPermissionStore(db: Db): PermissionStore {
return {
async assignmentsFor(subjectId: string, scope?: AuthzScope): Promise<SubjectAssignments> {
const key = scopeKey(scope);
// A request inside a tenant sees global rows plus that tenant's rows.
const roleRows = await db.all<{ role: string }>(
"SELECT role FROM _wrn_authz_assignment WHERE subject_id = ? AND (scope = '' OR scope = ?)",
[subjectId, key],
);
const grantRows = await db.all<{ permission: string; effect: GrantEffect }>(
"SELECT permission, effect FROM _wrn_authz_grant WHERE subject_id = ? AND (scope = '' OR scope = ?)",
[subjectId, key],
);
return {
roles: roleRows.map((row) => row.role),
grants: grantRows.filter((r) => r.effect === "allow").map((r) => r.permission),
denies: grantRows.filter((r) => r.effect === "deny").map((r) => r.permission),
};
},
async assignRole(subjectId, role, scope) {
const key = scopeKey(scope);
const existing = await db.all<{ id: number }>(
"SELECT id FROM _wrn_authz_assignment WHERE subject_id = ? AND scope = ? AND role = ?",
[subjectId, key, role],
);
if (existing.length) return;
await db.exec(
"INSERT INTO _wrn_authz_assignment (subject_id, scope, role) VALUES (?, ?, ?)",
[subjectId, key, role],
);
},
async revokeRole(subjectId, role, scope) {
await db.exec(
"DELETE FROM _wrn_authz_assignment WHERE subject_id = ? AND scope = ? AND role = ?",
[subjectId, scopeKey(scope), role],
);
},
async grant(subjectId, permission, effect, scope) {
const key = scopeKey(scope);
// Re-granting replaces the effect, so delete then insert.
await db.exec(
"DELETE FROM _wrn_authz_grant WHERE subject_id = ? AND scope = ? AND permission = ?",
[subjectId, key, permission],
);
await db.exec(
"INSERT INTO _wrn_authz_grant (subject_id, scope, permission, effect) VALUES (?, ?, ?, ?)",
[subjectId, key, permission, effect],
);
},
async revokeGrant(subjectId, permission, scope) {
await db.exec(
"DELETE FROM _wrn_authz_grant WHERE subject_id = ? AND scope = ? AND permission = ?",
[subjectId, scopeKey(scope), permission],
);
},
async listSubjects(scope) {
const key = scopeKey(scope);
const rows = await db.all<{ subject_id: string }>(
"SELECT subject_id FROM _wrn_authz_assignment WHERE scope = ? " +
"UNION SELECT subject_id FROM _wrn_authz_grant WHERE scope = ?",
[key, key],
);
return [...new Set(rows.map((row) => row.subject_id))];
},
};
}
- Step 5: Add the subpath export
In packages/authz/package.json, replace the exports block with:
"exports": {
".": "./src/index.ts",
"./db": "./src/db.ts"
},
Add "@wrnexus/authz/db": ["./packages/authz/src/db.ts"] to paths in the root tsconfig.json, next to the existing @wrnexus/authz entry.
- Step 6: Run test to verify it passes
Run: bun test packages/authz/test/store-db.test.ts
Expected: PASS — the same 13 conformance tests as the memory adapter
- Step 7: Commit
git add packages/authz/src/db.ts packages/authz/src/migrations.ts packages/authz/package.json packages/authz/test/store-db.test.ts tsconfig.json
git commit -m "feat(authz): add database-backed PermissionStore"
Task 12: Permission type codegen
Files:
- Create:
packages/authz/src/codegen.ts - Test:
packages/authz/test/codegen.test.ts
Interfaces:
-
Consumes:
AuthzCatalogfrom./types.ts -
Produces:
generatePermissionTypes(catalog: AuthzCatalog): string -
Step 1: Write the failing test
Create packages/authz/test/codegen.test.ts:
import { describe, expect, test } from "bun:test";
import { defineAuthz } from "../src/registry.ts";
import { mergeCatalogs, emptyCatalog } from "../src/catalog.ts";
import { generatePermissionTypes } from "../src/codegen.ts";
describe("generatePermissionTypes", () => {
test("emits sorted Permission and Role unions", () => {
const catalog = mergeCatalogs([
{
source: "t.ts",
module: defineAuthz({
permissions: { "post:write": {}, "post:read": {} },
roles: { editor: ["post:*"], admin: ["*"] },
}),
},
]);
const out = generatePermissionTypes(catalog);
expect(out).toContain('export type Permission = "post:read" | "post:write";');
expect(out).toContain('export type Role = "admin" | "editor";');
expect(out).toContain("DO NOT EDIT");
});
test("emits never for an empty catalog so the file still typechecks", () => {
const out = generatePermissionTypes(emptyCatalog());
expect(out).toContain("export type Permission = never;");
expect(out).toContain("export type Role = never;");
});
test("escapes quotes in identifiers", () => {
const catalog = mergeCatalogs([{ source: "t.ts", module: { roles: { 'we"ird': [] } } }]);
expect(generatePermissionTypes(catalog)).toContain('"we\\"ird"');
});
});
- Step 2: Run test to verify it fails
Run: bun test packages/authz/test/codegen.test.ts
Expected: FAIL — cannot resolve ../src/codegen.ts
- Step 3: Write the implementation
Create packages/authz/src/codegen.ts:
import type { AuthzCatalog } from "./types.ts";
function union(values: string[]): string {
if (!values.length) return "never";
return values
.slice()
.sort()
.map((value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`)
.join(" | ");
}
/**
* Emit compile-time unions for the registered permissions and roles, so a
* typo in can(ctx, "post:wrtie") is a type error rather than a silent false.
*/
export function generatePermissionTypes(catalog: AuthzCatalog): string {
return `// Generated by \`wrnexus authz generate\`. DO NOT EDIT.
export type Permission = ${union([...catalog.permissions.keys()])};
export type Role = ${union([...catalog.roles.keys()])};
`;
}
- Step 4: Export it
Append to packages/authz/src/index.ts (Task 13 imports this from the package entry):
export { generatePermissionTypes } from "./codegen.ts";
- Step 5: Run test to verify it passes
Run: bun test packages/authz/test/codegen.test.ts && bun run generate:public-api
Expected: PASS, 3 tests; baseline updated
- Step 6: Commit
git add packages/authz/src/codegen.ts packages/authz/src/index.ts packages/authz/test/codegen.test.ts docs/public-api-0.8.json
git commit -m "feat(authz): generate Permission and Role union types"
Task 13: wrnexus authz CLI
Files:
- Create:
packages/cli/src/authz.ts - Modify:
packages/cli/src/index.ts(addcase "authz"next tocase "db") - Test:
packages/cli/test/authz-command.test.ts
Interfaces:
-
Consumes:
buildRouterfrom@wrnexus/router;mergeCatalogs,generatePermissionTypes,authzMigrationSqlfrom@wrnexus/authz -
Produces:
loadAuthzCatalog(appDir: string): Promise<AuthzCatalog>,runAuthzCommand(root: string, sub: string | undefined, args: string[]): Promise<void> -
Step 1: Write the failing test
Create packages/cli/test/authz-command.test.ts:
import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadAuthzCatalog, runAuthzCommand } from "../src/authz.ts";
function scaffold(): string {
const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-cli-"));
mkdirSync(join(root, "app", "authz"), { recursive: true });
mkdirSync(join(root, "app", "pages"), { recursive: true });
writeFileSync(
join(root, "app", "authz", "blog.ts"),
`import { defineAuthz } from "@wrnexus/authz";
export default defineAuthz({
permissions: { "post:read": { title: "View posts" }, "post:write": {} },
roles: { editor: ["post:*"] },
});
`,
"utf8",
);
return root;
}
describe("wrnexus authz", () => {
test("loadAuthzCatalog merges every declaration", async () => {
const catalog = await loadAuthzCatalog(join(scaffold(), "app"));
expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "post:write"]);
expect([...catalog.roles.keys()]).toEqual(["editor"]);
});
test("generate writes the permission types file", async () => {
const root = scaffold();
await runAuthzCommand(root, "generate", []);
const generated = readFileSync(join(root, "app", "authz", "permissions.gen.ts"), "utf8");
expect(generated).toContain('export type Permission = "post:read" | "post:write";');
});
test("init writes a migration containing both tables", async () => {
const root = scaffold();
mkdirSync(join(root, "app", "db", "migrations"), { recursive: true });
await runAuthzCommand(root, "init", []);
const dir = join(root, "app", "db", "migrations");
const file = require("node:fs")
.readdirSync(dir)
.find((name: string) => name.includes("authz"));
expect(file).toBeDefined();
const sql = readFileSync(join(dir, file!), "utf8");
expect(sql).toContain("_wrn_authz_assignment");
expect(sql).toContain("_wrn_authz_grant");
expect(sql).toContain("-- +down");
});
test("list prints every permission and role", async () => {
const root = scaffold();
const lines: string[] = [];
const original = console.log;
console.log = (...args: unknown[]) => void lines.push(args.join(" "));
try {
await runAuthzCommand(root, "list", []);
} finally {
console.log = original;
}
const output = lines.join("\n");
expect(output).toContain("post:read");
expect(output).toContain("editor");
});
test("an unknown subcommand throws with usage", async () => {
await expect(runAuthzCommand(scaffold(), "bogus", [])).rejects.toThrow(/usage/i);
});
});
- Step 2: Run test to verify it fails
Run: bun test packages/cli/test/authz-command.test.ts
Expected: FAIL — cannot resolve ../src/authz.ts
- Step 3: Write the implementation
Create packages/cli/src/authz.ts:
/**
* `wrnexus authz <cmd>` — authorization catalog tooling.
*
* wrnexus authz list print every registered permission, role, and policy
* wrnexus authz generate write app/authz/permissions.gen.ts type unions
* wrnexus authz init scaffold the assignment-table migration
*/
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { buildRouter } from "@wrnexus/router";
import {
generatePermissionTypes,
mergeCatalogs,
type AuthzCatalog,
type AuthzModule,
type CatalogSource,
} from "@wrnexus/authz";
import { authzMigrationSql } from "@wrnexus/authz/db";
const USAGE = "usage: wrnexus authz <list|generate|init>";
/** Import every app/authz declaration and merge it into one catalog. */
export async function loadAuthzCatalog(appDir: string): Promise<AuthzCatalog> {
const router = buildRouter(appDir);
const sources: CatalogSource[] = [];
for (const entry of router.authz) {
const imported = (await import(pathToFileURL(entry.file).href)) as {
default?: AuthzModule;
};
if (!imported.default) {
console.warn(`[wrnexus] ${entry.file} has no default export; skipping`);
continue;
}
sources.push({ source: entry.file, module: imported.default });
}
return mergeCatalogs(sources);
}
function nextMigrationNumber(dir: string): string {
if (!existsSync(dir)) return "0001";
const numbers = readdirSync(dir)
.map((name) => Number.parseInt(name.slice(0, 4), 10))
.filter((value) => Number.isInteger(value));
return String((numbers.length ? Math.max(...numbers) : 0) + 1).padStart(4, "0");
}
export async function runAuthzCommand(
root: string,
sub: string | undefined,
args: string[],
): Promise<void> {
const appDir = join(resolve(root), "app");
switch (sub) {
case "list": {
const catalog = await loadAuthzCatalog(appDir);
console.log(`Permissions (${catalog.permissions.size}):`);
for (const [id, meta] of [...catalog.permissions].sort()) {
const tags = [meta.risk && `risk=${meta.risk}`, meta.public && "public"]
.filter(Boolean)
.join(" ");
console.log(` ${id}${meta.title ? ` — ${meta.title}` : ""}${tags ? ` [${tags}]` : ""}`);
}
console.log(`\nRoles (${catalog.roles.size}):`);
for (const [name, grants] of [...catalog.roles].sort()) {
console.log(` ${name} → ${grants.join(", ") || "(nothing)"}`);
}
console.log(`\nPolicies (${catalog.policies.size}):`);
for (const name of [...catalog.policies.keys()].sort()) {
const bound = [...catalog.bindings]
.filter(([, names]) => names.includes(name))
.map(([permission]) => permission);
console.log(` ${name}${bound.length ? ` → ${bound.join(", ")}` : " (unbound)"}`);
}
return;
}
case "generate": {
const catalog = await loadAuthzCatalog(appDir);
const target = join(appDir, "authz", "permissions.gen.ts");
mkdirSync(join(appDir, "authz"), { recursive: true });
writeFileSync(target, generatePermissionTypes(catalog), "utf8");
console.log(
`Wrote ${target} (${catalog.permissions.size} permissions, ${catalog.roles.size} roles)`,
);
return;
}
case "init": {
const dialect = (args.find((arg) => arg.startsWith("--dialect="))?.split("=")[1] ??
"sqlite") as "sqlite" | "postgres" | "mysql";
const dir = join(appDir, "db", "migrations");
mkdirSync(dir, { recursive: true });
const { up, down } = authzMigrationSql(dialect);
const file = join(dir, `${nextMigrationNumber(dir)}_authz_tables.sql`);
writeFileSync(file, `-- +up\n${up}\n\n-- +down\n${down}\n`, "utf8");
console.log(`Wrote ${file}`);
console.log("Run `wrnexus db migrate` to apply it.");
return;
}
default:
throw new Error(USAGE);
}
}
- Step 4: Wire it into the CLI
In packages/cli/src/index.ts, add immediately after the case "db" block:
case "authz": {
bootstrapProfile(".", "development", rest);
const { runAuthzCommand } = await import("./authz.ts");
const [sub, ...authzArgs] = rest.filter((a) => !a.startsWith("--profile="));
await runAuthzCommand(".", sub, authzArgs);
break;
}
Also add authz to the help text listing available commands.
- Step 5: Run test to verify it passes
Run: bun test packages/cli/test/authz-command.test.ts
Expected: PASS, 5 tests
- Step 6: Commit
git add packages/cli/src/authz.ts packages/cli/src/index.ts packages/cli/test/authz-command.test.ts
git commit -m "feat(cli): add wrnexus authz list/generate/init"
Task 14: Wire the catalog into dev and prod boot
Files:
- Modify:
packages/dev-server/src/index.ts(load catalog instartServer) - Modify:
packages/cli/src/build.ts(bake catalog into the prod manifest) - Test:
packages/dev-server/test/authz-boot.test.ts
Interfaces:
-
Consumes:
loadAuthzCatalogpattern from Task 13;authzMiddlewarefrom@wrnexus/authz -
Produces:
RuntimeDeps.authz?: AuthzCatalogavailable to the request pipeline -
Step 1: Write the failing test
Create packages/dev-server/test/authz-boot.test.ts:
import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadAppAuthzCatalog } from "../src/authz-boot.ts";
function scaffold(body: string): string {
const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-boot-"));
mkdirSync(join(root, "app", "authz"), { recursive: true });
mkdirSync(join(root, "app", "pages"), { recursive: true });
writeFileSync(join(root, "app", "authz", "main.ts"), body, "utf8");
return join(root, "app");
}
describe("loadAppAuthzCatalog", () => {
test("loads declarations from app/authz", async () => {
const appDir = scaffold(
`import { defineAuthz } from "@wrnexus/authz";
export default defineAuthz({ permissions: { "post:read": {} } });`,
);
const catalog = await loadAppAuthzCatalog(appDir);
expect(catalog.permissions.has("post:read")).toBe(true);
});
test("an app with no declarations gets an empty catalog rather than an error", async () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-empty-"));
mkdirSync(join(root, "app", "pages"), { recursive: true });
const catalog = await loadAppAuthzCatalog(join(root, "app"));
expect(catalog.permissions.size).toBe(0);
});
test("a conflicting declaration fails the boot loudly", async () => {
const appDir = scaffold(
`import { defineAuthz } from "@wrnexus/authz";
export default defineAuthz({ permissions: { "post:read": { risk: "low" } } });`,
);
writeFileSync(
join(appDir, "authz", "other.ts"),
`import { defineAuthz } from "@wrnexus/authz";
export default defineAuthz({ permissions: { "post:read": { risk: "high" } } });`,
"utf8",
);
await expect(loadAppAuthzCatalog(appDir)).rejects.toThrow(/WRN-AUTHZ-CONFLICT/);
});
});
- Step 2: Run test to verify it fails
Run: bun test packages/dev-server/test/authz-boot.test.ts
Expected: FAIL — cannot resolve ../src/authz-boot.ts
- Step 3: Write the loader
Create packages/dev-server/src/authz-boot.ts:
import { pathToFileURL } from "node:url";
import { buildRouter } from "@wrnexus/router";
import {
emptyCatalog,
mergeCatalogs,
type AuthzCatalog,
type AuthzModule,
type CatalogSource,
} from "@wrnexus/authz";
/**
* Load and merge every `app/authz/*.ts` declaration. Conflicts throw so a
* misconfigured catalog fails the boot rather than silently changing who can
* do what.
*/
export async function loadAppAuthzCatalog(appDir: string): Promise<AuthzCatalog> {
const router = buildRouter(appDir);
if (!router.authz.length) return emptyCatalog();
const sources: CatalogSource[] = [];
for (const entry of router.authz) {
if (entry.name === "permissions.gen") continue; // generated types, not a declaration
const imported = (await import(pathToFileURL(entry.file).href)) as { default?: AuthzModule };
if (!imported.default) continue;
sources.push({ source: entry.file, module: imported.default });
}
return mergeCatalogs(sources);
}
- Step 4: Run test to verify it passes
Run: bun test packages/dev-server/test/authz-boot.test.ts
Expected: PASS, 3 tests
- Step 5: Commit
git add packages/dev-server/src/authz-boot.ts packages/dev-server/test/authz-boot.test.ts
git commit -m "feat(dev-server): load the authz catalog at boot"
Task 15: Example app wiring and documentation
Files:
- Create:
examples/auth-showcase/app/authz/showcase.ts - Modify:
packages/authz/README.md - Test:
packages/authz/test/integration.test.ts
Interfaces:
-
Consumes: the full surface from Tasks 1-14
-
Produces: a worked end-to-end example proving the pieces compose
-
Step 1: Write the failing integration test
Create packages/authz/test/integration.test.ts:
import { describe, expect, test } from "bun:test";
import type { Context } from "@wrnexus/core";
import { createDb } from "@wrnexus/db";
import { sqlite } from "@wrnexus/db/sqlite";
import { dbPermissionStore, ensureAuthzTables } from "../src/db.ts";
import {
authzMiddleware,
can,
cachedPermissionStore,
defineAuthz,
guardPermission,
memoryAuditSink,
mergeCatalogs,
} from "../src/index.ts";
const catalog = mergeCatalogs([
{
source: "showcase.ts",
module: defineAuthz({
permissions: {
"post:read": { public: true },
"post:write": {},
"post:delete": { risk: "high" },
},
roles: { editor: ["post:write"], admin: ["role:editor", "post:delete"] },
policies: {
ownsPost: async (s: { id?: string }, r?: { authorId?: string }) =>
r?.authorId === s?.id ? { allowed: true } : { allowed: false, reason: "not owner" },
},
bindings: { "post:delete": ["ownsPost"] },
}),
},
]);
function makeCtx(user: unknown, tenantId?: string): Context {
return {
user,
tenant: tenantId ? { id: tenantId } : undefined,
locals: {},
url: new URL("http://localhost/"),
req: new Request("http://localhost/"),
} as unknown as Context;
}
describe("end-to-end authorization", () => {
test("db store, cache, catalog, middleware, and audit compose", async () => {
const db = createDb(sqlite(":memory:"));
await ensureAuthzTables(db, "sqlite");
const store = cachedPermissionStore(dbPermissionStore(db), { ttlMs: 1_000 });
const audit = memoryAuditSink();
await store.assignRole("alice", "admin", { tenantId: "acme" });
const alice = makeCtx({ id: "alice" }, "acme");
await authzMiddleware({ catalog, store, audit, strict: true })(
alice,
async () => new Response("ok"),
);
expect(await can(alice, "post:write")).toBe(true);
expect(await can(alice, "post:delete", { id: 1, authorId: "alice" })).toBe(true);
expect(await can(alice, "post:delete", { id: 2, authorId: "bob" })).toBe(false);
// Wrong tenant: the admin role was scoped to acme.
const elsewhere = makeCtx({ id: "alice" }, "other");
await authzMiddleware({ catalog, store, strict: true })(
elsewhere,
async () => new Response("ok"),
);
expect(await can(elsewhere, "post:write")).toBe(false);
// Anonymous can still read, because post:read is public.
const guest = makeCtx(null);
await authzMiddleware({ catalog, store, strict: true })(guest, async () => new Response("ok"));
expect(await can(guest, "post:read")).toBe(true);
expect(await can(guest, "post:write")).toBe(false);
// Only denials were audited.
expect(audit.events.every((event) => !event.allowed)).toBe(true);
expect(audit.events.length).toBeGreaterThan(0);
});
test("revoking a role takes effect immediately through the cache", async () => {
const db = createDb(sqlite(":memory:"));
await ensureAuthzTables(db, "sqlite");
const store = cachedPermissionStore(dbPermissionStore(db), { ttlMs: 60_000 });
await store.assignRole("bob", "editor");
const before = makeCtx({ id: "bob" });
await authzMiddleware({ catalog, store, strict: true })(before, async () => new Response("ok"));
expect(await can(before, "post:write")).toBe(true);
await store.revokeRole("bob", "editor");
const after = makeCtx({ id: "bob" });
await authzMiddleware({ catalog, store, strict: true })(after, async () => new Response("ok"));
expect(await can(after, "post:write")).toBe(false);
});
test("guardPermission returns an opaque 403", async () => {
const db = createDb(sqlite(":memory:"));
await ensureAuthzTables(db, "sqlite");
const ctx = makeCtx({ id: "carol" });
await authzMiddleware({ catalog, store: dbPermissionStore(db), strict: true })(
ctx,
async () => new Response("ok"),
);
const res = await guardPermission("post:write")(ctx, async () => new Response("passed"));
expect(res.status).toBe(403);
expect(await res.json()).toEqual({ ok: false, error: "Forbidden" });
});
});
- Step 2: Run test to verify it fails or passes
Run: bun test packages/authz/test/integration.test.ts
Expected: PASS if Tasks 1-14 are correct. Any failure here is a real integration
defect — fix the underlying module, not the test.
- Step 3: Add the example declaration
Create examples/auth-showcase/app/authz/showcase.ts:
import { defineAuthz } from "@wrnexus/authz";
export default defineAuthz({
permissions: {
"post:read": { title: "View posts", public: true },
"post:write": { title: "Create and edit posts" },
"post:delete": { title: "Delete posts", risk: "high" },
"admin:access": { title: "Reach the admin area", risk: "high" },
},
roles: {
viewer: ["post:read"],
editor: ["role:viewer", "post:write"],
admin: ["role:editor", "post:delete", "admin:access"],
},
policies: {
ownsPost: async (
subject: { id?: string },
resource?: { authorId?: string },
): Promise<{ allowed: boolean; reason?: string }> =>
resource?.authorId === subject?.id
? { allowed: true }
: { allowed: false, reason: "You are not the author" },
},
bindings: { "post:delete": ["ownsPost"] },
});
- Step 4: Document the surface
Append to packages/authz/README.md:
## Declaring permissions
Put declarations in `app/authz/<name>.ts`. They are discovered automatically.
```ts
import { defineAuthz, owner } from "@wrnexus/authz";
export default defineAuthz({
permissions: {
"post:read": { title: "View posts", public: true },
"post:delete": { title: "Delete posts", risk: "high" },
},
roles: { editor: ["post:*"], admin: ["role:editor"] },
policies: { ownsPost: owner("id", "authorId") },
bindings: { "post:delete": ["ownsPost"] },
});
```
## Checking permissions
Register the middleware once, then use `can()` and `guardPermission()`:
```ts
import { authzMiddleware, can, guardPermission } from "@wrnexus/authz";
import { dbPermissionStore } from "@wrnexus/authz/db";
import { getDb } from "@wrnexus/db";
export default [authzMiddleware({ catalog, store: dbPermissionStore(getDb()) })];
// in a route
export const middleware = [guardPermission("post:write")];
if (await can(ctx, "post:delete", post)) {
/* ... */
}
```
`can()` is a free function, not `ctx.can` — `@wrnexus/core` must not depend on
`@wrnexus/authz`.
## Precedence
1. An explicit deny wins over everything, including `*`.
2. A bound policy can veto a permission a role grants.
3. Otherwise the permission must be held via a role or an explicit grant.
4. Default deny.
Every failure — unknown permission, store outage, policy exception — denies.
## CLI
```bash
wrnexus authz list # every registered permission, role, and policy
wrnexus authz generate # app/authz/permissions.gen.ts type unions
wrnexus authz init # scaffold the assignment-table migration
```
- Step 5: Run the full gate
Run: bun run check:production
Expected: PASS. If check:public-api complains, run bun run generate:public-api and
re-run.
- Step 6: Commit
git add packages/authz/test/integration.test.ts packages/authz/README.md examples/auth-showcase/app/authz/showcase.ts docs/public-api-0.8.json
git commit -m "test(authz): end-to-end integration coverage, example, and docs"
Deferred phases
These are not in scope for this plan. Each needs its own design pass.
Phase 4 — .wrn view integration
Exposing can() inside compiler-generated {#if} expressions touches
packages/compiler/src/codegen.ts. Because {#if} compiles to a nested ternary inside a
template literal and can() is async, the resolution must happen before the view renders
— most likely by collecting referenced permissions at compile time and pre-resolving them
into the SSR scope, the way collectControlExprs already pre-resolves ssr { api ... }
bindings. Do not begin this without confirming that shape against the codegen.
Phase 5 — Admin UI
.wrn components for listing subjects and assigning roles, shipped in @wrnexus/ui behind
the existing wrnexus eject mechanism. Depends on listSubjects and the CLI landing first.
Inter-app communication seam
exportSubjectContext(ctx) / importSubjectContext(token) are specified in the design doc
but intentionally unbuilt. They belong to the inter-app communication system, which has not
been designed yet.