From c64434a1316418ec0f566a8d826fa757569d1936 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 15:57:22 +0530 Subject: [PATCH 01/59] fix(security): close SSRF, credential-leak, and auth bypass findings in 0.8.4 Audit of 0.8.4 found the repo's own gates green, so these came from manual review; each is covered by a new regression test. security/fetch.ts - safeFetch re-attached Authorization/Cookie on a same-origin redirect that followed a cross-origin hop (a -> b -> b), handing credentials to the second host. Compare against the origin the caller trusted, not the previous hop. - The private-network guard resolved the host, approved it, then let fetch resolve again, so a low-TTL record could answer public for the check and private for the connection. Pin the connection to the validated address, preserving Host and TLS serverName. Opt out with pinDns: false. - 0:0:0:0:0:ffff:127.0.0.1, ::ffff:7f00:1 and fec0::1 were not treated as private. Add uncompressed IPv4-mapped forms, site-local IPv6, 198.18/15 and 192.0.0/24. security/url.ts - sanitizeUrl returned "//evil.com" verbatim via the relative-path fast path, bypassing the host checks it had just run; in an href that navigates cross-origin. Resolve protocol-relative input instead. dev-server/gateway.ts - Malformed base64 in an Authorization header threw out of checkAuth on an unauthenticated path. Fail closed. - split(":", 2) truncated passwords at the first colon, so a password containing ":" could never authenticate. - The credential compare short-circuited on length mismatch, leaking length by timing. Extracted as verifyBasicAuth so it is testable. authz/index.ts - Namespace wildcards only matched the first segment, so "post:comment:*" did not grant "post:comment:delete". Match at every depth. uploader/operations.ts - Validate transcoder dimensions and bitrate rather than trusting the declared type, and reject ".." path segments. package.json - The brace-expansion override pinned 5.0.8, which is inside the advisory range >=4.0.0 <5.0.9. Bump to 5.0.9; bun audit is now clean. Verified: check:production passes (typecheck, lint, 1033 tests, format, ASVS, public-API baseline, editor checks). Co-Authored-By: Claude Opus 5 --- bun.lock | 7 +- package.json | 5 +- packages/authz/src/index.ts | 11 +- packages/authz/test/rbac-wildcard.test.ts | 40 +++++ packages/dev-server/src/gateway.ts | 50 +++++-- .../test/gateway-basic-auth.test.ts | 40 +++++ packages/security/src/fetch.ts | 80 ++++++++-- packages/security/src/url.ts | 17 ++- .../security/test/ssrf-regression.test.ts | 140 ++++++++++++++++++ packages/uploader/src/operations.ts | 24 ++- 10 files changed, 369 insertions(+), 45 deletions(-) create mode 100644 packages/authz/test/rbac-wildcard.test.ts create mode 100644 packages/dev-server/test/gateway-basic-auth.test.ts create mode 100644 packages/security/test/ssrf-regression.test.ts diff --git a/bun.lock b/bun.lock index e33141a8..be310f6d 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "wrnexus", + "dependencies": { + "brace-expansion": "^5.0.9", + }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/bun": "^1.3.14", @@ -531,7 +534,7 @@ }, }, "overrides": { - "brace-expansion": "5.0.8", + "brace-expansion": "5.0.9", "esbuild": "0.28.1", }, "packages": { @@ -917,7 +920,7 @@ "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], - "brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="], + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], diff --git a/package.json b/package.json index 6ac4583d..c173b162 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,9 @@ }, "overrides": { "esbuild": "0.28.1", - "brace-expansion": "5.0.8" + "brace-expansion": "5.0.9" + }, + "dependencies": { + "brace-expansion": "^5.0.9" } } diff --git a/packages/authz/src/index.ts b/packages/authz/src/index.ts index f57d5256..b7c7a3a2 100644 --- a/packages/authz/src/index.ts +++ b/packages/authz/src/index.ts @@ -52,11 +52,12 @@ export function defineRbac(roles: Record): Rbac { if (!subject?.roles?.length) return false; const perms = permissionsFor(subject.roles); if (perms.has("*") || perms.has(permission)) return true; - // Namespace wildcards: "post:*" grants "post:write". - const ns = permission.includes(":") - ? permission.slice(0, permission.indexOf(":")) + ":*" - : null; - return ns ? perms.has(ns) : false; + // Namespace wildcards at every depth: "post:*" and "post:comment:*" both + // grant "post:comment:delete". + for (let at = permission.indexOf(":"); at !== -1; at = permission.indexOf(":", at + 1)) { + if (perms.has(`${permission.slice(0, at)}:*`)) return true; + } + return false; }, }; } diff --git a/packages/authz/test/rbac-wildcard.test.ts b/packages/authz/test/rbac-wildcard.test.ts new file mode 100644 index 00000000..fd3ec720 --- /dev/null +++ b/packages/authz/test/rbac-wildcard.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { defineRbac } from "../src/index.ts"; + +describe("RBAC namespace wildcards", () => { + const rbac = defineRbac({ + admin: ["*"], + editor: ["post:*"], + moderator: ["post:comment:*"], + reader: ["post:read"], + }); + + test("a wildcard grants every depth beneath it", () => { + expect(rbac.can({ roles: ["editor"] }, "post:write")).toBe(true); + expect(rbac.can({ roles: ["editor"] }, "post:comment:delete")).toBe(true); + expect(rbac.can({ roles: ["editor"] }, "post:comment:flag:undo")).toBe(true); + }); + + test("a deeper wildcard grants its own subtree", () => { + expect(rbac.can({ roles: ["moderator"] }, "post:comment:delete")).toBe(true); + expect(rbac.can({ roles: ["moderator"] }, "post:comment:flag:undo")).toBe(true); + }); + + test("a wildcard does not leak sideways or upward", () => { + expect(rbac.can({ roles: ["moderator"] }, "post:write")).toBe(false); + expect(rbac.can({ roles: ["moderator"] }, "post")).toBe(false); + expect(rbac.can({ roles: ["editor"] }, "page:write")).toBe(false); + expect(rbac.can({ roles: ["reader"] }, "post:write")).toBe(false); + }); + + test("root wildcard and unknown subjects behave", () => { + expect(rbac.can({ roles: ["admin"] }, "anything:at:all")).toBe(true); + expect(rbac.can({ roles: [] }, "post:read")).toBe(false); + expect(rbac.can(undefined, "post:read")).toBe(false); + }); + + test("role inheritance terminates on cycles", () => { + const cyclic = defineRbac({ a: ["role:b", "p:a"], b: ["role:a", "p:b"] }); + expect([...cyclic.permissionsFor(["a"])].sort()).toEqual(["p:a", "p:b"]); + }); +}); diff --git a/packages/dev-server/src/gateway.ts b/packages/dev-server/src/gateway.ts index 37181f40..e5cf7da8 100644 --- a/packages/dev-server/src/gateway.ts +++ b/packages/dev-server/src/gateway.ts @@ -175,14 +175,46 @@ function gatewayWebSocketOriginAllowed( return target.domains.some((domain) => parsed.host.toLowerCase() === domain.toLowerCase()); } -/** Constant-time-ish string compare. */ +/** + * Constant-time string compare. Length is folded into the accumulator rather + * than short-circuiting, so a wrong guess cannot be distinguished from a + * wrong-length guess by timing. + */ function timingSafeEqual(a: string, b: string): boolean { - if (a.length !== b.length) return false; - let diff = 0; - for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + let diff = a.length ^ b.length; + const max = Math.max(a.length, b.length); + for (let i = 0; i < max; i++) diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0); return diff === 0; } +/** + * Verify an HTTP Basic `Authorization` header against the configured pairs. + * Malformed base64 fails closed rather than throwing, and the username and + * password are split on the FIRST colon so passwords may contain colons. + */ +export function verifyBasicAuth( + header: string | null | undefined, + pairs: readonly { user: string; pass: string }[], +): boolean { + if (!header?.startsWith("Basic ")) return false; + let decoded: string; + try { + decoded = atob(header.slice(6)); + } catch { + return false; + } + const separator = decoded.indexOf(":"); + if (separator === -1) return false; + const user = decoded.slice(0, separator); + const pass = decoded.slice(separator + 1); + // Evaluate every pair so the number of configured credentials is not + // observable through response timing. + return pairs.reduce( + (ok, p) => (timingSafeEqual(user, p.user) && timingSafeEqual(pass, p.pass)) || ok, + false, + ); +} + export function internalError(res: Response): string | null { const encoded = res.headers.get("x-wrnexus-internal-error"); if (!encoded) return null; @@ -267,15 +299,7 @@ async function checkAuth( if (auth.basic) { const pairs = Array.isArray(auth.basic) ? auth.basic : [auth.basic]; - const header = req.headers.get("authorization") ?? ""; - const ok = - header.startsWith("Basic ") && - (() => { - const [user, pass] = atob(header.slice(6)).split(":", 2); - return pairs.some( - (p) => timingSafeEqual(user ?? "", p.user) && timingSafeEqual(pass ?? "", p.pass), - ); - })(); + const ok = verifyBasicAuth(req.headers.get("authorization"), pairs); if (!ok) { return new Response("Authentication required", { status: 401, diff --git a/packages/dev-server/test/gateway-basic-auth.test.ts b/packages/dev-server/test/gateway-basic-auth.test.ts new file mode 100644 index 00000000..fe615f7d --- /dev/null +++ b/packages/dev-server/test/gateway-basic-auth.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { verifyBasicAuth } from "../src/gateway.ts"; + +const pairs = [ + { user: "admin", pass: "hunter2" }, + { user: "ops", pass: "p:a:s:s" }, +]; + +const basic = (raw: string) => `Basic ${btoa(raw)}`; + +describe("gateway basic auth", () => { + test("accepts a configured pair", () => { + expect(verifyBasicAuth(basic("admin:hunter2"), pairs)).toBe(true); + }); + + test("accepts a password containing colons", () => { + // split(":", 2) used to truncate this to "p", so it could never match. + expect(verifyBasicAuth(basic("ops:p:a:s:s"), pairs)).toBe(true); + }); + + test("rejects wrong credentials", () => { + expect(verifyBasicAuth(basic("admin:wrong"), pairs)).toBe(false); + expect(verifyBasicAuth(basic("nobody:hunter2"), pairs)).toBe(false); + }); + + test("fails closed on malformed input instead of throwing", () => { + // An unauthenticated request must not be able to raise a 500 here. + expect(() => verifyBasicAuth("Basic !!!!not-base64", pairs)).not.toThrow(); + expect(verifyBasicAuth("Basic !!!!not-base64", pairs)).toBe(false); + expect(verifyBasicAuth(basic("no-colon-at-all"), pairs)).toBe(false); + expect(verifyBasicAuth("Bearer token", pairs)).toBe(false); + expect(verifyBasicAuth(null, pairs)).toBe(false); + expect(verifyBasicAuth(undefined, pairs)).toBe(false); + expect(verifyBasicAuth("", pairs)).toBe(false); + }); + + test("empty credentials never match", () => { + expect(verifyBasicAuth(basic(":"), pairs)).toBe(false); + }); +}); diff --git a/packages/security/src/fetch.ts b/packages/security/src/fetch.ts index f49c1f06..f72cc8d9 100644 --- a/packages/security/src/fetch.ts +++ b/packages/security/src/fetch.ts @@ -10,6 +10,14 @@ export interface SafeFetchOptions extends RequestInit, SafeUrlPolicy { blockPrivateNetworks?: boolean; /** Forward Authorization, Cookie, and Proxy-Authorization across origin-changing redirects. Defaults to false. */ forwardSensitiveHeaders?: boolean; + /** + * Connect to the address this module validated instead of re-resolving the + * hostname inside `fetch`. Without it the private-network guard is advisory + * only: a low-TTL DNS record can answer with a public address for our lookup + * and a private one for the connection (DNS rebinding). Defaults to true + * whenever `blockPrivateNetworks` is on. + */ + pinDns?: boolean; resolver?: (hostname: string) => Promise; } @@ -28,25 +36,40 @@ function isPrivateIpv4(address: string): boolean { a === 127 || (a === 169 && b === 254) || (a === 172 && b! >= 16 && b! <= 31) || + (a === 192 && b === 0) || (a === 192 && b === 168) || + (a === 198 && b! >= 18 && b! <= 19) || (a === 100 && b! >= 64 && b! <= 127) || a! >= 224 ); } +/** + * Any IPv4-mapped form, not just the compact `::ffff:1.2.3.4` spelling. + * `0:0:0:0:0:ffff:127.0.0.1` and `::ffff:7f00:1` address loopback just as well. + */ +function mappedIpv4Of(normalized: string): string | null { + const dotted = /^(?:0*:)*0*ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(normalized)?.[1]; + if (dotted) return dotted; + const hex = /^(?:0*:)*0*ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(normalized); + if (!hex) return null; + const high = Number.parseInt(hex[1]!, 16); + const low = Number.parseInt(hex[2]!, 16); + return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`; +} + function isPrivateIpv6(address: string): boolean { const normalized = address.toLowerCase().split("%")[0]!; - const mappedIpv4 = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(normalized)?.[1]; + const mappedIpv4 = mappedIpv4Of(normalized); if (mappedIpv4) return isPrivateIpv4(mappedIpv4); + if (/^(?:0*:)+0*1$/.test(normalized)) return true; // ::1 in any expansion + if (/^(?:0*:)*0*$/.test(normalized)) return true; // :: / all-zero return ( - normalized === "::" || - normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || - normalized.startsWith("fe8") || - normalized.startsWith("fe9") || - normalized.startsWith("fea") || - normalized.startsWith("feb") || + // fe80::/10 link-local through fec0::/10 site-local: every fe8-febf plus + // the deprecated-but-still-routable fec0-feff site-local block. + /^fe[89abcdef]/.test(normalized) || normalized.startsWith("ff") ); } @@ -61,8 +84,9 @@ async function defaultResolver(hostname: string): Promise { return (await lookup(hostname, { all: true, verbatim: true })).map((entry) => entry.address); } -async function assertPublicHost(url: URL, options: SafeFetchOptions): Promise { - if (options.blockPrivateNetworks === false) return; +/** Resolve a host and reject it unless every answer is a public address. */ +async function resolvePublicHost(url: URL, options: SafeFetchOptions): Promise { + if (options.blockPrivateNetworks === false) return []; const addresses = await (options.resolver ?? defaultResolver)(url.hostname); if (!addresses.length) { throw new SecurityError("WRN-SEC-SSRF-DNS", `Host '${url.hostname}' did not resolve.`); @@ -75,6 +99,22 @@ async function assertPublicHost(url: URL, options: SafeFetchOptions): Promise).blockPrivateNetworks; delete (init as Record).resolver; delete (init as Record).forwardSensitiveHeaders; + delete (init as Record).pinDns; delete (init as Record).base; delete (init as Record).allowRelative; delete (init as Record).allowedProtocols; @@ -114,23 +155,30 @@ export async function safeFetch( allowRelative: false, allowedProtocols: options.allowedProtocols ?? ["https:"], }); - let previousOrigin = current.origin; + // Compare against where the caller's credentials were meant to go, not + // against the previous hop: a→b→b must not re-attach them on the second + // b request just because that hop did not change origin. + const credentialOrigin = current.origin; for (let redirect = 0; ; redirect++) { - await assertPublicHost(current, options); + const addresses = await resolvePublicHost(current, options); const requestInit: RequestInit = { ...init }; - if (!options.forwardSensitiveHeaders && current.origin !== previousOrigin) { - const headers = new Headers(init.headers); + const headers = new Headers(init.headers); + if (!options.forwardSensitiveHeaders && current.origin !== credentialOrigin) { headers.delete("authorization"); headers.delete("cookie"); headers.delete("proxy-authorization"); - requestInit.headers = headers; } - const response = await fetch(current, requestInit); + const pinDns = options.pinDns ?? options.blockPrivateNetworks !== false; + const target = + pinDns && addresses.length + ? pinToAddress(current, addresses[0]!, requestInit, headers) + : current; + requestInit.headers = headers; + const response = await fetch(target, requestInit); if (response.status >= 300 && response.status < 400 && response.headers.has("location")) { if (redirect >= maxRedirects) { throw new SecurityError("WRN-SEC-SSRF-REDIRECT", "Too many redirects.", 502); } - previousOrigin = current.origin; current = validateUrl(new URL(response.headers.get("location")!, current), { ...options, allowRelative: false, diff --git a/packages/security/src/url.ts b/packages/security/src/url.ts index 118ab310..a59c94c5 100644 --- a/packages/security/src/url.ts +++ b/packages/security/src/url.ts @@ -12,6 +12,17 @@ export interface SafeUrlPolicy { const DEFAULT_PROTOCOLS = ["http:", "https:"]; +const RELATIVE_PREFIX = /^(?:\.{0,2}\/|\/|\?|#)/; + +/** + * `//evil.com` (and the `/\evil.com` spelling browsers normalise to it) reads + * like a same-site path but navigates cross-origin. It must never be handed + * back verbatim, or the host checks above are bypassed entirely. + */ +function isProtocolRelative(raw: string): boolean { + return /^[/\\]{2}/.test(raw); +} + function hasAsciiControlOrSpace(value: string): boolean { for (const character of value) { const code = character.charCodeAt(0); @@ -39,7 +50,7 @@ export function validateUrl(value: string | URL, policy: SafeUrlPolicy = {}): UR ); } - const isRelative = /^(?:\.{0,2}\/|\/|\?|#)/.test(raw); + const isRelative = RELATIVE_PREFIX.test(raw); if (isRelative && policy.allowRelative === false) { throw new SecurityError("WRN-SEC-URL-RELATIVE", "Relative URLs are not allowed."); } @@ -93,7 +104,9 @@ export function sanitizeUrl(value: unknown, policy: SafeUrlPolicy = {}): string try { const raw = String(value ?? ""); const url = validateUrl(raw, policy); - if (/^(?:\.{0,2}\/|\/|\?|#)/.test(raw)) return raw; + // Genuinely relative input round-trips unchanged; protocol-relative input + // is resolved so the returned string carries the host the policy approved. + if (RELATIVE_PREFIX.test(raw) && !isProtocolRelative(raw)) return raw; return url.toString(); } catch { return "about:blank"; diff --git a/packages/security/test/ssrf-regression.test.ts b/packages/security/test/ssrf-regression.test.ts new file mode 100644 index 00000000..29eb8a1d --- /dev/null +++ b/packages/security/test/ssrf-regression.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { isPrivateAddress, safeFetch, sanitizeUrl } from "../src/index.ts"; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +/** Capture what each hop actually receives, and script the redirect chain. */ +function stubFetch(chain: (string | null)[]) { + const seen: { url: string; host: string | null; authorization: string | null }[] = []; + let hop = 0; + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const headers = new Headers(init?.headers); + seen.push({ + url: String(input), + host: headers.get("host"), + authorization: headers.get("authorization"), + }); + const location = chain[hop++]; + return location + ? new Response(null, { status: 302, headers: { location } }) + : new Response("done", { status: 200 }); + }) as typeof fetch; + return seen; +} + +describe("safeFetch redirect credential handling", () => { + test("does not re-attach credentials after leaving the original origin", async () => { + const seen = stubFetch(["https://b.example/x", "https://b.example/final", null]); + + await safeFetch("https://a.example/start", { + headers: { authorization: "Bearer SECRET", cookie: "sid=1" }, + resolver: async () => ["93.184.216.34"], + }); + + expect(seen).toHaveLength(3); + expect(seen[0]!.authorization).toBe("Bearer SECRET"); + // Both hops on b.example are off-origin, including the b→b one. + expect(seen[1]!.authorization).toBeNull(); + expect(seen[2]!.authorization).toBeNull(); + }); + + test("keeps credentials across same-origin redirects", async () => { + const seen = stubFetch(["https://a.example/next", null]); + + await safeFetch("https://a.example/start", { + headers: { authorization: "Bearer SECRET" }, + resolver: async () => ["93.184.216.34"], + }); + + expect(seen[1]!.authorization).toBe("Bearer SECRET"); + }); + + test("forwardSensitiveHeaders opts back in", async () => { + const seen = stubFetch(["https://b.example/x", null]); + + await safeFetch("https://a.example/start", { + headers: { authorization: "Bearer SECRET" }, + forwardSensitiveHeaders: true, + resolver: async () => ["93.184.216.34"], + }); + + expect(seen[1]!.authorization).toBe("Bearer SECRET"); + }); +}); + +describe("safeFetch DNS pinning", () => { + test("connects to the validated address and preserves the Host header", async () => { + const seen = stubFetch([null]); + + await safeFetch("https://rebind.example/data", { + resolver: async () => ["93.184.216.34"], + }); + + // The connection targets the address we checked, so a second DNS answer + // cannot redirect it at an internal host. + expect(seen[0]!.url).toBe("https://93.184.216.34/data"); + expect(seen[0]!.host).toBe("rebind.example"); + }); + + test("pinDns:false restores hostname dialling", async () => { + const seen = stubFetch([null]); + + await safeFetch("https://rebind.example/data", { + pinDns: false, + resolver: async () => ["93.184.216.34"], + }); + + expect(seen[0]!.url).toBe("https://rebind.example/data"); + }); + + test("still rejects hosts that resolve into private space", async () => { + stubFetch([null]); + + await expect( + safeFetch("https://rebind.example/data", { resolver: async () => ["169.254.169.254"] }), + ).rejects.toThrow(/blocked address/); + }); +}); + +describe("isPrivateAddress coverage", () => { + test("catches non-canonical IPv4-mapped and site-local IPv6", () => { + expect(isPrivateAddress("0:0:0:0:0:ffff:127.0.0.1")).toBe(true); + expect(isPrivateAddress("::ffff:7f00:1")).toBe(true); + expect(isPrivateAddress("fec0::1")).toBe(true); + expect(isPrivateAddress("0:0:0:0:0:0:0:1")).toBe(true); + expect(isPrivateAddress("198.18.0.1")).toBe(true); + expect(isPrivateAddress("192.0.0.192")).toBe(true); + }); + + test("leaves public addresses alone", () => { + expect(isPrivateAddress("93.184.216.34")).toBe(false); + expect(isPrivateAddress("2606:2800:220:1:248:1893:25c8:1946")).toBe(false); + }); +}); + +describe("sanitizeUrl protocol-relative handling", () => { + test("does not hand back a protocol-relative URL verbatim", () => { + // "//evil.com" in an href navigates cross-origin; returning it unchanged + // would bypass every host check validateUrl just performed. + expect(sanitizeUrl("//evil.com")).toBe("http://evil.com/"); + expect(sanitizeUrl("//evil.com/path?a=b")).toBe("http://evil.com/path?a=b"); + // Backslash spellings resolve the same way rather than passing through. + expect(sanitizeUrl("\\\\evil.com")).toBe("http://evil.com/"); + expect(sanitizeUrl("/\\evil.com")).toBe("http://evil.com/"); + }); + + test("honours host policy for protocol-relative input", () => { + expect(sanitizeUrl("//evil.com", { allowedHosts: ["good.com"] })).toBe("about:blank"); + expect(sanitizeUrl("//good.com/x", { allowedHosts: ["good.com"] })).toBe("http://good.com/x"); + }); + + test("genuine relative paths round-trip unchanged", () => { + expect(sanitizeUrl("/safe/path")).toBe("/safe/path"); + expect(sanitizeUrl("./rel")).toBe("./rel"); + expect(sanitizeUrl("?q=1")).toBe("?q=1"); + expect(sanitizeUrl("#frag")).toBe("#frag"); + }); +}); diff --git a/packages/uploader/src/operations.ts b/packages/uploader/src/operations.ts index 82d23bc7..65986eac 100644 --- a/packages/uploader/src/operations.ts +++ b/packages/uploader/src/operations.ts @@ -179,17 +179,29 @@ export function ffmpegVideoTranscoder( options: { executable?: string; spawn?: (args: string[]) => { exited: Promise } } = {}, ) { return async (input: string, output: string, config: VideoTranscodeOptions): Promise => { - if (!/^[\w .:\\/-]+$/.test(input) || !/^[\w .:\\/-]+$/.test(output)) - throw new Error("Invalid video path"); + const validPath = (value: string) => + /^[\w .:\\/-]+$/.test(value) && !value.split(/[\\/]/).includes(".."); + if (!validPath(input) || !validPath(output)) throw new Error("Invalid video path"); + if (config.format !== "mp4" && config.format !== "webm") + throw new Error("Invalid video format"); + // These reach an ffmpeg filter string, so reject anything that is not a + // plain positive integer rather than trusting the declared type. + const dimension = (value: number | undefined, name: string): number | undefined => { + if (value === undefined) return undefined; + if (!Number.isInteger(value) || value <= 0 || value > 16384) + throw new Error(`Invalid video ${name}`); + return value; + }; + const width = dimension(config.width, "width"); + const height = dimension(config.height, "height"); + const bitrate = dimension(config.videoBitrateKbps, "bitrate"); const args = [ options.executable ?? "ffmpeg", "-y", "-i", input, - ...(config.width || config.height - ? ["-vf", `scale=${config.width ?? -2}:${config.height ?? -2}`] - : []), - ...(config.videoBitrateKbps ? ["-b:v", `${config.videoBitrateKbps}k`] : []), + ...(width || height ? ["-vf", `scale=${width ?? -2}:${height ?? -2}`] : []), + ...(bitrate ? ["-b:v", `${bitrate}k`] : []), "-f", config.format, output, From b209936f86384a5ef6f69e13e5fe8faf5b6792c7 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 15:57:22 +0530 Subject: [PATCH 02/59] docs: design for the authz permissions system Separates declaration (what permissions, roles, policies and attributes exist) from assignment (who holds what), building on the decision primitives already in advanced.ts rather than replacing them. Covers the registry and app/authz discovery, the PermissionStore interface with memory and db adapters, tenant-scoped assignments meeting the existing TenantMembership, deny-wins precedence, fail-closed behaviour, the audit sink, codegen and CLI introspection, and the seam for propagating subject context to the inter-app communication system. Records two decisions worth keeping: cross-app sharing needs no runtime catalog distribution (declarations are static code in the shared package; only assignments are shared, via the database), and can() stays off Context to avoid a core -> authz dependency cycle. Co-Authored-By: Claude Opus 5 --- .../2026-08-04-authz-permissions-design.md | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 docs/plans/2026-08-04-authz-permissions-design.md diff --git a/docs/plans/2026-08-04-authz-permissions-design.md b/docs/plans/2026-08-04-authz-permissions-design.md new file mode 100644 index 00000000..14a72a16 --- /dev/null +++ b/docs/plans/2026-08-04-authz-permissions-design.md @@ -0,0 +1,286 @@ +# Permissions system design (`@wrnexus/authz`) + +Date: 2026-08-04 +Status: approved, not yet implemented +Supersedes: nothing — extends the existing `@wrnexus/authz` package + +## Problem + +`@wrnexus/authz` today evaluates authorization but does not **describe** it. `defineRbac()` +takes a literal object of roles, policies are anonymous closures, and nothing records which +permissions exist. The consequences: + +- No discoverability. Nothing can answer "what permissions does this system have?" +- No runtime assignment. Changing who is an admin requires a redeploy. +- No tenant awareness, despite `core/src/tenant.ts` already defining + `TenantMembership { tenantId, userId, roles[] }` that `authz` never reads. +- No audit trail. +- Typos in permission strings fail silently as `false`. + +The evaluation primitives are sound and stay: `AuthorizationDecision`, `DecisionPolicy`, +`owner`, `anyDecision`, `allDecisions`, `filterAuthorized`, and the guard middleware. + +## Approach + +Separate **declaration** (what permissions, roles, policies and attributes exist — static, +typed, in code) from **assignment** (who holds what — dynamic, in a store). The existing +package becomes the evaluation layer beneath both. + +Rejected alternatives: + +- **Extend `defineRbac` in place.** Half the work, but leaves discoverability, codegen, + cross-app catalog and audit with nowhere to live. +- **Adapter for OpenFGA / Cedar / SpiceDB.** Better at relationship-heavy authorization, + but puts a network dependency and a sidecar in the request path of a zero-dependency + framework. + +## Module layout + +``` +@wrnexus/authz + index.ts existing surface (unchanged exports) + advanced.ts existing decision primitives (unchanged exports) + registry.ts defineAuthz() — permissions, roles, policies, attributes + catalog.ts discovery, merge, conflict detection; frozen at boot + store.ts PermissionStore interface, memory adapter, cachedPermissionStore() + db.ts dbPermissionStore(getDb()) — subpath export @wrnexus/authz/db + engine.ts subject -> effective permissions -> Decision + guards.ts route middleware, extended for resources + audit.ts AuthzAuditSink + view.ts can() exposed to .wrn `{#if}` expressions +``` + +## Declaration + +Declarations live in `app/authz/*.ts`, discovered the same way `app/schemas/*.ts` already is +(`packages/router/src/index.ts` scans and populates `router.schemas`; this adds `router.authz`). + +```ts +// app/authz/blog.ts +import { defineAuthz, owner } 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" }, + }, + roles: { + editor: ["post:*"], + moderator: ["post:comment:*"], + admin: ["role:editor", "role:moderator"], + }, + policies: { + ownsPost: owner("id", "authorId"), + }, + attributes: { + department: { description: "Subject's department, from the identity provider" }, + }, +}); +``` + +A permission declared `public: true` is granted to anonymous subjects. Every other permission +denies when there is no authenticated user. + +## Catalog and cross-app scope + +Declarations are static code, so sharing them across workspace apps needs no runtime +distribution: the `defineAuthz` blocks live in the workspace's shared package +(`packages/shared`, already scaffolded by `wrnexus workspace`) and every app imports them. +They are identical by construction. + +What is genuinely shared at runtime is **assignments**, and those live in the shared database +behind `PermissionStore`. + +`wrnexus authz list` is therefore introspection, not distribution: it walks every app in the +workspace, merges catalogs, and reports the full permission/role/policy surface plus conflicts. + +Merge rules: + +- Two declarations of the same permission id with deep-equal metadata: no-op (lets shared + packages re-declare freely). +- Two declarations of the same permission id whose metadata is not deep-equal: boot error + naming both source files. +- The catalog is frozen after boot. Registration is not possible at request time. + +## Assignment store + +```ts +interface AuthzScope { + tenantId?: string; +} + +interface SubjectAssignments { + roles: string[]; + grants: string[]; // explicit allows, bypassing roles + denies: string[]; // explicit denies, win over everything +} + +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: "allow" | "deny", + scope?: AuthzScope, + ): Promise; + revokeGrant(subjectId: string, permission: string, scope?: AuthzScope): Promise; + listSubjects(scope?: AuthzScope): Promise; +} +``` + +`scope.tenantId` is how this meets the existing `TenantMembership`. An assignment with no +scope is global; a scoped assignment applies only within that tenant. Both are unioned for a +request whose `ctx.tenant` is set. + +Adapters: + +- `memoryPermissionStore()` — tests and single-process development. +- `dbPermissionStore(getDb())` — default; tables below. +- `cachedPermissionStore(inner, { ttlMs, max })` — decorator exposing + `invalidate(subjectId, scope)`. Role changes must invalidate explicitly rather than wait + out a TTL. + +### Tables + +``` +wrn_authz_assignment + id, subject_id, scope, role, granted_by, created_at + unique(subject_id, scope, role) + +wrn_authz_grant + id, subject_id, scope, permission, effect, granted_by, created_at + unique(subject_id, scope, permission) +``` + +`scope` stores the tenant id, or the empty string for global. Migrations are scaffolded by +`wrnexus authz init`, following the existing `db/src/migrate.ts` conventions. + +## Evaluation + +### How `can()` reaches a request + +`can` is **not** added to the `Context` interface. `@wrnexus/core` must not depend on +`@wrnexus/authz` — the same constraint that keeps `getDb()` off `Context` rather than +introducing a `core -> db` cycle. Instead: + +```ts +app.use(authzMiddleware({ store, catalog })); // stashes a resolver in ctx.locals +const allowed = await can(ctx, "post:delete", post); // imported from @wrnexus/authz +``` + +`authzMiddleware` puts the per-request resolver (with its memo table) into +`ctx.locals._authz`; `can(ctx, ...)` reads it and throws a clear setup error if the +middleware was not registered. Views get the bound form described under view integration. + +### Per request + +1. Subject is `ctx.user`; scope is `ctx.tenant`. +2. `store.assignmentsFor(subjectId, scope)`. +3. Registry expands roles into a permission set — wildcards at every depth + (`post:*` and `post:comment:*` both grant `post:comment:delete`), `role:` inheritance, + cycle-safe. +4. `can(ctx, permission, resource?)` checks the set, then runs any policy bound to that + permission with the resource. +5. Result is an `AuthorizationDecision`; denials go to the audit sink. + +Memoised per request. Precedence, highest first: + +1. Explicit deny (store `denies`) — beats everything including `*`. +2. Policy denial. +3. Explicit grant or role-derived permission. +4. Default deny. + +## Failure behaviour + +Every failure path denies. + +| Condition | Behaviour | +| ------------------------------ | ------------------------------------------------------ | +| Permission not in the registry | Throws in development, denies and audits in production | +| Store throws | Deny, audit, log. Never fail open. | +| Policy throws | Treated as a denial, logged with the policy name | +| No authenticated user | Deny, unless the permission is declared `public` | + +## Audit + +```ts +interface AuthzAuditEvent { + subjectId?: string; + scope?: AuthzScope; + permission: string; + allowed: boolean; + reason?: string; + policy?: string; + at: number; +} +interface AuthzAuditSink { + record(event: AuthzAuditEvent): void | Promise; +} +``` + +Default sink is a no-op. Records denials only unless configured otherwise, to bound write +volume on hot paths. Sink errors are logged and swallowed — auditing must never break a +request. + +## Tooling + +- `wrnexus authz list` — merged catalog across the workspace, with conflicts. +- `wrnexus authz generate` — emits `app/authz/permissions.gen.ts` exporting + `type Permission = "post:read" | "post:write" | ...`, so `can()` is checked at compile time. + Runs automatically in `build.ts`, mirroring `regenerateQueries`. +- `wrnexus authz init` — scaffolds the migration and a seed helper for default roles. +- Admin UI: `.wrn` components for listing subjects and assigning roles, shipped in + `@wrnexus/ui` behind the existing eject mechanism. + +## Inter-app seam + +Reserved for the inter-app communication system, specified but not built here: + +```ts +exportSubjectContext(ctx): string // signed, compact: subject id, scope, roles +importSubjectContext(token): Subject // verified on the receiving app +``` + +An app calling another on a user's behalf propagates identity and roles rather than +re-querying the store. The signing key and transport are the comms system's concern. + +## Security fix folded into this work + +`authorizeDecision` currently returns the internal `reason` and `policy` name in the 403 body, +disclosing policy structure to unauthenticated callers. This becomes opt-in via +`authorizeDecision(evaluate, { exposeReason: true })`, defaulting to a bare +`{ ok: false, error: "Forbidden" }`. + +## Testing + +- **Store conformance suite** — one shared set of tests run against both the memory and DB + adapters so they cannot drift. +- **Unit** — wildcard expansion at depth, deny precedence, role-cycle termination, catalog + merge conflicts, public-permission handling. +- **Integration** — guards return 403 for API and redirect for pages; `{#if can(...)}` omits + markup server-side rather than hiding it with CSS. +- **Security regression** — unregistered permission denies in production; 403 body does not + leak policy names unless opted in; store failure denies rather than allows. + +## Build order + +1. `registry.ts`, `catalog.ts`, `store.ts` (memory), `engine.ts`, extended `guards.ts`. +2. Resource-level policies wired to `filterAuthorized`; `audit.ts`. +3. `db.ts` adapter, migrations, `wrnexus authz init`, codegen, `wrnexus authz list`. +4. `.wrn` view integration (`can()` inside `{#if}`). +5. Admin UI components. + +Phase 4 is the only one whose shape is uncertain. The compiler supports `{#if}` at page level +and nested, but exposing `can()` into that scope touches codegen +(`packages/compiler/src/codegen.ts`). If it proves invasive, phases 1–3 ship on their own and +view integration returns as its own design. + +## Out of scope + +- Relationship-based authorization ("can edit because they're in the team that owns the doc"). + Role and policy checks cover the intended cases; revisit if hierarchical resources appear. +- Permission delegation and time-bounded grants. +- Cross-workspace federation. From 10da210b0ad05d81f01085eefcf5577b21f09fbd Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 16:10:37 +0530 Subject: [PATCH 03/59] docs: implementation plan for the authz permissions system Fifteen TDD tasks covering phases 1-3 of the approved design: registry, catalog merge, PermissionStore with a shared conformance suite, caching decorator, audit sink, resolution engine, request middleware and guards, router discovery, database adapter, codegen, and the wrnexus authz CLI. Phases 4 (.wrn view can()) and 5 (admin UI) are documented as deferred with the reason each needs its own design pass. Also folds in the authorizeDecision disclosure fix as Task 8, since the new guards share its 403 shape. Co-Authored-By: Claude Opus 5 --- ...-08-04-authz-permissions-implementation.md | 3038 +++++++++++++++++ 1 file changed, 3038 insertions(+) create mode 100644 docs/plans/2026-08-04-authz-permissions-implementation.md diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md new file mode 100644 index 00000000..439acde7 --- /dev/null +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -0,0 +1,3038 @@ +# Permissions System Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend `@wrnexus/authz` so permissions, roles, policies and attributes are declared in code and discoverable, while role assignments live in a pluggable store. + +**Architecture:** A **registry** (`defineAuthz`) declares what exists; a **catalog** merges declarations and freezes at boot; a **store** (`PermissionStore`) holds who-has-what; an **engine** resolves a subject to effective permissions and returns an `AuthorizationDecision`. The decision primitives already in `advanced.ts` are the evaluation layer and are not replaced. + +**Tech Stack:** TypeScript, Bun (`bun:test`), `@wrnexus/core` (Context/Middleware types only), `@wrnexus/db` (Db interface, migrations). + +## Global Constraints + +- Every `@wrnexus/*` package is version `0.8.4`. Do not change versions. +- Zero runtime npm dependencies. Use only Bun/WebCrypto/node: builtins. +- `@wrnexus/core` MUST NOT import `@wrnexus/authz`. `can()` stays off `Context`; the resolver lives in `ctx.locals._authz`. +- `@wrnexus/authz` may import **types only** from `@wrnexus/core` (`import type { Context, Middleware }`). +- Existing exports of `@wrnexus/authz` must keep working unchanged. This is additive. +- Framework-owned tables use the `_wrn_` prefix (matching `_wrn_tenant`, `_wrn_cursor`). The spec wrote `wrn_authz_assignment`; use `_wrn_authz_assignment` and `_wrn_authz_grant`. +- `requirePermission` is already exported with signature `(rbac: Rbac, permission: string)`. Do not change it. The new resource-aware guard is named `guardPermission`. +- Every failure path denies. Never fail open. +- After any change to `packages/authz/src/index.ts` exports, regenerate the API baseline with `bun run generate:public-api`. +- Full gate before declaring done: `bun run check:production`. +- Test files live in `packages//test/*.test.ts` and use `import { describe, expect, test } from "bun:test"`. + +--- + +## File Structure + +**Created:** + +| File | Responsibility | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------- | +| `packages/authz/src/types.ts` | Shared types: `AuthzScope`, `PermissionMeta`, `AuthzModule`, `AuthzCatalog`, `SubjectAssignments` | +| `packages/authz/src/registry.ts` | `defineAuthz()` — validate and freeze one declaration module | +| `packages/authz/src/catalog.ts` | `mergeCatalogs()` — merge modules, detect conflicts, freeze | +| `packages/authz/src/store.ts` | `PermissionStore` interface, `memoryPermissionStore()`, `cachedPermissionStore()` | +| `packages/authz/src/audit.ts` | `AuthzAuditSink`, `memoryAuditSink()`, `consoleAuditSink()` | +| `packages/authz/src/engine.ts` | `createAuthzResolver()` — effective permissions, precedence, fail-closed | +| `packages/authz/src/middleware.ts` | `authzMiddleware()`, `can()`, `decideFor()`, `guardPermission()` | +| `packages/authz/src/db.ts` | `dbPermissionStore(db)` — subpath export `@wrnexus/authz/db` | +| `packages/authz/src/migrations.ts` | `authzMigrationSql(dialect)` — DDL for the two tables | +| `packages/authz/src/codegen.ts` | `generatePermissionTypes(catalog)` — emits the `Permission`/`Role` unions | +| `packages/authz/test/store-conformance.ts` | Shared suite both store adapters must pass (not a `.test.ts`) | +| `packages/cli/src/authz.ts` | `runAuthzCommand(root, sub, args)` for `list` / `init` / `generate` | + +**Modified:** + +| File | Change | +| -------------------------------- | --------------------------------------------------------------- | +| `packages/authz/src/index.ts` | Re-export the new surface | +| `packages/authz/src/advanced.ts` | `authorizeDecision` gains `{ exposeReason }`, defaulting to off | +| `packages/authz/package.json` | Add `./db` subpath export | +| `packages/router/src/index.ts` | Discover `app/authz/*.{ts,js}` into `router.authz` | +| `packages/cli/src/index.ts` | Dispatch `case "authz"` | +| `docs/public-api-0.8.json` | Regenerated baseline | + +--- + +## Task 1: Types and registry + +**Files:** + +- Create: `packages/authz/src/types.ts` +- Create: `packages/authz/src/registry.ts` +- Test: `packages/authz/test/registry.test.ts` + +**Interfaces:** + +- Consumes: `DecisionPolicy` from `./advanced.ts` +- Produces: `AuthzScope`, `PermissionMeta`, `AuthzModule`, `AuthzCatalog`, `SubjectAssignments`, `defineAuthz(module: AuthzModule): AuthzModule` + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/registry.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { defineAuthz } from "../src/registry.ts"; + +describe("defineAuthz", () => { + test("returns a frozen module", () => { + const mod = defineAuthz({ + permissions: { "post:read": { title: "View posts" } }, + roles: { editor: ["post:*"] }, + }); + expect(Object.isFrozen(mod)).toBe(true); + expect(mod.permissions!["post:read"]!.title).toBe("View posts"); + expect(mod.roles!.editor).toEqual(["post:*"]); + }); + + test("defaults missing sections to empty objects", () => { + const mod = defineAuthz({}); + expect(mod.permissions).toEqual({}); + expect(mod.roles).toEqual({}); + expect(mod.policies).toEqual({}); + expect(mod.attributes).toEqual({}); + expect(mod.bindings).toEqual({}); + }); + + test("rejects a permission id that is not colon-namespaced lowercase", () => { + expect(() => defineAuthz({ permissions: { "Post Read": {} } })).toThrow(/permission id/i); + expect(() => defineAuthz({ permissions: { "post:*": {} } })).toThrow(/wildcard/i); + }); + + test("rejects a role granting an unknown-shaped entry", () => { + expect(() => defineAuthz({ roles: { editor: [""] } })).toThrow(/role 'editor'/i); + }); + + test("rejects a binding naming a policy that is not declared", () => { + expect(() => + defineAuthz({ + permissions: { "post:write": {} }, + bindings: { "post:write": ["missingPolicy"] }, + }), + ).toThrow(/missingPolicy/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/authz/test/registry.test.ts` +Expected: FAIL — cannot resolve `../src/registry.ts` + +- [ ] **Step 3: Write the types** + +Create `packages/authz/src/types.ts`: + +```ts +import type { DecisionPolicy } from "./advanced.ts"; + +/** Narrows an assignment to a tenant. Absent means a global assignment. */ +export interface AuthzScope { + tenantId?: string; +} + +export interface PermissionMeta { + title?: string; + description?: string; + risk?: "low" | "medium" | "high"; + /** Granted to anonymous subjects. Every other permission denies without a user. */ + public?: boolean; +} + +export interface AttributeMeta { + description?: string; +} + +/** One `app/authz/.ts` declaration. */ +export interface AuthzModule { + permissions?: Record; + roles?: Record; + policies?: Record>; + attributes?: Record; + /** permission id -> policy names that must pass for it. */ + bindings?: Record; +} + +/** The merged, frozen view of every declaration in the app. */ +export interface AuthzCatalog { + permissions: ReadonlyMap; + roles: ReadonlyMap; + policies: ReadonlyMap>; + attributes: ReadonlyMap; + bindings: ReadonlyMap; +} + +export interface SubjectAssignments { + roles: string[]; + /** Explicit allows, bypassing roles. */ + grants: string[]; + /** Explicit denies. Win over everything, including "*". */ + denies: string[]; +} +``` + +- [ ] **Step 4: Write the registry** + +Create `packages/authz/src/registry.ts`: + +```ts +import type { AuthzModule } from "./types.ts"; + +const PERMISSION_ID = /^[a-z0-9]+(?::[a-z0-9-]+)+$/; + +/** + * Validate and freeze one authorization declaration. Called from + * `app/authz/.ts` as the module's default export. + */ +export function defineAuthz(module: AuthzModule): AuthzModule { + const permissions = module.permissions ?? {}; + const roles = module.roles ?? {}; + const policies = module.policies ?? {}; + const attributes = module.attributes ?? {}; + const bindings = module.bindings ?? {}; + + for (const id of Object.keys(permissions)) { + if (id.includes("*")) { + throw new Error( + `WRN-AUTHZ-DECL: permission id '${id}' must not contain a wildcard; wildcards belong in roles.`, + ); + } + if (!PERMISSION_ID.test(id)) { + throw new Error( + `WRN-AUTHZ-DECL: permission id '${id}' must be lowercase colon-namespaced, e.g. 'post:read'.`, + ); + } + } + + for (const [role, grants] of Object.entries(roles)) { + for (const grant of grants) { + if (typeof grant !== "string" || !grant.trim()) { + throw new Error( + `WRN-AUTHZ-DECL: role '${role}' grants an empty entry; expected a permission, 'ns:*', or 'role:'.`, + ); + } + } + } + + for (const [permission, names] of Object.entries(bindings)) { + for (const name of names) { + if (!(name in policies)) { + throw new Error( + `WRN-AUTHZ-DECL: binding for '${permission}' names policy '${name}', which is not declared in the same module.`, + ); + } + } + } + + return Object.freeze({ permissions, roles, policies, attributes, bindings }); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `bun test packages/authz/test/registry.test.ts` +Expected: PASS, 5 tests + +- [ ] **Step 6: Commit** + +```bash +git add packages/authz/src/types.ts packages/authz/src/registry.ts packages/authz/test/registry.test.ts +git commit -m "feat(authz): add defineAuthz declaration registry" +``` + +--- + +## Task 2: Catalog merge and conflict detection + +**Files:** + +- Create: `packages/authz/src/catalog.ts` +- Test: `packages/authz/test/catalog.test.ts` + +**Interfaces:** + +- Consumes: `AuthzModule`, `AuthzCatalog` from `./types.ts`; `defineAuthz` from `./registry.ts` +- Produces: `mergeCatalogs(sources: CatalogSource[]): AuthzCatalog`, `interface CatalogSource { source: string; module: AuthzModule }`, `emptyCatalog(): AuthzCatalog` + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/catalog.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { defineAuthz } from "../src/registry.ts"; +import { emptyCatalog, mergeCatalogs } from "../src/catalog.ts"; + +describe("mergeCatalogs", () => { + test("merges disjoint modules", () => { + const catalog = mergeCatalogs([ + { source: "a.ts", module: defineAuthz({ permissions: { "post:read": {} } }) }, + { source: "b.ts", module: defineAuthz({ permissions: { "user:read": {} } }) }, + ]); + expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "user:read"]); + }); + + test("re-declaring a permission with deep-equal metadata is a no-op", () => { + const meta = { title: "View posts", risk: "low" as const }; + const catalog = mergeCatalogs([ + { source: "a.ts", module: defineAuthz({ permissions: { "post:read": meta } }) }, + { source: "b.ts", module: defineAuthz({ permissions: { "post:read": { ...meta } } }) }, + ]); + expect(catalog.permissions.size).toBe(1); + }); + + test("conflicting metadata is a boot error naming both files", () => { + expect(() => + mergeCatalogs([ + { source: "a.ts", module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }) }, + { source: "b.ts", module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }) }, + ]), + ).toThrow(/a\.ts.*b\.ts|b\.ts.*a\.ts/s); + }); + + test("conflicting role definitions are a boot error", () => { + expect(() => + mergeCatalogs([ + { source: "a.ts", module: defineAuthz({ roles: { editor: ["post:read"] } }) }, + { source: "b.ts", module: defineAuthz({ roles: { editor: ["post:write"] } }) }, + ]), + ).toThrow(/editor/); + }); + + test("bindings for the same permission union across modules", () => { + const p1 = defineAuthz({ + permissions: { "post:write": {} }, + policies: { ownsPost: async () => ({ allowed: true }) }, + bindings: { "post:write": ["ownsPost"] }, + }); + const p2 = defineAuthz({ + policies: { notLocked: async () => ({ allowed: true }) }, + bindings: { "post:write": ["notLocked"] }, + }); + const catalog = mergeCatalogs([ + { source: "a.ts", module: p1 }, + { source: "b.ts", module: p2 }, + ]); + expect([...catalog.bindings.get("post:write")!].sort()).toEqual(["notLocked", "ownsPost"]); + }); + + test("a binding referencing a policy no module declares is a boot error", () => { + expect(() => + mergeCatalogs([ + { + source: "a.ts", + module: { permissions: { "post:write": {} }, bindings: { "post:write": ["ghost"] } }, + }, + ]), + ).toThrow(/ghost/); + }); + + test("the merged catalog is frozen", () => { + const catalog = mergeCatalogs([]); + expect(() => (catalog.permissions as Map).set("x:y", {} as never)).toThrow(); + }); + + test("emptyCatalog has no entries", () => { + expect(emptyCatalog().permissions.size).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/authz/test/catalog.test.ts` +Expected: FAIL — cannot resolve `../src/catalog.ts` + +- [ ] **Step 3: Write the implementation** + +Create `packages/authz/src/catalog.ts`: + +```ts +import type { AttributeMeta, AuthzCatalog, AuthzModule, PermissionMeta } from "./types.ts"; +import type { DecisionPolicy } from "./advanced.ts"; + +export interface CatalogSource { + /** File or package that declared this module, used in conflict messages. */ + source: string; + module: AuthzModule; +} + +/** Structural equality for declaration metadata. Key order is irrelevant. */ +function deepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + const left = a as Record; + const right = b as Record; + const keys = new Set([...Object.keys(left), ...Object.keys(right)]); + for (const key of keys) if (!deepEqual(left[key], right[key])) return false; + return true; +} + +/** A frozen Map that throws on mutation, so the catalog cannot drift after boot. */ +function frozenMap(entries: Iterable<[string, V]>): ReadonlyMap { + const map = new Map(entries); + const reject = () => { + throw new Error("WRN-AUTHZ-FROZEN: the authorization catalog is frozen after boot."); + }; + map.set = reject as never; + map.delete = reject as never; + map.clear = reject as never; + return map; +} + +export function emptyCatalog(): AuthzCatalog { + return { + permissions: frozenMap([]), + roles: frozenMap([]), + policies: frozenMap>([]), + attributes: frozenMap([]), + bindings: frozenMap([]), + }; +} + +export function mergeCatalogs(sources: CatalogSource[]): AuthzCatalog { + const permissions = new Map(); + const roles = new Map(); + const policies = new Map>(); + const attributes = new Map(); + const bindings = new Map>(); + const origin = new Map(); + + const claim = ( + kind: string, + key: string, + source: string, + existingValue: unknown, + value: unknown, + ) => { + const previous = origin.get(`${kind}:${key}`); + if (previous === undefined) { + origin.set(`${kind}:${key}`, source); + return; + } + if (!deepEqual(existingValue, value)) { + throw new Error( + `WRN-AUTHZ-CONFLICT: ${kind} '${key}' is declared differently in ${previous} and ${source}.`, + ); + } + }; + + for (const { source, module } of sources) { + for (const [id, meta] of Object.entries(module.permissions ?? {})) { + claim("permission", id, source, permissions.get(id), meta); + permissions.set(id, meta); + } + for (const [name, grants] of Object.entries(module.roles ?? {})) { + claim("role", name, source, roles.get(name), grants); + roles.set(name, grants); + } + for (const [name, policy] of Object.entries(module.policies ?? {})) { + // Two closures are never deep-equal, so identity is the only sane test. + const existing = policies.get(name); + if (existing && existing !== policy) { + throw new Error( + `WRN-AUTHZ-CONFLICT: policy '${name}' is declared differently in ${origin.get(`policy:${name}`)} and ${source}.`, + ); + } + origin.set(`policy:${name}`, source); + policies.set(name, policy); + } + for (const [name, meta] of Object.entries(module.attributes ?? {})) { + claim("attribute", name, source, attributes.get(name), meta); + attributes.set(name, meta); + } + for (const [permission, names] of Object.entries(module.bindings ?? {})) { + const set = bindings.get(permission) ?? new Set(); + for (const name of names) set.add(name); + bindings.set(permission, set); + } + } + + for (const [permission, names] of bindings) { + for (const name of names) { + if (!policies.has(name)) { + throw new Error( + `WRN-AUTHZ-CONFLICT: binding for '${permission}' names policy '${name}', which no module declares.`, + ); + } + } + } + + return { + permissions: frozenMap(permissions), + roles: frozenMap(roles), + policies: frozenMap(policies), + attributes: frozenMap(attributes), + bindings: frozenMap([...bindings].map(([k, v]) => [k, [...v]] as [string, readonly string[]])), + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/authz/test/catalog.test.ts` +Expected: PASS, 8 tests + +- [ ] **Step 5: Commit** + +```bash +git add packages/authz/src/catalog.ts packages/authz/test/catalog.test.ts +git commit -m "feat(authz): merge declaration modules into a frozen catalog" +``` + +--- + +## Task 3: PermissionStore interface, memory adapter, conformance suite + +**Files:** + +- Create: `packages/authz/src/store.ts` +- Create: `packages/authz/test/store-conformance.ts` +- Test: `packages/authz/test/store-memory.test.ts` + +**Interfaces:** + +- Consumes: `AuthzScope`, `SubjectAssignments` from `./types.ts` +- Produces: `PermissionStore`, `memoryPermissionStore(): PermissionStore`, `runStoreConformance(name: string, makeStore: () => Promise)` + +- [ ] **Step 1: Write the conformance suite** + +Create `packages/authz/test/store-conformance.ts`. This is imported by adapter tests; it has no `.test.ts` suffix so Bun does not run it directly. + +```ts +import { beforeEach, describe, expect, test } from "bun:test"; +import type { PermissionStore } from "../src/store.ts"; + +/** + * Every PermissionStore adapter must pass this suite, so the memory and db + * implementations cannot drift apart. + */ +export function runStoreConformance(name: string, makeStore: () => Promise): void { + describe(`PermissionStore conformance: ${name}`, () => { + let store: PermissionStore; + beforeEach(async () => { + store = await makeStore(); + }); + + test("an unknown subject has empty assignments", async () => { + expect(await store.assignmentsFor("nobody")).toEqual({ + roles: [], + grants: [], + denies: [], + }); + }); + + test("assignRole then assignmentsFor round-trips", async () => { + await store.assignRole("u1", "editor"); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("assignRole is idempotent", async () => { + await store.assignRole("u1", "editor"); + await store.assignRole("u1", "editor"); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("revokeRole removes only that role", async () => { + await store.assignRole("u1", "editor"); + await store.assignRole("u1", "admin"); + await store.revokeRole("u1", "editor"); + expect((await store.assignmentsFor("u1")).roles).toEqual(["admin"]); + }); + + test("revoking a role that was never assigned is a no-op", async () => { + await store.revokeRole("u1", "ghost"); + expect((await store.assignmentsFor("u1")).roles).toEqual([]); + }); + + test("scoped assignments do not leak across tenants", async () => { + await store.assignRole("u1", "editor", { tenantId: "t1" }); + expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]); + expect((await store.assignmentsFor("u1", { tenantId: "t2" })).roles).toEqual([]); + }); + + test("a global assignment is visible inside every tenant", async () => { + await store.assignRole("u1", "superadmin"); + expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["superadmin"]); + }); + + test("global and scoped roles union within a tenant", async () => { + await store.assignRole("u1", "viewer"); + await store.assignRole("u1", "editor", { tenantId: "t1" }); + expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles.sort()).toEqual([ + "editor", + "viewer", + ]); + }); + + test("grant with allow and deny land in the right buckets", async () => { + await store.grant("u1", "post:write", "allow"); + await store.grant("u1", "post:delete", "deny"); + const assignments = await store.assignmentsFor("u1"); + expect(assignments.grants).toEqual(["post:write"]); + expect(assignments.denies).toEqual(["post:delete"]); + }); + + test("re-granting the same permission replaces its effect", async () => { + await store.grant("u1", "post:write", "allow"); + await store.grant("u1", "post:write", "deny"); + const assignments = await store.assignmentsFor("u1"); + expect(assignments.grants).toEqual([]); + expect(assignments.denies).toEqual(["post:write"]); + }); + + test("revokeGrant removes the permission entirely", async () => { + await store.grant("u1", "post:write", "allow"); + await store.revokeGrant("u1", "post:write"); + expect((await store.assignmentsFor("u1")).grants).toEqual([]); + }); + + test("listSubjects returns everyone with an assignment in scope", async () => { + await store.assignRole("u1", "editor", { tenantId: "t1" }); + await store.assignRole("u2", "editor", { tenantId: "t1" }); + await store.assignRole("u3", "editor", { tenantId: "t2" }); + expect((await store.listSubjects({ tenantId: "t1" })).sort()).toEqual(["u1", "u2"]); + }); + + test("listSubjects with no scope returns global assignees only", async () => { + await store.assignRole("g1", "viewer"); + await store.assignRole("s1", "editor", { tenantId: "t1" }); + expect(await store.listSubjects()).toEqual(["g1"]); + }); + }); +} +``` + +- [ ] **Step 2: Write the memory adapter test** + +Create `packages/authz/test/store-memory.test.ts`: + +```ts +import { memoryPermissionStore } from "../src/store.ts"; +import { runStoreConformance } from "./store-conformance.ts"; + +runStoreConformance("memory", async () => memoryPermissionStore()); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `bun test packages/authz/test/store-memory.test.ts` +Expected: FAIL — cannot resolve `../src/store.ts` + +- [ ] **Step 4: Write the implementation** + +Create `packages/authz/src/store.ts`: + +```ts +import type { AuthzScope, SubjectAssignments } from "./types.ts"; + +export type GrantEffect = "allow" | "deny"; + +export interface PermissionStore { + assignmentsFor(subjectId: string, scope?: AuthzScope): Promise; + assignRole(subjectId: string, role: string, scope?: AuthzScope): Promise; + revokeRole(subjectId: string, role: string, scope?: AuthzScope): Promise; + grant( + subjectId: string, + permission: string, + effect: GrantEffect, + scope?: AuthzScope, + ): Promise; + revokeGrant(subjectId: string, permission: string, scope?: AuthzScope): Promise; + listSubjects(scope?: AuthzScope): Promise; +} + +/** Global assignments are stored under the empty-string scope key. */ +export function scopeKey(scope?: AuthzScope): string { + return scope?.tenantId ?? ""; +} + +interface Row { + subjectId: string; + scope: string; +} +interface RoleRow extends Row { + role: string; +} +interface GrantRow extends Row { + permission: string; + effect: GrantEffect; +} + +export function memoryPermissionStore(): PermissionStore { + const roles: RoleRow[] = []; + const grants: GrantRow[] = []; + + // A request inside tenant t sees global assignments plus t's own. + const visible = (row: Row, key: string) => row.scope === "" || row.scope === key; + + return { + async assignmentsFor(subjectId, scope) { + const key = scopeKey(scope); + const mine = (row: Row) => row.subjectId === subjectId && visible(row, key); + const matched = grants.filter(mine); + return { + roles: roles.filter(mine).map((row) => row.role), + grants: matched.filter((row) => row.effect === "allow").map((row) => row.permission), + denies: matched.filter((row) => row.effect === "deny").map((row) => row.permission), + }; + }, + async assignRole(subjectId, role, scope) { + const key = scopeKey(scope); + if (roles.some((r) => r.subjectId === subjectId && r.scope === key && r.role === role)) + return; + roles.push({ subjectId, scope: key, role }); + }, + async revokeRole(subjectId, role, scope) { + const key = scopeKey(scope); + const at = roles.findIndex( + (r) => r.subjectId === subjectId && r.scope === key && r.role === role, + ); + if (at !== -1) roles.splice(at, 1); + }, + async grant(subjectId, permission, effect, scope) { + const key = scopeKey(scope); + const at = grants.findIndex( + (g) => g.subjectId === subjectId && g.scope === key && g.permission === permission, + ); + if (at !== -1) grants.splice(at, 1); + grants.push({ subjectId, scope: key, permission, effect }); + }, + async revokeGrant(subjectId, permission, scope) { + const key = scopeKey(scope); + const at = grants.findIndex( + (g) => g.subjectId === subjectId && g.scope === key && g.permission === permission, + ); + if (at !== -1) grants.splice(at, 1); + }, + async listSubjects(scope) { + const key = scopeKey(scope); + const ids = new Set(); + for (const row of roles) if (row.scope === key) ids.add(row.subjectId); + for (const row of grants) if (row.scope === key) ids.add(row.subjectId); + return [...ids]; + }, + }; +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `bun test packages/authz/test/store-memory.test.ts` +Expected: PASS, 13 tests + +- [ ] **Step 6: Commit** + +```bash +git add packages/authz/src/store.ts packages/authz/test/store-conformance.ts packages/authz/test/store-memory.test.ts +git commit -m "feat(authz): add PermissionStore contract with memory adapter and conformance suite" +``` + +--- + +## Task 4: Cached store decorator + +**Files:** + +- Modify: `packages/authz/src/store.ts` (append) +- Test: `packages/authz/test/store-cached.test.ts` + +**Interfaces:** + +- Consumes: `PermissionStore`, `scopeKey` from `./store.ts` +- Produces: `cachedPermissionStore(inner: PermissionStore, options?: { ttlMs?: number; max?: number }): CachedPermissionStore`, `interface CachedPermissionStore extends PermissionStore { invalidate(subjectId: string, scope?: AuthzScope): void; invalidateAll(): void }` + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/store-cached.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { cachedPermissionStore, memoryPermissionStore } from "../src/store.ts"; +import { runStoreConformance } from "./store-conformance.ts"; + +// A cache must not change observable behaviour: writes invalidate internally. +runStoreConformance("cached(memory)", async () => cachedPermissionStore(memoryPermissionStore())); + +describe("cachedPermissionStore", () => { + test("serves a repeat read from cache", async () => { + const inner = memoryPermissionStore(); + let reads = 0; + const counting = { + ...inner, + assignmentsFor: (id: string, scope?: { tenantId?: string }) => { + reads++; + return inner.assignmentsFor(id, scope); + }, + }; + const store = cachedPermissionStore(counting, { ttlMs: 60_000 }); + await store.assignmentsFor("u1"); + await store.assignmentsFor("u1"); + expect(reads).toBe(1); + }); + + test("a write invalidates that subject", async () => { + const store = cachedPermissionStore(memoryPermissionStore(), { ttlMs: 60_000 }); + await store.assignmentsFor("u1"); + await store.assignRole("u1", "editor"); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("invalidate() drops a cached subject", async () => { + const inner = memoryPermissionStore(); + const store = cachedPermissionStore(inner, { ttlMs: 60_000 }); + await store.assignmentsFor("u1"); + await inner.assignRole("u1", "editor"); // behind the cache's back + expect((await store.assignmentsFor("u1")).roles).toEqual([]); + store.invalidate("u1"); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("entries expire after ttlMs", async () => { + const inner = memoryPermissionStore(); + const store = cachedPermissionStore(inner, { ttlMs: 1 }); + await store.assignmentsFor("u1"); + await inner.assignRole("u1", "editor"); + await Bun.sleep(5); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("cache is bounded by max", async () => { + const store = cachedPermissionStore(memoryPermissionStore(), { ttlMs: 60_000, max: 2 }); + await store.assignmentsFor("a"); + await store.assignmentsFor("b"); + await store.assignmentsFor("c"); + expect(store.size()).toBeLessThanOrEqual(2); + }); + + test("scoped and global reads cache separately", async () => { + const inner = memoryPermissionStore(); + const store = cachedPermissionStore(inner, { ttlMs: 60_000 }); + await inner.assignRole("u1", "editor", { tenantId: "t1" }); + expect((await store.assignmentsFor("u1")).roles).toEqual([]); + expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/authz/test/store-cached.test.ts` +Expected: FAIL — `cachedPermissionStore` is not exported + +- [ ] **Step 3: Append the implementation to `packages/authz/src/store.ts`** + +```ts +export interface CachedPermissionStore extends PermissionStore { + /** Drop one subject. Call after changing roles out of band. */ + invalidate(subjectId: string, scope?: AuthzScope): void; + invalidateAll(): void; + /** Cached entry count, for tests and diagnostics. */ + size(): number; +} + +export interface CacheOptions { + ttlMs?: number; + max?: number; +} + +/** + * Caches assignment reads. Writes through this decorator invalidate the + * affected subject immediately; changes made directly against the inner store + * need an explicit `invalidate()` call rather than waiting out the TTL. + */ +export function cachedPermissionStore( + inner: PermissionStore, + options: CacheOptions = {}, +): CachedPermissionStore { + const ttlMs = options.ttlMs ?? 5_000; + const max = options.max ?? 1_000; + const entries = new Map(); + + const cacheKey = (subjectId: string, scope?: AuthzScope) => `${scopeKey(scope)}�${subjectId}`; + const drop = (subjectId: string, scope?: AuthzScope) => { + entries.delete(cacheKey(subjectId, scope)); + // A global write changes what every tenant sees for that subject. + if (scopeKey(scope) === "") { + for (const key of [...entries.keys()]) { + if (key.endsWith(`�${subjectId}`)) entries.delete(key); + } + } + }; + + return { + async assignmentsFor(subjectId, scope) { + const key = cacheKey(subjectId, scope); + const hit = entries.get(key); + if (hit && Date.now() - hit.at < ttlMs) return hit.value; + const value = await inner.assignmentsFor(subjectId, scope); + if (entries.size >= max) entries.delete(entries.keys().next().value!); + entries.set(key, { at: Date.now(), value }); + return value; + }, + async assignRole(subjectId, role, scope) { + await inner.assignRole(subjectId, role, scope); + drop(subjectId, scope); + }, + async revokeRole(subjectId, role, scope) { + await inner.revokeRole(subjectId, role, scope); + drop(subjectId, scope); + }, + async grant(subjectId, permission, effect, scope) { + await inner.grant(subjectId, permission, effect, scope); + drop(subjectId, scope); + }, + async revokeGrant(subjectId, permission, scope) { + await inner.revokeGrant(subjectId, permission, scope); + drop(subjectId, scope); + }, + listSubjects: (scope) => inner.listSubjects(scope), + invalidate: drop, + invalidateAll: () => entries.clear(), + size: () => entries.size, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/authz/test/store-cached.test.ts` +Expected: PASS — 13 conformance tests plus 6 cache tests + +- [ ] **Step 5: Commit** + +```bash +git add packages/authz/src/store.ts packages/authz/test/store-cached.test.ts +git commit -m "feat(authz): add caching decorator for PermissionStore" +``` + +--- + +## Task 5: Audit sink + +**Files:** + +- Create: `packages/authz/src/audit.ts` +- Test: `packages/authz/test/audit.test.ts` + +**Interfaces:** + +- Consumes: `AuthzScope` from `./types.ts` +- Produces: `AuthzAuditEvent`, `AuthzAuditSink`, `memoryAuditSink(): MemoryAuditSink`, `consoleAuditSink(): AuthzAuditSink`, `safeRecord(sink, event): void` + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/audit.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { memoryAuditSink, safeRecord } from "../src/audit.ts"; + +describe("audit sink", () => { + test("memoryAuditSink collects events", () => { + const sink = memoryAuditSink(); + sink.record({ permission: "post:read", allowed: true, at: 1 }); + expect(sink.events).toHaveLength(1); + expect(sink.events[0]!.permission).toBe("post:read"); + }); + + test("safeRecord swallows sink failures", () => { + const exploding = { + record() { + throw new Error("sink is down"); + }, + }; + // Auditing must never break a request. + expect(() => safeRecord(exploding, { permission: "p:x", allowed: false, at: 1 })).not.toThrow(); + }); + + test("safeRecord swallows async sink rejections", async () => { + const rejecting = { record: async () => Promise.reject(new Error("later")) }; + expect(() => safeRecord(rejecting, { permission: "p:x", allowed: false, at: 1 })).not.toThrow(); + await Bun.sleep(1); + }); + + test("safeRecord tolerates an undefined sink", () => { + expect(() => safeRecord(undefined, { permission: "p:x", allowed: true, at: 1 })).not.toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/authz/test/audit.test.ts` +Expected: FAIL — cannot resolve `../src/audit.ts` + +- [ ] **Step 3: Write the implementation** + +Create `packages/authz/src/audit.ts`: + +```ts +import type { AuthzScope } from "./types.ts"; + +export interface AuthzAuditEvent { + subjectId?: string; + scope?: AuthzScope; + permission: string; + allowed: boolean; + reason?: string; + policy?: string; + /** Epoch milliseconds. */ + at: number; +} + +export interface AuthzAuditSink { + record(event: AuthzAuditEvent): void | Promise; +} + +export interface MemoryAuditSink extends AuthzAuditSink { + events: AuthzAuditEvent[]; + clear(): void; +} + +export function memoryAuditSink(): MemoryAuditSink { + const events: AuthzAuditEvent[] = []; + return { + events, + record: (event) => void events.push(event), + clear: () => void events.splice(0, events.length), + }; +} + +export function consoleAuditSink(): AuthzAuditSink { + return { + record(event) { + const verdict = event.allowed ? "allow" : "deny"; + console.info( + `[wrnexus:authz] ${verdict} ${event.permission} subject=${event.subjectId ?? "anonymous"}` + + `${event.scope?.tenantId ? ` tenant=${event.scope.tenantId}` : ""}` + + `${event.reason ? ` reason=${event.reason}` : ""}`, + ); + }, + }; +} + +/** Record without ever letting a sink failure escape into the request path. */ +export function safeRecord(sink: AuthzAuditSink | undefined, event: AuthzAuditEvent): void { + if (!sink) return; + try { + const result = sink.record(event); + if (result instanceof Promise) { + result.catch((error) => console.warn("[wrnexus:authz] audit sink failed", error)); + } + } catch (error) { + console.warn("[wrnexus:authz] audit sink failed", error); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/authz/test/audit.test.ts` +Expected: PASS, 4 tests + +- [ ] **Step 5: Commit** + +```bash +git add packages/authz/src/audit.ts packages/authz/test/audit.test.ts +git commit -m "feat(authz): add pluggable authorization audit sink" +``` + +--- + +## Task 6: Resolution engine + +**Files:** + +- Create: `packages/authz/src/engine.ts` +- Test: `packages/authz/test/engine.test.ts` + +**Interfaces:** + +- Consumes: `AuthzCatalog`, `AuthzScope`, `SubjectAssignments` from `./types.ts`; `PermissionStore` from `./store.ts`; `AuthzAuditSink`, `safeRecord` from `./audit.ts`; `AuthorizationDecision` from `./advanced.ts` +- Produces: `createAuthzResolver(options: AuthzResolverOptions): AuthzResolver` with `AuthzResolver { permissionsFor(subjectId, scope?): Promise>; decide(input: DecideInput): Promise }`, `expandRoles(catalog, roles): Set`, `permissionMatches(granted: Set, permission: string): boolean` + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/engine.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { defineAuthz } from "../src/registry.ts"; +import { mergeCatalogs } from "../src/catalog.ts"; +import { memoryPermissionStore } from "../src/store.ts"; +import { memoryAuditSink } from "../src/audit.ts"; +import { createAuthzResolver, expandRoles, permissionMatches } from "../src/engine.ts"; + +const catalog = mergeCatalogs([ + { + source: "test.ts", + module: defineAuthz({ + permissions: { + "post:read": { public: true }, + "post:write": {}, + "post:delete": { risk: "high" }, + "post:comment:delete": {}, + }, + roles: { + editor: ["post:*"], + moderator: ["post:comment:*"], + admin: ["role:editor", "post:delete"], + cyclic: ["role:cyclic", "post:read"], + }, + policies: { + ownsPost: async (subject: { id?: string }, resource?: { authorId?: string }) => + resource?.authorId === subject?.id + ? { allowed: true } + : { allowed: false, reason: "not the author", policy: "ownsPost" }, + explodes: async () => { + throw new Error("policy blew up"); + }, + }, + bindings: { "post:write": ["ownsPost"] }, + }), + }, +]); + +const make = (store = memoryPermissionStore(), audit = memoryAuditSink()) => ({ + store, + audit, + resolver: createAuthzResolver({ catalog, store, audit, strict: false }), +}); + +describe("expandRoles", () => { + test("expands wildcards and role inheritance", () => { + expect([...expandRoles(catalog, ["admin"])].sort()).toEqual(["post:*", "post:delete"]); + }); + test("terminates on cyclic inheritance", () => { + expect([...expandRoles(catalog, ["cyclic"])]).toEqual(["post:read"]); + }); +}); + +describe("permissionMatches", () => { + test("matches exact, root wildcard, and every namespace depth", () => { + expect(permissionMatches(new Set(["post:read"]), "post:read")).toBe(true); + expect(permissionMatches(new Set(["*"]), "anything:at:all")).toBe(true); + expect(permissionMatches(new Set(["post:*"]), "post:comment:delete")).toBe(true); + expect(permissionMatches(new Set(["post:comment:*"]), "post:comment:delete")).toBe(true); + expect(permissionMatches(new Set(["post:comment:*"]), "post:write")).toBe(false); + }); +}); + +describe("createAuthzResolver.decide", () => { + test("allows a public permission for an anonymous subject", async () => { + const { resolver } = make(); + const result = await resolver.decide({ subject: null, permission: "post:read" }); + expect(result.allowed).toBe(true); + }); + + test("denies a non-public permission for an anonymous subject", async () => { + const { resolver } = make(); + const result = await resolver.decide({ subject: null, permission: "post:delete" }); + expect(result.allowed).toBe(false); + }); + + test("allows via a role-derived wildcard", async () => { + const { store, resolver } = make(); + await store.assignRole("u1", "moderator"); + const result = await resolver.decide({ + subject: { id: "u1" }, + permission: "post:comment:delete", + }); + expect(result.allowed).toBe(true); + }); + + test("an explicit deny beats a role and beats '*'", async () => { + const { store, resolver } = make(); + await store.assignRole("u1", "admin"); + await store.grant("u1", "post:delete", "deny"); + const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" }); + expect(result.allowed).toBe(false); + expect(result.reason).toMatch(/explicit deny/i); + }); + + test("a bound policy can deny a permission the role grants", async () => { + const { store, resolver } = make(); + await store.assignRole("u1", "editor"); + const denied = await resolver.decide({ + subject: { id: "u1" }, + permission: "post:write", + resource: { authorId: "someone-else" }, + }); + expect(denied.allowed).toBe(false); + expect(denied.policy).toBe("ownsPost"); + + const allowed = await resolver.decide({ + subject: { id: "u1" }, + permission: "post:write", + resource: { authorId: "u1" }, + }); + expect(allowed.allowed).toBe(true); + }); + + test("a throwing policy denies rather than escaping", async () => { + const throwing = mergeCatalogs([ + { + source: "t.ts", + module: defineAuthz({ + permissions: { "x:go": {} }, + policies: { + explodes: async () => { + throw new Error("boom"); + }, + }, + bindings: { "x:go": ["explodes"] }, + }), + }, + ]); + const store = memoryPermissionStore(); + await store.grant("u1", "x:go", "allow"); + const resolver = createAuthzResolver({ catalog: throwing, store, strict: false }); + const result = await resolver.decide({ subject: { id: "u1" }, permission: "x:go" }); + expect(result.allowed).toBe(false); + }); + + test("a store failure denies and does not throw", async () => { + const broken = { + ...memoryPermissionStore(), + assignmentsFor: async () => { + throw new Error("db down"); + }, + }; + const resolver = createAuthzResolver({ catalog, store: broken, strict: false }); + const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:read" }); + expect(result.allowed).toBe(false); + }); + + test("an unregistered permission denies when strict is off", async () => { + const { resolver } = make(); + const result = await resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" }); + expect(result.allowed).toBe(false); + expect(result.reason).toMatch(/not registered/i); + }); + + test("an unregistered permission throws when strict is on", async () => { + const resolver = createAuthzResolver({ + catalog, + store: memoryPermissionStore(), + strict: true, + }); + await expect( + resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" }), + ).rejects.toThrow(/ghost:perm/); + }); + + test("denials are audited and allows are not, by default", async () => { + const { store, audit, resolver } = make(); + await store.assignRole("u1", "editor"); + await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" }); + await resolver.decide({ subject: { id: "u1" }, permission: "post:read" }); + expect(audit.events).toHaveLength(1); + expect(audit.events[0]!.allowed).toBe(false); + }); + + test("auditAllows records both verdicts", async () => { + const store = memoryPermissionStore(); + const audit = memoryAuditSink(); + const resolver = createAuthzResolver({ + catalog, + store, + audit, + strict: false, + auditAllows: true, + }); + await resolver.decide({ subject: null, permission: "post:read" }); + expect(audit.events).toHaveLength(1); + expect(audit.events[0]!.allowed).toBe(true); + }); + + test("tenant scope selects the right assignments", async () => { + const { store, resolver } = make(); + await store.assignRole("u1", "editor", { tenantId: "t1" }); + const inside = await resolver.decide({ + subject: { id: "u1" }, + permission: "post:write", + resource: { authorId: "u1" }, + scope: { tenantId: "t1" }, + }); + const outside = await resolver.decide({ + subject: { id: "u1" }, + permission: "post:write", + resource: { authorId: "u1" }, + scope: { tenantId: "t2" }, + }); + expect(inside.allowed).toBe(true); + expect(outside.allowed).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/authz/test/engine.test.ts` +Expected: FAIL — cannot resolve `../src/engine.ts` + +- [ ] **Step 3: Write the implementation** + +Create `packages/authz/src/engine.ts`: + +```ts +import type { AuthorizationDecision } from "./advanced.ts"; +import { safeRecord, type AuthzAuditSink } from "./audit.ts"; +import type { PermissionStore } from "./store.ts"; +import type { AuthzCatalog, AuthzScope } from "./types.ts"; + +export interface AuthzResolverOptions { + catalog: AuthzCatalog; + store: PermissionStore; + audit?: AuthzAuditSink; + /** + * Throw on an unregistered permission instead of denying. Defaults to true + * outside production, so typos surface during development. + */ + strict?: boolean; + /** Record allows as well as denies. Off by default to bound write volume. */ + auditAllows?: boolean; +} + +export interface DecideInput { + subject: { id?: string; [key: string]: unknown } | null | undefined; + permission: string; + resource?: unknown; + scope?: AuthzScope; +} + +export interface AuthzResolver { + permissionsFor(subjectId: string, scope?: AuthzScope): Promise>; + decide(input: DecideInput): Promise; +} + +/** Expand roles into their granted entries, following `role:` and stopping on cycles. */ +export function expandRoles(catalog: AuthzCatalog, roles: readonly string[]): Set { + const out = new Set(); + const seen = new Set(); + const walk = (role: string) => { + if (seen.has(role)) return; + seen.add(role); + for (const entry of catalog.roles.get(role) ?? []) { + if (entry.startsWith("role:")) walk(entry.slice(5)); + else out.add(entry); + } + }; + for (const role of roles) walk(role); + return out; +} + +/** Exact match, root wildcard, or a namespace wildcard at any depth. */ +export function permissionMatches(granted: Set, permission: string): boolean { + if (granted.has("*") || granted.has(permission)) return true; + for (let at = permission.indexOf(":"); at !== -1; at = permission.indexOf(":", at + 1)) { + if (granted.has(`${permission.slice(0, at)}:*`)) return true; + } + return false; +} + +function isProduction(): boolean { + return (process.env.NODE_ENV ?? "development") === "production"; +} + +export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolver { + const { catalog, store, audit } = options; + const strict = options.strict ?? !isProduction(); + + const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise> => { + const assignments = await store.assignmentsFor(subjectId, scope); + const granted = expandRoles(catalog, assignments.roles); + for (const grant of assignments.grants) granted.add(grant); + return granted; + }; + + const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => { + if (!result.allowed || options.auditAllows) { + safeRecord(audit, { + subjectId: input.subject?.id, + scope: input.scope, + permission: input.permission, + allowed: result.allowed, + reason: result.reason, + policy: result.policy, + at: Date.now(), + }); + } + return result; + }; + + return { + permissionsFor, + + async decide(input) { + const { subject, permission, resource, scope } = input; + const meta = catalog.permissions.get(permission); + + if (!meta) { + if (strict) { + throw new Error( + `WRN-AUTHZ-UNKNOWN: permission '${permission}' is not registered. ` + + `Declare it with defineAuthz() in app/authz/.`, + ); + } + return finish(input, { + allowed: false, + reason: `Permission '${permission}' is not registered`, + }); + } + + const subjectId = subject?.id; + if (!subjectId) { + return finish( + input, + meta.public + ? { allowed: true, reason: "public permission" } + : { allowed: false, reason: "Authentication required" }, + ); + } + + let assignments; + let granted: Set; + try { + assignments = await store.assignmentsFor(subjectId, scope); + granted = expandRoles(catalog, assignments.roles); + for (const grant of assignments.grants) granted.add(grant); + } catch (error) { + console.error("[wrnexus:authz] permission store failed; denying", error); + return finish(input, { allowed: false, reason: "Authorization store unavailable" }); + } + + // 1. Explicit deny wins over everything, including "*". + if (assignments.denies.includes(permission)) { + return finish(input, { allowed: false, reason: "explicit deny" }); + } + + // 2. Must hold the permission at all. + if (!meta.public && !permissionMatches(granted, permission)) { + return finish(input, { allowed: false, reason: "Missing permission" }); + } + + // 3. Every bound policy must pass. + for (const name of catalog.bindings.get(permission) ?? []) { + const policy = catalog.policies.get(name); + if (!policy) continue; + try { + const verdict = await ( + policy as unknown as ( + s: unknown, + r: unknown, + ) => AuthorizationDecision | Promise + )(subject, resource); + if (!verdict.allowed) { + return finish(input, { ...verdict, policy: verdict.policy ?? name }); + } + } catch (error) { + console.error(`[wrnexus:authz] policy '${name}' threw; denying`, error); + return finish(input, { allowed: false, reason: "Policy error", policy: name }); + } + } + + return finish(input, { allowed: true }); + }, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/authz/test/engine.test.ts` +Expected: PASS, 15 tests + +- [ ] **Step 5: Commit** + +```bash +git add packages/authz/src/engine.ts packages/authz/test/engine.test.ts +git commit -m "feat(authz): add resolution engine with deny-wins precedence and fail-closed errors" +``` + +--- + +## Task 7: Middleware, can(), and guards + +**Files:** + +- Create: `packages/authz/src/middleware.ts` +- Test: `packages/authz/test/middleware.test.ts` + +**Interfaces:** + +- Consumes: `createAuthzResolver`, `AuthzResolverOptions`, `AuthzResolver` from `./engine.ts`; `Context`, `Middleware` types from `@wrnexus/core` +- Produces: `AUTHZ_LOCALS_KEY`, `authzMiddleware(options: AuthzResolverOptions): Middleware`, `decideFor(ctx, permission, resource?): Promise`, `can(ctx, permission, resource?): Promise`, `guardPermission(permission, getResource?): Middleware`, `filterCan(ctx, permission, items): Promise` + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/middleware.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import type { Context } from "@wrnexus/core"; +import { defineAuthz } from "../src/registry.ts"; +import { mergeCatalogs } from "../src/catalog.ts"; +import { memoryPermissionStore } from "../src/store.ts"; +import { authzMiddleware, can, filterCan, guardPermission } from "../src/middleware.ts"; + +const catalog = mergeCatalogs([ + { + source: "t.ts", + module: defineAuthz({ + permissions: { "post:read": { public: true }, "post:write": {}, "post:delete": {} }, + roles: { editor: ["post:write"] }, + policies: { + ownsPost: async (s: { id?: string }, r?: { authorId?: string }) => + r?.authorId === s?.id ? { allowed: true } : { allowed: false, reason: "not owner" }, + }, + bindings: { "post:delete": ["ownsPost"] }, + }), + }, +]); + +/** Minimal Context stand-in; the middleware only touches user, tenant, locals. */ +function makeCtx(user: unknown, tenantId?: string): Context { + return { + user, + tenant: tenantId ? { id: tenantId } : undefined, + locals: {}, + url: new URL("http://localhost/x"), + req: new Request("http://localhost/x"), + } as unknown as Context; +} + +const withMiddleware = async (ctx: Context, store = memoryPermissionStore()) => { + await authzMiddleware({ catalog, store, strict: false })(ctx, async () => new Response("ok")); + return store; +}; + +describe("authzMiddleware + can", () => { + test("can() resolves through the middleware-installed resolver", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.assignRole("u1", "editor"); + await withMiddleware(ctx, store); + expect(await can(ctx, "post:write")).toBe(true); + expect(await can(ctx, "post:delete", { authorId: "u1" })).toBe(false); + }); + + test("can() throws a clear setup error without the middleware", async () => { + const ctx = makeCtx({ id: "u1" }); + await expect(can(ctx, "post:read")).rejects.toThrow(/authzMiddleware/); + }); + + test("results are memoised per request", async () => { + const inner = memoryPermissionStore(); + let reads = 0; + const counting = { + ...inner, + assignmentsFor: (id: string, scope?: { tenantId?: string }) => { + reads++; + return inner.assignmentsFor(id, scope); + }, + }; + const ctx = makeCtx({ id: "u1" }); + await authzMiddleware({ catalog, store: counting, strict: false })( + ctx, + async () => new Response("ok"), + ); + await can(ctx, "post:write"); + await can(ctx, "post:write"); + expect(reads).toBe(1); + }); + + test("memoisation keys on the resource, not just the permission", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.grant("u1", "post:delete", "allow"); + await withMiddleware(ctx, store); + expect(await can(ctx, "post:delete", { authorId: "u1" })).toBe(true); + expect(await can(ctx, "post:delete", { authorId: "other" })).toBe(false); + }); + + test("the tenant on the context becomes the scope", async () => { + const ctx = makeCtx({ id: "u1" }, "t1"); + const store = memoryPermissionStore(); + await store.assignRole("u1", "editor", { tenantId: "t1" }); + await withMiddleware(ctx, store); + expect(await can(ctx, "post:write")).toBe(true); + }); +}); + +describe("guardPermission", () => { + test("calls next when allowed", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.assignRole("u1", "editor"); + await withMiddleware(ctx, store); + const res = await guardPermission("post:write")(ctx, async () => new Response("passed")); + expect(await res.text()).toBe("passed"); + }); + + test("returns 403 without leaking the reason by default", async () => { + const ctx = makeCtx({ id: "u1" }); + await withMiddleware(ctx); + const res = await guardPermission("post:write")(ctx, async () => new Response("passed")); + expect(res.status).toBe(403); + const body = (await res.json()) as Record; + expect(body).toEqual({ ok: false, error: "Forbidden" }); + }); + + test("exposeReason opts into diagnostics", async () => { + const ctx = makeCtx({ id: "u1" }); + await withMiddleware(ctx); + const res = await guardPermission("post:write", { exposeReason: true })( + ctx, + async () => new Response("passed"), + ); + const body = (await res.json()) as Record; + expect(body.reason).toBe("Missing permission"); + }); + + test("getResource feeds the bound policy", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.grant("u1", "post:delete", "allow"); + await withMiddleware(ctx, store); + const guard = guardPermission("post:delete", { getResource: () => ({ authorId: "u1" }) }); + const res = await guard(ctx, async () => new Response("passed")); + expect(await res.text()).toBe("passed"); + }); +}); + +describe("filterCan", () => { + test("keeps only the items the subject may act on", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.grant("u1", "post:delete", "allow"); + await withMiddleware(ctx, store); + const posts = [{ authorId: "u1" }, { authorId: "other" }, { authorId: "u1" }]; + expect(await filterCan(ctx, "post:delete", posts)).toHaveLength(2); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/authz/test/middleware.test.ts` +Expected: FAIL — cannot resolve `../src/middleware.ts` + +- [ ] **Step 3: Write the implementation** + +Create `packages/authz/src/middleware.ts`: + +```ts +import type { Context, Middleware } from "@wrnexus/core"; +import type { AuthorizationDecision } from "./advanced.ts"; +import { createAuthzResolver, type AuthzResolver, type AuthzResolverOptions } from "./engine.ts"; +import type { AuthzScope } from "./types.ts"; + +/** + * `can` is deliberately not a Context member: @wrnexus/core must not depend on + * @wrnexus/authz. The per-request resolver lives here instead. + */ +export const AUTHZ_LOCALS_KEY = "_authz"; + +interface RequestAuthz { + resolver: AuthzResolver; + scope?: AuthzScope; + memo: Map>; +} + +function readAuthz(ctx: Context): RequestAuthz { + const value = ctx.locals[AUTHZ_LOCALS_KEY] as RequestAuthz | undefined; + if (!value) { + throw new Error( + "WRN-AUTHZ-SETUP: authzMiddleware() is not registered for this request. " + + "Add it to app/middleware before calling can()/guardPermission().", + ); + } + return value; +} + +/** Install the per-request resolver. Register early, after sessionAuth. */ +export function authzMiddleware(options: AuthzResolverOptions): Middleware { + const resolver = createAuthzResolver(options); + return (ctx, next) => { + const request: RequestAuthz = { + resolver, + scope: ctx.tenant?.id ? { tenantId: ctx.tenant.id } : undefined, + memo: new Map(), + }; + ctx.locals[AUTHZ_LOCALS_KEY] = request; + return next(); + }; +} + +/** Stable memo key. Resources without an id fall back to their JSON shape. */ +function memoKey(permission: string, resource: unknown): string { + if (resource === undefined) return permission; + const id = (resource as { id?: unknown })?.id; + if (id !== undefined && id !== null) return `${permission}�${String(id)}`; + try { + return `${permission}�${JSON.stringify(resource)}`; + } catch { + return `${permission}�`; + } +} + +export function decideFor( + ctx: Context, + permission: string, + resource?: unknown, +): Promise { + const request = readAuthz(ctx); + const key = memoKey(permission, resource); + const cached = request.memo.get(key); + if (cached) return cached; + const pending = request.resolver.decide({ + subject: ctx.user as { id?: string } | null | undefined, + permission, + resource, + scope: request.scope, + }); + request.memo.set(key, pending); + return pending; +} + +export async function can(ctx: Context, permission: string, resource?: unknown): Promise { + return (await decideFor(ctx, permission, resource)).allowed; +} + +export interface GuardOptions { + /** Load the resource a bound policy needs. */ + getResource?: (ctx: Context) => unknown | Promise; + /** Include reason and policy name in the 403 body. Off by default. */ + exposeReason?: boolean; + /** Redirect page requests here instead of returning 403. */ + redirectTo?: string; +} + +/** + * Guard a route on a registered permission. Named `guardPermission` because + * `requirePermission(rbac, permission)` already exists with a different shape. + */ +export function guardPermission(permission: string, options: GuardOptions = {}): Middleware { + return async (ctx, next) => { + const resource = options.getResource ? await options.getResource(ctx) : undefined; + const result = await decideFor(ctx, permission, resource); + if (result.allowed) return next(); + if (options.redirectTo) { + return new Response(null, { status: 303, headers: { location: options.redirectTo } }); + } + return Response.json( + options.exposeReason + ? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy } + : { ok: false, error: "Forbidden" }, + { status: 403 }, + ); + }; +} + +/** Keep only the items the current subject may act on. */ +export async function filterCan( + ctx: Context, + permission: string, + items: readonly T[], +): Promise { + const verdicts = await Promise.all( + items.map(async (item) => ({ item, allowed: await can(ctx, permission, item) })), + ); + return verdicts.filter((entry) => entry.allowed).map((entry) => entry.item); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/authz/test/middleware.test.ts` +Expected: PASS, 10 tests + +- [ ] **Step 5: Commit** + +```bash +git add packages/authz/src/middleware.ts packages/authz/test/middleware.test.ts +git commit -m "feat(authz): add request middleware, can(), and guardPermission" +``` + +--- + +## Task 8: Stop `authorizeDecision` leaking policy internals + +**Files:** + +- Modify: `packages/authz/src/advanced.ts:72-83` +- Test: `packages/authz/test/authz.test.ts` (append) + +**Interfaces:** + +- Consumes: `AuthorizationDecision` from `./advanced.ts` +- Produces: `authorizeDecision(evaluate, options?: { exposeReason?: boolean }): Middleware` — behaviour change, body is now `{ ok: false, error: "Forbidden" }` unless opted in + +- [ ] **Step 1: Write the failing test** + +Append to `packages/authz/test/authz.test.ts`: + +```ts +describe("authorizeDecision disclosure", () => { + const ctx = { user: { id: "u1" } } as unknown as import("@wrnexus/core").Context; + const denier = async () => ({ allowed: false, reason: "secret internal rule", policy: "isVip" }); + + test("does not leak reason or policy by default", async () => { + const res = await authorizeDecision(denier)(ctx, async () => new Response("ok")); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ ok: false, error: "Forbidden" }); + }); + + test("exposeReason opts back in", async () => { + const res = await authorizeDecision(denier, { exposeReason: true })( + ctx, + async () => new Response("ok"), + ); + const body = (await res.json()) as Record; + expect(body.reason).toBe("secret internal rule"); + expect(body.policy).toBe("isVip"); + }); + + test("still calls next when allowed", async () => { + const res = await authorizeDecision(async () => ({ allowed: true }))( + ctx, + async () => new Response("passed"), + ); + expect(await res.text()).toBe("passed"); + }); +}); +``` + +Add `authorizeDecision` to the file's existing import from `../src/index.ts` if it is not already imported. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/authz/test/authz.test.ts` +Expected: FAIL — the default response still contains `reason` + +- [ ] **Step 3: Modify `packages/authz/src/advanced.ts`** + +Replace the `authorizeDecision` function with: + +```ts +export interface AuthorizeDecisionOptions { + /** + * Include `reason` and `policy` in the 403 body. Off by default: policy + * names describe internal authorization structure and should not reach an + * unauthenticated caller. + */ + exposeReason?: boolean; +} + +export function authorizeDecision( + evaluate: (ctx: Context) => AuthorizationDecision | Promise, + options: AuthorizeDecisionOptions = {}, +): Middleware { + return async (ctx, next) => { + const result = await evaluate(ctx); + if (result.allowed) return next(); + return Response.json( + options.exposeReason + ? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy } + : { ok: false, error: "Forbidden" }, + { status: 403 }, + ); + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/authz/test/authz.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/authz/src/advanced.ts packages/authz/test/authz.test.ts +git commit -m "fix(authz): stop authorizeDecision leaking policy names in 403 bodies" +``` + +--- + +## Task 9: Export the new surface + +**Files:** + +- Modify: `packages/authz/src/index.ts` (append to the existing re-export block) +- Modify: `docs/public-api-0.8.json` (regenerated) +- Test: `packages/authz/test/exports.test.ts` + +**Interfaces:** + +- Consumes: everything from Tasks 1-8 +- Produces: the public `@wrnexus/authz` surface + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/exports.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import * as authz from "../src/index.ts"; + +describe("@wrnexus/authz exports", () => { + test("keeps the pre-existing surface", () => { + for (const name of [ + "defineRbac", + "hasRole", + "any", + "all", + "attr", + "authorize", + "requireRole", + "requirePermission", + "allow", + "deny", + "decision", + "owner", + "anyDecision", + "allDecisions", + "authorizeDecision", + "filterAuthorized", + ]) { + expect(typeof (authz as Record)[name]).toBe("function"); + } + }); + + test("adds the registry, store, engine, and middleware surface", () => { + for (const name of [ + "defineAuthz", + "mergeCatalogs", + "emptyCatalog", + "memoryPermissionStore", + "cachedPermissionStore", + "memoryAuditSink", + "consoleAuditSink", + "createAuthzResolver", + "expandRoles", + "permissionMatches", + "authzMiddleware", + "can", + "decideFor", + "guardPermission", + "filterCan", + ]) { + expect(typeof (authz as Record)[name]).toBe("function"); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/authz/test/exports.test.ts` +Expected: FAIL — `defineAuthz` is undefined + +- [ ] **Step 3: Append to `packages/authz/src/index.ts`** + +```ts +export { defineAuthz } from "./registry.ts"; +export { mergeCatalogs, emptyCatalog } from "./catalog.ts"; +export type { CatalogSource } from "./catalog.ts"; +export { memoryPermissionStore, cachedPermissionStore, scopeKey } from "./store.ts"; +export type { PermissionStore, CachedPermissionStore, CacheOptions, GrantEffect } from "./store.ts"; +export { memoryAuditSink, consoleAuditSink, safeRecord } from "./audit.ts"; +export type { AuthzAuditEvent, AuthzAuditSink, MemoryAuditSink } from "./audit.ts"; +export { createAuthzResolver, expandRoles, permissionMatches } from "./engine.ts"; +export type { AuthzResolver, AuthzResolverOptions, DecideInput } from "./engine.ts"; +export { + authzMiddleware, + can, + decideFor, + guardPermission, + filterCan, + AUTHZ_LOCALS_KEY, +} from "./middleware.ts"; +export type { GuardOptions } from "./middleware.ts"; +export type { + AuthzScope, + AuthzCatalog, + AuthzModule, + AttributeMeta, + PermissionMeta, + SubjectAssignments, +} from "./types.ts"; +export type { AuthorizeDecisionOptions } from "./advanced.ts"; +``` + +- [ ] **Step 4: Run tests and regenerate the API baseline** + +Run: `bun test packages/authz && bun run generate:public-api && bun run check:public-api` +Expected: tests PASS; baseline regenerates; check reports a match + +- [ ] **Step 5: Commit** + +```bash +git add packages/authz/src/index.ts packages/authz/test/exports.test.ts docs/public-api-0.8.json +git commit -m "feat(authz): export registry, store, engine, and middleware surface" +``` + +--- + +## Task 10: Router discovery of `app/authz` + +**Files:** + +- Modify: `packages/router/src/index.ts:273-294` (alongside the existing schema scan) +- Test: `packages/router/test/authz-discovery.test.ts` + +**Interfaces:** + +- Consumes: `scanDir`, `isSafeIslandName`, `ComponentRef` already in `packages/router/src/index.ts` +- Produces: `Router.authz: ComponentRef[]` + +- [ ] **Step 1: Write the failing test** + +Create `packages/router/test/authz-discovery.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildRouter } from "../src/index.ts"; + +function appWithAuthz(files: Record): string { + const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-")); + const dir = join(root, "app", "authz"); + mkdirSync(dir, { recursive: true }); + mkdirSync(join(root, "app", "pages"), { recursive: true }); + for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body, "utf8"); + return join(root, "app"); +} + +describe("app/authz discovery", () => { + test("collects .ts and .js declarations by filename", () => { + const appDir = appWithAuthz({ + "blog.ts": "export default {};", + "billing.js": "export default {};", + }); + const router = buildRouter(appDir); + expect(router.authz.map((entry) => entry.name).sort()).toEqual(["billing", "blog"]); + }); + + test("ignores non-module files", () => { + const appDir = appWithAuthz({ "blog.ts": "export default {};", "notes.md": "# hi" }); + expect(buildRouter(appDir).authz.map((entry) => entry.name)).toEqual(["blog"]); + }); + + test("skips unsafe names", () => { + const appDir = appWithAuthz({ + "ok.ts": "export default {};", + "bad name!.ts": "export default {};", + }); + expect(buildRouter(appDir).authz.map((entry) => entry.name)).toEqual(["ok"]); + }); + + test("an app with no authz directory yields an empty list", () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-none-")); + mkdirSync(join(root, "app", "pages"), { recursive: true }); + expect(buildRouter(join(root, "app")).authz).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/router/test/authz-discovery.test.ts` +Expected: FAIL — `router.authz` is undefined + +- [ ] **Step 3: Modify `packages/router/src/index.ts`** + +Add to the `Router` interface, next to `schemas`: + +```ts + /** Authorization declarations (`app/authz/.ts`) merged into the catalog. */ + authz: ComponentRef[]; +``` + +Add the scan immediately after the existing `schemas` loop: + +```ts +// Authorization declarations: app/authz/.{ts,js}, each default-exporting +// a defineAuthz() module. Merged into the catalog at boot. +const authz: ComponentRef[] = []; +for (const f of scanDir(join(appDir, "authz"))) { + if (!/\.(ts|js)$/.test(f.file)) continue; + const name = basename(f.file).replace(/\.(ts|js)$/, ""); + if (!isSafeIslandName(name)) { + console.warn(`[wrnexus] skipping authz declaration with unsafe name: ${name}`); + continue; + } + authz.push({ name, file: f.file }); +} +``` + +Add `authz,` to the returned object, next to `schemas,`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/router` +Expected: PASS — the new file plus existing router tests + +- [ ] **Step 5: Commit** + +```bash +git add packages/router/src/index.ts packages/router/test/authz-discovery.test.ts +git commit -m "feat(router): discover app/authz declarations" +``` + +--- + +## Task 11: Database store adapter + +**Files:** + +- Create: `packages/authz/src/migrations.ts` +- Create: `packages/authz/src/db.ts` +- Modify: `packages/authz/package.json` (add `./db` export) +- Test: `packages/authz/test/store-db.test.ts` + +**Interfaces:** + +- Consumes: `PermissionStore`, `scopeKey` from `./store.ts`; `Db` type from `@wrnexus/db`; `Dialect` from `@wrnexus/db` +- Produces: `authzMigrationSql(dialect: Dialect): { up: string; down: string }`, `dbPermissionStore(db: Db): PermissionStore`, `ensureAuthzTables(db: Db, dialect?: Dialect): Promise` + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/store-db.test.ts`: + +```ts +import { createDb } from "@wrnexus/db"; +import { sqlite } from "@wrnexus/db/sqlite"; +import { dbPermissionStore, ensureAuthzTables } from "../src/db.ts"; +import { runStoreConformance } from "./store-conformance.ts"; + +// The db adapter must satisfy exactly the same contract as the memory one. +runStoreConformance("sqlite", async () => { + const db = createDb(sqlite(":memory:")); + await ensureAuthzTables(db, "sqlite"); + return dbPermissionStore(db); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/authz/test/store-db.test.ts` +Expected: FAIL — cannot resolve `../src/db.ts` + +- [ ] **Step 3: Write the migration SQL** + +Create `packages/authz/src/migrations.ts`: + +```ts +import type { Dialect } from "@wrnexus/db"; + +/** + * DDL for the two assignment tables. `scope` holds a tenant id, or the empty + * string for a global assignment, so the unique constraints work on every + * dialect (NULL is not comparable in a UNIQUE index). + */ +export function authzMigrationSql(dialect: Dialect): { up: string; down: string } { + const id = + dialect === "postgres" + ? "SERIAL PRIMARY KEY" + : dialect === "mysql" + ? "INT AUTO_INCREMENT PRIMARY KEY" + : "INTEGER PRIMARY KEY AUTOINCREMENT"; + const timestamp = dialect === "sqlite" ? "TEXT" : "TIMESTAMP"; + const now = dialect === "sqlite" ? "CURRENT_TIMESTAMP" : "CURRENT_TIMESTAMP"; + + const up = `CREATE TABLE IF NOT EXISTS _wrn_authz_assignment ( + id ${id}, + subject_id VARCHAR(255) NOT NULL, + scope VARCHAR(255) NOT NULL DEFAULT '', + role VARCHAR(255) NOT NULL, + granted_by VARCHAR(255), + created_at ${timestamp} NOT NULL DEFAULT ${now}, + CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role) +); + +CREATE TABLE IF NOT EXISTS _wrn_authz_grant ( + id ${id}, + subject_id VARCHAR(255) NOT NULL, + scope VARCHAR(255) NOT NULL DEFAULT '', + permission VARCHAR(255) NOT NULL, + effect VARCHAR(16) NOT NULL, + granted_by VARCHAR(255), + created_at ${timestamp} NOT NULL DEFAULT ${now}, + CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission) +);`; + + const down = `DROP TABLE IF EXISTS _wrn_authz_grant; +DROP TABLE IF EXISTS _wrn_authz_assignment;`; + + return { up, down }; +} +``` + +- [ ] **Step 4: Write the adapter** + +Create `packages/authz/src/db.ts`: + +```ts +import type { Db, Dialect } from "@wrnexus/db"; +import { authzMigrationSql } from "./migrations.ts"; +import { scopeKey, type GrantEffect, type PermissionStore } from "./store.ts"; +import type { AuthzScope, SubjectAssignments } from "./types.ts"; + +// Re-exported so `@wrnexus/authz/db` is the single entry point for everything +// database-related, including the DDL the CLI scaffolds. +export { authzMigrationSql } from "./migrations.ts"; + +/** Create the tables if absent. Production apps should use a real migration. */ +export async function ensureAuthzTables(db: Db, dialect: Dialect = "sqlite"): Promise { + for (const statement of authzMigrationSql(dialect).up.split(";\n\n")) { + const sql = statement.trim(); + if (sql) await db.exec(sql.endsWith(";") ? sql : `${sql};`); + } +} + +export function dbPermissionStore(db: Db): PermissionStore { + return { + async assignmentsFor(subjectId: string, scope?: AuthzScope): Promise { + const key = scopeKey(scope); + // A request inside a tenant sees global rows plus that tenant's rows. + const roleRows = await db.all<{ role: string }>( + "SELECT role FROM _wrn_authz_assignment WHERE subject_id = ? AND (scope = '' OR scope = ?)", + [subjectId, key], + ); + const grantRows = await db.all<{ permission: string; effect: GrantEffect }>( + "SELECT permission, effect FROM _wrn_authz_grant WHERE subject_id = ? AND (scope = '' OR scope = ?)", + [subjectId, key], + ); + return { + roles: roleRows.map((row) => row.role), + grants: grantRows.filter((r) => r.effect === "allow").map((r) => r.permission), + denies: grantRows.filter((r) => r.effect === "deny").map((r) => r.permission), + }; + }, + + async assignRole(subjectId, role, scope) { + const key = scopeKey(scope); + const existing = await db.all<{ id: number }>( + "SELECT id FROM _wrn_authz_assignment WHERE subject_id = ? AND scope = ? AND role = ?", + [subjectId, key, role], + ); + if (existing.length) return; + await db.exec( + "INSERT INTO _wrn_authz_assignment (subject_id, scope, role) VALUES (?, ?, ?)", + [subjectId, key, role], + ); + }, + + async revokeRole(subjectId, role, scope) { + await db.exec( + "DELETE FROM _wrn_authz_assignment WHERE subject_id = ? AND scope = ? AND role = ?", + [subjectId, scopeKey(scope), role], + ); + }, + + async grant(subjectId, permission, effect, scope) { + const key = scopeKey(scope); + // Re-granting replaces the effect, so delete then insert. + await db.exec( + "DELETE FROM _wrn_authz_grant WHERE subject_id = ? AND scope = ? AND permission = ?", + [subjectId, key, permission], + ); + await db.exec( + "INSERT INTO _wrn_authz_grant (subject_id, scope, permission, effect) VALUES (?, ?, ?, ?)", + [subjectId, key, permission, effect], + ); + }, + + async revokeGrant(subjectId, permission, scope) { + await db.exec( + "DELETE FROM _wrn_authz_grant WHERE subject_id = ? AND scope = ? AND permission = ?", + [subjectId, scopeKey(scope), permission], + ); + }, + + async listSubjects(scope) { + const key = scopeKey(scope); + const rows = await db.all<{ subject_id: string }>( + "SELECT subject_id FROM _wrn_authz_assignment WHERE scope = ? " + + "UNION SELECT subject_id FROM _wrn_authz_grant WHERE scope = ?", + [key, key], + ); + return [...new Set(rows.map((row) => row.subject_id))]; + }, + }; +} +``` + +- [ ] **Step 5: Add the subpath export** + +In `packages/authz/package.json`, replace the `exports` block with: + +```json + "exports": { + ".": "./src/index.ts", + "./db": "./src/db.ts" + }, +``` + +Add `"@wrnexus/authz/db": ["./packages/authz/src/db.ts"]` to `paths` in the root `tsconfig.json`, next to the existing `@wrnexus/authz` entry. + +- [ ] **Step 6: Run test to verify it passes** + +Run: `bun test packages/authz/test/store-db.test.ts` +Expected: PASS — the same 13 conformance tests as the memory adapter + +- [ ] **Step 7: Commit** + +```bash +git add packages/authz/src/db.ts packages/authz/src/migrations.ts packages/authz/package.json packages/authz/test/store-db.test.ts tsconfig.json +git commit -m "feat(authz): add database-backed PermissionStore" +``` + +--- + +## Task 12: Permission type codegen + +**Files:** + +- Create: `packages/authz/src/codegen.ts` +- Test: `packages/authz/test/codegen.test.ts` + +**Interfaces:** + +- Consumes: `AuthzCatalog` from `./types.ts` +- Produces: `generatePermissionTypes(catalog: AuthzCatalog): string` + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/codegen.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { defineAuthz } from "../src/registry.ts"; +import { mergeCatalogs, emptyCatalog } from "../src/catalog.ts"; +import { generatePermissionTypes } from "../src/codegen.ts"; + +describe("generatePermissionTypes", () => { + test("emits sorted Permission and Role unions", () => { + const catalog = mergeCatalogs([ + { + source: "t.ts", + module: defineAuthz({ + permissions: { "post:write": {}, "post:read": {} }, + roles: { editor: ["post:*"], admin: ["*"] }, + }), + }, + ]); + const out = generatePermissionTypes(catalog); + expect(out).toContain('export type Permission = "post:read" | "post:write";'); + expect(out).toContain('export type Role = "admin" | "editor";'); + expect(out).toContain("DO NOT EDIT"); + }); + + test("emits never for an empty catalog so the file still typechecks", () => { + const out = generatePermissionTypes(emptyCatalog()); + expect(out).toContain("export type Permission = never;"); + expect(out).toContain("export type Role = never;"); + }); + + test("escapes quotes in identifiers", () => { + const catalog = mergeCatalogs([{ source: "t.ts", module: { roles: { 'we"ird': [] } } }]); + expect(generatePermissionTypes(catalog)).toContain('"we\\"ird"'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/authz/test/codegen.test.ts` +Expected: FAIL — cannot resolve `../src/codegen.ts` + +- [ ] **Step 3: Write the implementation** + +Create `packages/authz/src/codegen.ts`: + +```ts +import type { AuthzCatalog } from "./types.ts"; + +function union(values: string[]): string { + if (!values.length) return "never"; + return values + .slice() + .sort() + .map((value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`) + .join(" | "); +} + +/** + * Emit compile-time unions for the registered permissions and roles, so a + * typo in can(ctx, "post:wrtie") is a type error rather than a silent false. + */ +export function generatePermissionTypes(catalog: AuthzCatalog): string { + return `// Generated by \`wrnexus authz generate\`. DO NOT EDIT. + +export type Permission = ${union([...catalog.permissions.keys()])}; + +export type Role = ${union([...catalog.roles.keys()])}; +`; +} +``` + +- [ ] **Step 4: Export it** + +Append to `packages/authz/src/index.ts` (Task 13 imports this from the package entry): + +```ts +export { generatePermissionTypes } from "./codegen.ts"; +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `bun test packages/authz/test/codegen.test.ts && bun run generate:public-api` +Expected: PASS, 3 tests; baseline updated + +- [ ] **Step 6: Commit** + +```bash +git add packages/authz/src/codegen.ts packages/authz/src/index.ts packages/authz/test/codegen.test.ts docs/public-api-0.8.json +git commit -m "feat(authz): generate Permission and Role union types" +``` + +--- + +## Task 13: `wrnexus authz` CLI + +**Files:** + +- Create: `packages/cli/src/authz.ts` +- Modify: `packages/cli/src/index.ts` (add `case "authz"` next to `case "db"`) +- Test: `packages/cli/test/authz-command.test.ts` + +**Interfaces:** + +- Consumes: `buildRouter` from `@wrnexus/router`; `mergeCatalogs`, `generatePermissionTypes`, `authzMigrationSql` from `@wrnexus/authz` +- Produces: `loadAuthzCatalog(appDir: string): Promise`, `runAuthzCommand(root: string, sub: string | undefined, args: string[]): Promise` + +- [ ] **Step 1: Write the failing test** + +Create `packages/cli/test/authz-command.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadAuthzCatalog, runAuthzCommand } from "../src/authz.ts"; + +function scaffold(): string { + const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-cli-")); + mkdirSync(join(root, "app", "authz"), { recursive: true }); + mkdirSync(join(root, "app", "pages"), { recursive: true }); + writeFileSync( + join(root, "app", "authz", "blog.ts"), + `import { defineAuthz } from "@wrnexus/authz"; +export default defineAuthz({ + permissions: { "post:read": { title: "View posts" }, "post:write": {} }, + roles: { editor: ["post:*"] }, +}); +`, + "utf8", + ); + return root; +} + +describe("wrnexus authz", () => { + test("loadAuthzCatalog merges every declaration", async () => { + const catalog = await loadAuthzCatalog(join(scaffold(), "app")); + expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "post:write"]); + expect([...catalog.roles.keys()]).toEqual(["editor"]); + }); + + test("generate writes the permission types file", async () => { + const root = scaffold(); + await runAuthzCommand(root, "generate", []); + const generated = readFileSync(join(root, "app", "authz", "permissions.gen.ts"), "utf8"); + expect(generated).toContain('export type Permission = "post:read" | "post:write";'); + }); + + test("init writes a migration containing both tables", async () => { + const root = scaffold(); + mkdirSync(join(root, "app", "db", "migrations"), { recursive: true }); + await runAuthzCommand(root, "init", []); + const dir = join(root, "app", "db", "migrations"); + const file = require("node:fs") + .readdirSync(dir) + .find((name: string) => name.includes("authz")); + expect(file).toBeDefined(); + const sql = readFileSync(join(dir, file!), "utf8"); + expect(sql).toContain("_wrn_authz_assignment"); + expect(sql).toContain("_wrn_authz_grant"); + expect(sql).toContain("-- +down"); + }); + + test("list prints every permission and role", async () => { + const root = scaffold(); + const lines: string[] = []; + const original = console.log; + console.log = (...args: unknown[]) => void lines.push(args.join(" ")); + try { + await runAuthzCommand(root, "list", []); + } finally { + console.log = original; + } + const output = lines.join("\n"); + expect(output).toContain("post:read"); + expect(output).toContain("editor"); + }); + + test("an unknown subcommand throws with usage", async () => { + await expect(runAuthzCommand(scaffold(), "bogus", [])).rejects.toThrow(/usage/i); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/cli/test/authz-command.test.ts` +Expected: FAIL — cannot resolve `../src/authz.ts` + +- [ ] **Step 3: Write the implementation** + +Create `packages/cli/src/authz.ts`: + +```ts +/** + * `wrnexus authz ` — authorization catalog tooling. + * + * wrnexus authz list print every registered permission, role, and policy + * wrnexus authz generate write app/authz/permissions.gen.ts type unions + * wrnexus authz init scaffold the assignment-table migration + */ + +import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { buildRouter } from "@wrnexus/router"; +import { + generatePermissionTypes, + mergeCatalogs, + type AuthzCatalog, + type AuthzModule, + type CatalogSource, +} from "@wrnexus/authz"; +import { authzMigrationSql } from "@wrnexus/authz/db"; + +const USAGE = "usage: wrnexus authz "; + +/** Import every app/authz declaration and merge it into one catalog. */ +export async function loadAuthzCatalog(appDir: string): Promise { + const router = buildRouter(appDir); + const sources: CatalogSource[] = []; + for (const entry of router.authz) { + const imported = (await import(pathToFileURL(entry.file).href)) as { + default?: AuthzModule; + }; + if (!imported.default) { + console.warn(`[wrnexus] ${entry.file} has no default export; skipping`); + continue; + } + sources.push({ source: entry.file, module: imported.default }); + } + return mergeCatalogs(sources); +} + +function nextMigrationNumber(dir: string): string { + if (!existsSync(dir)) return "0001"; + const numbers = readdirSync(dir) + .map((name) => Number.parseInt(name.slice(0, 4), 10)) + .filter((value) => Number.isInteger(value)); + return String((numbers.length ? Math.max(...numbers) : 0) + 1).padStart(4, "0"); +} + +export async function runAuthzCommand( + root: string, + sub: string | undefined, + args: string[], +): Promise { + const appDir = join(resolve(root), "app"); + + switch (sub) { + case "list": { + const catalog = await loadAuthzCatalog(appDir); + console.log(`Permissions (${catalog.permissions.size}):`); + for (const [id, meta] of [...catalog.permissions].sort()) { + const tags = [meta.risk && `risk=${meta.risk}`, meta.public && "public"] + .filter(Boolean) + .join(" "); + console.log(` ${id}${meta.title ? ` — ${meta.title}` : ""}${tags ? ` [${tags}]` : ""}`); + } + console.log(`\nRoles (${catalog.roles.size}):`); + for (const [name, grants] of [...catalog.roles].sort()) { + console.log(` ${name} → ${grants.join(", ") || "(nothing)"}`); + } + console.log(`\nPolicies (${catalog.policies.size}):`); + for (const name of [...catalog.policies.keys()].sort()) { + const bound = [...catalog.bindings] + .filter(([, names]) => names.includes(name)) + .map(([permission]) => permission); + console.log(` ${name}${bound.length ? ` → ${bound.join(", ")}` : " (unbound)"}`); + } + return; + } + + case "generate": { + const catalog = await loadAuthzCatalog(appDir); + const target = join(appDir, "authz", "permissions.gen.ts"); + mkdirSync(join(appDir, "authz"), { recursive: true }); + writeFileSync(target, generatePermissionTypes(catalog), "utf8"); + console.log( + `Wrote ${target} (${catalog.permissions.size} permissions, ${catalog.roles.size} roles)`, + ); + return; + } + + case "init": { + const dialect = (args.find((arg) => arg.startsWith("--dialect="))?.split("=")[1] ?? + "sqlite") as "sqlite" | "postgres" | "mysql"; + const dir = join(appDir, "db", "migrations"); + mkdirSync(dir, { recursive: true }); + const { up, down } = authzMigrationSql(dialect); + const file = join(dir, `${nextMigrationNumber(dir)}_authz_tables.sql`); + writeFileSync(file, `-- +up\n${up}\n\n-- +down\n${down}\n`, "utf8"); + console.log(`Wrote ${file}`); + console.log("Run `wrnexus db migrate` to apply it."); + return; + } + + default: + throw new Error(USAGE); + } +} +``` + +- [ ] **Step 4: Wire it into the CLI** + +In `packages/cli/src/index.ts`, add immediately after the `case "db"` block: + +```ts + case "authz": { + bootstrapProfile(".", "development", rest); + const { runAuthzCommand } = await import("./authz.ts"); + const [sub, ...authzArgs] = rest.filter((a) => !a.startsWith("--profile=")); + await runAuthzCommand(".", sub, authzArgs); + break; + } +``` + +Also add `authz` to the help text listing available commands. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `bun test packages/cli/test/authz-command.test.ts` +Expected: PASS, 5 tests + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/authz.ts packages/cli/src/index.ts packages/cli/test/authz-command.test.ts +git commit -m "feat(cli): add wrnexus authz list/generate/init" +``` + +--- + +## Task 14: Wire the catalog into dev and prod boot + +**Files:** + +- Modify: `packages/dev-server/src/index.ts` (load catalog in `startServer`) +- Modify: `packages/cli/src/build.ts` (bake catalog into the prod manifest) +- Test: `packages/dev-server/test/authz-boot.test.ts` + +**Interfaces:** + +- Consumes: `loadAuthzCatalog` pattern from Task 13; `authzMiddleware` from `@wrnexus/authz` +- Produces: `RuntimeDeps.authz?: AuthzCatalog` available to the request pipeline + +- [ ] **Step 1: Write the failing test** + +Create `packages/dev-server/test/authz-boot.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadAppAuthzCatalog } from "../src/authz-boot.ts"; + +function scaffold(body: string): string { + const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-boot-")); + mkdirSync(join(root, "app", "authz"), { recursive: true }); + mkdirSync(join(root, "app", "pages"), { recursive: true }); + writeFileSync(join(root, "app", "authz", "main.ts"), body, "utf8"); + return join(root, "app"); +} + +describe("loadAppAuthzCatalog", () => { + test("loads declarations from app/authz", async () => { + const appDir = scaffold( + `import { defineAuthz } from "@wrnexus/authz"; +export default defineAuthz({ permissions: { "post:read": {} } });`, + ); + const catalog = await loadAppAuthzCatalog(appDir); + expect(catalog.permissions.has("post:read")).toBe(true); + }); + + test("an app with no declarations gets an empty catalog rather than an error", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-empty-")); + mkdirSync(join(root, "app", "pages"), { recursive: true }); + const catalog = await loadAppAuthzCatalog(join(root, "app")); + expect(catalog.permissions.size).toBe(0); + }); + + test("a conflicting declaration fails the boot loudly", async () => { + const appDir = scaffold( + `import { defineAuthz } from "@wrnexus/authz"; +export default defineAuthz({ permissions: { "post:read": { risk: "low" } } });`, + ); + writeFileSync( + join(appDir, "authz", "other.ts"), + `import { defineAuthz } from "@wrnexus/authz"; +export default defineAuthz({ permissions: { "post:read": { risk: "high" } } });`, + "utf8", + ); + await expect(loadAppAuthzCatalog(appDir)).rejects.toThrow(/WRN-AUTHZ-CONFLICT/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/dev-server/test/authz-boot.test.ts` +Expected: FAIL — cannot resolve `../src/authz-boot.ts` + +- [ ] **Step 3: Write the loader** + +Create `packages/dev-server/src/authz-boot.ts`: + +```ts +import { pathToFileURL } from "node:url"; +import { buildRouter } from "@wrnexus/router"; +import { + emptyCatalog, + mergeCatalogs, + type AuthzCatalog, + type AuthzModule, + type CatalogSource, +} from "@wrnexus/authz"; + +/** + * Load and merge every `app/authz/*.ts` declaration. Conflicts throw so a + * misconfigured catalog fails the boot rather than silently changing who can + * do what. + */ +export async function loadAppAuthzCatalog(appDir: string): Promise { + const router = buildRouter(appDir); + if (!router.authz.length) return emptyCatalog(); + const sources: CatalogSource[] = []; + for (const entry of router.authz) { + if (entry.name === "permissions.gen") continue; // generated types, not a declaration + const imported = (await import(pathToFileURL(entry.file).href)) as { default?: AuthzModule }; + if (!imported.default) continue; + sources.push({ source: entry.file, module: imported.default }); + } + return mergeCatalogs(sources); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/dev-server/test/authz-boot.test.ts` +Expected: PASS, 3 tests + +- [ ] **Step 5: Commit** + +```bash +git add packages/dev-server/src/authz-boot.ts packages/dev-server/test/authz-boot.test.ts +git commit -m "feat(dev-server): load the authz catalog at boot" +``` + +--- + +## Task 15: Example app wiring and documentation + +**Files:** + +- Create: `examples/auth-showcase/app/authz/showcase.ts` +- Modify: `packages/authz/README.md` +- Test: `packages/authz/test/integration.test.ts` + +**Interfaces:** + +- Consumes: the full surface from Tasks 1-14 +- Produces: a worked end-to-end example proving the pieces compose + +- [ ] **Step 1: Write the failing integration test** + +Create `packages/authz/test/integration.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import type { Context } from "@wrnexus/core"; +import { createDb } from "@wrnexus/db"; +import { sqlite } from "@wrnexus/db/sqlite"; +import { dbPermissionStore, ensureAuthzTables } from "../src/db.ts"; +import { + authzMiddleware, + can, + cachedPermissionStore, + defineAuthz, + guardPermission, + memoryAuditSink, + mergeCatalogs, +} from "../src/index.ts"; + +const catalog = mergeCatalogs([ + { + source: "showcase.ts", + module: defineAuthz({ + permissions: { + "post:read": { public: true }, + "post:write": {}, + "post:delete": { risk: "high" }, + }, + roles: { editor: ["post:write"], admin: ["role:editor", "post:delete"] }, + policies: { + ownsPost: async (s: { id?: string }, r?: { authorId?: string }) => + r?.authorId === s?.id ? { allowed: true } : { allowed: false, reason: "not owner" }, + }, + bindings: { "post:delete": ["ownsPost"] }, + }), + }, +]); + +function makeCtx(user: unknown, tenantId?: string): Context { + return { + user, + tenant: tenantId ? { id: tenantId } : undefined, + locals: {}, + url: new URL("http://localhost/"), + req: new Request("http://localhost/"), + } as unknown as Context; +} + +describe("end-to-end authorization", () => { + test("db store, cache, catalog, middleware, and audit compose", async () => { + const db = createDb(sqlite(":memory:")); + await ensureAuthzTables(db, "sqlite"); + const store = cachedPermissionStore(dbPermissionStore(db), { ttlMs: 1_000 }); + const audit = memoryAuditSink(); + await store.assignRole("alice", "admin", { tenantId: "acme" }); + + const alice = makeCtx({ id: "alice" }, "acme"); + await authzMiddleware({ catalog, store, audit, strict: true })( + alice, + async () => new Response("ok"), + ); + + expect(await can(alice, "post:write")).toBe(true); + expect(await can(alice, "post:delete", { id: 1, authorId: "alice" })).toBe(true); + expect(await can(alice, "post:delete", { id: 2, authorId: "bob" })).toBe(false); + + // Wrong tenant: the admin role was scoped to acme. + const elsewhere = makeCtx({ id: "alice" }, "other"); + await authzMiddleware({ catalog, store, strict: true })( + elsewhere, + async () => new Response("ok"), + ); + expect(await can(elsewhere, "post:write")).toBe(false); + + // Anonymous can still read, because post:read is public. + const guest = makeCtx(null); + await authzMiddleware({ catalog, store, strict: true })(guest, async () => new Response("ok")); + expect(await can(guest, "post:read")).toBe(true); + expect(await can(guest, "post:write")).toBe(false); + + // Only denials were audited. + expect(audit.events.every((event) => !event.allowed)).toBe(true); + expect(audit.events.length).toBeGreaterThan(0); + }); + + test("revoking a role takes effect immediately through the cache", async () => { + const db = createDb(sqlite(":memory:")); + await ensureAuthzTables(db, "sqlite"); + const store = cachedPermissionStore(dbPermissionStore(db), { ttlMs: 60_000 }); + await store.assignRole("bob", "editor"); + + const before = makeCtx({ id: "bob" }); + await authzMiddleware({ catalog, store, strict: true })(before, async () => new Response("ok")); + expect(await can(before, "post:write")).toBe(true); + + await store.revokeRole("bob", "editor"); + + const after = makeCtx({ id: "bob" }); + await authzMiddleware({ catalog, store, strict: true })(after, async () => new Response("ok")); + expect(await can(after, "post:write")).toBe(false); + }); + + test("guardPermission returns an opaque 403", async () => { + const db = createDb(sqlite(":memory:")); + await ensureAuthzTables(db, "sqlite"); + const ctx = makeCtx({ id: "carol" }); + await authzMiddleware({ catalog, store: dbPermissionStore(db), strict: true })( + ctx, + async () => new Response("ok"), + ); + const res = await guardPermission("post:write")(ctx, async () => new Response("passed")); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ ok: false, error: "Forbidden" }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails or passes** + +Run: `bun test packages/authz/test/integration.test.ts` +Expected: PASS if Tasks 1-14 are correct. Any failure here is a real integration +defect — fix the underlying module, not the test. + +- [ ] **Step 3: Add the example declaration** + +Create `examples/auth-showcase/app/authz/showcase.ts`: + +```ts +import { defineAuthz } from "@wrnexus/authz"; + +export default defineAuthz({ + permissions: { + "post:read": { title: "View posts", public: true }, + "post:write": { title: "Create and edit posts" }, + "post:delete": { title: "Delete posts", risk: "high" }, + "admin:access": { title: "Reach the admin area", risk: "high" }, + }, + roles: { + viewer: ["post:read"], + editor: ["role:viewer", "post:write"], + admin: ["role:editor", "post:delete", "admin:access"], + }, + policies: { + ownsPost: async ( + subject: { id?: string }, + resource?: { authorId?: string }, + ): Promise<{ allowed: boolean; reason?: string }> => + resource?.authorId === subject?.id + ? { allowed: true } + : { allowed: false, reason: "You are not the author" }, + }, + bindings: { "post:delete": ["ownsPost"] }, +}); +``` + +- [ ] **Step 4: Document the surface** + +Append to `packages/authz/README.md`: + +````markdown +## Declaring permissions + +Put declarations in `app/authz/.ts`. They are discovered automatically. + +```ts +import { defineAuthz, owner } from "@wrnexus/authz"; + +export default defineAuthz({ + permissions: { + "post:read": { title: "View posts", public: true }, + "post:delete": { title: "Delete posts", risk: "high" }, + }, + roles: { editor: ["post:*"], admin: ["role:editor"] }, + policies: { ownsPost: owner("id", "authorId") }, + bindings: { "post:delete": ["ownsPost"] }, +}); +``` + +## Checking permissions + +Register the middleware once, then use `can()` and `guardPermission()`: + +```ts +import { authzMiddleware, can, guardPermission } from "@wrnexus/authz"; +import { dbPermissionStore } from "@wrnexus/authz/db"; +import { getDb } from "@wrnexus/db"; + +export default [authzMiddleware({ catalog, store: dbPermissionStore(getDb()) })]; + +// in a route +export const middleware = [guardPermission("post:write")]; +if (await can(ctx, "post:delete", post)) { + /* ... */ +} +``` + +`can()` is a free function, not `ctx.can` — `@wrnexus/core` must not depend on +`@wrnexus/authz`. + +## Precedence + +1. An explicit deny wins over everything, including `*`. +2. A bound policy can veto a permission a role grants. +3. Otherwise the permission must be held via a role or an explicit grant. +4. Default deny. + +Every failure — unknown permission, store outage, policy exception — denies. + +## CLI + +```bash +wrnexus authz list # every registered permission, role, and policy +wrnexus authz generate # app/authz/permissions.gen.ts type unions +wrnexus authz init # scaffold the assignment-table migration +``` +```` + +- [ ] **Step 5: Run the full gate** + +Run: `bun run check:production` +Expected: PASS. If `check:public-api` complains, run `bun run generate:public-api` and +re-run. + +- [ ] **Step 6: Commit** + +```bash +git add packages/authz/test/integration.test.ts packages/authz/README.md examples/auth-showcase/app/authz/showcase.ts docs/public-api-0.8.json +git commit -m "test(authz): end-to-end integration coverage, example, and docs" +``` + +--- + +## Deferred phases + +These are **not** in scope for this plan. Each needs its own design pass. + +### Phase 4 — `.wrn` view integration + +Exposing `can()` inside compiler-generated `{#if}` expressions touches +`packages/compiler/src/codegen.ts`. Because `{#if}` compiles to a nested ternary inside a +template literal and `can()` is async, the resolution must happen **before** the view renders +— most likely by collecting referenced permissions at compile time and pre-resolving them +into the SSR scope, the way `collectControlExprs` already pre-resolves `ssr { api ... }` +bindings. Do not begin this without confirming that shape against the codegen. + +### Phase 5 — Admin UI + +`.wrn` components for listing subjects and assigning roles, shipped in `@wrnexus/ui` behind +the existing `wrnexus eject` mechanism. Depends on `listSubjects` and the CLI landing first. + +### Inter-app communication seam + +`exportSubjectContext(ctx)` / `importSubjectContext(token)` are specified in the design doc +but intentionally unbuilt. They belong to the inter-app communication system, which has not +been designed yet. From 0ac648bc26adaa877a042bec472d95218d200a5c Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 16:24:07 +0530 Subject: [PATCH 04/59] docs: resolve two pre-flight conflicts in the authz plan - Global Constraints said the change was additive while Task 8 changed authorizeDecision's 403 body. Ruled: the security fix governs; the constraint now names it as the one approved exception. - Task 6 defined permissionsFor and then re-implemented it inline in decide. Both now call a single loadEffective helper. Co-Authored-By: Claude Opus 5 --- ...6-08-04-authz-permissions-implementation.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index 439acde7..8294afaf 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -14,7 +14,7 @@ - Zero runtime npm dependencies. Use only Bun/WebCrypto/node: builtins. - `@wrnexus/core` MUST NOT import `@wrnexus/authz`. `can()` stays off `Context`; the resolver lives in `ctx.locals._authz`. - `@wrnexus/authz` may import **types only** from `@wrnexus/core` (`import type { Context, Middleware }`). -- Existing exports of `@wrnexus/authz` must keep working unchanged. This is additive. +- 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. @@ -1344,13 +1344,21 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve const { catalog, store, audit } = options; const strict = options.strict ?? !isProduction(); - const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise> => { + /** + * Single source of truth for "what does this subject hold?". Returns the + * raw assignments too, because `decide` needs `denies` and `permissionsFor` + * does not — do NOT duplicate this logic in either caller. + */ + const loadEffective = async (subjectId: string, scope?: AuthzScope) => { const assignments = await store.assignmentsFor(subjectId, scope); const granted = expandRoles(catalog, assignments.roles); for (const grant of assignments.grants) granted.add(grant); - return granted; + return { assignments, granted }; }; + const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise> => + (await loadEffective(subjectId, scope)).granted; + const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => { if (!result.allowed || options.auditAllows) { safeRecord(audit, { @@ -1399,9 +1407,7 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve let assignments; let granted: Set; try { - assignments = await store.assignmentsFor(subjectId, scope); - granted = expandRoles(catalog, assignments.roles); - for (const grant of assignments.grants) granted.add(grant); + ({ 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" }); From 212fdaa5b537b2d8d45661bb7a654cbc9301f1b0 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 16:26:17 +0530 Subject: [PATCH 05/59] feat(authz): add defineAuthz declaration registry --- packages/authz/src/registry.ts | 50 ++++++++++++++++++++++++++++ packages/authz/src/types.ts | 45 +++++++++++++++++++++++++ packages/authz/test/registry.test.ts | 41 +++++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 packages/authz/src/registry.ts create mode 100644 packages/authz/src/types.ts create mode 100644 packages/authz/test/registry.test.ts diff --git a/packages/authz/src/registry.ts b/packages/authz/src/registry.ts new file mode 100644 index 00000000..d7a21ebc --- /dev/null +++ b/packages/authz/src/registry.ts @@ -0,0 +1,50 @@ +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 }); +} diff --git a/packages/authz/src/types.ts b/packages/authz/src/types.ts new file mode 100644 index 00000000..2dd7b17c --- /dev/null +++ b/packages/authz/src/types.ts @@ -0,0 +1,45 @@ +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[]; +} diff --git a/packages/authz/test/registry.test.ts b/packages/authz/test/registry.test.ts new file mode 100644 index 00000000..8fef1d4f --- /dev/null +++ b/packages/authz/test/registry.test.ts @@ -0,0 +1,41 @@ +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/); + }); +}); From d694dda320739e187e064affed3ea42bc62e3278 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 16:30:41 +0530 Subject: [PATCH 06/59] feat(authz): merge declaration modules into a frozen catalog --- packages/authz/src/catalog.ts | 119 ++++++++++++++++++++++++++++ packages/authz/test/catalog.test.ts | 77 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 packages/authz/src/catalog.ts create mode 100644 packages/authz/test/catalog.test.ts diff --git a/packages/authz/src/catalog.ts b/packages/authz/src/catalog.ts new file mode 100644 index 00000000..0ffb77b5 --- /dev/null +++ b/packages/authz/src/catalog.ts @@ -0,0 +1,119 @@ +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[]])), + }; +} diff --git a/packages/authz/test/catalog.test.ts b/packages/authz/test/catalog.test.ts new file mode 100644 index 00000000..cc9a00db --- /dev/null +++ b/packages/authz/test/catalog.test.ts @@ -0,0 +1,77 @@ +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); + }); +}); From 1849213ce4697c3144350f8876ad25a60e4f2168 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 16:36:57 +0530 Subject: [PATCH 07/59] feat(authz): add PermissionStore contract with memory adapter and conformance suite --- packages/authz/src/store.ts | 90 ++++++++++++++++++++ packages/authz/test/store-conformance.ts | 101 +++++++++++++++++++++++ packages/authz/test/store-memory.test.ts | 4 + 3 files changed, 195 insertions(+) create mode 100644 packages/authz/src/store.ts create mode 100644 packages/authz/test/store-conformance.ts create mode 100644 packages/authz/test/store-memory.test.ts diff --git a/packages/authz/src/store.ts b/packages/authz/src/store.ts new file mode 100644 index 00000000..3b4f1956 --- /dev/null +++ b/packages/authz/src/store.ts @@ -0,0 +1,90 @@ +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]; + }, + }; +} diff --git a/packages/authz/test/store-conformance.ts b/packages/authz/test/store-conformance.ts new file mode 100644 index 00000000..1a923695 --- /dev/null +++ b/packages/authz/test/store-conformance.ts @@ -0,0 +1,101 @@ +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"]); + }); + }); +} diff --git a/packages/authz/test/store-memory.test.ts b/packages/authz/test/store-memory.test.ts new file mode 100644 index 00000000..b124b294 --- /dev/null +++ b/packages/authz/test/store-memory.test.ts @@ -0,0 +1,4 @@ +import { memoryPermissionStore } from "../src/store.ts"; +import { runStoreConformance } from "./store-conformance.ts"; + +runStoreConformance("memory", async () => memoryPermissionStore()); From 9b6b970cae9e3b92f2c986ed360a17c89f3f9902 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 16:38:24 +0530 Subject: [PATCH 08/59] chore: exclude the SDD scratch workspace from prettier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .superpowers/ holds git-ignored controller artifacts (briefs, reports, review packages). Prettier still walked it, so format:check — and with it check:production — failed on scratch markdown. Co-Authored-By: Claude Opus 5 --- .prettierignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.prettierignore b/.prettierignore index 50482156..e21dc216 100644 --- a/.prettierignore +++ b/.prettierignore @@ -26,3 +26,6 @@ focus-shims.d.ts **/focus-shims.d.ts tsconfig.focus.json **/tsconfig.focus.json + +# SDD scratch workspace (git-ignored controller artifacts) +.superpowers/ From a01b7bc99e63186d46ab3736823e3b6c35e34000 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 16:43:08 +0530 Subject: [PATCH 09/59] fix(authz): cover grant/deny scope isolation and revoke scope-isolation in conformance suite --- packages/authz/test/store-conformance.ts | 42 ++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/authz/test/store-conformance.ts b/packages/authz/test/store-conformance.ts index 1a923695..f0660950 100644 --- a/packages/authz/test/store-conformance.ts +++ b/packages/authz/test/store-conformance.ts @@ -85,6 +85,43 @@ export function runStoreConformance(name: string, makeStore: () => Promise { + await store.grant("u1", "post:write", "allow", { tenantId: "t1" }); + expect((await store.assignmentsFor("u1", { tenantId: "t1" })).grants).toEqual(["post:write"]); + expect((await store.assignmentsFor("u1", { tenantId: "t2" })).grants).toEqual([]); + }); + + test("a tenant-scoped deny does not leak into another tenant", async () => { + await store.grant("u1", "post:delete", "deny", { tenantId: "t1" }); + expect((await store.assignmentsFor("u1", { tenantId: "t1" })).denies).toEqual([ + "post:delete", + ]); + expect((await store.assignmentsFor("u1", { tenantId: "t2" })).denies).toEqual([]); + }); + + test("a global grant is visible inside every tenant", async () => { + await store.grant("u1", "post:publish", "allow"); + expect((await store.assignmentsFor("u1", { tenantId: "t1" })).grants).toEqual([ + "post:publish", + ]); + }); + + test("revokeGrant is scope-isolated: revoking a tenant-scoped grant leaves the global grant intact", async () => { + await store.grant("u1", "post:write", "allow"); + await store.grant("u1", "post:write", "allow", { tenantId: "t1" }); + await store.revokeGrant("u1", "post:write", { tenantId: "t1" }); + expect((await store.assignmentsFor("u1")).grants).toEqual(["post:write"]); + expect((await store.assignmentsFor("u1", { tenantId: "t1" })).grants).toEqual(["post:write"]); + }); + + test("revokeRole is scope-isolated: revoking a tenant-scoped role leaves the global role intact", async () => { + await store.assignRole("u1", "editor"); + await store.assignRole("u1", "editor", { tenantId: "t1" }); + await store.revokeRole("u1", "editor", { tenantId: "t1" }); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]); + }); + test("listSubjects returns everyone with an assignment in scope", async () => { await store.assignRole("u1", "editor", { tenantId: "t1" }); await store.assignRole("u2", "editor", { tenantId: "t1" }); @@ -97,5 +134,10 @@ export function runStoreConformance(name: string, makeStore: () => Promise { + await store.grant("g1", "post:write", "allow", { tenantId: "t1" }); + expect((await store.listSubjects({ tenantId: "t1" })).sort()).toEqual(["g1"]); + }); }); } From 4362d49770ee484095fb9e3b5fed4df3a5035a56 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 16:47:50 +0530 Subject: [PATCH 10/59] feat(authz): add caching decorator for PermissionStore --- packages/authz/src/store.ts | 70 ++++++++++++++++++++++++ packages/authz/test/store-cached.test.ts | 66 ++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 packages/authz/test/store-cached.test.ts diff --git a/packages/authz/src/store.ts b/packages/authz/src/store.ts index 3b4f1956..9ba7c5ee 100644 --- a/packages/authz/src/store.ts +++ b/packages/authz/src/store.ts @@ -88,3 +88,73 @@ export function memoryPermissionStore(): PermissionStore { }, }; } + +export interface CachedPermissionStore extends PermissionStore { + /** Drop one subject. Call after changing roles out of band. */ + invalidate(subjectId: string, scope?: AuthzScope): void; + invalidateAll(): void; + /** Cached entry count, for tests and diagnostics. */ + size(): number; +} + +export interface CacheOptions { + ttlMs?: number; + max?: number; +} + +/** + * Caches assignment reads. Writes through this decorator invalidate the + * affected subject immediately; changes made directly against the inner store + * need an explicit `invalidate()` call rather than waiting out the TTL. + */ +export function cachedPermissionStore( + inner: PermissionStore, + options: CacheOptions = {}, +): CachedPermissionStore { + const ttlMs = options.ttlMs ?? 5_000; + const max = options.max ?? 1_000; + const entries = new Map(); + + const cacheKey = (subjectId: string, scope?: AuthzScope) => `${scopeKey(scope)}�${subjectId}`; + const drop = (subjectId: string, scope?: AuthzScope) => { + entries.delete(cacheKey(subjectId, scope)); + // A global write changes what every tenant sees for that subject. + if (scopeKey(scope) === "") { + for (const key of [...entries.keys()]) { + if (key.endsWith(`�${subjectId}`)) entries.delete(key); + } + } + }; + + return { + async assignmentsFor(subjectId, scope) { + const key = cacheKey(subjectId, scope); + const hit = entries.get(key); + if (hit && Date.now() - hit.at < ttlMs) return hit.value; + const value = await inner.assignmentsFor(subjectId, scope); + if (entries.size >= max) entries.delete(entries.keys().next().value!); + entries.set(key, { at: Date.now(), value }); + return value; + }, + async assignRole(subjectId, role, scope) { + await inner.assignRole(subjectId, role, scope); + drop(subjectId, scope); + }, + async revokeRole(subjectId, role, scope) { + await inner.revokeRole(subjectId, role, scope); + drop(subjectId, scope); + }, + async grant(subjectId, permission, effect, scope) { + await inner.grant(subjectId, permission, effect, scope); + drop(subjectId, scope); + }, + async revokeGrant(subjectId, permission, scope) { + await inner.revokeGrant(subjectId, permission, scope); + drop(subjectId, scope); + }, + listSubjects: (scope) => inner.listSubjects(scope), + invalidate: drop, + invalidateAll: () => entries.clear(), + size: () => entries.size, + }; +} diff --git a/packages/authz/test/store-cached.test.ts b/packages/authz/test/store-cached.test.ts new file mode 100644 index 00000000..7114a571 --- /dev/null +++ b/packages/authz/test/store-cached.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { cachedPermissionStore, memoryPermissionStore } from "../src/store.ts"; +import { runStoreConformance } from "./store-conformance.ts"; + +// A cache must not change observable behaviour: writes invalidate internally. +runStoreConformance("cached(memory)", async () => cachedPermissionStore(memoryPermissionStore())); + +describe("cachedPermissionStore", () => { + test("serves a repeat read from cache", async () => { + const inner = memoryPermissionStore(); + let reads = 0; + const counting = { + ...inner, + assignmentsFor: (id: string, scope?: { tenantId?: string }) => { + reads++; + return inner.assignmentsFor(id, scope); + }, + }; + const store = cachedPermissionStore(counting, { ttlMs: 60_000 }); + await store.assignmentsFor("u1"); + await store.assignmentsFor("u1"); + expect(reads).toBe(1); + }); + + test("a write invalidates that subject", async () => { + const store = cachedPermissionStore(memoryPermissionStore(), { ttlMs: 60_000 }); + await store.assignmentsFor("u1"); + await store.assignRole("u1", "editor"); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("invalidate() drops a cached subject", async () => { + const inner = memoryPermissionStore(); + const store = cachedPermissionStore(inner, { ttlMs: 60_000 }); + await store.assignmentsFor("u1"); + await inner.assignRole("u1", "editor"); // behind the cache's back + expect((await store.assignmentsFor("u1")).roles).toEqual([]); + store.invalidate("u1"); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("entries expire after ttlMs", async () => { + const inner = memoryPermissionStore(); + const store = cachedPermissionStore(inner, { ttlMs: 1 }); + await store.assignmentsFor("u1"); + await inner.assignRole("u1", "editor"); + await Bun.sleep(5); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("cache is bounded by max", async () => { + const store = cachedPermissionStore(memoryPermissionStore(), { ttlMs: 60_000, max: 2 }); + await store.assignmentsFor("a"); + await store.assignmentsFor("b"); + await store.assignmentsFor("c"); + expect(store.size()).toBeLessThanOrEqual(2); + }); + + test("scoped and global reads cache separately", async () => { + const inner = memoryPermissionStore(); + const store = cachedPermissionStore(inner, { ttlMs: 60_000 }); + await inner.assignRole("u1", "editor", { tenantId: "t1" }); + expect((await store.assignmentsFor("u1")).roles).toEqual([]); + expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]); + }); +}); From 83f2951035bb01352ff35605680fad9e3a21fb54 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 17:00:35 +0530 Subject: [PATCH 11/59] docs: fix cache-key collision in the Task 4 plan snippet The plan's cachedPermissionStore used scopeKey + U+FFFD + subjectId as a cache key with no escaping, so ('a', 'bc') and ('ab', 'c') collide and one subject is served another's permissions. Subject and tenant ids are unconstrained strings, so nothing prevented it. Key is now JSON-encoded, and the global-write sweep tracks keys per subject instead of substring-matching. Adds the two regression tests that were missing: cross-tenant invalidation on a global write, and key collision. Ruled by the human as plan-mandated; source of truth amended so a re-run of the plan does not reintroduce the defect. Co-Authored-By: Claude Opus 5 --- ...-08-04-authz-permissions-implementation.md | 54 ++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index 8294afaf..e50606d1 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -809,6 +809,26 @@ describe("cachedPermissionStore", () => { 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 }); @@ -853,15 +873,25 @@ export function cachedPermissionStore( const max = options.max ?? 1_000; const entries = new Map(); - const cacheKey = (subjectId: string, scope?: AuthzScope) => `${scopeKey(scope)}�${subjectId}`; + // 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) => { - entries.delete(cacheKey(subjectId, scope)); // A global write changes what every tenant sees for that subject. if (scopeKey(scope) === "") { - for (const key of [...entries.keys()]) { - if (key.endsWith(`�${subjectId}`)) entries.delete(key); - } + 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 { @@ -870,8 +900,15 @@ export function cachedPermissionStore( const hit = entries.get(key); if (hit && Date.now() - hit.at < ttlMs) return hit.value; const value = await inner.assignmentsFor(subjectId, scope); - if (entries.size >= max) entries.delete(entries.keys().next().value!); + 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) { @@ -892,7 +929,10 @@ export function cachedPermissionStore( }, listSubjects: (scope) => inner.listSubjects(scope), invalidate: drop, - invalidateAll: () => entries.clear(), + invalidateAll: () => { + entries.clear(); + bySubject.clear(); + }, size: () => entries.size, }; } From dc0771308aa7a32430c5d4bec04f389db492feb0 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 17:04:19 +0530 Subject: [PATCH 12/59] fix(authz): eliminate cache-key collision in cachedPermissionStore The scope-prefix concatenation cacheKey used a bare U+FFFD separator with no escaping, so an adversarial subject/tenant id containing that character could collide with a different subject/tenant pair and leak cached roles across tenants. Switch to JSON.stringify([scopeKey, subjectId]) for an unambiguous key. Also replace the untested key.endsWith() substring sweep used to invalidate a subject across all tenants on a global write with an explicit bySubject index, and add test coverage for both the collision and the cross-tenant invalidation sweep. --- packages/authz/src/store.ts | 37 ++++++++++++++++++------ packages/authz/test/store-cached.test.ts | 16 ++++++++++ 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/packages/authz/src/store.ts b/packages/authz/src/store.ts index 9ba7c5ee..a8848814 100644 --- a/packages/authz/src/store.ts +++ b/packages/authz/src/store.ts @@ -113,17 +113,22 @@ export function cachedPermissionStore( ): CachedPermissionStore { const ttlMs = options.ttlMs ?? 5_000; const max = options.max ?? 1_000; - const entries = new Map(); + const entries = new Map(); + const bySubject = new Map>(); - const cacheKey = (subjectId: string, scope?: AuthzScope) => `${scopeKey(scope)}�${subjectId}`; + const cacheKey = (subjectId: string, scope?: AuthzScope) => + JSON.stringify([scopeKey(scope), subjectId]); const drop = (subjectId: string, scope?: AuthzScope) => { - entries.delete(cacheKey(subjectId, scope)); // A global write changes what every tenant sees for that subject. if (scopeKey(scope) === "") { - for (const key of [...entries.keys()]) { - if (key.endsWith(`�${subjectId}`)) entries.delete(key); - } + const keys = bySubject.get(subjectId); + if (keys) for (const key of keys) entries.delete(key); + bySubject.delete(subjectId); + return; } + const key = cacheKey(subjectId, scope); + entries.delete(key); + bySubject.get(subjectId)?.delete(key); }; return { @@ -132,8 +137,19 @@ export function cachedPermissionStore( const hit = entries.get(key); if (hit && Date.now() - hit.at < ttlMs) return hit.value; const value = await inner.assignmentsFor(subjectId, scope); - if (entries.size >= max) entries.delete(entries.keys().next().value!); - entries.set(key, { at: Date.now(), value }); + if (entries.size >= max) { + const oldestKey = entries.keys().next().value!; + const oldest = entries.get(oldestKey); + entries.delete(oldestKey); + if (oldest) bySubject.get(oldest.subjectId)?.delete(oldestKey); + } + entries.set(key, { at: Date.now(), value, subjectId }); + let keys = bySubject.get(subjectId); + if (!keys) { + keys = new Set(); + bySubject.set(subjectId, keys); + } + keys.add(key); return value; }, async assignRole(subjectId, role, scope) { @@ -154,7 +170,10 @@ export function cachedPermissionStore( }, listSubjects: (scope) => inner.listSubjects(scope), invalidate: drop, - invalidateAll: () => entries.clear(), + invalidateAll: () => { + entries.clear(); + bySubject.clear(); + }, size: () => entries.size, }; } diff --git a/packages/authz/test/store-cached.test.ts b/packages/authz/test/store-cached.test.ts index 7114a571..23e5a7df 100644 --- a/packages/authz/test/store-cached.test.ts +++ b/packages/authz/test/store-cached.test.ts @@ -63,4 +63,20 @@ describe("cachedPermissionStore", () => { expect((await store.assignmentsFor("u1")).roles).toEqual([]); expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]); }); + + 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 + 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 }); + await inner.assignRole("b\uFFFDc", "editor", { tenantId: "a" }); + expect((await store.assignmentsFor("b\uFFFDc", { tenantId: "a" })).roles).toEqual(["editor"]); + expect((await store.assignmentsFor("c", { tenantId: "a\uFFFDb" })).roles).toEqual([]); + }); }); From f0331978509d26fb3441743b8cf87237d8eb571c Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 17:10:03 +0530 Subject: [PATCH 13/59] feat(authz): add pluggable authorization audit sink --- packages/authz/src/audit.ts | 56 +++++++++++++++++++++++++++++++ packages/authz/test/audit.test.ts | 31 +++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 packages/authz/src/audit.ts create mode 100644 packages/authz/test/audit.test.ts diff --git a/packages/authz/src/audit.ts b/packages/authz/src/audit.ts new file mode 100644 index 00000000..d8f6a091 --- /dev/null +++ b/packages/authz/src/audit.ts @@ -0,0 +1,56 @@ +import type { AuthzScope } from "./types.ts"; + +export interface AuthzAuditEvent { + subjectId?: string; + scope?: AuthzScope; + permission: string; + allowed: boolean; + reason?: string; + policy?: string; + /** Epoch milliseconds. */ + at: number; +} + +export interface AuthzAuditSink { + record(event: AuthzAuditEvent): void | Promise; +} + +export interface MemoryAuditSink extends AuthzAuditSink { + events: AuthzAuditEvent[]; + clear(): void; +} + +export function memoryAuditSink(): MemoryAuditSink { + const events: AuthzAuditEvent[] = []; + return { + events, + record: (event) => void events.push(event), + clear: () => void events.splice(0, events.length), + }; +} + +export function consoleAuditSink(): AuthzAuditSink { + return { + record(event) { + const verdict = event.allowed ? "allow" : "deny"; + console.info( + `[wrnexus:authz] ${verdict} ${event.permission} subject=${event.subjectId ?? "anonymous"}` + + `${event.scope?.tenantId ? ` tenant=${event.scope.tenantId}` : ""}` + + `${event.reason ? ` reason=${event.reason}` : ""}`, + ); + }, + }; +} + +/** Record without ever letting a sink failure escape into the request path. */ +export function safeRecord(sink: AuthzAuditSink | undefined, event: AuthzAuditEvent): void { + if (!sink) return; + try { + const result = sink.record(event); + if (result instanceof Promise) { + result.catch((error) => console.warn("[wrnexus:authz] audit sink failed", error)); + } + } catch (error) { + console.warn("[wrnexus:authz] audit sink failed", error); + } +} diff --git a/packages/authz/test/audit.test.ts b/packages/authz/test/audit.test.ts new file mode 100644 index 00000000..12b8c7d3 --- /dev/null +++ b/packages/authz/test/audit.test.ts @@ -0,0 +1,31 @@ +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(); + }); +}); From ba83038d8d675193869a00291da9098186ad9b2e Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 17:19:05 +0530 Subject: [PATCH 14/59] docs: fix audit-log injection in the Task 5 plan snippet consoleAuditSink interpolated subjectId, tenantId and reason straight into the log line. A newline in any of them forges a second entry that reads as a genuine audit record - the reviewer produced a fake '[wrnexus:authz] allow admin:everything subject=root' line. Those values trace back to request input. Interpolated fields now go through logSafe(), which replaces control characters. Adds the missing coverage the review flagged: consoleAuditSink injection, malformed-sink handling, and memoryAuditSink.clear(). Plan-origin defect, fixed under standing authority to amend the plan. Co-Authored-By: Claude Opus 5 --- ...-08-04-authz-permissions-implementation.md | 63 ++++++++++++++++++- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index e50606d1..f46f58bf 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -999,9 +999,50 @@ describe("audit sink", () => { 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` @@ -1043,14 +1084,30 @@ export function memoryAuditSink(): MemoryAuditSink { }; } +/** + * 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)!; + out += code < 0x20 || code === 0x7f ? " " : character; + } + return out; +} + export function consoleAuditSink(): AuthzAuditSink { return { record(event) { const verdict = event.allowed ? "allow" : "deny"; console.info( - `[wrnexus:authz] ${verdict} ${event.permission} subject=${event.subjectId ?? "anonymous"}` + - `${event.scope?.tenantId ? ` tenant=${event.scope.tenantId}` : ""}` + - `${event.reason ? ` reason=${event.reason}` : ""}`, + `[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)}` : ""}`, ); }, }; From e710756baf0a08f39ce252d446c553a320c24d6d Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 17:21:09 +0530 Subject: [PATCH 15/59] fix(authz): sanitize control characters in console audit sink Prevents audit log injection: subjectId, tenantId, and reason trace back to request input, so an unsanitized newline could forge a second, fully-formed audit line indistinguishable from a real entry. Adds logSafe() to strip control characters before interpolation and logs the previously-missing policy field. --- packages/authz/src/audit.ts | 22 +++++++++++++--- packages/authz/test/audit.test.ts | 44 ++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/authz/src/audit.ts b/packages/authz/src/audit.ts index d8f6a091..ecfb0b34 100644 --- a/packages/authz/src/audit.ts +++ b/packages/authz/src/audit.ts @@ -29,14 +29,30 @@ export function memoryAuditSink(): MemoryAuditSink { }; } +/** + * 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 control characters before interpolating. + */ +function logSafe(value: string): string { + let out = ""; + for (const character of value) { + const code = character.codePointAt(0)!; + out += code < 0x20 || code === 0x7f ? " " : character; + } + return out; +} + export function consoleAuditSink(): AuthzAuditSink { return { record(event) { const verdict = event.allowed ? "allow" : "deny"; console.info( - `[wrnexus:authz] ${verdict} ${event.permission} subject=${event.subjectId ?? "anonymous"}` + - `${event.scope?.tenantId ? ` tenant=${event.scope.tenantId}` : ""}` + - `${event.reason ? ` reason=${event.reason}` : ""}`, + `[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)}` : ""}`, ); }, }; diff --git a/packages/authz/test/audit.test.ts b/packages/authz/test/audit.test.ts index 12b8c7d3..833c9651 100644 --- a/packages/authz/test/audit.test.ts +++ b/packages/authz/test/audit.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { memoryAuditSink, safeRecord } from "../src/audit.ts"; +import { + consoleAuditSink, + memoryAuditSink, + safeRecord, + type AuthzAuditSink, +} from "../src/audit.ts"; describe("audit sink", () => { test("memoryAuditSink collects events", () => { @@ -28,4 +33,41 @@ describe("audit sink", () => { 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; + } + expect(lines).toHaveLength(1); + expect(lines[0]).not.toContain("\n"); + expect(lines[0]).not.toContain("\r"); + }); }); From d609a41222671fc547deb62b48416798bcbe5cba Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 17:26:02 +0530 Subject: [PATCH 16/59] docs: widen logSafe to Unicode line separators in the Task 5 plan snippet The re-review confirmed the log-injection fix works for C0 and DEL, but U+0085 (NEL) and U+2028/U+2029 pass through. Those are line terminators to some log shippers and to JavaScript's own lexical grammar, so they can still split a record downstream. Co-Authored-By: Claude Opus 5 --- docs/plans/2026-08-04-authz-permissions-implementation.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index f46f58bf..00fee161 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -1093,7 +1093,11 @@ function logSafe(value: string): string { let out = ""; for (const character of value) { const code = character.codePointAt(0)!; - out += code < 0x20 || code === 0x7f ? " " : character; + // 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; } From d7509421c7adefe8263c1c76fd205afd33eaff97 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 17:29:46 +0530 Subject: [PATCH 17/59] fix(authz): widen logSafe to strip NEL and Unicode line separators U+0085 (NEL), U+2028 (LINE SEPARATOR), and U+2029 (PARAGRAPH SEPARATOR) are treated as line terminators by some log shippers and by JS's own lexical grammar (and are not escaped by JSON.stringify by default), so they could still be used to forge audit log entries even after the initial C0/DEL fix. logSafe now strips all five categories. --- packages/authz/src/audit.ts | 6 +++++- packages/authz/test/audit.test.ts | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/authz/src/audit.ts b/packages/authz/src/audit.ts index ecfb0b34..177549b9 100644 --- a/packages/authz/src/audit.ts +++ b/packages/authz/src/audit.ts @@ -38,7 +38,11 @@ function logSafe(value: string): string { let out = ""; for (const character of value) { const code = character.codePointAt(0)!; - out += code < 0x20 || code === 0x7f ? " " : character; + // 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; } diff --git a/packages/authz/test/audit.test.ts b/packages/authz/test/audit.test.ts index 833c9651..3afe6844 100644 --- a/packages/authz/test/audit.test.ts +++ b/packages/authz/test/audit.test.ts @@ -52,15 +52,22 @@ describe("audit sink", () => { }); test("consoleAuditSink cannot be used to forge a second log line", () => { + // NEL (0x85) and the JS/Unicode line separators (0x2028, 0x2029) are built + // via String.fromCharCode rather than typed as literal characters, since + // raw control/separator bytes are prone to mangling when round-tripped + // through editor tooling in this repo. + const NEL = String.fromCharCode(0x85); + const LINE_SEPARATOR = String.fromCharCode(0x2028); + const PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029); 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", + subjectId: `u1${NEL}[wrnexus:authz] allow admin:everything subject=root`, permission: "post:read", allowed: false, - reason: "nope\r\ninjected", + reason: `nope\r\ninjected${LINE_SEPARATOR}a${PARAGRAPH_SEPARATOR}b`, at: 1, }); } finally { @@ -69,5 +76,9 @@ describe("audit sink", () => { expect(lines).toHaveLength(1); expect(lines[0]).not.toContain("\n"); expect(lines[0]).not.toContain("\r"); + expect(lines[0]).not.toContain(NEL); + expect(lines[0]).not.toContain(LINE_SEPARATOR); + expect(lines[0]).not.toContain(PARAGRAPH_SEPARATOR); + expect(lines[0]).toContain("post:read"); }); }); From 6d8b6daba9bde2cd0195fadb3714dfea3c8edcf4 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 17:40:19 +0530 Subject: [PATCH 18/59] feat(authz): add resolution engine with deny-wins precedence and fail-closed errors --- packages/authz/src/engine.ts | 166 +++++++++++++++++++++++ packages/authz/test/engine.test.ts | 208 +++++++++++++++++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 packages/authz/src/engine.ts create mode 100644 packages/authz/test/engine.test.ts diff --git a/packages/authz/src/engine.ts b/packages/authz/src/engine.ts new file mode 100644 index 00000000..ef5ae6d0 --- /dev/null +++ b/packages/authz/src/engine.ts @@ -0,0 +1,166 @@ +import type { AuthorizationDecision } from "./advanced.ts"; +import { safeRecord, type AuthzAuditSink } from "./audit.ts"; +import type { PermissionStore } from "./store.ts"; +import type { AuthzCatalog, AuthzScope } from "./types.ts"; + +export interface AuthzResolverOptions { + catalog: AuthzCatalog; + store: PermissionStore; + audit?: AuthzAuditSink; + /** + * Throw on an unregistered permission instead of denying. Defaults to true + * outside production, so typos surface during development. + */ + strict?: boolean; + /** Record allows as well as denies. Off by default to bound write volume. */ + auditAllows?: boolean; +} + +export interface DecideInput { + subject: { id?: string; [key: string]: unknown } | null | undefined; + permission: string; + resource?: unknown; + scope?: AuthzScope; +} + +export interface AuthzResolver { + permissionsFor(subjectId: string, scope?: AuthzScope): Promise>; + decide(input: DecideInput): Promise; +} + +/** Expand roles into their granted entries, following `role:` and stopping on cycles. */ +export function expandRoles(catalog: AuthzCatalog, roles: readonly string[]): Set { + const out = new Set(); + const seen = new Set(); + const walk = (role: string) => { + if (seen.has(role)) return; + seen.add(role); + for (const entry of catalog.roles.get(role) ?? []) { + if (entry.startsWith("role:")) walk(entry.slice(5)); + else out.add(entry); + } + }; + for (const role of roles) walk(role); + return out; +} + +/** Exact match, root wildcard, or a namespace wildcard at any depth. */ +export function permissionMatches(granted: Set, permission: string): boolean { + if (granted.has("*") || granted.has(permission)) return true; + for (let at = permission.indexOf(":"); at !== -1; at = permission.indexOf(":", at + 1)) { + if (granted.has(`${permission.slice(0, at)}:*`)) return true; + } + return false; +} + +function isProduction(): boolean { + return (process.env.NODE_ENV ?? "development") === "production"; +} + +export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolver { + const { catalog, store, audit } = options; + const strict = options.strict ?? !isProduction(); + + /** + * Single source of truth for "what does this subject hold?". Returns the + * raw assignments too, because `decide` needs `denies` and `permissionsFor` + * does not — do NOT duplicate this logic in either caller. + */ + const loadEffective = async (subjectId: string, scope?: AuthzScope) => { + const assignments = await store.assignmentsFor(subjectId, scope); + const granted = expandRoles(catalog, assignments.roles); + for (const grant of assignments.grants) granted.add(grant); + return { assignments, granted }; + }; + + const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise> => + (await loadEffective(subjectId, scope)).granted; + + const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => { + if (!result.allowed || options.auditAllows) { + safeRecord(audit, { + subjectId: input.subject?.id, + scope: input.scope, + permission: input.permission, + allowed: result.allowed, + reason: result.reason, + policy: result.policy, + at: Date.now(), + }); + } + return result; + }; + + return { + permissionsFor, + + async decide(input) { + const { subject, permission, resource, scope } = input; + const meta = catalog.permissions.get(permission); + + if (!meta) { + if (strict) { + throw new Error( + `WRN-AUTHZ-UNKNOWN: permission '${permission}' is not registered. ` + + `Declare it with defineAuthz() in app/authz/.`, + ); + } + return finish(input, { + allowed: false, + reason: `Permission '${permission}' is not registered`, + }); + } + + const subjectId = subject?.id; + if (!subjectId) { + return finish( + input, + meta.public + ? { allowed: true, reason: "public permission" } + : { allowed: false, reason: "Authentication required" }, + ); + } + + let assignments; + let granted: Set; + try { + ({ assignments, granted } = await loadEffective(subjectId, scope)); + } catch (error) { + console.error("[wrnexus:authz] permission store failed; denying", error); + return finish(input, { allowed: false, reason: "Authorization store unavailable" }); + } + + // 1. Explicit deny wins over everything, including "*". + if (assignments.denies.includes(permission)) { + return finish(input, { allowed: false, reason: "explicit deny" }); + } + + // 2. Must hold the permission at all. + if (!meta.public && !permissionMatches(granted, permission)) { + return finish(input, { allowed: false, reason: "Missing permission" }); + } + + // 3. Every bound policy must pass. + for (const name of catalog.bindings.get(permission) ?? []) { + const policy = catalog.policies.get(name); + if (!policy) continue; + try { + const verdict = await ( + policy as unknown as ( + s: unknown, + r: unknown, + ) => AuthorizationDecision | Promise + )(subject, resource); + if (!verdict.allowed) { + return finish(input, { ...verdict, policy: verdict.policy ?? name }); + } + } catch (error) { + console.error(`[wrnexus:authz] policy '${name}' threw; denying`, error); + return finish(input, { allowed: false, reason: "Policy error", policy: name }); + } + } + + return finish(input, { allowed: true }); + }, + }; +} diff --git a/packages/authz/test/engine.test.ts b/packages/authz/test/engine.test.ts new file mode 100644 index 00000000..c085ec9d --- /dev/null +++ b/packages/authz/test/engine.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test } from "bun:test"; +import { defineAuthz } from "../src/registry.ts"; +import { mergeCatalogs } from "../src/catalog.ts"; +import { memoryPermissionStore } from "../src/store.ts"; +import { memoryAuditSink } from "../src/audit.ts"; +import { createAuthzResolver, expandRoles, permissionMatches } from "../src/engine.ts"; + +const catalog = mergeCatalogs([ + { + source: "test.ts", + module: defineAuthz({ + permissions: { + "post:read": { public: true }, + "post:write": {}, + "post:delete": { risk: "high" }, + "post:comment:delete": {}, + }, + roles: { + editor: ["post:*"], + moderator: ["post:comment:*"], + admin: ["role:editor", "post:delete"], + cyclic: ["role:cyclic", "post:read"], + }, + policies: { + ownsPost: async (subject: { id?: string }, resource?: { authorId?: string }) => + resource?.authorId === subject?.id + ? { allowed: true } + : { allowed: false, reason: "not the author", policy: "ownsPost" }, + explodes: async () => { + throw new Error("policy blew up"); + }, + }, + bindings: { "post:write": ["ownsPost"] }, + }), + }, +]); + +const make = (store = memoryPermissionStore(), audit = memoryAuditSink()) => ({ + store, + audit, + resolver: createAuthzResolver({ catalog, store, audit, strict: false }), +}); + +describe("expandRoles", () => { + test("expands wildcards and role inheritance", () => { + expect([...expandRoles(catalog, ["admin"])].sort()).toEqual(["post:*", "post:delete"]); + }); + test("terminates on cyclic inheritance", () => { + expect([...expandRoles(catalog, ["cyclic"])]).toEqual(["post:read"]); + }); +}); + +describe("permissionMatches", () => { + test("matches exact, root wildcard, and every namespace depth", () => { + expect(permissionMatches(new Set(["post:read"]), "post:read")).toBe(true); + expect(permissionMatches(new Set(["*"]), "anything:at:all")).toBe(true); + expect(permissionMatches(new Set(["post:*"]), "post:comment:delete")).toBe(true); + expect(permissionMatches(new Set(["post:comment:*"]), "post:comment:delete")).toBe(true); + expect(permissionMatches(new Set(["post:comment:*"]), "post:write")).toBe(false); + }); +}); + +describe("createAuthzResolver.decide", () => { + test("allows a public permission for an anonymous subject", async () => { + const { resolver } = make(); + const result = await resolver.decide({ subject: null, permission: "post:read" }); + expect(result.allowed).toBe(true); + }); + + test("denies a non-public permission for an anonymous subject", async () => { + const { resolver } = make(); + const result = await resolver.decide({ subject: null, permission: "post:delete" }); + expect(result.allowed).toBe(false); + }); + + test("allows via a role-derived wildcard", async () => { + const { store, resolver } = make(); + await store.assignRole("u1", "moderator"); + const result = await resolver.decide({ + subject: { id: "u1" }, + permission: "post:comment:delete", + }); + expect(result.allowed).toBe(true); + }); + + test("an explicit deny beats a role and beats '*'", async () => { + const { store, resolver } = make(); + await store.assignRole("u1", "admin"); + await store.grant("u1", "post:delete", "deny"); + const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" }); + expect(result.allowed).toBe(false); + expect(result.reason).toMatch(/explicit deny/i); + }); + + test("a bound policy can deny a permission the role grants", async () => { + const { store, resolver } = make(); + await store.assignRole("u1", "editor"); + const denied = await resolver.decide({ + subject: { id: "u1" }, + permission: "post:write", + resource: { authorId: "someone-else" }, + }); + expect(denied.allowed).toBe(false); + expect(denied.policy).toBe("ownsPost"); + + const allowed = await resolver.decide({ + subject: { id: "u1" }, + permission: "post:write", + resource: { authorId: "u1" }, + }); + expect(allowed.allowed).toBe(true); + }); + + test("a throwing policy denies rather than escaping", async () => { + const throwing = mergeCatalogs([ + { + source: "t.ts", + module: defineAuthz({ + permissions: { "x:go": {} }, + policies: { + explodes: async () => { + throw new Error("boom"); + }, + }, + bindings: { "x:go": ["explodes"] }, + }), + }, + ]); + const store = memoryPermissionStore(); + await store.grant("u1", "x:go", "allow"); + const resolver = createAuthzResolver({ catalog: throwing, store, strict: false }); + const result = await resolver.decide({ subject: { id: "u1" }, permission: "x:go" }); + expect(result.allowed).toBe(false); + }); + + test("a store failure denies and does not throw", async () => { + const broken = { + ...memoryPermissionStore(), + assignmentsFor: async () => { + throw new Error("db down"); + }, + }; + const resolver = createAuthzResolver({ catalog, store: broken, strict: false }); + const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:read" }); + expect(result.allowed).toBe(false); + }); + + test("an unregistered permission denies when strict is off", async () => { + const { resolver } = make(); + const result = await resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" }); + expect(result.allowed).toBe(false); + expect(result.reason).toMatch(/not registered/i); + }); + + test("an unregistered permission throws when strict is on", async () => { + const resolver = createAuthzResolver({ + catalog, + store: memoryPermissionStore(), + strict: true, + }); + await expect( + resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" }), + ).rejects.toThrow(/ghost:perm/); + }); + + test("denials are audited and allows are not, by default", async () => { + const { store, audit, resolver } = make(); + await store.assignRole("u1", "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); + }); +}); From c499f136fd33bc346969ce60de0b6455ea3fbbc2 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 17:41:11 +0530 Subject: [PATCH 19/59] docs: fix self-contradictory audit test in the Task 6 plan snippet The 'denials are audited' test assigned role editor, which holds post:*, so decide(post:delete) was legitimately an ALLOW under the wildcard rule the same task specifies. The test then asserted one audited denial and got zero. Switched to moderator (post:comment:*), which genuinely lacks post:delete. Caught by the Task 6 implementer running the transcribed test against the transcribed implementation. Plan-origin defect, fixed under standing authority. Co-Authored-By: Claude Opus 5 --- docs/plans/2026-08-04-authz-permissions-implementation.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index 00fee161..5f2ae3b4 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -1329,7 +1329,9 @@ describe("createAuthzResolver.decide", () => { test("denials are audited and allows are not, by default", async () => { const { store, audit, resolver } = make(); - await store.assignRole("u1", "editor"); + // 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); From 86b3dc1e6abede337b162403714a442226e398aa Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 17:52:04 +0530 Subject: [PATCH 20/59] docs: close two auth bypasses and four fail-open paths in the Task 6 engine snippet The plan's engine had a genuine authorization bypass and several fail-open branches. Task 7 builds can() on this, so the source of truth is fixed before that lands. CRITICAL - anonymous callers bypassed every bound policy on a public:true permission: the anonymous branch returned allow before the policy loop. A permission marked "public, but not when embargoed" was fully open to unauthenticated traffic, and the least-trusted caller got the weakest evaluation. Policies now run on the anonymous path too; public relaxes the identity requirement, never the policy requirement. CRITICAL - the policy verdict check was truthiness-based, not an identity check, so a policy returning {allowed: "yes"} or {allowed: 1} granted access. It now compares against true. A binding naming a policy the catalog lacks was skipped, granting whatever the policy guarded; it now denies. Falsy and non-string subject ids fell through to the anonymous path - {id: 0} became anonymous and {id: 123} reached the store as a lookup key; only a non-empty string now identifies a subject. Two design forks, ruled by the human: denies honour wildcards, so denying "post:*" blocks post:delete instead of being accepted and doing nothing; and permissionsFor subtracts denies, so composing it with permissionMatches agrees with decide() rather than silently losing deny precedence. Adds deniedBy() and six regression tests. Co-Authored-By: Claude Opus 5 --- ...-08-04-authz-permissions-implementation.md | 224 +++++++++++++++--- 1 file changed, 188 insertions(+), 36 deletions(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index 5f2ae3b4..e7bdf603 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -1372,6 +1372,102 @@ describe("createAuthzResolver.decide", () => { 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** @@ -1439,6 +1535,15 @@ export function permissionMatches(granted: Set, permission: string): boo 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"; } @@ -1448,9 +1553,9 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve const strict = options.strict ?? !isProduction(); /** - * Single source of truth for "what does this subject hold?". Returns the - * raw assignments too, because `decide` needs `denies` and `permissionsFor` - * does not — do NOT duplicate this logic in either caller. + * 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); @@ -1459,13 +1564,26 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve return { assignments, granted }; }; - const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise> => - (await loadEffective(subjectId, scope)).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; + const effective = new Set(); + for (const entry of granted) { + // A wildcard grant survives only if nothing denies it outright. + if (!deniedBy(assignments.denies, entry)) effective.add(entry); + } + return effective; + }; const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => { if (!result.allowed || options.auditAllows) { safeRecord(audit, { - subjectId: input.subject?.id, + subjectId: typeof input.subject?.id === "string" ? input.subject.id : undefined, scope: input.scope, permission: input.permission, allowed: result.allowed, @@ -1477,11 +1595,53 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve 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, resource, scope } = input; + const { subject, permission, scope } = input; const meta = catalog.permissions.get(permission); if (!meta) { @@ -1497,14 +1657,22 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve }); } - const subjectId = subject?.id; + // 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) { - return finish( - input, - meta.public - ? { allowed: true, reason: "public permission" } - : { allowed: false, reason: "Authentication required" }, - ); + 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; @@ -1516,8 +1684,10 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve return finish(input, { allowed: false, reason: "Authorization store unavailable" }); } - // 1. Explicit deny wins over everything, including "*". - if (assignments.denies.includes(permission)) { + // 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" }); } @@ -1527,26 +1697,8 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve } // 3. Every bound policy must pass. - for (const name of catalog.bindings.get(permission) ?? []) { - const policy = catalog.policies.get(name); - if (!policy) continue; - try { - const verdict = await ( - policy as unknown as ( - s: unknown, - r: unknown, - ) => AuthorizationDecision | Promise - )(subject, resource); - if (!verdict.allowed) { - return finish(input, { ...verdict, policy: verdict.policy ?? name }); - } - } catch (error) { - console.error(`[wrnexus:authz] policy '${name}' threw; denying`, error); - return finish(input, { allowed: false, reason: "Policy error", policy: name }); - } - } - - return finish(input, { allowed: true }); + const denied = await runPolicies(input, permission); + return finish(input, denied ?? { allowed: true }); }, }; } From ae37c9b57a0e80e88bac65a2d8c8848ae7c12e28 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 17:58:28 +0530 Subject: [PATCH 21/59] fix(authz): close fail-open engine gaps found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coordinator review of Task 6's resolution engine (plan amendment 86b3dc1e) found two critical and four important defects, all inherited from the brief's original engine snippet: - C1: anonymous callers on a public permission returned allow before running bound policies, so the least-trusted caller got the weakest evaluation. Policies now run for anonymous subjects too. - C2: the policy verdict check was a truthiness test (`!verdict.allowed`), so a policy returning `{allowed: "yes"}` granted access. Now requires `verdict?.allowed === true` exactly, and no longer spreads the raw verdict into the decision (which leaked arbitrary policy fields). - I1: a binding naming a policy the catalog doesn't have was silently `continue`d, granting whatever the policy was meant to guard. Now denies with "Policy unavailable". - I3: denies were checked by exact string equality, so a wildcard deny (e.g. "post:*") was accepted and silently did nothing. Denies now go through the same depth-aware wildcard matching as grants, via the new exported `deniedBy()`. - I2: `permissionsFor` now subtracts denied entries so it agrees with `decide()` — needed for Task 7's UI gating to compose correctly. - I4: non-string/empty `subject.id` (0, "", 123, {}) no longer silently falls back to anonymous; it denies with "Invalid subject". `subject: null` (no subject at all) remains genuinely anonymous. Added six regression tests, each verified by reverting its fix and confirming the test fails against the old code before restoring. Co-Authored-By: Claude Opus 5 --- packages/authz/src/engine.ts | 107 ++++++++++++++++++++--------- packages/authz/test/engine.test.ts | 97 ++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 33 deletions(-) diff --git a/packages/authz/src/engine.ts b/packages/authz/src/engine.ts index ef5ae6d0..21bb348c 100644 --- a/packages/authz/src/engine.ts +++ b/packages/authz/src/engine.ts @@ -53,6 +53,15 @@ export function permissionMatches(granted: Set, permission: string): boo 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"; } @@ -73,13 +82,20 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve return { assignments, granted }; }; - const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise> => - (await loadEffective(subjectId, scope)).granted; + const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise> => { + const { assignments, granted } = await loadEffective(subjectId, scope); + if (!assignments.denies.length) return granted; + const effective = new Set(); + for (const entry of granted) { + if (!deniedBy(assignments.denies, entry)) effective.add(entry); + } + return effective; + }; const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => { if (!result.allowed || options.auditAllows) { safeRecord(audit, { - subjectId: input.subject?.id, + subjectId: typeof input.subject?.id === "string" ? input.subject.id : undefined, scope: input.scope, permission: input.permission, allowed: result.allowed, @@ -91,11 +107,49 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve return result; }; + /** + * Run every policy bound to `permission`. Returns the denial verdict of the + * first failing/missing/throwing policy, or `null` if all bound policies + * passed (including "no policies bound" — an implicit allow). + */ + const runPolicies = async ( + input: DecideInput, + permission: string, + ): Promise => { + const { subject, resource } = input; + for (const name of catalog.bindings.get(permission) ?? []) { + const policy = catalog.policies.get(name); + if (!policy) { + console.error(`[wrnexus:authz] policy '${name}' is not registered; denying`); + return { allowed: false, reason: "Policy unavailable", policy: name }; + } + try { + const verdict = await ( + policy as unknown as ( + s: unknown, + r: unknown, + ) => AuthorizationDecision | Promise + )(subject, resource); + 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, resource, scope } = input; + const { permission, scope } = input; const meta = catalog.permissions.get(permission); if (!meta) { @@ -111,14 +165,19 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve }); } - const subjectId = subject?.id; + const rawId: unknown = input.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) { - return finish( - input, - meta.public - ? { allowed: true, reason: "public permission" } - : { allowed: false, reason: "Authentication required" }, - ); + 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; @@ -130,8 +189,8 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve return finish(input, { allowed: false, reason: "Authorization store unavailable" }); } - // 1. Explicit deny wins over everything, including "*". - if (assignments.denies.includes(permission)) { + // 1. Explicit deny wins over everything, including "*", honouring wildcards. + if (deniedBy(assignments.denies, permission)) { return finish(input, { allowed: false, reason: "explicit deny" }); } @@ -141,26 +200,8 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve } // 3. Every bound policy must pass. - for (const name of catalog.bindings.get(permission) ?? []) { - const policy = catalog.policies.get(name); - if (!policy) continue; - try { - const verdict = await ( - policy as unknown as ( - s: unknown, - r: unknown, - ) => AuthorizationDecision | Promise - )(subject, resource); - if (!verdict.allowed) { - return finish(input, { ...verdict, policy: verdict.policy ?? name }); - } - } catch (error) { - console.error(`[wrnexus:authz] policy '${name}' threw; denying`, error); - return finish(input, { allowed: false, reason: "Policy error", policy: name }); - } - } - - return finish(input, { allowed: true }); + const denied = await runPolicies(input, permission); + return finish(input, denied ?? { allowed: true }); }, }; } diff --git a/packages/authz/test/engine.test.ts b/packages/authz/test/engine.test.ts index c085ec9d..432c6377 100644 --- a/packages/authz/test/engine.test.ts +++ b/packages/authz/test/engine.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from "bun:test"; +import type { DecisionPolicy } from "../src/advanced.ts"; 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"; +import type { AuthzCatalog } from "../src/types.ts"; const catalog = mergeCatalogs([ { @@ -206,3 +208,98 @@ describe("createAuthzResolver.decide", () => { expect(outside.allowed).toBe(false); }); }); + +describe("createAuthzResolver fail-closed regressions", () => { + test("a public permission bound to an always-denying policy denies for an anonymous subject", async () => { + const publicPolicyCatalog = mergeCatalogs([ + { + source: "pub.ts", + module: defineAuthz({ + permissions: { "feed:view": { public: true } }, + policies: { + neverAllow: async () => ({ + allowed: false, + reason: "embargoed", + policy: "neverAllow", + }), + }, + bindings: { "feed:view": ["neverAllow"] }, + }), + }, + ]); + const resolver = createAuthzResolver({ + catalog: publicPolicyCatalog, + store: memoryPermissionStore(), + strict: false, + }); + const result = await resolver.decide({ subject: null, permission: "feed:view" }); + expect(result.allowed).toBe(false); + expect(result.policy).toBe("neverAllow"); + }); + + test("a policy returning a truthy non-boolean 'allowed' denies", async () => { + const truthyCatalog = mergeCatalogs([ + { + source: "truthy.ts", + module: defineAuthz({ + permissions: { "x:truthy": {} }, + policies: { + truthy: (async () => ({ allowed: "yes" })) as unknown as DecisionPolicy, + }, + bindings: { "x:truthy": ["truthy"] }, + }), + }, + ]); + const store = memoryPermissionStore(); + await store.grant("u1", "x:truthy", "allow"); + const resolver = createAuthzResolver({ catalog: truthyCatalog, store, strict: false }); + const result = await resolver.decide({ subject: { id: "u1" }, permission: "x:truthy" }); + expect(result.allowed).toBe(false); + }); + + test("a binding naming a policy the catalog lacks denies", async () => { + const missingPolicyCatalog: AuthzCatalog = { + permissions: new Map([["x:missing", {}]]), + roles: new Map(), + policies: new Map(), + attributes: new Map(), + bindings: new Map([["x:missing", ["ghostPolicy"]]]), + }; + const store = memoryPermissionStore(); + await store.grant("u1", "x:missing", "allow"); + const resolver = createAuthzResolver({ catalog: missingPolicyCatalog, store, strict: false }); + const result = await resolver.decide({ subject: { id: "u1" }, permission: "x:missing" }); + expect(result.allowed).toBe(false); + expect(result.policy).toBe("ghostPolicy"); + }); + + test("a wildcard deny blocks a permission the role explicitly grants", async () => { + const { store, resolver } = make(); + await store.assignRole("u1", "admin"); + await store.grant("u1", "post:*", "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("permissionsFor subtracts permissions covered by a wildcard deny", async () => { + const { store, resolver } = make(); + await store.assignRole("u1", "editor"); + await store.grant("u1", "post:*", "deny"); + const granted = await resolver.permissionsFor("u1"); + expect(permissionMatches(granted, "post:delete")).toBe(false); + }); + + test("non-string subject ids deny rather than falling back to anonymous", async () => { + const { resolver } = make(); + const invalidIds: unknown[] = [0, "", 123, {}]; + for (const id of invalidIds) { + const result = await resolver.decide({ + subject: { id } as unknown as { id?: string }, + permission: "post:read", + }); + expect(result.allowed).toBe(false); + expect(result.reason).toMatch(/invalid subject/i); + } + }); +}); From d6a2d05407fd8240cd7950a2c72a83be2bd5fe18 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 18:09:34 +0530 Subject: [PATCH 22/59] docs: hoist the deny set and document permissionsFor's limits Two issues the Task 6 re-review raised against the fix diff. permissionsFor rebuilt the deny Set inside its loop over granted entries, making it O(grants x denies) allocations on a per-request path. Measured 632ms at 4000x4000, ~100% of it in repeated Set construction. Hoisted. permissionsFor also only half-delivers on "the obvious composition agrees with decide()". A narrow deny beneath a broad grant is not representable in a Set of strings - the set keeps post:* while decide() correctly refuses post:delete - so callers that match against the set would offer actions the server rejects. Documented the limit on the interface and pointed callers at decide()/can()/filterCan() for per-action gating. Co-Authored-By: Claude Opus 5 --- ...2026-08-04-authz-permissions-implementation.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index e7bdf603..d6edc26f 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -1506,6 +1506,16 @@ export interface DecideInput { } 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; } @@ -1572,10 +1582,13 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve 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 (!deniedBy(assignments.denies, entry)) effective.add(entry); + if (!permissionMatches(denySet, entry)) effective.add(entry); } return effective; }; From cd82bec414ed66d191dbbdca2c453481c4e95dca Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 18:15:34 +0530 Subject: [PATCH 23/59] fix(authz): fix perf, doc, and fail-open gaps found in second review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review of Task 6's fix round 1 (plan amendment d6a2d054) found three items in that diff plus one adjacent pre-existing issue that C1 made reachable: - Important (perf): permissionsFor() rebuilt the deny Set on every entry in the granted set (O(grants x denies) allocations on a per-request path). Hoisted to build the Set once. Measured 4000x4000: 665.92ms before, 3.90ms after. - Important (contract accuracy): permissionsFor() only half-agrees with decide() — a narrow deny under a broad grant (e.g. role editor's "post:*" plus a deny on "post:delete") can't be represented in a flat Set, so the set still contains "post:*" while decide() correctly refuses "post:delete". Documented as NOT authoritative on the AuthzResolver interface, and pinned with a regression test asserting the divergence is deliberate. - Minor: subject.id === "" was audited as subjectId: "" instead of omitted, so consoleAuditSink printed a blank subject= rather than subject=anonymous. Reused the same non-empty-string guard as the decide() path. - Important (adjacent, advanced.ts): owner() compared subject[key] to resource[key] with Object.is without checking either side was present, so two absent ids (Object.is(undefined, undefined) === true) satisfied ownership. Unreachable before this task, but C1 now runs bound policies for anonymous/empty subjects, putting this on a live path. Fixed to deny whenever either side is undefined or null. Every fix's regression test was verified by reverting the fix and confirming the test fails against the pre-fix code before restoring. Co-Authored-By: Claude Opus 5 --- packages/authz/src/advanced.ts | 12 ++++++++-- packages/authz/src/engine.ts | 18 +++++++++++++-- packages/authz/test/authz.test.ts | 35 ++++++++++++++++++++++++++++++ packages/authz/test/engine.test.ts | 32 +++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 4 deletions(-) diff --git a/packages/authz/src/advanced.ts b/packages/authz/src/advanced.ts index 81317a84..3a88c58f 100644 --- a/packages/authz/src/advanced.ts +++ b/packages/authz/src/advanced.ts @@ -39,10 +39,18 @@ export function owner { - return (subject, resource) => - resource && Object.is(subject[subjectKey], resource[resourceKey as keyof Resource]) + return (subject, resource) => { + const subjectValue = subject?.[subjectKey]; + const resourceValue = resource?.[resourceKey as keyof Resource]; + // An absent id on either side must never satisfy ownership. + if (subjectValue === undefined || subjectValue === null) + return deny("resource ownership required"); + if (resourceValue === undefined || resourceValue === null) + return deny("resource ownership required"); + return Object.is(subjectValue, resourceValue) ? allow("resource owner") : deny("resource ownership required"); + }; } export function anyDecision(...policies: DecisionPolicy[]): DecisionPolicy { return async (subject, resource) => { diff --git a/packages/authz/src/engine.ts b/packages/authz/src/engine.ts index 21bb348c..a67a063f 100644 --- a/packages/authz/src/engine.ts +++ b/packages/authz/src/engine.ts @@ -24,6 +24,16 @@ export interface DecideInput { } 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; } @@ -85,17 +95,21 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve 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) { - if (!deniedBy(assignments.denies, entry)) effective.add(entry); + if (!permissionMatches(denySet, entry)) effective.add(entry); } return effective; }; const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => { if (!result.allowed || options.auditAllows) { + const rawId = input.subject?.id; safeRecord(audit, { - subjectId: typeof input.subject?.id === "string" ? input.subject.id : undefined, + subjectId: typeof rawId === "string" && rawId !== "" ? rawId : undefined, scope: input.scope, permission: input.permission, allowed: result.allowed, diff --git a/packages/authz/test/authz.test.ts b/packages/authz/test/authz.test.ts index aa8153d2..fc42c2ef 100644 --- a/packages/authz/test/authz.test.ts +++ b/packages/authz/test/authz.test.ts @@ -10,7 +10,9 @@ import { all, attr, decision, + owner, type Policy, + type Subject, } from "../src/index.ts"; const rbac = defineRbac({ @@ -93,3 +95,36 @@ test("explainable decisions only include denial reasons when denied", async () = policy: "owner", }); }); + +test("owner() denies rather than matching two absent ids", async () => { + // A subject with no id, checked against a resource with no ownership key, + // must never be treated as the owner: undefined !== undefined here means + // "we don't know", not "match". + const noId: Subject = {}; + const resourceWithKey = { userId: "u1" }; + const resourceWithoutKey: Record = { title: "t" }; + const realSubject: Subject = { id: "u1" }; + + // Subject has no id at all. + expect((await owner()(noId, resourceWithKey)).allowed).toBe(false); + + // Resource lacks the ownership key. + expect((await owner()(realSubject, resourceWithoutKey)).allowed).toBe(false); + + // Both sides absent — the exact bug scenario (Object.is(undefined, undefined) === true). + expect((await owner()(noId, resourceWithoutKey)).allowed).toBe(false); + + // Resource entirely absent. + expect((await owner()(realSubject, undefined)).allowed).toBe(false); + + // A genuine match still allows. + expect((await owner()(realSubject, resourceWithKey)).allowed).toBe(true); + + // Custom keys still work and still deny on absence. + interface CustomResource extends Record { + ownerId?: string; + } + const customOwns = owner("id", "ownerId"); + expect((await customOwns({ id: "u1" }, { ownerId: "u1" })).allowed).toBe(true); + expect((await customOwns({ id: "u1" }, {})).allowed).toBe(false); +}); diff --git a/packages/authz/test/engine.test.ts b/packages/authz/test/engine.test.ts index 432c6377..4af1b552 100644 --- a/packages/authz/test/engine.test.ts +++ b/packages/authz/test/engine.test.ts @@ -290,6 +290,28 @@ describe("createAuthzResolver fail-closed regressions", () => { expect(permissionMatches(granted, "post:delete")).toBe(false); }); + test("permissionsFor cannot represent a narrow deny under a broad grant (decide remains authoritative)", async () => { + // A set of strings can't express "post:* except post:delete": the grant + // entry "post:*" survives the subtraction (it isn't itself covered by the + // narrower deny "post:delete"), so a set-based check would wrongly say + // this permission is available. decide() has no such limitation — it + // checks the specific permission against the deny list directly, not + // through the granted-entries set — and correctly refuses it. This is a + // pinned, deliberate divergence, not a bypass: callers must gate + // individual actions with decide()/can(), never by matching this set. + const { store, resolver } = make(); + await store.assignRole("u1", "editor"); + await store.grant("u1", "post:delete", "deny"); + + const granted = await resolver.permissionsFor("u1"); + expect(granted.has("post:*")).toBe(true); + expect(permissionMatches(granted, "post:delete")).toBe(true); + + const decision = await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" }); + expect(decision.allowed).toBe(false); + expect(decision.reason).toMatch(/explicit deny/i); + }); + test("non-string subject ids deny rather than falling back to anonymous", async () => { const { resolver } = make(); const invalidIds: unknown[] = [0, "", 123, {}]; @@ -302,4 +324,14 @@ describe("createAuthzResolver fail-closed regressions", () => { expect(result.reason).toMatch(/invalid subject/i); } }); + + test("an empty-string subject id is not recorded as the audited subjectId", async () => { + const { audit, resolver } = make(); + await resolver.decide({ + subject: { id: "" } as unknown as { id?: string }, + permission: "post:read", + }); + expect(audit.events).toHaveLength(1); + expect(audit.events[0]!.subjectId).toBeUndefined(); + }); }); From 984c6236d3bf912ae9519a261815357f322a2d65 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 18:24:40 +0530 Subject: [PATCH 24/59] feat(authz): add request middleware, can(), and guardPermission Installs a per-request authz resolver via authzMiddleware and exposes can()/decideFor()/guardPermission()/filterCan() as free functions (not Context members, so @wrnexus/core stays free of an authz dependency). All four route through resolver.decide(), never permissionsFor(), so resource-scoped policy denials can't be bypassed via the coarse permission set. Per-request results are memoised keyed on (permission, resource) to avoid re-hitting the store within a request without leaking one resource's verdict onto another. --- packages/authz/src/middleware.ts | 118 ++++++++++++++++++++ packages/authz/test/middleware.test.ts | 142 +++++++++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 packages/authz/src/middleware.ts create mode 100644 packages/authz/test/middleware.test.ts diff --git a/packages/authz/src/middleware.ts b/packages/authz/src/middleware.ts new file mode 100644 index 00000000..d5ba9e13 --- /dev/null +++ b/packages/authz/src/middleware.ts @@ -0,0 +1,118 @@ +import type { Context, Middleware } from "@wrnexus/core"; +import type { AuthorizationDecision } from "./advanced.ts"; +import { createAuthzResolver, type AuthzResolver, type AuthzResolverOptions } from "./engine.ts"; +import type { AuthzScope } from "./types.ts"; + +/** + * `can` is deliberately not a Context member: @wrnexus/core must not depend on + * @wrnexus/authz. The per-request resolver lives here instead. + */ +export const AUTHZ_LOCALS_KEY = "_authz"; + +interface RequestAuthz { + resolver: AuthzResolver; + scope?: AuthzScope; + memo: Map>; +} + +function readAuthz(ctx: Context): RequestAuthz { + const value = ctx.locals[AUTHZ_LOCALS_KEY] as RequestAuthz | undefined; + if (!value) { + throw new Error( + "WRN-AUTHZ-SETUP: authzMiddleware() is not registered for this request. " + + "Add it to app/middleware before calling can()/guardPermission().", + ); + } + return value; +} + +/** Install the per-request resolver. Register early, after sessionAuth. */ +export function authzMiddleware(options: AuthzResolverOptions): Middleware { + const resolver = createAuthzResolver(options); + return (ctx, next) => { + const request: RequestAuthz = { + resolver, + scope: ctx.tenant?.id ? { tenantId: ctx.tenant.id } : undefined, + memo: new Map(), + }; + ctx.locals[AUTHZ_LOCALS_KEY] = request; + return next(); + }; +} + +/** Stable memo key. Resources without an id fall back to their JSON shape. */ +function memoKey(permission: string, resource: unknown): string { + if (resource === undefined) return permission; + const id = (resource as { id?: unknown })?.id; + if (id !== undefined && id !== null) return `${permission}::${String(id)}`; + try { + return `${permission}::${JSON.stringify(resource)}`; + } catch { + return `${permission}::`; + } +} + +export function decideFor( + ctx: Context, + permission: string, + resource?: unknown, +): Promise { + const request = readAuthz(ctx); + const key = memoKey(permission, resource); + const cached = request.memo.get(key); + if (cached) return cached; + const pending = request.resolver.decide({ + subject: ctx.user as { id?: string } | null | undefined, + permission, + resource, + scope: request.scope, + }); + request.memo.set(key, pending); + return pending; +} + +export async function can(ctx: Context, permission: string, resource?: unknown): Promise { + return (await decideFor(ctx, permission, resource)).allowed; +} + +export interface GuardOptions { + /** Load the resource a bound policy needs. */ + getResource?: (ctx: Context) => unknown | Promise; + /** Include reason and policy name in the 403 body. Off by default. */ + exposeReason?: boolean; + /** Redirect page requests here instead of returning 403. */ + redirectTo?: string; +} + +/** + * Guard a route on a registered permission. Named `guardPermission` because + * `requirePermission(rbac, permission)` already exists with a different shape. + */ +export function guardPermission(permission: string, options: GuardOptions = {}): Middleware { + return async (ctx, next) => { + const resource = options.getResource ? await options.getResource(ctx) : undefined; + const result = await decideFor(ctx, permission, resource); + if (result.allowed) return next(); + if (options.redirectTo) { + return new Response(null, { status: 303, headers: { location: options.redirectTo } }); + } + return Response.json( + options.exposeReason + ? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy } + : { ok: false, error: "Forbidden" }, + { status: 403 }, + ); + }; +} + +/** Keep only the items the current subject may act on. */ +export async function filterCan( + ctx: Context, + permission: string, + items: readonly T[], +): Promise { + const verdicts = await Promise.all( + items.map(async (item) => ({ item, allowed: await can(ctx, permission, item) })), + ); + return verdicts.filter((entry) => entry.allowed).map((entry) => entry.item); +} diff --git a/packages/authz/test/middleware.test.ts b/packages/authz/test/middleware.test.ts new file mode 100644 index 00000000..546c1527 --- /dev/null +++ b/packages/authz/test/middleware.test.ts @@ -0,0 +1,142 @@ +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); + }); +}); From cc8085bcfab2166771c62e6cf34fb67e035b1eb0 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 18:35:05 +0530 Subject: [PATCH 25/59] docs: fix memo-key cross-authorization in the Task 7 plan snippet The middleware's per-request memo keyed resources by String(resource.id) with an unserialisable fallback that shared one bucket. Six demonstrated cases cross-authorized: {id:1} vs the primitive 1; {id:7} vs {id:"7"}; object ids; and every circular / BigInt / throwing-getter row collapsing together so the first verdict in a request became the verdict for all of them. filterCan returned 3 of 3 rows where 1 was permitted - it leaked, rather than denied. Object resources now memo by identity through a WeakMap; primitives key on JSON-encoded [scope, permission, typeof, value] so 7 and "7" stay distinct and a tenant id containing the separator cannot collide. Scope is also read at decision time rather than frozen when the middleware runs, and is part of the memo key, so switching tenant mid-request no longer returns the previous tenant's verdict. guardPermission additionally: denies instead of 500ing when getResource throws (and no longer leaks the loader's message), skips redirectTo for API requests using the same rule requireAuth applies, refuses a non-local redirect target, and sets cache-control: private, no-store. Adds eleven regression tests. Co-Authored-By: Claude Opus 5 --- ...-08-04-authz-permissions-implementation.md | 241 +++++++++++++++--- 1 file changed, 206 insertions(+), 35 deletions(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index d6edc26f..2b2a58d9 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -1889,6 +1889,120 @@ describe("filterCan", () => { 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); + } + }); }); ``` @@ -1915,8 +2029,10 @@ export const AUTHZ_LOCALS_KEY = "_authz"; interface RequestAuthz { resolver: AuthzResolver; - scope?: AuthzScope; - memo: Map>; + /** 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 { @@ -1930,48 +2046,66 @@ function readAuthz(ctx: Context): RequestAuthz { return value; } -/** Install the per-request resolver. Register early, after sessionAuth. */ +/** + * 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) => { - const request: RequestAuthz = { + ctx.locals[AUTHZ_LOCALS_KEY] = { resolver, - scope: ctx.tenant?.id ? { tenantId: ctx.tenant.id } : undefined, - memo: new Map(), - }; - ctx.locals[AUTHZ_LOCALS_KEY] = request; + byRef: new WeakMap(), + byValue: new Map(), + } satisfies RequestAuthz; return next(); }; } -/** Stable memo key. Resources without an id fall back to their JSON shape. */ -function memoKey(permission: string, resource: unknown): string { - if (resource === undefined) return permission; - const id = (resource as { id?: unknown })?.id; - if (id !== undefined && id !== null) return `${permission}�${String(id)}`; - try { - return `${permission}�${JSON.stringify(resource)}`; - } catch { - return `${permission}�`; - } -} - export function decideFor( ctx: Context, permission: string, resource?: unknown, ): Promise { const request = readAuthz(ctx); - const key = memoKey(permission, resource); - const cached = request.memo.get(key); + 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 = request.resolver.decide({ - subject: ctx.user as { id?: string } | null | undefined, - permission, - resource, - scope: request.scope, - }); - request.memo.set(key, pending); + const pending = run(); + request.byValue.set(valueKey, pending); return pending; } @@ -1981,30 +2115,67 @@ export async function can(ctx: Context, permission: string, resource?: unknown): export interface GuardOptions { /** Load the resource a bound policy needs. */ - getResource?: (ctx: Context) => unknown | Promise; + 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. */ + /** 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) => { - const resource = options.getResource ? await options.getResource(ctx) : undefined; + 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) { - return new Response(null, { status: 303, headers: { location: options.redirectTo } }); + + 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 }, + { status: 403, headers: { "cache-control": "private, no-store" } }, ); }; } From b7f3507b593036e4a4b9f0bbeabcd0726f224ebc Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 18:41:18 +0530 Subject: [PATCH 26/59] fix(authz): close memo cross-authorization and guard hardening gaps Fix round 1 for Task 7, addressing review findings against the brief's own memoKey design (now superseded per plan amendment cc8085bc): - C1: memoKey's String(id) + JSON.stringify-with-catch cross-authorized distinct resources whenever their ids stringified the same (numeric vs string ids, object-shaped ids) or whenever JSON.stringify threw (circular references, BigInt fields, throwing getters all shared one "" bucket, so the first verdict computed for any of them became the cached verdict for all of them in that request). - C2: filterCan inherited the same bypass, returning rows the subject could not act on. - Replaced serialisation-based memoization with identity-based memoization: object resources are memoised in a WeakMap keyed by the resource reference itself (never serialised), primitives/absent resources in a Map keyed by [scope, permission, typeof, String(value)] so 7 and "7" can never collide. - I1: scope is now read from ctx.tenant at decision time (currentScope), not captured once at middleware-install time, so a tenant switch mid-request is honoured on the next check. - I2/M1: guardPermission's redirectTo now only fires for non-JSON/API requests (replicated wantsJson check, since authz may only import core as types) and only for a validated local path (isLocalPath), closing an open-redirect and a JSON-caller-follows-303 gap. - I3: getResource is now wrapped in try/catch; a throw denies with the standard opaque 403 body instead of propagating the loader's error (e.g. a SQL string) to the client. - Added cache-control: private, no-store to both the 303 and 403 responses. Added 11 regression tests. C1/C2 revert-checked: temporarily restored the old memoKey design and confirmed the four collision tests fail against it before restoring the fix. --- packages/authz/src/middleware.ts | 144 ++++++++++++++++++++----- packages/authz/test/middleware.test.ts | 127 ++++++++++++++++++++++ 2 files changed, 242 insertions(+), 29 deletions(-) diff --git a/packages/authz/src/middleware.ts b/packages/authz/src/middleware.ts index d5ba9e13..4eb900ce 100644 --- a/packages/authz/src/middleware.ts +++ b/packages/authz/src/middleware.ts @@ -11,8 +11,10 @@ export const AUTHZ_LOCALS_KEY = "_authz"; interface RequestAuthz { resolver: AuthzResolver; - scope?: AuthzScope; - memo: Map>; + /** 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 { @@ -32,42 +34,72 @@ export function authzMiddleware(options: AuthzResolverOptions): Middleware { return (ctx, next) => { const request: RequestAuthz = { resolver, - scope: ctx.tenant?.id ? { tenantId: ctx.tenant.id } : undefined, - memo: new Map(), + byRef: new WeakMap(), + byValue: new Map(), }; ctx.locals[AUTHZ_LOCALS_KEY] = request; return next(); }; } -/** Stable memo key. Resources without an id fall back to their JSON shape. */ -function memoKey(permission: string, resource: unknown): string { - if (resource === undefined) return permission; - const id = (resource as { id?: unknown })?.id; - if (id !== undefined && id !== null) return `${permission}::${String(id)}`; - try { - return `${permission}::${JSON.stringify(resource)}`; - } catch { - return `${permission}::`; - } +/** + * 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; } +/** + * Object resources are memoised by identity (`byRef`), never by serialising + * their contents — serialisation is what let unrelated resources collide + * (same `id` shape, circular references, BigInt fields, throwing getters all + * funnelled into one bucket). Primitive/absent resources are memoised by a + * `[scope, permission, typeof, String(value)]` tuple so that e.g. `7` and + * `"7"` never share a cache slot. + */ export function decideFor( ctx: Context, permission: string, resource?: unknown, ): Promise { const request = readAuthz(ctx); - const key = memoKey(permission, resource); - const cached = request.memo.get(key); + const scope = currentScope(ctx); + const key = JSON.stringify([scope?.tenantId ?? "", permission]); + + const decide = () => + request.resolver.decide({ + subject: ctx.user as { id?: string } | null | undefined, + permission, + resource, + scope, + }); + + const isObjectResource = + resource !== null && + resource !== undefined && + (typeof resource === "object" || typeof resource === "function"); + + if (isObjectResource) { + const resourceObject = resource as object; + let inner = request.byRef.get(resourceObject); + if (!inner) { + inner = new Map(); + request.byRef.set(resourceObject, inner); + } + const cached = inner.get(key); + if (cached) return cached; + const pending = decide(); + inner.set(key, pending); + return pending; + } + + const valueKey = JSON.stringify([key, typeof resource, String(resource)]); + const cached = request.byValue.get(valueKey); if (cached) return cached; - const pending = request.resolver.decide({ - subject: ctx.user as { id?: string } | null | undefined, - permission, - resource, - scope: request.scope, - }); - request.memo.set(key, pending); + const pending = decide(); + request.byValue.set(valueKey, pending); return pending; } @@ -75,12 +107,44 @@ export async function can(ctx: Context, permission: string, resource?: unknown): return (await decideFor(ctx, permission, resource)).allowed; } +/** + * Replicates `packages/core/src/auth.ts`'s `wantsJson` (not imported: authz + * may only pull TYPES from @wrnexus/core, never runtime code). + */ +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"); +} + +/** + * Refuse anything but a same-origin, same-app path: no scheme/host + * (`https://evil.example.com/...`), no protocol-relative target (`//evil...` + * is host-relative in a browser, not path-relative), no backslashes (some + * user agents treat `\` as `/`, which can smuggle a host past a naive + * `startsWith("/")` check), and no control characters (CR/LF header/response + * splitting, etc). Written as a codepoint loop rather than a control-char + * regex literal, which tooling in this repo mangles. + */ +function isLocalPath(target: string): boolean { + if (!target.startsWith("/")) return false; + if (target.startsWith("//")) return false; + if (target.includes("\\")) return false; + for (const ch of target) { + const code = ch.codePointAt(0) ?? 0; + if (code < 0x20 || code === 0x7f) return false; + } + return true; +} + +const NO_STORE_HEADERS = { "cache-control": "private, no-store" } as const; + export interface GuardOptions { /** Load the resource a bound policy needs. */ - getResource?: (ctx: Context) => unknown | Promise; + 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. */ + /** Redirect page requests here instead of returning 403. Ignored for JSON/API requests and for any non-local target. */ redirectTo?: string; } @@ -90,17 +154,39 @@ export interface GuardOptions { */ export function guardPermission(permission: string, options: GuardOptions = {}): Middleware { return async (ctx, next) => { - const resource = options.getResource ? await options.getResource(ctx) : undefined; + let resource: unknown; + if (options.getResource) { + try { + resource = await options.getResource(ctx); + } catch (error) { + console.error(`[wrnexus:authz] getResource threw for '${permission}'; denying`, error); + return Response.json( + { ok: false, error: "Forbidden" }, + { status: 403, headers: NO_STORE_HEADERS }, + ); + } + } + const result = await decideFor(ctx, permission, resource); if (result.allowed) return next(); - if (options.redirectTo) { - return new Response(null, { status: 303, headers: { location: options.redirectTo } }); + + if (options.redirectTo && !wantsJson(ctx)) { + if (isLocalPath(options.redirectTo)) { + return new Response(null, { + status: 303, + headers: { location: options.redirectTo, ...NO_STORE_HEADERS }, + }); + } + console.error( + `[wrnexus:authz] guardPermission redirectTo '${options.redirectTo}' is not a local path; falling back to 403`, + ); } + return Response.json( options.exposeReason ? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy } : { ok: false, error: "Forbidden" }, - { status: 403 }, + { status: 403, headers: NO_STORE_HEADERS }, ); }; } diff --git a/packages/authz/test/middleware.test.ts b/packages/authz/test/middleware.test.ts index 546c1527..973573ca 100644 --- a/packages/authz/test/middleware.test.ts +++ b/packages/authz/test/middleware.test.ts @@ -139,4 +139,131 @@ describe("filterCan", () => { const posts = [{ authorId: "u1" }, { authorId: "other" }, { authorId: "u1" }]; expect(await filterCan(ctx, "post:delete", posts)).toHaveLength(2); }); + + test("handles BigInt fields and circular references without leaking", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.grant("u1", "post:delete", "allow"); + await withMiddleware(ctx, store); + + const mine = { authorId: "u1", views: 10n } as Record; + const other = { authorId: "other", views: 11n } as Record; + const circularMine = { authorId: "u1" } as Record; + circularMine.self = circularMine; + const circularOther = { authorId: "other" } as Record; + circularOther.self = circularOther; + + const result = await filterCan(ctx, "post:delete", [mine, other, circularMine, circularOther]); + expect(result).toEqual([mine, circularMine]); + }); + + test("returns an empty array for an empty input", async () => { + const ctx = makeCtx({ id: "u1" }); + await withMiddleware(ctx); + expect(await filterCan(ctx, "post:delete", [])).toEqual([]); + }); +}); + +describe("memoisation does not cross-authorize distinct resources", () => { + test("a numeric id and a string id on different resources do not collide", 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: 7, authorId: "u1" })).toBe(true); + expect(await can(ctx, "post:delete", { id: "7", authorId: "other" })).toBe(false); + }); + + test("resources with object-shaped ids do not collide", 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: { tenant: "A" }, authorId: "u1" })).toBe(true); + expect(await can(ctx, "post:delete", { id: { tenant: "B" }, authorId: "other" })).toBe(false); + }); + + test("two distinct resource objects sharing the same id value do not share a verdict", 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: 1, authorId: "u1" })).toBe(true); + expect(await can(ctx, "post:delete", { id: 1, authorId: "other" })).toBe(false); + }); + + test("switching ctx.tenant mid-request changes the scope for subsequent checks", 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 unknown as { tenant?: { id: string } }).tenant = { id: "t2" }; + expect(await can(ctx, "post:write")).toBe(false); + }); +}); + +describe("guardPermission hardening", () => { + test("throws the setup error and never calls next without the middleware", async () => { + const ctx = makeCtx({ id: "u1" }); + let called = false; + await expect( + guardPermission("post:write")(ctx, async () => { + called = true; + return new Response("passed"); + }), + ).rejects.toThrow(/authzMiddleware/); + expect(called).toBe(false); + }); + + test("a throwing getResource denies with the standard body, not the loader's message", async () => { + const ctx = makeCtx({ id: "u1" }); + await withMiddleware(ctx); + const guard = guardPermission("post:delete", { + getResource: () => { + throw new Error("SELECT * FROM posts WHERE id = 1 -- boom"); + }, + }); + const res = await guard(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("redirectTo issues a 303 for a page request", async () => { + const ctx = makeCtx({ id: "u1" }); + await withMiddleware(ctx); + const res = await guardPermission("post:write", { redirectTo: "/login" })( + ctx, + async () => new Response("passed"), + ); + expect(res.status).toBe(303); + expect(res.headers.get("location")).toBe("/login"); + expect(res.headers.get("cache-control")).toBe("private, no-store"); + }); + + test("redirectTo is ignored for an /api/ request, which gets 403 instead", async () => { + const ctx = { + user: { id: "u1" }, + tenant: undefined, + locals: {}, + url: new URL("http://localhost/api/x"), + req: new Request("http://localhost/api/x"), + } as unknown as Context; + await withMiddleware(ctx); + const res = await guardPermission("post:write", { redirectTo: "/login" })( + ctx, + async () => new Response("passed"), + ); + expect(res.status).toBe(403); + }); + + test("an off-site redirectTo is refused and falls back to 403", async () => { + const ctx = makeCtx({ id: "u1" }); + await withMiddleware(ctx); + const res = await guardPermission("post:write", { + redirectTo: "https://evil.example.com/harvest", + })(ctx, async () => new Response("passed")); + expect(res.status).toBe(403); + }); }); From 9e3624e584efcdf2d2bff4b172a8f9fc6729dbb3 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 18:54:30 +0530 Subject: [PATCH 27/59] docs: put the subject in the memo key in the Task 7 plan snippet The re-review closed all six earlier findings but surfaced the same bug class one level over: the memo key carried the scope but not the subject, so reassigning ctx.user mid-request served the previous principal's verdict. Demonstrated - u1 allowed, then ctx.user = u2 still returned true, and clearing ctx.user entirely revoked nothing. Triggered by impersonation or "view as user" middleware, step-up auth, session revocation mid-request, or simply registering an auth middleware after authzMiddleware. Also: symbols now memo by identity (String() collapsed two distinct symbols sharing a description into one slot), -0 stays distinct from 0, the rejected-redirect log no longer echoes CR/LF verbatim into the log stream, and a non-ASCII redirect target is encodeURI'd rather than throwing out of the Response constructor and 500ing on a denial path. Co-Authored-By: Claude Opus 5 --- ...-08-04-authz-permissions-implementation.md | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index 2b2a58d9..10c01502 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -2031,6 +2031,8 @@ interface RequestAuthz { resolver: AuthzResolver; /** Memo for object resources, keyed by identity so two rows never collide. */ byRef: WeakMap>>; + /** Memo for symbol resources, which also carry identity. */ + bySymbol: Map>>; /** Memo for primitive and absent resources. */ byValue: Map>; } @@ -2062,6 +2064,7 @@ export function authzMiddleware(options: AuthzResolverOptions): Middleware { ctx.locals[AUTHZ_LOCALS_KEY] = { resolver, byRef: new WeakMap(), + bySymbol: new Map(), byValue: new Map(), } satisfies RequestAuthz; return next(); @@ -2076,8 +2079,16 @@ export function decideFor( 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]); + // Subject and scope are both part of the key. A request that reassigns + // ctx.user (impersonation, step-up auth, session revocation) or ctx.tenant + // must not be served the previous principal's verdict from the memo. + const subjectId = (ctx.user as { id?: unknown } | null | undefined)?.id; + const key = JSON.stringify([ + scope?.tenantId ?? "", + permission, + typeof subjectId, + String(subjectId), + ]); const run = () => request.resolver.decide({ @@ -2090,6 +2101,19 @@ export function decideFor( // Object resources memo by IDENTITY. Serialising them would let two distinct // rows share a key and cross-authorize, and unserialisable ones (circular // refs, BigInt fields, throwing getters) would all collapse into one bucket. + // Symbols carry identity that String() erases, so they memo by identity too. + // They are held in a plain Map rather than the WeakMap: the memo is discarded + // with the request, so there is nothing to leak. + if (typeof resource === "symbol") { + let perSymbol = request.bySymbol.get(resource); + if (!perSymbol) request.bySymbol.set(resource, (perSymbol = new Map())); + const cached = perSymbol.get(key); + if (cached) return cached; + const pending = run(); + perSymbol.set(key, pending); + return pending; + } + if (resource !== null && (typeof resource === "object" || typeof resource === "function")) { let perResource = request.byRef.get(resource as object); if (!perResource) request.byRef.set(resource as object, (perResource = new Map())); @@ -2100,8 +2124,10 @@ export function decideFor( 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)]); + // typeof is part of the key so 7 and "7" are not the same resource, and + // -0 keeps its sign because String(-0) is "0". + const rendered = Object.is(resource, -0) ? "-0" : String(resource); + const valueKey = JSON.stringify([key, typeof resource, rendered]); const cached = request.byValue.get(valueKey); if (cached) return cached; const pending = run(); @@ -2161,13 +2187,20 @@ export function guardPermission(permission: string, options: GuardOptions = {}): if (options.redirectTo && !wantsJson(ctx)) { if (!isLocalPath(options.redirectTo)) { + // JSON-encode: this branch exists precisely for values containing + // CR/LF, which would otherwise forge a second log line. console.error( - `[wrnexus:authz] redirectTo must be a local path, got '${options.redirectTo}'; denying`, + `[wrnexus:authz] redirectTo must be a local path, got ${JSON.stringify(options.redirectTo)}; denying`, ); } else { return new Response(null, { status: 303, - headers: { location: options.redirectTo, "cache-control": "private, no-store" }, + headers: { + // Header values must be Latin-1; a localized path like /accounts + // in non-ASCII would otherwise throw and 500 on a denial path. + location: encodeURI(options.redirectTo), + "cache-control": "private, no-store", + }, }); } } From 77b9e49bf2df13bcaa9515224c2c5f3801298847 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 18:59:56 +0530 Subject: [PATCH 28/59] fix(authz): fold subject into the memo key, fix symbol/-0 and redirect issues Fix round 2 for Task 7 (plan amendment 9e3624e5): - N1 (Important): the memo key carried scope and permission but not the subject, so a request that reassigns ctx.user mid-flight (impersonation, step-up auth, session revocation, or an authz-before-auth middleware ordering mistake) could be served the previous principal's cached verdict. subjectId (typeof + String, matching the existing scope/value encoding style) is now folded into every memo key. - N2 (Minor): the primitive-value memo key used String(resource), which collapses distinct Symbol("row") values into one slot and maps -0 onto 0's slot. Added a dedicated bySymbol identity memo (WeakMap-style, but a plain Map since symbols aren't valid WeakMap keys pre-registry symbols and the memo is request-scoped anyway) and special-cased Object.is(x,-0) to render as "-0". - N3 (Minor): the rejected-redirect console.error interpolated redirectTo directly, exactly the value most likely to carry CR/LF in that branch. Switched to JSON.stringify(redirectTo) for the log line. - N4 (Minor): a non-ASCII (but otherwise valid, local) redirectTo passed isLocalPath and then threw inside `new Response` building the Location header. Wrapped it in encodeURI(). Added 5 regression tests: subject swap re-evaluates, clearing ctx.user denies, two same-description symbols get separate verdicts, 0 vs -0 get separate verdicts, non-ASCII redirectTo 303s with an encoded location instead of throwing. N1 revert-checked: temporarily restored the two-element (no-subject) key and confirmed both subject-swap tests fail against it before restoring the fix. --- packages/authz/src/middleware.ts | 53 ++++++++++++--- packages/authz/test/middleware.test.ts | 90 ++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 10 deletions(-) diff --git a/packages/authz/src/middleware.ts b/packages/authz/src/middleware.ts index 4eb900ce..bbc5b8e5 100644 --- a/packages/authz/src/middleware.ts +++ b/packages/authz/src/middleware.ts @@ -13,6 +13,8 @@ interface RequestAuthz { resolver: AuthzResolver; /** Memo for object resources, keyed by identity so two rows never collide. */ byRef: WeakMap>>; + /** Memo for symbol resources, keyed by identity for the same reason. */ + bySymbol: Map>>; /** Memo for primitive and absent resources. */ byValue: Map>; } @@ -35,6 +37,7 @@ export function authzMiddleware(options: AuthzResolverOptions): Middleware { const request: RequestAuthz = { resolver, byRef: new WeakMap(), + bySymbol: new Map(), byValue: new Map(), }; ctx.locals[AUTHZ_LOCALS_KEY] = request; @@ -55,9 +58,15 @@ function currentScope(ctx: Context): AuthzScope | undefined { * Object resources are memoised by identity (`byRef`), never by serialising * their contents — serialisation is what let unrelated resources collide * (same `id` shape, circular references, BigInt fields, throwing getters all - * funnelled into one bucket). Primitive/absent resources are memoised by a - * `[scope, permission, typeof, String(value)]` tuple so that e.g. `7` and - * `"7"` never share a cache slot. + * funnelled into one bucket). Symbols are memoised by identity too (`bySymbol`) + * since `String(symbol)` collapses distinct symbols with the same description. + * Primitive/absent resources are memoised by a + * `[scope, permission, typeof, String(value)]` tuple, with `-0` rendered + * distinctly from `0` since `String(-0) === "0"` would otherwise merge them. + * + * Subject and scope are both part of the key. A request that reassigns + * ctx.user (impersonation, step-up auth, session revocation) or ctx.tenant + * must not be served the previous principal's verdict from the memo. */ export function decideFor( ctx: Context, @@ -66,9 +75,15 @@ export function decideFor( ): Promise { const request = readAuthz(ctx); const scope = currentScope(ctx); - const key = JSON.stringify([scope?.tenantId ?? "", permission]); + const subjectId = (ctx.user as { id?: unknown } | null | undefined)?.id; + const key = JSON.stringify([ + scope?.tenantId ?? "", + permission, + typeof subjectId, + String(subjectId), + ]); - const decide = () => + const run = () => request.resolver.decide({ subject: ctx.user as { id?: string } | null | undefined, permission, @@ -76,6 +91,19 @@ export function decideFor( scope, }); + // Symbols carry identity that String() erases, so they memo by identity too. + // They are held in a plain Map rather than the WeakMap: the memo is discarded + // with the request, so there is nothing to leak. + if (typeof resource === "symbol") { + let perSymbol = request.bySymbol.get(resource); + if (!perSymbol) request.bySymbol.set(resource, (perSymbol = new Map())); + const cached = perSymbol.get(key); + if (cached) return cached; + const pending = run(); + perSymbol.set(key, pending); + return pending; + } + const isObjectResource = resource !== null && resource !== undefined && @@ -90,15 +118,16 @@ export function decideFor( } const cached = inner.get(key); if (cached) return cached; - const pending = decide(); + const pending = run(); inner.set(key, pending); return pending; } - const valueKey = JSON.stringify([key, typeof resource, String(resource)]); + const rendered = Object.is(resource, -0) ? "-0" : String(resource); + const valueKey = JSON.stringify([key, typeof resource, rendered]); const cached = request.byValue.get(valueKey); if (cached) return cached; - const pending = decide(); + const pending = run(); request.byValue.set(valueKey, pending); return pending; } @@ -174,11 +203,15 @@ export function guardPermission(permission: string, options: GuardOptions = {}): if (isLocalPath(options.redirectTo)) { return new Response(null, { status: 303, - headers: { location: options.redirectTo, ...NO_STORE_HEADERS }, + // encodeURI: a non-ASCII local path (e.g. a localized login route) + // is valid config but not a valid raw header value. + headers: { location: encodeURI(options.redirectTo), ...NO_STORE_HEADERS }, }); } + // JSON.stringify, not string interpolation: this branch exists precisely + // for targets containing CR/LF, which must not reach the log verbatim. console.error( - `[wrnexus:authz] guardPermission redirectTo '${options.redirectTo}' is not a local path; falling back to 403`, + `[wrnexus:authz] guardPermission redirectTo ${JSON.stringify(options.redirectTo)} is not a local path; falling back to 403`, ); } diff --git a/packages/authz/test/middleware.test.ts b/packages/authz/test/middleware.test.ts index 973573ca..cbfa7e2d 100644 --- a/packages/authz/test/middleware.test.ts +++ b/packages/authz/test/middleware.test.ts @@ -203,6 +203,83 @@ describe("memoisation does not cross-authorize distinct resources", () => { }); }); +describe("memoisation does not cross-authorize distinct subjects", () => { + test("swapping ctx.user mid-request re-evaluates for the new subject", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.grant("u1", "post:delete", "allow"); + await withMiddleware(ctx, store); + const resource = { authorId: "u1" }; + expect(await can(ctx, "post:delete", resource)).toBe(true); + (ctx as unknown as { user?: unknown }).user = { id: "u2" }; + expect(await can(ctx, "post:delete", resource)).toBe(false); + }); + + test("clearing ctx.user mid-request denies rather than replaying the old verdict", 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); + (ctx as unknown as { user?: unknown }).user = null; + expect(await can(ctx, "post:write")).toBe(false); + }); +}); + +describe("memoisation identity edge cases", () => { + test("two distinct symbols with the same description do not share a verdict", async () => { + const approved = Symbol("row"); + const other = Symbol("row"); + const localCatalog = mergeCatalogs([ + { + source: "symbol-identity-test.ts", + module: defineAuthz({ + permissions: { "sym:pick": {} }, + policies: { + isApproved: async (_s: unknown, r?: unknown) => + r === approved ? { allowed: true } : { allowed: false, reason: "not approved" }, + }, + bindings: { "sym:pick": ["isApproved"] }, + }), + }, + ]); + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.grant("u1", "sym:pick", "allow"); + await authzMiddleware({ catalog: localCatalog, store, strict: false })( + ctx, + async () => new Response("ok"), + ); + expect(await can(ctx, "sym:pick", approved)).toBe(true); + expect(await can(ctx, "sym:pick", other)).toBe(false); + }); + + test("0 and -0 do not share a verdict", async () => { + const localCatalog = mergeCatalogs([ + { + source: "negative-zero-test.ts", + module: defineAuthz({ + permissions: { "zero:pick": {} }, + policies: { + isPositiveZero: async (_s: unknown, r?: unknown) => + Object.is(r, 0) ? { allowed: true } : { allowed: false, reason: "not +0" }, + }, + bindings: { "zero:pick": ["isPositiveZero"] }, + }), + }, + ]); + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.grant("u1", "zero:pick", "allow"); + await authzMiddleware({ catalog: localCatalog, store, strict: false })( + ctx, + async () => new Response("ok"), + ); + expect(await can(ctx, "zero:pick", 0)).toBe(true); + expect(await can(ctx, "zero:pick", -0)).toBe(false); + }); +}); + describe("guardPermission hardening", () => { test("throws the setup error and never calls next without the middleware", async () => { const ctx = makeCtx({ id: "u1" }); @@ -242,6 +319,19 @@ describe("guardPermission hardening", () => { expect(res.headers.get("cache-control")).toBe("private, no-store"); }); + test("a non-ASCII redirectTo returns 303 with an encoded location rather than throwing", async () => { + const ctx = makeCtx({ id: "u1" }); + await withMiddleware(ctx); + // Built at runtime (no \u escapes in source) per the repo-wide constraint. + const target = "/" + String.fromCharCode(0x65e5) + String.fromCharCode(0x672c); + const res = await guardPermission("post:write", { redirectTo: target })( + ctx, + async () => new Response("passed"), + ); + expect(res.status).toBe(303); + expect(res.headers.get("location")).toBe(encodeURI(target)); + }); + test("redirectTo is ignored for an /api/ request, which gets 403 instead", async () => { const ctx = { user: { id: "u1" }, From 798f56734a053546e98d854baad65246116f97ec Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 19:07:15 +0530 Subject: [PATCH 29/59] docs: stop double-encoding redirectTo in the Task 7 plan snippet The previous fix used encodeURI to keep a non-ASCII redirect target from throwing inside new Response. But encodeURI also escapes "%", so an already-percent-encoded target is corrupted: /login?next=%2Fdash becomes /login?next=%252Fdash, which single-decodes to the literal "%2Fdash" rather than the intended path. That is the most common real use of redirectTo - "send them to login, then bounce back". Replaced with headerSafePath, a codepoint loop that encodes only what cannot be sent in a Latin-1 header and leaves existing escapes and reserved ASCII untouched. My prescription, my defect. Co-Authored-By: Claude Opus 5 --- ...6-08-04-authz-permissions-implementation.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index 10c01502..f8b6ee49 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -2155,6 +2155,20 @@ function wantsJson(ctx: Context): boolean { return accept.includes("application/json") && !accept.includes("text/html"); } +/** + * Header values must be Latin-1, so a localized path would otherwise throw + * inside `new Response` and 500 on a denial path. Encode ONLY the codepoints + * that cannot be sent: encodeURI would also escape "%", corrupting a target + * that already carries a percent-encoded return path. + */ +function headerSafePath(value: string): string { + let out = ""; + for (const character of value) { + out += character.codePointAt(0)! <= 0x7f ? character : encodeURIComponent(character); + } + return out; +} + /** Reject anything that could navigate off-site or inject a header. */ function isLocalPath(value: string): boolean { if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return false; @@ -2196,9 +2210,7 @@ export function guardPermission(permission: string, options: GuardOptions = {}): return new Response(null, { status: 303, headers: { - // Header values must be Latin-1; a localized path like /accounts - // in non-ASCII would otherwise throw and 500 on a denial path. - location: encodeURI(options.redirectTo), + location: headerSafePath(options.redirectTo), "cache-control": "private, no-store", }, }); From 3f1fcd0d2d08df52fde15c3d095066df35f4ae75 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 19:10:09 +0530 Subject: [PATCH 30/59] fix(authz): stop encodeURI from double-encoding a percent-escaped redirectTo Fix round 3 for Task 7 (N5, minor-to-important): fix round 2's encodeURI(options.redirectTo) fixed the non-ASCII crash but broke the most common real use of redirectTo -- a return-path query param that's already percent-encoded (e.g. /login?next=%2Fdash) -- because encodeURI also escapes "%", double-encoding it to %252Fdash. Replaced with headerSafePath(), a codepoint loop that encodes only codepoints above 0x7f (matching isLocalPath's style: no regex, no source escapes) and leaves "%" alone. Added tests: an already-percent-encoded target round-trips unchanged; a non-ASCII target still 303s without throwing and the location is ASCII-only; a plain ASCII target passes through byte-identical. --- packages/authz/src/middleware.ts | 22 ++++++++++++--- packages/authz/test/middleware.test.ts | 37 +++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/authz/src/middleware.ts b/packages/authz/src/middleware.ts index bbc5b8e5..e0e46c61 100644 --- a/packages/authz/src/middleware.ts +++ b/packages/authz/src/middleware.ts @@ -166,6 +166,20 @@ function isLocalPath(target: string): boolean { return true; } +/** + * Header values must be Latin-1, so a localized path would otherwise throw + * inside `new Response` and 500 on a denial path. Encode ONLY the codepoints + * that cannot be sent: encodeURI would also escape "%", corrupting a target + * that already carries a percent-encoded return path. + */ +function headerSafePath(value: string): string { + let out = ""; + for (const character of value) { + out += character.codePointAt(0)! <= 0x7f ? character : encodeURIComponent(character); + } + return out; +} + const NO_STORE_HEADERS = { "cache-control": "private, no-store" } as const; export interface GuardOptions { @@ -203,9 +217,11 @@ export function guardPermission(permission: string, options: GuardOptions = {}): if (isLocalPath(options.redirectTo)) { return new Response(null, { status: 303, - // encodeURI: a non-ASCII local path (e.g. a localized login route) - // is valid config but not a valid raw header value. - headers: { location: encodeURI(options.redirectTo), ...NO_STORE_HEADERS }, + // headerSafePath, not encodeURI: a non-ASCII local path (e.g. a + // localized login route) is valid config but not a valid raw + // header value, while encodeURI would also mangle a target that + // already carries a percent-encoded return path. + headers: { location: headerSafePath(options.redirectTo), ...NO_STORE_HEADERS }, }); } // JSON.stringify, not string interpolation: this branch exists precisely diff --git a/packages/authz/test/middleware.test.ts b/packages/authz/test/middleware.test.ts index cbfa7e2d..b3d29d90 100644 --- a/packages/authz/test/middleware.test.ts +++ b/packages/authz/test/middleware.test.ts @@ -319,17 +319,46 @@ describe("guardPermission hardening", () => { expect(res.headers.get("cache-control")).toBe("private, no-store"); }); - test("a non-ASCII redirectTo returns 303 with an encoded location rather than throwing", async () => { + test("a non-ASCII redirectTo returns 303 without throwing, and the location is ASCII-only", async () => { const ctx = makeCtx({ id: "u1" }); await withMiddleware(ctx); - // Built at runtime (no \u escapes in source) per the repo-wide constraint. - const target = "/" + String.fromCharCode(0x65e5) + String.fromCharCode(0x672c); + // Built at runtime via String.fromCodePoint (no non-ASCII characters + // typed into the source) per the repo-wide constraint. + const target = "/" + String.fromCodePoint(0x65e5) + String.fromCodePoint(0x672c); const res = await guardPermission("post:write", { redirectTo: target })( ctx, async () => new Response("passed"), ); expect(res.status).toBe(303); - expect(res.headers.get("location")).toBe(encodeURI(target)); + const location = res.headers.get("location"); + expect(location).not.toBeNull(); + for (const ch of location ?? "") { + expect(ch.codePointAt(0)! <= 0x7f).toBe(true); + } + }); + + test("an already-percent-encoded redirectTo round-trips unchanged", async () => { + const ctx = makeCtx({ id: "u1" }); + await withMiddleware(ctx); + const res = await guardPermission("post:write", { redirectTo: "/login?next=%2Fdash" })( + ctx, + async () => new Response("passed"), + ); + expect(res.status).toBe(303); + const location = res.headers.get("location"); + expect(location).toBe("/login?next=%2Fdash"); + expect(location).not.toContain("%25"); + }); + + test("a plain ASCII redirectTo is passed through byte-identical", async () => { + const ctx = makeCtx({ id: "u1" }); + await withMiddleware(ctx); + const res = await guardPermission("post:write", { redirectTo: "/login?next=/dashboard" })( + ctx, + async () => new Response("passed"), + ); + expect(res.status).toBe(303); + expect(res.headers.get("location")).toBe("/login?next=/dashboard"); }); test("redirectTo is ignored for an /api/ request, which gets 403 instead", async () => { From e15422ed8d6f13109cd8a8f80f69b0f7ea8c24b7 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 19:17:48 +0530 Subject: [PATCH 31/59] fix(authz): stop authorizeDecision leaking policy names in 403 bodies --- packages/authz/src/advanced.ts | 14 +++++++++++++- packages/authz/test/authz.test.ts | 32 ++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/authz/src/advanced.ts b/packages/authz/src/advanced.ts index 3a88c58f..a0f01394 100644 --- a/packages/authz/src/advanced.ts +++ b/packages/authz/src/advanced.ts @@ -77,14 +77,26 @@ export function allDecisions(...policies: DecisionPolicy[]): Decisio return allow("all policies passed"); }; } +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( - { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy }, + options.exposeReason + ? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy } + : { ok: false, error: "Forbidden" }, { status: 403 }, ); }; diff --git a/packages/authz/test/authz.test.ts b/packages/authz/test/authz.test.ts index fc42c2ef..e3940f38 100644 --- a/packages/authz/test/authz.test.ts +++ b/packages/authz/test/authz.test.ts @@ -1,9 +1,10 @@ -import { test, expect } from "bun:test"; +import { test, expect, describe } from "bun:test"; import { createContext } from "@wrnexus/core"; import { defineRbac, hasRole, authorize, + authorizeDecision, requireRole, requirePermission, any, @@ -128,3 +129,32 @@ test("owner() denies rather than matching two absent ids", async () => { expect((await customOwns({ id: "u1" }, { ownerId: "u1" })).allowed).toBe(true); expect((await customOwns({ id: "u1" }, {})).allowed).toBe(false); }); + +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"); + }); +}); From 13859ce7dc173c918533c14d36eb64b1954621ae Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 19:25:40 +0530 Subject: [PATCH 32/59] docs: add deniedBy to the Task 9 export list deniedBy was introduced in Task 6's fix round to make wildcard denies work, but the plan's export block and its exports test were never updated, so Task 9 would have shipped it module-private. Co-Authored-By: Claude Opus 5 --- docs/plans/2026-08-04-authz-permissions-implementation.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index f8b6ee49..21c8903a 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -2408,6 +2408,7 @@ describe("@wrnexus/authz exports", () => { "createAuthzResolver", "expandRoles", "permissionMatches", + "deniedBy", "authzMiddleware", "can", "decideFor", @@ -2435,7 +2436,7 @@ export { memoryPermissionStore, cachedPermissionStore, scopeKey } from "./store. 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 { createAuthzResolver, expandRoles, permissionMatches, deniedBy } from "./engine.ts"; export type { AuthzResolver, AuthzResolverOptions, DecideInput } from "./engine.ts"; export { authzMiddleware, From 6f3a53b9fffb69f896803980f050b87299c13884 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 19:29:08 +0530 Subject: [PATCH 33/59] feat(authz): export registry, store, engine, and middleware surface Appends the Task 1-8 modules (defineAuthz, catalog merge helpers, permission stores, audit sinks, resolver engine, and authzMiddleware/ can/guards) to the public @wrnexus/authz surface, and regenerates the public-api-0.8.json baseline to match. --- docs/public-api-0.8.json | 40 ++++++++++++++++++++++- packages/authz/src/index.ts | 27 ++++++++++++++++ packages/authz/test/exports.test.ts | 50 +++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 packages/authz/test/exports.test.ts diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index 5023957d..342c4e99 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -457,11 +457,31 @@ }, "@wrnexus/authz": { ".": [ + "AUTHZ_LOCALS_KEY", + "AttributeMeta", "AuthorizationDecision", + "AuthorizeDecisionOptions", + "AuthzAuditEvent", + "AuthzAuditSink", + "AuthzCatalog", + "AuthzModule", + "AuthzResolver", + "AuthzResolverOptions", + "AuthzScope", + "CacheOptions", + "CachedPermissionStore", + "CatalogSource", + "DecideInput", "DecisionPolicy", + "GrantEffect", + "GuardOptions", + "MemoryAuditSink", + "PermissionMeta", + "PermissionStore", "Policy", "Rbac", "Subject", + "SubjectAssignments", "all", "allDecisions", "allow", @@ -470,14 +490,32 @@ "attr", "authorize", "authorizeDecision", + "authzMiddleware", + "cachedPermissionStore", + "can", + "consoleAuditSink", + "createAuthzResolver", + "decideFor", "decision", + "defineAuthz", "defineRbac", + "deniedBy", "deny", + "emptyCatalog", + "expandRoles", "filterAuthorized", + "filterCan", + "guardPermission", "hasRole", + "memoryAuditSink", + "memoryPermissionStore", + "mergeCatalogs", "owner", + "permissionMatches", "requirePermission", - "requireRole" + "requireRole", + "safeRecord", + "scopeKey" ] }, "@wrnexus/benchmark": { diff --git a/packages/authz/src/index.ts b/packages/authz/src/index.ts index b7c7a3a2..08f8101f 100644 --- a/packages/authz/src/index.ts +++ b/packages/authz/src/index.ts @@ -140,3 +140,30 @@ export { filterAuthorized, } from "./advanced.ts"; export type { AuthorizationDecision, DecisionPolicy } from "./advanced.ts"; +export { defineAuthz } from "./registry.ts"; +export { mergeCatalogs, emptyCatalog } from "./catalog.ts"; +export type { CatalogSource } from "./catalog.ts"; +export { memoryPermissionStore, cachedPermissionStore, scopeKey } from "./store.ts"; +export type { PermissionStore, CachedPermissionStore, CacheOptions, GrantEffect } from "./store.ts"; +export { memoryAuditSink, consoleAuditSink, safeRecord } from "./audit.ts"; +export type { AuthzAuditEvent, AuthzAuditSink, MemoryAuditSink } from "./audit.ts"; +export { createAuthzResolver, expandRoles, permissionMatches, deniedBy } from "./engine.ts"; +export type { AuthzResolver, AuthzResolverOptions, DecideInput } from "./engine.ts"; +export { + authzMiddleware, + can, + decideFor, + guardPermission, + filterCan, + AUTHZ_LOCALS_KEY, +} from "./middleware.ts"; +export type { GuardOptions } from "./middleware.ts"; +export type { + AuthzScope, + AuthzCatalog, + AuthzModule, + AttributeMeta, + PermissionMeta, + SubjectAssignments, +} from "./types.ts"; +export type { AuthorizeDecisionOptions } from "./advanced.ts"; diff --git a/packages/authz/test/exports.test.ts b/packages/authz/test/exports.test.ts new file mode 100644 index 00000000..6c5867e9 --- /dev/null +++ b/packages/authz/test/exports.test.ts @@ -0,0 +1,50 @@ +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", + "deniedBy", + "authzMiddleware", + "can", + "decideFor", + "guardPermission", + "filterCan", + ]) { + expect(typeof (authz as Record)[name]).toBe("function"); + } + }); +}); From e05ddc7aa5a24fe163c73964536199dd81f0384d Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 19:36:24 +0530 Subject: [PATCH 34/59] docs: warn against the permissionMatches + permissionsFor composition permissionsFor carries a caveat that its Set cannot represent a narrow deny under a broad grant, so callers must gate with decide(). Now that permissionMatches is also public, the wrong composition is directly reachable and looks idiomatic - and the warning lived only on the other half of it. Adds the pointer to permissionMatches, and covers scopeKey and safeRecord in the exports test, which the brief omitted. Co-Authored-By: Claude Opus 5 --- .../2026-08-04-authz-permissions-implementation.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index 21c8903a..50fb10b7 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -1536,7 +1536,13 @@ export function expandRoles(catalog: AuthzCatalog, roles: readonly string[]): Se return out; } -/** Exact match, root wildcard, or a namespace wildcard at any depth. */ +/** + * Exact match, root wildcard, or a namespace wildcard at any depth. + * + * Do NOT gate access by matching against `permissionsFor()`'s result — that set + * cannot represent a narrow deny beneath a broad grant, so the composition + * returns true where `decide()` refuses. Use `decide()` / `can()` instead. + */ export function permissionMatches(granted: Set, permission: string): boolean { if (granted.has("*") || granted.has(permission)) return true; for (let at = permission.indexOf(":"); at !== -1; at = permission.indexOf(":", at + 1)) { @@ -2408,6 +2414,8 @@ describe("@wrnexus/authz exports", () => { "createAuthzResolver", "expandRoles", "permissionMatches", + "scopeKey", + "safeRecord", "deniedBy", "authzMiddleware", "can", From 703baa1ead5ff45d665f03be2f62df2bb4e53fc5 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 19:38:07 +0530 Subject: [PATCH 35/59] fix(authz): strengthen permissionMatches warning, complete export coverage Move the "don't gate on permissionsFor() with permissionMatches" warning onto permissionMatches itself so it's visible via autocomplete, not just on AuthzResolver.permissionsFor. Round out exports.test.ts to cover scopeKey, safeRecord, and AUTHZ_LOCALS_KEY, closing the gap where dropping either export from index.ts would not fail the test. --- packages/authz/src/engine.ts | 8 +++++++- packages/authz/test/exports.test.ts | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/authz/src/engine.ts b/packages/authz/src/engine.ts index a67a063f..b864c281 100644 --- a/packages/authz/src/engine.ts +++ b/packages/authz/src/engine.ts @@ -54,7 +54,13 @@ export function expandRoles(catalog: AuthzCatalog, roles: readonly string[]): Se return out; } -/** Exact match, root wildcard, or a namespace wildcard at any depth. */ +/** + * Exact match, root wildcard, or a namespace wildcard at any depth. + * + * Do NOT gate access by matching against `permissionsFor()`'s result — that set + * cannot represent a narrow deny beneath a broad grant, so the composition + * returns true where `decide()` refuses. Use `decide()` / `can()` instead. + */ export function permissionMatches(granted: Set, permission: string): boolean { if (granted.has("*") || granted.has(permission)) return true; for (let at = permission.indexOf(":"); at !== -1; at = permission.indexOf(":", at + 1)) { diff --git a/packages/authz/test/exports.test.ts b/packages/authz/test/exports.test.ts index 6c5867e9..85e2d5ef 100644 --- a/packages/authz/test/exports.test.ts +++ b/packages/authz/test/exports.test.ts @@ -43,8 +43,14 @@ describe("@wrnexus/authz exports", () => { "decideFor", "guardPermission", "filterCan", + "scopeKey", + "safeRecord", ]) { expect(typeof (authz as Record)[name]).toBe("function"); } }); + + test("exports the locals key used to reach the per-request resolver", () => { + expect(typeof (authz as Record).AUTHZ_LOCALS_KEY).toBe("string"); + }); }); From e7743cdbb523571341a062c8b8278051e1b7c682 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 19:52:37 +0530 Subject: [PATCH 36/59] feat(router): discover app/authz declarations Scan app/authz/.{ts,js} the same way app/schemas is scanned, exposing Router.authz: ComponentRef[]. Also update the two other literal Router construction sites (prod runtime, dev-server test fixture) that now need the new required field. scanDir gains an optional extraExtensions parameter (default []) so the authz scan can accept .js files without widening the extension allow-list used by route scanning (app/pages, app/api, app/realtime), which would otherwise leak .js into generated route URLs via fileToRoute. --- packages/dev-server/src/prod.ts | 1 + .../test/observability-runtime.test.ts | 1 + packages/router/src/index.ts | 16 +++++++ packages/router/src/scan.ts | 12 ++++- packages/router/test/authz-discovery.test.ts | 44 +++++++++++++++++++ 5 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 packages/router/test/authz-discovery.test.ts diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index b0c14201..b54fe2ae 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -239,6 +239,7 @@ function buildProdRouter(manifest: ProdManifest): { layouts: manifest.layouts.map((l) => ({ name: l.name, file: `layout:${l.name}` })), stores: [], schemas: [], // descriptors are pre-baked into schemasJs; not needed at runtime + authz: [], // authz declarations are not needed at runtime in production matchPage: optimizedMatcher(pages), matchApi: optimizedMatcher(api), matchRealtime: optimizedMatcher(realtime), diff --git a/packages/dev-server/test/observability-runtime.test.ts b/packages/dev-server/test/observability-runtime.test.ts index d068a41d..154b48f1 100644 --- a/packages/dev-server/test/observability-runtime.test.ts +++ b/packages/dev-server/test/observability-runtime.test.ts @@ -13,6 +13,7 @@ function runtime(health: HealthRegistry, trustProxy = false) { layouts: [], stores: [], schemas: [], + authz: [], matchPage: () => null, matchApi: () => null, matchRealtime: () => null, diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 9e4ea4b6..5923b5e3 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -59,6 +59,8 @@ export interface Router { stores: ComponentRef[]; /** Validation schemas (`app/schemas/.ts`) shared by API + forms. */ schemas: ComponentRef[]; + /** Authorization declarations (`app/authz/.ts`) merged into the catalog. */ + authz: ComponentRef[]; matchPage(pathname: string): RouteMatch | null; matchApi(pathname: string): RouteMatch | null; matchRealtime(pathname: string): RouteMatch | null; @@ -283,6 +285,19 @@ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router { schemas.push({ name, file: f.file }); } + // 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"), [".js"])) { + 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 }); + } + return { pages, api, @@ -292,6 +307,7 @@ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router { layouts, stores, schemas, + authz, matchPage: (p) => matchRoute(pages, p), matchApi: (p) => matchRoute(api, p), matchRealtime: (p) => matchRoute(realtime, p), diff --git a/packages/router/src/scan.ts b/packages/router/src/scan.ts index 724d39e1..7d6b7650 100644 --- a/packages/router/src/scan.ts +++ b/packages/router/src/scan.ts @@ -32,8 +32,13 @@ function isIgnored(name: string): boolean { /** * Recursively collect allowed route files under `baseDir`. * Returns [] if the directory does not exist (a route kind may be unused). + * + * `extraExtensions` widens the allow-list for callers that scan non-route + * directories (e.g. `app/schemas`, `app/authz`) and accept plain `.js` + * modules; it defaults to empty so route scanning (`app/pages`, `app/api`, + * `app/realtime`, ...) is unaffected. */ -export function scanDir(baseDir: string): ScannedFile[] { +export function scanDir(baseDir: string, extraExtensions: readonly string[] = []): ScannedFile[] { if (!existsSync(baseDir)) return []; const out: ScannedFile[] = []; @@ -45,7 +50,10 @@ export function scanDir(baseDir: string): ScannedFile[] { const stats = statSync(abs); if (stats.isDirectory()) { walk(abs); - } else if (stats.isFile() && hasAllowedExtension(entry)) { + } else if ( + stats.isFile() && + (hasAllowedExtension(entry) || extraExtensions.some((ext) => entry.endsWith(ext))) + ) { out.push({ file: abs, rel: relative(baseDir, abs).split(sep).join("/"), diff --git a/packages/router/test/authz-discovery.test.ts b/packages/router/test/authz-discovery.test.ts new file mode 100644 index 00000000..438e7fbe --- /dev/null +++ b/packages/router/test/authz-discovery.test.ts @@ -0,0 +1,44 @@ +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([]); + }); +}); From 41fb82b9e980ee5bb15f68c74e9accc86d890750 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 19:55:33 +0530 Subject: [PATCH 37/59] docs: skip generated type files in the Task 10 authz scan The brief asserted permissions.gen.ts would be discovered as an entry named permissions.gen and filtered by a later task. It is not: isSafeIslandName rejects the dot in the stripped basename, so it takes the warn-and-skip path and would print a warning on every boot of any app that ran the codegen, while Task 14's name-based filter for it was dead code. The scan now skips *.gen.ts / *.gen.js quietly, before the name check. Also records the extraExtensions argument the implementer added to scanDir, which keeps .js out of the route-scanning allow-list where it would otherwise leak into generated route URLs via fileToRoute. Caught by the Task 10 implementer testing the claim rather than trusting it. Co-Authored-By: Claude Opus 5 --- .../2026-08-04-authz-permissions-implementation.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index 50fb10b7..d015cef8 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -2563,8 +2563,14 @@ Add the scan immediately after the existing `schemas` loop: // 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"))) { +// scanDir's extension allow-list is route-oriented; passing [".js"] here keeps +// .js out of app/pages scanning, where it would leak into route URLs. +for (const f of scanDir(join(appDir, "authz"), [".js"])) { if (!/\.(ts|js)$/.test(f.file)) continue; + // Generated type files (permissions.gen.ts) live here too. Skip them quietly: + // they export types only, and isSafeIslandName would otherwise reject the dot + // and warn on every boot. + if (/[.]gen[.](ts|js)$/.test(f.file)) continue; const name = basename(f.file).replace(/\.(ts|js)$/, ""); if (!isSafeIslandName(name)) { console.warn(`[wrnexus] skipping authz declaration with unsafe name: ${name}`); @@ -3243,7 +3249,7 @@ export async function loadAppAuthzCatalog(appDir: string): Promise 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 + // buildRouter already skips *.gen.ts, so only real declarations arrive here. const imported = (await import(pathToFileURL(entry.file).href)) as { default?: AuthzModule }; if (!imported.default) continue; sources.push({ source: entry.file, module: imported.default }); From e136fbc56a214286a0ce639037bfa352ceebabf7 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 20:09:16 +0530 Subject: [PATCH 38/59] fix(router): quietly skip permissions.gen.{ts,js} in authz scan Task 10 fix round 1: the coordinator's plan doc (41fb82b9) recorded that generated authz type files should be skipped before the isSafeIslandName check, but the code change never landed. isSafeIslandName rejects the dot in the stripped basename "permissions.gen", so every app running Task 12's codegen would warn on every boot. Add a quiet skip for *.gen.ts / *.gen.js immediately after the extension guard, before the name check. Add tests: a .gen.ts file is skipped without a console.warn (spied), and a .gen.js file is skipped the same way while a legitimately named .js declaration is still discovered. Also corrects the scanDir extraExtensions doc comment, which incorrectly implied app/schemas passes it too (only app/authz does). Co-Authored-By: Claude Opus 5 --- packages/router/src/index.ts | 4 ++++ packages/router/src/scan.ts | 9 +++---- packages/router/test/authz-discovery.test.ts | 25 +++++++++++++++++++- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 5923b5e3..9060e178 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -290,6 +290,10 @@ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router { const authz: ComponentRef[] = []; for (const f of scanDir(join(appDir, "authz"), [".js"])) { if (!/\.(ts|js)$/.test(f.file)) continue; + // Generated type files (permissions.gen.ts) live here too. Skip them quietly: + // they export types only, and isSafeIslandName would otherwise reject the dot + // and warn on every boot. + if (/[.]gen[.](ts|js)$/.test(f.file)) continue; const name = basename(f.file).replace(/\.(ts|js)$/, ""); if (!isSafeIslandName(name)) { console.warn(`[wrnexus] skipping authz declaration with unsafe name: ${name}`); diff --git a/packages/router/src/scan.ts b/packages/router/src/scan.ts index 7d6b7650..dce4d52d 100644 --- a/packages/router/src/scan.ts +++ b/packages/router/src/scan.ts @@ -33,10 +33,11 @@ function isIgnored(name: string): boolean { * Recursively collect allowed route files under `baseDir`. * Returns [] if the directory does not exist (a route kind may be unused). * - * `extraExtensions` widens the allow-list for callers that scan non-route - * directories (e.g. `app/schemas`, `app/authz`) and accept plain `.js` - * modules; it defaults to empty so route scanning (`app/pages`, `app/api`, - * `app/realtime`, ...) is unaffected. + * `extraExtensions` widens the allow-list for a caller that scans a non-route + * directory and accepts plain `.js` modules (currently only `app/authz`); it + * defaults to empty so every other caller — route scanning (`app/pages`, + * `app/api`, `app/realtime`, ...) as well as `app/schemas`, which does not + * pass it and so still only sees `.ts`/`.tsx`/`.wrn` — is unaffected. */ export function scanDir(baseDir: string, extraExtensions: readonly string[] = []): ScannedFile[] { if (!existsSync(baseDir)) return []; diff --git a/packages/router/test/authz-discovery.test.ts b/packages/router/test/authz-discovery.test.ts index 438e7fbe..196fdd47 100644 --- a/packages/router/test/authz-discovery.test.ts +++ b/packages/router/test/authz-discovery.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -41,4 +41,27 @@ describe("app/authz discovery", () => { mkdirSync(join(root, "app", "pages"), { recursive: true }); expect(buildRouter(join(root, "app")).authz).toEqual([]); }); + + test("quietly skips generated permissions.gen.ts without warning", () => { + const appDir = appWithAuthz({ + "permissions.gen.ts": "export type Foo = 1;", + "blog.ts": "export default {};", + }); + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + try { + const router = buildRouter(appDir); + expect(router.authz.map((entry) => entry.name)).toEqual(["blog"]); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); + + test("skips permissions.gen.js too, while a legitimately named declaration is still discovered", () => { + const appDir = appWithAuthz({ + "permissions.gen.js": "export const x = 1;", + "billing.js": "export default {};", + }); + expect(buildRouter(appDir).authz.map((entry) => entry.name)).toEqual(["billing"]); + }); }); From 218f5e2dd660ce1b8d85e76fb14c84ce66aaa82f Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 20:17:39 +0530 Subject: [PATCH 39/59] chore: add .gitattributes enforcing LF The repo had none, and core.autocrlf=true is the usual Git-on-Windows setting, so a clone, checkout, or stash pop silently rewrites every text file to CRLF. That fails format:check against prettier's endOfLine: lf - it already turned the gate red once mid-branch, after a stash round-trip reintroduced CRLF into files that had been committed clean. Verified: no tracked file currently carries a CR byte at HEAD. Co-Authored-By: Claude Opus 5 --- .gitattributes | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..4b30a400 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +# Enforce LF in the working tree regardless of a contributor's core.autocrlf. +# Without this, Git on Windows smudges every text file to CRLF on clone, stash +# pop, or checkout, which fails `bun run format:check` (prettier endOfLine: lf). +* text=auto eol=lf + +# Binary assets Git must not touch. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.pdf binary +*.woff binary +*.woff2 binary +*.db binary From 3fa3fce5df59c7c31eef9875b2741b2b41ff4e0a Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 20:22:31 +0530 Subject: [PATCH 40/59] feat(authz): add database-backed PermissionStore Adds dbPermissionStore/ensureAuthzTables/authzMigrationSql, backed by _wrn_authz_assignment and _wrn_authz_grant tables, plus a ./db subpath export. Passes the identical 19-test store-conformance suite the memory adapter passes, including tenant-scope isolation. --- docs/public-api-0.8.json | 5 ++ packages/authz/package.json | 3 +- packages/authz/src/db.ts | 91 ++++++++++++++++++++++++++++ packages/authz/src/migrations.ts | 43 +++++++++++++ packages/authz/test/store-db.test.ts | 11 ++++ tsconfig.json | 1 + 6 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 packages/authz/src/db.ts create mode 100644 packages/authz/src/migrations.ts create mode 100644 packages/authz/test/store-db.test.ts diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index 342c4e99..f29e0a5d 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -516,6 +516,11 @@ "requireRole", "safeRecord", "scopeKey" + ], + "./db": [ + "authzMigrationSql", + "dbPermissionStore", + "ensureAuthzTables" ] }, "@wrnexus/benchmark": { diff --git a/packages/authz/package.json b/packages/authz/package.json index a1dcf4ec..4fd24545 100644 --- a/packages/authz/package.json +++ b/packages/authz/package.json @@ -5,6 +5,7 @@ "type": "module", "main": "src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./db": "./src/db.ts" } } diff --git a/packages/authz/src/db.ts b/packages/authz/src/db.ts new file mode 100644 index 00000000..8e2aa24c --- /dev/null +++ b/packages/authz/src/db.ts @@ -0,0 +1,91 @@ +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. Do the delete+insert inside a + // transaction so a failed insert can't leave the row missing. + await db.tx(async (tx) => { + await tx.exec( + "DELETE FROM _wrn_authz_grant WHERE subject_id = ? AND scope = ? AND permission = ?", + [subjectId, key, permission], + ); + await tx.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))]; + }, + }; +} diff --git a/packages/authz/src/migrations.ts b/packages/authz/src/migrations.ts new file mode 100644 index 00000000..19117af0 --- /dev/null +++ b/packages/authz/src/migrations.ts @@ -0,0 +1,43 @@ +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 = "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 }; +} diff --git a/packages/authz/test/store-db.test.ts b/packages/authz/test/store-db.test.ts new file mode 100644 index 00000000..ebdb50d1 --- /dev/null +++ b/packages/authz/test/store-db.test.ts @@ -0,0 +1,11 @@ +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); +}); diff --git a/tsconfig.json b/tsconfig.json index b6e26c2a..5f00913d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,7 @@ "@wrnexus/jwt": ["./packages/jwt/src/index.ts"], "@wrnexus/oauth": ["./packages/oauth/src/index.ts"], "@wrnexus/authz": ["./packages/authz/src/index.ts"], + "@wrnexus/authz/db": ["./packages/authz/src/db.ts"], "@wrnexus/helpers": ["./packages/helpers/src/index.ts"], "@wrnexus/encryption": ["./packages/encryption/src/index.ts"], "@wrnexus/pubsub": ["./packages/pubsub/src/index.ts"], From f19462dff034c035825417416c30852ad6aca62c Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 20:33:45 +0530 Subject: [PATCH 41/59] docs: fix fail-open concurrency and effect handling in the Task 11 plan The database store review found two Critical defects and several Important ones, all reachable in production. grant() was wrapped in db.tx for atomicity. The sqlite driver runs a bare BEGIN on one shared connection with no serialization, so an open transaction swallows any concurrent write from another method and discards it on rollback. Demonstrated: revokeRole resolved with no error while the role survived - a security-critical revoke reporting success with the privilege retained. Concurrent grants also rejected outright with "cannot start a transaction within a transaction". Replaced with single-statement upserts, which are atomic without a transaction; assignRole likewise drops its check-then-act SELECT for ON CONFLICT DO NOTHING, which was rejecting 19 of 20 concurrent identical calls. effect had no CHECK constraint and assignmentsFor classified by exact equality, so a mis-cased or corrupted value was dropped from BOTH buckets - a deny row that silently stopped denying. Added the constraint and made anything that is not literally "allow" count as a deny. scopeKey now refuses an explicitly empty tenantId rather than treating it as global, which otherwise let a caller who controls the tenant id read and write global assignments. Also: ensureAuthzTables takes the dialect from db.driver.dialect instead of defaulting to sqlite; the DDL is a list of statements rather than a blob split on a formatting-dependent separator; MySQL identity columns get a binary collation so tenant "T1" cannot match "t1"; and postgres placeholders are numbered. Adds four conformance tests for the concurrency and empty-scope cases. The suite was entirely sequential and structurally could not catch any of this. Co-Authored-By: Claude Opus 5 --- ...-08-04-authz-permissions-implementation.md | 183 ++++++++++++------ 1 file changed, 129 insertions(+), 54 deletions(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index d015cef8..d2915789 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -598,6 +598,42 @@ export function runStoreConformance(name: string, makeStore: () => Promise { + await store.assignRole("g1", "viewer"); + // Otherwise a caller who controls the tenant id reaches global scope. + await expect(store.assignmentsFor("g1", { tenantId: "" })).rejects.toThrow(/tenantId/); + await expect(store.assignRole("g1", "admin", { tenantId: "" })).rejects.toThrow(/tenantId/); + }); + + test("concurrent identical assignRole calls all resolve", async () => { + // Check-then-act loses this race; the UNIQUE constraint then rejects + // every loser even though the desired end state was already reached. + await Promise.all(Array.from({ length: 20 }, () => store.assignRole("u1", "editor"))); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("concurrent grants on distinct keys all resolve", async () => { + await Promise.all([ + store.grant("u1", "post:read", "allow"), + store.grant("u1", "post:write", "allow"), + store.grant("u1", "post:delete", "deny"), + ]); + const assignments = await store.assignmentsFor("u1"); + expect(assignments.grants.sort()).toEqual(["post:read", "post:write"]); + expect(assignments.denies).toEqual(["post:delete"]); + }); + + test("a concurrent write is not lost to another method's failure", async () => { + // A store that wraps one method in a transaction on a shared connection + // will roll back this unrelated write and still resolve successfully. + await store.assignRole("victim", "admin"); + await Promise.all([ + store.revokeRole("victim", "admin"), + store.grant("other", "post:read", "allow").catch(() => undefined), + ]); + expect((await store.assignmentsFor("victim")).roles).toEqual([]); + }); + test("listSubjects with no scope returns global assignees only", async () => { await store.assignRole("g1", "viewer"); await store.assignRole("s1", "editor", { tenantId: "t1" }); @@ -646,9 +682,21 @@ export interface PermissionStore { listSubjects(scope?: AuthzScope): Promise; } -/** Global assignments are stored under the empty-string scope key. */ +/** + * Global assignments are stored under the empty-string scope key. An OMITTED + * scope means global; an explicitly EMPTY tenantId is refused, because it is + * indistinguishable from global and would let a caller who controls the tenant + * id read and write global assignments. + */ export function scopeKey(scope?: AuthzScope): string { - return scope?.tenantId ?? ""; + const tenantId = scope?.tenantId; + if (tenantId === undefined) return ""; + if (tenantId === "") { + throw new Error( + "WRN-AUTHZ-SCOPE: tenantId must not be empty; omit the scope for a global assignment.", + ); + } + return tenantId; } interface Row { @@ -2645,7 +2693,18 @@ import type { Dialect } from "@wrnexus/db"; * 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 } { +/** + * DDL for the two assignment tables, as a list of statements rather than one + * blob: splitting a blob on a separator makes runtime correctness depend on + * source formatting, and only the sqlite driver accepts multi-statement exec. + * + * `scope` holds a tenant id, or the empty string for a global assignment, so + * the unique constraints work on every dialect (NULL is not comparable in a + * UNIQUE index). `effect` is CHECK-constrained: an unrecognised value would + * otherwise be dropped from both the grant and deny buckets on read, silently + * turning a deny into a no-op. + */ +export function authzMigrationSql(dialect: Dialect): { up: string[]; down: string[] } { const id = dialect === "postgres" ? "SERIAL PRIMARY KEY" @@ -2653,33 +2712,33 @@ export function authzMigrationSql(dialect: Dialect): { up: string; down: string ? "INT AUTO_INCREMENT PRIMARY KEY" : "INTEGER PRIMARY KEY AUTOINCREMENT"; const timestamp = dialect === "sqlite" ? "TEXT" : "TIMESTAMP"; - const now = dialect === "sqlite" ? "CURRENT_TIMESTAMP" : "CURRENT_TIMESTAMP"; + // MySQL's default collation is case- and accent-insensitive, which would let + // tenant "T1" match "t1" and collapse roles "admin"/"Admin" onto one row. + const exact = dialect === "mysql" ? " COLLATE utf8mb4_bin" : ""; + const key = `VARCHAR(255)${exact} NOT NULL`; - const up = `CREATE TABLE IF NOT EXISTS _wrn_authz_assignment ( + return { + 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}, + subject_id ${key}, + scope ${key} DEFAULT '', + role ${key}, + created_at ${timestamp} NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role) -); - -CREATE TABLE IF NOT EXISTS _wrn_authz_grant ( +)`, + `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}, + subject_id ${key}, + scope ${key} DEFAULT '', + permission ${key}, + effect VARCHAR(16) NOT NULL CHECK (effect IN ('allow', 'deny')), + created_at ${timestamp} NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission) -);`; - - const down = `DROP TABLE IF EXISTS _wrn_authz_grant; -DROP TABLE IF EXISTS _wrn_authz_assignment;`; - - return { up, down }; +)`, + ], + down: ["DROP TABLE IF EXISTS _wrn_authz_grant", "DROP TABLE IF EXISTS _wrn_authz_assignment"], + }; } ``` @@ -2698,69 +2757,85 @@ import type { AuthzScope, SubjectAssignments } from "./types.ts"; 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};`); - } +/** Create the tables if absent. Production apps should use a real migration. */ +export async function ensureAuthzTables( + db: Db, + dialect: Dialect = db.driver.dialect, +): Promise { + for (const statement of authzMigrationSql(dialect).up) await db.exec(statement); +} + +/** Positional placeholder for the dialect: postgres numbers them, others use "?". */ +function ph(dialect: Dialect, index: number): string { + return dialect === "postgres" ? `$${index}` : "?"; } export function dbPermissionStore(db: Db): PermissionStore { + const dialect = db.driver.dialect; + const p = (n: number) => ph(dialect, n); + // Single-statement upserts. A transaction here would be worse than useless: + // the drivers run BEGIN on one shared connection, so an open transaction + // swallows any concurrent write from another method and discards it on + // rollback - a revoke would resolve successfully while the role survived. + const onConflict = (columns: string, update: string) => + dialect === "mysql" + ? ` ON DUPLICATE KEY UPDATE ${update}` + : ` ON CONFLICT (${columns}) DO UPDATE SET ${update}`; + const onConflictIgnore = (columns: string) => + dialect === "mysql" + ? " ON DUPLICATE KEY UPDATE id = id" + : ` ON CONFLICT (${columns}) DO NOTHING`; + return { async assignmentsFor(subjectId: string, scope?: AuthzScope): Promise { 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 = ?)", + `SELECT role FROM _wrn_authz_assignment WHERE subject_id = ${p(1)} AND (scope = '' OR scope = ${p(2)})`, [subjectId, key], ); - const grantRows = await db.all<{ permission: string; effect: GrantEffect }>( - "SELECT permission, effect FROM _wrn_authz_grant WHERE subject_id = ? AND (scope = '' OR scope = ?)", + const grantRows = await db.all<{ permission: string; effect: string }>( + `SELECT permission, effect FROM _wrn_authz_grant WHERE subject_id = ${p(1)} AND (scope = '' OR scope = ${p(2)})`, [subjectId, key], ); return { roles: roleRows.map((row) => row.role), grants: grantRows.filter((r) => r.effect === "allow").map((r) => r.permission), - denies: grantRows.filter((r) => r.effect === "deny").map((r) => r.permission), + // Anything that is not literally "allow" counts as a deny, so a + // corrupted or mis-cased effect fails closed rather than vanishing. + denies: grantRows.filter((r) => r.effect !== "allow").map((r) => r.permission), }; }, async assignRole(subjectId, role, scope) { - 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], + `INSERT INTO _wrn_authz_assignment (subject_id, scope, role) VALUES (${p(1)}, ${p(2)}, ${p(3)})` + + onConflictIgnore("subject_id, scope, role"), + [subjectId, scopeKey(scope), role], ); }, async revokeRole(subjectId, role, scope) { await db.exec( - "DELETE FROM _wrn_authz_assignment WHERE subject_id = ? AND scope = ? AND role = ?", + `DELETE FROM _wrn_authz_assignment WHERE subject_id = ${p(1)} AND scope = ${p(2)} AND role = ${p(3)}`, [subjectId, scopeKey(scope), role], ); }, async grant(subjectId, permission, effect, scope) { - 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], + `INSERT INTO _wrn_authz_grant (subject_id, scope, permission, effect) VALUES (${p(1)}, ${p(2)}, ${p(3)}, ${p(4)})` + + onConflict( + "subject_id, scope, permission", + "effect = " + (dialect === "mysql" ? "VALUES(effect)" : "excluded.effect"), + ), + [subjectId, scopeKey(scope), permission, effect], ); }, async revokeGrant(subjectId, permission, scope) { await db.exec( - "DELETE FROM _wrn_authz_grant WHERE subject_id = ? AND scope = ? AND permission = ?", + `DELETE FROM _wrn_authz_grant WHERE subject_id = ${p(1)} AND scope = ${p(2)} AND permission = ${p(3)}`, [subjectId, scopeKey(scope), permission], ); }, @@ -2768,8 +2843,8 @@ export function dbPermissionStore(db: Db): PermissionStore { 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 = ?", + `SELECT subject_id FROM _wrn_authz_assignment WHERE scope = ${p(1)} ` + + `UNION SELECT subject_id FROM _wrn_authz_grant WHERE scope = ${p(2)}`, [key, key], ); return [...new Set(rows.map((row) => row.subject_id))]; From 205f4e2d4cf429b4c13c9f2099421b9fe4d8318c Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 20:46:48 +0530 Subject: [PATCH 42/59] fix(authz): close fail-open db store defects from review round 1 C1/C2: grant() wrapped its delete+insert in db.tx on a shared, unserialized sqlite connection, so a concurrent bare write from another method (e.g. revokeRole) got swept into the open transaction and discarded on rollback - a revoke could report success while the privilege survived. Also broke concurrent grants on distinct keys ("cannot start a transaction within a transaction"). Replaced with single-statement upserts (ON CONFLICT / ON DUPLICATE KEY UPDATE), atomic without a transaction. I1: assignRole's check-then-act SELECT lost 19/20 concurrent identical calls to a UNIQUE violation; switched to ON CONFLICT DO NOTHING. I2: an unrecognised `effect` value was dropped from both the grant and deny buckets on read. Added a CHECK constraint and made anything not literally "allow" count as a deny (fail closed). I3: ensureAuthzTables defaulted to sqlite instead of the Db's own dialect. I4: scopeKey now refuses an explicitly empty tenantId rather than treating it as global (shared with the memory adapter). I5: added migrations.test.ts asserting the generated DDL per dialect, including MySQL's binary collation on identity columns. M1: DDL is now a statement list instead of a blob split on a formatting-dependent separator. M3: declared @wrnexus/db as a workspace dependency. Extends the conformance suite with four concurrency/empty-scope tests (23 total, up from 19) that all three adapters now pass. Co-Authored-By: Claude Opus 5 --- packages/authz/package.json | 3 + packages/authz/src/db.ts | 82 ++++++++++++++---------- packages/authz/src/migrations.ts | 58 +++++++++-------- packages/authz/src/store.ts | 16 ++++- packages/authz/test/migrations.test.ts | 75 ++++++++++++++++++++++ packages/authz/test/store-conformance.ts | 36 +++++++++++ 6 files changed, 207 insertions(+), 63 deletions(-) create mode 100644 packages/authz/test/migrations.test.ts diff --git a/packages/authz/package.json b/packages/authz/package.json index 4fd24545..94afc646 100644 --- a/packages/authz/package.json +++ b/packages/authz/package.json @@ -7,5 +7,8 @@ "exports": { ".": "./src/index.ts", "./db": "./src/db.ts" + }, + "dependencies": { + "@wrnexus/db": "workspace:*" } } diff --git a/packages/authz/src/db.ts b/packages/authz/src/db.ts index 8e2aa24c..69ba7578 100644 --- a/packages/authz/src/db.ts +++ b/packages/authz/src/db.ts @@ -1,6 +1,6 @@ import type { Db, Dialect } from "@wrnexus/db"; import { authzMigrationSql } from "./migrations.ts"; -import { scopeKey, type GrantEffect, type PermissionStore } from "./store.ts"; +import { scopeKey, 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 @@ -8,72 +8,84 @@ import type { AuthzScope, SubjectAssignments } from "./types.ts"; 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 async function ensureAuthzTables( + db: Db, + dialect: Dialect = db.driver.dialect, +): Promise { + for (const statement of authzMigrationSql(dialect).up) await db.exec(statement); +} + +/** Positional placeholder for the dialect: postgres numbers them, others use "?". */ +function ph(dialect: Dialect, index: number): string { + return dialect === "postgres" ? `$${index}` : "?"; } export function dbPermissionStore(db: Db): PermissionStore { + const dialect = db.driver.dialect; + const p = (n: number) => ph(dialect, n); + // Single-statement upserts. A transaction here would be worse than useless: + // the drivers run BEGIN on one shared connection, so an open transaction + // swallows any concurrent write from another method and discards it on + // rollback - a revoke would resolve successfully while the role survived. + const onConflict = (columns: string, update: string) => + dialect === "mysql" + ? ` ON DUPLICATE KEY UPDATE ${update}` + : ` ON CONFLICT (${columns}) DO UPDATE SET ${update}`; + const onConflictIgnore = (columns: string) => + dialect === "mysql" + ? " ON DUPLICATE KEY UPDATE id = id" + : ` ON CONFLICT (${columns}) DO NOTHING`; + return { async assignmentsFor(subjectId: string, scope?: AuthzScope): Promise { 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 = ?)", + `SELECT role FROM _wrn_authz_assignment WHERE subject_id = ${p(1)} AND (scope = '' OR scope = ${p(2)})`, [subjectId, key], ); - const grantRows = await db.all<{ permission: string; effect: GrantEffect }>( - "SELECT permission, effect FROM _wrn_authz_grant WHERE subject_id = ? AND (scope = '' OR scope = ?)", + const grantRows = await db.all<{ permission: string; effect: string }>( + `SELECT permission, effect FROM _wrn_authz_grant WHERE subject_id = ${p(1)} AND (scope = '' OR scope = ${p(2)})`, [subjectId, key], ); return { roles: roleRows.map((row) => row.role), grants: grantRows.filter((r) => r.effect === "allow").map((r) => r.permission), - denies: grantRows.filter((r) => r.effect === "deny").map((r) => r.permission), + // Anything that is not literally "allow" counts as a deny, so a + // corrupted or mis-cased effect fails closed rather than vanishing. + denies: grantRows.filter((r) => r.effect !== "allow").map((r) => r.permission), }; }, async assignRole(subjectId, role, scope) { - 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], + `INSERT INTO _wrn_authz_assignment (subject_id, scope, role) VALUES (${p(1)}, ${p(2)}, ${p(3)})` + + onConflictIgnore("subject_id, scope, role"), + [subjectId, scopeKey(scope), role], ); }, async revokeRole(subjectId, role, scope) { await db.exec( - "DELETE FROM _wrn_authz_assignment WHERE subject_id = ? AND scope = ? AND role = ?", + `DELETE FROM _wrn_authz_assignment WHERE subject_id = ${p(1)} AND scope = ${p(2)} AND role = ${p(3)}`, [subjectId, scopeKey(scope), role], ); }, async grant(subjectId, permission, effect, scope) { - const key = scopeKey(scope); - // Re-granting replaces the effect. Do the delete+insert inside a - // transaction so a failed insert can't leave the row missing. - await db.tx(async (tx) => { - await tx.exec( - "DELETE FROM _wrn_authz_grant WHERE subject_id = ? AND scope = ? AND permission = ?", - [subjectId, key, permission], - ); - await tx.exec( - "INSERT INTO _wrn_authz_grant (subject_id, scope, permission, effect) VALUES (?, ?, ?, ?)", - [subjectId, key, permission, effect], - ); - }); + await db.exec( + `INSERT INTO _wrn_authz_grant (subject_id, scope, permission, effect) VALUES (${p(1)}, ${p(2)}, ${p(3)}, ${p(4)})` + + onConflict( + "subject_id, scope, permission", + "effect = " + (dialect === "mysql" ? "VALUES(effect)" : "excluded.effect"), + ), + [subjectId, scopeKey(scope), permission, effect], + ); }, async revokeGrant(subjectId, permission, scope) { await db.exec( - "DELETE FROM _wrn_authz_grant WHERE subject_id = ? AND scope = ? AND permission = ?", + `DELETE FROM _wrn_authz_grant WHERE subject_id = ${p(1)} AND scope = ${p(2)} AND permission = ${p(3)}`, [subjectId, scopeKey(scope), permission], ); }, @@ -81,8 +93,8 @@ export function dbPermissionStore(db: Db): PermissionStore { 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 = ?", + `SELECT subject_id FROM _wrn_authz_assignment WHERE scope = ${p(1)} ` + + `UNION SELECT subject_id FROM _wrn_authz_grant WHERE scope = ${p(2)}`, [key, key], ); return [...new Set(rows.map((row) => row.subject_id))]; diff --git a/packages/authz/src/migrations.ts b/packages/authz/src/migrations.ts index 19117af0..e4107c62 100644 --- a/packages/authz/src/migrations.ts +++ b/packages/authz/src/migrations.ts @@ -1,11 +1,17 @@ import type { Dialect } from "@wrnexus/db"; /** - * DDL for the two assignment tables. `scope` holds a tenant id, or the empty - * string for a global assignment, so the unique constraints work on every - * dialect (NULL is not comparable in a UNIQUE index). + * DDL for the two assignment tables, as a list of statements rather than one + * blob: splitting a blob on a separator makes runtime correctness depend on + * source formatting, and only the sqlite driver accepts multi-statement exec. + * + * `scope` holds a tenant id, or the empty string for a global assignment, so + * the unique constraints work on every dialect (NULL is not comparable in a + * UNIQUE index). `effect` is CHECK-constrained: an unrecognised value would + * otherwise be dropped from both the grant and deny buckets on read, silently + * turning a deny into a no-op. */ -export function authzMigrationSql(dialect: Dialect): { up: string; down: string } { +export function authzMigrationSql(dialect: Dialect): { up: string[]; down: string[] } { const id = dialect === "postgres" ? "SERIAL PRIMARY KEY" @@ -13,31 +19,31 @@ export function authzMigrationSql(dialect: Dialect): { up: string; down: string ? "INT AUTO_INCREMENT PRIMARY KEY" : "INTEGER PRIMARY KEY AUTOINCREMENT"; const timestamp = dialect === "sqlite" ? "TEXT" : "TIMESTAMP"; - const now = "CURRENT_TIMESTAMP"; + // MySQL's default collation is case- and accent-insensitive, which would let + // tenant "T1" match "t1" and collapse roles "admin"/"Admin" onto one row. + const exact = dialect === "mysql" ? " COLLATE utf8mb4_bin" : ""; + const key = `VARCHAR(255)${exact} NOT NULL`; - const up = `CREATE TABLE IF NOT EXISTS _wrn_authz_assignment ( + return { + 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}, + subject_id ${key}, + scope ${key} DEFAULT '', + role ${key}, + created_at ${timestamp} NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role) -); - -CREATE TABLE IF NOT EXISTS _wrn_authz_grant ( +)`, + `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}, + subject_id ${key}, + scope ${key} DEFAULT '', + permission ${key}, + effect VARCHAR(16) NOT NULL CHECK (effect IN ('allow', 'deny')), + created_at ${timestamp} NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission) -);`; - - const down = `DROP TABLE IF EXISTS _wrn_authz_grant; -DROP TABLE IF EXISTS _wrn_authz_assignment;`; - - return { up, down }; +)`, + ], + down: ["DROP TABLE IF EXISTS _wrn_authz_grant", "DROP TABLE IF EXISTS _wrn_authz_assignment"], + }; } diff --git a/packages/authz/src/store.ts b/packages/authz/src/store.ts index a8848814..89c1fe7e 100644 --- a/packages/authz/src/store.ts +++ b/packages/authz/src/store.ts @@ -16,9 +16,21 @@ export interface PermissionStore { listSubjects(scope?: AuthzScope): Promise; } -/** Global assignments are stored under the empty-string scope key. */ +/** + * Global assignments are stored under the empty-string scope key. An OMITTED + * scope means global; an explicitly EMPTY tenantId is refused, because it is + * indistinguishable from global and would let a caller who controls the tenant + * id read and write global assignments. + */ export function scopeKey(scope?: AuthzScope): string { - return scope?.tenantId ?? ""; + const tenantId = scope?.tenantId; + if (tenantId === undefined) return ""; + if (tenantId === "") { + throw new Error( + "WRN-AUTHZ-SCOPE: tenantId must not be empty; omit the scope for a global assignment.", + ); + } + return tenantId; } interface Row { diff --git a/packages/authz/test/migrations.test.ts b/packages/authz/test/migrations.test.ts new file mode 100644 index 00000000..81064a4f --- /dev/null +++ b/packages/authz/test/migrations.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test"; +import { authzMigrationSql } from "../src/migrations.ts"; + +/** + * The postgres/mysql DDL is generated but never exercised against a real + * server in this repo, so it has to be asserted statically: the id column + * type, the `effect` CHECK constraint (an unrecognised value must not vanish + * from both the grant and deny buckets), the MySQL binary collation (so + * tenant "T1" cannot match "t1" and role "admin" cannot collapse with + * "Admin"), and both UNIQUE constraints, per dialect. + */ +describe("authzMigrationSql", () => { + test("sqlite: autoincrement id, no collation, both constraints", () => { + const { up, down } = authzMigrationSql("sqlite"); + expect(up).toHaveLength(2); + const [assignment, grant] = up; + + expect(assignment).toContain("id INTEGER PRIMARY KEY AUTOINCREMENT"); + expect(assignment).toContain( + "CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role)", + ); + expect(assignment).not.toContain("COLLATE"); + + expect(grant).toContain("id INTEGER PRIMARY KEY AUTOINCREMENT"); + expect(grant).toContain("effect VARCHAR(16) NOT NULL CHECK (effect IN ('allow', 'deny'))"); + expect(grant).toContain( + "CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission)", + ); + expect(grant).not.toContain("COLLATE"); + + expect(down).toEqual([ + "DROP TABLE IF EXISTS _wrn_authz_grant", + "DROP TABLE IF EXISTS _wrn_authz_assignment", + ]); + }); + + test("postgres: SERIAL id, no collation, both constraints", () => { + const { up } = authzMigrationSql("postgres"); + const [assignment, grant] = up; + + expect(assignment).toContain("id SERIAL PRIMARY KEY"); + expect(assignment).toContain( + "CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role)", + ); + expect(assignment).not.toContain("COLLATE"); + + expect(grant).toContain("id SERIAL PRIMARY KEY"); + expect(grant).toContain("effect VARCHAR(16) NOT NULL CHECK (effect IN ('allow', 'deny'))"); + expect(grant).toContain( + "CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission)", + ); + expect(grant).not.toContain("COLLATE"); + }); + + test("mysql: AUTO_INCREMENT id, binary collation on identity columns, both constraints", () => { + const { up } = authzMigrationSql("mysql"); + const [assignment, grant] = up; + + expect(assignment).toContain("id INT AUTO_INCREMENT PRIMARY KEY"); + expect(assignment).toContain("subject_id VARCHAR(255) COLLATE utf8mb4_bin NOT NULL"); + expect(assignment).toContain("scope VARCHAR(255) COLLATE utf8mb4_bin NOT NULL DEFAULT ''"); + expect(assignment).toContain("role VARCHAR(255) COLLATE utf8mb4_bin NOT NULL"); + expect(assignment).toContain( + "CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role)", + ); + + expect(grant).toContain("id INT AUTO_INCREMENT PRIMARY KEY"); + expect(grant).toContain("subject_id VARCHAR(255) COLLATE utf8mb4_bin NOT NULL"); + expect(grant).toContain("permission VARCHAR(255) COLLATE utf8mb4_bin NOT NULL"); + expect(grant).toContain("effect VARCHAR(16) NOT NULL CHECK (effect IN ('allow', 'deny'))"); + expect(grant).toContain( + "CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission)", + ); + }); +}); diff --git a/packages/authz/test/store-conformance.ts b/packages/authz/test/store-conformance.ts index f0660950..a9154d8e 100644 --- a/packages/authz/test/store-conformance.ts +++ b/packages/authz/test/store-conformance.ts @@ -129,6 +129,42 @@ export function runStoreConformance(name: string, makeStore: () => Promise { + await store.assignRole("g1", "viewer"); + // Otherwise a caller who controls the tenant id reaches global scope. + await expect(store.assignmentsFor("g1", { tenantId: "" })).rejects.toThrow(/tenantId/); + await expect(store.assignRole("g1", "admin", { tenantId: "" })).rejects.toThrow(/tenantId/); + }); + + test("concurrent identical assignRole calls all resolve", async () => { + // Check-then-act loses this race; the UNIQUE constraint then rejects + // every loser even though the desired end state was already reached. + await Promise.all(Array.from({ length: 20 }, () => store.assignRole("u1", "editor"))); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("concurrent grants on distinct keys all resolve", async () => { + await Promise.all([ + store.grant("u1", "post:read", "allow"), + store.grant("u1", "post:write", "allow"), + store.grant("u1", "post:delete", "deny"), + ]); + const assignments = await store.assignmentsFor("u1"); + expect(assignments.grants.sort()).toEqual(["post:read", "post:write"]); + expect(assignments.denies).toEqual(["post:delete"]); + }); + + test("a concurrent write is not lost to another method's failure", async () => { + // A store that wraps one method in a transaction on a shared connection + // will roll back this unrelated write and still resolve successfully. + await store.assignRole("victim", "admin"); + await Promise.all([ + store.revokeRole("victim", "admin"), + store.grant("other", "post:read", "allow").catch(() => undefined), + ]); + expect((await store.assignmentsFor("victim")).roles).toEqual([]); + }); + test("listSubjects with no scope returns global assignees only", async () => { await store.assignRole("g1", "viewer"); await store.assignRole("s1", "editor", { tenantId: "t1" }); From 134c5fa4bcd52c06d2fff78dd8b78c623f677ed2 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 20:48:38 +0530 Subject: [PATCH 43/59] docs: replace a vacuous conformance test with an honest one I added "a concurrent write is not lost to another method's failure" to the conformance suite to guard the fail-open the Task 11 review demonstrated. The implementer reported they could not make it fail against the reverted code, across 600 stress iterations. They were right. I reproduced the underlying defect directly - forcing the transaction to open before the bare write gives "revoke resolved without error: true" with the role still present - so the mechanism is real. But the test cannot reach it: Promise.all in one process does not reliably land the bare write inside the open transaction, and grant() never fails on its own. The test passed against the defective implementation, which is exactly the false assurance this suite exists to prevent. Replaced with a property that is actually guaranteed and adapter-agnostic: a rejected write leaves unrelated state intact. The rollback hazard itself is prevented structurally, by the store using no transactions, and that is now stated in a comment rather than pretended to be under test. Co-Authored-By: Claude Opus 5 --- ...-08-04-authz-permissions-implementation.md | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index d2915789..ea1c93ae 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -623,17 +623,25 @@ export function runStoreConformance(name: string, makeStore: () => Promise { - // A store that wraps one method in a transaction on a shared connection - // will roll back this unrelated write and still resolve successfully. + test("a rejected write leaves unrelated state intact", async () => { await store.assignRole("victim", "admin"); - await Promise.all([ - store.revokeRole("victim", "admin"), - store.grant("other", "post:read", "allow").catch(() => undefined), - ]); - expect((await store.assignmentsFor("victim")).roles).toEqual([]); + await store.grant("victim", "post:read", "allow"); + // An invalid effect must be refused without disturbing anything else. + await expect(store.grant("victim", "post:write", "bogus" as never)).rejects.toThrow(); + const assignments = await store.assignmentsFor("victim"); + expect(assignments.roles).toEqual(["admin"]); + expect(assignments.grants).toEqual(["post:read"]); }); + // NOTE: the shared-connection rollback hazard - where one method's open + // transaction sweeps in a concurrent bare write from another method and + // discards it, so a revoke resolves successfully while the role survives - + // is prevented STRUCTURALLY, by the store using no transactions at all. + // It is deliberately not covered here: reproducing it needs the bare write + // to land inside the open transaction, which a single-process Promise.all + // does not reliably arrange, so any such test would pass against the + // defective implementation and give false assurance. + test("listSubjects with no scope returns global assignees only", async () => { await store.assignRole("g1", "viewer"); await store.assignRole("s1", "editor", { tenantId: "t1" }); From 1cc0b97a72ff2bc19f249128f80bd895d843aabb Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 20:52:18 +0530 Subject: [PATCH 44/59] fix(authz): replace vacuous concurrency test, validate effect in memory store The fix-round-1 test "a concurrent write is not lost to another method's failure" was vacuous: a single-process Promise.all cannot reliably land a bare write inside another method's open transaction, so it passed against both the fixed and the (previously) defective grant() implementation. The shared-connection rollback hazard it was meant to catch is real (confirmed separately by forcing the transaction open before the write), but this specific test could never reach that state and gave false assurance either way. Replaced it with "a rejected write leaves unrelated state intact", which asserts a grant() call with an invalid effect is refused without disturbing the subject's existing roles/grants, plus a NOTE documenting that the rollback hazard is now prevented structurally (no transactions) rather than by a dedicated concurrency test. memoryPermissionStore.grant() had no effect validation, so it failed the new test; added a guard mirroring the db adapter's CHECK constraint so both adapters agree on rejecting anything other than "allow"/"deny". Co-Authored-By: Claude Opus 5 --- packages/authz/src/store.ts | 8 ++++++++ packages/authz/test/store-conformance.ts | 24 ++++++++++++++++-------- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/authz/src/store.ts b/packages/authz/src/store.ts index 89c1fe7e..11480bab 100644 --- a/packages/authz/src/store.ts +++ b/packages/authz/src/store.ts @@ -77,6 +77,14 @@ export function memoryPermissionStore(): PermissionStore { if (at !== -1) roles.splice(at, 1); }, async grant(subjectId, permission, effect, scope) { + // The db adapter enforces this via a CHECK constraint; the memory + // adapter must agree, or a bad effect would silently vanish from both + // the grant and deny buckets on read instead of being refused up front. + if (effect !== "allow" && effect !== "deny") { + throw new TypeError( + `WRN-AUTHZ-EFFECT: effect must be "allow" or "deny", received ${JSON.stringify(effect)}`, + ); + } const key = scopeKey(scope); const at = grants.findIndex( (g) => g.subjectId === subjectId && g.scope === key && g.permission === permission, diff --git a/packages/authz/test/store-conformance.ts b/packages/authz/test/store-conformance.ts index a9154d8e..cb82f99f 100644 --- a/packages/authz/test/store-conformance.ts +++ b/packages/authz/test/store-conformance.ts @@ -154,17 +154,25 @@ export function runStoreConformance(name: string, makeStore: () => Promise { - // A store that wraps one method in a transaction on a shared connection - // will roll back this unrelated write and still resolve successfully. + test("a rejected write leaves unrelated state intact", async () => { await store.assignRole("victim", "admin"); - await Promise.all([ - store.revokeRole("victim", "admin"), - store.grant("other", "post:read", "allow").catch(() => undefined), - ]); - expect((await store.assignmentsFor("victim")).roles).toEqual([]); + await store.grant("victim", "post:read", "allow"); + // An invalid effect must be refused without disturbing anything else. + await expect(store.grant("victim", "post:write", "bogus" as never)).rejects.toThrow(); + const assignments = await store.assignmentsFor("victim"); + expect(assignments.roles).toEqual(["admin"]); + expect(assignments.grants).toEqual(["post:read"]); }); + // NOTE: the shared-connection rollback hazard - where one method's open + // transaction sweeps in a concurrent bare write from another method and + // discards it, so a revoke resolves successfully while the role survives - + // is prevented STRUCTURALLY, by the store using no transactions at all. + // It is deliberately not covered here: reproducing it needs the bare write + // to land inside the open transaction, which a single-process Promise.all + // does not reliably arrange, so any such test would pass against the + // defective implementation and give false assurance. + test("listSubjects with no scope returns global assignees only", async () => { await store.assignRole("g1", "viewer"); await store.assignRole("s1", "editor", { tenantId: "t1" }); From 2fbf059c00d59a498fd833181421e7a94e072069 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 21:01:26 +0530 Subject: [PATCH 45/59] docs: type-guard tenantId and add a no-transaction regression guard Two gaps the Task 11 re-review left open. scopeKey guarded the empty-string tenantId but not its type, so null, 0, false or an object flowed through un-normalised and the adapters diverged - the db rejects on NOT NULL while memory accepts an unreachable row. The whole premise of the empty-string guard was a caller who controls the tenant id, and that caller can just as easily hand over a null from a JSON body. The vacuous concurrency test was removed for good reason, but that left nothing failing if someone re-wraps grant() in db.tx and reintroduces the shared-connection rollback. A spy over driver.transaction discriminates that deterministically, with no timing dependency. Co-Authored-By: Claude Opus 5 --- ...26-08-04-authz-permissions-implementation.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index ea1c93ae..95677ba5 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -605,6 +605,15 @@ export function runStoreConformance(name: string, makeStore: () => Promise { + // Same class as the empty-string case: the caller controls this value. + for (const bad of [null, 0, false, {}]) { + await expect(store.assignmentsFor("u1", { tenantId: bad as never })).rejects.toThrow( + /tenantId/, + ); + } + }); + test("concurrent identical assignRole calls all resolve", async () => { // Check-then-act loses this race; the UNIQUE constraint then rejects // every loser even though the desired end state was already reached. @@ -699,9 +708,13 @@ export interface PermissionStore { export function scopeKey(scope?: AuthzScope): string { const tenantId = scope?.tenantId; if (tenantId === undefined) return ""; - if (tenantId === "") { + // Guard the TYPE as well as the value: a null from a JSON body or a nullable + // column would otherwise flow through un-normalised and the adapters would + // disagree about what happened - the db rejects on NOT NULL, memory accepts + // an unreachable row. + if (typeof tenantId !== "string" || tenantId === "") { throw new Error( - "WRN-AUTHZ-SCOPE: tenantId must not be empty; omit the scope for a global assignment.", + "WRN-AUTHZ-SCOPE: tenantId must be a non-empty string; omit the scope for a global assignment.", ); } return tenantId; From 91e5c6e0c50abf226f22fbb03298cd36638d31e2 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 21:06:11 +0530 Subject: [PATCH 46/59] fix(authz): guard scopeKey's tenantId type, add deterministic C1/C2 guard N1: scopeKey guarded the empty-string VALUE but not the TYPE. A non-string tenantId (null, 0, false, an object) flowed through un-normalised, and the adapters disagreed about the result - db rejects null on NOT NULL, memory accepts it as an unreachable row; 0 and false stringify differently and could collide. Now `typeof tenantId !== "string" || tenantId === ""` is refused with the same WRN-AUTHZ-SCOPE error. Added a conformance case covering null/0/false/{}. N2: nothing failed if grant() were re-wrapped in db.tx, reintroducing the shared-connection rollback from C1/C2 - timing-based tests can't reliably prove a transaction is never opened. Added db-no-transaction.test.ts: a fake Db with a spied driver.transaction and statement-recording all/exec, driving every PermissionStore method and asserting zero transaction calls and no "BEGIN" in any recorded statement. Verified it fails when grant() is temporarily re-wrapped in db.tx, then restored. Also documents two things in db.ts as comments only: the UNIQUE constraints are now load-bearing for ON CONFLICT/ON DUPLICATE KEY target inference, and MySQL's VALUES(effect) upsert syntax is deprecated since 8.0.20 (no MySQL server in CI to catch its removal). Co-Authored-By: Claude Opus 5 --- packages/authz/src/db.ts | 15 +++- packages/authz/src/store.ts | 17 ++-- packages/authz/test/db-no-transaction.test.ts | 85 +++++++++++++++++++ packages/authz/test/store-conformance.ts | 9 ++ 4 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 packages/authz/test/db-no-transaction.test.ts diff --git a/packages/authz/src/db.ts b/packages/authz/src/db.ts index 69ba7578..58b04d4d 100644 --- a/packages/authz/src/db.ts +++ b/packages/authz/src/db.ts @@ -7,7 +7,16 @@ import type { AuthzScope, SubjectAssignments } from "./types.ts"; // database-related, including the DDL the CLI scaffolds. export { authzMigrationSql } from "./migrations.ts"; -/** Create the tables if absent. Production apps should use a real migration. */ +/** + * Create the tables if absent. Production apps should use a real migration. + * + * The UNIQUE constraints in this DDL are load-bearing beyond deduplication: + * `grant`/`assignRole` below use ON CONFLICT / ON DUPLICATE KEY, which infers + * its conflict target from them. A hand-rolled migration that recreates these + * tables without `_wrn_authz_grant_unique` (or the assignment equivalent) + * will make those methods reject outright, where the old delete-then-insert + * approach would have silently worked without the constraint. + */ export async function ensureAuthzTables( db: Db, dialect: Dialect = db.driver.dialect, @@ -73,6 +82,10 @@ export function dbPermissionStore(db: Db): PermissionStore { }, async grant(subjectId, permission, effect, scope) { + // `VALUES(effect)` is deprecated as of MySQL 8.0.20 in favour of the + // row-alias form (`... VALUES (...) AS new ON DUPLICATE KEY UPDATE + // effect = new.effect`). Noted here rather than migrated because there + // is no MySQL server in CI to catch its eventual removal. await db.exec( `INSERT INTO _wrn_authz_grant (subject_id, scope, permission, effect) VALUES (${p(1)}, ${p(2)}, ${p(3)}, ${p(4)})` + onConflict( diff --git a/packages/authz/src/store.ts b/packages/authz/src/store.ts index 11480bab..3de33d14 100644 --- a/packages/authz/src/store.ts +++ b/packages/authz/src/store.ts @@ -18,16 +18,23 @@ export interface PermissionStore { /** * Global assignments are stored under the empty-string scope key. An OMITTED - * scope means global; an explicitly EMPTY tenantId is refused, because it is - * indistinguishable from global and would let a caller who controls the tenant - * id read and write global assignments. + * scope means global; an explicitly EMPTY or non-string tenantId is refused, + * because an empty string is indistinguishable from global (and would let a + * caller who controls the tenant id read and write global assignments), and a + * non-string value (e.g. `null` from a JSON body or a nullable column) would + * otherwise flow through un-normalised and leave the adapters disagreeing + * about what happened. */ export function scopeKey(scope?: AuthzScope): string { const tenantId = scope?.tenantId; if (tenantId === undefined) return ""; - if (tenantId === "") { + // Guard the TYPE as well as the value: a null from a JSON body or a nullable + // column would otherwise flow through un-normalised and the adapters would + // disagree about what happened - the db rejects on NOT NULL, memory accepts + // an unreachable row. + if (typeof tenantId !== "string" || tenantId === "") { throw new Error( - "WRN-AUTHZ-SCOPE: tenantId must not be empty; omit the scope for a global assignment.", + "WRN-AUTHZ-SCOPE: tenantId must be a non-empty string; omit the scope for a global assignment.", ); } return tenantId; diff --git a/packages/authz/test/db-no-transaction.test.ts b/packages/authz/test/db-no-transaction.test.ts new file mode 100644 index 00000000..60a89e7f --- /dev/null +++ b/packages/authz/test/db-no-transaction.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import type { Db, Dialect, Driver, ExecResult, Row, TxHandle } from "@wrnexus/db"; +import { dbPermissionStore } from "../src/db.ts"; + +/** + * A deterministic regression guard for C1/C2 (round-1 review): grant() used to + * wrap its delete-then-insert in db.tx, and the sqlite driver runs a bare + * BEGIN on one shared, unserialized connection - so an open transaction there + * could sweep in and discard a concurrent bare write from another method. + * Timing-based tests can't reliably prove the absence of that; this can, + * because it needs no concurrency at all - it just asserts the store never + * asks the driver to open a transaction in the first place. + */ +function makeFakeDb(): { db: Db; statements: string[]; transactionCalls: number } { + const statements: string[] = []; + const stats = { transactionCalls: 0 }; + + const driver: Driver = { + dialect: "sqlite" as Dialect, + async query(sql: string): Promise { + statements.push(sql); + return []; + }, + async exec(sql: string): Promise { + statements.push(sql); + return { changes: 0 }; + }, + async transaction(fn: (tx: TxHandle) => Promise): Promise { + // The spy: this must never be called by a transaction-free store. + stats.transactionCalls++; + statements.push("BEGIN"); + return fn(driver); + }, + close() {}, + }; + + const db: Db = { + driver, + async all(sql: string): Promise { + statements.push(sql); + return []; + }, + async one(sql: string): Promise { + statements.push(sql); + return null; + }, + async exec(sql: string): Promise { + statements.push(sql); + return { changes: 0 }; + }, + async tx(fn: (tx: Db) => Promise): Promise { + // Routed through the same driver.transaction spy a real Db would use. + return driver.transaction(() => fn(db)); + }, + async createTable() {}, + close() {}, + }; + + return { + db, + statements, + get transactionCalls() { + return stats.transactionCalls; + }, + }; +} + +describe("dbPermissionStore opens no transaction", () => { + test("the store opens no transaction: a shared-connection rollback would discard concurrent writes from other methods", async () => { + const fake = makeFakeDb(); + const store = dbPermissionStore(fake.db); + + await store.assignmentsFor("u1"); + await store.assignRole("u1", "editor"); + await store.revokeRole("u1", "editor"); + await store.grant("u1", "post:write", "allow"); + await store.revokeGrant("u1", "post:write"); + await store.listSubjects(); + + expect(fake.transactionCalls).toBe(0); + for (const sql of fake.statements) { + expect(sql).not.toContain("BEGIN"); + } + }); +}); diff --git a/packages/authz/test/store-conformance.ts b/packages/authz/test/store-conformance.ts index cb82f99f..f9b41a7d 100644 --- a/packages/authz/test/store-conformance.ts +++ b/packages/authz/test/store-conformance.ts @@ -136,6 +136,15 @@ export function runStoreConformance(name: string, makeStore: () => Promise { + // Same class as the empty-string case: the caller controls this value. + for (const bad of [null, 0, false, {}]) { + await expect(store.assignmentsFor("u1", { tenantId: bad as never })).rejects.toThrow( + /tenantId/, + ); + } + }); + test("concurrent identical assignRole calls all resolve", async () => { // Check-then-act loses this race; the UNIQUE constraint then rejects // every loser even though the desired end state was already reached. From 726b8a7d2491869de9f0396fe41bcba4b0191651 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 21:19:04 +0530 Subject: [PATCH 47/59] feat(authz): generate Permission and Role union types Emits sorted TS unions from the merged catalog so a typo in can(ctx, "post:wrtie") is a compile-time error. Uses JSON.stringify for string-literal escaping (not manual backslash/quote replace) so role names containing raw newlines still produce valid TypeScript; role names are not regex-validated like permission ids, so this matters for the raw mergeCatalogs path. --- docs/public-api-0.8.json | 1 + packages/authz/src/codegen.ts | 26 +++++++++++++++++++++++ packages/authz/src/index.ts | 1 + packages/authz/test/codegen.test.ts | 33 +++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+) create mode 100644 packages/authz/src/codegen.ts create mode 100644 packages/authz/test/codegen.test.ts diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index f29e0a5d..b01a003e 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -505,6 +505,7 @@ "expandRoles", "filterAuthorized", "filterCan", + "generatePermissionTypes", "guardPermission", "hasRole", "memoryAuditSink", diff --git a/packages/authz/src/codegen.ts b/packages/authz/src/codegen.ts new file mode 100644 index 00000000..144f5a19 --- /dev/null +++ b/packages/authz/src/codegen.ts @@ -0,0 +1,26 @@ +import type { AuthzCatalog } from "./types.ts"; + +function union(values: string[]): string { + if (!values.length) return "never"; + // JSON.stringify escapes backslashes, quotes, and control characters + // (including raw newlines, which the registry does not reject in role + // names and which would otherwise break out of the string literal). + return values + .slice() + .sort() + .map((value) => JSON.stringify(value)) + .join(" | "); +} + +/** + * Emit compile-time unions for the registered permissions and roles, so a + * typo in can(ctx, "post:wrtie") is a type error rather than a silent false. + */ +export function generatePermissionTypes(catalog: AuthzCatalog): string { + return `// Generated by \`wrnexus authz generate\`. DO NOT EDIT. + +export type Permission = ${union([...catalog.permissions.keys()])}; + +export type Role = ${union([...catalog.roles.keys()])}; +`; +} diff --git a/packages/authz/src/index.ts b/packages/authz/src/index.ts index 08f8101f..a05b6d20 100644 --- a/packages/authz/src/index.ts +++ b/packages/authz/src/index.ts @@ -167,3 +167,4 @@ export type { SubjectAssignments, } from "./types.ts"; export type { AuthorizeDecisionOptions } from "./advanced.ts"; +export { generatePermissionTypes } from "./codegen.ts"; diff --git a/packages/authz/test/codegen.test.ts b/packages/authz/test/codegen.test.ts new file mode 100644 index 00000000..a025413f --- /dev/null +++ b/packages/authz/test/codegen.test.ts @@ -0,0 +1,33 @@ +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"'); + }); +}); From 3ef353de83a9a8157d64688269df06749443696e Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 21:20:11 +0530 Subject: [PATCH 48/59] docs: use JSON.stringify for codegen escaping in the Task 12 plan The plan's union helper hand-rolled escaping for backslash and double quote only. Role names reach the emitter through the raw mergeCatalogs path, which does not apply the registry's permission-id regex, so a value containing a newline was emitted verbatim and the generated file failed to compile with TS1002 Unterminated string literal. Caught by the Task 12 implementer actually running tsc over the generated output rather than eyeballing the string. Co-Authored-By: Claude Opus 5 --- docs/plans/2026-08-04-authz-permissions-implementation.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index 95677ba5..41d40608 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -2967,10 +2967,14 @@ import type { AuthzCatalog } from "./types.ts"; function union(values: string[]): string { if (!values.length) return "never"; + // JSON.stringify, not hand-rolled escaping: role names reach this via the + // raw mergeCatalogs path without the registry's id validation, so a value + // may contain a newline, which manual quote/backslash escaping would emit + // as an unterminated string literal. return values .slice() .sort() - .map((value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`) + .map((value) => JSON.stringify(value)) .join(" | "); } From e5d0654d2af7a1a61a65402192b9fee7c02dd897 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 21:26:10 +0530 Subject: [PATCH 49/59] docs: join the DDL statement lists in the Task 13 init command authzMigrationSql was changed in Task 11 to return statement arrays rather than one blob, but Task 13's init still interpolated them straight into the migration file, which would comma-join two CREATE TABLE statements into one unparseable line. Co-Authored-By: Claude Opus 5 --- docs/plans/2026-08-04-authz-permissions-implementation.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index 41d40608..1d4ce78b 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -3212,7 +3212,10 @@ export async function runAuthzCommand( 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"); + // up/down are statement LISTS; interpolating the arrays directly would + // comma-join them into one unparseable statement. + const block = (statements: string[]) => statements.map((s) => `${s};`).join("\n\n"); + writeFileSync(file, `-- +up\n${block(up)}\n\n-- +down\n${block(down)}\n`, "utf8"); console.log(`Wrote ${file}`); console.log("Run `wrnexus db migrate` to apply it."); return; From b9098382b30af260ff61d0fe93e9d1bb3f0ec793 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 21:34:56 +0530 Subject: [PATCH 50/59] feat(cli): add wrnexus authz list/generate/init Introspects the merged authz catalog, emits app/authz/permissions.gen.ts type unions, and scaffolds the assignment-table migration. init validates --dialect explicitly (unrecognised values reject rather than silently falling back to sqlite) and joins authzMigrationSql's up/down statement lists with terminators instead of interpolating the arrays. Test scaffolding for dynamically-imported app/authz declarations must live inside the repo tree (not os.tmpdir()) for the "@wrnexus/*" bare specifier to resolve via tsconfig paths; .gitignore excludes the scratch dirs this produces. --- .gitignore | 5 + packages/cli/src/authz.ts | 123 ++++++++++++++++ packages/cli/src/index.ts | 9 ++ packages/cli/test/authz-command.test.ts | 188 ++++++++++++++++++++++++ 4 files changed, 325 insertions(+) create mode 100644 packages/cli/src/authz.ts create mode 100644 packages/cli/test/authz-command.test.ts diff --git a/.gitignore b/.gitignore index f03ba3ac..26bd7488 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ bun.lockb # Local focused typecheck helpers must never enter the repository. focus-shims.d.ts tsconfig.focus.json + +# Scratch dirs for tests that must dynamically import scaffolded files using +# "@wrnexus/*" bare specifiers (resolved via the root tsconfig.json `paths`, +# which requires the scaffold to live inside the repo tree). +**/test/.tmp-*/ diff --git a/packages/cli/src/authz.ts b/packages/cli/src/authz.ts new file mode 100644 index 00000000..8af48500 --- /dev/null +++ b/packages/cli/src/authz.ts @@ -0,0 +1,123 @@ +/** + * `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 [--dialect=sqlite|postgres|mysql] + * 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"; +import type { Dialect } from "@wrnexus/db"; + +const USAGE = "usage: wrnexus authz "; +const DIALECTS = ["sqlite", "postgres", "mysql"] as const; + +/** 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"); +} + +/** Parse `--dialect=` from CLI args. Defaults to sqlite; rejects unknown values. */ +function resolveDialect(args: string[]): Dialect { + const flag = args.find((arg) => arg.startsWith("--dialect=")); + if (!flag) return "sqlite"; + const value = flag.split("=")[1]; + if ((DIALECTS as readonly string[]).includes(value ?? "")) return value as Dialect; + throw new Error( + `WRN-AUTHZ-INIT: unrecognised --dialect='${value}'. Use one of: ${DIALECTS.join(", ")}.`, + ); +} + +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 = resolveDialect(args); + const dir = join(appDir, "db", "migrations"); + mkdirSync(dir, { recursive: true }); + const { up, down } = authzMigrationSql(dialect); + const file = join(dir, `${nextMigrationNumber(dir)}_authz_tables.sql`); + // up/down are statement LISTS; interpolating the arrays directly would + // comma-join them into one unparseable statement. + const block = (statements: string[]) => statements.map((s) => `${s};`).join("\n\n"); + writeFileSync(file, `-- +up\n${block(up)}\n\n-- +down\n${block(down)}\n`, "utf8"); + console.log(`Wrote ${file}`); + console.log("Run `wrnexus db migrate` to apply it."); + return; + } + + default: + throw new Error(USAGE); + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8d87a994..a1b003be 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,6 +7,7 @@ * wrnexus create scaffold a new app * wrnexus eject copy a Wire UI component into your app * wrnexus db database migrations + * wrnexus authz authorization catalog tooling */ import { join, resolve } from "node:path"; @@ -66,6 +67,7 @@ Usage: wrnexus eject Copy a Wire UI component into app/components wrnexus update [dir] [--latest] Upgrade deps, migrate project files, and verify the app wrnexus db Migrations: migrate | rollback | status | seed | generate | new + wrnexus authz Authorization: list | generate | init [--dialect=sqlite|postgres|mysql] wrnexus test [level] [app-dir] [--watch] Run unit | component | api | browser | visual | accessibility | performance wrnexus profiles [app-dir] List config profiles (dev/prod/uat/…) and their env files @@ -270,6 +272,13 @@ async function main(): Promise { await runDbCommand(".", sub, dbArgs); break; } + 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; + } case "profiles": { const { listProfiles } = await import("./profiles.ts"); await listProfiles(rest.find((a) => !a.startsWith("--")) ?? "."); diff --git a/packages/cli/test/authz-command.test.ts b/packages/cli/test/authz-command.test.ts new file mode 100644 index 00000000..1e6eb958 --- /dev/null +++ b/packages/cli/test/authz-command.test.ts @@ -0,0 +1,188 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + writeFileSync, + existsSync, + rmSync, +} from "node:fs"; +import { join } from "node:path"; +import { loadAuthzCatalog, runAuthzCommand } from "../src/authz.ts"; + +// Declarations under app/authz/ import "@wrnexus/authz" with a bare specifier, +// which Bun resolves via the root tsconfig.json `paths` map by walking up from +// the imported file's directory. os.tmpdir() lives outside the repo tree (often +// on a different drive on Windows), so that walk never reaches the root +// tsconfig.json and the dynamic import fails with "Cannot find module +// '@wrnexus/authz'". Scaffolding under this test file's own directory keeps the +// walk-up inside the repo, exactly like a real app (which has its own +// node_modules/tsconfig with @wrnexus/authz installed). +const scratchRoot = join(import.meta.dir, ".tmp-authz-cli"); +const createdRoots: string[] = []; + +function scaffold(): string { + mkdirSync(scratchRoot, { recursive: true }); + const root = mkdtempSync(join(scratchRoot, "run-")); + createdRoots.push(root); + 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; +} + +afterAll(() => { + rmSync(scratchRoot, { recursive: true, force: true }); +}); + +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 = 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); + }); + + test("a missing subcommand throws with usage", async () => { + await expect(runAuthzCommand(scaffold(), undefined, [])).rejects.toThrow(/usage/i); + }); + + test("list does not crash on an app with no app/authz directory", async () => { + mkdirSync(scratchRoot, { recursive: true }); + const root = mkdtempSync(join(scratchRoot, "empty-")); + createdRoots.push(root); + mkdirSync(join(root, "app", "pages"), { recursive: true }); + 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; + } + expect(lines.join("\n")).toContain("Permissions (0)"); + }); + + test("loadAuthzCatalog warns and skips a declaration file with no default export", async () => { + const root = scaffold(); + writeFileSync(join(root, "app", "authz", "empty.ts"), `export const notDefault = 1;\n`, "utf8"); + const warnings: unknown[][] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => void warnings.push(args); + try { + const catalog = await loadAuthzCatalog(join(root, "app")); + expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "post:write"]); + } finally { + console.warn = originalWarn; + } + expect(warnings.some((args) => String(args.join(" ")).includes("no default export"))).toBe( + true, + ); + }); + + test("generate is idempotent when run twice", async () => { + const root = scaffold(); + await runAuthzCommand(root, "generate", []); + const first = readFileSync(join(root, "app", "authz", "permissions.gen.ts"), "utf8"); + await runAuthzCommand(root, "generate", []); + const second = readFileSync(join(root, "app", "authz", "permissions.gen.ts"), "utf8"); + expect(second).toBe(first); + }); + + test("init --dialect=postgres emits postgres DDL", async () => { + const root = scaffold(); + mkdirSync(join(root, "app", "db", "migrations"), { recursive: true }); + await runAuthzCommand(root, "init", ["--dialect=postgres"]); + const dir = join(root, "app", "db", "migrations"); + const file = readdirSync(dir).find((name: string) => name.includes("authz")); + const sql = readFileSync(join(dir, file!), "utf8"); + expect(sql).toContain("SERIAL PRIMARY KEY"); + }); + + test("init --dialect=mysql emits mysql DDL", async () => { + const root = scaffold(); + mkdirSync(join(root, "app", "db", "migrations"), { recursive: true }); + await runAuthzCommand(root, "init", ["--dialect=mysql"]); + const dir = join(root, "app", "db", "migrations"); + const file = readdirSync(dir).find((name: string) => name.includes("authz")); + const sql = readFileSync(join(dir, file!), "utf8"); + expect(sql).toContain("AUTO_INCREMENT PRIMARY KEY"); + }); + + test("init with an unrecognised --dialect= does not silently fall back to sqlite", async () => { + const root = scaffold(); + mkdirSync(join(root, "app", "db", "migrations"), { recursive: true }); + await expect(runAuthzCommand(root, "init", ["--dialect=oracle"])).rejects.toThrow(/dialect/i); + }); + + test("init writes a migration that the migration runner can parse", async () => { + const { loadMigrations } = await import("@wrnexus/db"); + const root = scaffold(); + const dir = join(root, "app", "db", "migrations"); + mkdirSync(dir, { recursive: true }); + await runAuthzCommand(root, "init", []); + const migrations = loadMigrations(dir); + expect(migrations.length).toBe(1); + const migration = migrations[0]!; + expect(migration.up).toContain("_wrn_authz_assignment"); + expect(migration.up).toContain("_wrn_authz_grant"); + expect(migration.down).toContain("DROP TABLE IF EXISTS _wrn_authz_grant"); + expect(migration.down).toContain("DROP TABLE IF EXISTS _wrn_authz_assignment"); + }); + + test("generate does not clobber a real declaration file", async () => { + const root = scaffold(); + await runAuthzCommand(root, "generate", []); + expect(existsSync(join(root, "app", "authz", "blog.ts"))).toBe(true); + const original = readFileSync(join(root, "app", "authz", "blog.ts"), "utf8"); + expect(original).toContain("defineAuthz"); + }); +}); From bc5437063d5e9ccb3ca26b1d15cf1d4c40686043 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 21:51:38 +0530 Subject: [PATCH 51/59] fix(cli): declare @wrnexus/authz dependency, exit cleanly on bad authz input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 review fixes for Task 13: - packages/cli/package.json was missing @wrnexus/authz, and packages/authz/package.json was missing @wrnexus/core despite importing its types in index.ts/middleware.ts/advanced.ts. Both only worked in-repo because bare "@wrnexus/*" specifiers resolve through the root tsconfig.json paths map; a standalone install of @wrnexus/cli or @wrnexus/authz would fail at runtime. - authz.ts's unknown/missing-subcommand and bad --dialect paths now console.error + process.exit(1), matching db.ts's convention, instead of throwing — index.ts's top-level catch previously printed those as a raw stack trace. Added a subprocess-level test that spawns the real CLI and asserts stderr has the usage line with no stack frame. - nextMigrationNumber now extracts the leading-digit run the same way db/migrate.ts's nextNumber does, instead of a fixed slice(0, 4) that would have undercounted once a migration number passed 9999. --- packages/authz/package.json | 1 + packages/cli/package.json | 1 + packages/cli/src/authz.ts | 30 ++++++---- packages/cli/test/authz-command.test.ts | 79 +++++++++++++++++++++++-- 4 files changed, 95 insertions(+), 16 deletions(-) diff --git a/packages/authz/package.json b/packages/authz/package.json index 94afc646..014807f8 100644 --- a/packages/authz/package.json +++ b/packages/authz/package.json @@ -9,6 +9,7 @@ "./db": "./src/db.ts" }, "dependencies": { + "@wrnexus/core": "workspace:*", "@wrnexus/db": "workspace:*" } } diff --git a/packages/cli/package.json b/packages/cli/package.json index 2ef75a6d..17c6d364 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -23,6 +23,7 @@ "@wrnexus/mcp": "workspace:*", "@wrnexus/playground": "workspace:*", "@wrnexus/db": "workspace:*", + "@wrnexus/authz": "workspace:*", "@wrnexus/plugin": "workspace:*", "@wrnexus/syntax": "workspace:*", "@wrnexus/typecheck": "workspace:*", diff --git a/packages/cli/src/authz.ts b/packages/cli/src/authz.ts index 8af48500..b83de5cb 100644 --- a/packages/cli/src/authz.ts +++ b/packages/cli/src/authz.ts @@ -41,23 +41,33 @@ export async function loadAuthzCatalog(appDir: string): Promise { return mergeCatalogs(sources); } +/** Print a message and exit non-zero, matching db.ts's convention for user-facing + * CLI errors: never throw, so index.ts's generic `main().catch` handler (which + * prints the raw error, stack and all) is never reached for an expected failure. */ +function fail(message: string): never { + console.error(message); + process.exit(1); +} + +// Same leading-digit extraction as db/migrate.ts's `nextNumber`: a fixed +// `slice(0, 4)` would undercount once a migration number grows past 9999. 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"); + let max = 0; + for (const name of readdirSync(dir)) { + const match = /^(\d+)/.exec(name); + if (match) max = Math.max(max, Number(match[1])); + } + return String(max + 1).padStart(4, "0"); } /** Parse `--dialect=` from CLI args. Defaults to sqlite; rejects unknown values. */ function resolveDialect(args: string[]): Dialect { const flag = args.find((arg) => arg.startsWith("--dialect=")); if (!flag) return "sqlite"; - const value = flag.split("=")[1]; - if ((DIALECTS as readonly string[]).includes(value ?? "")) return value as Dialect; - throw new Error( - `WRN-AUTHZ-INIT: unrecognised --dialect='${value}'. Use one of: ${DIALECTS.join(", ")}.`, - ); + const value = flag.split("=")[1] ?? ""; + if ((DIALECTS as readonly string[]).includes(value)) return value as Dialect; + return fail(`Unrecognised --dialect='${value}'. Use one of: ${DIALECTS.join(", ")}.`); } export async function runAuthzCommand( @@ -118,6 +128,6 @@ export async function runAuthzCommand( } default: - throw new Error(USAGE); + fail(USAGE); } } diff --git a/packages/cli/test/authz-command.test.ts b/packages/cli/test/authz-command.test.ts index 1e6eb958..fefa1fd2 100644 --- a/packages/cli/test/authz-command.test.ts +++ b/packages/cli/test/authz-command.test.ts @@ -1,4 +1,4 @@ -import { afterAll, describe, expect, test } from "bun:test"; +import { afterAll, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, mkdtempSync, @@ -45,6 +45,31 @@ afterAll(() => { rmSync(scratchRoot, { recursive: true, force: true }); }); +/** + * authz.ts's `fail()` helper (usage errors, bad --dialect) mirrors db.ts's + * convention: console.error + process.exit(1), never throw — so index.ts's + * generic `main().catch(err) { console.error(err); process.exit(1); }` (which + * prints the raw Error, stack and all) never sees an expected validation + * failure. That means a *direct* call to runAuthzCommand() would normally kill + * the whole test worker via a real process.exit(); intercept both console.error + * and process.exit so the failure path stays testable in-process. + */ +async function expectCleanFailure(run: () => Promise): Promise { + const errors: string[] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => void errors.push(args.join(" ")); + const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`__process_exit_${code}__`); + }) as never); + try { + await expect(run()).rejects.toThrow(/^__process_exit_1__$/); + } finally { + console.error = originalError; + exitSpy.mockRestore(); + } + return errors.join("\n"); +} + describe("wrnexus authz", () => { test("loadAuthzCatalog merges every declaration", async () => { const catalog = await loadAuthzCatalog(join(scaffold(), "app")); @@ -87,14 +112,38 @@ describe("wrnexus authz", () => { expect(output).toContain("editor"); }); - test("an unknown subcommand throws with usage", async () => { - await expect(runAuthzCommand(scaffold(), "bogus", [])).rejects.toThrow(/usage/i); + test("an unknown subcommand prints usage and exits 1, not a thrown error", async () => { + const errorOutput = await expectCleanFailure(() => runAuthzCommand(scaffold(), "bogus", [])); + expect(errorOutput).toMatch(/usage/i); }); - test("a missing subcommand throws with usage", async () => { - await expect(runAuthzCommand(scaffold(), undefined, [])).rejects.toThrow(/usage/i); + test("a missing subcommand prints usage and exits 1", async () => { + const errorOutput = await expectCleanFailure(() => runAuthzCommand(scaffold(), undefined, [])); + expect(errorOutput).toMatch(/usage/i); }); + test("CLI subprocess: unknown subcommand prints usage without a stack trace", async () => { + const cliEntry = join(import.meta.dir, "..", "src", "index.ts"); + const root = scaffold(); + const proc = Bun.spawn({ + cmd: ["bun", cliEntry, "authz", "bogus"], + cwd: root, + env: { ...process.env, WRNEXUS_NO_UPDATE_CHECK: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr] = await Promise.all([ + new Response(proc.stderr).text(), + new Response(proc.stdout).text(), + ]); + const exitCode = await proc.exited; + expect(exitCode).not.toBe(0); + expect(stderr).toMatch(/usage/i); + // A raw Error/stack trace looks like "at (file.ts:12:34)"; the clean + // console.error(usage) + process.exit(1) path never produces that shape. + expect(stderr).not.toMatch(/at .*\.ts:\d+/); + }, 15000); + test("list does not crash on an app with no app/authz directory", async () => { mkdirSync(scratchRoot, { recursive: true }); const root = mkdtempSync(join(scratchRoot, "empty-")); @@ -160,7 +209,10 @@ describe("wrnexus authz", () => { test("init with an unrecognised --dialect= does not silently fall back to sqlite", async () => { const root = scaffold(); mkdirSync(join(root, "app", "db", "migrations"), { recursive: true }); - await expect(runAuthzCommand(root, "init", ["--dialect=oracle"])).rejects.toThrow(/dialect/i); + const errorOutput = await expectCleanFailure(() => + runAuthzCommand(root, "init", ["--dialect=oracle"]), + ); + expect(errorOutput).toMatch(/dialect/i); }); test("init writes a migration that the migration runner can parse", async () => { @@ -178,6 +230,21 @@ describe("wrnexus authz", () => { expect(migration.down).toContain("DROP TABLE IF EXISTS _wrn_authz_assignment"); }); + test("init numbers the next migration correctly past a 5-digit prefix", async () => { + // nextMigrationNumber originally sliced the first 4 characters of the + // filename, which would have parsed "10000_big.sql" as "1000" and reused + // that number instead of advancing past it. It must match db/migrate.ts's + // leading-digit regex instead. + const root = scaffold(); + const dir = join(root, "app", "db", "migrations"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "0001_users.sql"), "-- +up\n\n-- +down\n", "utf8"); + writeFileSync(join(dir, "10000_big.sql"), "-- +up\n\n-- +down\n", "utf8"); + await runAuthzCommand(root, "init", []); + const file = readdirSync(dir).find((name) => name.includes("authz")); + expect(file).toBe("10001_authz_tables.sql"); + }); + test("generate does not clobber a real declaration file", async () => { const root = scaffold(); await runAuthzCommand(root, "generate", []); From daea59cf5dc80c1f04797b25fcf65e014ae3befc Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 22:03:28 +0530 Subject: [PATCH 52/59] feat(dev-server): load the authz catalog at boot Adds loadAppAuthzCatalog(appDir) to @wrnexus/dev-server: discovers app/authz/*.ts declarations via buildRouter, imports and merges them into an AuthzCatalog, returning an empty catalog when the app has no declarations. A declaration with no default export is skipped with a warning; a genuine conflict between two declarations throws WRN-AUTHZ-CONFLICT naming both source files. Declared the missing @wrnexus/authz workspace dependency in dev-server's package.json. --- packages/dev-server/package.json | 1 + packages/dev-server/src/authz-boot.ts | 31 +++++++++++ packages/dev-server/test/authz-boot.test.ts | 62 +++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 packages/dev-server/src/authz-boot.ts create mode 100644 packages/dev-server/test/authz-boot.test.ts diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 33256894..da910339 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -8,6 +8,7 @@ "./serve-entry": "./src/serve-entry.ts" }, "dependencies": { + "@wrnexus/authz": "workspace:*", "@wrnexus/core": "workspace:*", "@wrnexus/dev-toolbar": "workspace:*", "@wrnexus/router": "workspace:*", diff --git a/packages/dev-server/src/authz-boot.ts b/packages/dev-server/src/authz-boot.ts new file mode 100644 index 00000000..d22022e7 --- /dev/null +++ b/packages/dev-server/src/authz-boot.ts @@ -0,0 +1,31 @@ +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. An app with no `app/authz/` directory gets an empty catalog rather + * than an error, since not every app uses permissions. + */ +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) { + // buildRouter already skips *.gen.ts, so only real declarations arrive here. + const imported = (await import(pathToFileURL(entry.file).href)) as { default?: AuthzModule }; + if (!imported.default) { + console.warn(`[wrnexus] authz declaration ${entry.file} has no default export; skipping.`); + continue; + } + sources.push({ source: entry.file, module: imported.default }); + } + return mergeCatalogs(sources); +} diff --git a/packages/dev-server/test/authz-boot.test.ts b/packages/dev-server/test/authz-boot.test.ts new file mode 100644 index 00000000..52240762 --- /dev/null +++ b/packages/dev-server/test/authz-boot.test.ts @@ -0,0 +1,62 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { loadAppAuthzCatalog } from "../src/authz-boot.ts"; + +// Fixtures must live inside the repo tree, not os.tmpdir(). A scaffolded file +// under app/authz importing "@wrnexus/authz" by bare specifier resolves via +// the root tsconfig.json `paths` map, walked from the *imported file's* +// location — an out-of-tree path (os.tmpdir(), often a different drive on +// Windows) never reaches it and fails to resolve the module. +const scratchRoot = join(import.meta.dir, ".tmp-authz-boot"); +mkdirSync(scratchRoot, { recursive: true }); + +function scaffold(body: string): string { + const root = mkdtempSync(join(scratchRoot, "app-")); + 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"); +} + +afterAll(() => { + rmSync(scratchRoot, { recursive: true, force: true }); +}); + +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(scratchRoot, "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/); + }); + + test("a declaration with no default export is skipped, not fatal", async () => { + const appDir = scaffold(`export const notDefault = 1;`); + const catalog = await loadAppAuthzCatalog(appDir); + expect(catalog.permissions.size).toBe(0); + }); +}); From 226217ecbf88ed8d7d2f5a085260c2eeb77697e7 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 22:40:42 +0530 Subject: [PATCH 53/59] feat(authz): reach the merged catalog from boot via a process-wide singleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix round 1 for Task 14 — closes the gap flagged in the last report: loadAppAuthzCatalog existed but nothing called it. - packages/authz/src/client.ts (new): setAuthzCatalog/getAuthzCatalog/ hasAuthzCatalog, mirroring @wrnexus/db's client.ts. App middleware runs at module-eval time and needs the catalog then, so ctx cannot carry it; getAuthzCatalog() throws a setup error naming the fix, like getDb() does. Exported from packages/authz/src/index.ts. - packages/dev-server/src/index.ts: startServer calls loadAppAuthzCatalog + setAuthzCatalog before middleware is resolved (schemasJs precedent), and populates the new RuntimeDeps.authz field. - packages/dev-server/src/runtime.ts: RuntimeDeps gains authz?: AuthzCatalog. - packages/cli/src/build.ts: emits static imports of each app/authz/*.ts file into the generated entry (components/layouts precedent) and passes { source, module } pairs through ProdOptions.authz — the catalog holds policy functions, so it cannot be JSON-baked like schemasJs. - packages/dev-server/src/prod.ts: createProductionHandlers merges those declarations and calls setAuthzCatalog before the server accepts traffic, so a conflict fails the boot instead of surfacing on the first request. Runs for every deployment adapter, not only Bun.serve. The framework never installs authzMiddleware itself; the app still registers it with its own store. Verified end-to-end: added a temporary app/authz declaration to examples/basic-app, ran `bun run build`, inspected the generated entry's static import + authz array, and booted dist/server.js to confirm the merge/setAuthzCatalog call succeeds against real bundled code (reverted before commit). --- docs/public-api-0.8.json | 5 +- packages/authz/src/client.ts | 44 ++++++++ packages/authz/src/index.ts | 1 + packages/authz/test/client.test.ts | 78 +++++++++++++ packages/cli/src/build.ts | 19 ++++ packages/dev-server/src/authz-boot.ts | 4 + packages/dev-server/src/index.ts | 13 +++ packages/dev-server/src/prod.ts | 29 +++++ packages/dev-server/src/runtime.ts | 12 ++ .../dev-server/test/authz-startserver.test.ts | 103 ++++++++++++++++++ 10 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 packages/authz/src/client.ts create mode 100644 packages/authz/test/client.test.ts create mode 100644 packages/dev-server/test/authz-startserver.test.ts diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index b01a003e..83d15413 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -506,7 +506,9 @@ "filterAuthorized", "filterCan", "generatePermissionTypes", + "getAuthzCatalog", "guardPermission", + "hasAuthzCatalog", "hasRole", "memoryAuditSink", "memoryPermissionStore", @@ -516,7 +518,8 @@ "requirePermission", "requireRole", "safeRecord", - "scopeKey" + "scopeKey", + "setAuthzCatalog" ], "./db": [ "authzMigrationSql", diff --git a/packages/authz/src/client.ts b/packages/authz/src/client.ts new file mode 100644 index 00000000..dba562d2 --- /dev/null +++ b/packages/authz/src/client.ts @@ -0,0 +1,44 @@ +/** + * A process-wide authorization catalog registry, mirroring `@wrnexus/db`'s + * `client.ts` (`setDb`/`getDb`/`hasDb`). It exists for the same reason: app + * middleware runs at module-eval time — `app/middleware/*.ts` registers + * `authzMiddleware({ catalog, store, ... })` itself, and it needs the merged + * catalog *then*, before the first request. Passing it through `ctx` does not + * work at that point, so the framework loads and merges every `app/authz/*.ts` + * declaration at boot (dev: `loadAppAuthzCatalog` + `setAuthzCatalog`, before + * middleware is resolved; prod: `mergeCatalogs` over the statically-imported + * declarations + `setAuthzCatalog`, before the server starts listening) and + * stashes it here. The framework never installs `authzMiddleware` itself — the + * app always chooses its own store and registers the middleware; this registry + * only makes the merged catalog reachable when it does. + */ + +import type { AuthzCatalog } from "./types.ts"; + +let catalog: AuthzCatalog | undefined; + +/** Set the process-wide authorization catalog (called by the framework at boot). */ +export function setAuthzCatalog(next: AuthzCatalog): AuthzCatalog { + catalog = next; + return next; +} + +/** The process-wide authorization catalog. Throws if it hasn't been set. */ +export function getAuthzCatalog(): AuthzCatalog { + if (!catalog) { + throw new Error( + "WRN-AUTHZ-SETUP: no authorization catalog is configured. The dev server and " + + "production build call loadAppAuthzCatalog()/mergeCatalogs() and setAuthzCatalog() " + + "automatically before your app's middleware runs. If you're seeing this, either " + + "getAuthzCatalog() ran before that boot step (e.g. at import time) or you're " + + "outside the normal boot path (a standalone script or test) and must call " + + "setAuthzCatalog(catalog) yourself first.", + ); + } + return catalog; +} + +/** Whether the process-wide authorization catalog has been set. */ +export function hasAuthzCatalog(): boolean { + return catalog !== undefined; +} diff --git a/packages/authz/src/index.ts b/packages/authz/src/index.ts index a05b6d20..e91b8443 100644 --- a/packages/authz/src/index.ts +++ b/packages/authz/src/index.ts @@ -143,6 +143,7 @@ export type { AuthorizationDecision, DecisionPolicy } from "./advanced.ts"; export { defineAuthz } from "./registry.ts"; export { mergeCatalogs, emptyCatalog } from "./catalog.ts"; export type { CatalogSource } from "./catalog.ts"; +export { setAuthzCatalog, getAuthzCatalog, hasAuthzCatalog } from "./client.ts"; export { memoryPermissionStore, cachedPermissionStore, scopeKey } from "./store.ts"; export type { PermissionStore, CachedPermissionStore, CacheOptions, GrantEffect } from "./store.ts"; export { memoryAuditSink, consoleAuditSink, safeRecord } from "./audit.ts"; diff --git a/packages/authz/test/client.test.ts b/packages/authz/test/client.test.ts new file mode 100644 index 00000000..9587c2d5 --- /dev/null +++ b/packages/authz/test/client.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test"; +import { pathToFileURL } from "node:url"; +import { join } from "node:path"; +import { defineAuthz } from "../src/registry.ts"; +import { mergeCatalogs } from "../src/catalog.ts"; +import { getAuthzCatalog, hasAuthzCatalog, setAuthzCatalog } from "../src/client.ts"; + +const CLIENT_URL = pathToFileURL(join(import.meta.dir, "..", "src", "client.ts")).href; + +describe("authz process-wide catalog singleton", () => { + // `catalog` is module-level state, and bun test does NOT isolate module + // instances between test files run in the same `bun test` invocation (a + // single import in one file is visible to every other file in the run). So + // "before any setAuthzCatalog call anywhere in the whole suite" cannot be + // observed reliably in-process — a fresh subprocess is the only way to + // guarantee the catalog genuinely has never been set. + test("getAuthzCatalog throws a setup error before setAuthzCatalog is ever called, in a fresh process", async () => { + const proc = Bun.spawn({ + cmd: [ + "bun", + "-e", + `const mod = await import(${JSON.stringify(CLIENT_URL)}); + if (mod.hasAuthzCatalog()) { console.log("UNEXPECTED_HAS_CATALOG"); process.exit(1); } + try { + mod.getAuthzCatalog(); + console.log("UNEXPECTED_NO_THROW"); + process.exit(1); + } catch (e) { + console.log("THREW:" + (e instanceof Error ? e.message : String(e))); + }`, + ], + stdout: "pipe", + stderr: "pipe", + cwd: join(import.meta.dir, ".."), + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(stdout).toContain("THREW:"); + // Names the fix, like getDb()'s "No database configured. Add `db: ...`" message. + expect(stdout).toContain("WRN-AUTHZ-SETUP"); + expect(stdout).toContain("setAuthzCatalog"); + }); + + test("setAuthzCatalog/getAuthzCatalog round-trip, and hasAuthzCatalog reflects the set state", () => { + const catalog = mergeCatalogs([ + { + source: "client.test.ts", + module: defineAuthz({ permissions: { "post:read": { title: "View posts" } } }), + }, + ]); + + const returned = setAuthzCatalog(catalog); + expect(returned).toBe(catalog); + expect(hasAuthzCatalog()).toBe(true); + expect(getAuthzCatalog()).toBe(catalog); + expect(getAuthzCatalog().permissions.get("post:read")).toEqual({ title: "View posts" }); + }); + + test("setAuthzCatalog overwrites a previously set catalog", () => { + const first = mergeCatalogs([ + { source: "a.ts", module: defineAuthz({ permissions: { "a:read": {} } }) }, + ]); + const second = mergeCatalogs([ + { source: "b.ts", module: defineAuthz({ permissions: { "b:read": {} } }) }, + ]); + setAuthzCatalog(first); + expect(getAuthzCatalog()).toBe(first); + setAuthzCatalog(second); + expect(getAuthzCatalog()).toBe(second); + expect(getAuthzCatalog().permissions.has("a:read")).toBe(false); + expect(getAuthzCatalog().permissions.has("b:read")).toBe(true); + }); +}); diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 607cb9d2..dde41c08 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -604,6 +604,24 @@ export async function runBuild(appRoot: string): Promise { .join(", "); if (router.layouts.length) console.log(`✓ Layouts: ${router.layouts.length}`); + // Authorization declarations (app/authz/*.ts), statically imported like + // components/layouts — NOT baked into JSON like schemasJs, because the + // catalog contains policy FUNCTIONS, which JSON.stringify cannot carry. + // Each module is passed through by reference in ProdOptions.authz and + // merged into the process-wide catalog at prod startup (prod.ts), before + // the server begins listening, so a conflicting pair of declarations fails + // the boot instead of surfacing on the first request. A file with no + // default export becomes `module: undefined` here; prod.ts warns and skips + // it, matching the dev loader (authz-boot.ts). + const authzLit = router.authz + .map((a) => { + const v = `az${counter++}`; + imports.push(`import * as ${v} from ${JSON.stringify(fwd(a.file))};`); + return `{ source: ${JSON.stringify(fwd(a.file))}, module: ${v}.default }`; + }) + .join(", "); + if (router.authz.length) console.log(`✓ Authz: ${router.authz.length} declaration(s)`); + const entry = `// AUTO-GENERATED production server entry — do not edit. import { join } from "node:path"; import { createProductionServer } from ${JSON.stringify(PROD_MODULE)}; @@ -627,6 +645,7 @@ await createProductionServer( uiCssPath: join(import.meta.dir, "ui.css"), frameworkCssPath: join(import.meta.dir, "framework.css"), schemasJs: ${JSON.stringify(schemasJs)}, + authz: [${authzLit}], i18n: ${i18n ? JSON.stringify(i18n) : "undefined"}, db: ${config.db ? JSON.stringify(config.db) : "undefined"}, databases: ${config.databases ? JSON.stringify(config.databases) : "undefined"}, diff --git a/packages/dev-server/src/authz-boot.ts b/packages/dev-server/src/authz-boot.ts index d22022e7..4b24b056 100644 --- a/packages/dev-server/src/authz-boot.ts +++ b/packages/dev-server/src/authz-boot.ts @@ -20,6 +20,10 @@ export async function loadAppAuthzCatalog(appDir: string): Promise const sources: CatalogSource[] = []; for (const entry of router.authz) { // buildRouter already skips *.gen.ts, so only real declarations arrive here. + // A file that throws on import is intentionally NOT caught here: it is the + // same failure class as a genuine conflict (a broken/misconfigured catalog), + // and letting it propagate fails the boot loudly instead of silently + // producing a partial catalog. Do not "helpfully" wrap this in a try/catch. const imported = (await import(pathToFileURL(entry.file).href)) as { default?: AuthzModule }; if (!imported.default) { console.warn(`[wrnexus] authz declaration ${entry.file} has no default export; skipping.`); diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts index 4519d03b..e95a579e 100644 --- a/packages/dev-server/src/index.ts +++ b/packages/dev-server/src/index.ts @@ -32,6 +32,8 @@ import { } from "@wrnexus/db"; import { connectFromConfig } from "@wrnexus/db/connect"; import { configureStorage, type StorageConfig } from "@wrnexus/uploader"; +import { setAuthzCatalog, type AuthzCatalog } from "@wrnexus/authz"; +import { loadAppAuthzCatalog } from "./authz-boot.ts"; import { realtimeBusFromConfig } from "./realtime-bus.ts"; import { invalidateModule, @@ -336,6 +338,16 @@ export async function startServer(opts: ServeOptions): Promise { const schemasJs = await schemaRuntime(router); + // Authorization: load and merge every app/authz/*.ts declaration, then stash + // it in the process-wide registry BEFORE middleware is resolved. App + // middleware (which registers authzMiddleware itself, with its own store — + // the framework never installs one) runs at request time and needs + // getAuthzCatalog() already populated by then. An app with no declarations + // gets an empty catalog; a genuine conflict between declarations throws and + // fails this boot loudly. + const authzCatalog: AuthzCatalog = await loadAppAuthzCatalog(appDir); + setAuthzCatalog(authzCatalog); + // i18n is opt-in by the presence of app/locales/*.json. const localeMessages = loadLocales(join(appDir, "locales"), { strict: opts.i18n?.strict }); const i18n = Object.keys(localeMessages).length @@ -457,6 +469,7 @@ export async function startServer(opts: ServeOptions): Promise { security: opts.security, observability: opts.observability, tenancy: opts.tenancy, + authz: authzCatalog, navigation: opts.navigation, clientRuntimes: pluginContributions.clientRuntimes, hub, diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index b54fe2ae..9b4832fe 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -38,6 +38,7 @@ import { VALIDATE_RUNTIME } from "@wrnexus/validation"; import { I18N_RUNTIME, type ResolvedI18n } from "@wrnexus/i18n"; import { setDb, registerLazyDb, getDb, hasDb, migrate } from "@wrnexus/db"; import { connectFromConfig } from "@wrnexus/db/connect"; +import { mergeCatalogs, setAuthzCatalog, type AuthzModule } from "@wrnexus/authz"; import { configureStorage, serveStoredFile, @@ -103,6 +104,15 @@ export interface ProdOptions { frameworkCssPath?: string; /** Pre-built `window.__wireSchemas = {...}` script for client validation. */ schemasJs?: string; + /** + * Authorization declarations discovered by `wrnexus build` from + * `app/authz/*.ts`, statically imported into the generated entry (the + * catalog holds policy FUNCTIONS, so — unlike `schemasJs` — it cannot be + * JSON-serialised). `module` is `undefined` for a file with no default + * export; `createProductionHandlers` warns and skips it, then merges the + * rest into the process-wide catalog before the server accepts traffic. + */ + authz?: { source: string; module?: AuthzModule }[]; /** Resolved i18n bundle (default lang + locale messages). */ i18n?: ResolvedI18n; /** Default database connection (driver + url); enables `getDb()`. */ @@ -353,6 +363,24 @@ export function createProductionHandlers( // (NOT dist/, which is rebuilt) so uploads persist across deploys. configureStorage(opts.storage, process.cwd()); + // Authorization: merge the build's statically-imported app/authz/*.ts + // declarations into the process-wide catalog BEFORE the handlers (and thus + // any request) exist, so a conflicting pair of declarations fails the boot + // loudly instead of surfacing on the first request. This runs for every + // deployment adapter that calls createProductionHandlers, not only the + // Bun.serve path in createProductionServer below. The app still registers + // authzMiddleware itself with its own store; this only makes the merged + // catalog reachable. No declarations -> an empty catalog, no error. + const authzSources = (opts.authz ?? []).flatMap((entry) => { + if (!entry.module) { + console.warn(`[wrnexus] authz declaration ${entry.source} has no default export; skipping.`); + return []; + } + return [{ source: entry.source, module: entry.module }]; + }); + const authzCatalog = mergeCatalogs(authzSources); + setAuthzCatalog(authzCatalog); + // Middleware is already an ordered array of functions. const getMiddleware = async (): Promise => manifest.middleware; @@ -388,6 +416,7 @@ export function createProductionHandlers( security: opts.security, observability: opts.observability, tenancy: opts.tenancy, + authz: authzCatalog, navigation: opts.navigation, maxBodyBytes: opts.maxBodyBytes, realtimeBus: realtimeBusFromConfig(opts.realtime), diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index e8534bc2..f572b354 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -63,6 +63,7 @@ import { requestStoreContainer, } from "@wrnexus/ssr/store-context"; import type { StoreDefinition } from "@wrnexus/store"; +import type { AuthzCatalog } from "@wrnexus/authz"; import type { ClientRuntimeDefinition } from "@wrnexus/plugin"; import { CacheCoordinator } from "@wrnexus/cache"; import { generateServiceWorker } from "@wrnexus/pwa"; @@ -185,6 +186,17 @@ export interface RuntimeDeps { health?: HealthRegistry; /** Built-in tenant identity resolution. */ tenancy?: TenancyConfig; + /** + * Process-wide authorization catalog, merged from `app/authz/*.ts` at boot + * (dev: `loadAppAuthzCatalog`; prod: `mergeCatalogs` over the build's static + * imports). Also reachable via `@wrnexus/authz`'s `getAuthzCatalog()` + * singleton, which is what the app's own `authzMiddleware` registration + * actually reads — this field exists so the request pipeline can see the + * catalog without importing that singleton directly. The framework never + * installs `authzMiddleware` itself; the app always registers it with its + * own store. + */ + authz?: AuthzCatalog; /** Max request body size in bytes (413 above this). Default 10 MB. */ maxBodyBytes?: number; /** HMR hub for browser live-update sockets (dev only). */ diff --git a/packages/dev-server/test/authz-startserver.test.ts b/packages/dev-server/test/authz-startserver.test.ts new file mode 100644 index 00000000..5644076d --- /dev/null +++ b/packages/dev-server/test/authz-startserver.test.ts @@ -0,0 +1,103 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getAuthzCatalog, hasAuthzCatalog } from "@wrnexus/authz"; +import { startServer } from "../src/index.ts"; + +// Fixtures live inside the repo tree, not os.tmpdir(): a scaffolded file under +// app/authz importing "@wrnexus/authz" by bare specifier resolves via the root +// tsconfig.json `paths` map, walked from the *imported file's* location — an +// out-of-tree path (os.tmpdir(), often a different drive on Windows) never +// reaches it. +const scratchRoot = join(import.meta.dir, ".tmp-authz-startserver"); +mkdirSync(scratchRoot, { recursive: true }); + +function scaffold(name: string, authzFiles: Record): string { + const root = mkdtempSync(join(scratchRoot, `${name}-`)); + const appDir = join(root, "app"); + mkdirSync(join(appDir, "pages"), { recursive: true }); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: `authz-startserver-${name}` }), + "utf8", + ); + if (Object.keys(authzFiles).length) { + mkdirSync(join(appDir, "authz"), { recursive: true }); + for (const [file, body] of Object.entries(authzFiles)) { + writeFileSync(join(appDir, "authz", file), body, "utf8"); + } + } + return appDir; +} + +afterAll(() => { + rmSync(scratchRoot, { recursive: true, force: true }); +}); + +describe("dev boot loads the authz catalog before middleware is resolved", () => { + test("an app with declarations makes getAuthzCatalog() return them after boot", async () => { + const appDir = scaffold("has-decls", { + "main.ts": `import { defineAuthz } from "@wrnexus/authz"; +export default defineAuthz({ permissions: { "post:read": { title: "View posts" } } });`, + }); + const server = await startServer({ + appDir, + hostname: "127.0.0.1", + port: 0, + mode: "development", + hmr: false, + }); + try { + expect(hasAuthzCatalog()).toBe(true); + expect(getAuthzCatalog().permissions.has("post:read")).toBe(true); + } finally { + server.stop(); + } + }); + + test("an app with no app/authz declarations boots without throwing", async () => { + const appDir = scaffold("no-decls", {}); + const server = await startServer({ + appDir, + hostname: "127.0.0.1", + port: 0, + mode: "development", + hmr: false, + }); + try { + expect(getAuthzCatalog().permissions.size).toBe(0); + } finally { + server.stop(); + } + }); + + test("a conflicting pair of declarations fails the boot, naming both source files", async () => { + const appDir = scaffold("conflict", { + "a.ts": `import { defineAuthz } from "@wrnexus/authz"; +export default defineAuthz({ permissions: { "post:read": { risk: "low" } } });`, + "b.ts": `import { defineAuthz } from "@wrnexus/authz"; +export default defineAuthz({ permissions: { "post:read": { risk: "high" } } });`, + }); + + let thrown: unknown; + try { + const server = await startServer({ + appDir, + hostname: "127.0.0.1", + port: 0, + mode: "development", + hmr: false, + }); + // Should be unreachable; stop it anyway so a regression doesn't leak a port. + server.stop(); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + const message = (thrown as Error).message; + expect(message).toContain("WRN-AUTHZ-CONFLICT"); + expect(message).toContain(join(appDir, "authz", "a.ts")); + expect(message).toContain(join(appDir, "authz", "b.ts")); + }); +}); From 57097c820445fba064a19514493bd78bd20e2f94 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 23:16:41 +0530 Subject: [PATCH 54/59] fix(authz): fix prod boot-order (C1), dev HMR staleness (I2), add prod coverage (I4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix round 2 for Task 14, addressing a critical review finding reproduced on a real built server. C1 (critical): the generated production entry set the authz catalog inside createProductionServer's BODY, but ES modules evaluate every static import (including app middleware, emitted as a static import) before the importing module's body runs. Middleware reading getAuthzCatalog() at module scope — the same eager shape authzMiddleware({ catalog, ... }) itself requires, and the pattern app/middleware/logger.ts's `export default requestLogger({...})` already uses — saw an unset catalog and crashed the whole process at import time, after every other gate (typecheck/lint/tests/a plain `bun run build`) stayed green. Fix: packages/cli/src/build.ts now emits a small side-effecting `.authz-setup.ts` module containing the static imports of every app/authz/*.ts declaration plus a call to the new applyAuthzManifestEarly(entries) (packages/dev-server/src/prod.ts), and imports THAT MODULE FIRST in the generated entry — before pages, api, realtime, middleware, components, and layouts. applyAuthzManifestEarly is deliberately silent (no missing-default-export warnings, though a genuine conflict still throws and fails the boot at import time); createProductionHandlers keeps its own unconditional merge+set as an idempotent, always-warning second pass, so an adapter that bypasses the generated entry and calls it directly still gets a correctly merged, validated catalog, and so the function stays independently testable. I3: corrected packages/authz/src/client.ts's WRN-AUTHZ-SETUP message, which claimed prod always sets the catalog before middleware runs — true again for the generated entry after the C1 fix, but not for a custom entry that calls createProductionHandlers directly. I2: dev HMR editing app/authz/*.ts reloaded the page while the OLD catalog stayed authoritative (watch.ts classifies any non-CSS change as "server"; hotUpdate had no authz/ branch) — a false security signal, since tightening or removing a permission looked like it took effect but didn't until a restart. Added the branch (packages/dev-server/src/index.ts), and gave loadAppAuthzCatalog (authz-boot.ts) an injectable importer: a raw import() would have silently no-op'd on the re-import (Bun caches local TS/JS modules by filesystem path and ignores query strings), so the hot path routes through loadModule (pipeline.ts) instead, which copies the edited file to a versioned sibling specifically to defeat that cache. I4: added direct createProductionHandlers/applyAuthzManifestEarly tests (packages/dev-server/test/authz-prod.test.ts: conflict throws naming both files, missing default export warns and skips, empty array yields an empty catalog, a second call re-validates rather than trusting a stale singleton) and the regression test that matters most (packages/cli/test/authz-prod-coldstart.test.ts): a real `runBuild` + a real `bun dist/server.js` boot, with a middleware module reading getAuthzCatalog() at module scope, asserting it actually serves a request. M5: startServer built its own router once, then loadAppAuthzCatalog built a second one from scratch on every dev boot and every authz/ hot reload. loadAppAuthzCatalog now accepts either an appDir (still used standalone, e.g. by the test suite) or an already-built Router, and both call sites in index.ts now pass the router they already have. Every fix in this round was verified non-vacuous by sabotaging it and confirming the corresponding test fails, then reverting. --- docs/public-api-0.8.json | 2 + packages/authz/src/client.ts | 47 ++++-- packages/cli/src/build.ts | 61 ++++++-- .../cli/test/authz-prod-coldstart.test.ts | 128 ++++++++++++++++ packages/dev-server/src/authz-boot.ts | 33 +++- packages/dev-server/src/index.ts | 42 ++++- packages/dev-server/src/prod.ts | 69 +++++++-- packages/dev-server/test/authz-prod.test.ts | 145 ++++++++++++++++++ .../dev-server/test/authz-startserver.test.ts | 49 ++++++ 9 files changed, 535 insertions(+), 41 deletions(-) create mode 100644 packages/cli/test/authz-prod-coldstart.test.ts create mode 100644 packages/dev-server/test/authz-prod.test.ts diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index 83d15413..67398c01 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -1423,6 +1423,7 @@ "@wrnexus/dev-server": { ".": [ "AssetServer", + "AuthzManifestEntry", "FetchHandler", "GatewayApp", "GatewayAuth", @@ -1435,6 +1436,7 @@ "ServeOptions", "WrnCompileMetrics", "WsData", + "applyAuthzManifestEarly", "createHandlers", "createProductionHandlers", "createProductionServer", diff --git a/packages/authz/src/client.ts b/packages/authz/src/client.ts index dba562d2..a1504918 100644 --- a/packages/authz/src/client.ts +++ b/packages/authz/src/client.ts @@ -2,15 +2,32 @@ * A process-wide authorization catalog registry, mirroring `@wrnexus/db`'s * `client.ts` (`setDb`/`getDb`/`hasDb`). It exists for the same reason: app * middleware runs at module-eval time — `app/middleware/*.ts` registers - * `authzMiddleware({ catalog, store, ... })` itself, and it needs the merged - * catalog *then*, before the first request. Passing it through `ctx` does not - * work at that point, so the framework loads and merges every `app/authz/*.ts` - * declaration at boot (dev: `loadAppAuthzCatalog` + `setAuthzCatalog`, before - * middleware is resolved; prod: `mergeCatalogs` over the statically-imported - * declarations + `setAuthzCatalog`, before the server starts listening) and - * stashes it here. The framework never installs `authzMiddleware` itself — the - * app always chooses its own store and registers the middleware; this registry - * only makes the merged catalog reachable when it does. + * `authzMiddleware({ catalog, store, ... })` itself, an EAGER call (the same + * shape as `logger.ts`'s `export default requestLogger({...})`), and it needs + * the merged catalog *then*, before its own module body finishes running. + * Passing it through `ctx` does not work at that point, so the framework + * loads and merges every `app/authz/*.ts` declaration and stashes it here + * before any other module can observe it: + * + * - dev: `startServer` calls `loadAppAuthzCatalog` + `setAuthzCatalog` + * before middleware is resolved. + * - prod (the normal `wrnexus build` output): the generated entry statically + * imports a small `.authz-setup.ts` module FIRST — before any page, API, + * or middleware import — which calls `setAuthzCatalog` at ITS OWN module + * scope. ES modules evaluate every static import before the importing + * module's body runs, and evaluate sibling imports in declaration order, + * so import position is evaluation order: this guarantees the catalog + * exists before app middleware's own module body (which may read it + * eagerly) ever evaluates. `createProductionHandlers` (`prod.ts`) then + * repeats the merge as an idempotent second pass, mainly so a caller who + * bypasses the generated entry and invokes it directly still gets a + * catalog — for THAT path specifically, an eager module-scope read in + * middleware is only safe if the caller sets the catalog before importing + * the middleware itself, since no generated `.authz-setup.ts` runs first. + * + * The framework never installs `authzMiddleware` itself — the app always + * chooses its own store and registers the middleware; this registry only + * makes the merged catalog reachable when it does. */ import type { AuthzCatalog } from "./types.ts"; @@ -28,11 +45,13 @@ export function getAuthzCatalog(): AuthzCatalog { if (!catalog) { throw new Error( "WRN-AUTHZ-SETUP: no authorization catalog is configured. The dev server and " + - "production build call loadAppAuthzCatalog()/mergeCatalogs() and setAuthzCatalog() " + - "automatically before your app's middleware runs. If you're seeing this, either " + - "getAuthzCatalog() ran before that boot step (e.g. at import time) or you're " + - "outside the normal boot path (a standalone script or test) and must call " + - "setAuthzCatalog(catalog) yourself first.", + "`wrnexus build`'s generated production entry both call setAuthzCatalog() before " + + "any other module — including your app's middleware — evaluates. If you're seeing " + + "this: (a) you're on a custom production entry that calls createProductionHandlers " + + "directly instead of the generated one, so you must call setAuthzCatalog(catalog) " + + "yourself before importing anything that reads it eagerly; or (b) you're outside " + + "the normal boot path entirely (a standalone script or test) and must call " + + "setAuthzCatalog(catalog) first.", ); } return catalog; diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index dde41c08..7f72ae3d 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -563,6 +563,46 @@ export async function runBuild(appRoot: string): Promise { const imports: string[] = []; let counter = 0; + // Authorization: emit a small side-effecting module that statically imports + // every app/authz/*.ts declaration and calls setAuthzCatalog EAGERLY, then + // import THAT MODULE FIRST — before pages/api/realtime/middleware/ + // components/layouts — so it runs before any other static import's module + // body, including app middleware that reads getAuthzCatalog() at module + // scope (the same eager shape authzMiddleware({ catalog, ... }) itself + // requires; app/middleware/logger.ts's `export default requestLogger({...})` + // is the same pattern). ES modules evaluate every static import before the + // importing module's own body runs, and evaluate sibling imports in + // declaration order — so import POSITION is evaluation order, and this + // must be imports[0], strictly before every other push into `imports` + // below (in particular before any `mw*` import). This module is + // deliberately silent about a missing default export (see + // applyAuthzManifestEarly in @wrnexus/dev-server): createProductionHandlers + // performs the identical merge again, with its warnings, as an idempotent + // second pass — both for adapters that bypass this generated entry and to + // avoid warning twice about the same declaration in the normal path. + { + let authzSetupCounter = 0; + const authzSetupImports: string[] = []; + const authzSetupEntries = router.authz + .map((a) => { + const v = `d${authzSetupCounter++}`; + authzSetupImports.push(`import * as ${v} from ${JSON.stringify(fwd(a.file))};`); + return `{ source: ${JSON.stringify(fwd(a.file))}, module: ${v}.default }`; + }) + .join(", "); + const authzSetupContent = `// AUTO-GENERATED authz catalog setup — do not edit. +// Imported FIRST by the production entry (see the "Authorization" comment +// there) so getAuthzCatalog() is populated before any other static import's +// module body runs. +import { applyAuthzManifestEarly } from "@wrnexus/dev-server"; +${authzSetupImports.join("\n")} + +applyAuthzManifestEarly([${authzSetupEntries}]); +`; + writeFileSync(join(distDir, ".authz-setup.ts"), authzSetupContent, "utf8"); + imports.push(`import "./.authz-setup.ts";`); + } + const manifestRoutes = (routes: Route[]): string => { const parts = routes.map((r) => { const v = `m${counter++}`; @@ -604,15 +644,18 @@ export async function runBuild(appRoot: string): Promise { .join(", "); if (router.layouts.length) console.log(`✓ Layouts: ${router.layouts.length}`); - // Authorization declarations (app/authz/*.ts), statically imported like - // components/layouts — NOT baked into JSON like schemasJs, because the - // catalog contains policy FUNCTIONS, which JSON.stringify cannot carry. - // Each module is passed through by reference in ProdOptions.authz and - // merged into the process-wide catalog at prod startup (prod.ts), before - // the server begins listening, so a conflicting pair of declarations fails - // the boot instead of surfacing on the first request. A file with no - // default export becomes `module: undefined` here; prod.ts warns and skips - // it, matching the dev loader (authz-boot.ts). + // Authorization declarations again, this time for ProdOptions.authz — a + // SEPARATE set of static imports of the exact same files (harmless; ES + // modules are evaluated once and shared across every importer), statically + // imported like components/layouts — NOT baked into JSON like schemasJs, + // because the catalog contains policy FUNCTIONS, which JSON.stringify + // cannot carry. Each module is passed through by reference and merged + // AGAIN into the process-wide catalog by createProductionHandlers's second + // pass (prod.ts) — see the ".authz-setup.ts" block above for the EARLY, + // eager pass that actually makes the catalog visible to app middleware. A + // file with no default export becomes `module: undefined` here; + // createProductionHandlers warns and skips it, matching the dev loader + // (authz-boot.ts). const authzLit = router.authz .map((a) => { const v = `az${counter++}`; diff --git a/packages/cli/test/authz-prod-coldstart.test.ts b/packages/cli/test/authz-prod-coldstart.test.ts new file mode 100644 index 00000000..14571c05 --- /dev/null +++ b/packages/cli/test/authz-prod-coldstart.test.ts @@ -0,0 +1,128 @@ +import { afterAll, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { runBuild } from "../src/build.ts"; + +// This is the regression test for a CRITICAL boot-order bug (C1): in the +// generated production entry, app middleware was emitted as a static import +// AFTER the authz merge/set happened in the entry's own body. ES modules +// evaluate every static import (including middleware) before the importing +// module's body runs, so a middleware module reading getAuthzCatalog() at its +// own module scope — the SAME eager shape authzMiddleware({ catalog, ... }) +// itself requires, and the same pattern examples/basic-app's +// app/middleware/logger.ts uses for `export default requestLogger({...})` — +// saw an unset catalog and threw, taking the app down at deploy while every +// other gate (typecheck/lint/tests/a plain `bun run build`) stayed green. +// A manual build+boot caught it once; this makes that check permanent. +// +// Fixtures live inside the repo tree, not os.tmpdir(): both the scaffolded +// app files AND the code Bun.build bundles from them import "@wrnexus/authz" +// by bare specifier, which resolves via the root tsconfig.json `paths` map +// walked from the *importing file's* location — an out-of-tree path never +// reaches it. +const scratchRoot = join(import.meta.dir, ".tmp-authz-coldstart"); +mkdirSync(scratchRoot, { recursive: true }); + +afterAll(() => { + rmSync(scratchRoot, { recursive: true, force: true }); +}); + +test("a module-eval getAuthzCatalog() in app middleware survives a real production cold start", async () => { + const root = mkdtempSync(join(scratchRoot, "app-")); + const appDir = join(root, "app"); + mkdirSync(join(appDir, "api"), { recursive: true }); + mkdirSync(join(appDir, "authz"), { recursive: true }); + mkdirSync(join(appDir, "middleware"), { recursive: true }); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "authz-coldstart-fixture" }), + "utf8", + ); + writeFileSync( + join(appDir, "api", "health.ts"), + `export function GET() { + return Response.json({ ok: true }); +} +`, + "utf8", + ); + writeFileSync( + join(appDir, "authz", "main.ts"), + `import { defineAuthz } from "@wrnexus/authz"; + +export default defineAuthz({ permissions: { "post:read": {} } }); +`, + "utf8", + ); + writeFileSync( + join(appDir, "middleware", "authz-probe.ts"), + `import { authzMiddleware, getAuthzCatalog, memoryPermissionStore } from "@wrnexus/authz"; + +// Module-eval-time read, on purpose: this is exactly the pattern the +// setAuthzCatalog() singleton exists for, and exactly what took the app down +// under the pre-fix boot order. If getAuthzCatalog() throws here, this WHOLE +// MODULE fails to evaluate and the entry crashes at import time, before +// Bun.serve is ever reached. +export default authzMiddleware({ catalog: getAuthzCatalog(), store: memoryPermissionStore() }); +`, + "utf8", + ); + + await runBuild(root); + + const serverPath = join(root, "dist", "server.js"); + const proc = Bun.spawn({ + cmd: ["bun", serverPath], + env: { ...process.env, PORT: "0" }, + stdout: "pipe", + stderr: "pipe", + cwd: root, + }); + + let port: number | undefined; + let stderrText = ""; + try { + const reader = proc.stdout.getReader(); + const errReader = proc.stderr.getReader(); + const decoder = new TextDecoder(); + let buffered = ""; + const deadline = Date.now() + 20_000; + const TIMED_OUT = Symbol("timed out"); + while (port === undefined && Date.now() < deadline) { + const outcome = await Promise.race([ + reader.read(), + new Promise((resolve) => setTimeout(() => resolve(TIMED_OUT), 250)), + ]); + if (outcome === TIMED_OUT) continue; + const { value, done } = outcome; + if (done) break; + buffered += decoder.decode(value); + const match = /listening on http:\/\/[^:]+:(\d+)/.exec(buffered); + if (match) port = Number(match[1]); + } + reader.releaseLock(); + + if (port === undefined) { + // Drain stderr for a useful failure message before giving up. + const errOutcome = await Promise.race([ + errReader.read(), + new Promise((resolve) => setTimeout(() => resolve(TIMED_OUT), 500)), + ]); + if (errOutcome !== TIMED_OUT && errOutcome.value) { + stderrText += decoder.decode(errOutcome.value); + } + errReader.releaseLock(); + throw new Error( + `production server never printed a "listening on" line within 20s. stderr:\n${stderrText}`, + ); + } + errReader.releaseLock(); + + const response = await fetch(`http://127.0.0.1:${port}/api/health`); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + } finally { + proc.kill(); + await proc.exited; + } +}, 30_000); diff --git a/packages/dev-server/src/authz-boot.ts b/packages/dev-server/src/authz-boot.ts index 4b24b056..6cdbc2a5 100644 --- a/packages/dev-server/src/authz-boot.ts +++ b/packages/dev-server/src/authz-boot.ts @@ -1,5 +1,5 @@ import { pathToFileURL } from "node:url"; -import { buildRouter } from "@wrnexus/router"; +import { buildRouter, type Router } from "@wrnexus/router"; import { emptyCatalog, mergeCatalogs, @@ -8,14 +8,39 @@ import { type CatalogSource, } from "@wrnexus/authz"; +/** Imports one declaration module. Defaults to a raw `import()`; the hot-reload + * call site passes `loadModule` instead (see the note below on why). */ +export type AuthzImporter = (file: string) => Promise<{ default?: AuthzModule }>; + +const rawImport: AuthzImporter = (file) => + import(pathToFileURL(file).href) as Promise<{ default?: AuthzModule }>; + /** * 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. An app with no `app/authz/` directory gets an empty catalog rather * than an error, since not every app uses permissions. + * + * Accepts either an app directory — the original, standalone shape, still + * used by the test suite and by any caller without a router on hand — or an + * already-built `Router`. `startServer` passes its own router (built once at + * `:315` with the full `componentDirs`/`externalRoutes`/`middlewareFiles` + * options) to avoid a second, redundant filesystem scan of the whole `app/` + * tree on every dev boot and on every hot reload of an `app/authz/*.ts` file. + * + * `importModule` defaults to a raw dynamic `import()`, correct for the + * initial boot. On a HOT reload, the caller must instead pass `loadModule` + * (from `./pipeline.ts`): Bun caches local TS/JS modules by filesystem path + * and ignores query strings, so re-`import()`-ing the same absolute path + * after an edit silently returns the stale, already-cached module — + * `loadModule` is what copies an edited file to a versioned sibling path + * specifically to defeat that cache. */ -export async function loadAppAuthzCatalog(appDir: string): Promise { - const router = buildRouter(appDir); +export async function loadAppAuthzCatalog( + appDirOrRouter: string | Router, + importModule: AuthzImporter = rawImport, +): Promise { + const router = typeof appDirOrRouter === "string" ? buildRouter(appDirOrRouter) : appDirOrRouter; if (!router.authz.length) return emptyCatalog(); const sources: CatalogSource[] = []; for (const entry of router.authz) { @@ -24,7 +49,7 @@ export async function loadAppAuthzCatalog(appDir: string): Promise // same failure class as a genuine conflict (a broken/misconfigured catalog), // and letting it propagate fails the boot loudly instead of silently // producing a partial catalog. Do not "helpfully" wrap this in a try/catch. - const imported = (await import(pathToFileURL(entry.file).href)) as { default?: AuthzModule }; + const imported = await importModule(entry.file); if (!imported.default) { console.warn(`[wrnexus] authz declaration ${entry.file} has no default export; skipping.`); continue; diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts index e95a579e..2bd55810 100644 --- a/packages/dev-server/src/index.ts +++ b/packages/dev-server/src/index.ts @@ -32,7 +32,7 @@ import { } from "@wrnexus/db"; import { connectFromConfig } from "@wrnexus/db/connect"; import { configureStorage, type StorageConfig } from "@wrnexus/uploader"; -import { setAuthzCatalog, type AuthzCatalog } from "@wrnexus/authz"; +import { setAuthzCatalog, type AuthzCatalog, type AuthzModule } from "@wrnexus/authz"; import { loadAppAuthzCatalog } from "./authz-boot.ts"; import { realtimeBusFromConfig } from "./realtime-bus.ts"; import { @@ -344,8 +344,11 @@ export async function startServer(opts: ServeOptions): Promise { // the framework never installs one) runs at request time and needs // getAuthzCatalog() already populated by then. An app with no declarations // gets an empty catalog; a genuine conflict between declarations throws and - // fails this boot loudly. - const authzCatalog: AuthzCatalog = await loadAppAuthzCatalog(appDir); + // fails this boot loudly. Pass the already-built `router` (not `appDir`): + // it was just built above with the full componentDirs/externalRoutes/ + // middlewareFiles options, so this avoids a second, redundant filesystem + // scan of the whole app/ tree on every dev boot. + const authzCatalog: AuthzCatalog = await loadAppAuthzCatalog(router); setAuthzCatalog(authzCatalog); // i18n is opt-in by the presence of app/locales/*.json. @@ -615,6 +618,33 @@ export async function startServer(opts: ServeOptions): Promise { middleware.invalidate(); const appFiles = files.filter((file) => !isAbsolute(file)); + // Without this branch, editing app/authz/*.ts reloaded the page (watch.ts + // classifies any non-CSS change as "server") while the OLD catalog stayed + // authoritative — a false security signal: tightening or removing a + // permission LOOKS like it took effect but does not until a restart. A + // raw `import()` here would silently no-op: Bun caches local TS/JS + // modules by filesystem path and ignores query strings, so the edited + // file must be re-imported through `loadModule` (pipeline.ts), which + // copies it to a versioned sibling path specifically to defeat that + // cache — the same mechanism every other hot-reloaded module already + // uses. `router` was just rebuilt above, so this reuses it rather than + // re-scanning the filesystem a third time. + if (appFiles.some((file) => file === "authz" || file.startsWith("authz/"))) { + try { + const nextAuthzCatalog = await loadAppAuthzCatalog( + router, + (file) => loadModule(file) as Promise<{ default?: AuthzModule }>, + ); + setAuthzCatalog(nextAuthzCatalog); + runtimeDeps.authz = nextAuthzCatalog; + } catch (error) { + console.error( + "[wrnexus] authz hot update failed — the PREVIOUS catalog remains authoritative " + + "until this is fixed and the file saved again", + error, + ); + } + } if (appFiles.some((file) => file === "schemas" || file.startsWith("schemas/"))) { assets.updateSchemas(await schemaRuntime(router)); } @@ -730,5 +760,11 @@ export type { // Deployment: the portable production handler + the node:http adapter. export { createProductionServer, createProductionHandlers } from "./prod.ts"; +// Internal: called only by the generated `.authz-setup.ts` module (see +// packages/cli/src/build.ts) to populate the authorization catalog before any +// other static import — including app middleware — evaluates. Not meant for +// direct use by application code. +export { applyAuthzManifestEarly } from "./prod.ts"; +export type { AuthzManifestEntry } from "./prod.ts"; export { toRequest, writeResponse, nodeListener, serveNode } from "./adapters/node.ts"; export type { FetchHandler } from "./adapters/node.ts"; diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index 9b4832fe..9099b513 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -109,10 +109,14 @@ export interface ProdOptions { * `app/authz/*.ts`, statically imported into the generated entry (the * catalog holds policy FUNCTIONS, so — unlike `schemasJs` — it cannot be * JSON-serialised). `module` is `undefined` for a file with no default - * export; `createProductionHandlers` warns and skips it, then merges the - * rest into the process-wide catalog before the server accepts traffic. + * export. In the NORMAL generated-entry build, the catalog is already set + * by the generated `.authz-setup.ts` module before this ever runs (see + * `applyAuthzManifestEarly` below); `createProductionHandlers` merges this + * same list again as an idempotent second pass — with its warnings — so a + * caller that bypasses the generated entry and calls it directly still gets + * a correctly merged catalog. */ - authz?: { source: string; module?: AuthzModule }[]; + authz?: AuthzManifestEntry[]; /** Resolved i18n bundle (default lang + locale messages). */ i18n?: ResolvedI18n; /** Default database connection (driver + url); enables `getDb()`. */ @@ -198,6 +202,40 @@ export function resolveProductionHostname( return environmentHostname?.trim() || explicit || "0.0.0.0"; } +/** One `app/authz/*.ts` declaration as passed through `ProdOptions.authz`. */ +export interface AuthzManifestEntry { + source: string; + /** Undefined when the declaration file has no default export. */ + module?: AuthzModule; +} + +function resolveAuthzSources( + entries: AuthzManifestEntry[], +): { source: string; module: AuthzModule }[] { + return entries.flatMap((entry) => + entry.module ? [{ source: entry.source, module: entry.module }] : [], + ); +} + +/** + * Merge + `setAuthzCatalog` as EARLY as possible, deliberately silently (no + * missing-default-export warnings). Called ONLY from the generated + * `.authz-setup.ts` module that `wrnexus build` imports FIRST in the + * production entry — before any other static import, including app + * middleware — so that a middleware module reading `getAuthzCatalog()` at its + * own module scope (the same eager shape `authzMiddleware({ catalog, ... })` + * itself requires) sees a populated catalog. `createProductionHandlers` below + * performs the exact same merge again, WITH its warnings, as the canonical, + * always-warns second pass — this function stays silent specifically so the + * normal boot path does not print the same "no default export" warning + * twice. A genuine conflict still throws here (via `mergeCatalogs`), which + * fails the boot at import time — before the entry body, and thus + * `createProductionHandlers`, ever runs. + */ +export function applyAuthzManifestEarly(entries: AuthzManifestEntry[]): void { + setAuthzCatalog(mergeCatalogs(resolveAuthzSources(entries))); +} + /** Build the route-matching tables + a module map from the manifest. */ function buildProdRouter(manifest: ProdManifest): { router: Router; @@ -371,14 +409,23 @@ export function createProductionHandlers( // Bun.serve path in createProductionServer below. The app still registers // authzMiddleware itself with its own store; this only makes the merged // catalog reachable. No declarations -> an empty catalog, no error. - const authzSources = (opts.authz ?? []).flatMap((entry) => { - if (!entry.module) { - console.warn(`[wrnexus] authz declaration ${entry.source} has no default export; skipping.`); - return []; - } - return [{ source: entry.source, module: entry.module }]; - }); - const authzCatalog = mergeCatalogs(authzSources); + // + // In the NORMAL generated-entry build, this is a deliberately redundant + // SECOND pass: the generated `.authz-setup.ts` module already ran this + // exact merge (silently, via applyAuthzManifestEarly above) before this + // function was ever called, specifically so a middleware module that reads + // getAuthzCatalog() at its own module scope sees a populated catalog — this + // function's body runs too late for that (it is reached only once every + // OTHER static import, including middleware, has already evaluated). This + // pass still runs unconditionally (not skipped when the catalog is already + // set) so a direct caller that bypasses the generated entry — and thus + // never ran that early pass — still gets a correctly merged, validated + // catalog, and so this function's own authorization handling stays fully + // testable in isolation. + for (const missing of (opts.authz ?? []).filter((entry) => !entry.module)) { + console.warn(`[wrnexus] authz declaration ${missing.source} has no default export; skipping.`); + } + const authzCatalog = mergeCatalogs(resolveAuthzSources(opts.authz ?? [])); setAuthzCatalog(authzCatalog); // Middleware is already an ordered array of functions. diff --git a/packages/dev-server/test/authz-prod.test.ts b/packages/dev-server/test/authz-prod.test.ts new file mode 100644 index 00000000..71a753f1 --- /dev/null +++ b/packages/dev-server/test/authz-prod.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from "bun:test"; +import { defineAuthz, getAuthzCatalog } from "@wrnexus/authz"; +import { + applyAuthzManifestEarly, + createProductionHandlers, + type ProdManifest, +} from "../src/prod.ts"; + +const EMPTY_MANIFEST: ProdManifest = { + pages: [], + api: [], + realtime: [], + middleware: [], + components: [], + layouts: [], +}; + +describe("createProductionHandlers authorization wiring (the idempotent second pass)", () => { + test("an empty (or absent) authz array yields an empty catalog, no error", () => { + createProductionHandlers(EMPTY_MANIFEST, { authz: [] }); + expect(getAuthzCatalog().permissions.size).toBe(0); + + createProductionHandlers(EMPTY_MANIFEST, {}); + expect(getAuthzCatalog().permissions.size).toBe(0); + }); + + test("a declaration with no default export warns and is skipped, not fatal", () => { + const originalWarn = console.warn; + const warnings: unknown[][] = []; + console.warn = (...args: unknown[]) => { + warnings.push(args); + }; + try { + createProductionHandlers(EMPTY_MANIFEST, { + authz: [ + { source: "broken.ts", module: undefined }, + { + source: "ok.ts", + module: defineAuthz({ permissions: { "post:read": {} } }), + }, + ], + }); + } finally { + console.warn = originalWarn; + } + expect(getAuthzCatalog().permissions.has("post:read")).toBe(true); + expect(getAuthzCatalog().permissions.size).toBe(1); + expect(warnings.some((args) => args.some((arg) => String(arg).includes("broken.ts")))).toBe( + true, + ); + }); + + test("a conflicting pair of declarations throws, naming both source files", () => { + expect(() => + createProductionHandlers(EMPTY_MANIFEST, { + authz: [ + { + source: "a.ts", + module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }), + }, + { + source: "b.ts", + module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }), + }, + ], + }), + ).toThrow(/WRN-AUTHZ-CONFLICT/); + + let thrown: unknown; + try { + createProductionHandlers(EMPTY_MANIFEST, { + authz: [ + { + source: "a.ts", + module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }), + }, + { + source: "b.ts", + module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }), + }, + ], + }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + const message = (thrown as Error).message; + expect(message).toContain("a.ts"); + expect(message).toContain("b.ts"); + }); + + test("calling createProductionHandlers a second time with different declarations re-validates, not skips", () => { + // Regression guard for the "skip merging if a catalog is already set" + // trap: since setAuthzCatalog is a process-wide singleton, an earlier + // test (or an earlier createProductionHandlers call in the same process) + // can leave hasAuthzCatalog() true. This call must still independently + // merge+validate its OWN opts.authz, not silently trust a stale catalog + // left over from something else. + createProductionHandlers(EMPTY_MANIFEST, { + authz: [{ source: "first.ts", module: defineAuthz({ permissions: { "a:read": {} } }) }], + }); + expect(getAuthzCatalog().permissions.has("a:read")).toBe(true); + + createProductionHandlers(EMPTY_MANIFEST, { + authz: [{ source: "second.ts", module: defineAuthz({ permissions: { "b:read": {} } }) }], + }); + expect(getAuthzCatalog().permissions.has("a:read")).toBe(false); + expect(getAuthzCatalog().permissions.has("b:read")).toBe(true); + }); +}); + +describe("applyAuthzManifestEarly (the eager, silent pass called only by the generated .authz-setup.ts)", () => { + test("sets the catalog from valid declarations", () => { + applyAuthzManifestEarly([ + { source: "early.ts", module: defineAuthz({ permissions: { "early:read": {} } }) }, + ]); + expect(getAuthzCatalog().permissions.has("early:read")).toBe(true); + }); + + test("silently skips a missing default export — no warning, no throw", () => { + const originalWarn = console.warn; + let warnCalls = 0; + console.warn = () => { + warnCalls++; + }; + try { + expect(() => + applyAuthzManifestEarly([{ source: "broken.ts", module: undefined }]), + ).not.toThrow(); + } finally { + console.warn = originalWarn; + } + expect(warnCalls).toBe(0); + expect(getAuthzCatalog().permissions.size).toBe(0); + }); + + test("still throws on a genuine conflict (fatal either way, just earlier)", () => { + expect(() => + applyAuthzManifestEarly([ + { source: "a.ts", module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }) }, + { source: "b.ts", module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }) }, + ]), + ).toThrow(/WRN-AUTHZ-CONFLICT/); + }); +}); diff --git a/packages/dev-server/test/authz-startserver.test.ts b/packages/dev-server/test/authz-startserver.test.ts index 5644076d..a4ca0251 100644 --- a/packages/dev-server/test/authz-startserver.test.ts +++ b/packages/dev-server/test/authz-startserver.test.ts @@ -34,6 +34,19 @@ afterAll(() => { rmSync(scratchRoot, { recursive: true, force: true }); }); +async function waitFor( + condition: () => boolean, + timeoutMs: number, + intervalMs = 50, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + if (!condition()) throw new Error(`waitFor: condition was not met within ${timeoutMs}ms`); +} + describe("dev boot loads the authz catalog before middleware is resolved", () => { test("an app with declarations makes getAuthzCatalog() return them after boot", async () => { const appDir = scaffold("has-decls", { @@ -100,4 +113,40 @@ export default defineAuthz({ permissions: { "post:read": { risk: "high" } } });` expect(message).toContain(join(appDir, "authz", "a.ts")); expect(message).toContain(join(appDir, "authz", "b.ts")); }); + + test("editing a declaration in a RUNNING dev server updates the live catalog, via the real file watcher", async () => { + const appDir = scaffold("hmr-live", { + "main.ts": `import { defineAuthz } from "@wrnexus/authz"; +export default defineAuthz({ permissions: { "post:read": {} } });`, + }); + const server = await startServer({ + appDir, + hostname: "127.0.0.1", + port: 0, + mode: "development", + hmr: true, + }); + try { + expect(getAuthzCatalog().permissions.has("post:read")).toBe(true); + expect(getAuthzCatalog().permissions.has("post:write")).toBe(false); + + // A real write to disk, picked up by the real fs watcher (watch.ts / + // startWatcher) — not a direct call into any internal hot-update + // function. This is the only way to prove the wiring actually works, + // as opposed to proving only that the code branch exists. + writeFileSync( + join(appDir, "authz", "main.ts"), + `import { defineAuthz } from "@wrnexus/authz"; +export default defineAuthz({ permissions: { "post:write": {} } });`, + "utf8", + ); + + await waitFor(() => getAuthzCatalog().permissions.has("post:write"), 10_000); + + expect(getAuthzCatalog().permissions.has("post:write")).toBe(true); + expect(getAuthzCatalog().permissions.has("post:read")).toBe(false); + } finally { + server.stop(); + } + }, 15_000); }); From fd5e2b71286c411b678e34d38f395ec1938c096d Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 01:22:13 +0530 Subject: [PATCH 55/59] test(authz): end-to-end integration coverage, worked example, and docs Task 15 of the authz permissions plan: proves db store + cache + catalog + middleware + audit compose correctly, wires a real (non-dangling) example into auth-showcase, and documents the declaration/registration/precedence surface in the package README. --- examples/auth-showcase/app/authz/showcase.ts | 30 ++++ .../auth-showcase/app/middleware/authz.ts | 24 +++ examples/auth-showcase/package.json | 1 + examples/auth-showcase/test/showcase.test.ts | 20 +++ packages/authz/README.md | 130 ++++++++++++++++ packages/authz/test/integration.test.ts | 143 ++++++++++++++++++ 6 files changed, 348 insertions(+) create mode 100644 examples/auth-showcase/app/authz/showcase.ts create mode 100644 examples/auth-showcase/app/middleware/authz.ts create mode 100644 packages/authz/test/integration.test.ts diff --git a/examples/auth-showcase/app/authz/showcase.ts b/examples/auth-showcase/app/authz/showcase.ts new file mode 100644 index 00000000..31e01d39 --- /dev/null +++ b/examples/auth-showcase/app/authz/showcase.ts @@ -0,0 +1,30 @@ +import { defineAuthz } from "@wrnexus/authz"; + +/** + * `app/authz/.ts` declarations are discovered automatically and merged + * into the process-wide catalog at boot (see `app/middleware/authz.ts`, which + * registers the middleware that resolves against it). + */ +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"] }, +}); diff --git a/examples/auth-showcase/app/middleware/authz.ts b/examples/auth-showcase/app/middleware/authz.ts new file mode 100644 index 00000000..dbbc862b --- /dev/null +++ b/examples/auth-showcase/app/middleware/authz.ts @@ -0,0 +1,24 @@ +import { authzMiddleware, getAuthzCatalog, memoryPermissionStore } from "@wrnexus/authz"; + +/** + * Registers the per-request authorization resolver against the catalog merged + * from `app/authz/*.ts` (see `showcase.ts`). This is an eager, module-scope + * call — the same shape `authzMiddleware({...})` requires — so it must run + * after `getAuthzCatalog()` has been populated. Both the dev server and + * `wrnexus build`'s generated production entry guarantee that happens before + * any app middleware module evaluates. + * + * Middleware runs in alphabetical filename order, so `authz.ts` runs after + * `auth.ts`, which hydrates `ctx.user` from the session. Route handlers and + * pages can then call `can(ctx, "post:write")` or guard a route with + * `guardPermission("post:delete")`. + * + * A real deployment would swap `memoryPermissionStore()` for + * `dbPermissionStore(getDb())` from `@wrnexus/authz/db` so role and grant + * assignments survive a restart; the showcase keeps everything in memory so + * it stays dependency-free. + */ +export default authzMiddleware({ + catalog: getAuthzCatalog(), + store: memoryPermissionStore(), +}); diff --git a/examples/auth-showcase/package.json b/examples/auth-showcase/package.json index de256ea7..2c455272 100644 --- a/examples/auth-showcase/package.json +++ b/examples/auth-showcase/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@wrnexus/auth": "workspace:*", + "@wrnexus/authz": "workspace:*", "@wrnexus/captcha": "workspace:*", "@wrnexus/core": "workspace:*", "@wrnexus/validation": "workspace:*" diff --git a/examples/auth-showcase/test/showcase.test.ts b/examples/auth-showcase/test/showcase.test.ts index c6f91653..af0a8328 100644 --- a/examples/auth-showcase/test/showcase.test.ts +++ b/examples/auth-showcase/test/showcase.test.ts @@ -91,3 +91,23 @@ test("package auth schemas are shared by browser forms and API handlers", () => expect(forgotPassword).toContain("data-schema='{schema}'"); expect(forgotPassword).toContain("novalidate"); }); + +test("authz is wired with a real declaration and a registered middleware, not a dangling file", () => { + expect(existsSync(join(root, "app", "authz", "showcase.ts"))).toBe(true); + expect(existsSync(join(root, "app", "middleware", "authz.ts"))).toBe(true); + + const declaration = read(root, "app", "authz", "showcase.ts"); + expect(declaration).toContain("defineAuthz"); + expect(declaration).toContain('"post:read": { title: "View posts", public: true }'); + expect(declaration).toContain("bindings:"); + + const middleware = read(root, "app", "middleware", "authz.ts"); + expect(middleware).toContain("authzMiddleware"); + expect(middleware).toContain("getAuthzCatalog()"); + expect(middleware).toContain("memoryPermissionStore()"); + + const manifest = JSON.parse(read(root, "package.json")) as { + dependencies?: Record; + }; + expect(manifest.dependencies?.["@wrnexus/authz"]).toBe("workspace:*"); +}); diff --git a/packages/authz/README.md b/packages/authz/README.md index f0102ea5..207ae431 100644 --- a/packages/authz/README.md +++ b/packages/authz/README.md @@ -149,3 +149,133 @@ app.put( - **Bun-only** — like the rest of WrNexus, this package targets the Bun runtime; Node is not supported. - Works with [`@wrnexus/core`](../core) — the guards return `Middleware` and read the subject from `ctx.user` on the request `Context`. Both types are imported from `@wrnexus/core`. - Policy combinators (`any`, `all`) and `authorize` are async-aware, so policies may return a `Promise` (e.g. for a database ownership check). + +## Declaring permissions + +The RBAC/PBAC/ABAC surface above is the low-level toolkit. On top of it sits a +declarative **registry + catalog + store + engine**: permissions, roles, and +policies are declared once in code, merged into a frozen catalog at boot, and +resolved per-request against a pluggable `PermissionStore` that holds who has +what. + +Put declarations in `app/authz/.ts`; they are discovered automatically +and merged (conflicting declarations of the same permission/role/policy across +files fail the boot loudly, naming both source files). + +```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" }, + }, + // "post:*" is a namespace wildcard grant, valid inside a role's list — it is + // not itself a registered permission, so it can only ever grant permissions + // that ARE declared above (e.g. "post:read", "post:delete"). + roles: { editor: ["post:*"], admin: ["role:editor"] }, + policies: { ownsPost: owner("id", "authorId") }, + bindings: { "post:delete": ["ownsPost"] }, +}); +``` + +`public: true` means anonymous callers may hold the permission — but any +policy bound to it still runs, and can still veto the anonymous caller (e.g. a +`notBanned` policy on a public `post:preview` permission). + +## Checking permissions + +Register `authzMiddleware` once, in `app/middleware/`, with the merged +catalog and a `PermissionStore`. Like every other `app/middleware/*.ts` file, +the registration is an eager, module-scope call — the same shape as +`authzMiddleware({ catalog, store })` requires — so it must run after the +catalog has been populated. Both the dev server and `wrnexus build`'s +generated production entry guarantee `getAuthzCatalog()` is populated before +any app middleware module evaluates. Name the file so it sorts after whatever +middleware sets `ctx.user` (middleware runs in alphabetical filename order — +`authz.ts` after `auth.ts`, for instance). + +```ts +// app/middleware/authz.ts +import { authzMiddleware, getAuthzCatalog } from "@wrnexus/authz"; +import { dbPermissionStore } from "@wrnexus/authz/db"; +import { getDb } from "@wrnexus/db"; + +export default authzMiddleware({ catalog: getAuthzCatalog(), store: dbPermissionStore(getDb()) }); +``` + +There is no per-route `middleware` export — `app/middleware/*.ts` is the only +place middleware is registered. To gate part of the app, branch on the +request the same way any other conditional middleware does (compare +`app/middleware/captcha-login.ts` in the auth showcase, which branches on +method + path the same way): + +```ts +// app/middleware/protect-posts.ts +import type { Context, Next } from "@wrnexus/core"; +import { guardPermission } from "@wrnexus/authz"; + +const guardPostWrite = guardPermission("post:write"); + +export default function protectPosts(ctx: Context, next: Next) { + return ctx.url.pathname.startsWith("/api/posts") && ctx.req.method !== "GET" + ? guardPostWrite(ctx, next) + : next(); +} +``` + +Or check inline inside a route handler with the free function `can()`: + +```ts +// app/api/posts/[id].ts +import type { Context } from "@wrnexus/core"; +import { can } from "@wrnexus/authz"; + +export const DELETE = async (ctx: Context) => { + const post = { id: "1", authorId: "alice" }; // load your own resource here + if (!(await can(ctx, "post:delete", post))) { + return Response.json({ ok: false, error: "Forbidden" }, { status: 403 }); + } + return Response.json({ ok: true }); +}; +``` + +`can()` is a free function taking `ctx`, not `ctx.can` — `@wrnexus/core` must +not depend on `@wrnexus/authz`, so the per-request resolver lives in +`ctx.locals` instead, reached through `can()` / `decideFor()` / +`guardPermission()` / `filterCan()`. Calling any of them before +`authzMiddleware` has run for that request throws a `WRN-AUTHZ-SETUP` error +naming the missing registration, rather than silently denying. + +See `examples/auth-showcase/app/authz/showcase.ts` and +`examples/auth-showcase/app/middleware/authz.ts` for a complete, runnable +version of this wiring. + +## Precedence + +1. An explicit deny wins over everything, including `*` — and honours the + same namespace-wildcard matching as grants (denying `post:*` blocks + `post:comment:delete`, not just `post:*` itself). +2. A bound policy can veto a permission a role grants, and runs even for a + `public: true` permission — including for an anonymous caller. +3. Otherwise the permission must be held via a role or an explicit grant. +4. Default deny. + +Every failure — an unknown permission (outside strict/dev mode), a store +outage, a thrown policy — denies rather than throwing through to the caller. + +`permissionsFor()` (on the resolver returned by `createAuthzResolver`) is a +coarse hint for hiding UI (e.g. a menu section), **never authoritative**. A +`Set` cannot represent "granted `post:*` except `post:delete`", so a +narrow deny beneath a broad grant is invisible to it — the set still contains +`post:*` while `can()` / `decide()` correctly refuse `post:delete`. Gate real +actions with `can()`, `decideFor()`, or `filterCan()`; never by matching +against `permissionsFor()`'s result. + +## 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 +``` diff --git a/packages/authz/test/integration.test.ts b/packages/authz/test/integration.test.ts new file mode 100644 index 00000000..e442b61b --- /dev/null +++ b/packages/authz/test/integration.test.ts @@ -0,0 +1,143 @@ +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"; + +// Exercises the full composition end to end: db-backed store -> cache +// decorator -> merged catalog -> per-request middleware -> can()/guardPermission() +// -> audit sink. Each piece already has unit coverage elsewhere; this file is +// only about the seams between them. +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, and only alice's requests used the resolver + // that was wired to this audit sink. + expect(audit.events.length).toBeGreaterThan(0); + expect(audit.events.every((event) => !event.allowed)).toBe(true); + }); + + 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" }); + }); + + test("a public permission still runs its bound policy, including for an anonymous caller", async () => { + const publicPolicyCatalog = mergeCatalogs([ + { + source: "public-policy.ts", + module: defineAuthz({ + permissions: { "post:preview": { public: true } }, + policies: { + notBanned: async (_s: { id?: string } | null | undefined, r?: { banned?: boolean }) => + r?.banned ? { allowed: false, reason: "resource banned" } : { allowed: true }, + }, + bindings: { "post:preview": ["notBanned"] }, + }), + }, + ]); + const db = createDb(sqlite(":memory:")); + await ensureAuthzTables(db, "sqlite"); + const store = dbPermissionStore(db); + + const guest = makeCtx(null); + await authzMiddleware({ catalog: publicPolicyCatalog, store, strict: true })( + guest, + async () => new Response("ok"), + ); + expect(await can(guest, "post:preview", { banned: false })).toBe(true); + expect(await can(guest, "post:preview", { banned: true })).toBe(false); + }); +}); From a7255fa1bd10f6040572ad4914babbb028b002dd Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 02:10:00 +0530 Subject: [PATCH 56/59] fix(dev-server): don't clobber a caller-set authz catalog; drop dead RuntimeDeps.authz createProductionHandlers called setAuthzCatalog unconditionally, so a caller using client.ts's documented escape hatch (setAuthzCatalog(catalog) before importing anything that reads it) had that catalog silently wiped to empty whenever opts.authz was omitted. Now only sets when opts.authz has entries to contribute, or when nothing has been set yet; a non-empty opts.authz still always sets and still throws on a genuine conflict. Also removes RuntimeDeps.authz: nothing read it, and its doc comment described a consumer that doesn't exist. The real wiring is getAuthzCatalog()/setAuthzCatalog(), including the HMR hot-update path, which is untouched. Co-Authored-By: Claude Opus 5 --- packages/dev-server/src/index.ts | 2 - packages/dev-server/src/prod.ts | 27 ++++-- packages/dev-server/src/runtime.ts | 12 --- packages/dev-server/test/authz-prod.test.ts | 101 ++++++++++++++++++-- 4 files changed, 112 insertions(+), 30 deletions(-) diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts index 2bd55810..8b1845f2 100644 --- a/packages/dev-server/src/index.ts +++ b/packages/dev-server/src/index.ts @@ -472,7 +472,6 @@ export async function startServer(opts: ServeOptions): Promise { security: opts.security, observability: opts.observability, tenancy: opts.tenancy, - authz: authzCatalog, navigation: opts.navigation, clientRuntimes: pluginContributions.clientRuntimes, hub, @@ -636,7 +635,6 @@ export async function startServer(opts: ServeOptions): Promise { (file) => loadModule(file) as Promise<{ default?: AuthzModule }>, ); setAuthzCatalog(nextAuthzCatalog); - runtimeDeps.authz = nextAuthzCatalog; } catch (error) { console.error( "[wrnexus] authz hot update failed — the PREVIOUS catalog remains authoritative " + diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index 9099b513..f6be505d 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -38,7 +38,7 @@ import { VALIDATE_RUNTIME } from "@wrnexus/validation"; import { I18N_RUNTIME, type ResolvedI18n } from "@wrnexus/i18n"; import { setDb, registerLazyDb, getDb, hasDb, migrate } from "@wrnexus/db"; import { connectFromConfig } from "@wrnexus/db/connect"; -import { mergeCatalogs, setAuthzCatalog, type AuthzModule } from "@wrnexus/authz"; +import { hasAuthzCatalog, mergeCatalogs, setAuthzCatalog, type AuthzModule } from "@wrnexus/authz"; import { configureStorage, serveStoredFile, @@ -416,17 +416,25 @@ export function createProductionHandlers( // function was ever called, specifically so a middleware module that reads // getAuthzCatalog() at its own module scope sees a populated catalog — this // function's body runs too late for that (it is reached only once every - // OTHER static import, including middleware, has already evaluated). This - // pass still runs unconditionally (not skipped when the catalog is already - // set) so a direct caller that bypasses the generated entry — and thus - // never ran that early pass — still gets a correctly merged, validated - // catalog, and so this function's own authorization handling stays fully - // testable in isolation. + // OTHER static import, including middleware, has already evaluated). + // + // The merge+validation of opts.authz always runs (a genuine conflict must + // still fail the boot loudly, no matter which pass discovers it). But + // setAuthzCatalog is only called when this pass actually has something to + // contribute, OR when nothing has been set yet: client.ts documents an + // escape hatch where a direct caller of createProductionHandlers may call + // setAuthzCatalog(catalog) itself before importing anything that reads it, + // specifically for a custom entry that never ran the generated + // `.authz-setup.ts` pass. Calling setAuthzCatalog unconditionally here would + // clobber that caller's catalog with an empty one whenever opts.authz is + // omitted — silently deleting every permission the app declared. for (const missing of (opts.authz ?? []).filter((entry) => !entry.module)) { console.warn(`[wrnexus] authz declaration ${missing.source} has no default export; skipping.`); } - const authzCatalog = mergeCatalogs(resolveAuthzSources(opts.authz ?? [])); - setAuthzCatalog(authzCatalog); + const mergedAuthzCatalog = mergeCatalogs(resolveAuthzSources(opts.authz ?? [])); + if ((opts.authz?.length ?? 0) > 0 || !hasAuthzCatalog()) { + setAuthzCatalog(mergedAuthzCatalog); + } // Middleware is already an ordered array of functions. const getMiddleware = async (): Promise => manifest.middleware; @@ -463,7 +471,6 @@ export function createProductionHandlers( security: opts.security, observability: opts.observability, tenancy: opts.tenancy, - authz: authzCatalog, navigation: opts.navigation, maxBodyBytes: opts.maxBodyBytes, realtimeBus: realtimeBusFromConfig(opts.realtime), diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index f572b354..e8534bc2 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -63,7 +63,6 @@ import { requestStoreContainer, } from "@wrnexus/ssr/store-context"; import type { StoreDefinition } from "@wrnexus/store"; -import type { AuthzCatalog } from "@wrnexus/authz"; import type { ClientRuntimeDefinition } from "@wrnexus/plugin"; import { CacheCoordinator } from "@wrnexus/cache"; import { generateServiceWorker } from "@wrnexus/pwa"; @@ -186,17 +185,6 @@ export interface RuntimeDeps { health?: HealthRegistry; /** Built-in tenant identity resolution. */ tenancy?: TenancyConfig; - /** - * Process-wide authorization catalog, merged from `app/authz/*.ts` at boot - * (dev: `loadAppAuthzCatalog`; prod: `mergeCatalogs` over the build's static - * imports). Also reachable via `@wrnexus/authz`'s `getAuthzCatalog()` - * singleton, which is what the app's own `authzMiddleware` registration - * actually reads — this field exists so the request pipeline can see the - * catalog without importing that singleton directly. The framework never - * installs `authzMiddleware` itself; the app always registers it with its - * own store. - */ - authz?: AuthzCatalog; /** Max request body size in bytes (413 above this). Default 10 MB. */ maxBodyBytes?: number; /** HMR hub for browser live-update sockets (dev only). */ diff --git a/packages/dev-server/test/authz-prod.test.ts b/packages/dev-server/test/authz-prod.test.ts index 71a753f1..910ac49a 100644 --- a/packages/dev-server/test/authz-prod.test.ts +++ b/packages/dev-server/test/authz-prod.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { defineAuthz, getAuthzCatalog } from "@wrnexus/authz"; +import { pathToFileURL } from "node:url"; +import { join } from "node:path"; +import { defineAuthz, getAuthzCatalog, mergeCatalogs, setAuthzCatalog } from "@wrnexus/authz"; import { applyAuthzManifestEarly, createProductionHandlers, @@ -15,13 +17,42 @@ const EMPTY_MANIFEST: ProdManifest = { layouts: [], }; -describe("createProductionHandlers authorization wiring (the idempotent second pass)", () => { - test("an empty (or absent) authz array yields an empty catalog, no error", () => { - createProductionHandlers(EMPTY_MANIFEST, { authz: [] }); - expect(getAuthzCatalog().permissions.size).toBe(0); +const PROD_URL = pathToFileURL(join(import.meta.dir, "..", "src", "prod.ts")).href; +describe("createProductionHandlers authorization wiring (the idempotent second pass)", () => { + test("an empty/absent authz array never throws, whatever the ambient catalog state", () => { + createProductionHandlers(EMPTY_MANIFEST, { authz: [] }); createProductionHandlers(EMPTY_MANIFEST, {}); - expect(getAuthzCatalog().permissions.size).toBe(0); + }); + + test("starting from a genuinely unset catalog, an empty/absent authz array yields an empty catalog", async () => { + // bun test does NOT isolate module instances between test files run in + // the same invocation (see client.test.ts's comment on the same trap), + // so "no catalog set yet" cannot be observed reliably in-process — some + // other file's test may already have called setAuthzCatalog. A fresh + // subprocess is the only way to guarantee that. + const proc = Bun.spawn({ + cmd: [ + "bun", + "-e", + `const mod = await import(${JSON.stringify(PROD_URL)}); + const manifest = { pages: [], api: [], realtime: [], middleware: [], components: [], layouts: [] }; + mod.createProductionHandlers(manifest, { authz: [] }); + const { getAuthzCatalog } = await import("@wrnexus/authz"); + console.log("SIZE:" + getAuthzCatalog().permissions.size);`, + ], + stdout: "pipe", + stderr: "pipe", + cwd: join(import.meta.dir, ".."), + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(stdout).toContain("SIZE:0"); }); test("a declaration with no default export warns and is skipped, not fatal", () => { @@ -107,6 +138,64 @@ describe("createProductionHandlers authorization wiring (the idempotent second p expect(getAuthzCatalog().permissions.has("a:read")).toBe(false); expect(getAuthzCatalog().permissions.has("b:read")).toBe(true); }); + + test("a caller-set catalog survives when opts.authz is omitted (the client.ts escape hatch)", () => { + // client.ts documents that a direct caller of createProductionHandlers may + // call setAuthzCatalog(catalog) itself, before importing anything that + // reads it, when it bypasses the generated `.authz-setup.ts` entry. That + // catalog must not be wiped just because this call's own opts.authz is + // empty/absent. + const preset = mergeCatalogs([ + { + source: "preset.ts", + module: defineAuthz({ + permissions: { "preset:read": {}, "preset:write": {}, "preset:delete": {} }, + }), + }, + ]); + setAuthzCatalog(preset); + expect(getAuthzCatalog().permissions.size).toBe(3); + + createProductionHandlers(EMPTY_MANIFEST, {}); + + expect(getAuthzCatalog()).toBe(preset); + expect(getAuthzCatalog().permissions.size).toBe(3); + expect(getAuthzCatalog().permissions.has("preset:read")).toBe(true); + }); + + test("a non-empty opts.authz still sets (and still throws on a conflict), even over a pre-set catalog", () => { + const preset = mergeCatalogs([ + { source: "preset.ts", module: defineAuthz({ permissions: { "preset:read": {} } }) }, + ]); + setAuthzCatalog(preset); + + // A non-empty authz array must still replace the pre-set catalog with the + // merged result of ITS OWN declarations, not defer to the pre-set one. + createProductionHandlers(EMPTY_MANIFEST, { + authz: [{ source: "own.ts", module: defineAuthz({ permissions: { "own:read": {} } }) }], + }); + expect(getAuthzCatalog()).not.toBe(preset); + expect(getAuthzCatalog().permissions.has("own:read")).toBe(true); + expect(getAuthzCatalog().permissions.has("preset:read")).toBe(false); + + // And a genuine conflict inside that non-empty array still throws, exactly + // as it did before this pass became conditional. + setAuthzCatalog(preset); + expect(() => + createProductionHandlers(EMPTY_MANIFEST, { + authz: [ + { + source: "a.ts", + module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }), + }, + { + source: "b.ts", + module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }), + }, + ], + }), + ).toThrow(/WRN-AUTHZ-CONFLICT/); + }); }); describe("applyAuthzManifestEarly (the eager, silent pass called only by the generated .authz-setup.ts)", () => { From 3867e7c183666ec6e89ff40a3c65395fdc43c0f1 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 02:10:11 +0530 Subject: [PATCH 57/59] fix(authz): audit getResource denials; fail closed on a malformed denies shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit guardPermission's getResource catch returned 403 directly, never reaching decideFor -> decide -> finish, so the audit sink never saw it — an attacker probing ids that make the resource loader throw got a clean 403 stream invisible to the audit trail. The audit sink is now stashed on the per-request RequestAuthz object (authzMiddleware already receives it via AuthzResolverOptions), and the catch records an "allowed: false" event with an opaque reason before returning the 403. Also: the explicit-deny check sat outside decide()'s try/catch, and deniedBy() guarded on denies.length rather than Array.isArray(denies). A store returning denies as a bare string let new Set(denies) iterate characters instead of the permission, so the deny matched nothing and was silently discarded; a store omitting denies entirely threw straight out of decide(). Both are now validated and handled inside the try, denying via the same "Authorization store unavailable" path as any other store failure. Co-Authored-By: Claude Opus 5 --- packages/authz/src/engine.ts | 42 ++++++++++++++++++------ packages/authz/src/middleware.ts | 33 +++++++++++++++++++ packages/authz/test/engine.test.ts | 44 ++++++++++++++++++++++++++ packages/authz/test/middleware.test.ts | 28 ++++++++++++++++ 4 files changed, 137 insertions(+), 10 deletions(-) diff --git a/packages/authz/src/engine.ts b/packages/authz/src/engine.ts index b864c281..ca566ed7 100644 --- a/packages/authz/src/engine.ts +++ b/packages/authz/src/engine.ts @@ -75,6 +75,13 @@ export function permissionMatches(granted: Set, permission: string): boo * post:comment:delete rather than being accepted and silently doing nothing. */ export function deniedBy(denies: readonly string[], permission: string): boolean { + // A non-conforming store (e.g. denies: "post:write" instead of an array) + // must not silently discard an explicit deny: new Set("post:write") would + // iterate the string's characters instead of throwing, so the deny would + // match nothing and fail open. Array.isArray guards the SHAPE, not just + // the length, so a truthy-but-non-array denies value denies by falling + // through to the caller's catch instead of matching nothing here. + if (!Array.isArray(denies)) return false; return denies.length ? permissionMatches(new Set(denies), permission) : false; } @@ -204,21 +211,36 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve let granted: Set; try { ({ assignments, granted } = await loadEffective(subjectId, scope)); + + // A store returning a non-array `denies` (e.g. a single string, or + // omitting the field entirely) violates the PermissionStore contract. + // Treat that exactly like assignmentsFor() itself throwing — fail + // closed — rather than letting a malformed shape flow into + // deniedBy(): a string denies would otherwise iterate as + // CHARACTERS (new Set("post:write") is a set of letters, not the + // permission), so an explicit deny would silently match nothing and + // be discarded, and an omitted `denies` would throw past this + // function entirely if it weren't caught here. + if (!Array.isArray(assignments.denies)) { + throw new TypeError( + "WRN-AUTHZ-STORE: assignmentsFor() must return an array for `denies`", + ); + } + + // 1. Explicit deny wins over everything, including "*", honouring wildcards. + 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" }); + } } 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 "*", honouring wildcards. - 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 }); diff --git a/packages/authz/src/middleware.ts b/packages/authz/src/middleware.ts index e0e46c61..a7dceaa8 100644 --- a/packages/authz/src/middleware.ts +++ b/packages/authz/src/middleware.ts @@ -1,5 +1,6 @@ import type { Context, Middleware } from "@wrnexus/core"; import type { AuthorizationDecision } from "./advanced.ts"; +import { safeRecord, type AuthzAuditSink } from "./audit.ts"; import { createAuthzResolver, type AuthzResolver, type AuthzResolverOptions } from "./engine.ts"; import type { AuthzScope } from "./types.ts"; @@ -11,6 +12,12 @@ export const AUTHZ_LOCALS_KEY = "_authz"; interface RequestAuthz { resolver: AuthzResolver; + /** + * Same sink `decide()` records through. Stashed here too so a denial that + * never reaches the resolver (e.g. `guardPermission`'s `getResource` + * throwing) can still be audited, instead of vanishing from the trail. + */ + audit: AuthzAuditSink | undefined; /** Memo for object resources, keyed by identity so two rows never collide. */ byRef: WeakMap>>; /** Memo for symbol resources, keyed by identity for the same reason. */ @@ -36,6 +43,7 @@ export function authzMiddleware(options: AuthzResolverOptions): Middleware { return (ctx, next) => { const request: RequestAuthz = { resolver, + audit: options.audit, byRef: new WeakMap(), bySymbol: new Map(), byValue: new Map(), @@ -54,6 +62,16 @@ function currentScope(ctx: Context): AuthzScope | undefined { return typeof tenantId === "string" && tenantId !== "" ? { tenantId } : undefined; } +/** + * Same normalization `decide()` applies before handing a subject id to the + * audit sink: a non-empty string, or undefined (never a raw non-string id + * leaking into an audit record). + */ +function subjectIdOf(ctx: Context): string | undefined { + const rawId = (ctx.user as { id?: unknown } | null | undefined)?.id; + return typeof rawId === "string" && rawId !== "" ? rawId : undefined; +} + /** * Object resources are memoised by identity (`byRef`), never by serialising * their contents — serialisation is what let unrelated resources collide @@ -203,6 +221,21 @@ export function guardPermission(permission: string, options: GuardOptions = {}): resource = await options.getResource(ctx); } catch (error) { console.error(`[wrnexus:authz] getResource threw for '${permission}'; denying`, error); + // This denial never reaches decideFor()/decide()/finish() — the + // resource load failed before there was anything to decide — so + // without recording here it would be invisible to the audit trail: + // an attacker probing ids that make the loader throw gets a clean + // 403 stream no operator can see. Keep the response body opaque + // (no loader message), same as every other guardPermission denial. + const { audit } = readAuthz(ctx); + safeRecord(audit, { + subjectId: subjectIdOf(ctx), + scope: currentScope(ctx), + permission, + allowed: false, + reason: "Resource unavailable", + at: Date.now(), + }); return Response.json( { ok: false, error: "Forbidden" }, { status: 403, headers: NO_STORE_HEADERS }, diff --git a/packages/authz/test/engine.test.ts b/packages/authz/test/engine.test.ts index 4af1b552..05fc2b28 100644 --- a/packages/authz/test/engine.test.ts +++ b/packages/authz/test/engine.test.ts @@ -312,6 +312,50 @@ describe("createAuthzResolver fail-closed regressions", () => { expect(decision.reason).toMatch(/explicit deny/i); }); + test("a store returning a non-array `denies` (e.g. a string) denies rather than silently allowing", async () => { + // new Set("post:write") would iterate CHARACTERS, not the permission, so + // a store returning a malformed `denies` shape must not let an otherwise + // role-granted permission slip through as allowed. Uses "post:comment:delete" + // (granted via the "moderator" role's "post:comment:*" wildcard) rather + // than "post:write", specifically because "post:write" is bound to the + // "ownsPost" policy in this test catalog — a resource-ownership check + // that would itself deny an unowned resource and mask the exact bug this + // test exists to catch, passing for the wrong reason even without the fix. + const store = memoryPermissionStore(); + await store.assignRole("u1", "moderator"); // moderator -> post:comment:* wildcard grant + const malformed = { + ...store, + assignmentsFor: async (subjectId: string, scope?: { tenantId?: string }) => { + const real = await store.assignmentsFor(subjectId, scope); + return { ...real, denies: "post:comment:delete" as unknown as string[] }; + }, + }; + const resolver = createAuthzResolver({ catalog, store: malformed, strict: false }); + const result = await resolver.decide({ + subject: { id: "u1" }, + permission: "post:comment:delete", + }); + expect(result.allowed).toBe(false); + }); + + test("a store omitting `denies` entirely denies rather than throwing out of decide()", async () => { + const store = memoryPermissionStore(); + await store.assignRole("u1", "editor"); + const malformed = { + ...store, + assignmentsFor: async (subjectId: string, scope?: { tenantId?: string }) => { + const real = await store.assignmentsFor(subjectId, scope); + const { denies: _denies, ...withoutDenies } = real; + return withoutDenies as unknown as typeof real; + }, + }; + const resolver = createAuthzResolver({ catalog, store: malformed, strict: false }); + // If decide() still threw/rejected instead of denying, this `await` would + // reject and fail the test right here rather than reaching the assertion. + const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:write" }); + expect(result.allowed).toBe(false); + }); + test("non-string subject ids deny rather than falling back to anonymous", async () => { const { resolver } = make(); const invalidIds: unknown[] = [0, "", 123, {}]; diff --git a/packages/authz/test/middleware.test.ts b/packages/authz/test/middleware.test.ts index b3d29d90..2c4f7c17 100644 --- a/packages/authz/test/middleware.test.ts +++ b/packages/authz/test/middleware.test.ts @@ -3,6 +3,7 @@ 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 { memoryAuditSink } from "../src/audit.ts"; import { authzMiddleware, can, filterCan, guardPermission } from "../src/middleware.ts"; const catalog = mergeCatalogs([ @@ -307,6 +308,33 @@ describe("guardPermission hardening", () => { expect(body).toEqual({ ok: false, error: "Forbidden" }); }); + test("a throwing getResource still records exactly one audit event, not a silent gap", async () => { + // The catch used to return the 403 directly, never entering + // decideFor -> decide -> finish, so the audit sink never saw it — an + // attacker probing ids that make the loader throw got a clean 403 stream + // invisible to the audit trail. + const ctx = makeCtx({ id: "u1" }); + const audit = memoryAuditSink(); + await authzMiddleware({ catalog, store: memoryPermissionStore(), strict: false, audit })( + ctx, + async () => new Response("ok"), + ); + const guard = guardPermission("post:delete", { + getResource: () => { + throw new Error("SELECT * FROM posts WHERE id = 1 -- boom"); + }, + }); + const res = await guard(ctx, async () => new Response("passed")); + expect(res.status).toBe(403); + const body = (await res.json()) as Record; + expect(body).toEqual({ ok: false, error: "Forbidden" }); + expect(audit.events).toHaveLength(1); + expect(audit.events[0]!.allowed).toBe(false); + expect(audit.events[0]!.permission).toBe("post:delete"); + // The loader's message must never reach the audit record either. + expect(JSON.stringify(audit.events[0])).not.toContain("SELECT"); + }); + test("redirectTo issues a 303 for a page request", async () => { const ctx = makeCtx({ id: "u1" }); await withMiddleware(ctx); From 41b6e2ed2b21cc8f84b54511c4efdf150f2b0839 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 02:10:24 +0530 Subject: [PATCH 58/59] fix(authz): freeze catalog values after boot; correct compile-time-check claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit frozenMap only blocked the Map's own mutators, so catalog.roles.get("editor").push("*") escalated a role to a full wildcard past an error string claiming the catalog is frozen after boot; the same applied to permission/attribute metadata objects and binding arrays. mergeCatalogs now stores frozen copies of each, so the original declaring module's objects are never mutated either. Also corrects two docstrings (codegen.ts, the design doc) that claimed `wrnexus authz generate`'s output makes a permission typo a type error — can(), guardPermission(), and decideFor() all take a bare string and nothing consumes the generated union automatically. Documents what it actually is: a Permission/Role union to type your own helpers/constants against. Also adds a README note on the subject.id contract (must be a non-empty string; owner() compares with Object.is). Co-Authored-By: Claude Opus 5 --- .../2026-08-04-authz-permissions-design.md | 6 +- packages/authz/README.md | 23 ++++++++ packages/authz/src/catalog.ts | 18 ++++-- packages/authz/src/codegen.ts | 9 ++- packages/authz/test/catalog.test.ts | 58 +++++++++++++++++++ 5 files changed, 106 insertions(+), 8 deletions(-) diff --git a/docs/plans/2026-08-04-authz-permissions-design.md b/docs/plans/2026-08-04-authz-permissions-design.md index 14a72a16..f02f6665 100644 --- a/docs/plans/2026-08-04-authz-permissions-design.md +++ b/docs/plans/2026-08-04-authz-permissions-design.md @@ -229,8 +229,10 @@ request. - `wrnexus authz list` — merged catalog across the workspace, with conflicts. - `wrnexus authz generate` — emits `app/authz/permissions.gen.ts` exporting - `type Permission = "post:read" | "post:write" | ...`, so `can()` is checked at compile time. - Runs automatically in `build.ts`, mirroring `regenerateQueries`. + `type Permission = "post:read" | "post:write" | ...`. `can()`, `guardPermission()`, + and `decideFor()` all take a bare `string` and nothing consumes this union + automatically — it exists to type your own helpers/constants against the + registered catalog. Runs automatically in `build.ts`, mirroring `regenerateQueries`. - `wrnexus authz init` — scaffolds the migration and a seed helper for default roles. - Admin UI: `.wrn` components for listing subjects and assigning roles, shipped in `@wrnexus/ui` behind the existing eject mechanism. diff --git a/packages/authz/README.md b/packages/authz/README.md index 207ae431..4247ad62 100644 --- a/packages/authz/README.md +++ b/packages/authz/README.md @@ -204,6 +204,16 @@ import { getDb } from "@wrnexus/db"; export default authzMiddleware({ catalog: getAuthzCatalog(), store: dbPermissionStore(getDb()) }); ``` +> **`subject.id` must be a non-empty string.** The engine denies (and logs to +> stderr) whenever `ctx.user.id` is present but not a non-empty string — this +> includes the common case of an integer primary key. Coerce it before it +> reaches `ctx.user`, e.g. `user.id = String(row.id)`, or every request for +> that user denies with "Invalid subject" instead of resolving normally. +> `owner()` (the built-in ownership policy) compares subject and resource ids +> with `Object.is`, so both sides must be the same type too — `owner()` on a +> numeric `resource.authorId` against a stringified `subject.id` never +> matches even when they represent "the same" id. + There is no per-route `middleware` export — `app/middleware/*.ts` is the only place middleware is registered. To gate part of the app, branch on the request the same way any other conditional middleware does (compare @@ -279,3 +289,16 @@ 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 ``` + +`wrnexus authz generate`'s output is a plain `Permission | Role` string-literal +union — `can()`, `guardPermission()`, and `decideFor()` all take a bare +`string` and nothing reads this file automatically, so import it to type your +own helpers/constants against the registered catalog, e.g.: + +```ts +import type { Permission } from "app/authz/permissions.gen.ts"; + +function guard(permission: Permission) { + return guardPermission(permission); +} +``` diff --git a/packages/authz/src/catalog.ts b/packages/authz/src/catalog.ts index 0ffb77b5..f8df76ba 100644 --- a/packages/authz/src/catalog.ts +++ b/packages/authz/src/catalog.ts @@ -71,11 +71,19 @@ export function mergeCatalogs(sources: CatalogSource[]): AuthzCatalog { 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); + // Freeze a COPY, not the app's own declared object: `frozenMap` only + // blocks the Map's mutators, so `catalog.permissions.get("x").risk = + // "low"` would otherwise silently rewrite metadata past a catalog that + // claims to be frozen after boot. Copying also avoids freezing (and + // thus permanently locking) an object the declaring module might still + // hold a live reference to. + permissions.set(id, Object.freeze({ ...meta })); } for (const [name, grants] of Object.entries(module.roles ?? {})) { claim("role", name, source, roles.get(name), grants); - roles.set(name, grants); + // Same reasoning: without this, `catalog.roles.get("editor").push("*")` + // succeeds and silently escalates a role to a full wildcard. + roles.set(name, Object.freeze([...grants])); } for (const [name, policy] of Object.entries(module.policies ?? {})) { // Two closures are never deep-equal, so identity is the only sane test. @@ -90,7 +98,7 @@ export function mergeCatalogs(sources: CatalogSource[]): AuthzCatalog { } for (const [name, meta] of Object.entries(module.attributes ?? {})) { claim("attribute", name, source, attributes.get(name), meta); - attributes.set(name, meta); + attributes.set(name, Object.freeze({ ...meta })); } for (const [permission, names] of Object.entries(module.bindings ?? {})) { const set = bindings.get(permission) ?? new Set(); @@ -114,6 +122,8 @@ export function mergeCatalogs(sources: CatalogSource[]): AuthzCatalog { roles: frozenMap(roles), policies: frozenMap(policies), attributes: frozenMap(attributes), - bindings: frozenMap([...bindings].map(([k, v]) => [k, [...v]] as [string, readonly string[]])), + bindings: frozenMap( + [...bindings].map(([k, v]) => [k, Object.freeze([...v])] as [string, readonly string[]]), + ), }; } diff --git a/packages/authz/src/codegen.ts b/packages/authz/src/codegen.ts index 144f5a19..885a1f91 100644 --- a/packages/authz/src/codegen.ts +++ b/packages/authz/src/codegen.ts @@ -13,8 +13,13 @@ function union(values: string[]): string { } /** - * 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. + * Emit `Permission`/`Role` string-literal unions from the registered catalog. + * + * This does NOT make `can(ctx, "post:wrtie")` a type error — `can()`, + * `guardPermission()`, and `decideFor()` all take a bare `string`, and + * nothing in the framework consumes this generated file automatically. + * Import the unions yourself to type your OWN helpers/constants, e.g. + * `const PERM: Permission = "post:write"` or a typed wrapper around `can()`. */ export function generatePermissionTypes(catalog: AuthzCatalog): string { return `// Generated by \`wrnexus authz generate\`. DO NOT EDIT. diff --git a/packages/authz/test/catalog.test.ts b/packages/authz/test/catalog.test.ts index cc9a00db..e255d428 100644 --- a/packages/authz/test/catalog.test.ts +++ b/packages/authz/test/catalog.test.ts @@ -71,6 +71,64 @@ describe("mergeCatalogs", () => { expect(() => (catalog.permissions as Map).set("x:y", {} as never)).toThrow(); }); + test("a role's granted-entries array cannot be mutated to escalate it after boot", () => { + // frozenMap only blocks the Map's own mutators (set/delete/clear) — the + // VALUES it holds are a separate concern. Without freezing them too, + // catalog.roles.get("editor").push("*") would succeed and silently + // escalate "editor" to a full wildcard past an error string that claims + // the catalog is frozen after boot. + const catalog = mergeCatalogs([ + { source: "a.ts", module: defineAuthz({ roles: { editor: ["post:write"] } }) }, + ]); + const editorRole = catalog.roles.get("editor")!; + expect(() => (editorRole as string[]).push("*")).toThrow(); + expect(catalog.roles.get("editor")).toEqual(["post:write"]); + }); + + test("a permission's metadata object cannot be mutated after boot", () => { + const catalog = mergeCatalogs([ + { + source: "a.ts", + module: defineAuthz({ permissions: { "post:delete": { risk: "low" } } }), + }, + ]); + const meta = catalog.permissions.get("post:delete")!; + expect(() => { + (meta as { risk?: string }).risk = "high"; + }).toThrow(); + expect(catalog.permissions.get("post:delete")!.risk).toBe("low"); + }); + + test("an attribute's metadata object cannot be mutated after boot", () => { + const catalog = mergeCatalogs([ + { + source: "a.ts", + module: defineAuthz({ attributes: { department: { description: "org unit" } } }), + }, + ]); + const meta = catalog.attributes.get("department")!; + expect(() => { + (meta as { description?: string }).description = "tampered"; + }).toThrow(); + expect(catalog.attributes.get("department")!.description).toBe("org unit"); + }); + + test("a binding's policy-name array cannot be mutated after boot", () => { + const catalog = mergeCatalogs([ + { + source: "a.ts", + module: defineAuthz({ + permissions: { "post:write": {} }, + policies: { ownsPost: async () => ({ allowed: true }) }, + bindings: { "post:write": ["ownsPost"] }, + }), + }, + ]); + const names = catalog.bindings.get("post:write")!; + expect(() => (names as string[]).push("injectedPolicy")).toThrow(); + expect(catalog.bindings.get("post:write")).toEqual(["ownsPost"]); + }); + test("emptyCatalog has no entries", () => { expect(emptyCatalog().permissions.size).toBe(0); }); From 2c339bee15ead0a8ffe49eda3e1be403795db678 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 02:20:39 +0530 Subject: [PATCH 59/59] docs: record the adjudicated non-blocking authz findings Findings from the task and whole-branch reviews that were ruled non-blocking, plus the behaviour changes that need release notes. None is an authorization bypass. Recorded in the repo because the review workspace is scratch and git history does not carry the reasoning. Co-Authored-By: Claude Opus 5 --- docs/plans/2026-08-05-authz-follow-ups.md | 80 +++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/plans/2026-08-05-authz-follow-ups.md diff --git a/docs/plans/2026-08-05-authz-follow-ups.md b/docs/plans/2026-08-05-authz-follow-ups.md new file mode 100644 index 00000000..90519d68 --- /dev/null +++ b/docs/plans/2026-08-05-authz-follow-ups.md @@ -0,0 +1,80 @@ +# Authz follow-ups + +Findings from the reviews on branch `security/0.8.4-audit-and-authz-design` that were +adjudicated as non-blocking. None is an authorization bypass. Recorded here because the +review workspace is scratch and git history does not carry the reasoning. + +## Worth a ticket + +**`guardPermission` turns a 403 into a 500 when the middleware is missing.** +`packages/authz/src/middleware.ts` — the `getResource` catch now calls `readAuthz(ctx)` to +reach the audit sink, and `readAuthz` throws `WRN-AUTHZ-SETUP` when `authzMiddleware` was +never registered. That configuration is already broken, and a throw denies rather than +grants, but it converts a clean denial into a framework 500. Read the sink defensively +instead of destructuring `readAuthz`. + +**`deniedBy()` fails open in isolation.** `packages/authz/src/engine.ts` returns `false` for +a non-array argument. Safe for the one in-repo caller, which pre-validates, but `deniedBy` +is on the public surface and an external caller passing a string gets a silent `false`. +Throwing a `TypeError` would make the guard self-contained. + +**`permissionsFor()` still reads `denies` unguarded.** Same shape the engine's `decide()` +was hardened against: a store omitting `denies` throws a raw `TypeError`. Not fail-open, +and the doc comment already says never to gate on this result, but it is inconsistent with +the fix applied next to it. + +## Behaviour to carry into release notes + +**`authorizeDecision`'s 403 body no longer contains `reason` or `policy`.** Approved +breaking change — policy names describe internal authorization structure. Opt back in with +`{ exposeReason: true }`. No in-repo caller relied on the old shape. + +**RBAC namespace wildcards now match at every depth.** `post:comment:*` previously did not +grant `post:comment:delete`. The fix is correct, but it _widens_ access for any app that +relied on the old first-segment-only behaviour. + +**`Router` gained a required `authz` field.** Compile-time break for anything constructing a +`Router` object literal — custom deployment adapters, test fixtures. Consider making it +optional. + +**`subject.id` must be a non-empty string.** Integer primary keys deny every request and log +to stderr. Documented in the authz README; worth a release-note line too. + +## Known gaps, deliberately accepted + +**`listSubjects` and `assignmentsFor` disagree about "in this tenant".** Reads union global +and tenant scope; `listSubjects` matches the scope key exactly. An admin UI built on +`listSubjects` omits globally-granted superusers. Both adapters agree with each other, so +this is a model choice, not drift — but it is on the public `PermissionStore` interface. + +**DNS pinning has no real-TLS test.** Every test in `ssrf-regression.test.ts` stubs +`globalThis.fetch`, so `tls: { serverName }` is only asserted as an object property. If a +runtime ever validates the certificate against the dialed IP rather than `serverName`, +every HTTPS `safeFetch` breaks by default and no test would notice. One live-network smoke +test closes this. + +**`safeFetch` re-attaches credentials on a→b→a.** Credentials return to the intended origin, +but the path is attacker-chosen. Browsers do not re-add after leaving the origin. Track a +`hasLeftOrigin` latch. + +**Gateway basic-auth: the username compare short-circuits.** `packages/dev-server/src/gateway.ts` +— `&&` skips the password compare when the username misses, giving a measured 2.1x timing +signal (39.8ms vs 83.6ms over 200k iterations). Username enumeration. The password compare +itself is constant-time. Evaluate both, then combine. + +**`safeFetch` buffers the whole body before checking `maxResponseBytes`.** Pre-existing, not +introduced by this branch: with no `content-length`, `await response.arrayBuffer()` buffers +everything first. Verified 8MB buffered against a 1KB limit. + +**`packages/router` does not declare `@wrnexus/ui`.** Pre-existing. Passes every in-repo gate +because bare `@wrnexus/*` specifiers resolve through the root tsconfig `paths` map, not +`node_modules` — the same class of defect that would have shipped a broken published CLI. +Worth auditing every package's declared-vs-imported dependencies once. + +**The generated `Permission` union has no consumer.** `can`, `guardPermission` and +`decideFor` take bare `string`. The docstrings and design doc were corrected to stop +promising compile-time checking; wiring a type parameter is a real option if wanted. + +**Catalog conflict origin tracking drifts.** A later re-declaration overwrites the recorded +source file, so a conflict message can name the wrong original. The conflict is still +detected; only the diagnostic is affected.