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 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/.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/ 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/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..f02f6665 --- /dev/null +++ b/docs/plans/2026-08-04-authz-permissions-design.md @@ -0,0 +1,288 @@ +# 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" | ...`. `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. + +## 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. 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..1d4ce78b --- /dev/null +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -0,0 +1,3646 @@ +# Permissions System Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend `@wrnexus/authz` so permissions, roles, policies and attributes are declared in code and discoverable, while role assignments live in a pluggable store. + +**Architecture:** A **registry** (`defineAuthz`) declares what exists; a **catalog** merges declarations and freezes at boot; a **store** (`PermissionStore`) holds who-has-what; an **engine** resolves a subject to effective permissions and returns an `AuthorizationDecision`. The decision primitives already in `advanced.ts` are the evaluation layer and are not replaced. + +**Tech Stack:** TypeScript, Bun (`bun:test`), `@wrnexus/core` (Context/Middleware types only), `@wrnexus/db` (Db interface, migrations). + +## Global Constraints + +- Every `@wrnexus/*` package is version `0.8.4`. Do not change versions. +- Zero runtime npm dependencies. Use only Bun/WebCrypto/node: builtins. +- `@wrnexus/core` MUST NOT import `@wrnexus/authz`. `can()` stays off `Context`; the resolver lives in `ctx.locals._authz`. +- `@wrnexus/authz` may import **types only** from `@wrnexus/core` (`import type { Context, Middleware }`). +- Existing exports of `@wrnexus/authz` keep working unchanged, with ONE approved exception: Task 8 changes the default 403 body of `authorizeDecision` to stop disclosing policy internals. That break is intentional and ruled on; everything else is additive. +- Framework-owned tables use the `_wrn_` prefix (matching `_wrn_tenant`, `_wrn_cursor`). The spec wrote `wrn_authz_assignment`; use `_wrn_authz_assignment` and `_wrn_authz_grant`. +- `requirePermission` is already exported with signature `(rbac: Rbac, permission: string)`. Do not change it. The new resource-aware guard is named `guardPermission`. +- Every failure path denies. Never fail open. +- After any change to `packages/authz/src/index.ts` exports, regenerate the API baseline with `bun run generate:public-api`. +- Full gate before declaring done: `bun run check:production`. +- Test files live in `packages//test/*.test.ts` and use `import { describe, expect, test } from "bun:test"`. + +--- + +## File Structure + +**Created:** + +| File | Responsibility | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------- | +| `packages/authz/src/types.ts` | Shared types: `AuthzScope`, `PermissionMeta`, `AuthzModule`, `AuthzCatalog`, `SubjectAssignments` | +| `packages/authz/src/registry.ts` | `defineAuthz()` — validate and freeze one declaration module | +| `packages/authz/src/catalog.ts` | `mergeCatalogs()` — merge modules, detect conflicts, freeze | +| `packages/authz/src/store.ts` | `PermissionStore` interface, `memoryPermissionStore()`, `cachedPermissionStore()` | +| `packages/authz/src/audit.ts` | `AuthzAuditSink`, `memoryAuditSink()`, `consoleAuditSink()` | +| `packages/authz/src/engine.ts` | `createAuthzResolver()` — effective permissions, precedence, fail-closed | +| `packages/authz/src/middleware.ts` | `authzMiddleware()`, `can()`, `decideFor()`, `guardPermission()` | +| `packages/authz/src/db.ts` | `dbPermissionStore(db)` — subpath export `@wrnexus/authz/db` | +| `packages/authz/src/migrations.ts` | `authzMigrationSql(dialect)` — DDL for the two tables | +| `packages/authz/src/codegen.ts` | `generatePermissionTypes(catalog)` — emits the `Permission`/`Role` unions | +| `packages/authz/test/store-conformance.ts` | Shared suite both store adapters must pass (not a `.test.ts`) | +| `packages/cli/src/authz.ts` | `runAuthzCommand(root, sub, args)` for `list` / `init` / `generate` | + +**Modified:** + +| File | Change | +| -------------------------------- | --------------------------------------------------------------- | +| `packages/authz/src/index.ts` | Re-export the new surface | +| `packages/authz/src/advanced.ts` | `authorizeDecision` gains `{ exposeReason }`, defaulting to off | +| `packages/authz/package.json` | Add `./db` subpath export | +| `packages/router/src/index.ts` | Discover `app/authz/*.{ts,js}` into `router.authz` | +| `packages/cli/src/index.ts` | Dispatch `case "authz"` | +| `docs/public-api-0.8.json` | Regenerated baseline | + +--- + +## Task 1: Types and registry + +**Files:** + +- Create: `packages/authz/src/types.ts` +- Create: `packages/authz/src/registry.ts` +- Test: `packages/authz/test/registry.test.ts` + +**Interfaces:** + +- Consumes: `DecisionPolicy` from `./advanced.ts` +- Produces: `AuthzScope`, `PermissionMeta`, `AuthzModule`, `AuthzCatalog`, `SubjectAssignments`, `defineAuthz(module: AuthzModule): AuthzModule` + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/registry.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { defineAuthz } from "../src/registry.ts"; + +describe("defineAuthz", () => { + test("returns a frozen module", () => { + const mod = defineAuthz({ + permissions: { "post:read": { title: "View posts" } }, + roles: { editor: ["post:*"] }, + }); + expect(Object.isFrozen(mod)).toBe(true); + expect(mod.permissions!["post:read"]!.title).toBe("View posts"); + expect(mod.roles!.editor).toEqual(["post:*"]); + }); + + test("defaults missing sections to empty objects", () => { + const mod = defineAuthz({}); + expect(mod.permissions).toEqual({}); + expect(mod.roles).toEqual({}); + expect(mod.policies).toEqual({}); + expect(mod.attributes).toEqual({}); + expect(mod.bindings).toEqual({}); + }); + + test("rejects a permission id that is not colon-namespaced lowercase", () => { + expect(() => defineAuthz({ permissions: { "Post Read": {} } })).toThrow(/permission id/i); + expect(() => defineAuthz({ permissions: { "post:*": {} } })).toThrow(/wildcard/i); + }); + + test("rejects a role granting an unknown-shaped entry", () => { + expect(() => defineAuthz({ roles: { editor: [""] } })).toThrow(/role 'editor'/i); + }); + + test("rejects a binding naming a policy that is not declared", () => { + expect(() => + defineAuthz({ + permissions: { "post:write": {} }, + bindings: { "post:write": ["missingPolicy"] }, + }), + ).toThrow(/missingPolicy/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/authz/test/registry.test.ts` +Expected: FAIL — cannot resolve `../src/registry.ts` + +- [ ] **Step 3: Write the types** + +Create `packages/authz/src/types.ts`: + +```ts +import type { DecisionPolicy } from "./advanced.ts"; + +/** Narrows an assignment to a tenant. Absent means a global assignment. */ +export interface AuthzScope { + tenantId?: string; +} + +export interface PermissionMeta { + title?: string; + description?: string; + risk?: "low" | "medium" | "high"; + /** Granted to anonymous subjects. Every other permission denies without a user. */ + public?: boolean; +} + +export interface AttributeMeta { + description?: string; +} + +/** One `app/authz/.ts` declaration. */ +export interface AuthzModule { + permissions?: Record; + roles?: Record; + policies?: Record>; + attributes?: Record; + /** permission id -> policy names that must pass for it. */ + bindings?: Record; +} + +/** The merged, frozen view of every declaration in the app. */ +export interface AuthzCatalog { + permissions: ReadonlyMap; + roles: ReadonlyMap; + policies: ReadonlyMap>; + attributes: ReadonlyMap; + bindings: ReadonlyMap; +} + +export interface SubjectAssignments { + roles: string[]; + /** Explicit allows, bypassing roles. */ + grants: string[]; + /** Explicit denies. Win over everything, including "*". */ + denies: string[]; +} +``` + +- [ ] **Step 4: Write the registry** + +Create `packages/authz/src/registry.ts`: + +```ts +import type { AuthzModule } from "./types.ts"; + +const PERMISSION_ID = /^[a-z0-9]+(?::[a-z0-9-]+)+$/; + +/** + * Validate and freeze one authorization declaration. Called from + * `app/authz/.ts` as the module's default export. + */ +export function defineAuthz(module: AuthzModule): AuthzModule { + const permissions = module.permissions ?? {}; + const roles = module.roles ?? {}; + const policies = module.policies ?? {}; + const attributes = module.attributes ?? {}; + const bindings = module.bindings ?? {}; + + for (const id of Object.keys(permissions)) { + if (id.includes("*")) { + throw new Error( + `WRN-AUTHZ-DECL: permission id '${id}' must not contain a wildcard; wildcards belong in roles.`, + ); + } + if (!PERMISSION_ID.test(id)) { + throw new Error( + `WRN-AUTHZ-DECL: permission id '${id}' must be lowercase colon-namespaced, e.g. 'post:read'.`, + ); + } + } + + for (const [role, grants] of Object.entries(roles)) { + for (const grant of grants) { + if (typeof grant !== "string" || !grant.trim()) { + throw new Error( + `WRN-AUTHZ-DECL: role '${role}' grants an empty entry; expected a permission, 'ns:*', or 'role:'.`, + ); + } + } + } + + for (const [permission, names] of Object.entries(bindings)) { + for (const name of names) { + if (!(name in policies)) { + throw new Error( + `WRN-AUTHZ-DECL: binding for '${permission}' names policy '${name}', which is not declared in the same module.`, + ); + } + } + } + + return Object.freeze({ permissions, roles, policies, attributes, bindings }); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `bun test packages/authz/test/registry.test.ts` +Expected: PASS, 5 tests + +- [ ] **Step 6: Commit** + +```bash +git add packages/authz/src/types.ts packages/authz/src/registry.ts packages/authz/test/registry.test.ts +git commit -m "feat(authz): add defineAuthz declaration registry" +``` + +--- + +## Task 2: Catalog merge and conflict detection + +**Files:** + +- Create: `packages/authz/src/catalog.ts` +- Test: `packages/authz/test/catalog.test.ts` + +**Interfaces:** + +- Consumes: `AuthzModule`, `AuthzCatalog` from `./types.ts`; `defineAuthz` from `./registry.ts` +- Produces: `mergeCatalogs(sources: CatalogSource[]): AuthzCatalog`, `interface CatalogSource { source: string; module: AuthzModule }`, `emptyCatalog(): AuthzCatalog` + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/catalog.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { defineAuthz } from "../src/registry.ts"; +import { emptyCatalog, mergeCatalogs } from "../src/catalog.ts"; + +describe("mergeCatalogs", () => { + test("merges disjoint modules", () => { + const catalog = mergeCatalogs([ + { source: "a.ts", module: defineAuthz({ permissions: { "post:read": {} } }) }, + { source: "b.ts", module: defineAuthz({ permissions: { "user:read": {} } }) }, + ]); + expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "user:read"]); + }); + + test("re-declaring a permission with deep-equal metadata is a no-op", () => { + const meta = { title: "View posts", risk: "low" as const }; + const catalog = mergeCatalogs([ + { source: "a.ts", module: defineAuthz({ permissions: { "post:read": meta } }) }, + { source: "b.ts", module: defineAuthz({ permissions: { "post:read": { ...meta } } }) }, + ]); + expect(catalog.permissions.size).toBe(1); + }); + + test("conflicting metadata is a boot error naming both files", () => { + expect(() => + mergeCatalogs([ + { source: "a.ts", module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }) }, + { source: "b.ts", module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }) }, + ]), + ).toThrow(/a\.ts.*b\.ts|b\.ts.*a\.ts/s); + }); + + test("conflicting role definitions are a boot error", () => { + expect(() => + mergeCatalogs([ + { source: "a.ts", module: defineAuthz({ roles: { editor: ["post:read"] } }) }, + { source: "b.ts", module: defineAuthz({ roles: { editor: ["post:write"] } }) }, + ]), + ).toThrow(/editor/); + }); + + test("bindings for the same permission union across modules", () => { + const p1 = defineAuthz({ + permissions: { "post:write": {} }, + policies: { ownsPost: async () => ({ allowed: true }) }, + bindings: { "post:write": ["ownsPost"] }, + }); + const p2 = defineAuthz({ + policies: { notLocked: async () => ({ allowed: true }) }, + bindings: { "post:write": ["notLocked"] }, + }); + const catalog = mergeCatalogs([ + { source: "a.ts", module: p1 }, + { source: "b.ts", module: p2 }, + ]); + expect([...catalog.bindings.get("post:write")!].sort()).toEqual(["notLocked", "ownsPost"]); + }); + + test("a binding referencing a policy no module declares is a boot error", () => { + expect(() => + mergeCatalogs([ + { + source: "a.ts", + module: { permissions: { "post:write": {} }, bindings: { "post:write": ["ghost"] } }, + }, + ]), + ).toThrow(/ghost/); + }); + + test("the merged catalog is frozen", () => { + const catalog = mergeCatalogs([]); + expect(() => (catalog.permissions as Map).set("x:y", {} as never)).toThrow(); + }); + + test("emptyCatalog has no entries", () => { + expect(emptyCatalog().permissions.size).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/authz/test/catalog.test.ts` +Expected: FAIL — cannot resolve `../src/catalog.ts` + +- [ ] **Step 3: Write the implementation** + +Create `packages/authz/src/catalog.ts`: + +```ts +import type { AttributeMeta, AuthzCatalog, AuthzModule, PermissionMeta } from "./types.ts"; +import type { DecisionPolicy } from "./advanced.ts"; + +export interface CatalogSource { + /** File or package that declared this module, used in conflict messages. */ + source: string; + module: AuthzModule; +} + +/** Structural equality for declaration metadata. Key order is irrelevant. */ +function deepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + const left = a as Record; + const right = b as Record; + const keys = new Set([...Object.keys(left), ...Object.keys(right)]); + for (const key of keys) if (!deepEqual(left[key], right[key])) return false; + return true; +} + +/** A frozen Map that throws on mutation, so the catalog cannot drift after boot. */ +function frozenMap(entries: Iterable<[string, V]>): ReadonlyMap { + const map = new Map(entries); + const reject = () => { + throw new Error("WRN-AUTHZ-FROZEN: the authorization catalog is frozen after boot."); + }; + map.set = reject as never; + map.delete = reject as never; + map.clear = reject as never; + return map; +} + +export function emptyCatalog(): AuthzCatalog { + return { + permissions: frozenMap([]), + roles: frozenMap([]), + policies: frozenMap>([]), + attributes: frozenMap([]), + bindings: frozenMap([]), + }; +} + +export function mergeCatalogs(sources: CatalogSource[]): AuthzCatalog { + const permissions = new Map(); + const roles = new Map(); + const policies = new Map>(); + const attributes = new Map(); + const bindings = new Map>(); + const origin = new Map(); + + const claim = ( + kind: string, + key: string, + source: string, + existingValue: unknown, + value: unknown, + ) => { + const previous = origin.get(`${kind}:${key}`); + if (previous === undefined) { + origin.set(`${kind}:${key}`, source); + return; + } + if (!deepEqual(existingValue, value)) { + throw new Error( + `WRN-AUTHZ-CONFLICT: ${kind} '${key}' is declared differently in ${previous} and ${source}.`, + ); + } + }; + + for (const { source, module } of sources) { + for (const [id, meta] of Object.entries(module.permissions ?? {})) { + claim("permission", id, source, permissions.get(id), meta); + permissions.set(id, meta); + } + for (const [name, grants] of Object.entries(module.roles ?? {})) { + claim("role", name, source, roles.get(name), grants); + roles.set(name, grants); + } + for (const [name, policy] of Object.entries(module.policies ?? {})) { + // Two closures are never deep-equal, so identity is the only sane test. + const existing = policies.get(name); + if (existing && existing !== policy) { + throw new Error( + `WRN-AUTHZ-CONFLICT: policy '${name}' is declared differently in ${origin.get(`policy:${name}`)} and ${source}.`, + ); + } + origin.set(`policy:${name}`, source); + policies.set(name, policy); + } + for (const [name, meta] of Object.entries(module.attributes ?? {})) { + claim("attribute", name, source, attributes.get(name), meta); + attributes.set(name, meta); + } + for (const [permission, names] of Object.entries(module.bindings ?? {})) { + const set = bindings.get(permission) ?? new Set(); + for (const name of names) set.add(name); + bindings.set(permission, set); + } + } + + for (const [permission, names] of bindings) { + for (const name of names) { + if (!policies.has(name)) { + throw new Error( + `WRN-AUTHZ-CONFLICT: binding for '${permission}' names policy '${name}', which no module declares.`, + ); + } + } + } + + return { + permissions: frozenMap(permissions), + roles: frozenMap(roles), + policies: frozenMap(policies), + attributes: frozenMap(attributes), + bindings: frozenMap([...bindings].map(([k, v]) => [k, [...v]] as [string, readonly string[]])), + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/authz/test/catalog.test.ts` +Expected: PASS, 8 tests + +- [ ] **Step 5: Commit** + +```bash +git add packages/authz/src/catalog.ts packages/authz/test/catalog.test.ts +git commit -m "feat(authz): merge declaration modules into a frozen catalog" +``` + +--- + +## Task 3: PermissionStore interface, memory adapter, conformance suite + +**Files:** + +- Create: `packages/authz/src/store.ts` +- Create: `packages/authz/test/store-conformance.ts` +- Test: `packages/authz/test/store-memory.test.ts` + +**Interfaces:** + +- Consumes: `AuthzScope`, `SubjectAssignments` from `./types.ts` +- Produces: `PermissionStore`, `memoryPermissionStore(): PermissionStore`, `runStoreConformance(name: string, makeStore: () => Promise)` + +- [ ] **Step 1: Write the conformance suite** + +Create `packages/authz/test/store-conformance.ts`. This is imported by adapter tests; it has no `.test.ts` suffix so Bun does not run it directly. + +```ts +import { beforeEach, describe, expect, test } from "bun:test"; +import type { PermissionStore } from "../src/store.ts"; + +/** + * Every PermissionStore adapter must pass this suite, so the memory and db + * implementations cannot drift apart. + */ +export function runStoreConformance(name: string, makeStore: () => Promise): void { + describe(`PermissionStore conformance: ${name}`, () => { + let store: PermissionStore; + beforeEach(async () => { + store = await makeStore(); + }); + + test("an unknown subject has empty assignments", async () => { + expect(await store.assignmentsFor("nobody")).toEqual({ + roles: [], + grants: [], + denies: [], + }); + }); + + test("assignRole then assignmentsFor round-trips", async () => { + await store.assignRole("u1", "editor"); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("assignRole is idempotent", async () => { + await store.assignRole("u1", "editor"); + await store.assignRole("u1", "editor"); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("revokeRole removes only that role", async () => { + await store.assignRole("u1", "editor"); + await store.assignRole("u1", "admin"); + await store.revokeRole("u1", "editor"); + expect((await store.assignmentsFor("u1")).roles).toEqual(["admin"]); + }); + + test("revoking a role that was never assigned is a no-op", async () => { + await store.revokeRole("u1", "ghost"); + expect((await store.assignmentsFor("u1")).roles).toEqual([]); + }); + + test("scoped assignments do not leak across tenants", async () => { + await store.assignRole("u1", "editor", { tenantId: "t1" }); + expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]); + expect((await store.assignmentsFor("u1", { tenantId: "t2" })).roles).toEqual([]); + }); + + test("a global assignment is visible inside every tenant", async () => { + await store.assignRole("u1", "superadmin"); + expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["superadmin"]); + }); + + test("global and scoped roles union within a tenant", async () => { + await store.assignRole("u1", "viewer"); + await store.assignRole("u1", "editor", { tenantId: "t1" }); + expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles.sort()).toEqual([ + "editor", + "viewer", + ]); + }); + + test("grant with allow and deny land in the right buckets", async () => { + await store.grant("u1", "post:write", "allow"); + await store.grant("u1", "post:delete", "deny"); + const assignments = await store.assignmentsFor("u1"); + expect(assignments.grants).toEqual(["post:write"]); + expect(assignments.denies).toEqual(["post:delete"]); + }); + + test("re-granting the same permission replaces its effect", async () => { + await store.grant("u1", "post:write", "allow"); + await store.grant("u1", "post:write", "deny"); + const assignments = await store.assignmentsFor("u1"); + expect(assignments.grants).toEqual([]); + expect(assignments.denies).toEqual(["post:write"]); + }); + + test("revokeGrant removes the permission entirely", async () => { + await store.grant("u1", "post:write", "allow"); + await store.revokeGrant("u1", "post:write"); + expect((await store.assignmentsFor("u1")).grants).toEqual([]); + }); + + test("listSubjects returns everyone with an assignment in scope", async () => { + await store.assignRole("u1", "editor", { tenantId: "t1" }); + await store.assignRole("u2", "editor", { tenantId: "t1" }); + await store.assignRole("u3", "editor", { tenantId: "t2" }); + expect((await store.listSubjects({ tenantId: "t1" })).sort()).toEqual(["u1", "u2"]); + }); + + test("an explicitly empty tenantId is refused, not treated as global", async () => { + 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("a non-string tenantId is refused", async () => { + // 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. + 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 rejected write leaves unrelated state intact", async () => { + await store.assignRole("victim", "admin"); + 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" }); + 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. 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 { + const tenantId = scope?.tenantId; + if (tenantId === undefined) return ""; + // 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 be a non-empty string; omit the scope for a global assignment.", + ); + } + return tenantId; +} + +interface Row { + subjectId: string; + scope: string; +} +interface RoleRow extends Row { + role: string; +} +interface GrantRow extends Row { + permission: string; + effect: GrantEffect; +} + +export function memoryPermissionStore(): PermissionStore { + const roles: RoleRow[] = []; + const grants: GrantRow[] = []; + + // A request inside tenant t sees global assignments plus t's own. + const visible = (row: Row, key: string) => row.scope === "" || row.scope === key; + + return { + async assignmentsFor(subjectId, scope) { + const key = scopeKey(scope); + const mine = (row: Row) => row.subjectId === subjectId && visible(row, key); + const matched = grants.filter(mine); + return { + roles: roles.filter(mine).map((row) => row.role), + grants: matched.filter((row) => row.effect === "allow").map((row) => row.permission), + denies: matched.filter((row) => row.effect === "deny").map((row) => row.permission), + }; + }, + async assignRole(subjectId, role, scope) { + const key = scopeKey(scope); + if (roles.some((r) => r.subjectId === subjectId && r.scope === key && r.role === role)) + return; + roles.push({ subjectId, scope: key, role }); + }, + async revokeRole(subjectId, role, scope) { + const key = scopeKey(scope); + const at = roles.findIndex( + (r) => r.subjectId === subjectId && r.scope === key && r.role === role, + ); + if (at !== -1) roles.splice(at, 1); + }, + async grant(subjectId, permission, effect, scope) { + const key = scopeKey(scope); + const at = grants.findIndex( + (g) => g.subjectId === subjectId && g.scope === key && g.permission === permission, + ); + if (at !== -1) grants.splice(at, 1); + grants.push({ subjectId, scope: key, permission, effect }); + }, + async revokeGrant(subjectId, permission, scope) { + const key = scopeKey(scope); + const at = grants.findIndex( + (g) => g.subjectId === subjectId && g.scope === key && g.permission === permission, + ); + if (at !== -1) grants.splice(at, 1); + }, + async listSubjects(scope) { + const key = scopeKey(scope); + const ids = new Set(); + for (const row of roles) if (row.scope === key) ids.add(row.subjectId); + for (const row of grants) if (row.scope === key) ids.add(row.subjectId); + return [...ids]; + }, + }; +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `bun test packages/authz/test/store-memory.test.ts` +Expected: PASS, 13 tests + +- [ ] **Step 6: Commit** + +```bash +git add packages/authz/src/store.ts packages/authz/test/store-conformance.ts packages/authz/test/store-memory.test.ts +git commit -m "feat(authz): add PermissionStore contract with memory adapter and conformance suite" +``` + +--- + +## Task 4: Cached store decorator + +**Files:** + +- Modify: `packages/authz/src/store.ts` (append) +- Test: `packages/authz/test/store-cached.test.ts` + +**Interfaces:** + +- Consumes: `PermissionStore`, `scopeKey` from `./store.ts` +- Produces: `cachedPermissionStore(inner: PermissionStore, options?: { ttlMs?: number; max?: number }): CachedPermissionStore`, `interface CachedPermissionStore extends PermissionStore { invalidate(subjectId: string, scope?: AuthzScope): void; invalidateAll(): void }` + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/store-cached.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { cachedPermissionStore, memoryPermissionStore } from "../src/store.ts"; +import { runStoreConformance } from "./store-conformance.ts"; + +// A cache must not change observable behaviour: writes invalidate internally. +runStoreConformance("cached(memory)", async () => cachedPermissionStore(memoryPermissionStore())); + +describe("cachedPermissionStore", () => { + test("serves a repeat read from cache", async () => { + const inner = memoryPermissionStore(); + let reads = 0; + const counting = { + ...inner, + assignmentsFor: (id: string, scope?: { tenantId?: string }) => { + reads++; + return inner.assignmentsFor(id, scope); + }, + }; + const store = cachedPermissionStore(counting, { ttlMs: 60_000 }); + await store.assignmentsFor("u1"); + await store.assignmentsFor("u1"); + expect(reads).toBe(1); + }); + + test("a write invalidates that subject", async () => { + const store = cachedPermissionStore(memoryPermissionStore(), { ttlMs: 60_000 }); + await store.assignmentsFor("u1"); + await store.assignRole("u1", "editor"); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("invalidate() drops a cached subject", async () => { + const inner = memoryPermissionStore(); + const store = cachedPermissionStore(inner, { ttlMs: 60_000 }); + await store.assignmentsFor("u1"); + await inner.assignRole("u1", "editor"); // behind the cache's back + expect((await store.assignmentsFor("u1")).roles).toEqual([]); + store.invalidate("u1"); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("entries expire after ttlMs", async () => { + const inner = memoryPermissionStore(); + const store = cachedPermissionStore(inner, { ttlMs: 1 }); + await store.assignmentsFor("u1"); + await inner.assignRole("u1", "editor"); + await Bun.sleep(5); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("cache is bounded by max", async () => { + const store = cachedPermissionStore(memoryPermissionStore(), { ttlMs: 60_000, max: 2 }); + await store.assignmentsFor("a"); + await store.assignmentsFor("b"); + await store.assignmentsFor("c"); + expect(store.size()).toBeLessThanOrEqual(2); + }); + + test("a global write invalidates the subject in every tenant", async () => { + const inner = memoryPermissionStore(); + const store = cachedPermissionStore(inner, { ttlMs: 60_000 }); + await store.assignmentsFor("u1", { tenantId: "t1" }); // warm the tenant entry + await store.assignRole("u1", "editor"); // global write + // Global roles are visible inside every tenant, so the cached t1 entry + // must not survive this write. + expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]); + }); + + test("cache keys cannot collide across subject/tenant boundaries", async () => { + const inner = memoryPermissionStore(); + const store = cachedPermissionStore(inner, { ttlMs: 60_000 }); + // Naive "scope + separator + subject" concatenation makes these two pairs + // produce the same key, serving one subject the other's permissions. + await inner.assignRole("b�c", "editor", { tenantId: "a" }); + expect((await store.assignmentsFor("b�c", { tenantId: "a" })).roles).toEqual(["editor"]); + expect((await store.assignmentsFor("c", { tenantId: "a�b" })).roles).toEqual([]); + }); + + test("scoped and global reads cache separately", async () => { + const inner = memoryPermissionStore(); + const store = cachedPermissionStore(inner, { ttlMs: 60_000 }); + await inner.assignRole("u1", "editor", { tenantId: "t1" }); + expect((await store.assignmentsFor("u1")).roles).toEqual([]); + expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/authz/test/store-cached.test.ts` +Expected: FAIL — `cachedPermissionStore` is not exported + +- [ ] **Step 3: Append the implementation to `packages/authz/src/store.ts`** + +```ts +export interface CachedPermissionStore extends PermissionStore { + /** Drop one subject. Call after changing roles out of band. */ + invalidate(subjectId: string, scope?: AuthzScope): void; + invalidateAll(): void; + /** Cached entry count, for tests and diagnostics. */ + size(): number; +} + +export interface CacheOptions { + ttlMs?: number; + max?: number; +} + +/** + * Caches assignment reads. Writes through this decorator invalidate the + * affected subject immediately; changes made directly against the inner store + * need an explicit `invalidate()` call rather than waiting out the TTL. + */ +export function cachedPermissionStore( + inner: PermissionStore, + options: CacheOptions = {}, +): CachedPermissionStore { + const ttlMs = options.ttlMs ?? 5_000; + const max = options.max ?? 1_000; + const entries = new Map(); + + // Subject and tenant ids are unconstrained strings, so the key must be + // unambiguous: concatenating around a separator lets ("a", "bc") and + // ("ab", "c") collide, which would serve one subject another's + // permissions. JSON encoding escapes the components. + const cacheKey = (subjectId: string, scope?: AuthzScope) => + JSON.stringify([scopeKey(scope), subjectId]); + // Track subjects separately rather than pattern-matching key strings, so a + // global write can find every tenant entry without substring guesswork. + const bySubject = new Map>(); + const drop = (subjectId: string, scope?: AuthzScope) => { + // A global write changes what every tenant sees for that subject. + if (scopeKey(scope) === "") { + for (const key of bySubject.get(subjectId) ?? []) entries.delete(key); + bySubject.delete(subjectId); + return; + } + const key = cacheKey(subjectId, scope); + entries.delete(key); + bySubject.get(subjectId)?.delete(key); + }; + + return { + async assignmentsFor(subjectId, scope) { + const key = cacheKey(subjectId, scope); + const hit = entries.get(key); + if (hit && Date.now() - hit.at < ttlMs) return hit.value; + const value = await inner.assignmentsFor(subjectId, scope); + if (entries.size >= max) { + const oldest = entries.keys().next().value!; + entries.delete(oldest); + for (const keys of bySubject.values()) keys.delete(oldest); + } + entries.set(key, { at: Date.now(), value }); + let keys = bySubject.get(subjectId); + if (!keys) bySubject.set(subjectId, (keys = new Set())); + keys.add(key); + return value; + }, + async assignRole(subjectId, role, scope) { + await inner.assignRole(subjectId, role, scope); + drop(subjectId, scope); + }, + async revokeRole(subjectId, role, scope) { + await inner.revokeRole(subjectId, role, scope); + drop(subjectId, scope); + }, + async grant(subjectId, permission, effect, scope) { + await inner.grant(subjectId, permission, effect, scope); + drop(subjectId, scope); + }, + async revokeGrant(subjectId, permission, scope) { + await inner.revokeGrant(subjectId, permission, scope); + drop(subjectId, scope); + }, + listSubjects: (scope) => inner.listSubjects(scope), + invalidate: drop, + invalidateAll: () => { + entries.clear(); + bySubject.clear(); + }, + size: () => entries.size, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/authz/test/store-cached.test.ts` +Expected: PASS — 13 conformance tests plus 6 cache tests + +- [ ] **Step 5: Commit** + +```bash +git add packages/authz/src/store.ts packages/authz/test/store-cached.test.ts +git commit -m "feat(authz): add caching decorator for PermissionStore" +``` + +--- + +## Task 5: Audit sink + +**Files:** + +- Create: `packages/authz/src/audit.ts` +- Test: `packages/authz/test/audit.test.ts` + +**Interfaces:** + +- Consumes: `AuthzScope` from `./types.ts` +- Produces: `AuthzAuditEvent`, `AuthzAuditSink`, `memoryAuditSink(): MemoryAuditSink`, `consoleAuditSink(): AuthzAuditSink`, `safeRecord(sink, event): void` + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/audit.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { memoryAuditSink, safeRecord } from "../src/audit.ts"; + +describe("audit sink", () => { + test("memoryAuditSink collects events", () => { + const sink = memoryAuditSink(); + sink.record({ permission: "post:read", allowed: true, at: 1 }); + expect(sink.events).toHaveLength(1); + expect(sink.events[0]!.permission).toBe("post:read"); + }); + + test("safeRecord swallows sink failures", () => { + const exploding = { + record() { + throw new Error("sink is down"); + }, + }; + // Auditing must never break a request. + expect(() => safeRecord(exploding, { permission: "p:x", allowed: false, at: 1 })).not.toThrow(); + }); + + test("safeRecord swallows async sink rejections", async () => { + const rejecting = { record: async () => Promise.reject(new Error("later")) }; + expect(() => safeRecord(rejecting, { permission: "p:x", allowed: false, at: 1 })).not.toThrow(); + await Bun.sleep(1); + }); + + test("safeRecord tolerates an undefined sink", () => { + expect(() => safeRecord(undefined, { permission: "p:x", allowed: true, at: 1 })).not.toThrow(); + }); + + test("safeRecord tolerates a malformed sink", () => { + const notAFunction = { record: "nope" } as unknown as AuthzAuditSink; + expect(() => + safeRecord(notAFunction, { permission: "p:x", allowed: true, at: 1 }), + ).not.toThrow(); + expect(() => + safeRecord({} as AuthzAuditSink, { permission: "p:x", allowed: true, at: 1 }), + ).not.toThrow(); + }); + + test("memoryAuditSink.clear empties the buffer", () => { + const sink = memoryAuditSink(); + sink.record({ permission: "p:x", allowed: true, at: 1 }); + sink.clear(); + expect(sink.events).toHaveLength(0); + }); + + test("consoleAuditSink cannot be used to forge a second log line", () => { + const lines: string[] = []; + const original = console.info; + console.info = (...args: unknown[]) => void lines.push(args.join(" ")); + try { + consoleAuditSink().record({ + subjectId: "u1\n[wrnexus:authz] allow admin:everything subject=root", + permission: "post:read", + allowed: false, + reason: "nope\r\ninjected", + at: 1, + }); + } finally { + console.info = original; + } + // One event must produce exactly one line, with no embedded newlines. + expect(lines).toHaveLength(1); + expect(lines[0]).not.toContain("\n"); + expect(lines[0]).not.toContain("\r"); + }); +}); +``` + +The test file's imports must include `consoleAuditSink` and the `AuthzAuditSink` type +alongside `memoryAuditSink` and `safeRecord`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/authz/test/audit.test.ts` +Expected: FAIL — cannot resolve `../src/audit.ts` + +- [ ] **Step 3: Write the implementation** + +Create `packages/authz/src/audit.ts`: + +```ts +import type { AuthzScope } from "./types.ts"; + +export interface AuthzAuditEvent { + subjectId?: string; + scope?: AuthzScope; + permission: string; + allowed: boolean; + reason?: string; + policy?: string; + /** Epoch milliseconds. */ + at: number; +} + +export interface AuthzAuditSink { + record(event: AuthzAuditEvent): void | Promise; +} + +export interface MemoryAuditSink extends AuthzAuditSink { + events: AuthzAuditEvent[]; + clear(): void; +} + +export function memoryAuditSink(): MemoryAuditSink { + const events: AuthzAuditEvent[] = []; + return { + events, + record: (event) => void events.push(event), + clear: () => void events.splice(0, events.length), + }; +} + +/** + * Subject ids, tenant ids, and denial reasons trace back to request input, so + * a newline in one would forge a second audit line indistinguishable from a + * real entry. Strip CR/LF and other control characters before interpolating. + */ +function logSafe(value: string): string { + let out = ""; + for (const character of value) { + const code = character.codePointAt(0)!; + // C0 + DEL, plus NEL and the Unicode line/paragraph separators, which some + // log shippers and JSON consumers also treat as line terminators. + const isLineBreaking = + code < 0x20 || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029; + out += isLineBreaking ? " " : character; + } + return out; +} + +export function consoleAuditSink(): AuthzAuditSink { + return { + record(event) { + const verdict = event.allowed ? "allow" : "deny"; + console.info( + `[wrnexus:authz] ${verdict} ${logSafe(event.permission)} ` + + `subject=${logSafe(event.subjectId ?? "anonymous")}` + + `${event.scope?.tenantId ? ` tenant=${logSafe(event.scope.tenantId)}` : ""}` + + `${event.reason ? ` reason=${logSafe(event.reason)}` : ""}` + + `${event.policy ? ` policy=${logSafe(event.policy)}` : ""}`, + ); + }, + }; +} + +/** Record without ever letting a sink failure escape into the request path. */ +export function safeRecord(sink: AuthzAuditSink | undefined, event: AuthzAuditEvent): void { + if (!sink) return; + try { + const result = sink.record(event); + if (result instanceof Promise) { + result.catch((error) => console.warn("[wrnexus:authz] audit sink failed", error)); + } + } catch (error) { + console.warn("[wrnexus:authz] audit sink failed", error); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/authz/test/audit.test.ts` +Expected: PASS, 4 tests + +- [ ] **Step 5: Commit** + +```bash +git add packages/authz/src/audit.ts packages/authz/test/audit.test.ts +git commit -m "feat(authz): add pluggable authorization audit sink" +``` + +--- + +## Task 6: Resolution engine + +**Files:** + +- Create: `packages/authz/src/engine.ts` +- Test: `packages/authz/test/engine.test.ts` + +**Interfaces:** + +- Consumes: `AuthzCatalog`, `AuthzScope`, `SubjectAssignments` from `./types.ts`; `PermissionStore` from `./store.ts`; `AuthzAuditSink`, `safeRecord` from `./audit.ts`; `AuthorizationDecision` from `./advanced.ts` +- Produces: `createAuthzResolver(options: AuthzResolverOptions): AuthzResolver` with `AuthzResolver { permissionsFor(subjectId, scope?): Promise>; decide(input: DecideInput): Promise }`, `expandRoles(catalog, roles): Set`, `permissionMatches(granted: Set, permission: string): boolean` + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/engine.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { defineAuthz } from "../src/registry.ts"; +import { mergeCatalogs } from "../src/catalog.ts"; +import { memoryPermissionStore } from "../src/store.ts"; +import { memoryAuditSink } from "../src/audit.ts"; +import { createAuthzResolver, expandRoles, permissionMatches } from "../src/engine.ts"; + +const catalog = mergeCatalogs([ + { + source: "test.ts", + module: defineAuthz({ + permissions: { + "post:read": { public: true }, + "post:write": {}, + "post:delete": { risk: "high" }, + "post:comment:delete": {}, + }, + roles: { + editor: ["post:*"], + moderator: ["post:comment:*"], + admin: ["role:editor", "post:delete"], + cyclic: ["role:cyclic", "post:read"], + }, + policies: { + ownsPost: async (subject: { id?: string }, resource?: { authorId?: string }) => + resource?.authorId === subject?.id + ? { allowed: true } + : { allowed: false, reason: "not the author", policy: "ownsPost" }, + explodes: async () => { + throw new Error("policy blew up"); + }, + }, + bindings: { "post:write": ["ownsPost"] }, + }), + }, +]); + +const make = (store = memoryPermissionStore(), audit = memoryAuditSink()) => ({ + store, + audit, + resolver: createAuthzResolver({ catalog, store, audit, strict: false }), +}); + +describe("expandRoles", () => { + test("expands wildcards and role inheritance", () => { + expect([...expandRoles(catalog, ["admin"])].sort()).toEqual(["post:*", "post:delete"]); + }); + test("terminates on cyclic inheritance", () => { + expect([...expandRoles(catalog, ["cyclic"])]).toEqual(["post:read"]); + }); +}); + +describe("permissionMatches", () => { + test("matches exact, root wildcard, and every namespace depth", () => { + expect(permissionMatches(new Set(["post:read"]), "post:read")).toBe(true); + expect(permissionMatches(new Set(["*"]), "anything:at:all")).toBe(true); + expect(permissionMatches(new Set(["post:*"]), "post:comment:delete")).toBe(true); + expect(permissionMatches(new Set(["post:comment:*"]), "post:comment:delete")).toBe(true); + expect(permissionMatches(new Set(["post:comment:*"]), "post:write")).toBe(false); + }); +}); + +describe("createAuthzResolver.decide", () => { + test("allows a public permission for an anonymous subject", async () => { + const { resolver } = make(); + const result = await resolver.decide({ subject: null, permission: "post:read" }); + expect(result.allowed).toBe(true); + }); + + test("denies a non-public permission for an anonymous subject", async () => { + const { resolver } = make(); + const result = await resolver.decide({ subject: null, permission: "post:delete" }); + expect(result.allowed).toBe(false); + }); + + test("allows via a role-derived wildcard", async () => { + const { store, resolver } = make(); + await store.assignRole("u1", "moderator"); + const result = await resolver.decide({ + subject: { id: "u1" }, + permission: "post:comment:delete", + }); + expect(result.allowed).toBe(true); + }); + + test("an explicit deny beats a role and beats '*'", async () => { + const { store, resolver } = make(); + await store.assignRole("u1", "admin"); + await store.grant("u1", "post:delete", "deny"); + const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" }); + expect(result.allowed).toBe(false); + expect(result.reason).toMatch(/explicit deny/i); + }); + + test("a bound policy can deny a permission the role grants", async () => { + const { store, resolver } = make(); + await store.assignRole("u1", "editor"); + const denied = await resolver.decide({ + subject: { id: "u1" }, + permission: "post:write", + resource: { authorId: "someone-else" }, + }); + expect(denied.allowed).toBe(false); + expect(denied.policy).toBe("ownsPost"); + + const allowed = await resolver.decide({ + subject: { id: "u1" }, + permission: "post:write", + resource: { authorId: "u1" }, + }); + expect(allowed.allowed).toBe(true); + }); + + test("a throwing policy denies rather than escaping", async () => { + const throwing = mergeCatalogs([ + { + source: "t.ts", + module: defineAuthz({ + permissions: { "x:go": {} }, + policies: { + explodes: async () => { + throw new Error("boom"); + }, + }, + bindings: { "x:go": ["explodes"] }, + }), + }, + ]); + const store = memoryPermissionStore(); + await store.grant("u1", "x:go", "allow"); + const resolver = createAuthzResolver({ catalog: throwing, store, strict: false }); + const result = await resolver.decide({ subject: { id: "u1" }, permission: "x:go" }); + expect(result.allowed).toBe(false); + }); + + test("a store failure denies and does not throw", async () => { + const broken = { + ...memoryPermissionStore(), + assignmentsFor: async () => { + throw new Error("db down"); + }, + }; + const resolver = createAuthzResolver({ catalog, store: broken, strict: false }); + const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:read" }); + expect(result.allowed).toBe(false); + }); + + test("an unregistered permission denies when strict is off", async () => { + const { resolver } = make(); + const result = await resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" }); + expect(result.allowed).toBe(false); + expect(result.reason).toMatch(/not registered/i); + }); + + test("an unregistered permission throws when strict is on", async () => { + const resolver = createAuthzResolver({ + catalog, + store: memoryPermissionStore(), + strict: true, + }); + await expect( + resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" }), + ).rejects.toThrow(/ghost:perm/); + }); + + test("denials are audited and allows are not, by default", async () => { + const { store, audit, resolver } = make(); + // moderator, NOT editor: editor holds "post:*", which legitimately grants + // post:delete, so that call would be an allow and nothing would be audited. + await store.assignRole("u1", "moderator"); + await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" }); + await resolver.decide({ subject: { id: "u1" }, permission: "post:read" }); + expect(audit.events).toHaveLength(1); + expect(audit.events[0]!.allowed).toBe(false); + }); + + test("auditAllows records both verdicts", async () => { + const store = memoryPermissionStore(); + const audit = memoryAuditSink(); + const resolver = createAuthzResolver({ + catalog, + store, + audit, + strict: false, + auditAllows: true, + }); + await resolver.decide({ subject: null, permission: "post:read" }); + expect(audit.events).toHaveLength(1); + expect(audit.events[0]!.allowed).toBe(true); + }); + + test("tenant scope selects the right assignments", async () => { + const { store, resolver } = make(); + await store.assignRole("u1", "editor", { tenantId: "t1" }); + const inside = await resolver.decide({ + subject: { id: "u1" }, + permission: "post:write", + resource: { authorId: "u1" }, + scope: { tenantId: "t1" }, + }); + const outside = await resolver.decide({ + subject: { id: "u1" }, + permission: "post:write", + resource: { authorId: "u1" }, + scope: { tenantId: "t2" }, + }); + expect(inside.allowed).toBe(true); + expect(outside.allowed).toBe(false); + }); +}); + +describe("createAuthzResolver fail-closed regressions", () => { + const guarded = mergeCatalogs([ + { + source: "guarded.ts", + module: defineAuthz({ + permissions: { "feed:view": { public: true }, "x:go": {} }, + policies: { + never: async () => ({ allowed: false, reason: "always no", policy: "never" }), + truthy: async () => ({ allowed: "yes" }) as never, + }, + bindings: { "feed:view": ["never"] }, + }), + }, + ]); + + test("a public permission still runs its bound policies for anonymous callers", async () => { + // The least-trusted caller must not receive the weakest evaluation: + // `public` relaxes the identity requirement, never the policy requirement. + const resolver = createAuthzResolver({ + catalog: guarded, + store: memoryPermissionStore(), + strict: false, + }); + const anonymous = await resolver.decide({ subject: null, permission: "feed:view" }); + expect(anonymous.allowed).toBe(false); + expect(anonymous.policy).toBe("never"); + }); + + test("a policy returning a truthy non-boolean denies", async () => { + const catalog = mergeCatalogs([ + { + source: "t.ts", + module: defineAuthz({ + permissions: { "x:go": {} }, + policies: { truthy: async () => ({ allowed: "yes" }) as never }, + bindings: { "x:go": ["truthy"] }, + }), + }, + ]); + const store = memoryPermissionStore(); + await store.grant("u1", "x:go", "allow"); + const resolver = createAuthzResolver({ catalog, store, strict: false }); + expect((await resolver.decide({ subject: { id: "u1" }, permission: "x:go" })).allowed).toBe( + false, + ); + }); + + test("a binding naming a policy the catalog lacks denies rather than skipping", async () => { + // Hand-built catalog: mergeCatalogs would reject this, but the resolver + // accepts any AuthzCatalog and must not grant what the policy guarded. + const broken = { + permissions: new Map([["x:go", {}]]), + roles: new Map(), + policies: new Map(), + attributes: new Map(), + bindings: new Map([["x:go", ["ghost"]]]), + } as unknown as Parameters[0]["catalog"]; + const store = memoryPermissionStore(); + await store.grant("u1", "x:go", "allow"); + const resolver = createAuthzResolver({ catalog: broken, store, strict: false }); + expect((await resolver.decide({ subject: { id: "u1" }, permission: "x:go" })).allowed).toBe( + false, + ); + }); + + test("a wildcard deny blocks the whole namespace", async () => { + const { store, resolver } = make(); + await store.assignRole("u1", "admin"); + await store.grant("u1", "post:*", "deny"); + expect( + (await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" })).allowed, + ).toBe(false); + }); + + test("permissionsFor omits denied permissions", async () => { + const { store, resolver } = make(); + await store.assignRole("u1", "editor"); + await store.grant("u1", "post:*", "deny"); + const effective = await resolver.permissionsFor("u1"); + // The obvious composition must agree with decide(). + expect(permissionMatches(effective, "post:write")).toBe(false); + }); + + test("a non-string or empty subject id denies instead of falling back to anonymous", async () => { + const { resolver } = make(); + for (const id of [0, "", null, 123, {}]) { + const result = await resolver.decide({ + subject: { id } as never, + permission: "post:read", // public — must still not be reached this way + }); + if (id === null) continue; // null is genuinely anonymous + expect(result.allowed).toBe(false); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/authz/test/engine.test.ts` +Expected: FAIL — cannot resolve `../src/engine.ts` + +- [ ] **Step 3: Write the implementation** + +Create `packages/authz/src/engine.ts`: + +```ts +import type { AuthorizationDecision } from "./advanced.ts"; +import { safeRecord, type AuthzAuditSink } from "./audit.ts"; +import type { PermissionStore } from "./store.ts"; +import type { AuthzCatalog, AuthzScope } from "./types.ts"; + +export interface AuthzResolverOptions { + catalog: AuthzCatalog; + store: PermissionStore; + audit?: AuthzAuditSink; + /** + * Throw on an unregistered permission instead of denying. Defaults to true + * outside production, so typos surface during development. + */ + strict?: boolean; + /** Record allows as well as denies. Off by default to bound write volume. */ + auditAllows?: boolean; +} + +export interface DecideInput { + subject: { id?: string; [key: string]: unknown } | null | undefined; + permission: string; + resource?: unknown; + scope?: AuthzScope; +} + +export interface AuthzResolver { + /** + * Effective permissions with denied entries removed — for coarse gating such + * as hiding a menu section. + * + * NOT authoritative. A set of strings cannot express "everything under + * `post:*` except `post:delete`", so a narrow deny beneath a broad grant is + * not representable here: the set still contains `post:*` while `decide()` + * correctly refuses `post:delete`. Gate individual actions with `decide()` + * (or `can()` / `filterCan()`), never by matching against this set. + */ + permissionsFor(subjectId: string, scope?: AuthzScope): Promise>; + decide(input: DecideInput): Promise; +} + +/** Expand roles into their granted entries, following `role:` and stopping on cycles. */ +export function expandRoles(catalog: AuthzCatalog, roles: readonly string[]): Set { + const out = new Set(); + const seen = new Set(); + const walk = (role: string) => { + if (seen.has(role)) return; + seen.add(role); + for (const entry of catalog.roles.get(role) ?? []) { + if (entry.startsWith("role:")) walk(entry.slice(5)); + else out.add(entry); + } + }; + for (const role of roles) walk(role); + return out; +} + +/** + * Exact match, root wildcard, or a namespace wildcard at any depth. + * + * 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)) { + if (granted.has(`${permission.slice(0, at)}:*`)) return true; + } + return false; +} + +/** + * True if any entry in the deny list covers `permission`. Denies honour the + * same depth-aware wildcards as grants, so denying "post:*" blocks + * post:comment:delete rather than being accepted and silently doing nothing. + */ +export function deniedBy(denies: readonly string[], permission: string): boolean { + return denies.length ? permissionMatches(new Set(denies), permission) : false; +} + +function isProduction(): boolean { + return (process.env.NODE_ENV ?? "development") === "production"; +} + +export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolver { + const { catalog, store, audit } = options; + const strict = options.strict ?? !isProduction(); + + /** + * Single source of truth for "what does this subject hold?". Returns the raw + * assignments alongside the effective set, because `decide` reports on the + * deny that blocked it. Do NOT duplicate this logic in either caller. + */ + const loadEffective = async (subjectId: string, scope?: AuthzScope) => { + const assignments = await store.assignmentsFor(subjectId, scope); + const granted = expandRoles(catalog, assignments.roles); + for (const grant of assignments.grants) granted.add(grant); + return { assignments, granted }; + }; + + /** + * Effective permissions, denies already removed. Callers compose this with + * `permissionMatches` to gate menus and admin UI, so it must not report a + * permission that `decide` would refuse. + */ + const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise> => { + const { assignments, granted } = await loadEffective(subjectId, scope); + if (!assignments.denies.length) return granted; + // Hoist the deny set: rebuilding it per entry makes this O(grants x denies) + // allocations on a per-request path whose input size an operator controls. + const denySet = new Set(assignments.denies); + const effective = new Set(); + for (const entry of granted) { + // A wildcard grant survives only if nothing denies it outright. + if (!permissionMatches(denySet, entry)) effective.add(entry); + } + return effective; + }; + + const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => { + if (!result.allowed || options.auditAllows) { + safeRecord(audit, { + subjectId: typeof input.subject?.id === "string" ? input.subject.id : undefined, + scope: input.scope, + permission: input.permission, + allowed: result.allowed, + reason: result.reason, + policy: result.policy, + at: Date.now(), + }); + } + return result; + }; + + /** + * Run every policy bound to a permission. Returns a denial, or null to allow. + * Anonymous callers run this too: `public` relaxes the identity requirement, + * never the policy requirement. + */ + const runPolicies = async ( + input: DecideInput, + permission: string, + ): Promise => { + for (const name of catalog.bindings.get(permission) ?? []) { + const policy = catalog.policies.get(name); + if (!policy) { + // A binding naming a policy the catalog lacks must deny, not skip: + // silently ignoring it would grant whatever the policy guarded. + console.error( + `[wrnexus:authz] binding for '${permission}' names unknown policy '${name}'; denying`, + ); + return { allowed: false, reason: "Policy unavailable", policy: name }; + } + try { + const verdict = await ( + policy as unknown as ( + s: unknown, + r: unknown, + ) => AuthorizationDecision | Promise + )(input.subject, input.resource); + // Identity check, not truthiness: {allowed: "yes"} must not grant. + if (verdict?.allowed !== true) { + return { + allowed: false, + reason: verdict?.reason ?? "Policy denied access", + policy: verdict?.policy ?? name, + }; + } + } catch (error) { + console.error(`[wrnexus:authz] policy '${name}' threw; denying`, error); + return { allowed: false, reason: "Policy error", policy: name }; + } + } + return null; + }; + + return { + permissionsFor, + + async decide(input) { + const { subject, permission, scope } = input; + const meta = catalog.permissions.get(permission); + + if (!meta) { + if (strict) { + throw new Error( + `WRN-AUTHZ-UNKNOWN: permission '${permission}' is not registered. ` + + `Declare it with defineAuthz() in app/authz/.`, + ); + } + return finish(input, { + allowed: false, + reason: `Permission '${permission}' is not registered`, + }); + } + + // Only a non-empty string identifies a subject. A numeric id of 0 or a + // non-string id must not fall through to the anonymous path, and must + // never reach the store as a lookup key. + const rawId: unknown = subject?.id; + const subjectId = typeof rawId === "string" && rawId !== "" ? rawId : undefined; + if (rawId !== undefined && rawId !== null && subjectId === undefined) { + console.error("[wrnexus:authz] subject.id must be a non-empty string; denying"); + return finish(input, { allowed: false, reason: "Invalid subject" }); + } + + if (!subjectId) { + if (!meta.public) { + return finish(input, { allowed: false, reason: "Authentication required" }); + } + const denied = await runPolicies(input, permission); + return finish(input, denied ?? { allowed: true, reason: "public permission" }); + } + + let assignments; + let granted: Set; + try { + ({ assignments, granted } = await loadEffective(subjectId, scope)); + } catch (error) { + console.error("[wrnexus:authz] permission store failed; denying", error); + return finish(input, { allowed: false, reason: "Authorization store unavailable" }); + } + + // 1. Explicit deny wins over everything, including "*". Wildcards are + // honoured here exactly as they are for grants, so denying "post:*" + // blocks post:delete rather than silently doing nothing. + if (deniedBy(assignments.denies, permission)) { + return finish(input, { allowed: false, reason: "explicit deny" }); + } + + // 2. Must hold the permission at all. + if (!meta.public && !permissionMatches(granted, permission)) { + return finish(input, { allowed: false, reason: "Missing permission" }); + } + + // 3. Every bound policy must pass. + const denied = await runPolicies(input, permission); + return finish(input, denied ?? { allowed: true }); + }, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/authz/test/engine.test.ts` +Expected: PASS, 15 tests + +- [ ] **Step 5: Commit** + +```bash +git add packages/authz/src/engine.ts packages/authz/test/engine.test.ts +git commit -m "feat(authz): add resolution engine with deny-wins precedence and fail-closed errors" +``` + +--- + +## Task 7: Middleware, can(), and guards + +**Files:** + +- Create: `packages/authz/src/middleware.ts` +- Test: `packages/authz/test/middleware.test.ts` + +**Interfaces:** + +- Consumes: `createAuthzResolver`, `AuthzResolverOptions`, `AuthzResolver` from `./engine.ts`; `Context`, `Middleware` types from `@wrnexus/core` +- Produces: `AUTHZ_LOCALS_KEY`, `authzMiddleware(options: AuthzResolverOptions): Middleware`, `decideFor(ctx, permission, resource?): Promise`, `can(ctx, permission, resource?): Promise`, `guardPermission(permission, getResource?): Middleware`, `filterCan(ctx, permission, items): Promise` + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/middleware.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import type { Context } from "@wrnexus/core"; +import { defineAuthz } from "../src/registry.ts"; +import { mergeCatalogs } from "../src/catalog.ts"; +import { memoryPermissionStore } from "../src/store.ts"; +import { authzMiddleware, can, filterCan, guardPermission } from "../src/middleware.ts"; + +const catalog = mergeCatalogs([ + { + source: "t.ts", + module: defineAuthz({ + permissions: { "post:read": { public: true }, "post:write": {}, "post:delete": {} }, + roles: { editor: ["post:write"] }, + policies: { + ownsPost: async (s: { id?: string }, r?: { authorId?: string }) => + r?.authorId === s?.id ? { allowed: true } : { allowed: false, reason: "not owner" }, + }, + bindings: { "post:delete": ["ownsPost"] }, + }), + }, +]); + +/** Minimal Context stand-in; the middleware only touches user, tenant, locals. */ +function makeCtx(user: unknown, tenantId?: string): Context { + return { + user, + tenant: tenantId ? { id: tenantId } : undefined, + locals: {}, + url: new URL("http://localhost/x"), + req: new Request("http://localhost/x"), + } as unknown as Context; +} + +const withMiddleware = async (ctx: Context, store = memoryPermissionStore()) => { + await authzMiddleware({ catalog, store, strict: false })(ctx, async () => new Response("ok")); + return store; +}; + +describe("authzMiddleware + can", () => { + test("can() resolves through the middleware-installed resolver", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.assignRole("u1", "editor"); + await withMiddleware(ctx, store); + expect(await can(ctx, "post:write")).toBe(true); + expect(await can(ctx, "post:delete", { authorId: "u1" })).toBe(false); + }); + + test("can() throws a clear setup error without the middleware", async () => { + const ctx = makeCtx({ id: "u1" }); + await expect(can(ctx, "post:read")).rejects.toThrow(/authzMiddleware/); + }); + + test("results are memoised per request", async () => { + const inner = memoryPermissionStore(); + let reads = 0; + const counting = { + ...inner, + assignmentsFor: (id: string, scope?: { tenantId?: string }) => { + reads++; + return inner.assignmentsFor(id, scope); + }, + }; + const ctx = makeCtx({ id: "u1" }); + await authzMiddleware({ catalog, store: counting, strict: false })( + ctx, + async () => new Response("ok"), + ); + await can(ctx, "post:write"); + await can(ctx, "post:write"); + expect(reads).toBe(1); + }); + + test("memoisation keys on the resource, not just the permission", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.grant("u1", "post:delete", "allow"); + await withMiddleware(ctx, store); + expect(await can(ctx, "post:delete", { authorId: "u1" })).toBe(true); + expect(await can(ctx, "post:delete", { authorId: "other" })).toBe(false); + }); + + test("the tenant on the context becomes the scope", async () => { + const ctx = makeCtx({ id: "u1" }, "t1"); + const store = memoryPermissionStore(); + await store.assignRole("u1", "editor", { tenantId: "t1" }); + await withMiddleware(ctx, store); + expect(await can(ctx, "post:write")).toBe(true); + }); +}); + +describe("guardPermission", () => { + test("calls next when allowed", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.assignRole("u1", "editor"); + await withMiddleware(ctx, store); + const res = await guardPermission("post:write")(ctx, async () => new Response("passed")); + expect(await res.text()).toBe("passed"); + }); + + test("returns 403 without leaking the reason by default", async () => { + const ctx = makeCtx({ id: "u1" }); + await withMiddleware(ctx); + const res = await guardPermission("post:write")(ctx, async () => new Response("passed")); + expect(res.status).toBe(403); + const body = (await res.json()) as Record; + expect(body).toEqual({ ok: false, error: "Forbidden" }); + }); + + test("exposeReason opts into diagnostics", async () => { + const ctx = makeCtx({ id: "u1" }); + await withMiddleware(ctx); + const res = await guardPermission("post:write", { exposeReason: true })( + ctx, + async () => new Response("passed"), + ); + const body = (await res.json()) as Record; + expect(body.reason).toBe("Missing permission"); + }); + + test("getResource feeds the bound policy", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.grant("u1", "post:delete", "allow"); + await withMiddleware(ctx, store); + const guard = guardPermission("post:delete", { getResource: () => ({ authorId: "u1" }) }); + const res = await guard(ctx, async () => new Response("passed")); + expect(await res.text()).toBe("passed"); + }); +}); + +describe("filterCan", () => { + test("keeps only the items the subject may act on", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.grant("u1", "post:delete", "allow"); + await withMiddleware(ctx, store); + const posts = [{ authorId: "u1" }, { authorId: "other" }, { authorId: "u1" }]; + expect(await filterCan(ctx, "post:delete", posts)).toHaveLength(2); + }); + + test("does not leak rows the memo cannot serialise", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.grant("u1", "post:delete", "allow"); + await withMiddleware(ctx, store); + // BigInt columns and circular references are ordinary in ORM rows. A memo + // that serialises resources funnels all of these into one shared key and + // returns the first verdict for every later row. + const circular: Record = { authorId: "other" }; + circular.self = circular; + const rows = [{ authorId: "u1", views: 10n }, { authorId: "other", views: 11n }, circular]; + expect(await filterCan(ctx, "post:delete", rows)).toEqual([rows[0]]); + }); + + test("returns an empty array for no items", async () => { + const ctx = makeCtx({ id: "u1" }); + await withMiddleware(ctx); + expect(await filterCan(ctx, "post:delete", [])).toEqual([]); + }); +}); + +describe("per-request memo isolation", () => { + test("distinct resources are never cross-authorized", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.grant("u1", "post:delete", "allow"); + await withMiddleware(ctx, store); + // Same id, different owner; object ids; primitives of different type. + expect(await can(ctx, "post:delete", { id: 7, authorId: "u1" })).toBe(true); + expect(await can(ctx, "post:delete", { id: "7", authorId: "other" })).toBe(false); + expect(await can(ctx, "post:delete", { id: { t: "A" }, authorId: "u1" })).toBe(true); + expect(await can(ctx, "post:delete", { id: { t: "B" }, authorId: "other" })).toBe(false); + }); + + test("a changed row is not authorized against the stale copy", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.grant("u1", "post:delete", "allow"); + await withMiddleware(ctx, store); + expect(await can(ctx, "post:delete", { id: "p1", authorId: "u1" })).toBe(true); + expect(await can(ctx, "post:delete", { id: "p1", authorId: "someone-else" })).toBe(false); + }); + + test("switching tenant mid-request re-evaluates", async () => { + const ctx = makeCtx({ id: "u1" }, "t1"); + const store = memoryPermissionStore(); + await store.assignRole("u1", "editor", { tenantId: "t1" }); + await withMiddleware(ctx, store); + expect(await can(ctx, "post:write")).toBe(true); + (ctx as { tenant?: { id: string } }).tenant = { id: "t2" }; + // Scope is read at decision time, so the t1 grant must not carry over. + expect(await can(ctx, "post:write")).toBe(false); + }); +}); + +describe("guardPermission hardening", () => { + test("throws the setup error rather than calling next", async () => { + const ctx = makeCtx({ id: "u1" }); // no authzMiddleware + let reached = false; + await expect( + guardPermission("post:write")(ctx, async () => { + reached = true; + return new Response("passed"); + }), + ).rejects.toThrow(/authzMiddleware/); + expect(reached).toBe(false); + }); + + test("a throwing getResource denies instead of 500ing", async () => { + const ctx = makeCtx({ id: "u1" }); + await withMiddleware(ctx); + const guard = guardPermission("post:delete", { + getResource: () => { + throw new Error("SELECT * FROM posts WHERE id=$1 failed"); + }, + }); + const res = await guard(ctx, async () => new Response("passed")); + expect(res.status).toBe(403); + const body = await res.text(); + expect(body).not.toContain("SELECT"); + }); + + test("redirectTo applies to page requests but not API requests", async () => { + const page = makeCtx({ id: "u1" }); + await withMiddleware(page); + const redirected = await guardPermission("post:write", { redirectTo: "/login" })( + page, + async () => new Response("passed"), + ); + expect(redirected.status).toBe(303); + + const api = makeCtx({ id: "u1" }); + (api as { url: URL }).url = new URL("http://localhost/api/posts"); + await withMiddleware(api); + const json = await guardPermission("post:write", { redirectTo: "/login" })( + api, + async () => new Response("passed"), + ); + // An API caller must see the denial, not follow a redirect into a 200. + expect(json.status).toBe(403); + }); + + test("an off-site redirectTo is refused", async () => { + const ctx = makeCtx({ id: "u1" }); + await withMiddleware(ctx); + for (const target of ["https://evil.example.com/harvest", "//evil.example.com"]) { + const res = await guardPermission("post:write", { redirectTo: target })( + ctx, + async () => new Response("passed"), + ); + expect(res.status).toBe(403); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/authz/test/middleware.test.ts` +Expected: FAIL — cannot resolve `../src/middleware.ts` + +- [ ] **Step 3: Write the implementation** + +Create `packages/authz/src/middleware.ts`: + +```ts +import type { Context, Middleware } from "@wrnexus/core"; +import type { AuthorizationDecision } from "./advanced.ts"; +import { createAuthzResolver, type AuthzResolver, type AuthzResolverOptions } from "./engine.ts"; +import type { AuthzScope } from "./types.ts"; + +/** + * `can` is deliberately not a Context member: @wrnexus/core must not depend on + * @wrnexus/authz. The per-request resolver lives here instead. + */ +export const AUTHZ_LOCALS_KEY = "_authz"; + +interface RequestAuthz { + resolver: AuthzResolver; + /** Memo for object resources, keyed by identity so two rows never collide. */ + byRef: WeakMap>>; + /** Memo for symbol resources, which also carry identity. */ + bySymbol: Map>>; + /** Memo for primitive and absent resources. */ + byValue: Map>; +} + +function readAuthz(ctx: Context): RequestAuthz { + const value = ctx.locals[AUTHZ_LOCALS_KEY] as RequestAuthz | undefined; + if (!value) { + throw new Error( + "WRN-AUTHZ-SETUP: authzMiddleware() is not registered for this request. " + + "Add it to app/middleware before calling can()/guardPermission().", + ); + } + return value; +} + +/** + * Read the tenant from the context at decision time, not at middleware time: + * a request that switches tenant mid-flight must not keep the old scope. + */ +function currentScope(ctx: Context): AuthzScope | undefined { + const tenantId = ctx.tenant?.id; + return typeof tenantId === "string" && tenantId !== "" ? { tenantId } : undefined; +} + +/** Install the per-request resolver. Register after sessionAuth and tenantMiddleware. */ +export function authzMiddleware(options: AuthzResolverOptions): Middleware { + const resolver = createAuthzResolver(options); + return (ctx, next) => { + ctx.locals[AUTHZ_LOCALS_KEY] = { + resolver, + byRef: new WeakMap(), + bySymbol: new Map(), + byValue: new Map(), + } satisfies RequestAuthz; + return next(); + }; +} + +export function decideFor( + ctx: Context, + permission: string, + resource?: unknown, +): Promise { + const request = readAuthz(ctx); + const scope = currentScope(ctx); + // Scope is part of the key: the same permission decides differently per tenant. + // 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({ + 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. + // 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())); + 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, 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(); + request.byValue.set(valueKey, pending); + return pending; +} + +export async function can(ctx: Context, permission: string, resource?: unknown): Promise { + return (await decideFor(ctx, permission, resource)).allowed; +} + +export interface GuardOptions { + /** Load the resource a bound policy needs. */ + getResource?: (ctx: Context) => unknown; + /** Include reason and policy name in the 403 body. Off by default. */ + exposeReason?: boolean; + /** Redirect page requests here instead of returning 403. Must be a local path. */ + redirectTo?: string; +} + +/** Same rule requireAuth uses, replicated because authz may only import TYPES from core. */ +function wantsJson(ctx: Context): boolean { + if (ctx.url.pathname.startsWith("/api/")) return true; + const accept = ctx.req.headers.get("accept") ?? ""; + return accept.includes("application/json") && !accept.includes("text/html"); +} + +/** + * 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; + for (const character of value) { + const code = character.codePointAt(0)!; + if (code < 0x20 || code === 0x7f) return false; + } + return true; +} + +/** + * Guard a route on a registered permission. Named `guardPermission` because + * `requirePermission(rbac, permission)` already exists with a different shape. + */ +export function guardPermission(permission: string, options: GuardOptions = {}): Middleware { + return async (ctx, next) => { + let resource: unknown; + if (options.getResource) { + try { + resource = await options.getResource(ctx); + } catch (error) { + // Loading the resource failed, so the policy cannot be evaluated. Deny + // rather than 500 — and never leak the loader's message to the client. + console.error(`[wrnexus:authz] getResource for '${permission}' threw; denying`, error); + return Response.json({ ok: false, error: "Forbidden" }, { status: 403 }); + } + } + const result = await decideFor(ctx, permission, resource); + if (result.allowed) return next(); + + if (options.redirectTo && !wantsJson(ctx)) { + if (!isLocalPath(options.redirectTo)) { + // 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 ${JSON.stringify(options.redirectTo)}; denying`, + ); + } else { + return new Response(null, { + status: 303, + headers: { + location: headerSafePath(options.redirectTo), + "cache-control": "private, no-store", + }, + }); + } + } + return Response.json( + options.exposeReason + ? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy } + : { ok: false, error: "Forbidden" }, + { status: 403, headers: { "cache-control": "private, no-store" } }, + ); + }; +} + +/** Keep only the items the current subject may act on. */ +export async function filterCan( + ctx: Context, + permission: string, + items: readonly T[], +): Promise { + const verdicts = await Promise.all( + items.map(async (item) => ({ item, allowed: await can(ctx, permission, item) })), + ); + return verdicts.filter((entry) => entry.allowed).map((entry) => entry.item); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/authz/test/middleware.test.ts` +Expected: PASS, 10 tests + +- [ ] **Step 5: Commit** + +```bash +git add packages/authz/src/middleware.ts packages/authz/test/middleware.test.ts +git commit -m "feat(authz): add request middleware, can(), and guardPermission" +``` + +--- + +## Task 8: Stop `authorizeDecision` leaking policy internals + +**Files:** + +- Modify: `packages/authz/src/advanced.ts:72-83` +- Test: `packages/authz/test/authz.test.ts` (append) + +**Interfaces:** + +- Consumes: `AuthorizationDecision` from `./advanced.ts` +- Produces: `authorizeDecision(evaluate, options?: { exposeReason?: boolean }): Middleware` — behaviour change, body is now `{ ok: false, error: "Forbidden" }` unless opted in + +- [ ] **Step 1: Write the failing test** + +Append to `packages/authz/test/authz.test.ts`: + +```ts +describe("authorizeDecision disclosure", () => { + const ctx = { user: { id: "u1" } } as unknown as import("@wrnexus/core").Context; + const denier = async () => ({ allowed: false, reason: "secret internal rule", policy: "isVip" }); + + test("does not leak reason or policy by default", async () => { + const res = await authorizeDecision(denier)(ctx, async () => new Response("ok")); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ ok: false, error: "Forbidden" }); + }); + + test("exposeReason opts back in", async () => { + const res = await authorizeDecision(denier, { exposeReason: true })( + ctx, + async () => new Response("ok"), + ); + const body = (await res.json()) as Record; + expect(body.reason).toBe("secret internal rule"); + expect(body.policy).toBe("isVip"); + }); + + test("still calls next when allowed", async () => { + const res = await authorizeDecision(async () => ({ allowed: true }))( + ctx, + async () => new Response("passed"), + ); + expect(await res.text()).toBe("passed"); + }); +}); +``` + +Add `authorizeDecision` to the file's existing import from `../src/index.ts` if it is not already imported. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test packages/authz/test/authz.test.ts` +Expected: FAIL — the default response still contains `reason` + +- [ ] **Step 3: Modify `packages/authz/src/advanced.ts`** + +Replace the `authorizeDecision` function with: + +```ts +export interface AuthorizeDecisionOptions { + /** + * Include `reason` and `policy` in the 403 body. Off by default: policy + * names describe internal authorization structure and should not reach an + * unauthenticated caller. + */ + exposeReason?: boolean; +} + +export function authorizeDecision( + evaluate: (ctx: Context) => AuthorizationDecision | Promise, + options: AuthorizeDecisionOptions = {}, +): Middleware { + return async (ctx, next) => { + const result = await evaluate(ctx); + if (result.allowed) return next(); + return Response.json( + options.exposeReason + ? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy } + : { ok: false, error: "Forbidden" }, + { status: 403 }, + ); + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test packages/authz/test/authz.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/authz/src/advanced.ts packages/authz/test/authz.test.ts +git commit -m "fix(authz): stop authorizeDecision leaking policy names in 403 bodies" +``` + +--- + +## Task 9: Export the new surface + +**Files:** + +- Modify: `packages/authz/src/index.ts` (append to the existing re-export block) +- Modify: `docs/public-api-0.8.json` (regenerated) +- Test: `packages/authz/test/exports.test.ts` + +**Interfaces:** + +- Consumes: everything from Tasks 1-8 +- Produces: the public `@wrnexus/authz` surface + +- [ ] **Step 1: Write the failing test** + +Create `packages/authz/test/exports.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import * as authz from "../src/index.ts"; + +describe("@wrnexus/authz exports", () => { + test("keeps the pre-existing surface", () => { + for (const name of [ + "defineRbac", + "hasRole", + "any", + "all", + "attr", + "authorize", + "requireRole", + "requirePermission", + "allow", + "deny", + "decision", + "owner", + "anyDecision", + "allDecisions", + "authorizeDecision", + "filterAuthorized", + ]) { + expect(typeof (authz as Record)[name]).toBe("function"); + } + }); + + test("adds the registry, store, engine, and middleware surface", () => { + for (const name of [ + "defineAuthz", + "mergeCatalogs", + "emptyCatalog", + "memoryPermissionStore", + "cachedPermissionStore", + "memoryAuditSink", + "consoleAuditSink", + "createAuthzResolver", + "expandRoles", + "permissionMatches", + "scopeKey", + "safeRecord", + "deniedBy", + "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, 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"; +``` + +- [ ] **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[] = []; +// 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}`); + 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). + */ +/** + * 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" + : dialect === "mysql" + ? "INT AUTO_INCREMENT PRIMARY KEY" + : "INTEGER PRIMARY KEY AUTOINCREMENT"; + const timestamp = dialect === "sqlite" ? "TEXT" : "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`; + + return { + up: [ + `CREATE TABLE IF NOT EXISTS _wrn_authz_assignment ( + id ${id}, + 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 ( + id ${id}, + 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) +)`, + ], + down: ["DROP TABLE IF EXISTS _wrn_authz_grant", "DROP TABLE IF EXISTS _wrn_authz_assignment"], + }; +} +``` + +- [ ] **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. */ +/** 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 = ${p(1)} AND (scope = '' OR scope = ${p(2)})`, + [subjectId, key], + ); + 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), + // 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) { + await db.exec( + `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 = ${p(1)} AND scope = ${p(2)} AND role = ${p(3)}`, + [subjectId, scopeKey(scope), role], + ); + }, + + async grant(subjectId, permission, effect, scope) { + 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 = ${p(1)} AND scope = ${p(2)} AND permission = ${p(3)}`, + [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 = ${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))]; + }, + }; +} +``` + +- [ ] **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"; + // 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) => 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()])}; +`; +} +``` + +- [ ] **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`); + // 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); + } +} +``` + +- [ ] **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) { + // 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 }); + } + 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. 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. diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index 5023957d..67398c01 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,41 @@ "attr", "authorize", "authorizeDecision", + "authzMiddleware", + "cachedPermissionStore", + "can", + "consoleAuditSink", + "createAuthzResolver", + "decideFor", "decision", + "defineAuthz", "defineRbac", + "deniedBy", "deny", + "emptyCatalog", + "expandRoles", "filterAuthorized", + "filterCan", + "generatePermissionTypes", + "getAuthzCatalog", + "guardPermission", + "hasAuthzCatalog", "hasRole", + "memoryAuditSink", + "memoryPermissionStore", + "mergeCatalogs", "owner", + "permissionMatches", "requirePermission", - "requireRole" + "requireRole", + "safeRecord", + "scopeKey", + "setAuthzCatalog" + ], + "./db": [ + "authzMigrationSql", + "dbPermissionStore", + "ensureAuthzTables" ] }, "@wrnexus/benchmark": { @@ -1376,6 +1423,7 @@ "@wrnexus/dev-server": { ".": [ "AssetServer", + "AuthzManifestEntry", "FetchHandler", "GatewayApp", "GatewayAuth", @@ -1388,6 +1436,7 @@ "ServeOptions", "WrnCompileMetrics", "WsData", + "applyAuthzManifestEarly", "createHandlers", "createProductionHandlers", "createProductionServer", 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/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/README.md b/packages/authz/README.md index f0102ea5..4247ad62 100644 --- a/packages/authz/README.md +++ b/packages/authz/README.md @@ -149,3 +149,156 @@ 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()) }); +``` + +> **`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 +`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 +``` + +`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/package.json b/packages/authz/package.json index a1dcf4ec..014807f8 100644 --- a/packages/authz/package.json +++ b/packages/authz/package.json @@ -5,6 +5,11 @@ "type": "module", "main": "src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./db": "./src/db.ts" + }, + "dependencies": { + "@wrnexus/core": "workspace:*", + "@wrnexus/db": "workspace:*" } } diff --git a/packages/authz/src/advanced.ts b/packages/authz/src/advanced.ts index 81317a84..a0f01394 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) => { @@ -69,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/src/audit.ts b/packages/authz/src/audit.ts new file mode 100644 index 00000000..177549b9 --- /dev/null +++ b/packages/authz/src/audit.ts @@ -0,0 +1,76 @@ +import type { AuthzScope } from "./types.ts"; + +export interface AuthzAuditEvent { + subjectId?: string; + scope?: AuthzScope; + permission: string; + allowed: boolean; + reason?: string; + policy?: string; + /** Epoch milliseconds. */ + at: number; +} + +export interface AuthzAuditSink { + record(event: AuthzAuditEvent): void | Promise; +} + +export interface MemoryAuditSink extends AuthzAuditSink { + events: AuthzAuditEvent[]; + clear(): void; +} + +export function memoryAuditSink(): MemoryAuditSink { + const events: AuthzAuditEvent[] = []; + return { + events, + record: (event) => void events.push(event), + clear: () => void events.splice(0, events.length), + }; +} + +/** + * Subject ids, tenant ids, and denial reasons trace back to request input, so + * a newline in one would forge a second audit line indistinguishable from a + * real entry. Strip control characters before interpolating. + */ +function logSafe(value: string): string { + let out = ""; + for (const character of value) { + const code = character.codePointAt(0)!; + // C0 + DEL, plus NEL and the Unicode line/paragraph separators, which some + // log shippers and JSON consumers also treat as line terminators. + const isLineBreaking = + code < 0x20 || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029; + out += isLineBreaking ? " " : character; + } + return out; +} + +export function consoleAuditSink(): AuthzAuditSink { + return { + record(event) { + const verdict = event.allowed ? "allow" : "deny"; + console.info( + `[wrnexus:authz] ${verdict} ${logSafe(event.permission)} ` + + `subject=${logSafe(event.subjectId ?? "anonymous")}` + + `${event.scope?.tenantId ? ` tenant=${logSafe(event.scope.tenantId)}` : ""}` + + `${event.reason ? ` reason=${logSafe(event.reason)}` : ""}` + + `${event.policy ? ` policy=${logSafe(event.policy)}` : ""}`, + ); + }, + }; +} + +/** Record without ever letting a sink failure escape into the request path. */ +export function safeRecord(sink: AuthzAuditSink | undefined, event: AuthzAuditEvent): void { + if (!sink) return; + try { + const result = sink.record(event); + if (result instanceof Promise) { + result.catch((error) => console.warn("[wrnexus:authz] audit sink failed", error)); + } + } catch (error) { + console.warn("[wrnexus:authz] audit sink failed", error); + } +} diff --git a/packages/authz/src/catalog.ts b/packages/authz/src/catalog.ts new file mode 100644 index 00000000..f8df76ba --- /dev/null +++ b/packages/authz/src/catalog.ts @@ -0,0 +1,129 @@ +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); + // 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); + // 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. + 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, Object.freeze({ ...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, Object.freeze([...v])] as [string, readonly string[]]), + ), + }; +} diff --git a/packages/authz/src/client.ts b/packages/authz/src/client.ts new file mode 100644 index 00000000..a1504918 --- /dev/null +++ b/packages/authz/src/client.ts @@ -0,0 +1,63 @@ +/** + * 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, 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"; + +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 " + + "`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; +} + +/** Whether the process-wide authorization catalog has been set. */ +export function hasAuthzCatalog(): boolean { + return catalog !== undefined; +} diff --git a/packages/authz/src/codegen.ts b/packages/authz/src/codegen.ts new file mode 100644 index 00000000..885a1f91 --- /dev/null +++ b/packages/authz/src/codegen.ts @@ -0,0 +1,31 @@ +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 `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. + +export type Permission = ${union([...catalog.permissions.keys()])}; + +export type Role = ${union([...catalog.roles.keys()])}; +`; +} diff --git a/packages/authz/src/db.ts b/packages/authz/src/db.ts new file mode 100644 index 00000000..58b04d4d --- /dev/null +++ b/packages/authz/src/db.ts @@ -0,0 +1,116 @@ +import type { Db, Dialect } from "@wrnexus/db"; +import { authzMigrationSql } from "./migrations.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 +// 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. + * + * 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, +): 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 = ${p(1)} AND (scope = '' OR scope = ${p(2)})`, + [subjectId, key], + ); + 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), + // 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) { + await db.exec( + `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 = ${p(1)} AND scope = ${p(2)} AND role = ${p(3)}`, + [subjectId, scopeKey(scope), role], + ); + }, + + 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( + "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 = ${p(1)} AND scope = ${p(2)} AND permission = ${p(3)}`, + [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 = ${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/engine.ts b/packages/authz/src/engine.ts new file mode 100644 index 00000000..ca566ed7 --- /dev/null +++ b/packages/authz/src/engine.ts @@ -0,0 +1,249 @@ +import type { AuthorizationDecision } from "./advanced.ts"; +import { safeRecord, type AuthzAuditSink } from "./audit.ts"; +import type { PermissionStore } from "./store.ts"; +import type { AuthzCatalog, AuthzScope } from "./types.ts"; + +export interface AuthzResolverOptions { + catalog: AuthzCatalog; + store: PermissionStore; + audit?: AuthzAuditSink; + /** + * Throw on an unregistered permission instead of denying. Defaults to true + * outside production, so typos surface during development. + */ + strict?: boolean; + /** Record allows as well as denies. Off by default to bound write volume. */ + auditAllows?: boolean; +} + +export interface DecideInput { + subject: { id?: string; [key: string]: unknown } | null | undefined; + permission: string; + resource?: unknown; + scope?: AuthzScope; +} + +export interface AuthzResolver { + /** + * Effective permissions with denied entries removed — for coarse gating such + * as hiding a menu section. + * + * NOT authoritative. A set of strings cannot express "everything under + * `post:*` except `post:delete`", so a narrow deny beneath a broad grant is + * not representable here: the set still contains `post:*` while `decide()` + * correctly refuses `post:delete`. Gate individual actions with `decide()` + * (or `can()` / `filterCan()`), never by matching against this set. + */ + permissionsFor(subjectId: string, scope?: AuthzScope): Promise>; + decide(input: DecideInput): Promise; +} + +/** Expand roles into their granted entries, following `role:` and stopping on cycles. */ +export function expandRoles(catalog: AuthzCatalog, roles: readonly string[]): Set { + const out = new Set(); + const seen = new Set(); + const walk = (role: string) => { + if (seen.has(role)) return; + seen.add(role); + for (const entry of catalog.roles.get(role) ?? []) { + if (entry.startsWith("role:")) walk(entry.slice(5)); + else out.add(entry); + } + }; + for (const role of roles) walk(role); + return out; +} + +/** + * Exact match, root wildcard, or a namespace wildcard at any depth. + * + * 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)) { + if (granted.has(`${permission.slice(0, at)}:*`)) return true; + } + return false; +} + +/** + * True if any entry in the deny list covers `permission`. Denies honour the + * same depth-aware wildcards as grants, so denying "post:*" blocks + * post:comment:delete rather than being accepted and silently doing nothing. + */ +export function deniedBy(denies: readonly string[], permission: string): boolean { + // 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; +} + +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> => { + 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 (!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 rawId === "string" && rawId !== "" ? rawId : undefined, + scope: input.scope, + permission: input.permission, + allowed: result.allowed, + reason: result.reason, + policy: result.policy, + at: Date.now(), + }); + } + return result; + }; + + /** + * Run every policy bound to `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 { permission, scope } = input; + const meta = catalog.permissions.get(permission); + + if (!meta) { + if (strict) { + throw new Error( + `WRN-AUTHZ-UNKNOWN: permission '${permission}' is not registered. ` + + `Declare it with defineAuthz() in app/authz/.`, + ); + } + return finish(input, { + allowed: false, + reason: `Permission '${permission}' is not registered`, + }); + } + + 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) { + if (!meta.public) { + return finish(input, { allowed: false, reason: "Authentication required" }); + } + const denied = await runPolicies(input, permission); + return finish(input, denied ?? { allowed: true, reason: "public permission" }); + } + + let assignments; + let granted: Set; + try { + ({ assignments, granted } = await loadEffective(subjectId, scope)); + + // 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" }); + } + + // 3. Every bound policy must pass. + const denied = await runPolicies(input, permission); + return finish(input, denied ?? { allowed: true }); + }, + }; +} diff --git a/packages/authz/src/index.ts b/packages/authz/src/index.ts index f57d5256..e91b8443 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; }, }; } @@ -139,3 +140,32 @@ 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 { 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"; +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"; +export { generatePermissionTypes } from "./codegen.ts"; diff --git a/packages/authz/src/middleware.ts b/packages/authz/src/middleware.ts new file mode 100644 index 00000000..a7dceaa8 --- /dev/null +++ b/packages/authz/src/middleware.ts @@ -0,0 +1,286 @@ +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"; + +/** + * `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; + /** + * 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. */ + bySymbol: Map>>; + /** Memo for primitive and absent resources. */ + byValue: Map>; +} + +function readAuthz(ctx: Context): RequestAuthz { + const value = ctx.locals[AUTHZ_LOCALS_KEY] as RequestAuthz | undefined; + if (!value) { + throw new Error( + "WRN-AUTHZ-SETUP: authzMiddleware() is not registered for this request. " + + "Add it to app/middleware before calling can()/guardPermission().", + ); + } + return value; +} + +/** 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, + audit: options.audit, + byRef: new WeakMap(), + bySymbol: new Map(), + byValue: new Map(), + }; + ctx.locals[AUTHZ_LOCALS_KEY] = request; + return next(); + }; +} + +/** + * 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; +} + +/** + * 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 + * (same `id` shape, circular references, BigInt fields, throwing getters all + * 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, + permission: string, + resource?: unknown, +): Promise { + const request = readAuthz(ctx); + const scope = currentScope(ctx); + 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({ + subject: ctx.user as { id?: string } | null | undefined, + permission, + resource, + 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 && + (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 = run(); + inner.set(key, pending); + return pending; + } + + 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(); + request.byValue.set(valueKey, pending); + return pending; +} + +export async function can(ctx: Context, permission: string, resource?: unknown): Promise { + return (await decideFor(ctx, permission, resource)).allowed; +} + +/** + * 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; +} + +/** + * 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 { + /** Load the resource a bound policy needs. */ + getResource?: (ctx: Context) => unknown; + /** Include reason and policy name in the 403 body. Off by default. */ + exposeReason?: boolean; + /** Redirect page requests here instead of returning 403. Ignored for JSON/API requests and for any non-local target. */ + 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) => { + let resource: unknown; + if (options.getResource) { + try { + 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 }, + ); + } + } + + const result = await decideFor(ctx, permission, resource); + if (result.allowed) return next(); + + if (options.redirectTo && !wantsJson(ctx)) { + if (isLocalPath(options.redirectTo)) { + return new Response(null, { + status: 303, + // 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 + // for targets containing CR/LF, which must not reach the log verbatim. + console.error( + `[wrnexus:authz] guardPermission redirectTo ${JSON.stringify(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, headers: NO_STORE_HEADERS }, + ); + }; +} + +/** 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/src/migrations.ts b/packages/authz/src/migrations.ts new file mode 100644 index 00000000..e4107c62 --- /dev/null +++ b/packages/authz/src/migrations.ts @@ -0,0 +1,49 @@ +import type { Dialect } from "@wrnexus/db"; + +/** + * 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" + : dialect === "mysql" + ? "INT AUTO_INCREMENT PRIMARY KEY" + : "INTEGER PRIMARY KEY AUTOINCREMENT"; + const timestamp = dialect === "sqlite" ? "TEXT" : "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`; + + return { + up: [ + `CREATE TABLE IF NOT EXISTS _wrn_authz_assignment ( + id ${id}, + 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 ( + id ${id}, + 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) +)`, + ], + down: ["DROP TABLE IF EXISTS _wrn_authz_grant", "DROP TABLE IF EXISTS _wrn_authz_assignment"], + }; +} 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/store.ts b/packages/authz/src/store.ts new file mode 100644 index 00000000..3de33d14 --- /dev/null +++ b/packages/authz/src/store.ts @@ -0,0 +1,206 @@ +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. An OMITTED + * 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 ""; + // 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 be a non-empty string; omit the scope for a global assignment.", + ); + } + return 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) { + // 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, + ); + 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]; + }, + }; +} + +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 bySubject = new Map>(); + + const cacheKey = (subjectId: string, scope?: AuthzScope) => + JSON.stringify([scopeKey(scope), subjectId]); + const drop = (subjectId: string, scope?: AuthzScope) => { + // A global write changes what every tenant sees for that subject. + if (scopeKey(scope) === "") { + 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 { + async assignmentsFor(subjectId, scope) { + const key = cacheKey(subjectId, scope); + const hit = entries.get(key); + if (hit && Date.now() - hit.at < ttlMs) return hit.value; + const value = await inner.assignmentsFor(subjectId, scope); + if (entries.size >= max) { + const 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) { + await inner.assignRole(subjectId, role, scope); + drop(subjectId, scope); + }, + async revokeRole(subjectId, role, scope) { + await inner.revokeRole(subjectId, role, scope); + drop(subjectId, scope); + }, + async grant(subjectId, permission, effect, scope) { + await inner.grant(subjectId, permission, effect, scope); + drop(subjectId, scope); + }, + async revokeGrant(subjectId, permission, scope) { + await inner.revokeGrant(subjectId, permission, scope); + drop(subjectId, scope); + }, + listSubjects: (scope) => inner.listSubjects(scope), + invalidate: drop, + invalidateAll: () => { + entries.clear(); + bySubject.clear(); + }, + size: () => entries.size, + }; +} 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/audit.test.ts b/packages/authz/test/audit.test.ts new file mode 100644 index 00000000..3afe6844 --- /dev/null +++ b/packages/authz/test/audit.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { + consoleAuditSink, + memoryAuditSink, + safeRecord, + type AuthzAuditSink, +} from "../src/audit.ts"; + +describe("audit sink", () => { + test("memoryAuditSink collects events", () => { + const sink = memoryAuditSink(); + sink.record({ permission: "post:read", allowed: true, at: 1 }); + expect(sink.events).toHaveLength(1); + expect(sink.events[0]!.permission).toBe("post:read"); + }); + + test("safeRecord swallows sink failures", () => { + const exploding = { + record() { + throw new Error("sink is down"); + }, + }; + // Auditing must never break a request. + expect(() => safeRecord(exploding, { permission: "p:x", allowed: false, at: 1 })).not.toThrow(); + }); + + test("safeRecord swallows async sink rejections", async () => { + const rejecting = { record: async () => Promise.reject(new Error("later")) }; + expect(() => safeRecord(rejecting, { permission: "p:x", allowed: false, at: 1 })).not.toThrow(); + await Bun.sleep(1); + }); + + test("safeRecord tolerates an undefined sink", () => { + expect(() => safeRecord(undefined, { permission: "p:x", allowed: true, at: 1 })).not.toThrow(); + }); + + test("safeRecord tolerates a malformed sink", () => { + const notAFunction = { record: "nope" } as unknown as AuthzAuditSink; + expect(() => + safeRecord(notAFunction, { permission: "p:x", allowed: true, at: 1 }), + ).not.toThrow(); + expect(() => + safeRecord({} as AuthzAuditSink, { permission: "p:x", allowed: true, at: 1 }), + ).not.toThrow(); + }); + + test("memoryAuditSink.clear empties the buffer", () => { + const sink = memoryAuditSink(); + sink.record({ permission: "p:x", allowed: true, at: 1 }); + sink.clear(); + expect(sink.events).toHaveLength(0); + }); + + test("consoleAuditSink cannot be used to forge a second log line", () => { + // 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${NEL}[wrnexus:authz] allow admin:everything subject=root`, + permission: "post:read", + allowed: false, + reason: `nope\r\ninjected${LINE_SEPARATOR}a${PARAGRAPH_SEPARATOR}b`, + at: 1, + }); + } finally { + console.info = original; + } + 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"); + }); +}); diff --git a/packages/authz/test/authz.test.ts b/packages/authz/test/authz.test.ts index aa8153d2..e3940f38 100644 --- a/packages/authz/test/authz.test.ts +++ b/packages/authz/test/authz.test.ts @@ -1,16 +1,19 @@ -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, all, attr, decision, + owner, type Policy, + type Subject, } from "../src/index.ts"; const rbac = defineRbac({ @@ -93,3 +96,65 @@ 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); +}); + +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"); + }); +}); diff --git a/packages/authz/test/catalog.test.ts b/packages/authz/test/catalog.test.ts new file mode 100644 index 00000000..e255d428 --- /dev/null +++ b/packages/authz/test/catalog.test.ts @@ -0,0 +1,135 @@ +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("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); + }); +}); 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/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"'); + }); +}); 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/engine.test.ts b/packages/authz/test/engine.test.ts new file mode 100644 index 00000000..05fc2b28 --- /dev/null +++ b/packages/authz/test/engine.test.ts @@ -0,0 +1,381 @@ +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([ + { + 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); + }); +}); + +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("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("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, {}]; + 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); + } + }); + + 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(); + }); +}); diff --git a/packages/authz/test/exports.test.ts b/packages/authz/test/exports.test.ts new file mode 100644 index 00000000..85e2d5ef --- /dev/null +++ b/packages/authz/test/exports.test.ts @@ -0,0 +1,56 @@ +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", + "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"); + }); +}); 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); + }); +}); diff --git a/packages/authz/test/middleware.test.ts b/packages/authz/test/middleware.test.ts new file mode 100644 index 00000000..2c4f7c17 --- /dev/null +++ b/packages/authz/test/middleware.test.ts @@ -0,0 +1,416 @@ +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 { memoryAuditSink } from "../src/audit.ts"; +import { authzMiddleware, can, filterCan, guardPermission } from "../src/middleware.ts"; + +const catalog = mergeCatalogs([ + { + source: "t.ts", + module: defineAuthz({ + permissions: { "post:read": { public: true }, "post:write": {}, "post:delete": {} }, + roles: { editor: ["post:write"] }, + policies: { + ownsPost: async (s: { id?: string }, r?: { authorId?: string }) => + r?.authorId === s?.id ? { allowed: true } : { allowed: false, reason: "not owner" }, + }, + bindings: { "post:delete": ["ownsPost"] }, + }), + }, +]); + +/** Minimal Context stand-in; the middleware only touches user, tenant, locals. */ +function makeCtx(user: unknown, tenantId?: string): Context { + return { + user, + tenant: tenantId ? { id: tenantId } : undefined, + locals: {}, + url: new URL("http://localhost/x"), + req: new Request("http://localhost/x"), + } as unknown as Context; +} + +const withMiddleware = async (ctx: Context, store = memoryPermissionStore()) => { + await authzMiddleware({ catalog, store, strict: false })(ctx, async () => new Response("ok")); + return store; +}; + +describe("authzMiddleware + can", () => { + test("can() resolves through the middleware-installed resolver", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.assignRole("u1", "editor"); + await withMiddleware(ctx, store); + expect(await can(ctx, "post:write")).toBe(true); + expect(await can(ctx, "post:delete", { authorId: "u1" })).toBe(false); + }); + + test("can() throws a clear setup error without the middleware", async () => { + const ctx = makeCtx({ id: "u1" }); + await expect(can(ctx, "post:read")).rejects.toThrow(/authzMiddleware/); + }); + + test("results are memoised per request", async () => { + const inner = memoryPermissionStore(); + let reads = 0; + const counting = { + ...inner, + assignmentsFor: (id: string, scope?: { tenantId?: string }) => { + reads++; + return inner.assignmentsFor(id, scope); + }, + }; + const ctx = makeCtx({ id: "u1" }); + await authzMiddleware({ catalog, store: counting, strict: false })( + ctx, + async () => new Response("ok"), + ); + await can(ctx, "post:write"); + await can(ctx, "post:write"); + expect(reads).toBe(1); + }); + + test("memoisation keys on the resource, not just the permission", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.grant("u1", "post:delete", "allow"); + await withMiddleware(ctx, store); + expect(await can(ctx, "post:delete", { authorId: "u1" })).toBe(true); + expect(await can(ctx, "post:delete", { authorId: "other" })).toBe(false); + }); + + test("the tenant on the context becomes the scope", async () => { + const ctx = makeCtx({ id: "u1" }, "t1"); + const store = memoryPermissionStore(); + await store.assignRole("u1", "editor", { tenantId: "t1" }); + await withMiddleware(ctx, store); + expect(await can(ctx, "post:write")).toBe(true); + }); +}); + +describe("guardPermission", () => { + test("calls next when allowed", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.assignRole("u1", "editor"); + await withMiddleware(ctx, store); + const res = await guardPermission("post:write")(ctx, async () => new Response("passed")); + expect(await res.text()).toBe("passed"); + }); + + test("returns 403 without leaking the reason by default", async () => { + const ctx = makeCtx({ id: "u1" }); + await withMiddleware(ctx); + const res = await guardPermission("post:write")(ctx, async () => new Response("passed")); + expect(res.status).toBe(403); + const body = (await res.json()) as Record; + expect(body).toEqual({ ok: false, error: "Forbidden" }); + }); + + test("exposeReason opts into diagnostics", async () => { + const ctx = makeCtx({ id: "u1" }); + await withMiddleware(ctx); + const res = await guardPermission("post:write", { exposeReason: true })( + ctx, + async () => new Response("passed"), + ); + const body = (await res.json()) as Record; + expect(body.reason).toBe("Missing permission"); + }); + + test("getResource feeds the bound policy", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.grant("u1", "post:delete", "allow"); + await withMiddleware(ctx, store); + const guard = guardPermission("post:delete", { getResource: () => ({ authorId: "u1" }) }); + const res = await guard(ctx, async () => new Response("passed")); + expect(await res.text()).toBe("passed"); + }); +}); + +describe("filterCan", () => { + test("keeps only the items the subject may act on", async () => { + const ctx = makeCtx({ id: "u1" }); + const store = memoryPermissionStore(); + await store.grant("u1", "post:delete", "allow"); + await withMiddleware(ctx, store); + const posts = [{ authorId: "u1" }, { authorId: "other" }, { authorId: "u1" }]; + expect(await filterCan(ctx, "post:delete", posts)).toHaveLength(2); + }); + + test("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("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" }); + 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("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); + 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("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 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); + 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 () => { + 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); + }); +}); 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/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/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/); + }); +}); diff --git a/packages/authz/test/store-cached.test.ts b/packages/authz/test/store-cached.test.ts new file mode 100644 index 00000000..23e5a7df --- /dev/null +++ b/packages/authz/test/store-cached.test.ts @@ -0,0 +1,82 @@ +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"]); + }); + + 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([]); + }); +}); diff --git a/packages/authz/test/store-conformance.ts b/packages/authz/test/store-conformance.ts new file mode 100644 index 00000000..f9b41a7d --- /dev/null +++ b/packages/authz/test/store-conformance.ts @@ -0,0 +1,196 @@ +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("a tenant-scoped grant does not leak into another tenant", async () => { + 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" }); + await store.assignRole("u3", "editor", { tenantId: "t2" }); + expect((await store.listSubjects({ tenantId: "t1" })).sort()).toEqual(["u1", "u2"]); + }); + + test("an explicitly empty tenantId is refused, not treated as global", async () => { + 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("a non-string tenantId is refused", async () => { + // 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. + 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 rejected write leaves unrelated state intact", async () => { + await store.assignRole("victim", "admin"); + 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" }); + expect(await store.listSubjects()).toEqual(["g1"]); + }); + + test("listSubjects credits grant-only subjects", async () => { + await store.grant("g1", "post:write", "allow", { tenantId: "t1" }); + expect((await store.listSubjects({ tenantId: "t1" })).sort()).toEqual(["g1"]); + }); + }); +} 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/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()); 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 new file mode 100644 index 00000000..b83de5cb --- /dev/null +++ b/packages/cli/src/authz.ts @@ -0,0 +1,133 @@ +/** + * `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); +} + +/** 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"; + 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; + return fail(`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: + fail(USAGE); + } +} diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 607cb9d2..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,6 +644,27 @@ export async function runBuild(appRoot: string): Promise { .join(", "); if (router.layouts.length) console.log(`✓ Layouts: ${router.layouts.length}`); + // 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++}`; + 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 +688,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/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..fefa1fd2 --- /dev/null +++ b/packages/cli/test/authz-command.test.ts @@ -0,0 +1,255 @@ +import { afterAll, describe, expect, spyOn, 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 }); +}); + +/** + * 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")); + 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 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 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-")); + 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 }); + 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 () => { + 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("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", []); + expect(existsSync(join(root, "app", "authz", "blog.ts"))).toBe(true); + const original = readFileSync(join(root, "app", "authz", "blog.ts"), "utf8"); + expect(original).toContain("defineAuthz"); + }); +}); 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/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..6cdbc2a5 --- /dev/null +++ b/packages/dev-server/src/authz-boot.ts @@ -0,0 +1,60 @@ +import { pathToFileURL } from "node:url"; +import { buildRouter, type Router } from "@wrnexus/router"; +import { + emptyCatalog, + mergeCatalogs, + type AuthzCatalog, + type AuthzModule, + 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( + 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) { + // 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 importModule(entry.file); + 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/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/src/index.ts b/packages/dev-server/src/index.ts index 4519d03b..8b1845f2 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, type AuthzModule } from "@wrnexus/authz"; +import { loadAppAuthzCatalog } from "./authz-boot.ts"; import { realtimeBusFromConfig } from "./realtime-bus.ts"; import { invalidateModule, @@ -336,6 +338,19 @@ 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. 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. const localeMessages = loadLocales(join(appDir, "locales"), { strict: opts.i18n?.strict }); const i18n = Object.keys(localeMessages).length @@ -602,6 +617,32 @@ 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); + } 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)); } @@ -717,5 +758,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 b0c14201..f6be505d 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 { hasAuthzCatalog, mergeCatalogs, setAuthzCatalog, type AuthzModule } from "@wrnexus/authz"; import { configureStorage, serveStoredFile, @@ -103,6 +104,19 @@ 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. 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?: AuthzManifestEntry[]; /** Resolved i18n bundle (default lang + locale messages). */ i18n?: ResolvedI18n; /** Default database connection (driver + url); enables `getDb()`. */ @@ -188,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; @@ -239,6 +287,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), @@ -352,6 +401,41 @@ 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. + // + // 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). + // + // 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 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; 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); + }); +}); 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..910ac49a --- /dev/null +++ b/packages/dev-server/test/authz-prod.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, test } from "bun:test"; +import { pathToFileURL } from "node:url"; +import { join } from "node:path"; +import { defineAuthz, getAuthzCatalog, mergeCatalogs, setAuthzCatalog } from "@wrnexus/authz"; +import { + applyAuthzManifestEarly, + createProductionHandlers, + type ProdManifest, +} from "../src/prod.ts"; + +const EMPTY_MANIFEST: ProdManifest = { + pages: [], + api: [], + realtime: [], + middleware: [], + components: [], + layouts: [], +}; + +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, {}); + }); + + 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", () => { + 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); + }); + + 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)", () => { + 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 new file mode 100644 index 00000000..a4ca0251 --- /dev/null +++ b/packages/dev-server/test/authz-startserver.test.ts @@ -0,0 +1,152 @@ +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 }); +}); + +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", { + "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")); + }); + + 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); +}); 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/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..9060e178 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,23 @@ 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; + // 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}`); + continue; + } + authz.push({ name, file: f.file }); + } + return { pages, api, @@ -292,6 +311,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..dce4d52d 100644 --- a/packages/router/src/scan.ts +++ b/packages/router/src/scan.ts @@ -32,8 +32,14 @@ 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 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): ScannedFile[] { +export function scanDir(baseDir: string, extraExtensions: readonly string[] = []): ScannedFile[] { if (!existsSync(baseDir)) return []; const out: ScannedFile[] = []; @@ -45,7 +51,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..196fdd47 --- /dev/null +++ b/packages/router/test/authz-discovery.test.ts @@ -0,0 +1,67 @@ +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"; +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([]); + }); + + 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"]); + }); +}); 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, 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"],