One-command bootstrap (todo §3); idempotent first-boot seed: JWKS-if-absent, demo admin in Kratos, admin role in Keto
This commit is contained in:
112
src/bootstrap.test.ts
Normal file
112
src/bootstrap.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// One-command bootstrap (§3): idempotent first-boot seeding. Guards the pure payload
|
||||
// builders (Kratos create-identity body + Keto role tuple), the idempotent seedAdmin
|
||||
// orchestration (fresh 201 vs existing 409 → reuse id), and the JWKS generate-if-absent
|
||||
// safety net. Live boot is verified by running the stack; these catch contract drift.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ensureJwks, identityPayload, roleTuple, seedAdmin } from "./bootstrap.ts";
|
||||
|
||||
const json = (status: number, body?: unknown) =>
|
||||
new Response(body === undefined ? null : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
test("identityPayload is a valid Kratos create-identity body with a password credential", () => {
|
||||
const body = identityPayload("admin@plainpages.local", "admin");
|
||||
assert.equal(body.schema_id, "default");
|
||||
assert.equal(body.traits.email, "admin@plainpages.local");
|
||||
assert.equal(body.credentials.password.config.password, "admin");
|
||||
});
|
||||
|
||||
test("roleTuple grants a role to user:<id> in the Role namespace", () => {
|
||||
const id = randomUUID();
|
||||
assert.deepEqual(roleTuple(id, "admin"), {
|
||||
namespace: "Role",
|
||||
object: "admin",
|
||||
relation: "members",
|
||||
subject_id: `user:${id}`,
|
||||
});
|
||||
});
|
||||
|
||||
test("seedAdmin on a fresh stack creates the identity and grants the role", async () => {
|
||||
const id = randomUUID();
|
||||
const calls: { method: string; url: string; body?: unknown }[] = [];
|
||||
const fetchImpl = (async (url, init) => {
|
||||
const u = String(url);
|
||||
calls.push({ method: init?.method ?? "GET", url: u, body: init?.body && JSON.parse(String(init.body)) });
|
||||
if (u.endsWith("/admin/identities")) return json(201, { id });
|
||||
if (u.includes("/admin/relation-tuples")) return json(201, {});
|
||||
throw new Error(`unexpected ${u}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await seedAdmin({
|
||||
email: "admin@plainpages.local",
|
||||
fetchImpl,
|
||||
ketoWriteUrl: "http://keto:4467",
|
||||
kratosAdminUrl: "http://kratos:4434",
|
||||
password: "admin",
|
||||
role: "admin",
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { created: true, id, role: "admin" });
|
||||
const put = calls.find((c) => c.url.includes("relation-tuples"))!;
|
||||
assert.equal(put.method, "PUT");
|
||||
assert.deepEqual(put.body, { namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` });
|
||||
});
|
||||
|
||||
test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the role", async () => {
|
||||
const id = randomUUID();
|
||||
let granted: unknown;
|
||||
const fetchImpl = (async (url, init) => {
|
||||
const u = String(url);
|
||||
if (u.endsWith("/admin/identities") && init?.method === "POST") return json(409, { error: { code: 409 } });
|
||||
if (u.includes("/admin/identities?")) return json(200, [{ id, traits: { email: "admin@plainpages.local" } }]);
|
||||
if (u.includes("/admin/relation-tuples")) {
|
||||
granted = JSON.parse(String(init?.body));
|
||||
return json(201, {});
|
||||
}
|
||||
throw new Error(`unexpected ${u}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await seedAdmin({
|
||||
email: "admin@plainpages.local",
|
||||
fetchImpl,
|
||||
ketoWriteUrl: "http://keto:4467",
|
||||
kratosAdminUrl: "http://kratos:4434",
|
||||
password: "admin",
|
||||
role: "admin",
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { created: false, id, role: "admin" });
|
||||
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", 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",
|
||||
fetchImpl,
|
||||
ketoWriteUrl: "http://keto:4467",
|
||||
kratosAdminUrl: "http://kratos:4434",
|
||||
password: "admin",
|
||||
role: "admin",
|
||||
}),
|
||||
/Kratos/,
|
||||
);
|
||||
});
|
||||
|
||||
test("ensureJwks generates a key only when the file is absent", () => {
|
||||
const writes: { content: string; path: string }[] = [];
|
||||
const write = (path: string, content: string) => writes.push({ content, path });
|
||||
const path = "/etc/config/kratos/tokenizer/jwks.json";
|
||||
|
||||
assert.equal(ensureJwks(path, { exists: () => false, write }), true);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(JSON.parse(writes[0]!.content).keys.length, 1); // a real ES256 key landed
|
||||
|
||||
assert.equal(ensureJwks(path, { exists: () => true, write }), false);
|
||||
assert.equal(writes.length, 1); // present → nothing written
|
||||
});
|
||||
120
src/bootstrap.ts
Normal file
120
src/bootstrap.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
// One-command bootstrap (todo §3, the MVP bar). Runs as the one-shot `bootstrap` compose
|
||||
// service after kratos+keto are healthy; `web` waits for it to finish. Idempotent — safe
|
||||
// to re-run on every `docker compose up`:
|
||||
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
|
||||
// 2. seed a demo admin identity (admin@plainpages.local / admin) in Kratos;
|
||||
// 3. grant it the `admin` role in Keto so menu/permission checks resolve out of the box.
|
||||
// Fails loud on any unexpected upstream error.
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
|
||||
|
||||
// --- Pure payload builders (the Kratos/Keto request contracts) -----------------------
|
||||
|
||||
export function identityPayload(email: string, password: string) {
|
||||
return {
|
||||
credentials: { password: { config: { password } } }, // cleartext; Kratos hashes it
|
||||
schema_id: "default",
|
||||
traits: { email, name: { first: "Admin", last: "User" } },
|
||||
};
|
||||
}
|
||||
|
||||
// Coarse-role grant: `Role:<role>#members@user:<id>`. Subject ids are `user:<kratos-id>`
|
||||
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT roles.
|
||||
export function roleTuple(identityId: string, role: string) {
|
||||
return { namespace: "Role", object: role, relation: "members", subject_id: `user:${identityId}` };
|
||||
}
|
||||
|
||||
// --- JWKS safety net -----------------------------------------------------------------
|
||||
|
||||
export interface JwksFsHooks {
|
||||
exists?: (path: string) => boolean;
|
||||
generate?: () => JwkSet;
|
||||
write?: (path: string, content: string) => void;
|
||||
}
|
||||
|
||||
// Generate the signing key only when the file is missing; returns whether it wrote one.
|
||||
export function ensureJwks(path: string, hooks: JwksFsHooks = {}): boolean {
|
||||
const exists = hooks.exists ?? existsSync;
|
||||
if (exists(path)) return false;
|
||||
const generate = hooks.generate ?? generateJwks;
|
||||
const write = hooks.write ?? ((p, c) => writeFileSync(p, c));
|
||||
write(path, `${JSON.stringify(generate(), null, 2)}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Admin seeding -------------------------------------------------------------------
|
||||
|
||||
export interface SeedOptions {
|
||||
email: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
ketoWriteUrl: string;
|
||||
kratosAdminUrl: string;
|
||||
password: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface SeedResult {
|
||||
created: boolean;
|
||||
id: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
|
||||
const http = opts.fetchImpl ?? fetch;
|
||||
|
||||
// Create the identity. A 409 means it already exists (a re-run) — look up its id.
|
||||
const res = await http(`${opts.kratosAdminUrl}/admin/identities`, {
|
||||
body: JSON.stringify(identityPayload(opts.email, opts.password)),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
let created: boolean;
|
||||
let id: string;
|
||||
if (res.status === 201) {
|
||||
id = ((await res.json()) as { id: string }).id;
|
||||
created = true;
|
||||
} else if (res.status === 409) {
|
||||
id = await findIdentityId(http, opts.kratosAdminUrl, opts.email);
|
||||
created = false;
|
||||
} else {
|
||||
throw new Error(`bootstrap: Kratos create identity failed (${res.status}): ${await res.text()}`);
|
||||
}
|
||||
|
||||
// Grant the role in Keto. PUT is idempotent — re-running just re-asserts the tuple.
|
||||
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, {
|
||||
body: JSON.stringify(roleTuple(id, opts.role)),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "PUT",
|
||||
});
|
||||
if (!grant.ok) throw new Error(`bootstrap: Keto grant role failed (${grant.status}): ${await grant.text()}`);
|
||||
|
||||
return { created, id, role: opts.role };
|
||||
}
|
||||
|
||||
async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
|
||||
const res = await http(`${adminUrl}/admin/identities?credentials_identifier=${encodeURIComponent(email)}`);
|
||||
if (!res.ok) throw new Error(`bootstrap: Kratos lookup failed (${res.status}): ${await res.text()}`);
|
||||
const found = ((await res.json()) as { id: string }[])[0];
|
||||
if (!found?.id) throw new Error(`bootstrap: ${email} reported as existing but not found`);
|
||||
return found.id;
|
||||
}
|
||||
|
||||
// --- CLI (the bootstrap container entrypoint) ----------------------------------------
|
||||
|
||||
async function main() {
|
||||
const env = process.env;
|
||||
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) console.log("bootstrap: generated a JWKS signing key");
|
||||
|
||||
const role = env["ADMIN_ROLE"] ?? "admin";
|
||||
const result = await seedAdmin({
|
||||
email: env["ADMIN_EMAIL"] ?? "admin@plainpages.local",
|
||||
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
|
||||
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
|
||||
password: env["ADMIN_PASSWORD"] ?? "admin",
|
||||
role,
|
||||
});
|
||||
console.log(`bootstrap: admin ${result.created ? "created" : "already present"} (${result.id}); role "${role}" granted`);
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
|
||||
@@ -37,6 +37,21 @@ test("prod base publishes no internal Ory ports; dev exposes the host-facing one
|
||||
assert.match(override, /"4444:4444"/, "dev publishes hydra public");
|
||||
});
|
||||
|
||||
test("a one-shot bootstrap seeds the stack before web starts", () => {
|
||||
// §3 MVP bar: `bootstrap` runs after kratos+keto are healthy, seeds the admin +
|
||||
// JWKS, then exits; web waits for it to complete. Live seeding is boot-verified.
|
||||
const boot = compose.slice(compose.indexOf("\n bootstrap:"));
|
||||
assert.match(boot, /node src\/bootstrap\.ts/, "bootstrap runs the seed script");
|
||||
for (const svc of ["kratos", "keto"])
|
||||
assert.match(boot, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`),
|
||||
`bootstrap waits for ${svc} healthy`);
|
||||
// Generates the JWKS into the committed tokenizer dir if absent → needs it writable (no :ro).
|
||||
assert.match(boot, /\.\/ory\/kratos\/tokenizer:\/etc\/config\/kratos\/tokenizer(?!:ro)/,
|
||||
"bootstrap mounts the tokenizer dir read-write");
|
||||
assert.match(webBlock, /bootstrap:\s*\n\s*condition:\s*service_completed_successfully/,
|
||||
"web waits for bootstrap to finish");
|
||||
});
|
||||
|
||||
test("the visual E2E does not drag in the Ory stack", () => {
|
||||
// web's Ory deps are reset for E2E (the dashboard is mock data — no Ory needed).
|
||||
assert.match(e2e, /depends_on:\s*!reset\b/, "E2E resets web's depends_on");
|
||||
|
||||
Reference in New Issue
Block a user