feat(cli): add wrnexus authz list/generate/init
Introspects the merged authz catalog, emits app/authz/permissions.gen.ts type unions, and scaffolds the assignment-table migration. init validates --dialect explicitly (unrecognised values reject rather than silently falling back to sqlite) and joins authzMigrationSql's up/down statement lists with terminators instead of interpolating the arrays. Test scaffolding for dynamically-imported app/authz declarations must live inside the repo tree (not os.tmpdir()) for the "@wrnexus/*" bare specifier to resolve via tsconfig paths; .gitignore excludes the scratch dirs this produces.
This commit is contained in:
@@ -19,3 +19,8 @@ bun.lockb
|
|||||||
# Local focused typecheck helpers must never enter the repository.
|
# Local focused typecheck helpers must never enter the repository.
|
||||||
focus-shims.d.ts
|
focus-shims.d.ts
|
||||||
tsconfig.focus.json
|
tsconfig.focus.json
|
||||||
|
|
||||||
|
# Scratch dirs for tests that must dynamically import scaffolded files using
|
||||||
|
# "@wrnexus/*" bare specifiers (resolved via the root tsconfig.json `paths`,
|
||||||
|
# which requires the scaffold to live inside the repo tree).
|
||||||
|
**/test/.tmp-*/
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* `wrnexus authz <cmd>` — authorization catalog tooling.
|
||||||
|
*
|
||||||
|
* wrnexus authz list print every registered permission, role, and policy
|
||||||
|
* wrnexus authz generate write app/authz/permissions.gen.ts type unions
|
||||||
|
* wrnexus authz init [--dialect=sqlite|postgres|mysql]
|
||||||
|
* scaffold the assignment-table migration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { buildRouter } from "@wrnexus/router";
|
||||||
|
import {
|
||||||
|
generatePermissionTypes,
|
||||||
|
mergeCatalogs,
|
||||||
|
type AuthzCatalog,
|
||||||
|
type AuthzModule,
|
||||||
|
type CatalogSource,
|
||||||
|
} from "@wrnexus/authz";
|
||||||
|
import { authzMigrationSql } from "@wrnexus/authz/db";
|
||||||
|
import type { Dialect } from "@wrnexus/db";
|
||||||
|
|
||||||
|
const USAGE = "usage: wrnexus authz <list|generate|init>";
|
||||||
|
const DIALECTS = ["sqlite", "postgres", "mysql"] as const;
|
||||||
|
|
||||||
|
/** Import every app/authz declaration and merge it into one catalog. */
|
||||||
|
export async function loadAuthzCatalog(appDir: string): Promise<AuthzCatalog> {
|
||||||
|
const router = buildRouter(appDir);
|
||||||
|
const sources: CatalogSource[] = [];
|
||||||
|
for (const entry of router.authz) {
|
||||||
|
const imported = (await import(pathToFileURL(entry.file).href)) as {
|
||||||
|
default?: AuthzModule;
|
||||||
|
};
|
||||||
|
if (!imported.default) {
|
||||||
|
console.warn(`[wrnexus] ${entry.file} has no default export; skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sources.push({ source: entry.file, module: imported.default });
|
||||||
|
}
|
||||||
|
return mergeCatalogs(sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMigrationNumber(dir: string): string {
|
||||||
|
if (!existsSync(dir)) return "0001";
|
||||||
|
const numbers = readdirSync(dir)
|
||||||
|
.map((name) => Number.parseInt(name.slice(0, 4), 10))
|
||||||
|
.filter((value) => Number.isInteger(value));
|
||||||
|
return String((numbers.length ? Math.max(...numbers) : 0) + 1).padStart(4, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse `--dialect=<value>` from CLI args. Defaults to sqlite; rejects unknown values. */
|
||||||
|
function resolveDialect(args: string[]): Dialect {
|
||||||
|
const flag = args.find((arg) => arg.startsWith("--dialect="));
|
||||||
|
if (!flag) return "sqlite";
|
||||||
|
const value = flag.split("=")[1];
|
||||||
|
if ((DIALECTS as readonly string[]).includes(value ?? "")) return value as Dialect;
|
||||||
|
throw new Error(
|
||||||
|
`WRN-AUTHZ-INIT: unrecognised --dialect='${value}'. Use one of: ${DIALECTS.join(", ")}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAuthzCommand(
|
||||||
|
root: string,
|
||||||
|
sub: string | undefined,
|
||||||
|
args: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const appDir = join(resolve(root), "app");
|
||||||
|
|
||||||
|
switch (sub) {
|
||||||
|
case "list": {
|
||||||
|
const catalog = await loadAuthzCatalog(appDir);
|
||||||
|
console.log(`Permissions (${catalog.permissions.size}):`);
|
||||||
|
for (const [id, meta] of [...catalog.permissions].sort()) {
|
||||||
|
const tags = [meta.risk && `risk=${meta.risk}`, meta.public && "public"]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
console.log(` ${id}${meta.title ? ` — ${meta.title}` : ""}${tags ? ` [${tags}]` : ""}`);
|
||||||
|
}
|
||||||
|
console.log(`\nRoles (${catalog.roles.size}):`);
|
||||||
|
for (const [name, grants] of [...catalog.roles].sort()) {
|
||||||
|
console.log(` ${name} → ${grants.join(", ") || "(nothing)"}`);
|
||||||
|
}
|
||||||
|
console.log(`\nPolicies (${catalog.policies.size}):`);
|
||||||
|
for (const name of [...catalog.policies.keys()].sort()) {
|
||||||
|
const bound = [...catalog.bindings]
|
||||||
|
.filter(([, names]) => names.includes(name))
|
||||||
|
.map(([permission]) => permission);
|
||||||
|
console.log(` ${name}${bound.length ? ` → ${bound.join(", ")}` : " (unbound)"}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "generate": {
|
||||||
|
const catalog = await loadAuthzCatalog(appDir);
|
||||||
|
const target = join(appDir, "authz", "permissions.gen.ts");
|
||||||
|
mkdirSync(join(appDir, "authz"), { recursive: true });
|
||||||
|
writeFileSync(target, generatePermissionTypes(catalog), "utf8");
|
||||||
|
console.log(
|
||||||
|
`Wrote ${target} (${catalog.permissions.size} permissions, ${catalog.roles.size} roles)`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "init": {
|
||||||
|
const dialect = resolveDialect(args);
|
||||||
|
const dir = join(appDir, "db", "migrations");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const { up, down } = authzMigrationSql(dialect);
|
||||||
|
const file = join(dir, `${nextMigrationNumber(dir)}_authz_tables.sql`);
|
||||||
|
// up/down are statement LISTS; interpolating the arrays directly would
|
||||||
|
// comma-join them into one unparseable statement.
|
||||||
|
const block = (statements: string[]) => statements.map((s) => `${s};`).join("\n\n");
|
||||||
|
writeFileSync(file, `-- +up\n${block(up)}\n\n-- +down\n${block(down)}\n`, "utf8");
|
||||||
|
console.log(`Wrote ${file}`);
|
||||||
|
console.log("Run `wrnexus db migrate` to apply it.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(USAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
* wrnexus create <app-name> scaffold a new app
|
* wrnexus create <app-name> scaffold a new app
|
||||||
* wrnexus eject <name...> copy a Wire UI component into your app
|
* wrnexus eject <name...> copy a Wire UI component into your app
|
||||||
* wrnexus db <migrate|rollback|status|new> database migrations
|
* wrnexus db <migrate|rollback|status|new> database migrations
|
||||||
|
* wrnexus authz <list|generate|init> authorization catalog tooling
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
@@ -66,6 +67,7 @@ Usage:
|
|||||||
wrnexus eject <name...> Copy a Wire UI component into app/components
|
wrnexus eject <name...> Copy a Wire UI component into app/components
|
||||||
wrnexus update [dir] [--latest] Upgrade deps, migrate project files, and verify the app
|
wrnexus update [dir] [--latest] Upgrade deps, migrate project files, and verify the app
|
||||||
wrnexus db <cmd> Migrations: migrate | rollback | status | seed | generate | new
|
wrnexus db <cmd> Migrations: migrate | rollback | status | seed | generate | new
|
||||||
|
wrnexus authz <cmd> Authorization: list | generate | init [--dialect=sqlite|postgres|mysql]
|
||||||
wrnexus test [level] [app-dir] [--watch]
|
wrnexus test [level] [app-dir] [--watch]
|
||||||
Run unit | component | api | browser | visual | accessibility | performance
|
Run unit | component | api | browser | visual | accessibility | performance
|
||||||
wrnexus profiles [app-dir] List config profiles (dev/prod/uat/…) and their env files
|
wrnexus profiles [app-dir] List config profiles (dev/prod/uat/…) and their env files
|
||||||
@@ -270,6 +272,13 @@ async function main(): Promise<void> {
|
|||||||
await runDbCommand(".", sub, dbArgs);
|
await runDbCommand(".", sub, dbArgs);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "authz": {
|
||||||
|
bootstrapProfile(".", "development", rest);
|
||||||
|
const { runAuthzCommand } = await import("./authz.ts");
|
||||||
|
const [sub, ...authzArgs] = rest.filter((a) => !a.startsWith("--profile="));
|
||||||
|
await runAuthzCommand(".", sub, authzArgs);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "profiles": {
|
case "profiles": {
|
||||||
const { listProfiles } = await import("./profiles.ts");
|
const { listProfiles } = await import("./profiles.ts");
|
||||||
await listProfiles(rest.find((a) => !a.startsWith("--")) ?? ".");
|
await listProfiles(rest.find((a) => !a.startsWith("--")) ?? ".");
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
writeFileSync,
|
||||||
|
existsSync,
|
||||||
|
rmSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { loadAuthzCatalog, runAuthzCommand } from "../src/authz.ts";
|
||||||
|
|
||||||
|
// Declarations under app/authz/ import "@wrnexus/authz" with a bare specifier,
|
||||||
|
// which Bun resolves via the root tsconfig.json `paths` map by walking up from
|
||||||
|
// the imported file's directory. os.tmpdir() lives outside the repo tree (often
|
||||||
|
// on a different drive on Windows), so that walk never reaches the root
|
||||||
|
// tsconfig.json and the dynamic import fails with "Cannot find module
|
||||||
|
// '@wrnexus/authz'". Scaffolding under this test file's own directory keeps the
|
||||||
|
// walk-up inside the repo, exactly like a real app (which has its own
|
||||||
|
// node_modules/tsconfig with @wrnexus/authz installed).
|
||||||
|
const scratchRoot = join(import.meta.dir, ".tmp-authz-cli");
|
||||||
|
const createdRoots: string[] = [];
|
||||||
|
|
||||||
|
function scaffold(): string {
|
||||||
|
mkdirSync(scratchRoot, { recursive: true });
|
||||||
|
const root = mkdtempSync(join(scratchRoot, "run-"));
|
||||||
|
createdRoots.push(root);
|
||||||
|
mkdirSync(join(root, "app", "authz"), { recursive: true });
|
||||||
|
mkdirSync(join(root, "app", "pages"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "app", "authz", "blog.ts"),
|
||||||
|
`import { defineAuthz } from "@wrnexus/authz";
|
||||||
|
export default defineAuthz({
|
||||||
|
permissions: { "post:read": { title: "View posts" }, "post:write": {} },
|
||||||
|
roles: { editor: ["post:*"] },
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(scratchRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("wrnexus authz", () => {
|
||||||
|
test("loadAuthzCatalog merges every declaration", async () => {
|
||||||
|
const catalog = await loadAuthzCatalog(join(scaffold(), "app"));
|
||||||
|
expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "post:write"]);
|
||||||
|
expect([...catalog.roles.keys()]).toEqual(["editor"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generate writes the permission types file", async () => {
|
||||||
|
const root = scaffold();
|
||||||
|
await runAuthzCommand(root, "generate", []);
|
||||||
|
const generated = readFileSync(join(root, "app", "authz", "permissions.gen.ts"), "utf8");
|
||||||
|
expect(generated).toContain('export type Permission = "post:read" | "post:write";');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("init writes a migration containing both tables", async () => {
|
||||||
|
const root = scaffold();
|
||||||
|
mkdirSync(join(root, "app", "db", "migrations"), { recursive: true });
|
||||||
|
await runAuthzCommand(root, "init", []);
|
||||||
|
const dir = join(root, "app", "db", "migrations");
|
||||||
|
const file = readdirSync(dir).find((name: string) => name.includes("authz"));
|
||||||
|
expect(file).toBeDefined();
|
||||||
|
const sql = readFileSync(join(dir, file!), "utf8");
|
||||||
|
expect(sql).toContain("_wrn_authz_assignment");
|
||||||
|
expect(sql).toContain("_wrn_authz_grant");
|
||||||
|
expect(sql).toContain("-- +down");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("list prints every permission and role", async () => {
|
||||||
|
const root = scaffold();
|
||||||
|
const lines: string[] = [];
|
||||||
|
const original = console.log;
|
||||||
|
console.log = (...args: unknown[]) => void lines.push(args.join(" "));
|
||||||
|
try {
|
||||||
|
await runAuthzCommand(root, "list", []);
|
||||||
|
} finally {
|
||||||
|
console.log = original;
|
||||||
|
}
|
||||||
|
const output = lines.join("\n");
|
||||||
|
expect(output).toContain("post:read");
|
||||||
|
expect(output).toContain("editor");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unknown subcommand throws with usage", async () => {
|
||||||
|
await expect(runAuthzCommand(scaffold(), "bogus", [])).rejects.toThrow(/usage/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a missing subcommand throws with usage", async () => {
|
||||||
|
await expect(runAuthzCommand(scaffold(), undefined, [])).rejects.toThrow(/usage/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("list does not crash on an app with no app/authz directory", async () => {
|
||||||
|
mkdirSync(scratchRoot, { recursive: true });
|
||||||
|
const root = mkdtempSync(join(scratchRoot, "empty-"));
|
||||||
|
createdRoots.push(root);
|
||||||
|
mkdirSync(join(root, "app", "pages"), { recursive: true });
|
||||||
|
const lines: string[] = [];
|
||||||
|
const original = console.log;
|
||||||
|
console.log = (...args: unknown[]) => void lines.push(args.join(" "));
|
||||||
|
try {
|
||||||
|
await runAuthzCommand(root, "list", []);
|
||||||
|
} finally {
|
||||||
|
console.log = original;
|
||||||
|
}
|
||||||
|
expect(lines.join("\n")).toContain("Permissions (0)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loadAuthzCatalog warns and skips a declaration file with no default export", async () => {
|
||||||
|
const root = scaffold();
|
||||||
|
writeFileSync(join(root, "app", "authz", "empty.ts"), `export const notDefault = 1;\n`, "utf8");
|
||||||
|
const warnings: unknown[][] = [];
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
console.warn = (...args: unknown[]) => void warnings.push(args);
|
||||||
|
try {
|
||||||
|
const catalog = await loadAuthzCatalog(join(root, "app"));
|
||||||
|
expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "post:write"]);
|
||||||
|
} finally {
|
||||||
|
console.warn = originalWarn;
|
||||||
|
}
|
||||||
|
expect(warnings.some((args) => String(args.join(" ")).includes("no default export"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generate is idempotent when run twice", async () => {
|
||||||
|
const root = scaffold();
|
||||||
|
await runAuthzCommand(root, "generate", []);
|
||||||
|
const first = readFileSync(join(root, "app", "authz", "permissions.gen.ts"), "utf8");
|
||||||
|
await runAuthzCommand(root, "generate", []);
|
||||||
|
const second = readFileSync(join(root, "app", "authz", "permissions.gen.ts"), "utf8");
|
||||||
|
expect(second).toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("init --dialect=postgres emits postgres DDL", async () => {
|
||||||
|
const root = scaffold();
|
||||||
|
mkdirSync(join(root, "app", "db", "migrations"), { recursive: true });
|
||||||
|
await runAuthzCommand(root, "init", ["--dialect=postgres"]);
|
||||||
|
const dir = join(root, "app", "db", "migrations");
|
||||||
|
const file = readdirSync(dir).find((name: string) => name.includes("authz"));
|
||||||
|
const sql = readFileSync(join(dir, file!), "utf8");
|
||||||
|
expect(sql).toContain("SERIAL PRIMARY KEY");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("init --dialect=mysql emits mysql DDL", async () => {
|
||||||
|
const root = scaffold();
|
||||||
|
mkdirSync(join(root, "app", "db", "migrations"), { recursive: true });
|
||||||
|
await runAuthzCommand(root, "init", ["--dialect=mysql"]);
|
||||||
|
const dir = join(root, "app", "db", "migrations");
|
||||||
|
const file = readdirSync(dir).find((name: string) => name.includes("authz"));
|
||||||
|
const sql = readFileSync(join(dir, file!), "utf8");
|
||||||
|
expect(sql).toContain("AUTO_INCREMENT PRIMARY KEY");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("init with an unrecognised --dialect= does not silently fall back to sqlite", async () => {
|
||||||
|
const root = scaffold();
|
||||||
|
mkdirSync(join(root, "app", "db", "migrations"), { recursive: true });
|
||||||
|
await expect(runAuthzCommand(root, "init", ["--dialect=oracle"])).rejects.toThrow(/dialect/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("init writes a migration that the migration runner can parse", async () => {
|
||||||
|
const { loadMigrations } = await import("@wrnexus/db");
|
||||||
|
const root = scaffold();
|
||||||
|
const dir = join(root, "app", "db", "migrations");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
await runAuthzCommand(root, "init", []);
|
||||||
|
const migrations = loadMigrations(dir);
|
||||||
|
expect(migrations.length).toBe(1);
|
||||||
|
const migration = migrations[0]!;
|
||||||
|
expect(migration.up).toContain("_wrn_authz_assignment");
|
||||||
|
expect(migration.up).toContain("_wrn_authz_grant");
|
||||||
|
expect(migration.down).toContain("DROP TABLE IF EXISTS _wrn_authz_grant");
|
||||||
|
expect(migration.down).toContain("DROP TABLE IF EXISTS _wrn_authz_assignment");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generate does not clobber a real declaration file", async () => {
|
||||||
|
const root = scaffold();
|
||||||
|
await runAuthzCommand(root, "generate", []);
|
||||||
|
expect(existsSync(join(root, "app", "authz", "blog.ts"))).toBe(true);
|
||||||
|
const original = readFileSync(join(root, "app", "authz", "blog.ts"), "utf8");
|
||||||
|
expect(original).toContain("defineAuthz");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user