merge: 0.8.4 security audit fixes and the permissions system

Two bodies of work, both reviewed before merge.

SECURITY AUDIT of 0.8.4. The repo's own gates were already green, so every
finding came from manual review and each was reproduced before being claimed:
safeFetch re-attached credentials after a cross-origin redirect; its
private-network guard was advisory only and defeated by DNS rebinding; three
IPv6 forms bypassed the private-address check; sanitizeUrl returned
protocol-relative input verbatim (open redirect); the gateway threw on
malformed Basic credentials, truncated passwords at the first colon, and
leaked password length by timing; RBAC namespace wildcards matched only the
first segment; the brace-expansion override was pinned to the exact
vulnerable version.

PERMISSIONS SYSTEM in @wrnexus/authz. Declaration catalog discovered from
app/authz, a pluggable PermissionStore with memory and sqlite adapters held
to one 24-test conformance suite, a resolution engine with deny-wins
precedence and fail-closed error handling, request middleware, an audit sink,
type codegen, a wrnexus authz CLI, and dev/prod boot wiring.

Behaviour changes needing release notes: authorizeDecision's 403 body no
longer carries reason or policy (opt back in with exposeReason); RBAC
wildcards now match at every depth, which widens access for anyone relying on
the old behaviour; Router gained a required authz field; subject.id must be a
non-empty string. See docs/plans/2026-08-05-authz-follow-ups.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 08:37:55 +05:30
co-authored by Claude Opus 5
69 changed files with 9257 additions and 53 deletions
+16
View File
@@ -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
+5
View File
@@ -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-*/
+3
View File
@@ -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/
+5 -2
View File
@@ -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=="],
@@ -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<SubjectAssignments>;
assignRole(subjectId: string, role: string, scope?: AuthzScope): Promise<void>;
revokeRole(subjectId: string, role: string, scope?: AuthzScope): Promise<void>;
grant(
subjectId: string,
permission: string,
effect: "allow" | "deny",
scope?: AuthzScope,
): Promise<void>;
revokeGrant(subjectId: string, permission: string, scope?: AuthzScope): Promise<void>;
listSubjects(scope?: AuthzScope): Promise<string[]>;
}
```
`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<void>;
}
```
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 13 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.
File diff suppressed because it is too large Load Diff
+80
View File
@@ -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.
+50 -1
View File
@@ -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",
@@ -0,0 +1,30 @@
import { defineAuthz } from "@wrnexus/authz";
/**
* `app/authz/<name>.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"] },
});
@@ -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(),
});
+1
View File
@@ -12,6 +12,7 @@
},
"dependencies": {
"@wrnexus/auth": "workspace:*",
"@wrnexus/authz": "workspace:*",
"@wrnexus/captcha": "workspace:*",
"@wrnexus/core": "workspace:*",
"@wrnexus/validation": "workspace:*"
@@ -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<string, string>;
};
expect(manifest.dependencies?.["@wrnexus/authz"]).toBe("workspace:*");
});
+4 -1
View File
@@ -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"
}
}
+153
View File
@@ -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<boolean>` (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/<name>.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<string>` 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);
}
```
+6 -1
View File
@@ -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:*"
}
}
+23 -3
View File
@@ -39,10 +39,18 @@ export function owner<SubjectType extends Subject, Resource extends Record<strin
subjectKey: keyof SubjectType = "id",
resourceKey: keyof Resource | string = "userId",
): DecisionPolicy<SubjectType, Resource> {
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<S, R>(...policies: DecisionPolicy<S, R>[]): DecisionPolicy<S, R> {
return async (subject, resource) => {
@@ -69,14 +77,26 @@ export function allDecisions<S, R>(...policies: DecisionPolicy<S, R>[]): 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<AuthorizationDecision>,
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 },
);
};
+76
View File
@@ -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<void>;
}
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);
}
}
+129
View File
@@ -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<string, unknown>;
const right = b as Record<string, unknown>;
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<V>(entries: Iterable<[string, V]>): ReadonlyMap<string, V> {
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<PermissionMeta>([]),
roles: frozenMap<readonly string[]>([]),
policies: frozenMap<DecisionPolicy<never, never>>([]),
attributes: frozenMap<AttributeMeta>([]),
bindings: frozenMap<readonly string[]>([]),
};
}
export function mergeCatalogs(sources: CatalogSource[]): AuthzCatalog {
const permissions = new Map<string, PermissionMeta>();
const roles = new Map<string, readonly string[]>();
const policies = new Map<string, DecisionPolicy<never, never>>();
const attributes = new Map<string, AttributeMeta>();
const bindings = new Map<string, Set<string>>();
const origin = new Map<string, string>();
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<string>();
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[]]),
),
};
}
+63
View File
@@ -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;
}
+31
View File
@@ -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()])};
`;
}
+116
View File
@@ -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<void> {
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<SubjectAssignments> {
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))];
},
};
}
+249
View File
@@ -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<Set<string>>;
decide(input: DecideInput): Promise<AuthorizationDecision>;
}
/** Expand roles into their granted entries, following `role:` and stopping on cycles. */
export function expandRoles(catalog: AuthzCatalog, roles: readonly string[]): Set<string> {
const out = new Set<string>();
const seen = new Set<string>();
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<string>, 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<Set<string>> => {
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<string>();
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<AuthorizationDecision | null> => {
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<AuthorizationDecision>
)(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<string>;
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 });
},
};
}
+35 -5
View File
@@ -52,11 +52,12 @@ export function defineRbac(roles: Record<string, string[]>): 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";
+286
View File
@@ -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<object, Map<string, Promise<AuthorizationDecision>>>;
/** Memo for symbol resources, keyed by identity for the same reason. */
bySymbol: Map<symbol, Map<string, Promise<AuthorizationDecision>>>;
/** Memo for primitive and absent resources. */
byValue: Map<string, Promise<AuthorizationDecision>>;
}
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<AuthorizationDecision> {
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<boolean> {
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<T>(
ctx: Context,
permission: string,
items: readonly T[],
): Promise<T[]> {
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);
}
+49
View File
@@ -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"],
};
}
+50
View File
@@ -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/<name>.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:<name>'.`,
);
}
}
}
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 });
}
+206
View File
@@ -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<SubjectAssignments>;
assignRole(subjectId: string, role: string, scope?: AuthzScope): Promise<void>;
revokeRole(subjectId: string, role: string, scope?: AuthzScope): Promise<void>;
grant(
subjectId: string,
permission: string,
effect: GrantEffect,
scope?: AuthzScope,
): Promise<void>;
revokeGrant(subjectId: string, permission: string, scope?: AuthzScope): Promise<void>;
listSubjects(scope?: AuthzScope): Promise<string[]>;
}
/**
* 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<string>();
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<string, { at: number; value: SubjectAssignments; subjectId: string }>();
const bySubject = new Map<string, Set<string>>();
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,
};
}
+45
View File
@@ -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/<name>.ts` declaration. */
export interface AuthzModule {
permissions?: Record<string, PermissionMeta>;
roles?: Record<string, string[]>;
policies?: Record<string, DecisionPolicy<never, never>>;
attributes?: Record<string, AttributeMeta>;
/** permission id -> policy names that must pass for it. */
bindings?: Record<string, string[]>;
}
/** The merged, frozen view of every declaration in the app. */
export interface AuthzCatalog {
permissions: ReadonlyMap<string, PermissionMeta>;
roles: ReadonlyMap<string, readonly string[]>;
policies: ReadonlyMap<string, DecisionPolicy<never, never>>;
attributes: ReadonlyMap<string, AttributeMeta>;
bindings: ReadonlyMap<string, readonly string[]>;
}
export interface SubjectAssignments {
roles: string[];
/** Explicit allows, bypassing roles. */
grants: string[];
/** Explicit denies. Win over everything, including "*". */
denies: string[];
}
+84
View File
@@ -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");
});
});
+66 -1
View File
@@ -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<string, unknown> = { 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<string, unknown> {
ownerId?: string;
}
const customOwns = owner<Subject, CustomResource>("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<string, unknown>;
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");
});
});
+135
View File
@@ -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<string, never>).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);
});
});
+78
View File
@@ -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);
});
});
+33
View File
@@ -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"');
});
});
@@ -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<Row[]> {
statements.push(sql);
return [];
},
async exec(sql: string): Promise<ExecResult> {
statements.push(sql);
return { changes: 0 };
},
async transaction<T>(fn: (tx: TxHandle) => Promise<T>): Promise<T> {
// 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<T = Row>(sql: string): Promise<T[]> {
statements.push(sql);
return [];
},
async one<T = Row>(sql: string): Promise<T | null> {
statements.push(sql);
return null;
},
async exec(sql: string): Promise<ExecResult> {
statements.push(sql);
return { changes: 0 };
},
async tx<T>(fn: (tx: Db) => Promise<T>): Promise<T> {
// 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");
}
});
});
+381
View File
@@ -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<never, never>,
},
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();
});
});
+56
View File
@@ -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<string, unknown>)[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<string, unknown>)[name]).toBe("function");
}
});
test("exports the locals key used to reach the per-request resolver", () => {
expect(typeof (authz as Record<string, unknown>).AUTHZ_LOCALS_KEY).toBe("string");
});
});
+143
View File
@@ -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);
});
});
+416
View File
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const other = { authorId: "other", views: 11n } as Record<string, unknown>;
const circularMine = { authorId: "u1" } as Record<string, unknown>;
circularMine.self = circularMine;
const circularOther = { authorId: "other" } as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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);
});
});
+75
View File
@@ -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)",
);
});
});
+40
View File
@@ -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"]);
});
});
+41
View File
@@ -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/);
});
});
+82
View File
@@ -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([]);
});
});
+196
View File
@@ -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<PermissionStore>): 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"]);
});
});
}
+11
View File
@@ -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);
});
+4
View File
@@ -0,0 +1,4 @@
import { memoryPermissionStore } from "../src/store.ts";
import { runStoreConformance } from "./store-conformance.ts";
runStoreConformance("memory", async () => memoryPermissionStore());
+1
View File
@@ -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:*",
+133
View File
@@ -0,0 +1,133 @@
/**
* `wrnexus authz <cmd>` — 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 <list|generate|init>";
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<AuthzCatalog> {
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=<value>` 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<void> {
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);
}
}
+62
View File
@@ -563,6 +563,46 @@ export async function runBuild(appRoot: string): Promise<void> {
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<void> {
.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"},
+9
View File
@@ -7,6 +7,7 @@
* wrnexus create <app-name> scaffold a new app
* wrnexus eject <name...> copy a Wire UI component into your app
* wrnexus db <migrate|rollback|status|new> database migrations
* wrnexus authz <list|generate|init> authorization catalog tooling
*/
import { join, resolve } from "node:path";
@@ -66,6 +67,7 @@ Usage:
wrnexus eject <name...> Copy a Wire UI component into app/components
wrnexus update [dir] [--latest] Upgrade deps, migrate project files, and verify the app
wrnexus db <cmd> Migrations: migrate | rollback | status | seed | generate | new
wrnexus authz <cmd> 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<void> {
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("--")) ?? ".");
+255
View File
@@ -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<void>): Promise<string> {
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 <fn> (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");
});
});
@@ -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<typeof TIMED_OUT>((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<typeof TIMED_OUT>((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);
+1
View File
@@ -8,6 +8,7 @@
"./serve-entry": "./src/serve-entry.ts"
},
"dependencies": {
"@wrnexus/authz": "workspace:*",
"@wrnexus/core": "workspace:*",
"@wrnexus/dev-toolbar": "workspace:*",
"@wrnexus/router": "workspace:*",
+60
View File
@@ -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<AuthzCatalog> {
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);
}
+37 -13
View File
@@ -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,
+47
View File
@@ -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<RunningServer> {
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<RunningServer> {
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";
+84
View File
@@ -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<Middleware[]> => manifest.middleware;
@@ -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);
});
});
+234
View File
@@ -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/);
});
});
@@ -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, string>): 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<void> {
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);
});
@@ -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);
});
});
@@ -13,6 +13,7 @@ function runtime(health: HealthRegistry, trustProxy = false) {
layouts: [],
stores: [],
schemas: [],
authz: [],
matchPage: () => null,
matchApi: () => null,
matchRealtime: () => null,
+20
View File
@@ -59,6 +59,8 @@ export interface Router {
stores: ComponentRef[];
/** Validation schemas (`app/schemas/<name>.ts`) shared by API + forms. */
schemas: ComponentRef[];
/** Authorization declarations (`app/authz/<name>.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/<name>.{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),
+11 -2
View File
@@ -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("/"),
@@ -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, string>): 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"]);
});
});
+64 -16
View File
@@ -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<string[]>;
}
@@ -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<string[]> {
return (await lookup(hostname, { all: true, verbatim: true })).map((entry) => entry.address);
}
async function assertPublicHost(url: URL, options: SafeFetchOptions): Promise<void> {
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<string[]> {
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<vo
403,
);
}
return addresses;
}
/**
* Rewrite the request to dial the address we just validated, keeping `Host`
* (and TLS SNI/certificate verification) pointed at the original hostname.
* This is what makes the private-network guard binding rather than advisory.
*/
function pinToAddress(url: URL, address: string, init: RequestInit, headers: Headers): URL {
const pinned = new URL(url);
pinned.hostname = isIP(address) === 6 ? `[${address}]` : address;
headers.set("host", url.host);
if (url.protocol === "https:") {
(init as { tls?: { serverName: string } }).tls = { serverName: url.hostname };
}
return pinned;
}
export async function safeFetch(
@@ -100,6 +140,7 @@ export async function safeFetch(
delete (init as Record<string, unknown>).blockPrivateNetworks;
delete (init as Record<string, unknown>).resolver;
delete (init as Record<string, unknown>).forwardSensitiveHeaders;
delete (init as Record<string, unknown>).pinDns;
delete (init as Record<string, unknown>).base;
delete (init as Record<string, unknown>).allowRelative;
delete (init as Record<string, unknown>).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,
+15 -2
View File
@@ -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";
@@ -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");
});
});
+18 -6
View File
@@ -179,17 +179,29 @@ export function ffmpegVideoTranscoder(
options: { executable?: string; spawn?: (args: string[]) => { exited: Promise<number> } } = {},
) {
return async (input: string, output: string, config: VideoTranscodeOptions): Promise<void> => {
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,
+1
View File
@@ -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"],