From 10da210b0ad05d81f01085eefcf5577b21f09fbd Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 16:10:37 +0530 Subject: [PATCH] docs: implementation plan for the authz permissions system Fifteen TDD tasks covering phases 1-3 of the approved design: registry, catalog merge, PermissionStore with a shared conformance suite, caching decorator, audit sink, resolution engine, request middleware and guards, router discovery, database adapter, codegen, and the wrnexus authz CLI. Phases 4 (.wrn view can()) and 5 (admin UI) are documented as deferred with the reason each needs its own design pass. Also folds in the authorizeDecision disclosure fix as Task 8, since the new guards share its 403 shape. Co-Authored-By: Claude Opus 5 --- ...-08-04-authz-permissions-implementation.md | 3038 +++++++++++++++++ 1 file changed, 3038 insertions(+) create mode 100644 docs/plans/2026-08-04-authz-permissions-implementation.md 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.