permissionsFor carries a caveat that its Set cannot represent a narrow deny under a broad grant, so callers must gate with decide(). Now that permissionMatches is also public, the wrong composition is directly reachable and looks idiomatic - and the warning lived only on the other half of it. Adds the pointer to permissionMatches, and covers scopeKey and safeRecord in the exports test, which the brief omitted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3538 lines
123 KiB
Markdown
3538 lines
123 KiB
Markdown
# Permissions System Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Extend `@wrnexus/authz` so permissions, roles, policies and attributes are declared in code and discoverable, while role assignments live in a pluggable store.
|
|
|
|
**Architecture:** A **registry** (`defineAuthz`) declares what exists; a **catalog** merges declarations and freezes at boot; a **store** (`PermissionStore`) holds who-has-what; an **engine** resolves a subject to effective permissions and returns an `AuthorizationDecision`. The decision primitives already in `advanced.ts` are the evaluation layer and are not replaced.
|
|
|
|
**Tech Stack:** TypeScript, Bun (`bun:test`), `@wrnexus/core` (Context/Middleware types only), `@wrnexus/db` (Db interface, migrations).
|
|
|
|
## Global Constraints
|
|
|
|
- Every `@wrnexus/*` package is version `0.8.4`. Do not change versions.
|
|
- Zero runtime npm dependencies. Use only Bun/WebCrypto/node: builtins.
|
|
- `@wrnexus/core` MUST NOT import `@wrnexus/authz`. `can()` stays off `Context`; the resolver lives in `ctx.locals._authz`.
|
|
- `@wrnexus/authz` may import **types only** from `@wrnexus/core` (`import type { Context, Middleware }`).
|
|
- Existing exports of `@wrnexus/authz` keep working unchanged, with ONE approved exception: Task 8 changes the default 403 body of `authorizeDecision` to stop disclosing policy internals. That break is intentional and ruled on; everything else is additive.
|
|
- Framework-owned tables use the `_wrn_` prefix (matching `_wrn_tenant`, `_wrn_cursor`). The spec wrote `wrn_authz_assignment`; use `_wrn_authz_assignment` and `_wrn_authz_grant`.
|
|
- `requirePermission` is already exported with signature `(rbac: Rbac, permission: string)`. Do not change it. The new resource-aware guard is named `guardPermission`.
|
|
- Every failure path denies. Never fail open.
|
|
- After any change to `packages/authz/src/index.ts` exports, regenerate the API baseline with `bun run generate:public-api`.
|
|
- Full gate before declaring done: `bun run check:production`.
|
|
- Test files live in `packages/<pkg>/test/*.test.ts` and use `import { describe, expect, test } from "bun:test"`.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
**Created:**
|
|
|
|
| File | Responsibility |
|
|
| ------------------------------------------ | ------------------------------------------------------------------------------------------------- |
|
|
| `packages/authz/src/types.ts` | Shared types: `AuthzScope`, `PermissionMeta`, `AuthzModule`, `AuthzCatalog`, `SubjectAssignments` |
|
|
| `packages/authz/src/registry.ts` | `defineAuthz()` — validate and freeze one declaration module |
|
|
| `packages/authz/src/catalog.ts` | `mergeCatalogs()` — merge modules, detect conflicts, freeze |
|
|
| `packages/authz/src/store.ts` | `PermissionStore` interface, `memoryPermissionStore()`, `cachedPermissionStore()` |
|
|
| `packages/authz/src/audit.ts` | `AuthzAuditSink`, `memoryAuditSink()`, `consoleAuditSink()` |
|
|
| `packages/authz/src/engine.ts` | `createAuthzResolver()` — effective permissions, precedence, fail-closed |
|
|
| `packages/authz/src/middleware.ts` | `authzMiddleware()`, `can()`, `decideFor()`, `guardPermission()` |
|
|
| `packages/authz/src/db.ts` | `dbPermissionStore(db)` — subpath export `@wrnexus/authz/db` |
|
|
| `packages/authz/src/migrations.ts` | `authzMigrationSql(dialect)` — DDL for the two tables |
|
|
| `packages/authz/src/codegen.ts` | `generatePermissionTypes(catalog)` — emits the `Permission`/`Role` unions |
|
|
| `packages/authz/test/store-conformance.ts` | Shared suite both store adapters must pass (not a `.test.ts`) |
|
|
| `packages/cli/src/authz.ts` | `runAuthzCommand(root, sub, args)` for `list` / `init` / `generate` |
|
|
|
|
**Modified:**
|
|
|
|
| File | Change |
|
|
| -------------------------------- | --------------------------------------------------------------- |
|
|
| `packages/authz/src/index.ts` | Re-export the new surface |
|
|
| `packages/authz/src/advanced.ts` | `authorizeDecision` gains `{ exposeReason }`, defaulting to off |
|
|
| `packages/authz/package.json` | Add `./db` subpath export |
|
|
| `packages/router/src/index.ts` | Discover `app/authz/*.{ts,js}` into `router.authz` |
|
|
| `packages/cli/src/index.ts` | Dispatch `case "authz"` |
|
|
| `docs/public-api-0.8.json` | Regenerated baseline |
|
|
|
|
---
|
|
|
|
## Task 1: Types and registry
|
|
|
|
**Files:**
|
|
|
|
- Create: `packages/authz/src/types.ts`
|
|
- Create: `packages/authz/src/registry.ts`
|
|
- Test: `packages/authz/test/registry.test.ts`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: `DecisionPolicy` from `./advanced.ts`
|
|
- Produces: `AuthzScope`, `PermissionMeta`, `AuthzModule`, `AuthzCatalog`, `SubjectAssignments`, `defineAuthz(module: AuthzModule): AuthzModule`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `packages/authz/test/registry.test.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, test } from "bun:test";
|
|
import { defineAuthz } from "../src/registry.ts";
|
|
|
|
describe("defineAuthz", () => {
|
|
test("returns a frozen module", () => {
|
|
const mod = defineAuthz({
|
|
permissions: { "post:read": { title: "View posts" } },
|
|
roles: { editor: ["post:*"] },
|
|
});
|
|
expect(Object.isFrozen(mod)).toBe(true);
|
|
expect(mod.permissions!["post:read"]!.title).toBe("View posts");
|
|
expect(mod.roles!.editor).toEqual(["post:*"]);
|
|
});
|
|
|
|
test("defaults missing sections to empty objects", () => {
|
|
const mod = defineAuthz({});
|
|
expect(mod.permissions).toEqual({});
|
|
expect(mod.roles).toEqual({});
|
|
expect(mod.policies).toEqual({});
|
|
expect(mod.attributes).toEqual({});
|
|
expect(mod.bindings).toEqual({});
|
|
});
|
|
|
|
test("rejects a permission id that is not colon-namespaced lowercase", () => {
|
|
expect(() => defineAuthz({ permissions: { "Post Read": {} } })).toThrow(/permission id/i);
|
|
expect(() => defineAuthz({ permissions: { "post:*": {} } })).toThrow(/wildcard/i);
|
|
});
|
|
|
|
test("rejects a role granting an unknown-shaped entry", () => {
|
|
expect(() => defineAuthz({ roles: { editor: [""] } })).toThrow(/role 'editor'/i);
|
|
});
|
|
|
|
test("rejects a binding naming a policy that is not declared", () => {
|
|
expect(() =>
|
|
defineAuthz({
|
|
permissions: { "post:write": {} },
|
|
bindings: { "post:write": ["missingPolicy"] },
|
|
}),
|
|
).toThrow(/missingPolicy/);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `bun test packages/authz/test/registry.test.ts`
|
|
Expected: FAIL — cannot resolve `../src/registry.ts`
|
|
|
|
- [ ] **Step 3: Write the types**
|
|
|
|
Create `packages/authz/src/types.ts`:
|
|
|
|
```ts
|
|
import type { DecisionPolicy } from "./advanced.ts";
|
|
|
|
/** Narrows an assignment to a tenant. Absent means a global assignment. */
|
|
export interface AuthzScope {
|
|
tenantId?: string;
|
|
}
|
|
|
|
export interface PermissionMeta {
|
|
title?: string;
|
|
description?: string;
|
|
risk?: "low" | "medium" | "high";
|
|
/** Granted to anonymous subjects. Every other permission denies without a user. */
|
|
public?: boolean;
|
|
}
|
|
|
|
export interface AttributeMeta {
|
|
description?: string;
|
|
}
|
|
|
|
/** One `app/authz/<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[];
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Write the registry**
|
|
|
|
Create `packages/authz/src/registry.ts`:
|
|
|
|
```ts
|
|
import type { AuthzModule } from "./types.ts";
|
|
|
|
const PERMISSION_ID = /^[a-z0-9]+(?::[a-z0-9-]+)+$/;
|
|
|
|
/**
|
|
* Validate and freeze one authorization declaration. Called from
|
|
* `app/authz/<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 });
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run test to verify it passes**
|
|
|
|
Run: `bun test packages/authz/test/registry.test.ts`
|
|
Expected: PASS, 5 tests
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add packages/authz/src/types.ts packages/authz/src/registry.ts packages/authz/test/registry.test.ts
|
|
git commit -m "feat(authz): add defineAuthz declaration registry"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Catalog merge and conflict detection
|
|
|
|
**Files:**
|
|
|
|
- Create: `packages/authz/src/catalog.ts`
|
|
- Test: `packages/authz/test/catalog.test.ts`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: `AuthzModule`, `AuthzCatalog` from `./types.ts`; `defineAuthz` from `./registry.ts`
|
|
- Produces: `mergeCatalogs(sources: CatalogSource[]): AuthzCatalog`, `interface CatalogSource { source: string; module: AuthzModule }`, `emptyCatalog(): AuthzCatalog`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `packages/authz/test/catalog.test.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, test } from "bun:test";
|
|
import { defineAuthz } from "../src/registry.ts";
|
|
import { emptyCatalog, mergeCatalogs } from "../src/catalog.ts";
|
|
|
|
describe("mergeCatalogs", () => {
|
|
test("merges disjoint modules", () => {
|
|
const catalog = mergeCatalogs([
|
|
{ source: "a.ts", module: defineAuthz({ permissions: { "post:read": {} } }) },
|
|
{ source: "b.ts", module: defineAuthz({ permissions: { "user:read": {} } }) },
|
|
]);
|
|
expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "user:read"]);
|
|
});
|
|
|
|
test("re-declaring a permission with deep-equal metadata is a no-op", () => {
|
|
const meta = { title: "View posts", risk: "low" as const };
|
|
const catalog = mergeCatalogs([
|
|
{ source: "a.ts", module: defineAuthz({ permissions: { "post:read": meta } }) },
|
|
{ source: "b.ts", module: defineAuthz({ permissions: { "post:read": { ...meta } } }) },
|
|
]);
|
|
expect(catalog.permissions.size).toBe(1);
|
|
});
|
|
|
|
test("conflicting metadata is a boot error naming both files", () => {
|
|
expect(() =>
|
|
mergeCatalogs([
|
|
{ source: "a.ts", module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }) },
|
|
{ source: "b.ts", module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }) },
|
|
]),
|
|
).toThrow(/a\.ts.*b\.ts|b\.ts.*a\.ts/s);
|
|
});
|
|
|
|
test("conflicting role definitions are a boot error", () => {
|
|
expect(() =>
|
|
mergeCatalogs([
|
|
{ source: "a.ts", module: defineAuthz({ roles: { editor: ["post:read"] } }) },
|
|
{ source: "b.ts", module: defineAuthz({ roles: { editor: ["post:write"] } }) },
|
|
]),
|
|
).toThrow(/editor/);
|
|
});
|
|
|
|
test("bindings for the same permission union across modules", () => {
|
|
const p1 = defineAuthz({
|
|
permissions: { "post:write": {} },
|
|
policies: { ownsPost: async () => ({ allowed: true }) },
|
|
bindings: { "post:write": ["ownsPost"] },
|
|
});
|
|
const p2 = defineAuthz({
|
|
policies: { notLocked: async () => ({ allowed: true }) },
|
|
bindings: { "post:write": ["notLocked"] },
|
|
});
|
|
const catalog = mergeCatalogs([
|
|
{ source: "a.ts", module: p1 },
|
|
{ source: "b.ts", module: p2 },
|
|
]);
|
|
expect([...catalog.bindings.get("post:write")!].sort()).toEqual(["notLocked", "ownsPost"]);
|
|
});
|
|
|
|
test("a binding referencing a policy no module declares is a boot error", () => {
|
|
expect(() =>
|
|
mergeCatalogs([
|
|
{
|
|
source: "a.ts",
|
|
module: { permissions: { "post:write": {} }, bindings: { "post:write": ["ghost"] } },
|
|
},
|
|
]),
|
|
).toThrow(/ghost/);
|
|
});
|
|
|
|
test("the merged catalog is frozen", () => {
|
|
const catalog = mergeCatalogs([]);
|
|
expect(() => (catalog.permissions as Map<string, never>).set("x:y", {} as never)).toThrow();
|
|
});
|
|
|
|
test("emptyCatalog has no entries", () => {
|
|
expect(emptyCatalog().permissions.size).toBe(0);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `bun test packages/authz/test/catalog.test.ts`
|
|
Expected: FAIL — cannot resolve `../src/catalog.ts`
|
|
|
|
- [ ] **Step 3: Write the implementation**
|
|
|
|
Create `packages/authz/src/catalog.ts`:
|
|
|
|
```ts
|
|
import type { AttributeMeta, AuthzCatalog, AuthzModule, PermissionMeta } from "./types.ts";
|
|
import type { DecisionPolicy } from "./advanced.ts";
|
|
|
|
export interface CatalogSource {
|
|
/** File or package that declared this module, used in conflict messages. */
|
|
source: string;
|
|
module: AuthzModule;
|
|
}
|
|
|
|
/** Structural equality for declaration metadata. Key order is irrelevant. */
|
|
function deepEqual(a: unknown, b: unknown): boolean {
|
|
if (Object.is(a, b)) return true;
|
|
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
const left = a as Record<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);
|
|
permissions.set(id, meta);
|
|
}
|
|
for (const [name, grants] of Object.entries(module.roles ?? {})) {
|
|
claim("role", name, source, roles.get(name), grants);
|
|
roles.set(name, grants);
|
|
}
|
|
for (const [name, policy] of Object.entries(module.policies ?? {})) {
|
|
// Two closures are never deep-equal, so identity is the only sane test.
|
|
const existing = policies.get(name);
|
|
if (existing && existing !== policy) {
|
|
throw new Error(
|
|
`WRN-AUTHZ-CONFLICT: policy '${name}' is declared differently in ${origin.get(`policy:${name}`)} and ${source}.`,
|
|
);
|
|
}
|
|
origin.set(`policy:${name}`, source);
|
|
policies.set(name, policy);
|
|
}
|
|
for (const [name, meta] of Object.entries(module.attributes ?? {})) {
|
|
claim("attribute", name, source, attributes.get(name), meta);
|
|
attributes.set(name, meta);
|
|
}
|
|
for (const [permission, names] of Object.entries(module.bindings ?? {})) {
|
|
const set = bindings.get(permission) ?? new Set<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, [...v]] as [string, readonly string[]])),
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `bun test packages/authz/test/catalog.test.ts`
|
|
Expected: PASS, 8 tests
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/authz/src/catalog.ts packages/authz/test/catalog.test.ts
|
|
git commit -m "feat(authz): merge declaration modules into a frozen catalog"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: PermissionStore interface, memory adapter, conformance suite
|
|
|
|
**Files:**
|
|
|
|
- Create: `packages/authz/src/store.ts`
|
|
- Create: `packages/authz/test/store-conformance.ts`
|
|
- Test: `packages/authz/test/store-memory.test.ts`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: `AuthzScope`, `SubjectAssignments` from `./types.ts`
|
|
- Produces: `PermissionStore`, `memoryPermissionStore(): PermissionStore`, `runStoreConformance(name: string, makeStore: () => Promise<PermissionStore>)`
|
|
|
|
- [ ] **Step 1: Write the conformance suite**
|
|
|
|
Create `packages/authz/test/store-conformance.ts`. This is imported by adapter tests; it has no `.test.ts` suffix so Bun does not run it directly.
|
|
|
|
```ts
|
|
import { beforeEach, describe, expect, test } from "bun:test";
|
|
import type { PermissionStore } from "../src/store.ts";
|
|
|
|
/**
|
|
* Every PermissionStore adapter must pass this suite, so the memory and db
|
|
* implementations cannot drift apart.
|
|
*/
|
|
export function runStoreConformance(name: string, makeStore: () => Promise<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("listSubjects returns everyone with an assignment in scope", async () => {
|
|
await store.assignRole("u1", "editor", { tenantId: "t1" });
|
|
await store.assignRole("u2", "editor", { tenantId: "t1" });
|
|
await store.assignRole("u3", "editor", { tenantId: "t2" });
|
|
expect((await store.listSubjects({ tenantId: "t1" })).sort()).toEqual(["u1", "u2"]);
|
|
});
|
|
|
|
test("listSubjects with no scope returns global assignees only", async () => {
|
|
await store.assignRole("g1", "viewer");
|
|
await store.assignRole("s1", "editor", { tenantId: "t1" });
|
|
expect(await store.listSubjects()).toEqual(["g1"]);
|
|
});
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write the memory adapter test**
|
|
|
|
Create `packages/authz/test/store-memory.test.ts`:
|
|
|
|
```ts
|
|
import { memoryPermissionStore } from "../src/store.ts";
|
|
import { runStoreConformance } from "./store-conformance.ts";
|
|
|
|
runStoreConformance("memory", async () => memoryPermissionStore());
|
|
```
|
|
|
|
- [ ] **Step 3: Run test to verify it fails**
|
|
|
|
Run: `bun test packages/authz/test/store-memory.test.ts`
|
|
Expected: FAIL — cannot resolve `../src/store.ts`
|
|
|
|
- [ ] **Step 4: Write the implementation**
|
|
|
|
Create `packages/authz/src/store.ts`:
|
|
|
|
```ts
|
|
import type { AuthzScope, SubjectAssignments } from "./types.ts";
|
|
|
|
export type GrantEffect = "allow" | "deny";
|
|
|
|
export interface PermissionStore {
|
|
assignmentsFor(subjectId: string, scope?: AuthzScope): Promise<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. */
|
|
export function scopeKey(scope?: AuthzScope): string {
|
|
return scope?.tenantId ?? "";
|
|
}
|
|
|
|
interface Row {
|
|
subjectId: string;
|
|
scope: string;
|
|
}
|
|
interface RoleRow extends Row {
|
|
role: string;
|
|
}
|
|
interface GrantRow extends Row {
|
|
permission: string;
|
|
effect: GrantEffect;
|
|
}
|
|
|
|
export function memoryPermissionStore(): PermissionStore {
|
|
const roles: RoleRow[] = [];
|
|
const grants: GrantRow[] = [];
|
|
|
|
// A request inside tenant t sees global assignments plus t's own.
|
|
const visible = (row: Row, key: string) => row.scope === "" || row.scope === key;
|
|
|
|
return {
|
|
async assignmentsFor(subjectId, scope) {
|
|
const key = scopeKey(scope);
|
|
const mine = (row: Row) => row.subjectId === subjectId && visible(row, key);
|
|
const matched = grants.filter(mine);
|
|
return {
|
|
roles: roles.filter(mine).map((row) => row.role),
|
|
grants: matched.filter((row) => row.effect === "allow").map((row) => row.permission),
|
|
denies: matched.filter((row) => row.effect === "deny").map((row) => row.permission),
|
|
};
|
|
},
|
|
async assignRole(subjectId, role, scope) {
|
|
const key = scopeKey(scope);
|
|
if (roles.some((r) => r.subjectId === subjectId && r.scope === key && r.role === role))
|
|
return;
|
|
roles.push({ subjectId, scope: key, role });
|
|
},
|
|
async revokeRole(subjectId, role, scope) {
|
|
const key = scopeKey(scope);
|
|
const at = roles.findIndex(
|
|
(r) => r.subjectId === subjectId && r.scope === key && r.role === role,
|
|
);
|
|
if (at !== -1) roles.splice(at, 1);
|
|
},
|
|
async grant(subjectId, permission, effect, scope) {
|
|
const key = scopeKey(scope);
|
|
const at = grants.findIndex(
|
|
(g) => g.subjectId === subjectId && g.scope === key && g.permission === permission,
|
|
);
|
|
if (at !== -1) grants.splice(at, 1);
|
|
grants.push({ subjectId, scope: key, permission, effect });
|
|
},
|
|
async revokeGrant(subjectId, permission, scope) {
|
|
const key = scopeKey(scope);
|
|
const at = grants.findIndex(
|
|
(g) => g.subjectId === subjectId && g.scope === key && g.permission === permission,
|
|
);
|
|
if (at !== -1) grants.splice(at, 1);
|
|
},
|
|
async listSubjects(scope) {
|
|
const key = scopeKey(scope);
|
|
const ids = new Set<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];
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run test to verify it passes**
|
|
|
|
Run: `bun test packages/authz/test/store-memory.test.ts`
|
|
Expected: PASS, 13 tests
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add packages/authz/src/store.ts packages/authz/test/store-conformance.ts packages/authz/test/store-memory.test.ts
|
|
git commit -m "feat(authz): add PermissionStore contract with memory adapter and conformance suite"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Cached store decorator
|
|
|
|
**Files:**
|
|
|
|
- Modify: `packages/authz/src/store.ts` (append)
|
|
- Test: `packages/authz/test/store-cached.test.ts`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: `PermissionStore`, `scopeKey` from `./store.ts`
|
|
- Produces: `cachedPermissionStore(inner: PermissionStore, options?: { ttlMs?: number; max?: number }): CachedPermissionStore`, `interface CachedPermissionStore extends PermissionStore { invalidate(subjectId: string, scope?: AuthzScope): void; invalidateAll(): void }`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `packages/authz/test/store-cached.test.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, test } from "bun:test";
|
|
import { cachedPermissionStore, memoryPermissionStore } from "../src/store.ts";
|
|
import { runStoreConformance } from "./store-conformance.ts";
|
|
|
|
// A cache must not change observable behaviour: writes invalidate internally.
|
|
runStoreConformance("cached(memory)", async () => cachedPermissionStore(memoryPermissionStore()));
|
|
|
|
describe("cachedPermissionStore", () => {
|
|
test("serves a repeat read from cache", async () => {
|
|
const inner = memoryPermissionStore();
|
|
let reads = 0;
|
|
const counting = {
|
|
...inner,
|
|
assignmentsFor: (id: string, scope?: { tenantId?: string }) => {
|
|
reads++;
|
|
return inner.assignmentsFor(id, scope);
|
|
},
|
|
};
|
|
const store = cachedPermissionStore(counting, { ttlMs: 60_000 });
|
|
await store.assignmentsFor("u1");
|
|
await store.assignmentsFor("u1");
|
|
expect(reads).toBe(1);
|
|
});
|
|
|
|
test("a write invalidates that subject", async () => {
|
|
const store = cachedPermissionStore(memoryPermissionStore(), { ttlMs: 60_000 });
|
|
await store.assignmentsFor("u1");
|
|
await store.assignRole("u1", "editor");
|
|
expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
|
|
});
|
|
|
|
test("invalidate() drops a cached subject", async () => {
|
|
const inner = memoryPermissionStore();
|
|
const store = cachedPermissionStore(inner, { ttlMs: 60_000 });
|
|
await store.assignmentsFor("u1");
|
|
await inner.assignRole("u1", "editor"); // behind the cache's back
|
|
expect((await store.assignmentsFor("u1")).roles).toEqual([]);
|
|
store.invalidate("u1");
|
|
expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
|
|
});
|
|
|
|
test("entries expire after ttlMs", async () => {
|
|
const inner = memoryPermissionStore();
|
|
const store = cachedPermissionStore(inner, { ttlMs: 1 });
|
|
await store.assignmentsFor("u1");
|
|
await inner.assignRole("u1", "editor");
|
|
await Bun.sleep(5);
|
|
expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
|
|
});
|
|
|
|
test("cache is bounded by max", async () => {
|
|
const store = cachedPermissionStore(memoryPermissionStore(), { ttlMs: 60_000, max: 2 });
|
|
await store.assignmentsFor("a");
|
|
await store.assignmentsFor("b");
|
|
await store.assignmentsFor("c");
|
|
expect(store.size()).toBeLessThanOrEqual(2);
|
|
});
|
|
|
|
test("a global write invalidates the subject in every tenant", async () => {
|
|
const inner = memoryPermissionStore();
|
|
const store = cachedPermissionStore(inner, { ttlMs: 60_000 });
|
|
await store.assignmentsFor("u1", { tenantId: "t1" }); // warm the tenant entry
|
|
await store.assignRole("u1", "editor"); // global write
|
|
// Global roles are visible inside every tenant, so the cached t1 entry
|
|
// must not survive this write.
|
|
expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]);
|
|
});
|
|
|
|
test("cache keys cannot collide across subject/tenant boundaries", async () => {
|
|
const inner = memoryPermissionStore();
|
|
const store = cachedPermissionStore(inner, { ttlMs: 60_000 });
|
|
// Naive "scope + separator + subject" concatenation makes these two pairs
|
|
// produce the same key, serving one subject the other's permissions.
|
|
await inner.assignRole("b�c", "editor", { tenantId: "a" });
|
|
expect((await store.assignmentsFor("b�c", { tenantId: "a" })).roles).toEqual(["editor"]);
|
|
expect((await store.assignmentsFor("c", { tenantId: "a�b" })).roles).toEqual([]);
|
|
});
|
|
|
|
test("scoped and global reads cache separately", async () => {
|
|
const inner = memoryPermissionStore();
|
|
const store = cachedPermissionStore(inner, { ttlMs: 60_000 });
|
|
await inner.assignRole("u1", "editor", { tenantId: "t1" });
|
|
expect((await store.assignmentsFor("u1")).roles).toEqual([]);
|
|
expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `bun test packages/authz/test/store-cached.test.ts`
|
|
Expected: FAIL — `cachedPermissionStore` is not exported
|
|
|
|
- [ ] **Step 3: Append the implementation to `packages/authz/src/store.ts`**
|
|
|
|
```ts
|
|
export interface CachedPermissionStore extends PermissionStore {
|
|
/** Drop one subject. Call after changing roles out of band. */
|
|
invalidate(subjectId: string, scope?: AuthzScope): void;
|
|
invalidateAll(): void;
|
|
/** Cached entry count, for tests and diagnostics. */
|
|
size(): number;
|
|
}
|
|
|
|
export interface CacheOptions {
|
|
ttlMs?: number;
|
|
max?: number;
|
|
}
|
|
|
|
/**
|
|
* Caches assignment reads. Writes through this decorator invalidate the
|
|
* affected subject immediately; changes made directly against the inner store
|
|
* need an explicit `invalidate()` call rather than waiting out the TTL.
|
|
*/
|
|
export function cachedPermissionStore(
|
|
inner: PermissionStore,
|
|
options: CacheOptions = {},
|
|
): CachedPermissionStore {
|
|
const ttlMs = options.ttlMs ?? 5_000;
|
|
const max = options.max ?? 1_000;
|
|
const entries = new Map<string, { at: number; value: SubjectAssignments }>();
|
|
|
|
// Subject and tenant ids are unconstrained strings, so the key must be
|
|
// unambiguous: concatenating around a separator lets ("a", "b<sep>c") and
|
|
// ("a<sep>b", "c") collide, which would serve one subject another's
|
|
// permissions. JSON encoding escapes the components.
|
|
const cacheKey = (subjectId: string, scope?: AuthzScope) =>
|
|
JSON.stringify([scopeKey(scope), subjectId]);
|
|
// Track subjects separately rather than pattern-matching key strings, so a
|
|
// global write can find every tenant entry without substring guesswork.
|
|
const bySubject = new Map<string, Set<string>>();
|
|
const drop = (subjectId: string, scope?: AuthzScope) => {
|
|
// A global write changes what every tenant sees for that subject.
|
|
if (scopeKey(scope) === "") {
|
|
for (const key of bySubject.get(subjectId) ?? []) entries.delete(key);
|
|
bySubject.delete(subjectId);
|
|
return;
|
|
}
|
|
const key = cacheKey(subjectId, scope);
|
|
entries.delete(key);
|
|
bySubject.get(subjectId)?.delete(key);
|
|
};
|
|
|
|
return {
|
|
async assignmentsFor(subjectId, scope) {
|
|
const key = cacheKey(subjectId, scope);
|
|
const hit = entries.get(key);
|
|
if (hit && Date.now() - hit.at < ttlMs) return hit.value;
|
|
const value = await inner.assignmentsFor(subjectId, scope);
|
|
if (entries.size >= max) {
|
|
const oldest = entries.keys().next().value!;
|
|
entries.delete(oldest);
|
|
for (const keys of bySubject.values()) keys.delete(oldest);
|
|
}
|
|
entries.set(key, { at: Date.now(), value });
|
|
let keys = bySubject.get(subjectId);
|
|
if (!keys) bySubject.set(subjectId, (keys = new Set()));
|
|
keys.add(key);
|
|
return value;
|
|
},
|
|
async assignRole(subjectId, role, scope) {
|
|
await inner.assignRole(subjectId, role, scope);
|
|
drop(subjectId, scope);
|
|
},
|
|
async revokeRole(subjectId, role, scope) {
|
|
await inner.revokeRole(subjectId, role, scope);
|
|
drop(subjectId, scope);
|
|
},
|
|
async grant(subjectId, permission, effect, scope) {
|
|
await inner.grant(subjectId, permission, effect, scope);
|
|
drop(subjectId, scope);
|
|
},
|
|
async revokeGrant(subjectId, permission, scope) {
|
|
await inner.revokeGrant(subjectId, permission, scope);
|
|
drop(subjectId, scope);
|
|
},
|
|
listSubjects: (scope) => inner.listSubjects(scope),
|
|
invalidate: drop,
|
|
invalidateAll: () => {
|
|
entries.clear();
|
|
bySubject.clear();
|
|
},
|
|
size: () => entries.size,
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `bun test packages/authz/test/store-cached.test.ts`
|
|
Expected: PASS — 13 conformance tests plus 6 cache tests
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/authz/src/store.ts packages/authz/test/store-cached.test.ts
|
|
git commit -m "feat(authz): add caching decorator for PermissionStore"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Audit sink
|
|
|
|
**Files:**
|
|
|
|
- Create: `packages/authz/src/audit.ts`
|
|
- Test: `packages/authz/test/audit.test.ts`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: `AuthzScope` from `./types.ts`
|
|
- Produces: `AuthzAuditEvent`, `AuthzAuditSink`, `memoryAuditSink(): MemoryAuditSink`, `consoleAuditSink(): AuthzAuditSink`, `safeRecord(sink, event): void`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `packages/authz/test/audit.test.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, test } from "bun:test";
|
|
import { memoryAuditSink, safeRecord } from "../src/audit.ts";
|
|
|
|
describe("audit sink", () => {
|
|
test("memoryAuditSink collects events", () => {
|
|
const sink = memoryAuditSink();
|
|
sink.record({ permission: "post:read", allowed: true, at: 1 });
|
|
expect(sink.events).toHaveLength(1);
|
|
expect(sink.events[0]!.permission).toBe("post:read");
|
|
});
|
|
|
|
test("safeRecord swallows sink failures", () => {
|
|
const exploding = {
|
|
record() {
|
|
throw new Error("sink is down");
|
|
},
|
|
};
|
|
// Auditing must never break a request.
|
|
expect(() => safeRecord(exploding, { permission: "p:x", allowed: false, at: 1 })).not.toThrow();
|
|
});
|
|
|
|
test("safeRecord swallows async sink rejections", async () => {
|
|
const rejecting = { record: async () => Promise.reject(new Error("later")) };
|
|
expect(() => safeRecord(rejecting, { permission: "p:x", allowed: false, at: 1 })).not.toThrow();
|
|
await Bun.sleep(1);
|
|
});
|
|
|
|
test("safeRecord tolerates an undefined sink", () => {
|
|
expect(() => safeRecord(undefined, { permission: "p:x", allowed: true, at: 1 })).not.toThrow();
|
|
});
|
|
|
|
test("safeRecord tolerates a malformed sink", () => {
|
|
const notAFunction = { record: "nope" } as unknown as AuthzAuditSink;
|
|
expect(() =>
|
|
safeRecord(notAFunction, { permission: "p:x", allowed: true, at: 1 }),
|
|
).not.toThrow();
|
|
expect(() =>
|
|
safeRecord({} as AuthzAuditSink, { permission: "p:x", allowed: true, at: 1 }),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("memoryAuditSink.clear empties the buffer", () => {
|
|
const sink = memoryAuditSink();
|
|
sink.record({ permission: "p:x", allowed: true, at: 1 });
|
|
sink.clear();
|
|
expect(sink.events).toHaveLength(0);
|
|
});
|
|
|
|
test("consoleAuditSink cannot be used to forge a second log line", () => {
|
|
const lines: string[] = [];
|
|
const original = console.info;
|
|
console.info = (...args: unknown[]) => void lines.push(args.join(" "));
|
|
try {
|
|
consoleAuditSink().record({
|
|
subjectId: "u1\n[wrnexus:authz] allow admin:everything subject=root",
|
|
permission: "post:read",
|
|
allowed: false,
|
|
reason: "nope\r\ninjected",
|
|
at: 1,
|
|
});
|
|
} finally {
|
|
console.info = original;
|
|
}
|
|
// One event must produce exactly one line, with no embedded newlines.
|
|
expect(lines).toHaveLength(1);
|
|
expect(lines[0]).not.toContain("\n");
|
|
expect(lines[0]).not.toContain("\r");
|
|
});
|
|
});
|
|
```
|
|
|
|
The test file's imports must include `consoleAuditSink` and the `AuthzAuditSink` type
|
|
alongside `memoryAuditSink` and `safeRecord`.
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `bun test packages/authz/test/audit.test.ts`
|
|
Expected: FAIL — cannot resolve `../src/audit.ts`
|
|
|
|
- [ ] **Step 3: Write the implementation**
|
|
|
|
Create `packages/authz/src/audit.ts`:
|
|
|
|
```ts
|
|
import type { AuthzScope } from "./types.ts";
|
|
|
|
export interface AuthzAuditEvent {
|
|
subjectId?: string;
|
|
scope?: AuthzScope;
|
|
permission: string;
|
|
allowed: boolean;
|
|
reason?: string;
|
|
policy?: string;
|
|
/** Epoch milliseconds. */
|
|
at: number;
|
|
}
|
|
|
|
export interface AuthzAuditSink {
|
|
record(event: AuthzAuditEvent): void | Promise<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 CR/LF and other control characters before interpolating.
|
|
*/
|
|
function logSafe(value: string): string {
|
|
let out = "";
|
|
for (const character of value) {
|
|
const code = character.codePointAt(0)!;
|
|
// C0 + DEL, plus NEL and the Unicode line/paragraph separators, which some
|
|
// log shippers and JSON consumers also treat as line terminators.
|
|
const isLineBreaking =
|
|
code < 0x20 || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029;
|
|
out += isLineBreaking ? " " : character;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function consoleAuditSink(): AuthzAuditSink {
|
|
return {
|
|
record(event) {
|
|
const verdict = event.allowed ? "allow" : "deny";
|
|
console.info(
|
|
`[wrnexus:authz] ${verdict} ${logSafe(event.permission)} ` +
|
|
`subject=${logSafe(event.subjectId ?? "anonymous")}` +
|
|
`${event.scope?.tenantId ? ` tenant=${logSafe(event.scope.tenantId)}` : ""}` +
|
|
`${event.reason ? ` reason=${logSafe(event.reason)}` : ""}` +
|
|
`${event.policy ? ` policy=${logSafe(event.policy)}` : ""}`,
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Record without ever letting a sink failure escape into the request path. */
|
|
export function safeRecord(sink: AuthzAuditSink | undefined, event: AuthzAuditEvent): void {
|
|
if (!sink) return;
|
|
try {
|
|
const result = sink.record(event);
|
|
if (result instanceof Promise) {
|
|
result.catch((error) => console.warn("[wrnexus:authz] audit sink failed", error));
|
|
}
|
|
} catch (error) {
|
|
console.warn("[wrnexus:authz] audit sink failed", error);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `bun test packages/authz/test/audit.test.ts`
|
|
Expected: PASS, 4 tests
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/authz/src/audit.ts packages/authz/test/audit.test.ts
|
|
git commit -m "feat(authz): add pluggable authorization audit sink"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Resolution engine
|
|
|
|
**Files:**
|
|
|
|
- Create: `packages/authz/src/engine.ts`
|
|
- Test: `packages/authz/test/engine.test.ts`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: `AuthzCatalog`, `AuthzScope`, `SubjectAssignments` from `./types.ts`; `PermissionStore` from `./store.ts`; `AuthzAuditSink`, `safeRecord` from `./audit.ts`; `AuthorizationDecision` from `./advanced.ts`
|
|
- Produces: `createAuthzResolver(options: AuthzResolverOptions): AuthzResolver` with `AuthzResolver { permissionsFor(subjectId, scope?): Promise<Set<string>>; decide(input: DecideInput): Promise<AuthorizationDecision> }`, `expandRoles(catalog, roles): Set<string>`, `permissionMatches(granted: Set<string>, permission: string): boolean`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `packages/authz/test/engine.test.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, test } from "bun:test";
|
|
import { defineAuthz } from "../src/registry.ts";
|
|
import { mergeCatalogs } from "../src/catalog.ts";
|
|
import { memoryPermissionStore } from "../src/store.ts";
|
|
import { memoryAuditSink } from "../src/audit.ts";
|
|
import { createAuthzResolver, expandRoles, permissionMatches } from "../src/engine.ts";
|
|
|
|
const catalog = mergeCatalogs([
|
|
{
|
|
source: "test.ts",
|
|
module: defineAuthz({
|
|
permissions: {
|
|
"post:read": { public: true },
|
|
"post:write": {},
|
|
"post:delete": { risk: "high" },
|
|
"post:comment:delete": {},
|
|
},
|
|
roles: {
|
|
editor: ["post:*"],
|
|
moderator: ["post:comment:*"],
|
|
admin: ["role:editor", "post:delete"],
|
|
cyclic: ["role:cyclic", "post:read"],
|
|
},
|
|
policies: {
|
|
ownsPost: async (subject: { id?: string }, resource?: { authorId?: string }) =>
|
|
resource?.authorId === subject?.id
|
|
? { allowed: true }
|
|
: { allowed: false, reason: "not the author", policy: "ownsPost" },
|
|
explodes: async () => {
|
|
throw new Error("policy blew up");
|
|
},
|
|
},
|
|
bindings: { "post:write": ["ownsPost"] },
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const make = (store = memoryPermissionStore(), audit = memoryAuditSink()) => ({
|
|
store,
|
|
audit,
|
|
resolver: createAuthzResolver({ catalog, store, audit, strict: false }),
|
|
});
|
|
|
|
describe("expandRoles", () => {
|
|
test("expands wildcards and role inheritance", () => {
|
|
expect([...expandRoles(catalog, ["admin"])].sort()).toEqual(["post:*", "post:delete"]);
|
|
});
|
|
test("terminates on cyclic inheritance", () => {
|
|
expect([...expandRoles(catalog, ["cyclic"])]).toEqual(["post:read"]);
|
|
});
|
|
});
|
|
|
|
describe("permissionMatches", () => {
|
|
test("matches exact, root wildcard, and every namespace depth", () => {
|
|
expect(permissionMatches(new Set(["post:read"]), "post:read")).toBe(true);
|
|
expect(permissionMatches(new Set(["*"]), "anything:at:all")).toBe(true);
|
|
expect(permissionMatches(new Set(["post:*"]), "post:comment:delete")).toBe(true);
|
|
expect(permissionMatches(new Set(["post:comment:*"]), "post:comment:delete")).toBe(true);
|
|
expect(permissionMatches(new Set(["post:comment:*"]), "post:write")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("createAuthzResolver.decide", () => {
|
|
test("allows a public permission for an anonymous subject", async () => {
|
|
const { resolver } = make();
|
|
const result = await resolver.decide({ subject: null, permission: "post:read" });
|
|
expect(result.allowed).toBe(true);
|
|
});
|
|
|
|
test("denies a non-public permission for an anonymous subject", async () => {
|
|
const { resolver } = make();
|
|
const result = await resolver.decide({ subject: null, permission: "post:delete" });
|
|
expect(result.allowed).toBe(false);
|
|
});
|
|
|
|
test("allows via a role-derived wildcard", async () => {
|
|
const { store, resolver } = make();
|
|
await store.assignRole("u1", "moderator");
|
|
const result = await resolver.decide({
|
|
subject: { id: "u1" },
|
|
permission: "post:comment:delete",
|
|
});
|
|
expect(result.allowed).toBe(true);
|
|
});
|
|
|
|
test("an explicit deny beats a role and beats '*'", async () => {
|
|
const { store, resolver } = make();
|
|
await store.assignRole("u1", "admin");
|
|
await store.grant("u1", "post:delete", "deny");
|
|
const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" });
|
|
expect(result.allowed).toBe(false);
|
|
expect(result.reason).toMatch(/explicit deny/i);
|
|
});
|
|
|
|
test("a bound policy can deny a permission the role grants", async () => {
|
|
const { store, resolver } = make();
|
|
await store.assignRole("u1", "editor");
|
|
const denied = await resolver.decide({
|
|
subject: { id: "u1" },
|
|
permission: "post:write",
|
|
resource: { authorId: "someone-else" },
|
|
});
|
|
expect(denied.allowed).toBe(false);
|
|
expect(denied.policy).toBe("ownsPost");
|
|
|
|
const allowed = await resolver.decide({
|
|
subject: { id: "u1" },
|
|
permission: "post:write",
|
|
resource: { authorId: "u1" },
|
|
});
|
|
expect(allowed.allowed).toBe(true);
|
|
});
|
|
|
|
test("a throwing policy denies rather than escaping", async () => {
|
|
const throwing = mergeCatalogs([
|
|
{
|
|
source: "t.ts",
|
|
module: defineAuthz({
|
|
permissions: { "x:go": {} },
|
|
policies: {
|
|
explodes: async () => {
|
|
throw new Error("boom");
|
|
},
|
|
},
|
|
bindings: { "x:go": ["explodes"] },
|
|
}),
|
|
},
|
|
]);
|
|
const store = memoryPermissionStore();
|
|
await store.grant("u1", "x:go", "allow");
|
|
const resolver = createAuthzResolver({ catalog: throwing, store, strict: false });
|
|
const result = await resolver.decide({ subject: { id: "u1" }, permission: "x:go" });
|
|
expect(result.allowed).toBe(false);
|
|
});
|
|
|
|
test("a store failure denies and does not throw", async () => {
|
|
const broken = {
|
|
...memoryPermissionStore(),
|
|
assignmentsFor: async () => {
|
|
throw new Error("db down");
|
|
},
|
|
};
|
|
const resolver = createAuthzResolver({ catalog, store: broken, strict: false });
|
|
const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:read" });
|
|
expect(result.allowed).toBe(false);
|
|
});
|
|
|
|
test("an unregistered permission denies when strict is off", async () => {
|
|
const { resolver } = make();
|
|
const result = await resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" });
|
|
expect(result.allowed).toBe(false);
|
|
expect(result.reason).toMatch(/not registered/i);
|
|
});
|
|
|
|
test("an unregistered permission throws when strict is on", async () => {
|
|
const resolver = createAuthzResolver({
|
|
catalog,
|
|
store: memoryPermissionStore(),
|
|
strict: true,
|
|
});
|
|
await expect(
|
|
resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" }),
|
|
).rejects.toThrow(/ghost:perm/);
|
|
});
|
|
|
|
test("denials are audited and allows are not, by default", async () => {
|
|
const { store, audit, resolver } = make();
|
|
// moderator, NOT editor: editor holds "post:*", which legitimately grants
|
|
// post:delete, so that call would be an allow and nothing would be audited.
|
|
await store.assignRole("u1", "moderator");
|
|
await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" });
|
|
await resolver.decide({ subject: { id: "u1" }, permission: "post:read" });
|
|
expect(audit.events).toHaveLength(1);
|
|
expect(audit.events[0]!.allowed).toBe(false);
|
|
});
|
|
|
|
test("auditAllows records both verdicts", async () => {
|
|
const store = memoryPermissionStore();
|
|
const audit = memoryAuditSink();
|
|
const resolver = createAuthzResolver({
|
|
catalog,
|
|
store,
|
|
audit,
|
|
strict: false,
|
|
auditAllows: true,
|
|
});
|
|
await resolver.decide({ subject: null, permission: "post:read" });
|
|
expect(audit.events).toHaveLength(1);
|
|
expect(audit.events[0]!.allowed).toBe(true);
|
|
});
|
|
|
|
test("tenant scope selects the right assignments", async () => {
|
|
const { store, resolver } = make();
|
|
await store.assignRole("u1", "editor", { tenantId: "t1" });
|
|
const inside = await resolver.decide({
|
|
subject: { id: "u1" },
|
|
permission: "post:write",
|
|
resource: { authorId: "u1" },
|
|
scope: { tenantId: "t1" },
|
|
});
|
|
const outside = await resolver.decide({
|
|
subject: { id: "u1" },
|
|
permission: "post:write",
|
|
resource: { authorId: "u1" },
|
|
scope: { tenantId: "t2" },
|
|
});
|
|
expect(inside.allowed).toBe(true);
|
|
expect(outside.allowed).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("createAuthzResolver fail-closed regressions", () => {
|
|
const guarded = mergeCatalogs([
|
|
{
|
|
source: "guarded.ts",
|
|
module: defineAuthz({
|
|
permissions: { "feed:view": { public: true }, "x:go": {} },
|
|
policies: {
|
|
never: async () => ({ allowed: false, reason: "always no", policy: "never" }),
|
|
truthy: async () => ({ allowed: "yes" }) as never,
|
|
},
|
|
bindings: { "feed:view": ["never"] },
|
|
}),
|
|
},
|
|
]);
|
|
|
|
test("a public permission still runs its bound policies for anonymous callers", async () => {
|
|
// The least-trusted caller must not receive the weakest evaluation:
|
|
// `public` relaxes the identity requirement, never the policy requirement.
|
|
const resolver = createAuthzResolver({
|
|
catalog: guarded,
|
|
store: memoryPermissionStore(),
|
|
strict: false,
|
|
});
|
|
const anonymous = await resolver.decide({ subject: null, permission: "feed:view" });
|
|
expect(anonymous.allowed).toBe(false);
|
|
expect(anonymous.policy).toBe("never");
|
|
});
|
|
|
|
test("a policy returning a truthy non-boolean denies", async () => {
|
|
const catalog = mergeCatalogs([
|
|
{
|
|
source: "t.ts",
|
|
module: defineAuthz({
|
|
permissions: { "x:go": {} },
|
|
policies: { truthy: async () => ({ allowed: "yes" }) as never },
|
|
bindings: { "x:go": ["truthy"] },
|
|
}),
|
|
},
|
|
]);
|
|
const store = memoryPermissionStore();
|
|
await store.grant("u1", "x:go", "allow");
|
|
const resolver = createAuthzResolver({ catalog, store, strict: false });
|
|
expect((await resolver.decide({ subject: { id: "u1" }, permission: "x:go" })).allowed).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("a binding naming a policy the catalog lacks denies rather than skipping", async () => {
|
|
// Hand-built catalog: mergeCatalogs would reject this, but the resolver
|
|
// accepts any AuthzCatalog and must not grant what the policy guarded.
|
|
const broken = {
|
|
permissions: new Map([["x:go", {}]]),
|
|
roles: new Map(),
|
|
policies: new Map(),
|
|
attributes: new Map(),
|
|
bindings: new Map([["x:go", ["ghost"]]]),
|
|
} as unknown as Parameters<typeof createAuthzResolver>[0]["catalog"];
|
|
const store = memoryPermissionStore();
|
|
await store.grant("u1", "x:go", "allow");
|
|
const resolver = createAuthzResolver({ catalog: broken, store, strict: false });
|
|
expect((await resolver.decide({ subject: { id: "u1" }, permission: "x:go" })).allowed).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("a wildcard deny blocks the whole namespace", async () => {
|
|
const { store, resolver } = make();
|
|
await store.assignRole("u1", "admin");
|
|
await store.grant("u1", "post:*", "deny");
|
|
expect(
|
|
(await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" })).allowed,
|
|
).toBe(false);
|
|
});
|
|
|
|
test("permissionsFor omits denied permissions", async () => {
|
|
const { store, resolver } = make();
|
|
await store.assignRole("u1", "editor");
|
|
await store.grant("u1", "post:*", "deny");
|
|
const effective = await resolver.permissionsFor("u1");
|
|
// The obvious composition must agree with decide().
|
|
expect(permissionMatches(effective, "post:write")).toBe(false);
|
|
});
|
|
|
|
test("a non-string or empty subject id denies instead of falling back to anonymous", async () => {
|
|
const { resolver } = make();
|
|
for (const id of [0, "", null, 123, {}]) {
|
|
const result = await resolver.decide({
|
|
subject: { id } as never,
|
|
permission: "post:read", // public — must still not be reached this way
|
|
});
|
|
if (id === null) continue; // null is genuinely anonymous
|
|
expect(result.allowed).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `bun test packages/authz/test/engine.test.ts`
|
|
Expected: FAIL — cannot resolve `../src/engine.ts`
|
|
|
|
- [ ] **Step 3: Write the implementation**
|
|
|
|
Create `packages/authz/src/engine.ts`:
|
|
|
|
```ts
|
|
import type { AuthorizationDecision } from "./advanced.ts";
|
|
import { safeRecord, type AuthzAuditSink } from "./audit.ts";
|
|
import type { PermissionStore } from "./store.ts";
|
|
import type { AuthzCatalog, AuthzScope } from "./types.ts";
|
|
|
|
export interface AuthzResolverOptions {
|
|
catalog: AuthzCatalog;
|
|
store: PermissionStore;
|
|
audit?: AuthzAuditSink;
|
|
/**
|
|
* Throw on an unregistered permission instead of denying. Defaults to true
|
|
* outside production, so typos surface during development.
|
|
*/
|
|
strict?: boolean;
|
|
/** Record allows as well as denies. Off by default to bound write volume. */
|
|
auditAllows?: boolean;
|
|
}
|
|
|
|
export interface DecideInput {
|
|
subject: { id?: string; [key: string]: unknown } | null | undefined;
|
|
permission: string;
|
|
resource?: unknown;
|
|
scope?: AuthzScope;
|
|
}
|
|
|
|
export interface AuthzResolver {
|
|
/**
|
|
* Effective permissions with denied entries removed — for coarse gating such
|
|
* as hiding a menu section.
|
|
*
|
|
* NOT authoritative. A set of strings cannot express "everything under
|
|
* `post:*` except `post:delete`", so a narrow deny beneath a broad grant is
|
|
* not representable here: the set still contains `post:*` while `decide()`
|
|
* correctly refuses `post:delete`. Gate individual actions with `decide()`
|
|
* (or `can()` / `filterCan()`), never by matching against this set.
|
|
*/
|
|
permissionsFor(subjectId: string, scope?: AuthzScope): Promise<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 {
|
|
return denies.length ? permissionMatches(new Set(denies), permission) : false;
|
|
}
|
|
|
|
function isProduction(): boolean {
|
|
return (process.env.NODE_ENV ?? "development") === "production";
|
|
}
|
|
|
|
export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolver {
|
|
const { catalog, store, audit } = options;
|
|
const strict = options.strict ?? !isProduction();
|
|
|
|
/**
|
|
* Single source of truth for "what does this subject hold?". Returns the raw
|
|
* assignments alongside the effective set, because `decide` reports on the
|
|
* deny that blocked it. Do NOT duplicate this logic in either caller.
|
|
*/
|
|
const loadEffective = async (subjectId: string, scope?: AuthzScope) => {
|
|
const assignments = await store.assignmentsFor(subjectId, scope);
|
|
const granted = expandRoles(catalog, assignments.roles);
|
|
for (const grant of assignments.grants) granted.add(grant);
|
|
return { assignments, granted };
|
|
};
|
|
|
|
/**
|
|
* Effective permissions, denies already removed. Callers compose this with
|
|
* `permissionMatches` to gate menus and admin UI, so it must not report a
|
|
* permission that `decide` would refuse.
|
|
*/
|
|
const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise<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) {
|
|
// A wildcard grant survives only if nothing denies it outright.
|
|
if (!permissionMatches(denySet, entry)) effective.add(entry);
|
|
}
|
|
return effective;
|
|
};
|
|
|
|
const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => {
|
|
if (!result.allowed || options.auditAllows) {
|
|
safeRecord(audit, {
|
|
subjectId: typeof input.subject?.id === "string" ? input.subject.id : undefined,
|
|
scope: input.scope,
|
|
permission: input.permission,
|
|
allowed: result.allowed,
|
|
reason: result.reason,
|
|
policy: result.policy,
|
|
at: Date.now(),
|
|
});
|
|
}
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* Run every policy bound to a permission. Returns a denial, or null to allow.
|
|
* Anonymous callers run this too: `public` relaxes the identity requirement,
|
|
* never the policy requirement.
|
|
*/
|
|
const runPolicies = async (
|
|
input: DecideInput,
|
|
permission: string,
|
|
): Promise<AuthorizationDecision | null> => {
|
|
for (const name of catalog.bindings.get(permission) ?? []) {
|
|
const policy = catalog.policies.get(name);
|
|
if (!policy) {
|
|
// A binding naming a policy the catalog lacks must deny, not skip:
|
|
// silently ignoring it would grant whatever the policy guarded.
|
|
console.error(
|
|
`[wrnexus:authz] binding for '${permission}' names unknown policy '${name}'; denying`,
|
|
);
|
|
return { allowed: false, reason: "Policy unavailable", policy: name };
|
|
}
|
|
try {
|
|
const verdict = await (
|
|
policy as unknown as (
|
|
s: unknown,
|
|
r: unknown,
|
|
) => AuthorizationDecision | Promise<AuthorizationDecision>
|
|
)(input.subject, input.resource);
|
|
// Identity check, not truthiness: {allowed: "yes"} must not grant.
|
|
if (verdict?.allowed !== true) {
|
|
return {
|
|
allowed: false,
|
|
reason: verdict?.reason ?? "Policy denied access",
|
|
policy: verdict?.policy ?? name,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.error(`[wrnexus:authz] policy '${name}' threw; denying`, error);
|
|
return { allowed: false, reason: "Policy error", policy: name };
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
return {
|
|
permissionsFor,
|
|
|
|
async decide(input) {
|
|
const { subject, permission, scope } = input;
|
|
const meta = catalog.permissions.get(permission);
|
|
|
|
if (!meta) {
|
|
if (strict) {
|
|
throw new Error(
|
|
`WRN-AUTHZ-UNKNOWN: permission '${permission}' is not registered. ` +
|
|
`Declare it with defineAuthz() in app/authz/.`,
|
|
);
|
|
}
|
|
return finish(input, {
|
|
allowed: false,
|
|
reason: `Permission '${permission}' is not registered`,
|
|
});
|
|
}
|
|
|
|
// Only a non-empty string identifies a subject. A numeric id of 0 or a
|
|
// non-string id must not fall through to the anonymous path, and must
|
|
// never reach the store as a lookup key.
|
|
const rawId: unknown = subject?.id;
|
|
const subjectId = typeof rawId === "string" && rawId !== "" ? rawId : undefined;
|
|
if (rawId !== undefined && rawId !== null && subjectId === undefined) {
|
|
console.error("[wrnexus:authz] subject.id must be a non-empty string; denying");
|
|
return finish(input, { allowed: false, reason: "Invalid subject" });
|
|
}
|
|
|
|
if (!subjectId) {
|
|
if (!meta.public) {
|
|
return finish(input, { allowed: false, reason: "Authentication required" });
|
|
}
|
|
const denied = await runPolicies(input, permission);
|
|
return finish(input, denied ?? { allowed: true, reason: "public permission" });
|
|
}
|
|
|
|
let assignments;
|
|
let granted: Set<string>;
|
|
try {
|
|
({ assignments, granted } = await loadEffective(subjectId, scope));
|
|
} catch (error) {
|
|
console.error("[wrnexus:authz] permission store failed; denying", error);
|
|
return finish(input, { allowed: false, reason: "Authorization store unavailable" });
|
|
}
|
|
|
|
// 1. Explicit deny wins over everything, including "*". Wildcards are
|
|
// honoured here exactly as they are for grants, so denying "post:*"
|
|
// blocks post:delete rather than silently doing nothing.
|
|
if (deniedBy(assignments.denies, permission)) {
|
|
return finish(input, { allowed: false, reason: "explicit deny" });
|
|
}
|
|
|
|
// 2. Must hold the permission at all.
|
|
if (!meta.public && !permissionMatches(granted, permission)) {
|
|
return finish(input, { allowed: false, reason: "Missing permission" });
|
|
}
|
|
|
|
// 3. Every bound policy must pass.
|
|
const denied = await runPolicies(input, permission);
|
|
return finish(input, denied ?? { allowed: true });
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `bun test packages/authz/test/engine.test.ts`
|
|
Expected: PASS, 15 tests
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/authz/src/engine.ts packages/authz/test/engine.test.ts
|
|
git commit -m "feat(authz): add resolution engine with deny-wins precedence and fail-closed errors"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Middleware, can(), and guards
|
|
|
|
**Files:**
|
|
|
|
- Create: `packages/authz/src/middleware.ts`
|
|
- Test: `packages/authz/test/middleware.test.ts`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: `createAuthzResolver`, `AuthzResolverOptions`, `AuthzResolver` from `./engine.ts`; `Context`, `Middleware` types from `@wrnexus/core`
|
|
- Produces: `AUTHZ_LOCALS_KEY`, `authzMiddleware(options: AuthzResolverOptions): Middleware`, `decideFor(ctx, permission, resource?): Promise<AuthorizationDecision>`, `can(ctx, permission, resource?): Promise<boolean>`, `guardPermission(permission, getResource?): Middleware`, `filterCan<T>(ctx, permission, items): Promise<T[]>`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `packages/authz/test/middleware.test.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, test } from "bun:test";
|
|
import type { Context } from "@wrnexus/core";
|
|
import { defineAuthz } from "../src/registry.ts";
|
|
import { mergeCatalogs } from "../src/catalog.ts";
|
|
import { memoryPermissionStore } from "../src/store.ts";
|
|
import { authzMiddleware, can, filterCan, guardPermission } from "../src/middleware.ts";
|
|
|
|
const catalog = mergeCatalogs([
|
|
{
|
|
source: "t.ts",
|
|
module: defineAuthz({
|
|
permissions: { "post:read": { public: true }, "post:write": {}, "post:delete": {} },
|
|
roles: { editor: ["post:write"] },
|
|
policies: {
|
|
ownsPost: async (s: { id?: string }, r?: { authorId?: string }) =>
|
|
r?.authorId === s?.id ? { allowed: true } : { allowed: false, reason: "not owner" },
|
|
},
|
|
bindings: { "post:delete": ["ownsPost"] },
|
|
}),
|
|
},
|
|
]);
|
|
|
|
/** Minimal Context stand-in; the middleware only touches user, tenant, locals. */
|
|
function makeCtx(user: unknown, tenantId?: string): Context {
|
|
return {
|
|
user,
|
|
tenant: tenantId ? { id: tenantId } : undefined,
|
|
locals: {},
|
|
url: new URL("http://localhost/x"),
|
|
req: new Request("http://localhost/x"),
|
|
} as unknown as Context;
|
|
}
|
|
|
|
const withMiddleware = async (ctx: Context, store = memoryPermissionStore()) => {
|
|
await authzMiddleware({ catalog, store, strict: false })(ctx, async () => new Response("ok"));
|
|
return store;
|
|
};
|
|
|
|
describe("authzMiddleware + can", () => {
|
|
test("can() resolves through the middleware-installed resolver", async () => {
|
|
const ctx = makeCtx({ id: "u1" });
|
|
const store = memoryPermissionStore();
|
|
await store.assignRole("u1", "editor");
|
|
await withMiddleware(ctx, store);
|
|
expect(await can(ctx, "post:write")).toBe(true);
|
|
expect(await can(ctx, "post:delete", { authorId: "u1" })).toBe(false);
|
|
});
|
|
|
|
test("can() throws a clear setup error without the middleware", async () => {
|
|
const ctx = makeCtx({ id: "u1" });
|
|
await expect(can(ctx, "post:read")).rejects.toThrow(/authzMiddleware/);
|
|
});
|
|
|
|
test("results are memoised per request", async () => {
|
|
const inner = memoryPermissionStore();
|
|
let reads = 0;
|
|
const counting = {
|
|
...inner,
|
|
assignmentsFor: (id: string, scope?: { tenantId?: string }) => {
|
|
reads++;
|
|
return inner.assignmentsFor(id, scope);
|
|
},
|
|
};
|
|
const ctx = makeCtx({ id: "u1" });
|
|
await authzMiddleware({ catalog, store: counting, strict: false })(
|
|
ctx,
|
|
async () => new Response("ok"),
|
|
);
|
|
await can(ctx, "post:write");
|
|
await can(ctx, "post:write");
|
|
expect(reads).toBe(1);
|
|
});
|
|
|
|
test("memoisation keys on the resource, not just the permission", async () => {
|
|
const ctx = makeCtx({ id: "u1" });
|
|
const store = memoryPermissionStore();
|
|
await store.grant("u1", "post:delete", "allow");
|
|
await withMiddleware(ctx, store);
|
|
expect(await can(ctx, "post:delete", { authorId: "u1" })).toBe(true);
|
|
expect(await can(ctx, "post:delete", { authorId: "other" })).toBe(false);
|
|
});
|
|
|
|
test("the tenant on the context becomes the scope", async () => {
|
|
const ctx = makeCtx({ id: "u1" }, "t1");
|
|
const store = memoryPermissionStore();
|
|
await store.assignRole("u1", "editor", { tenantId: "t1" });
|
|
await withMiddleware(ctx, store);
|
|
expect(await can(ctx, "post:write")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("guardPermission", () => {
|
|
test("calls next when allowed", async () => {
|
|
const ctx = makeCtx({ id: "u1" });
|
|
const store = memoryPermissionStore();
|
|
await store.assignRole("u1", "editor");
|
|
await withMiddleware(ctx, store);
|
|
const res = await guardPermission("post:write")(ctx, async () => new Response("passed"));
|
|
expect(await res.text()).toBe("passed");
|
|
});
|
|
|
|
test("returns 403 without leaking the reason by default", async () => {
|
|
const ctx = makeCtx({ id: "u1" });
|
|
await withMiddleware(ctx);
|
|
const res = await guardPermission("post:write")(ctx, async () => new Response("passed"));
|
|
expect(res.status).toBe(403);
|
|
const body = (await res.json()) as Record<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("does not leak rows the memo cannot serialise", async () => {
|
|
const ctx = makeCtx({ id: "u1" });
|
|
const store = memoryPermissionStore();
|
|
await store.grant("u1", "post:delete", "allow");
|
|
await withMiddleware(ctx, store);
|
|
// BigInt columns and circular references are ordinary in ORM rows. A memo
|
|
// that serialises resources funnels all of these into one shared key and
|
|
// returns the first verdict for every later row.
|
|
const circular: Record<string, unknown> = { authorId: "other" };
|
|
circular.self = circular;
|
|
const rows = [{ authorId: "u1", views: 10n }, { authorId: "other", views: 11n }, circular];
|
|
expect(await filterCan(ctx, "post:delete", rows)).toEqual([rows[0]]);
|
|
});
|
|
|
|
test("returns an empty array for no items", async () => {
|
|
const ctx = makeCtx({ id: "u1" });
|
|
await withMiddleware(ctx);
|
|
expect(await filterCan(ctx, "post:delete", [])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("per-request memo isolation", () => {
|
|
test("distinct resources are never cross-authorized", async () => {
|
|
const ctx = makeCtx({ id: "u1" });
|
|
const store = memoryPermissionStore();
|
|
await store.grant("u1", "post:delete", "allow");
|
|
await withMiddleware(ctx, store);
|
|
// Same id, different owner; object ids; primitives of different type.
|
|
expect(await can(ctx, "post:delete", { id: 7, authorId: "u1" })).toBe(true);
|
|
expect(await can(ctx, "post:delete", { id: "7", authorId: "other" })).toBe(false);
|
|
expect(await can(ctx, "post:delete", { id: { t: "A" }, authorId: "u1" })).toBe(true);
|
|
expect(await can(ctx, "post:delete", { id: { t: "B" }, authorId: "other" })).toBe(false);
|
|
});
|
|
|
|
test("a changed row is not authorized against the stale copy", async () => {
|
|
const ctx = makeCtx({ id: "u1" });
|
|
const store = memoryPermissionStore();
|
|
await store.grant("u1", "post:delete", "allow");
|
|
await withMiddleware(ctx, store);
|
|
expect(await can(ctx, "post:delete", { id: "p1", authorId: "u1" })).toBe(true);
|
|
expect(await can(ctx, "post:delete", { id: "p1", authorId: "someone-else" })).toBe(false);
|
|
});
|
|
|
|
test("switching tenant mid-request re-evaluates", async () => {
|
|
const ctx = makeCtx({ id: "u1" }, "t1");
|
|
const store = memoryPermissionStore();
|
|
await store.assignRole("u1", "editor", { tenantId: "t1" });
|
|
await withMiddleware(ctx, store);
|
|
expect(await can(ctx, "post:write")).toBe(true);
|
|
(ctx as { tenant?: { id: string } }).tenant = { id: "t2" };
|
|
// Scope is read at decision time, so the t1 grant must not carry over.
|
|
expect(await can(ctx, "post:write")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("guardPermission hardening", () => {
|
|
test("throws the setup error rather than calling next", async () => {
|
|
const ctx = makeCtx({ id: "u1" }); // no authzMiddleware
|
|
let reached = false;
|
|
await expect(
|
|
guardPermission("post:write")(ctx, async () => {
|
|
reached = true;
|
|
return new Response("passed");
|
|
}),
|
|
).rejects.toThrow(/authzMiddleware/);
|
|
expect(reached).toBe(false);
|
|
});
|
|
|
|
test("a throwing getResource denies instead of 500ing", async () => {
|
|
const ctx = makeCtx({ id: "u1" });
|
|
await withMiddleware(ctx);
|
|
const guard = guardPermission("post:delete", {
|
|
getResource: () => {
|
|
throw new Error("SELECT * FROM posts WHERE id=$1 failed");
|
|
},
|
|
});
|
|
const res = await guard(ctx, async () => new Response("passed"));
|
|
expect(res.status).toBe(403);
|
|
const body = await res.text();
|
|
expect(body).not.toContain("SELECT");
|
|
});
|
|
|
|
test("redirectTo applies to page requests but not API requests", async () => {
|
|
const page = makeCtx({ id: "u1" });
|
|
await withMiddleware(page);
|
|
const redirected = await guardPermission("post:write", { redirectTo: "/login" })(
|
|
page,
|
|
async () => new Response("passed"),
|
|
);
|
|
expect(redirected.status).toBe(303);
|
|
|
|
const api = makeCtx({ id: "u1" });
|
|
(api as { url: URL }).url = new URL("http://localhost/api/posts");
|
|
await withMiddleware(api);
|
|
const json = await guardPermission("post:write", { redirectTo: "/login" })(
|
|
api,
|
|
async () => new Response("passed"),
|
|
);
|
|
// An API caller must see the denial, not follow a redirect into a 200.
|
|
expect(json.status).toBe(403);
|
|
});
|
|
|
|
test("an off-site redirectTo is refused", async () => {
|
|
const ctx = makeCtx({ id: "u1" });
|
|
await withMiddleware(ctx);
|
|
for (const target of ["https://evil.example.com/harvest", "//evil.example.com"]) {
|
|
const res = await guardPermission("post:write", { redirectTo: target })(
|
|
ctx,
|
|
async () => new Response("passed"),
|
|
);
|
|
expect(res.status).toBe(403);
|
|
}
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `bun test packages/authz/test/middleware.test.ts`
|
|
Expected: FAIL — cannot resolve `../src/middleware.ts`
|
|
|
|
- [ ] **Step 3: Write the implementation**
|
|
|
|
Create `packages/authz/src/middleware.ts`:
|
|
|
|
```ts
|
|
import type { Context, Middleware } from "@wrnexus/core";
|
|
import type { AuthorizationDecision } from "./advanced.ts";
|
|
import { createAuthzResolver, type AuthzResolver, type AuthzResolverOptions } from "./engine.ts";
|
|
import type { AuthzScope } from "./types.ts";
|
|
|
|
/**
|
|
* `can` is deliberately not a Context member: @wrnexus/core must not depend on
|
|
* @wrnexus/authz. The per-request resolver lives here instead.
|
|
*/
|
|
export const AUTHZ_LOCALS_KEY = "_authz";
|
|
|
|
interface RequestAuthz {
|
|
resolver: AuthzResolver;
|
|
/** Memo for object resources, keyed by identity so two rows never collide. */
|
|
byRef: WeakMap<object, Map<string, Promise<AuthorizationDecision>>>;
|
|
/** Memo for symbol resources, which also carry identity. */
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Read the tenant from the context at decision time, not at middleware time:
|
|
* a request that switches tenant mid-flight must not keep the old scope.
|
|
*/
|
|
function currentScope(ctx: Context): AuthzScope | undefined {
|
|
const tenantId = ctx.tenant?.id;
|
|
return typeof tenantId === "string" && tenantId !== "" ? { tenantId } : undefined;
|
|
}
|
|
|
|
/** Install the per-request resolver. Register after sessionAuth and tenantMiddleware. */
|
|
export function authzMiddleware(options: AuthzResolverOptions): Middleware {
|
|
const resolver = createAuthzResolver(options);
|
|
return (ctx, next) => {
|
|
ctx.locals[AUTHZ_LOCALS_KEY] = {
|
|
resolver,
|
|
byRef: new WeakMap(),
|
|
bySymbol: new Map(),
|
|
byValue: new Map(),
|
|
} satisfies RequestAuthz;
|
|
return next();
|
|
};
|
|
}
|
|
|
|
export function decideFor(
|
|
ctx: Context,
|
|
permission: string,
|
|
resource?: unknown,
|
|
): Promise<AuthorizationDecision> {
|
|
const request = readAuthz(ctx);
|
|
const scope = currentScope(ctx);
|
|
// Scope is part of the key: the same permission decides differently per tenant.
|
|
// Subject and scope are both part of the key. A request that reassigns
|
|
// ctx.user (impersonation, step-up auth, session revocation) or ctx.tenant
|
|
// must not be served the previous principal's verdict from the memo.
|
|
const subjectId = (ctx.user as { id?: unknown } | null | undefined)?.id;
|
|
const key = JSON.stringify([
|
|
scope?.tenantId ?? "",
|
|
permission,
|
|
typeof subjectId,
|
|
String(subjectId),
|
|
]);
|
|
|
|
const run = () =>
|
|
request.resolver.decide({
|
|
subject: ctx.user as { id?: string } | null | undefined,
|
|
permission,
|
|
resource,
|
|
scope,
|
|
});
|
|
|
|
// Object resources memo by IDENTITY. Serialising them would let two distinct
|
|
// rows share a key and cross-authorize, and unserialisable ones (circular
|
|
// refs, BigInt fields, throwing getters) would all collapse into one bucket.
|
|
// Symbols carry identity that String() erases, so they memo by identity too.
|
|
// They are held in a plain Map rather than the WeakMap: the memo is discarded
|
|
// with the request, so there is nothing to leak.
|
|
if (typeof resource === "symbol") {
|
|
let perSymbol = request.bySymbol.get(resource);
|
|
if (!perSymbol) request.bySymbol.set(resource, (perSymbol = new Map()));
|
|
const cached = perSymbol.get(key);
|
|
if (cached) return cached;
|
|
const pending = run();
|
|
perSymbol.set(key, pending);
|
|
return pending;
|
|
}
|
|
|
|
if (resource !== null && (typeof resource === "object" || typeof resource === "function")) {
|
|
let perResource = request.byRef.get(resource as object);
|
|
if (!perResource) request.byRef.set(resource as object, (perResource = new Map()));
|
|
const cached = perResource.get(key);
|
|
if (cached) return cached;
|
|
const pending = run();
|
|
perResource.set(key, pending);
|
|
return pending;
|
|
}
|
|
|
|
// typeof is part of the key so 7 and "7" are not the same resource, and
|
|
// -0 keeps its sign because String(-0) is "0".
|
|
const rendered = Object.is(resource, -0) ? "-0" : String(resource);
|
|
const valueKey = JSON.stringify([key, typeof resource, rendered]);
|
|
const cached = request.byValue.get(valueKey);
|
|
if (cached) return cached;
|
|
const pending = run();
|
|
request.byValue.set(valueKey, pending);
|
|
return pending;
|
|
}
|
|
|
|
export async function can(ctx: Context, permission: string, resource?: unknown): Promise<boolean> {
|
|
return (await decideFor(ctx, permission, resource)).allowed;
|
|
}
|
|
|
|
export interface GuardOptions {
|
|
/** Load the resource a bound policy needs. */
|
|
getResource?: (ctx: Context) => unknown;
|
|
/** Include reason and policy name in the 403 body. Off by default. */
|
|
exposeReason?: boolean;
|
|
/** Redirect page requests here instead of returning 403. Must be a local path. */
|
|
redirectTo?: string;
|
|
}
|
|
|
|
/** Same rule requireAuth uses, replicated because authz may only import TYPES from core. */
|
|
function wantsJson(ctx: Context): boolean {
|
|
if (ctx.url.pathname.startsWith("/api/")) return true;
|
|
const accept = ctx.req.headers.get("accept") ?? "";
|
|
return accept.includes("application/json") && !accept.includes("text/html");
|
|
}
|
|
|
|
/**
|
|
* Header values must be Latin-1, so a localized path would otherwise throw
|
|
* inside `new Response` and 500 on a denial path. Encode ONLY the codepoints
|
|
* that cannot be sent: encodeURI would also escape "%", corrupting a target
|
|
* that already carries a percent-encoded return path.
|
|
*/
|
|
function headerSafePath(value: string): string {
|
|
let out = "";
|
|
for (const character of value) {
|
|
out += character.codePointAt(0)! <= 0x7f ? character : encodeURIComponent(character);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Reject anything that could navigate off-site or inject a header. */
|
|
function isLocalPath(value: string): boolean {
|
|
if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return false;
|
|
for (const character of value) {
|
|
const code = character.codePointAt(0)!;
|
|
if (code < 0x20 || code === 0x7f) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Guard a route on a registered permission. Named `guardPermission` because
|
|
* `requirePermission(rbac, permission)` already exists with a different shape.
|
|
*/
|
|
export function guardPermission(permission: string, options: GuardOptions = {}): Middleware {
|
|
return async (ctx, next) => {
|
|
let resource: unknown;
|
|
if (options.getResource) {
|
|
try {
|
|
resource = await options.getResource(ctx);
|
|
} catch (error) {
|
|
// Loading the resource failed, so the policy cannot be evaluated. Deny
|
|
// rather than 500 — and never leak the loader's message to the client.
|
|
console.error(`[wrnexus:authz] getResource for '${permission}' threw; denying`, error);
|
|
return Response.json({ ok: false, error: "Forbidden" }, { status: 403 });
|
|
}
|
|
}
|
|
const result = await decideFor(ctx, permission, resource);
|
|
if (result.allowed) return next();
|
|
|
|
if (options.redirectTo && !wantsJson(ctx)) {
|
|
if (!isLocalPath(options.redirectTo)) {
|
|
// JSON-encode: this branch exists precisely for values containing
|
|
// CR/LF, which would otherwise forge a second log line.
|
|
console.error(
|
|
`[wrnexus:authz] redirectTo must be a local path, got ${JSON.stringify(options.redirectTo)}; denying`,
|
|
);
|
|
} else {
|
|
return new Response(null, {
|
|
status: 303,
|
|
headers: {
|
|
location: headerSafePath(options.redirectTo),
|
|
"cache-control": "private, no-store",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
return Response.json(
|
|
options.exposeReason
|
|
? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy }
|
|
: { ok: false, error: "Forbidden" },
|
|
{ status: 403, headers: { "cache-control": "private, no-store" } },
|
|
);
|
|
};
|
|
}
|
|
|
|
/** Keep only the items the current subject may act on. */
|
|
export async function filterCan<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);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `bun test packages/authz/test/middleware.test.ts`
|
|
Expected: PASS, 10 tests
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/authz/src/middleware.ts packages/authz/test/middleware.test.ts
|
|
git commit -m "feat(authz): add request middleware, can(), and guardPermission"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Stop `authorizeDecision` leaking policy internals
|
|
|
|
**Files:**
|
|
|
|
- Modify: `packages/authz/src/advanced.ts:72-83`
|
|
- Test: `packages/authz/test/authz.test.ts` (append)
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: `AuthorizationDecision` from `./advanced.ts`
|
|
- Produces: `authorizeDecision(evaluate, options?: { exposeReason?: boolean }): Middleware` — behaviour change, body is now `{ ok: false, error: "Forbidden" }` unless opted in
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Append to `packages/authz/test/authz.test.ts`:
|
|
|
|
```ts
|
|
describe("authorizeDecision disclosure", () => {
|
|
const ctx = { user: { id: "u1" } } as unknown as import("@wrnexus/core").Context;
|
|
const denier = async () => ({ allowed: false, reason: "secret internal rule", policy: "isVip" });
|
|
|
|
test("does not leak reason or policy by default", async () => {
|
|
const res = await authorizeDecision(denier)(ctx, async () => new Response("ok"));
|
|
expect(res.status).toBe(403);
|
|
expect(await res.json()).toEqual({ ok: false, error: "Forbidden" });
|
|
});
|
|
|
|
test("exposeReason opts back in", async () => {
|
|
const res = await authorizeDecision(denier, { exposeReason: true })(
|
|
ctx,
|
|
async () => new Response("ok"),
|
|
);
|
|
const body = (await res.json()) as Record<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");
|
|
});
|
|
});
|
|
```
|
|
|
|
Add `authorizeDecision` to the file's existing import from `../src/index.ts` if it is not already imported.
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `bun test packages/authz/test/authz.test.ts`
|
|
Expected: FAIL — the default response still contains `reason`
|
|
|
|
- [ ] **Step 3: Modify `packages/authz/src/advanced.ts`**
|
|
|
|
Replace the `authorizeDecision` function with:
|
|
|
|
```ts
|
|
export interface AuthorizeDecisionOptions {
|
|
/**
|
|
* Include `reason` and `policy` in the 403 body. Off by default: policy
|
|
* names describe internal authorization structure and should not reach an
|
|
* unauthenticated caller.
|
|
*/
|
|
exposeReason?: boolean;
|
|
}
|
|
|
|
export function authorizeDecision(
|
|
evaluate: (ctx: Context) => AuthorizationDecision | Promise<AuthorizationDecision>,
|
|
options: AuthorizeDecisionOptions = {},
|
|
): Middleware {
|
|
return async (ctx, next) => {
|
|
const result = await evaluate(ctx);
|
|
if (result.allowed) return next();
|
|
return Response.json(
|
|
options.exposeReason
|
|
? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy }
|
|
: { ok: false, error: "Forbidden" },
|
|
{ status: 403 },
|
|
);
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `bun test packages/authz/test/authz.test.ts`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/authz/src/advanced.ts packages/authz/test/authz.test.ts
|
|
git commit -m "fix(authz): stop authorizeDecision leaking policy names in 403 bodies"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Export the new surface
|
|
|
|
**Files:**
|
|
|
|
- Modify: `packages/authz/src/index.ts` (append to the existing re-export block)
|
|
- Modify: `docs/public-api-0.8.json` (regenerated)
|
|
- Test: `packages/authz/test/exports.test.ts`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: everything from Tasks 1-8
|
|
- Produces: the public `@wrnexus/authz` surface
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `packages/authz/test/exports.test.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, test } from "bun:test";
|
|
import * as authz from "../src/index.ts";
|
|
|
|
describe("@wrnexus/authz exports", () => {
|
|
test("keeps the pre-existing surface", () => {
|
|
for (const name of [
|
|
"defineRbac",
|
|
"hasRole",
|
|
"any",
|
|
"all",
|
|
"attr",
|
|
"authorize",
|
|
"requireRole",
|
|
"requirePermission",
|
|
"allow",
|
|
"deny",
|
|
"decision",
|
|
"owner",
|
|
"anyDecision",
|
|
"allDecisions",
|
|
"authorizeDecision",
|
|
"filterAuthorized",
|
|
]) {
|
|
expect(typeof (authz as Record<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",
|
|
"scopeKey",
|
|
"safeRecord",
|
|
"deniedBy",
|
|
"authzMiddleware",
|
|
"can",
|
|
"decideFor",
|
|
"guardPermission",
|
|
"filterCan",
|
|
]) {
|
|
expect(typeof (authz as Record<string, unknown>)[name]).toBe("function");
|
|
}
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `bun test packages/authz/test/exports.test.ts`
|
|
Expected: FAIL — `defineAuthz` is undefined
|
|
|
|
- [ ] **Step 3: Append to `packages/authz/src/index.ts`**
|
|
|
|
```ts
|
|
export { defineAuthz } from "./registry.ts";
|
|
export { mergeCatalogs, emptyCatalog } from "./catalog.ts";
|
|
export type { CatalogSource } from "./catalog.ts";
|
|
export { memoryPermissionStore, cachedPermissionStore, scopeKey } from "./store.ts";
|
|
export type { PermissionStore, CachedPermissionStore, CacheOptions, GrantEffect } from "./store.ts";
|
|
export { memoryAuditSink, consoleAuditSink, safeRecord } from "./audit.ts";
|
|
export type { AuthzAuditEvent, AuthzAuditSink, MemoryAuditSink } from "./audit.ts";
|
|
export { createAuthzResolver, expandRoles, permissionMatches, deniedBy } from "./engine.ts";
|
|
export type { AuthzResolver, AuthzResolverOptions, DecideInput } from "./engine.ts";
|
|
export {
|
|
authzMiddleware,
|
|
can,
|
|
decideFor,
|
|
guardPermission,
|
|
filterCan,
|
|
AUTHZ_LOCALS_KEY,
|
|
} from "./middleware.ts";
|
|
export type { GuardOptions } from "./middleware.ts";
|
|
export type {
|
|
AuthzScope,
|
|
AuthzCatalog,
|
|
AuthzModule,
|
|
AttributeMeta,
|
|
PermissionMeta,
|
|
SubjectAssignments,
|
|
} from "./types.ts";
|
|
export type { AuthorizeDecisionOptions } from "./advanced.ts";
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests and regenerate the API baseline**
|
|
|
|
Run: `bun test packages/authz && bun run generate:public-api && bun run check:public-api`
|
|
Expected: tests PASS; baseline regenerates; check reports a match
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/authz/src/index.ts packages/authz/test/exports.test.ts docs/public-api-0.8.json
|
|
git commit -m "feat(authz): export registry, store, engine, and middleware surface"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Router discovery of `app/authz`
|
|
|
|
**Files:**
|
|
|
|
- Modify: `packages/router/src/index.ts:273-294` (alongside the existing schema scan)
|
|
- Test: `packages/router/test/authz-discovery.test.ts`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: `scanDir`, `isSafeIslandName`, `ComponentRef` already in `packages/router/src/index.ts`
|
|
- Produces: `Router.authz: ComponentRef[]`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `packages/router/test/authz-discovery.test.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, test } from "bun:test";
|
|
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { buildRouter } from "../src/index.ts";
|
|
|
|
function appWithAuthz(files: Record<string, 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([]);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `bun test packages/router/test/authz-discovery.test.ts`
|
|
Expected: FAIL — `router.authz` is undefined
|
|
|
|
- [ ] **Step 3: Modify `packages/router/src/index.ts`**
|
|
|
|
Add to the `Router` interface, next to `schemas`:
|
|
|
|
```ts
|
|
/** Authorization declarations (`app/authz/<name>.ts`) merged into the catalog. */
|
|
authz: ComponentRef[];
|
|
```
|
|
|
|
Add the scan immediately after the existing `schemas` loop:
|
|
|
|
```ts
|
|
// 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"))) {
|
|
if (!/\.(ts|js)$/.test(f.file)) continue;
|
|
const name = basename(f.file).replace(/\.(ts|js)$/, "");
|
|
if (!isSafeIslandName(name)) {
|
|
console.warn(`[wrnexus] skipping authz declaration with unsafe name: ${name}`);
|
|
continue;
|
|
}
|
|
authz.push({ name, file: f.file });
|
|
}
|
|
```
|
|
|
|
Add `authz,` to the returned object, next to `schemas,`.
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `bun test packages/router`
|
|
Expected: PASS — the new file plus existing router tests
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/router/src/index.ts packages/router/test/authz-discovery.test.ts
|
|
git commit -m "feat(router): discover app/authz declarations"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Database store adapter
|
|
|
|
**Files:**
|
|
|
|
- Create: `packages/authz/src/migrations.ts`
|
|
- Create: `packages/authz/src/db.ts`
|
|
- Modify: `packages/authz/package.json` (add `./db` export)
|
|
- Test: `packages/authz/test/store-db.test.ts`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: `PermissionStore`, `scopeKey` from `./store.ts`; `Db` type from `@wrnexus/db`; `Dialect` from `@wrnexus/db`
|
|
- Produces: `authzMigrationSql(dialect: Dialect): { up: string; down: string }`, `dbPermissionStore(db: Db): PermissionStore`, `ensureAuthzTables(db: Db, dialect?: Dialect): Promise<void>`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `packages/authz/test/store-db.test.ts`:
|
|
|
|
```ts
|
|
import { createDb } from "@wrnexus/db";
|
|
import { sqlite } from "@wrnexus/db/sqlite";
|
|
import { dbPermissionStore, ensureAuthzTables } from "../src/db.ts";
|
|
import { runStoreConformance } from "./store-conformance.ts";
|
|
|
|
// The db adapter must satisfy exactly the same contract as the memory one.
|
|
runStoreConformance("sqlite", async () => {
|
|
const db = createDb(sqlite(":memory:"));
|
|
await ensureAuthzTables(db, "sqlite");
|
|
return dbPermissionStore(db);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `bun test packages/authz/test/store-db.test.ts`
|
|
Expected: FAIL — cannot resolve `../src/db.ts`
|
|
|
|
- [ ] **Step 3: Write the migration SQL**
|
|
|
|
Create `packages/authz/src/migrations.ts`:
|
|
|
|
```ts
|
|
import type { Dialect } from "@wrnexus/db";
|
|
|
|
/**
|
|
* DDL for the two assignment tables. `scope` holds a tenant id, or the empty
|
|
* string for a global assignment, so the unique constraints work on every
|
|
* dialect (NULL is not comparable in a UNIQUE index).
|
|
*/
|
|
export function authzMigrationSql(dialect: Dialect): { up: string; down: string } {
|
|
const id =
|
|
dialect === "postgres"
|
|
? "SERIAL PRIMARY KEY"
|
|
: dialect === "mysql"
|
|
? "INT AUTO_INCREMENT PRIMARY KEY"
|
|
: "INTEGER PRIMARY KEY AUTOINCREMENT";
|
|
const timestamp = dialect === "sqlite" ? "TEXT" : "TIMESTAMP";
|
|
const now = dialect === "sqlite" ? "CURRENT_TIMESTAMP" : "CURRENT_TIMESTAMP";
|
|
|
|
const up = `CREATE TABLE IF NOT EXISTS _wrn_authz_assignment (
|
|
id ${id},
|
|
subject_id VARCHAR(255) NOT NULL,
|
|
scope VARCHAR(255) NOT NULL DEFAULT '',
|
|
role VARCHAR(255) NOT NULL,
|
|
granted_by VARCHAR(255),
|
|
created_at ${timestamp} NOT NULL DEFAULT ${now},
|
|
CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS _wrn_authz_grant (
|
|
id ${id},
|
|
subject_id VARCHAR(255) NOT NULL,
|
|
scope VARCHAR(255) NOT NULL DEFAULT '',
|
|
permission VARCHAR(255) NOT NULL,
|
|
effect VARCHAR(16) NOT NULL,
|
|
granted_by VARCHAR(255),
|
|
created_at ${timestamp} NOT NULL DEFAULT ${now},
|
|
CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission)
|
|
);`;
|
|
|
|
const down = `DROP TABLE IF EXISTS _wrn_authz_grant;
|
|
DROP TABLE IF EXISTS _wrn_authz_assignment;`;
|
|
|
|
return { up, down };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Write the adapter**
|
|
|
|
Create `packages/authz/src/db.ts`:
|
|
|
|
```ts
|
|
import type { Db, Dialect } from "@wrnexus/db";
|
|
import { authzMigrationSql } from "./migrations.ts";
|
|
import { scopeKey, type GrantEffect, type PermissionStore } from "./store.ts";
|
|
import type { AuthzScope, SubjectAssignments } from "./types.ts";
|
|
|
|
// Re-exported so `@wrnexus/authz/db` is the single entry point for everything
|
|
// database-related, including the DDL the CLI scaffolds.
|
|
export { authzMigrationSql } from "./migrations.ts";
|
|
|
|
/** Create the tables if absent. Production apps should use a real migration. */
|
|
export async function ensureAuthzTables(db: Db, dialect: Dialect = "sqlite"): Promise<void> {
|
|
for (const statement of authzMigrationSql(dialect).up.split(";\n\n")) {
|
|
const sql = statement.trim();
|
|
if (sql) await db.exec(sql.endsWith(";") ? sql : `${sql};`);
|
|
}
|
|
}
|
|
|
|
export function dbPermissionStore(db: Db): PermissionStore {
|
|
return {
|
|
async assignmentsFor(subjectId: string, scope?: AuthzScope): Promise<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 = ? AND (scope = '' OR scope = ?)",
|
|
[subjectId, key],
|
|
);
|
|
const grantRows = await db.all<{ permission: string; effect: GrantEffect }>(
|
|
"SELECT permission, effect FROM _wrn_authz_grant WHERE subject_id = ? AND (scope = '' OR scope = ?)",
|
|
[subjectId, key],
|
|
);
|
|
return {
|
|
roles: roleRows.map((row) => row.role),
|
|
grants: grantRows.filter((r) => r.effect === "allow").map((r) => r.permission),
|
|
denies: grantRows.filter((r) => r.effect === "deny").map((r) => r.permission),
|
|
};
|
|
},
|
|
|
|
async assignRole(subjectId, role, scope) {
|
|
const key = scopeKey(scope);
|
|
const existing = await db.all<{ id: number }>(
|
|
"SELECT id FROM _wrn_authz_assignment WHERE subject_id = ? AND scope = ? AND role = ?",
|
|
[subjectId, key, role],
|
|
);
|
|
if (existing.length) return;
|
|
await db.exec(
|
|
"INSERT INTO _wrn_authz_assignment (subject_id, scope, role) VALUES (?, ?, ?)",
|
|
[subjectId, key, role],
|
|
);
|
|
},
|
|
|
|
async revokeRole(subjectId, role, scope) {
|
|
await db.exec(
|
|
"DELETE FROM _wrn_authz_assignment WHERE subject_id = ? AND scope = ? AND role = ?",
|
|
[subjectId, scopeKey(scope), role],
|
|
);
|
|
},
|
|
|
|
async grant(subjectId, permission, effect, scope) {
|
|
const key = scopeKey(scope);
|
|
// Re-granting replaces the effect, so delete then insert.
|
|
await db.exec(
|
|
"DELETE FROM _wrn_authz_grant WHERE subject_id = ? AND scope = ? AND permission = ?",
|
|
[subjectId, key, permission],
|
|
);
|
|
await db.exec(
|
|
"INSERT INTO _wrn_authz_grant (subject_id, scope, permission, effect) VALUES (?, ?, ?, ?)",
|
|
[subjectId, key, permission, effect],
|
|
);
|
|
},
|
|
|
|
async revokeGrant(subjectId, permission, scope) {
|
|
await db.exec(
|
|
"DELETE FROM _wrn_authz_grant WHERE subject_id = ? AND scope = ? AND permission = ?",
|
|
[subjectId, scopeKey(scope), permission],
|
|
);
|
|
},
|
|
|
|
async listSubjects(scope) {
|
|
const key = scopeKey(scope);
|
|
const rows = await db.all<{ subject_id: string }>(
|
|
"SELECT subject_id FROM _wrn_authz_assignment WHERE scope = ? " +
|
|
"UNION SELECT subject_id FROM _wrn_authz_grant WHERE scope = ?",
|
|
[key, key],
|
|
);
|
|
return [...new Set(rows.map((row) => row.subject_id))];
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Add the subpath export**
|
|
|
|
In `packages/authz/package.json`, replace the `exports` block with:
|
|
|
|
```json
|
|
"exports": {
|
|
".": "./src/index.ts",
|
|
"./db": "./src/db.ts"
|
|
},
|
|
```
|
|
|
|
Add `"@wrnexus/authz/db": ["./packages/authz/src/db.ts"]` to `paths` in the root `tsconfig.json`, next to the existing `@wrnexus/authz` entry.
|
|
|
|
- [ ] **Step 6: Run test to verify it passes**
|
|
|
|
Run: `bun test packages/authz/test/store-db.test.ts`
|
|
Expected: PASS — the same 13 conformance tests as the memory adapter
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add packages/authz/src/db.ts packages/authz/src/migrations.ts packages/authz/package.json packages/authz/test/store-db.test.ts tsconfig.json
|
|
git commit -m "feat(authz): add database-backed PermissionStore"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Permission type codegen
|
|
|
|
**Files:**
|
|
|
|
- Create: `packages/authz/src/codegen.ts`
|
|
- Test: `packages/authz/test/codegen.test.ts`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: `AuthzCatalog` from `./types.ts`
|
|
- Produces: `generatePermissionTypes(catalog: AuthzCatalog): string`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `packages/authz/test/codegen.test.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, test } from "bun:test";
|
|
import { defineAuthz } from "../src/registry.ts";
|
|
import { mergeCatalogs, emptyCatalog } from "../src/catalog.ts";
|
|
import { generatePermissionTypes } from "../src/codegen.ts";
|
|
|
|
describe("generatePermissionTypes", () => {
|
|
test("emits sorted Permission and Role unions", () => {
|
|
const catalog = mergeCatalogs([
|
|
{
|
|
source: "t.ts",
|
|
module: defineAuthz({
|
|
permissions: { "post:write": {}, "post:read": {} },
|
|
roles: { editor: ["post:*"], admin: ["*"] },
|
|
}),
|
|
},
|
|
]);
|
|
const out = generatePermissionTypes(catalog);
|
|
expect(out).toContain('export type Permission = "post:read" | "post:write";');
|
|
expect(out).toContain('export type Role = "admin" | "editor";');
|
|
expect(out).toContain("DO NOT EDIT");
|
|
});
|
|
|
|
test("emits never for an empty catalog so the file still typechecks", () => {
|
|
const out = generatePermissionTypes(emptyCatalog());
|
|
expect(out).toContain("export type Permission = never;");
|
|
expect(out).toContain("export type Role = never;");
|
|
});
|
|
|
|
test("escapes quotes in identifiers", () => {
|
|
const catalog = mergeCatalogs([{ source: "t.ts", module: { roles: { 'we"ird': [] } } }]);
|
|
expect(generatePermissionTypes(catalog)).toContain('"we\\"ird"');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `bun test packages/authz/test/codegen.test.ts`
|
|
Expected: FAIL — cannot resolve `../src/codegen.ts`
|
|
|
|
- [ ] **Step 3: Write the implementation**
|
|
|
|
Create `packages/authz/src/codegen.ts`:
|
|
|
|
```ts
|
|
import type { AuthzCatalog } from "./types.ts";
|
|
|
|
function union(values: string[]): string {
|
|
if (!values.length) return "never";
|
|
return values
|
|
.slice()
|
|
.sort()
|
|
.map((value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`)
|
|
.join(" | ");
|
|
}
|
|
|
|
/**
|
|
* Emit compile-time unions for the registered permissions and roles, so a
|
|
* typo in can(ctx, "post:wrtie") is a type error rather than a silent false.
|
|
*/
|
|
export function generatePermissionTypes(catalog: AuthzCatalog): string {
|
|
return `// Generated by \`wrnexus authz generate\`. DO NOT EDIT.
|
|
|
|
export type Permission = ${union([...catalog.permissions.keys()])};
|
|
|
|
export type Role = ${union([...catalog.roles.keys()])};
|
|
`;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Export it**
|
|
|
|
Append to `packages/authz/src/index.ts` (Task 13 imports this from the package entry):
|
|
|
|
```ts
|
|
export { generatePermissionTypes } from "./codegen.ts";
|
|
```
|
|
|
|
- [ ] **Step 5: Run test to verify it passes**
|
|
|
|
Run: `bun test packages/authz/test/codegen.test.ts && bun run generate:public-api`
|
|
Expected: PASS, 3 tests; baseline updated
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add packages/authz/src/codegen.ts packages/authz/src/index.ts packages/authz/test/codegen.test.ts docs/public-api-0.8.json
|
|
git commit -m "feat(authz): generate Permission and Role union types"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: `wrnexus authz` CLI
|
|
|
|
**Files:**
|
|
|
|
- Create: `packages/cli/src/authz.ts`
|
|
- Modify: `packages/cli/src/index.ts` (add `case "authz"` next to `case "db"`)
|
|
- Test: `packages/cli/test/authz-command.test.ts`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: `buildRouter` from `@wrnexus/router`; `mergeCatalogs`, `generatePermissionTypes`, `authzMigrationSql` from `@wrnexus/authz`
|
|
- Produces: `loadAuthzCatalog(appDir: string): Promise<AuthzCatalog>`, `runAuthzCommand(root: string, sub: string | undefined, args: string[]): Promise<void>`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `packages/cli/test/authz-command.test.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, test } from "bun:test";
|
|
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { loadAuthzCatalog, runAuthzCommand } from "../src/authz.ts";
|
|
|
|
function scaffold(): string {
|
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-cli-"));
|
|
mkdirSync(join(root, "app", "authz"), { recursive: true });
|
|
mkdirSync(join(root, "app", "pages"), { recursive: true });
|
|
writeFileSync(
|
|
join(root, "app", "authz", "blog.ts"),
|
|
`import { defineAuthz } from "@wrnexus/authz";
|
|
export default defineAuthz({
|
|
permissions: { "post:read": { title: "View posts" }, "post:write": {} },
|
|
roles: { editor: ["post:*"] },
|
|
});
|
|
`,
|
|
"utf8",
|
|
);
|
|
return root;
|
|
}
|
|
|
|
describe("wrnexus authz", () => {
|
|
test("loadAuthzCatalog merges every declaration", async () => {
|
|
const catalog = await loadAuthzCatalog(join(scaffold(), "app"));
|
|
expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "post:write"]);
|
|
expect([...catalog.roles.keys()]).toEqual(["editor"]);
|
|
});
|
|
|
|
test("generate writes the permission types file", async () => {
|
|
const root = scaffold();
|
|
await runAuthzCommand(root, "generate", []);
|
|
const generated = readFileSync(join(root, "app", "authz", "permissions.gen.ts"), "utf8");
|
|
expect(generated).toContain('export type Permission = "post:read" | "post:write";');
|
|
});
|
|
|
|
test("init writes a migration containing both tables", async () => {
|
|
const root = scaffold();
|
|
mkdirSync(join(root, "app", "db", "migrations"), { recursive: true });
|
|
await runAuthzCommand(root, "init", []);
|
|
const dir = join(root, "app", "db", "migrations");
|
|
const file = require("node:fs")
|
|
.readdirSync(dir)
|
|
.find((name: string) => name.includes("authz"));
|
|
expect(file).toBeDefined();
|
|
const sql = readFileSync(join(dir, file!), "utf8");
|
|
expect(sql).toContain("_wrn_authz_assignment");
|
|
expect(sql).toContain("_wrn_authz_grant");
|
|
expect(sql).toContain("-- +down");
|
|
});
|
|
|
|
test("list prints every permission and role", async () => {
|
|
const root = scaffold();
|
|
const lines: string[] = [];
|
|
const original = console.log;
|
|
console.log = (...args: unknown[]) => void lines.push(args.join(" "));
|
|
try {
|
|
await runAuthzCommand(root, "list", []);
|
|
} finally {
|
|
console.log = original;
|
|
}
|
|
const output = lines.join("\n");
|
|
expect(output).toContain("post:read");
|
|
expect(output).toContain("editor");
|
|
});
|
|
|
|
test("an unknown subcommand throws with usage", async () => {
|
|
await expect(runAuthzCommand(scaffold(), "bogus", [])).rejects.toThrow(/usage/i);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `bun test packages/cli/test/authz-command.test.ts`
|
|
Expected: FAIL — cannot resolve `../src/authz.ts`
|
|
|
|
- [ ] **Step 3: Write the implementation**
|
|
|
|
Create `packages/cli/src/authz.ts`:
|
|
|
|
```ts
|
|
/**
|
|
* `wrnexus authz <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 scaffold the assignment-table migration
|
|
*/
|
|
|
|
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { buildRouter } from "@wrnexus/router";
|
|
import {
|
|
generatePermissionTypes,
|
|
mergeCatalogs,
|
|
type AuthzCatalog,
|
|
type AuthzModule,
|
|
type CatalogSource,
|
|
} from "@wrnexus/authz";
|
|
import { authzMigrationSql } from "@wrnexus/authz/db";
|
|
|
|
const USAGE = "usage: wrnexus authz <list|generate|init>";
|
|
|
|
/** 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);
|
|
}
|
|
|
|
function nextMigrationNumber(dir: string): string {
|
|
if (!existsSync(dir)) return "0001";
|
|
const numbers = readdirSync(dir)
|
|
.map((name) => Number.parseInt(name.slice(0, 4), 10))
|
|
.filter((value) => Number.isInteger(value));
|
|
return String((numbers.length ? Math.max(...numbers) : 0) + 1).padStart(4, "0");
|
|
}
|
|
|
|
export async function runAuthzCommand(
|
|
root: string,
|
|
sub: string | undefined,
|
|
args: string[],
|
|
): Promise<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 = (args.find((arg) => arg.startsWith("--dialect="))?.split("=")[1] ??
|
|
"sqlite") as "sqlite" | "postgres" | "mysql";
|
|
const dir = join(appDir, "db", "migrations");
|
|
mkdirSync(dir, { recursive: true });
|
|
const { up, down } = authzMigrationSql(dialect);
|
|
const file = join(dir, `${nextMigrationNumber(dir)}_authz_tables.sql`);
|
|
writeFileSync(file, `-- +up\n${up}\n\n-- +down\n${down}\n`, "utf8");
|
|
console.log(`Wrote ${file}`);
|
|
console.log("Run `wrnexus db migrate` to apply it.");
|
|
return;
|
|
}
|
|
|
|
default:
|
|
throw new Error(USAGE);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Wire it into the CLI**
|
|
|
|
In `packages/cli/src/index.ts`, add immediately after the `case "db"` block:
|
|
|
|
```ts
|
|
case "authz": {
|
|
bootstrapProfile(".", "development", rest);
|
|
const { runAuthzCommand } = await import("./authz.ts");
|
|
const [sub, ...authzArgs] = rest.filter((a) => !a.startsWith("--profile="));
|
|
await runAuthzCommand(".", sub, authzArgs);
|
|
break;
|
|
}
|
|
```
|
|
|
|
Also add `authz` to the help text listing available commands.
|
|
|
|
- [ ] **Step 5: Run test to verify it passes**
|
|
|
|
Run: `bun test packages/cli/test/authz-command.test.ts`
|
|
Expected: PASS, 5 tests
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add packages/cli/src/authz.ts packages/cli/src/index.ts packages/cli/test/authz-command.test.ts
|
|
git commit -m "feat(cli): add wrnexus authz list/generate/init"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 14: Wire the catalog into dev and prod boot
|
|
|
|
**Files:**
|
|
|
|
- Modify: `packages/dev-server/src/index.ts` (load catalog in `startServer`)
|
|
- Modify: `packages/cli/src/build.ts` (bake catalog into the prod manifest)
|
|
- Test: `packages/dev-server/test/authz-boot.test.ts`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: `loadAuthzCatalog` pattern from Task 13; `authzMiddleware` from `@wrnexus/authz`
|
|
- Produces: `RuntimeDeps.authz?: AuthzCatalog` available to the request pipeline
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `packages/dev-server/test/authz-boot.test.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, test } from "bun:test";
|
|
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { loadAppAuthzCatalog } from "../src/authz-boot.ts";
|
|
|
|
function scaffold(body: string): string {
|
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-boot-"));
|
|
mkdirSync(join(root, "app", "authz"), { recursive: true });
|
|
mkdirSync(join(root, "app", "pages"), { recursive: true });
|
|
writeFileSync(join(root, "app", "authz", "main.ts"), body, "utf8");
|
|
return join(root, "app");
|
|
}
|
|
|
|
describe("loadAppAuthzCatalog", () => {
|
|
test("loads declarations from app/authz", async () => {
|
|
const appDir = scaffold(
|
|
`import { defineAuthz } from "@wrnexus/authz";
|
|
export default defineAuthz({ permissions: { "post:read": {} } });`,
|
|
);
|
|
const catalog = await loadAppAuthzCatalog(appDir);
|
|
expect(catalog.permissions.has("post:read")).toBe(true);
|
|
});
|
|
|
|
test("an app with no declarations gets an empty catalog rather than an error", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-empty-"));
|
|
mkdirSync(join(root, "app", "pages"), { recursive: true });
|
|
const catalog = await loadAppAuthzCatalog(join(root, "app"));
|
|
expect(catalog.permissions.size).toBe(0);
|
|
});
|
|
|
|
test("a conflicting declaration fails the boot loudly", async () => {
|
|
const appDir = scaffold(
|
|
`import { defineAuthz } from "@wrnexus/authz";
|
|
export default defineAuthz({ permissions: { "post:read": { risk: "low" } } });`,
|
|
);
|
|
writeFileSync(
|
|
join(appDir, "authz", "other.ts"),
|
|
`import { defineAuthz } from "@wrnexus/authz";
|
|
export default defineAuthz({ permissions: { "post:read": { risk: "high" } } });`,
|
|
"utf8",
|
|
);
|
|
await expect(loadAppAuthzCatalog(appDir)).rejects.toThrow(/WRN-AUTHZ-CONFLICT/);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `bun test packages/dev-server/test/authz-boot.test.ts`
|
|
Expected: FAIL — cannot resolve `../src/authz-boot.ts`
|
|
|
|
- [ ] **Step 3: Write the loader**
|
|
|
|
Create `packages/dev-server/src/authz-boot.ts`:
|
|
|
|
```ts
|
|
import { pathToFileURL } from "node:url";
|
|
import { buildRouter } from "@wrnexus/router";
|
|
import {
|
|
emptyCatalog,
|
|
mergeCatalogs,
|
|
type AuthzCatalog,
|
|
type AuthzModule,
|
|
type CatalogSource,
|
|
} from "@wrnexus/authz";
|
|
|
|
/**
|
|
* Load and merge every `app/authz/*.ts` declaration. Conflicts throw so a
|
|
* misconfigured catalog fails the boot rather than silently changing who can
|
|
* do what.
|
|
*/
|
|
export async function loadAppAuthzCatalog(appDir: string): Promise<AuthzCatalog> {
|
|
const router = buildRouter(appDir);
|
|
if (!router.authz.length) return emptyCatalog();
|
|
const sources: CatalogSource[] = [];
|
|
for (const entry of router.authz) {
|
|
if (entry.name === "permissions.gen") continue; // generated types, not a declaration
|
|
const imported = (await import(pathToFileURL(entry.file).href)) as { default?: AuthzModule };
|
|
if (!imported.default) continue;
|
|
sources.push({ source: entry.file, module: imported.default });
|
|
}
|
|
return mergeCatalogs(sources);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `bun test packages/dev-server/test/authz-boot.test.ts`
|
|
Expected: PASS, 3 tests
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add packages/dev-server/src/authz-boot.ts packages/dev-server/test/authz-boot.test.ts
|
|
git commit -m "feat(dev-server): load the authz catalog at boot"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 15: Example app wiring and documentation
|
|
|
|
**Files:**
|
|
|
|
- Create: `examples/auth-showcase/app/authz/showcase.ts`
|
|
- Modify: `packages/authz/README.md`
|
|
- Test: `packages/authz/test/integration.test.ts`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: the full surface from Tasks 1-14
|
|
- Produces: a worked end-to-end example proving the pieces compose
|
|
|
|
- [ ] **Step 1: Write the failing integration test**
|
|
|
|
Create `packages/authz/test/integration.test.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, test } from "bun:test";
|
|
import type { Context } from "@wrnexus/core";
|
|
import { createDb } from "@wrnexus/db";
|
|
import { sqlite } from "@wrnexus/db/sqlite";
|
|
import { dbPermissionStore, ensureAuthzTables } from "../src/db.ts";
|
|
import {
|
|
authzMiddleware,
|
|
can,
|
|
cachedPermissionStore,
|
|
defineAuthz,
|
|
guardPermission,
|
|
memoryAuditSink,
|
|
mergeCatalogs,
|
|
} from "../src/index.ts";
|
|
|
|
const catalog = mergeCatalogs([
|
|
{
|
|
source: "showcase.ts",
|
|
module: defineAuthz({
|
|
permissions: {
|
|
"post:read": { public: true },
|
|
"post:write": {},
|
|
"post:delete": { risk: "high" },
|
|
},
|
|
roles: { editor: ["post:write"], admin: ["role:editor", "post:delete"] },
|
|
policies: {
|
|
ownsPost: async (s: { id?: string }, r?: { authorId?: string }) =>
|
|
r?.authorId === s?.id ? { allowed: true } : { allowed: false, reason: "not owner" },
|
|
},
|
|
bindings: { "post:delete": ["ownsPost"] },
|
|
}),
|
|
},
|
|
]);
|
|
|
|
function makeCtx(user: unknown, tenantId?: string): Context {
|
|
return {
|
|
user,
|
|
tenant: tenantId ? { id: tenantId } : undefined,
|
|
locals: {},
|
|
url: new URL("http://localhost/"),
|
|
req: new Request("http://localhost/"),
|
|
} as unknown as Context;
|
|
}
|
|
|
|
describe("end-to-end authorization", () => {
|
|
test("db store, cache, catalog, middleware, and audit compose", async () => {
|
|
const db = createDb(sqlite(":memory:"));
|
|
await ensureAuthzTables(db, "sqlite");
|
|
const store = cachedPermissionStore(dbPermissionStore(db), { ttlMs: 1_000 });
|
|
const audit = memoryAuditSink();
|
|
await store.assignRole("alice", "admin", { tenantId: "acme" });
|
|
|
|
const alice = makeCtx({ id: "alice" }, "acme");
|
|
await authzMiddleware({ catalog, store, audit, strict: true })(
|
|
alice,
|
|
async () => new Response("ok"),
|
|
);
|
|
|
|
expect(await can(alice, "post:write")).toBe(true);
|
|
expect(await can(alice, "post:delete", { id: 1, authorId: "alice" })).toBe(true);
|
|
expect(await can(alice, "post:delete", { id: 2, authorId: "bob" })).toBe(false);
|
|
|
|
// Wrong tenant: the admin role was scoped to acme.
|
|
const elsewhere = makeCtx({ id: "alice" }, "other");
|
|
await authzMiddleware({ catalog, store, strict: true })(
|
|
elsewhere,
|
|
async () => new Response("ok"),
|
|
);
|
|
expect(await can(elsewhere, "post:write")).toBe(false);
|
|
|
|
// Anonymous can still read, because post:read is public.
|
|
const guest = makeCtx(null);
|
|
await authzMiddleware({ catalog, store, strict: true })(guest, async () => new Response("ok"));
|
|
expect(await can(guest, "post:read")).toBe(true);
|
|
expect(await can(guest, "post:write")).toBe(false);
|
|
|
|
// Only denials were audited.
|
|
expect(audit.events.every((event) => !event.allowed)).toBe(true);
|
|
expect(audit.events.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("revoking a role takes effect immediately through the cache", async () => {
|
|
const db = createDb(sqlite(":memory:"));
|
|
await ensureAuthzTables(db, "sqlite");
|
|
const store = cachedPermissionStore(dbPermissionStore(db), { ttlMs: 60_000 });
|
|
await store.assignRole("bob", "editor");
|
|
|
|
const before = makeCtx({ id: "bob" });
|
|
await authzMiddleware({ catalog, store, strict: true })(before, async () => new Response("ok"));
|
|
expect(await can(before, "post:write")).toBe(true);
|
|
|
|
await store.revokeRole("bob", "editor");
|
|
|
|
const after = makeCtx({ id: "bob" });
|
|
await authzMiddleware({ catalog, store, strict: true })(after, async () => new Response("ok"));
|
|
expect(await can(after, "post:write")).toBe(false);
|
|
});
|
|
|
|
test("guardPermission returns an opaque 403", async () => {
|
|
const db = createDb(sqlite(":memory:"));
|
|
await ensureAuthzTables(db, "sqlite");
|
|
const ctx = makeCtx({ id: "carol" });
|
|
await authzMiddleware({ catalog, store: dbPermissionStore(db), strict: true })(
|
|
ctx,
|
|
async () => new Response("ok"),
|
|
);
|
|
const res = await guardPermission("post:write")(ctx, async () => new Response("passed"));
|
|
expect(res.status).toBe(403);
|
|
expect(await res.json()).toEqual({ ok: false, error: "Forbidden" });
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails or passes**
|
|
|
|
Run: `bun test packages/authz/test/integration.test.ts`
|
|
Expected: PASS if Tasks 1-14 are correct. Any failure here is a real integration
|
|
defect — fix the underlying module, not the test.
|
|
|
|
- [ ] **Step 3: Add the example declaration**
|
|
|
|
Create `examples/auth-showcase/app/authz/showcase.ts`:
|
|
|
|
```ts
|
|
import { defineAuthz } from "@wrnexus/authz";
|
|
|
|
export default defineAuthz({
|
|
permissions: {
|
|
"post:read": { title: "View posts", public: true },
|
|
"post:write": { title: "Create and edit posts" },
|
|
"post:delete": { title: "Delete posts", risk: "high" },
|
|
"admin:access": { title: "Reach the admin area", risk: "high" },
|
|
},
|
|
roles: {
|
|
viewer: ["post:read"],
|
|
editor: ["role:viewer", "post:write"],
|
|
admin: ["role:editor", "post:delete", "admin:access"],
|
|
},
|
|
policies: {
|
|
ownsPost: async (
|
|
subject: { id?: string },
|
|
resource?: { authorId?: string },
|
|
): Promise<{ allowed: boolean; reason?: string }> =>
|
|
resource?.authorId === subject?.id
|
|
? { allowed: true }
|
|
: { allowed: false, reason: "You are not the author" },
|
|
},
|
|
bindings: { "post:delete": ["ownsPost"] },
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Document the surface**
|
|
|
|
Append to `packages/authz/README.md`:
|
|
|
|
````markdown
|
|
## Declaring permissions
|
|
|
|
Put declarations in `app/authz/<name>.ts`. They are discovered automatically.
|
|
|
|
```ts
|
|
import { defineAuthz, owner } from "@wrnexus/authz";
|
|
|
|
export default defineAuthz({
|
|
permissions: {
|
|
"post:read": { title: "View posts", public: true },
|
|
"post:delete": { title: "Delete posts", risk: "high" },
|
|
},
|
|
roles: { editor: ["post:*"], admin: ["role:editor"] },
|
|
policies: { ownsPost: owner("id", "authorId") },
|
|
bindings: { "post:delete": ["ownsPost"] },
|
|
});
|
|
```
|
|
|
|
## Checking permissions
|
|
|
|
Register the middleware once, then use `can()` and `guardPermission()`:
|
|
|
|
```ts
|
|
import { authzMiddleware, can, guardPermission } from "@wrnexus/authz";
|
|
import { dbPermissionStore } from "@wrnexus/authz/db";
|
|
import { getDb } from "@wrnexus/db";
|
|
|
|
export default [authzMiddleware({ catalog, store: dbPermissionStore(getDb()) })];
|
|
|
|
// in a route
|
|
export const middleware = [guardPermission("post:write")];
|
|
if (await can(ctx, "post:delete", post)) {
|
|
/* ... */
|
|
}
|
|
```
|
|
|
|
`can()` is a free function, not `ctx.can` — `@wrnexus/core` must not depend on
|
|
`@wrnexus/authz`.
|
|
|
|
## Precedence
|
|
|
|
1. An explicit deny wins over everything, including `*`.
|
|
2. A bound policy can veto a permission a role grants.
|
|
3. Otherwise the permission must be held via a role or an explicit grant.
|
|
4. Default deny.
|
|
|
|
Every failure — unknown permission, store outage, policy exception — denies.
|
|
|
|
## CLI
|
|
|
|
```bash
|
|
wrnexus authz list # every registered permission, role, and policy
|
|
wrnexus authz generate # app/authz/permissions.gen.ts type unions
|
|
wrnexus authz init # scaffold the assignment-table migration
|
|
```
|
|
````
|
|
|
|
- [ ] **Step 5: Run the full gate**
|
|
|
|
Run: `bun run check:production`
|
|
Expected: PASS. If `check:public-api` complains, run `bun run generate:public-api` and
|
|
re-run.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add packages/authz/test/integration.test.ts packages/authz/README.md examples/auth-showcase/app/authz/showcase.ts docs/public-api-0.8.json
|
|
git commit -m "test(authz): end-to-end integration coverage, example, and docs"
|
|
```
|
|
|
|
---
|
|
|
|
## Deferred phases
|
|
|
|
These are **not** in scope for this plan. Each needs its own design pass.
|
|
|
|
### Phase 4 — `.wrn` view integration
|
|
|
|
Exposing `can()` inside compiler-generated `{#if}` expressions touches
|
|
`packages/compiler/src/codegen.ts`. Because `{#if}` compiles to a nested ternary inside a
|
|
template literal and `can()` is async, the resolution must happen **before** the view renders
|
|
— most likely by collecting referenced permissions at compile time and pre-resolving them
|
|
into the SSR scope, the way `collectControlExprs` already pre-resolves `ssr { api ... }`
|
|
bindings. Do not begin this without confirming that shape against the codegen.
|
|
|
|
### Phase 5 — Admin UI
|
|
|
|
`.wrn` components for listing subjects and assigning roles, shipped in `@wrnexus/ui` behind
|
|
the existing `wrnexus eject` mechanism. Depends on `listSubjects` and the CLI landing first.
|
|
|
|
### Inter-app communication seam
|
|
|
|
`exportSubjectContext(ctx)` / `importSubjectContext(token)` are specified in the design doc
|
|
but intentionally unbuilt. They belong to the inter-app communication system, which has not
|
|
been designed yet.
|