authzMigrationSql was changed in Task 11 to return statement arrays rather than one blob, but Task 13's init still interpolated them straight into the migration file, which would comma-join two CREATE TABLE statements into one unparseable line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 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("an explicitly empty tenantId is refused, not treated as global", async () => {
await store.assignRole("g1", "viewer");
// Otherwise a caller who controls the tenant id reaches global scope.
await expect(store.assignmentsFor("g1", { tenantId: "" })).rejects.toThrow(/tenantId/);
await expect(store.assignRole("g1", "admin", { tenantId: "" })).rejects.toThrow(/tenantId/);
});
test("a non-string tenantId is refused", async () => {
// Same class as the empty-string case: the caller controls this value.
for (const bad of [null, 0, false, {}]) {
await expect(store.assignmentsFor("u1", { tenantId: bad as never })).rejects.toThrow(
/tenantId/,
);
}
});
test("concurrent identical assignRole calls all resolve", async () => {
// Check-then-act loses this race; the UNIQUE constraint then rejects
// every loser even though the desired end state was already reached.
await Promise.all(Array.from({ length: 20 }, () => store.assignRole("u1", "editor")));
expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
});
test("concurrent grants on distinct keys all resolve", async () => {
await Promise.all([
store.grant("u1", "post:read", "allow"),
store.grant("u1", "post:write", "allow"),
store.grant("u1", "post:delete", "deny"),
]);
const assignments = await store.assignmentsFor("u1");
expect(assignments.grants.sort()).toEqual(["post:read", "post:write"]);
expect(assignments.denies).toEqual(["post:delete"]);
});
test("a rejected write leaves unrelated state intact", async () => {
await store.assignRole("victim", "admin");
await store.grant("victim", "post:read", "allow");
// An invalid effect must be refused without disturbing anything else.
await expect(store.grant("victim", "post:write", "bogus" as never)).rejects.toThrow();
const assignments = await store.assignmentsFor("victim");
expect(assignments.roles).toEqual(["admin"]);
expect(assignments.grants).toEqual(["post:read"]);
});
// NOTE: the shared-connection rollback hazard - where one method's open
// transaction sweeps in a concurrent bare write from another method and
// discards it, so a revoke resolves successfully while the role survives -
// is prevented STRUCTURALLY, by the store using no transactions at all.
// It is deliberately not covered here: reproducing it needs the bare write
// to land inside the open transaction, which a single-process Promise.all
// does not reliably arrange, so any such test would pass against the
// defective implementation and give false assurance.
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. An OMITTED
* scope means global; an explicitly EMPTY tenantId is refused, because it is
* indistinguishable from global and would let a caller who controls the tenant
* id read and write global assignments.
*/
export function scopeKey(scope?: AuthzScope): string {
const tenantId = scope?.tenantId;
if (tenantId === undefined) return "";
// Guard the TYPE as well as the value: a null from a JSON body or a nullable
// column would otherwise flow through un-normalised and the adapters would
// disagree about what happened - the db rejects on NOT NULL, memory accepts
// an unreachable row.
if (typeof tenantId !== "string" || tenantId === "") {
throw new Error(
"WRN-AUTHZ-SCOPE: tenantId must be a non-empty string; omit the scope for a global assignment.",
);
}
return 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.
*
* Do NOT gate access by matching against `permissionsFor()`'s result — that set
* cannot represent a narrow deny beneath a broad grant, so the composition
* returns true where `decide()` refuses. Use `decide()` / `can()` instead.
*/
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 symbol resources, which also carry identity. */
bySymbol: Map<symbol, 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(),
bySymbol: new Map(),
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.
// Subject and scope are both part of the key. A request that reassigns
// ctx.user (impersonation, step-up auth, session revocation) or ctx.tenant
// must not be served the previous principal's verdict from the memo.
const subjectId = (ctx.user as { id?: unknown } | null | undefined)?.id;
const key = JSON.stringify([
scope?.tenantId ?? "",
permission,
typeof subjectId,
String(subjectId),
]);
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.
// Symbols carry identity that String() erases, so they memo by identity too.
// They are held in a plain Map rather than the WeakMap: the memo is discarded
// with the request, so there is nothing to leak.
if (typeof resource === "symbol") {
let perSymbol = request.bySymbol.get(resource);
if (!perSymbol) request.bySymbol.set(resource, (perSymbol = new Map()));
const cached = perSymbol.get(key);
if (cached) return cached;
const pending = run();
perSymbol.set(key, pending);
return pending;
}
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, and
// -0 keeps its sign because String(-0) is "0".
const rendered = Object.is(resource, -0) ? "-0" : String(resource);
const valueKey = JSON.stringify([key, typeof resource, rendered]);
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");
}
/**
* Header values must be Latin-1, so a localized path would otherwise throw
* inside `new Response` and 500 on a denial path. Encode ONLY the codepoints
* that cannot be sent: encodeURI would also escape "%", corrupting a target
* that already carries a percent-encoded return path.
*/
function headerSafePath(value: string): string {
let out = "";
for (const character of value) {
out += character.codePointAt(0)! <= 0x7f ? character : encodeURIComponent(character);
}
return out;
}
/** 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)) {
// JSON-encode: this branch exists precisely for values containing
// CR/LF, which would otherwise forge a second log line.
console.error(
`[wrnexus:authz] redirectTo must be a local path, got ${JSON.stringify(options.redirectTo)}; denying`,
);
} else {
return new Response(null, {
status: 303,
headers: {
location: headerSafePath(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",
"scopeKey",
"safeRecord",
"deniedBy",
"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, deniedBy } 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[] = [];
// scanDir's extension allow-list is route-oriented; passing [".js"] here keeps
// .js out of app/pages scanning, where it would leak into route URLs.
for (const f of scanDir(join(appDir, "authz"), [".js"])) {
if (!/\.(ts|js)$/.test(f.file)) continue;
// Generated type files (permissions.gen.ts) live here too. Skip them quietly:
// they export types only, and isSafeIslandName would otherwise reject the dot
// and warn on every boot.
if (/[.]gen[.](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).
*/
/**
* DDL for the two assignment tables, as a list of statements rather than one
* blob: splitting a blob on a separator makes runtime correctness depend on
* source formatting, and only the sqlite driver accepts multi-statement exec.
*
* `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). `effect` is CHECK-constrained: an unrecognised value would
* otherwise be dropped from both the grant and deny buckets on read, silently
* turning a deny into a no-op.
*/
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";
// MySQL's default collation is case- and accent-insensitive, which would let
// tenant "T1" match "t1" and collapse roles "admin"/"Admin" onto one row.
const exact = dialect === "mysql" ? " COLLATE utf8mb4_bin" : "";
const key = `VARCHAR(255)${exact} NOT NULL`;
return {
up: [
`CREATE TABLE IF NOT EXISTS _wrn_authz_assignment (
id ${id},
subject_id ${key},
scope ${key} DEFAULT '',
role ${key},
created_at ${timestamp} NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role)
)`,
`CREATE TABLE IF NOT EXISTS _wrn_authz_grant (
id ${id},
subject_id ${key},
scope ${key} DEFAULT '',
permission ${key},
effect VARCHAR(16) NOT NULL CHECK (effect IN ('allow', 'deny')),
created_at ${timestamp} NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission)
)`,
],
down: ["DROP TABLE IF EXISTS _wrn_authz_grant", "DROP TABLE IF EXISTS _wrn_authz_assignment"],
};
}
- 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. */
/** Create the tables if absent. Production apps should use a real migration. */
export async function ensureAuthzTables(
db: Db,
dialect: Dialect = db.driver.dialect,
): Promise<void> {
for (const statement of authzMigrationSql(dialect).up) await db.exec(statement);
}
/** Positional placeholder for the dialect: postgres numbers them, others use "?". */
function ph(dialect: Dialect, index: number): string {
return dialect === "postgres" ? `$${index}` : "?";
}
export function dbPermissionStore(db: Db): PermissionStore {
const dialect = db.driver.dialect;
const p = (n: number) => ph(dialect, n);
// Single-statement upserts. A transaction here would be worse than useless:
// the drivers run BEGIN on one shared connection, so an open transaction
// swallows any concurrent write from another method and discards it on
// rollback - a revoke would resolve successfully while the role survived.
const onConflict = (columns: string, update: string) =>
dialect === "mysql"
? ` ON DUPLICATE KEY UPDATE ${update}`
: ` ON CONFLICT (${columns}) DO UPDATE SET ${update}`;
const onConflictIgnore = (columns: string) =>
dialect === "mysql"
? " ON DUPLICATE KEY UPDATE id = id"
: ` ON CONFLICT (${columns}) DO NOTHING`;
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 = ${p(1)} AND (scope = '' OR scope = ${p(2)})`,
[subjectId, key],
);
const grantRows = await db.all<{ permission: string; effect: string }>(
`SELECT permission, effect FROM _wrn_authz_grant WHERE subject_id = ${p(1)} AND (scope = '' OR scope = ${p(2)})`,
[subjectId, key],
);
return {
roles: roleRows.map((row) => row.role),
grants: grantRows.filter((r) => r.effect === "allow").map((r) => r.permission),
// Anything that is not literally "allow" counts as a deny, so a
// corrupted or mis-cased effect fails closed rather than vanishing.
denies: grantRows.filter((r) => r.effect !== "allow").map((r) => r.permission),
};
},
async assignRole(subjectId, role, scope) {
await db.exec(
`INSERT INTO _wrn_authz_assignment (subject_id, scope, role) VALUES (${p(1)}, ${p(2)}, ${p(3)})` +
onConflictIgnore("subject_id, scope, role"),
[subjectId, scopeKey(scope), role],
);
},
async revokeRole(subjectId, role, scope) {
await db.exec(
`DELETE FROM _wrn_authz_assignment WHERE subject_id = ${p(1)} AND scope = ${p(2)} AND role = ${p(3)}`,
[subjectId, scopeKey(scope), role],
);
},
async grant(subjectId, permission, effect, scope) {
await db.exec(
`INSERT INTO _wrn_authz_grant (subject_id, scope, permission, effect) VALUES (${p(1)}, ${p(2)}, ${p(3)}, ${p(4)})` +
onConflict(
"subject_id, scope, permission",
"effect = " + (dialect === "mysql" ? "VALUES(effect)" : "excluded.effect"),
),
[subjectId, scopeKey(scope), permission, effect],
);
},
async revokeGrant(subjectId, permission, scope) {
await db.exec(
`DELETE FROM _wrn_authz_grant WHERE subject_id = ${p(1)} AND scope = ${p(2)} AND permission = ${p(3)}`,
[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 = ${p(1)} ` +
`UNION SELECT subject_id FROM _wrn_authz_grant WHERE scope = ${p(2)}`,
[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";
// JSON.stringify, not hand-rolled escaping: role names reach this via the
// raw mergeCatalogs path without the registry's id validation, so a value
// may contain a newline, which manual quote/backslash escaping would emit
// as an unterminated string literal.
return values
.slice()
.sort()
.map((value) => JSON.stringify(value))
.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`);
// up/down are statement LISTS; interpolating the arrays directly would
// comma-join them into one unparseable statement.
const block = (statements: string[]) => statements.map((s) => `${s};`).join("\n\n");
writeFileSync(file, `-- +up\n${block(up)}\n\n-- +down\n${block(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) {
// buildRouter already skips *.gen.ts, so only real declarations arrive here.
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.