diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md new file mode 100644 index 00000000..439acde7 --- /dev/null +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -0,0 +1,3038 @@ +# 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 version `0.8.4`. Do not change versions. +- Zero runtime npm dependencies. Use only Bun/WebCrypto/node: builtins. +- `@wrnexus/core` MUST NOT import `@wrnexus/authz`. `can()` stays off `Context`; the resolver lives in `ctx.locals._authz`. +- `@wrnexus/authz` may import **types only** from `@wrnexus/core` (`import type { Context, Middleware }`). +- Existing exports of `@wrnexus/authz` must keep working unchanged. This is additive. +- Framework-owned tables use the `_wrn_` prefix (matching `_wrn_tenant`, `_wrn_cursor`). The spec wrote `wrn_authz_assignment`; use `_wrn_authz_assignment` and `_wrn_authz_grant`. +- `requirePermission` is already exported with signature `(rbac: Rbac, permission: string)`. Do not change it. The new resource-aware guard is named `guardPermission`. +- Every failure path denies. Never fail open. +- After any change to `packages/authz/src/index.ts` exports, regenerate the API baseline with `bun run generate:public-api`. +- Full gate before declaring done: `bun run check:production`. +- Test files live in `packages//test/*.test.ts` and use `import { 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: `DecisionPolicy` from `./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`: + +```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`: + +```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/.ts` declaration. */ +export interface AuthzModule { + permissions?: Record; + roles?: Record; + policies?: Record>; + attributes?: Record; + /** permission id -> policy names that must pass for it. */ + bindings?: Record; +} + +/** The merged, frozen view of every declaration in the app. */ +export interface AuthzCatalog { + permissions: ReadonlyMap; + roles: ReadonlyMap; + policies: ReadonlyMap>; + attributes: ReadonlyMap; + bindings: ReadonlyMap; +} + +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`: + +```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/.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:'.`, + ); + } + } + } + + 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** + +```bash +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`, `AuthzCatalog` from `./types.ts`; `defineAuthz` from `./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`: + +```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).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`: + +```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; + const right = b as Record; + 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(entries: Iterable<[string, V]>): ReadonlyMap { + 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([]), + roles: frozenMap([]), + policies: frozenMap>([]), + attributes: frozenMap([]), + bindings: frozenMap([]), + }; +} + +export function mergeCatalogs(sources: CatalogSource[]): AuthzCatalog { + const permissions = new Map(); + const roles = new Map(); + const policies = new Map>(); + const attributes = new Map(); + const bindings = new Map>(); + const origin = new Map(); + + 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(); + 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** + +```bash +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`, `SubjectAssignments` from `./types.ts` +- Produces: `PermissionStore`, `memoryPermissionStore(): PermissionStore`, `runStoreConformance(name: string, makeStore: () => Promise)` + +- [ ] **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. + +```ts +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): 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`: + +```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`: + +```ts +import type { AuthzScope, SubjectAssignments } from "./types.ts"; + +export type GrantEffect = "allow" | "deny"; + +export interface PermissionStore { + assignmentsFor(subjectId: string, scope?: AuthzScope): Promise; + assignRole(subjectId: string, role: string, scope?: AuthzScope): Promise; + revokeRole(subjectId: string, role: string, scope?: AuthzScope): Promise; + grant( + subjectId: string, + permission: string, + effect: GrantEffect, + scope?: AuthzScope, + ): Promise; + revokeGrant(subjectId: string, permission: string, scope?: AuthzScope): Promise; + listSubjects(scope?: AuthzScope): Promise; +} + +/** 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(); + 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** + +```bash +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`, `scopeKey` from `./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`: + +```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("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`** + +```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(); + + const cacheKey = (subjectId: string, scope?: AuthzScope) => `${scopeKey(scope)}�${subjectId}`; + const drop = (subjectId: string, scope?: AuthzScope) => { + entries.delete(cacheKey(subjectId, scope)); + // A global write changes what every tenant sees for that subject. + if (scopeKey(scope) === "") { + for (const key of [...entries.keys()]) { + if (key.endsWith(`�${subjectId}`)) entries.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) entries.delete(entries.keys().next().value!); + entries.set(key, { at: Date.now(), value }); + 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(), + 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** + +```bash +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: `AuthzScope` from `./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`: + +```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(); + }); +}); +``` + +- [ ] **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`: + +```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; +} + +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), + }; +} + +export function consoleAuditSink(): AuthzAuditSink { + return { + record(event) { + const verdict = event.allowed ? "allow" : "deny"; + console.info( + `[wrnexus:authz] ${verdict} ${event.permission} subject=${event.subjectId ?? "anonymous"}` + + `${event.scope?.tenantId ? ` tenant=${event.scope.tenantId}` : ""}` + + `${event.reason ? ` reason=${event.reason}` : ""}`, + ); + }, + }; +} + +/** 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** + +```bash +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`, `SubjectAssignments` from `./types.ts`; `PermissionStore` from `./store.ts`; `AuthzAuditSink`, `safeRecord` from `./audit.ts`; `AuthorizationDecision` from `./advanced.ts` +- Produces: `createAuthzResolver(options: AuthzResolverOptions): AuthzResolver` with `AuthzResolver { permissionsFor(subjectId, scope?): Promise>; decide(input: DecideInput): Promise }`, `expandRoles(catalog, roles): Set`, `permissionMatches(granted: Set, permission: string): boolean` + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/engine.test.ts`: + +```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(); + await store.assignRole("u1", "editor"); + 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`: + +```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>; + decide(input: DecideInput): Promise; +} + +/** Expand roles into their granted entries, following `role:` and stopping on cycles. */ +export function expandRoles(catalog: AuthzCatalog, roles: readonly string[]): Set { + const out = new Set(); + const seen = new Set(); + 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, 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(); + + const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise> => { + const assignments = await store.assignmentsFor(subjectId, scope); + const granted = expandRoles(catalog, assignments.roles); + for (const grant of assignments.grants) granted.add(grant); + return 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; + try { + assignments = await store.assignmentsFor(subjectId, scope); + granted = expandRoles(catalog, assignments.roles); + for (const grant of assignments.grants) granted.add(grant); + } 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 + )(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** + +```bash +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`, `AuthzResolver` from `./engine.ts`; `Context`, `Middleware` types from `@wrnexus/core` +- Produces: `AUTHZ_LOCALS_KEY`, `authzMiddleware(options: AuthzResolverOptions): Middleware`, `decideFor(ctx, permission, resource?): Promise`, `can(ctx, permission, resource?): Promise`, `guardPermission(permission, getResource?): Middleware`, `filterCan(ctx, permission, items): Promise` + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/middleware.test.ts`: + +```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; + 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; + 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`: + +```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>; +} + +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}�`; + } +} + +export function decideFor( + ctx: Context, + permission: string, + resource?: unknown, +): Promise { + 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 { + return (await decideFor(ctx, permission, resource)).allowed; +} + +export interface GuardOptions { + /** Load the resource a bound policy needs. */ + getResource?: (ctx: Context) => unknown | Promise; + /** 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( + ctx: Context, + permission: string, + items: readonly T[], +): Promise { + 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** + +```bash +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: `AuthorizationDecision` from `./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`: + +```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; + 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: + +```ts +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, + 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** + +```bash +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/authz` surface + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/exports.test.ts`: + +```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)[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)[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`** + +```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** + +```bash +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`, `ComponentRef` already in `packages/router/src/index.ts` +- Produces: `Router.authz: ComponentRef[]` + +- [ ] **Step 1: Write the failing test** + +Create `packages/router/test/authz-discovery.test.ts`: + +```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 { + 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`: + +```ts + /** Authorization declarations (`app/authz/.ts`) merged into the catalog. */ + authz: ComponentRef[]; +``` + +Add the scan immediately after the existing `schemas` loop: + +```ts +// Authorization declarations: app/authz/.{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** + +```bash +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 `./db` export) +- Test: `packages/authz/test/store-db.test.ts` + +**Interfaces:** + +- Consumes: `PermissionStore`, `scopeKey` from `./store.ts`; `Db` type from `@wrnexus/db`; `Dialect` from `@wrnexus/db` +- Produces: `authzMigrationSql(dialect: Dialect): { up: string; down: string }`, `dbPermissionStore(db: Db): PermissionStore`, `ensureAuthzTables(db: Db, dialect?: Dialect): Promise` + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/store-db.test.ts`: + +```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`: + +```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`: + +```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 { + 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 { + 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: + +```json + "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** + +```bash +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: `AuthzCatalog` from `./types.ts` +- Produces: `generatePermissionTypes(catalog: AuthzCatalog): string` + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/codegen.test.ts`: + +```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`: + +```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): + +```ts +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** + +```bash +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` (add `case "authz"` next to `case "db"`) +- Test: `packages/cli/test/authz-command.test.ts` + +**Interfaces:** + +- Consumes: `buildRouter` from `@wrnexus/router`; `mergeCatalogs`, `generatePermissionTypes`, `authzMigrationSql` from `@wrnexus/authz` +- Produces: `loadAuthzCatalog(appDir: string): Promise`, `runAuthzCommand(root: string, sub: string | undefined, args: string[]): Promise` + +- [ ] **Step 1: Write the failing test** + +Create `packages/cli/test/authz-command.test.ts`: + +```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`: + +```ts +/** + * `wrnexus authz ` — 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 "; + +/** Import every app/authz declaration and merge it into one catalog. */ +export async function loadAuthzCatalog(appDir: string): Promise { + 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 { + 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: + +```ts + 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** + +```bash +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 in `startServer`) +- Modify: `packages/cli/src/build.ts` (bake catalog into the prod manifest) +- Test: `packages/dev-server/test/authz-boot.test.ts` + +**Interfaces:** + +- Consumes: `loadAuthzCatalog` pattern from Task 13; `authzMiddleware` from `@wrnexus/authz` +- Produces: `RuntimeDeps.authz?: AuthzCatalog` available to the request pipeline + +- [ ] **Step 1: Write the failing test** + +Create `packages/dev-server/test/authz-boot.test.ts`: + +```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`: + +```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 { + 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** + +```bash +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`: + +```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`: + +```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`: + +````markdown +## Declaring permissions + +Put declarations in `app/authz/.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** + +```bash +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.