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