diff --git a/src/auth/bootstrap.test.ts b/src/auth/bootstrap.test.ts index b95412e..e7b6c3f 100644 --- a/src/auth/bootstrap.test.ts +++ b/src/auth/bootstrap.test.ts @@ -119,18 +119,29 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` }); }); -test("seedAdmin fails loud on an unexpected Kratos error", async () => { - const fetchImpl = (async () => json(500, { error: "boom" })) as typeof fetch; - await assert.rejects( - seedAdmin({ - email: "admin@plainpages.local", - keto: createKetoClient({ fetchImpl, readUrl: "http://keto:4466", writeUrl: "http://keto:4467" }), - kratos: createKratosAdmin({ baseUrl: "http://kratos:4434", fetchImpl }), - password: "admin", - permissions: ["admin"], - }), - /Kratos/, - ); +const clients = (fetchImpl: typeof fetch) => ({ + keto: createKetoClient({ fetchImpl, readUrl: "http://keto:4466", writeUrl: "http://keto:4467" }), + kratos: createKratosAdmin({ baseUrl: "http://kratos:4434", fetchImpl }), +}); + +test("seedAdmin fails loud on a Kratos error other than 409, without looking the admin up", async () => { + const urls: string[] = []; + const fetchImpl = (async (url) => { + urls.push(String(url)); + return json(500, { error: "boom" }); + }) as typeof fetch; + await assert.rejects(seedAdmin({ email: "admin@plainpages.local", password: "admin", permissions: ["admin"], ...clients(fetchImpl) }), /bootstrap: Kratos.*500/); + assert.equal(urls.length, 1); +}); + +test("seedAdmin fails loud when a 409 admin cannot be found", async () => { + const fetchImpl = (async (_url, init) => (init?.method === "POST" ? json(409, {}) : json(200, []))) as typeof fetch; + await assert.rejects(seedAdmin({ email: "admin@plainpages.local", password: "admin", permissions: [], ...clients(fetchImpl) }), /reported as existing but not found/); +}); + +test("seedAdmin fails loud when Keto refuses a grant, naming the permission", async () => { + const fetchImpl = (async (url) => (String(url).endsWith("/admin/identities") ? json(201, { id: "u1" }) : json(500, {}))) as typeof fetch; + await assert.rejects(seedAdmin({ email: "admin@plainpages.local", password: "admin", permissions: ["users:read"], ...clients(fetchImpl) }), /granting "users:read" in Keto/); }); test("firstRunBanner prints the login URL, seeded creds, and a change-before-production warning", () => { diff --git a/src/auth/bootstrap.ts b/src/auth/bootstrap.ts index d81e302..7fd9c2f 100644 --- a/src/auth/bootstrap.ts +++ b/src/auth/bootstrap.ts @@ -1,15 +1,12 @@ -// The one-shot compose service (`node src/auth/bootstrap.ts`) that prepares a stack; `web` waits for -// it. It runs once Kratos and Keto are healthy, repeats safely on every `docker compose up`, and -// fails loud on any unexpected upstream error. In order: -// 1. JWKS: write the session-token signing key if the file is missing. -// 2. Plugin storage: create a database and login role for each plugin that declares storage. -// 3. Admin: create the demo admin in Kratos, or find it again. -// 4. Permissions: grant that admin in Keto every discovered plugin's declared permission names plus -// ADMIN_PERMISSIONS, so a dropped-in plugin works with no host config edit. -// It ends by printing the first-run banner. Settings come from `loadBootstrapConfig`. +// The one-shot compose service (`node src/auth/bootstrap.ts`) that prepares a stack. It starts once +// Kratos and Keto are healthy, `web` waits for it, and it repeats safely on every `docker compose up`. +// Steps, in the order `main` runs them: write the JWKS signing key if absent, create each storage +// plugin's database and role, then seed the demo admin in Kratos and grant it in Keto every +// discovered plugin's declared permissions (plus ADMIN_PERMISSIONS). Any unexpected upstream error +// fails the run loudly. import { existsSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { loadBootstrapConfig, resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "../config.ts"; +import { type BootstrapConfig, loadBootstrapConfig, resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "../config.ts"; import { discoverPlugins } from "../plugin-host/discovery.ts"; import { declaredPermissions, isValidPermissionName, type Plugin } from "../plugin-host/plugin.ts"; import { provisionStorage } from "../plugin-host/storage-provisioning.ts"; @@ -89,12 +86,11 @@ export interface SeedResult { export async function seedAdmin(opts: SeedOptions): Promise { const { created, id } = await createOrFindAdmin(opts.kratos, opts.email, opts.password); - // PUT is idempotent — re-running just re-asserts the tuple. for (const permission of opts.permissions) { try { await opts.keto.writeTuple(permissionTuple(id, permission)); } catch (err) { - throw new Error(`bootstrap: Keto grant permission "${permission}" failed: ${(err as Error).message}`, { cause: err }); + throw new Error(`bootstrap: granting "${permission}" in Keto: ${(err as Error).message}`, { cause: err }); } } @@ -133,21 +129,19 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password: async function main() { const env = { ...process.env }; // snapshot: the storage credentials leave process.env before discovery - // Structured like the web app so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME. - const log = createLogger({ - format: env["LOG_FORMAT"] === "json" ? "json" : "text", - ...(env["SERVICE_NAME"] ? { serviceName: env["SERVICE_NAME"] } : {}), - }); - // runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls. + const config = loadBootstrapConfig(env); // first, so a bad setting fails before any step has run + // Structured like the web app so prod logs stay uniform. + const log = createLogger({ format: config.logFormat, ...(config.serviceName ? { serviceName: config.serviceName } : {}) }); + // runWithLog makes `log` ambient so the Kratos/Keto clients' tracedFetch traces the seed calls. await runWithLog(log, async () => { - if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key"); + if (ensureJwks(config.jwksFile)) log.info("generated a JWKS signing key"); // Discovery imports every plugin module — and its dependencies — into *this* process, which holds // the credential that may CREATE DATABASE/ROLE. Same move as server.ts, on the stronger secret. delete process.env["PLUGIN_DB_ADMIN_URL"]; delete process.env["PLUGIN_DB_SECRET"]; const plugins = await discoverPlugins(); await provisionPluginStorage(env, plugins, log); - await seedAdminAndPermissions(env, plugins, log); + await seedAdminAndPermissions(config, plugins, log); }); await log.end(); // flush any pending OTLP spans/logs before the one-shot exits } @@ -200,13 +194,12 @@ function safeHostPort(url: string): string | null { // Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the // shipped example — and any dropped-in plugin — works for the demo admin without a host edit. -async function seedAdminAndPermissions(env: Env, plugins: Plugin[], log: Log): Promise { +async function seedAdminAndPermissions(config: BootstrapConfig, plugins: Plugin[], log: Log): Promise { const declared = declaredPermissions(plugins).map((decl) => decl.name); - const { ignored, permissions } = seedPermissions(env["ADMIN_PERMISSIONS"], declared); + const { ignored, permissions } = seedPermissions(config.adminPermissions, declared); if (ignored.length > 0) { log.warn("ignoring ADMIN_PERMISSIONS entries that are not :", { ignored: ignored.join(", ") }); } - const config = loadBootstrapConfig(env); const result = await seedAdmin({ email: config.adminEmail, keto: createKetoClient({ fetchImpl: tracedFetch, readUrl: config.ketoReadUrl, writeUrl: config.ketoWriteUrl }), diff --git a/src/config.test.ts b/src/config.test.ts index 74169cd..0b63b5a 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -68,14 +68,31 @@ test("the one-shot bootstrap config has dev defaults and demands no web secret, assert.deepEqual(c, { adminEmail: "admin@plainpages.local", adminPassword: "admin", + adminPermissions: undefined, appUrl: "http://localhost:3000", ketoReadUrl: "http://keto:4466", ketoWriteUrl: "http://keto:4467", kratosAdminUrl: "http://kratos:4434", + jwksFile: "/etc/config/kratos/tokenizer/jwks.json", + logFormat: "text", + serviceName: undefined, }); - assert.equal(loadBootstrapConfig({ ADMIN_EMAIL: "", ADMIN_PASSWORD: "" }).adminEmail, "admin@plainpages.local"); // compose passes an unset variable as "" - const set = loadBootstrapConfig({ ADMIN_EMAIL: "a@b.c", APP_URL: "https://x.test", KRATOS_ADMIN_URL: "http://k:1" }); - assert.deepEqual([set.adminEmail, set.appUrl, set.kratosAdminUrl], ["a@b.c", "https://x.test", "http://k:1"]); + assert.equal(loadBootstrapConfig({ ADMIN_EMAIL: "", ADMIN_PASSWORD: "" }).adminEmail, "admin@plainpages.local"); + const set = loadBootstrapConfig({ + ADMIN_EMAIL: "a@b.c", + ADMIN_PASSWORD: "s3cret", + ADMIN_PERMISSIONS: "users:read", + APP_URL: "https://x.test", + JWKS_FILE: "/k.json", + KRATOS_ADMIN_URL: "http://k:1", + LOG_FORMAT: "json", + SERVICE_NAME: "boot", + }); + assert.deepEqual( + [set.adminEmail, set.adminPassword, set.adminPermissions, set.appUrl, set.jwksFile, set.kratosAdminUrl, set.logFormat, set.serviceName], + ["a@b.c", "s3cret", "users:read", "https://x.test", "/k.json", "http://k:1", "json", "boot"], + ); + assert.throws(() => loadBootstrapConfig({ LOG_FORMAT: "JSON" }), /LOG_FORMAT/); assert.throws(() => loadBootstrapConfig({ KETO_WRITE_URL: "not a url" }), /KETO_WRITE_URL/); }); diff --git a/src/config.ts b/src/config.ts index b82cb85..f171e77 100644 --- a/src/config.ts +++ b/src/config.ts @@ -169,19 +169,28 @@ function readKratosKetoUrls(env: Env) { export interface BootstrapConfig { adminEmail: string; adminPassword: string; + adminPermissions: string | undefined; // comma-separated, validated where it is used appUrl: string; // only printed in the first-run banner; unlike Config.appUrl, it has a default ketoReadUrl: string; ketoWriteUrl: string; kratosAdminUrl: string; + jwksFile: string; + logFormat: Config["logFormat"]; + serviceName: string | undefined; } -// What the one-shot bootstrap service reads. Not `loadConfig`: that demands web's own secrets, which -// compose withholds from bootstrap while REQUIRE_SECURE_SECRETS is on there. +// What the one-shot bootstrap service reads, apart from the plugin-storage variables, which +// `resolvePluginDb*` and `PLUGIN_DB_*` cover. Not `loadConfig`: that demands web's own secrets, which +// compose does not give the bootstrap service while it sets REQUIRE_SECURE_SECRETS there. export function loadBootstrapConfig(env: Env): BootstrapConfig { return { adminEmail: env["ADMIN_EMAIL"] || "admin@plainpages.local", adminPassword: env["ADMIN_PASSWORD"] || "admin", + adminPermissions: readOptional(env, "ADMIN_PERMISSIONS"), appUrl: readOptionalUrl(env, "APP_URL") ?? "http://localhost:3000", + jwksFile: env["JWKS_FILE"] || "/etc/config/kratos/tokenizer/jwks.json", + logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"), + serviceName: readOptional(env, "SERVICE_NAME"), ...readKratosKetoUrls(env), }; }