Make bootstrap read its settings through config.ts and call Kratos and Keto through their clients #234

Merged
lilleman merged 4 commits from item-66 into main 2026-11-09 01:13:58 +01:00
4 changed files with 70 additions and 40 deletions
Showing only changes of commit ebb9757e80 - Show all commits
+21 -10
View File
@@ -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",
const clients = (fetchImpl: typeof fetch) => ({
keto: createKetoClient({ fetchImpl, readUrl: "http://keto:4466", writeUrl: "http://keto:4467" }),
kratos: createKratosAdmin({ baseUrl: "http://kratos:4434", fetchImpl }),
password: "admin",
permissions: ["admin"],
}),
/Kratos/,
);
});
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", () => {
+16 -23
View File
@@ -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<SeedResult> {
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<void> {
async function seedAdminAndPermissions(config: BootstrapConfig, plugins: Plugin[], log: Log): Promise<void> {
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 <resource>:<action>", { ignored: ignored.join(", ") });
}
const config = loadBootstrapConfig(env);
const result = await seedAdmin({
email: config.adminEmail,
keto: createKetoClient({ fetchImpl: tracedFetch, readUrl: config.ketoReadUrl, writeUrl: config.ketoWriteUrl }),
+20 -3
View File
@@ -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/);
});
+11 -2
View File
@@ -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),
};
}