# 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` keep working unchanged, with ONE approved exception: Task 8 changes the default 403 body of `authorizeDecision` to stop disclosing policy internals. That break is intentional and ruled on; everything else is additive. - Framework-owned tables use the `_wrn_` prefix (matching `_wrn_tenant`, `_wrn_cursor`). The spec 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("a global write invalidates the subject in every tenant", async () => { const inner = memoryPermissionStore(); const store = cachedPermissionStore(inner, { ttlMs: 60_000 }); await store.assignmentsFor("u1", { tenantId: "t1" }); // warm the tenant entry await store.assignRole("u1", "editor"); // global write // Global roles are visible inside every tenant, so the cached t1 entry // must not survive this write. expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]); }); test("cache keys cannot collide across subject/tenant boundaries", async () => { const inner = memoryPermissionStore(); const store = cachedPermissionStore(inner, { ttlMs: 60_000 }); // Naive "scope + separator + subject" concatenation makes these two pairs // produce the same key, serving one subject the other's permissions. await inner.assignRole("b�c", "editor", { tenantId: "a" }); expect((await store.assignmentsFor("b�c", { tenantId: "a" })).roles).toEqual(["editor"]); expect((await store.assignmentsFor("c", { tenantId: "a�b" })).roles).toEqual([]); }); test("scoped and global reads cache separately", async () => { const inner = memoryPermissionStore(); const store = cachedPermissionStore(inner, { ttlMs: 60_000 }); await inner.assignRole("u1", "editor", { tenantId: "t1" }); expect((await store.assignmentsFor("u1")).roles).toEqual([]); expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `bun test packages/authz/test/store-cached.test.ts` Expected: FAIL — `cachedPermissionStore` is not exported - [ ] **Step 3: Append the implementation to `packages/authz/src/store.ts`** ```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(); // Subject and tenant ids are unconstrained strings, so the key must be // unambiguous: concatenating around a separator lets ("a", "bc") and // ("ab", "c") collide, which would serve one subject another's // permissions. JSON encoding escapes the components. const cacheKey = (subjectId: string, scope?: AuthzScope) => JSON.stringify([scopeKey(scope), subjectId]); // Track subjects separately rather than pattern-matching key strings, so a // global write can find every tenant entry without substring guesswork. const bySubject = new Map>(); const drop = (subjectId: string, scope?: AuthzScope) => { // A global write changes what every tenant sees for that subject. if (scopeKey(scope) === "") { for (const key of bySubject.get(subjectId) ?? []) entries.delete(key); bySubject.delete(subjectId); return; } const key = cacheKey(subjectId, scope); entries.delete(key); bySubject.get(subjectId)?.delete(key); }; return { async assignmentsFor(subjectId, scope) { const key = cacheKey(subjectId, scope); const hit = entries.get(key); if (hit && Date.now() - hit.at < ttlMs) return hit.value; const value = await inner.assignmentsFor(subjectId, scope); if (entries.size >= max) { const oldest = entries.keys().next().value!; entries.delete(oldest); for (const keys of bySubject.values()) keys.delete(oldest); } entries.set(key, { at: Date.now(), value }); let keys = bySubject.get(subjectId); if (!keys) bySubject.set(subjectId, (keys = new Set())); keys.add(key); return value; }, async assignRole(subjectId, role, scope) { await inner.assignRole(subjectId, role, scope); drop(subjectId, scope); }, async revokeRole(subjectId, role, scope) { await inner.revokeRole(subjectId, role, scope); drop(subjectId, scope); }, async grant(subjectId, permission, effect, scope) { await inner.grant(subjectId, permission, effect, scope); drop(subjectId, scope); }, async revokeGrant(subjectId, permission, scope) { await inner.revokeGrant(subjectId, permission, scope); drop(subjectId, scope); }, listSubjects: (scope) => inner.listSubjects(scope), invalidate: drop, invalidateAll: () => { entries.clear(); bySubject.clear(); }, size: () => entries.size, }; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `bun test packages/authz/test/store-cached.test.ts` Expected: PASS — 13 conformance tests plus 6 cache tests - [ ] **Step 5: Commit** ```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(); }); test("safeRecord tolerates a malformed sink", () => { const notAFunction = { record: "nope" } as unknown as AuthzAuditSink; expect(() => safeRecord(notAFunction, { permission: "p:x", allowed: true, at: 1 }), ).not.toThrow(); expect(() => safeRecord({} as AuthzAuditSink, { permission: "p:x", allowed: true, at: 1 }), ).not.toThrow(); }); test("memoryAuditSink.clear empties the buffer", () => { const sink = memoryAuditSink(); sink.record({ permission: "p:x", allowed: true, at: 1 }); sink.clear(); expect(sink.events).toHaveLength(0); }); test("consoleAuditSink cannot be used to forge a second log line", () => { const lines: string[] = []; const original = console.info; console.info = (...args: unknown[]) => void lines.push(args.join(" ")); try { consoleAuditSink().record({ subjectId: "u1\n[wrnexus:authz] allow admin:everything subject=root", permission: "post:read", allowed: false, reason: "nope\r\ninjected", at: 1, }); } finally { console.info = original; } // One event must produce exactly one line, with no embedded newlines. expect(lines).toHaveLength(1); expect(lines[0]).not.toContain("\n"); expect(lines[0]).not.toContain("\r"); }); }); ``` The test file's imports must include `consoleAuditSink` and the `AuthzAuditSink` type alongside `memoryAuditSink` and `safeRecord`. - [ ] **Step 2: Run test to verify it fails** Run: `bun test packages/authz/test/audit.test.ts` Expected: FAIL — cannot resolve `../src/audit.ts` - [ ] **Step 3: Write the implementation** Create `packages/authz/src/audit.ts`: ```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), }; } /** * Subject ids, tenant ids, and denial reasons trace back to request input, so * a newline in one would forge a second audit line indistinguishable from a * real entry. Strip CR/LF and other control characters before interpolating. */ function logSafe(value: string): string { let out = ""; for (const character of value) { const code = character.codePointAt(0)!; // C0 + DEL, plus NEL and the Unicode line/paragraph separators, which some // log shippers and JSON consumers also treat as line terminators. const isLineBreaking = code < 0x20 || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029; out += isLineBreaking ? " " : character; } return out; } export function consoleAuditSink(): AuthzAuditSink { return { record(event) { const verdict = event.allowed ? "allow" : "deny"; console.info( `[wrnexus:authz] ${verdict} ${logSafe(event.permission)} ` + `subject=${logSafe(event.subjectId ?? "anonymous")}` + `${event.scope?.tenantId ? ` tenant=${logSafe(event.scope.tenantId)}` : ""}` + `${event.reason ? ` reason=${logSafe(event.reason)}` : ""}` + `${event.policy ? ` policy=${logSafe(event.policy)}` : ""}`, ); }, }; } /** Record without ever letting a sink failure escape into the request path. */ export function safeRecord(sink: AuthzAuditSink | undefined, event: AuthzAuditEvent): void { if (!sink) return; try { const result = sink.record(event); if (result instanceof Promise) { result.catch((error) => console.warn("[wrnexus:authz] audit sink failed", error)); } } catch (error) { console.warn("[wrnexus:authz] audit sink failed", error); } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `bun test packages/authz/test/audit.test.ts` Expected: PASS, 4 tests - [ ] **Step 5: Commit** ```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(); // moderator, NOT editor: editor holds "post:*", which legitimately grants // post:delete, so that call would be an allow and nothing would be audited. await store.assignRole("u1", "moderator"); await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" }); await resolver.decide({ subject: { id: "u1" }, permission: "post:read" }); expect(audit.events).toHaveLength(1); expect(audit.events[0]!.allowed).toBe(false); }); test("auditAllows records both verdicts", async () => { const store = memoryPermissionStore(); const audit = memoryAuditSink(); const resolver = createAuthzResolver({ catalog, store, audit, strict: false, auditAllows: true, }); await resolver.decide({ subject: null, permission: "post:read" }); expect(audit.events).toHaveLength(1); expect(audit.events[0]!.allowed).toBe(true); }); test("tenant scope selects the right assignments", async () => { const { store, resolver } = make(); await store.assignRole("u1", "editor", { tenantId: "t1" }); const inside = await resolver.decide({ subject: { id: "u1" }, permission: "post:write", resource: { authorId: "u1" }, scope: { tenantId: "t1" }, }); const outside = await resolver.decide({ subject: { id: "u1" }, permission: "post:write", resource: { authorId: "u1" }, scope: { tenantId: "t2" }, }); expect(inside.allowed).toBe(true); expect(outside.allowed).toBe(false); }); }); describe("createAuthzResolver fail-closed regressions", () => { const guarded = mergeCatalogs([ { source: "guarded.ts", module: defineAuthz({ permissions: { "feed:view": { public: true }, "x:go": {} }, policies: { never: async () => ({ allowed: false, reason: "always no", policy: "never" }), truthy: async () => ({ allowed: "yes" }) as never, }, bindings: { "feed:view": ["never"] }, }), }, ]); test("a public permission still runs its bound policies for anonymous callers", async () => { // The least-trusted caller must not receive the weakest evaluation: // `public` relaxes the identity requirement, never the policy requirement. const resolver = createAuthzResolver({ catalog: guarded, store: memoryPermissionStore(), strict: false, }); const anonymous = await resolver.decide({ subject: null, permission: "feed:view" }); expect(anonymous.allowed).toBe(false); expect(anonymous.policy).toBe("never"); }); test("a policy returning a truthy non-boolean denies", async () => { const catalog = mergeCatalogs([ { source: "t.ts", module: defineAuthz({ permissions: { "x:go": {} }, policies: { truthy: async () => ({ allowed: "yes" }) as never }, bindings: { "x:go": ["truthy"] }, }), }, ]); const store = memoryPermissionStore(); await store.grant("u1", "x:go", "allow"); const resolver = createAuthzResolver({ catalog, store, strict: false }); expect((await resolver.decide({ subject: { id: "u1" }, permission: "x:go" })).allowed).toBe( false, ); }); test("a binding naming a policy the catalog lacks denies rather than skipping", async () => { // Hand-built catalog: mergeCatalogs would reject this, but the resolver // accepts any AuthzCatalog and must not grant what the policy guarded. const broken = { permissions: new Map([["x:go", {}]]), roles: new Map(), policies: new Map(), attributes: new Map(), bindings: new Map([["x:go", ["ghost"]]]), } as unknown as Parameters[0]["catalog"]; const store = memoryPermissionStore(); await store.grant("u1", "x:go", "allow"); const resolver = createAuthzResolver({ catalog: broken, store, strict: false }); expect((await resolver.decide({ subject: { id: "u1" }, permission: "x:go" })).allowed).toBe( false, ); }); test("a wildcard deny blocks the whole namespace", async () => { const { store, resolver } = make(); await store.assignRole("u1", "admin"); await store.grant("u1", "post:*", "deny"); expect( (await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" })).allowed, ).toBe(false); }); test("permissionsFor omits denied permissions", async () => { const { store, resolver } = make(); await store.assignRole("u1", "editor"); await store.grant("u1", "post:*", "deny"); const effective = await resolver.permissionsFor("u1"); // The obvious composition must agree with decide(). expect(permissionMatches(effective, "post:write")).toBe(false); }); test("a non-string or empty subject id denies instead of falling back to anonymous", async () => { const { resolver } = make(); for (const id of [0, "", null, 123, {}]) { const result = await resolver.decide({ subject: { id } as never, permission: "post:read", // public — must still not be reached this way }); if (id === null) continue; // null is genuinely anonymous expect(result.allowed).toBe(false); } }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `bun test packages/authz/test/engine.test.ts` Expected: FAIL — cannot resolve `../src/engine.ts` - [ ] **Step 3: Write the implementation** Create `packages/authz/src/engine.ts`: ```ts import type { AuthorizationDecision } from "./advanced.ts"; import { safeRecord, type AuthzAuditSink } from "./audit.ts"; import type { PermissionStore } from "./store.ts"; import type { AuthzCatalog, AuthzScope } from "./types.ts"; export interface AuthzResolverOptions { catalog: AuthzCatalog; store: PermissionStore; audit?: AuthzAuditSink; /** * Throw on an unregistered permission instead of denying. Defaults to true * outside production, so typos surface during development. */ strict?: boolean; /** Record allows as well as denies. Off by default to bound write volume. */ auditAllows?: boolean; } export interface DecideInput { subject: { id?: string; [key: string]: unknown } | null | undefined; permission: string; resource?: unknown; scope?: AuthzScope; } export interface AuthzResolver { /** * Effective permissions with denied entries removed — for coarse gating such * as hiding a menu section. * * NOT authoritative. A set of strings cannot express "everything under * `post:*` except `post:delete`", so a narrow deny beneath a broad grant is * not representable here: the set still contains `post:*` while `decide()` * correctly refuses `post:delete`. Gate individual actions with `decide()` * (or `can()` / `filterCan()`), never by matching against this set. */ permissionsFor(subjectId: string, scope?: AuthzScope): Promise>; 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; } /** * True if any entry in the deny list covers `permission`. Denies honour the * same depth-aware wildcards as grants, so denying "post:*" blocks * post:comment:delete rather than being accepted and silently doing nothing. */ export function deniedBy(denies: readonly string[], permission: string): boolean { return denies.length ? permissionMatches(new Set(denies), permission) : false; } function isProduction(): boolean { return (process.env.NODE_ENV ?? "development") === "production"; } export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolver { const { catalog, store, audit } = options; const strict = options.strict ?? !isProduction(); /** * Single source of truth for "what does this subject hold?". Returns the raw * assignments alongside the effective set, because `decide` reports on the * deny that blocked it. Do NOT duplicate this logic in either caller. */ const loadEffective = async (subjectId: string, scope?: AuthzScope) => { const assignments = await store.assignmentsFor(subjectId, scope); const granted = expandRoles(catalog, assignments.roles); for (const grant of assignments.grants) granted.add(grant); return { assignments, granted }; }; /** * Effective permissions, denies already removed. Callers compose this with * `permissionMatches` to gate menus and admin UI, so it must not report a * permission that `decide` would refuse. */ const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise> => { const { assignments, granted } = await loadEffective(subjectId, scope); if (!assignments.denies.length) return granted; // Hoist the deny set: rebuilding it per entry makes this O(grants x denies) // allocations on a per-request path whose input size an operator controls. const denySet = new Set(assignments.denies); const effective = new Set(); for (const entry of granted) { // A wildcard grant survives only if nothing denies it outright. if (!permissionMatches(denySet, entry)) effective.add(entry); } return effective; }; const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => { if (!result.allowed || options.auditAllows) { safeRecord(audit, { subjectId: typeof input.subject?.id === "string" ? input.subject.id : undefined, scope: input.scope, permission: input.permission, allowed: result.allowed, reason: result.reason, policy: result.policy, at: Date.now(), }); } return result; }; /** * Run every policy bound to a permission. Returns a denial, or null to allow. * Anonymous callers run this too: `public` relaxes the identity requirement, * never the policy requirement. */ const runPolicies = async ( input: DecideInput, permission: string, ): Promise => { for (const name of catalog.bindings.get(permission) ?? []) { const policy = catalog.policies.get(name); if (!policy) { // A binding naming a policy the catalog lacks must deny, not skip: // silently ignoring it would grant whatever the policy guarded. console.error( `[wrnexus:authz] binding for '${permission}' names unknown policy '${name}'; denying`, ); return { allowed: false, reason: "Policy unavailable", policy: name }; } try { const verdict = await ( policy as unknown as ( s: unknown, r: unknown, ) => AuthorizationDecision | Promise )(input.subject, input.resource); // Identity check, not truthiness: {allowed: "yes"} must not grant. if (verdict?.allowed !== true) { return { allowed: false, reason: verdict?.reason ?? "Policy denied access", policy: verdict?.policy ?? name, }; } } catch (error) { console.error(`[wrnexus:authz] policy '${name}' threw; denying`, error); return { allowed: false, reason: "Policy error", policy: name }; } } return null; }; return { permissionsFor, async decide(input) { const { subject, permission, scope } = input; const meta = catalog.permissions.get(permission); if (!meta) { if (strict) { throw new Error( `WRN-AUTHZ-UNKNOWN: permission '${permission}' is not registered. ` + `Declare it with defineAuthz() in app/authz/.`, ); } return finish(input, { allowed: false, reason: `Permission '${permission}' is not registered`, }); } // Only a non-empty string identifies a subject. A numeric id of 0 or a // non-string id must not fall through to the anonymous path, and must // never reach the store as a lookup key. const rawId: unknown = subject?.id; const subjectId = typeof rawId === "string" && rawId !== "" ? rawId : undefined; if (rawId !== undefined && rawId !== null && subjectId === undefined) { console.error("[wrnexus:authz] subject.id must be a non-empty string; denying"); return finish(input, { allowed: false, reason: "Invalid subject" }); } if (!subjectId) { if (!meta.public) { return finish(input, { allowed: false, reason: "Authentication required" }); } const denied = await runPolicies(input, permission); return finish(input, denied ?? { allowed: true, reason: "public permission" }); } let assignments; let granted: Set; try { ({ assignments, granted } = await loadEffective(subjectId, scope)); } catch (error) { console.error("[wrnexus:authz] permission store failed; denying", error); return finish(input, { allowed: false, reason: "Authorization store unavailable" }); } // 1. Explicit deny wins over everything, including "*". Wildcards are // honoured here exactly as they are for grants, so denying "post:*" // blocks post:delete rather than silently doing nothing. if (deniedBy(assignments.denies, permission)) { return finish(input, { allowed: false, reason: "explicit deny" }); } // 2. Must hold the permission at all. if (!meta.public && !permissionMatches(granted, permission)) { return finish(input, { allowed: false, reason: "Missing permission" }); } // 3. Every bound policy must pass. const denied = await runPolicies(input, permission); return finish(input, denied ?? { allowed: true }); }, }; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `bun test packages/authz/test/engine.test.ts` Expected: PASS, 15 tests - [ ] **Step 5: Commit** ```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); }); test("does not leak rows the memo cannot serialise", async () => { const ctx = makeCtx({ id: "u1" }); const store = memoryPermissionStore(); await store.grant("u1", "post:delete", "allow"); await withMiddleware(ctx, store); // BigInt columns and circular references are ordinary in ORM rows. A memo // that serialises resources funnels all of these into one shared key and // returns the first verdict for every later row. const circular: Record = { authorId: "other" }; circular.self = circular; const rows = [{ authorId: "u1", views: 10n }, { authorId: "other", views: 11n }, circular]; expect(await filterCan(ctx, "post:delete", rows)).toEqual([rows[0]]); }); test("returns an empty array for no items", async () => { const ctx = makeCtx({ id: "u1" }); await withMiddleware(ctx); expect(await filterCan(ctx, "post:delete", [])).toEqual([]); }); }); describe("per-request memo isolation", () => { test("distinct resources are never cross-authorized", async () => { const ctx = makeCtx({ id: "u1" }); const store = memoryPermissionStore(); await store.grant("u1", "post:delete", "allow"); await withMiddleware(ctx, store); // Same id, different owner; object ids; primitives of different type. expect(await can(ctx, "post:delete", { id: 7, authorId: "u1" })).toBe(true); expect(await can(ctx, "post:delete", { id: "7", authorId: "other" })).toBe(false); expect(await can(ctx, "post:delete", { id: { t: "A" }, authorId: "u1" })).toBe(true); expect(await can(ctx, "post:delete", { id: { t: "B" }, authorId: "other" })).toBe(false); }); test("a changed row is not authorized against the stale copy", async () => { const ctx = makeCtx({ id: "u1" }); const store = memoryPermissionStore(); await store.grant("u1", "post:delete", "allow"); await withMiddleware(ctx, store); expect(await can(ctx, "post:delete", { id: "p1", authorId: "u1" })).toBe(true); expect(await can(ctx, "post:delete", { id: "p1", authorId: "someone-else" })).toBe(false); }); test("switching tenant mid-request re-evaluates", async () => { const ctx = makeCtx({ id: "u1" }, "t1"); const store = memoryPermissionStore(); await store.assignRole("u1", "editor", { tenantId: "t1" }); await withMiddleware(ctx, store); expect(await can(ctx, "post:write")).toBe(true); (ctx as { tenant?: { id: string } }).tenant = { id: "t2" }; // Scope is read at decision time, so the t1 grant must not carry over. expect(await can(ctx, "post:write")).toBe(false); }); }); describe("guardPermission hardening", () => { test("throws the setup error rather than calling next", async () => { const ctx = makeCtx({ id: "u1" }); // no authzMiddleware let reached = false; await expect( guardPermission("post:write")(ctx, async () => { reached = true; return new Response("passed"); }), ).rejects.toThrow(/authzMiddleware/); expect(reached).toBe(false); }); test("a throwing getResource denies instead of 500ing", async () => { const ctx = makeCtx({ id: "u1" }); await withMiddleware(ctx); const guard = guardPermission("post:delete", { getResource: () => { throw new Error("SELECT * FROM posts WHERE id=$1 failed"); }, }); const res = await guard(ctx, async () => new Response("passed")); expect(res.status).toBe(403); const body = await res.text(); expect(body).not.toContain("SELECT"); }); test("redirectTo applies to page requests but not API requests", async () => { const page = makeCtx({ id: "u1" }); await withMiddleware(page); const redirected = await guardPermission("post:write", { redirectTo: "/login" })( page, async () => new Response("passed"), ); expect(redirected.status).toBe(303); const api = makeCtx({ id: "u1" }); (api as { url: URL }).url = new URL("http://localhost/api/posts"); await withMiddleware(api); const json = await guardPermission("post:write", { redirectTo: "/login" })( api, async () => new Response("passed"), ); // An API caller must see the denial, not follow a redirect into a 200. expect(json.status).toBe(403); }); test("an off-site redirectTo is refused", async () => { const ctx = makeCtx({ id: "u1" }); await withMiddleware(ctx); for (const target of ["https://evil.example.com/harvest", "//evil.example.com"]) { const res = await guardPermission("post:write", { redirectTo: target })( ctx, async () => new Response("passed"), ); expect(res.status).toBe(403); } }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `bun test packages/authz/test/middleware.test.ts` Expected: FAIL — cannot resolve `../src/middleware.ts` - [ ] **Step 3: Write the implementation** Create `packages/authz/src/middleware.ts`: ```ts import type { Context, Middleware } from "@wrnexus/core"; import type { AuthorizationDecision } from "./advanced.ts"; import { createAuthzResolver, type AuthzResolver, type AuthzResolverOptions } from "./engine.ts"; import type { AuthzScope } from "./types.ts"; /** * `can` is deliberately not a Context member: @wrnexus/core must not depend on * @wrnexus/authz. The per-request resolver lives here instead. */ export const AUTHZ_LOCALS_KEY = "_authz"; interface RequestAuthz { resolver: AuthzResolver; /** Memo for object resources, keyed by identity so two rows never collide. */ byRef: WeakMap>>; /** Memo for primitive and absent resources. */ byValue: 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; } /** * Read the tenant from the context at decision time, not at middleware time: * a request that switches tenant mid-flight must not keep the old scope. */ function currentScope(ctx: Context): AuthzScope | undefined { const tenantId = ctx.tenant?.id; return typeof tenantId === "string" && tenantId !== "" ? { tenantId } : undefined; } /** Install the per-request resolver. Register after sessionAuth and tenantMiddleware. */ export function authzMiddleware(options: AuthzResolverOptions): Middleware { const resolver = createAuthzResolver(options); return (ctx, next) => { ctx.locals[AUTHZ_LOCALS_KEY] = { resolver, byRef: new WeakMap(), byValue: new Map(), } satisfies RequestAuthz; return next(); }; } export function decideFor( ctx: Context, permission: string, resource?: unknown, ): Promise { const request = readAuthz(ctx); const scope = currentScope(ctx); // Scope is part of the key: the same permission decides differently per tenant. // JSON-encoded so a tenant id containing the separator cannot collide. const key = JSON.stringify([scope?.tenantId ?? "", permission]); const run = () => request.resolver.decide({ subject: ctx.user as { id?: string } | null | undefined, permission, resource, scope, }); // Object resources memo by IDENTITY. Serialising them would let two distinct // rows share a key and cross-authorize, and unserialisable ones (circular // refs, BigInt fields, throwing getters) would all collapse into one bucket. if (resource !== null && (typeof resource === "object" || typeof resource === "function")) { let perResource = request.byRef.get(resource as object); if (!perResource) request.byRef.set(resource as object, (perResource = new Map())); const cached = perResource.get(key); if (cached) return cached; const pending = run(); perResource.set(key, pending); return pending; } // typeof is part of the key so 7 and "7" are not the same resource. const valueKey = JSON.stringify([key, typeof resource, String(resource)]); const cached = request.byValue.get(valueKey); if (cached) return cached; const pending = run(); request.byValue.set(valueKey, pending); return pending; } export async function can(ctx: Context, permission: string, resource?: unknown): Promise { return (await decideFor(ctx, permission, resource)).allowed; } export interface GuardOptions { /** Load the resource a bound policy needs. */ getResource?: (ctx: Context) => unknown; /** Include reason and policy name in the 403 body. Off by default. */ exposeReason?: boolean; /** Redirect page requests here instead of returning 403. Must be a local path. */ redirectTo?: string; } /** Same rule requireAuth uses, replicated because authz may only import TYPES from core. */ function wantsJson(ctx: Context): boolean { if (ctx.url.pathname.startsWith("/api/")) return true; const accept = ctx.req.headers.get("accept") ?? ""; return accept.includes("application/json") && !accept.includes("text/html"); } /** Reject anything that could navigate off-site or inject a header. */ function isLocalPath(value: string): boolean { if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return false; for (const character of value) { const code = character.codePointAt(0)!; if (code < 0x20 || code === 0x7f) return false; } return true; } /** * Guard a route on a registered permission. Named `guardPermission` because * `requirePermission(rbac, permission)` already exists with a different shape. */ export function guardPermission(permission: string, options: GuardOptions = {}): Middleware { return async (ctx, next) => { let resource: unknown; if (options.getResource) { try { resource = await options.getResource(ctx); } catch (error) { // Loading the resource failed, so the policy cannot be evaluated. Deny // rather than 500 — and never leak the loader's message to the client. console.error(`[wrnexus:authz] getResource for '${permission}' threw; denying`, error); return Response.json({ ok: false, error: "Forbidden" }, { status: 403 }); } } const result = await decideFor(ctx, permission, resource); if (result.allowed) return next(); if (options.redirectTo && !wantsJson(ctx)) { if (!isLocalPath(options.redirectTo)) { console.error( `[wrnexus:authz] redirectTo must be a local path, got '${options.redirectTo}'; denying`, ); } else { return new Response(null, { status: 303, headers: { location: options.redirectTo, "cache-control": "private, no-store" }, }); } } return Response.json( options.exposeReason ? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy } : { ok: false, error: "Forbidden" }, { status: 403, headers: { "cache-control": "private, no-store" } }, ); }; } /** Keep only the items the current subject may act on. */ export async function filterCan( 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.