diff --git a/README.md b/README.md index 8c7b16a..04be01d 100644 --- a/README.md +++ b/README.md @@ -115,8 +115,13 @@ docker compose up # http://localhost:3000, live reload via `node --wa `docker compose up` brings up the full stack — web + Postgres + Kratos/Keto/Hydra — merging `compose.override.yml`, which mounts the source and restarts the server on -change. The web app waits for Kratos + Keto to be healthy before starting (each Ory -service has a readiness healthcheck). Dev publishes the host-facing Ory ports — +change. A one-shot `bootstrap` service then seeds first-boot state with **zero manual +prep** — it generates the JWT signing key if absent, creates a demo admin +(`admin@plainpages.local` / `admin`) in Kratos, and grants it the `admin` role in Keto +so permission checks resolve out of the box; it is idempotent, so every `up` re-runs it +safely. **Change the demo admin before production.** The web app waits for Kratos + Keto +to be healthy *and* the bootstrap to finish before starting (each Ory service has a +readiness healthcheck). Dev publishes the host-facing Ory ports — Kratos public `4433` (the browser POSTs self-service flows there) and Hydra public `4444`; prod (`docker compose -f compose.yml up`) keeps them internal. Kratos recovery/verification emails are caught by **mailpit** in dev — read the codes at @@ -464,6 +469,7 @@ src/app.ts Request routing + EJS rendering src/static.ts Static file serving (path-traversal protection) + routePublic(): /public// → a plugin's public/ src/jwt.ts JWS signature verify via node:crypto, no jose; claims+JWKS are §4 src/gen-jwks.ts generateJwks() + CLI: mint the ES256 session-tokenizer signing JWKS (§3); see JWT signing key & rotation +src/bootstrap.ts One-command bootstrap (§3): idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4) src/context.ts RequestContext handed to handlers + buildContext() src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot diff --git a/compose.yml b/compose.yml index f1abf6e..36b026e 100644 --- a/compose.yml +++ b/compose.yml @@ -10,9 +10,12 @@ services: environment: CACHE_TEMPLATES: "true" REQUIRE_SECURE_SECRETS: "true" - # Wait for the identity/permission services the app talks to (config.ts: kratos + keto). - # Hydra is post-MVP (§6) and absent from config.ts, so web doesn't gate on it. + # Wait for the identity/permission services the app talks to (config.ts: kratos + keto) + # and for the one-shot bootstrap to seed the admin + JWKS. Hydra is post-MVP (§6) and + # absent from config.ts, so web doesn't gate on it. depends_on: + bootstrap: + condition: service_completed_successfully kratos: condition: service_healthy keto: @@ -100,6 +103,29 @@ services: retries: 20 restart: unless-stopped + # One-command bootstrap (§3, the MVP bar): a one-shot that seeds first-boot state, then + # exits — generate the JWKS if absent, create the demo admin (admin@plainpages.local / + # admin) in Kratos, grant it the `admin` role in Keto. Idempotent, so it re-runs cleanly. + # Runs once kratos+keto are healthy; web waits for it to complete. Tokenizer dir is + # mounted read-write (the only writer) so the absent-JWKS safety net can land the key. + bootstrap: + build: . + depends_on: + kratos: + condition: service_healthy + keto: + condition: service_healthy + environment: + ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} + JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json + KETO_WRITE_URL: http://keto:4467 + KRATOS_ADMIN_URL: http://kratos:4434 + volumes: + - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer + command: node src/bootstrap.ts + restart: on-failure + # Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README). # DSN is the per-service `hydra` DB (init.sql). Issuer + login/consent/logout run at # our app routes (ory/hydra/hydra.yml); the handlers that drive them are §6. Dev diff --git a/src/bootstrap.test.ts b/src/bootstrap.test.ts new file mode 100644 index 0000000..111136a --- /dev/null +++ b/src/bootstrap.test.ts @@ -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: 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 +}); diff --git a/src/bootstrap.ts b/src/bootstrap.ts new file mode 100644 index 0000000..4307a7e --- /dev/null +++ b/src/bootstrap.ts @@ -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:#members@user:`. Subject ids are `user:` +// (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 { + 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 { + 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(); diff --git a/src/compose.test.ts b/src/compose.test.ts index d1685f0..1888f09 100644 --- a/src/compose.test.ts +++ b/src/compose.test.ts @@ -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"); diff --git a/todo.md b/todo.md index 4b7b5d7..881240e 100644 --- a/todo.md +++ b/todo.md @@ -67,7 +67,7 @@ everything via Docker. - [x] `keto` service (pinned) + `migrate`; namespaces in OPL (`role`, `group`, resource permissions). → `compose.yml` adds `keto`/`keto-migrate` pinned to `oryd/keto:v26.2.0` (Ory's unified versioning — same train as kratos; verified latest stable); `keto-migrate` runs `migrate up -y` against the per-service `keto` DB after postgres is healthy, `keto` waits on it (`service_completed_successfully`) — mirrors the kratos pattern. `ory/keto/keto.yml` serves read on 4466 + write on 4467 (the ports `config.ts` already targets), DSN via env, loads the OPL from the mounted file. `ory/keto/namespaces.keto.ts` is the OPL model: `User` (subject = Kratos id), `Group`/`Role` as subject sets with `members` (the coarse roles read at login → JWT, README), and a fine-grained `Resource` with `permits` view/edit/delete over owner ⊇ editor ⊇ viewer (README's third "may I?" tier). OPL stays out of tsconfig `include` (Keto-dialect, like the jsonnets). README: Status note + Layout updated, the role tuple example fixed to `#members` to match the OPL. Tests-first (`keto.test.ts`: version pin + migrate-before-serve + DSN→keto DB + read/write ports + OPL namespaces/permits). Fixed a pre-existing kratos test that over-asserted *every* compose DSN was kratos's (now scoped to kratos DSNs). Boot-verified the whole model live: migrate exits 0, read API ready, then over the write/read APIs — `role:admin#members@user:alice` checks allowed; `Resource:doc1` owner→delete/view allowed, viewer→view allowed but delete denied, stranger denied; and a transitive `Group:eng members ⊆ Role:editor` resolved `user:erin`→editor; torn down. typecheck + 135 units green. - [x] `hydra` service (pinned) + `migrate`; issuer + login/consent URLs → our app. → `compose.yml` adds `hydra`/`hydra-migrate` pinned to `oryd/hydra:v26.2.0` (Ory's unified train — same version as kratos/keto; verified latest); `hydra-migrate` runs `migrate sql -e --yes` against the per-service `hydra` DB after postgres is healthy, `hydra` waits on it (`service_completed_successfully`) — mirrors the kratos pattern. `ory/hydra/hydra.yml` serves public 4444 + admin 4445, `urls.self.issuer` = the public OAuth2 URL, and `urls.login`/`consent`/`logout` point at our app routes (`/oauth2/login`, `/oauth2/consent`, `/oauth2/logout`; §6 renders the handlers, namespaced under `/oauth2/` so they don't collide with Kratos's first-party `/login`). Dev throwaway `secrets.system` (prod overrides via env). Hydra refuses an http issuer in prod, so `compose.override.yml` adds `serve all --dev` + exposes `4444` for dev (the full dev/prod split + health checks is the next §3 item). Tests-first (`hydra.test.ts`: version pin + migrate-before-serve + DSN→hydra DB + public/admin ports + issuer/login/consent/logout URLs). Boot-verified end-to-end: migrate exits 0, public+admin `/health/ready` 200, OIDC discovery reports `issuer: http://127.0.0.1:4444/`, and a real authorization flow (created an OAuth2 client, hit `/oauth2/auth`) 302-redirected to `http://127.0.0.1:3000/oauth2/login?login_challenge=…` — our app; torn down. typecheck + 140 units green. - [x] Split dev (`compose.override.yml`) vs prod (`compose.yml`) wiring; health checks + `depends_on` ordering. → `compose.yml` (base/prod) adds busybox-`wget` `/health/ready` healthchecks to the long-running Ory services (kratos:4433, keto:4466, hydra:4444) and gates `web` on `kratos`+`keto` `service_healthy` (the services `config.ts` talks to — hydra is post-MVP §6, absent from config, so web doesn't gate on it; ordering is transitive through the migrate gates). Dev/prod split: prod publishes **no** internal Ory ports; `compose.override.yml` exposes only the host-facing ones the browser needs — kratos public 4433 (self-service flows POST to `flow.ui.action`, kratos.yml base_url) alongside the existing hydra 4444 + mailpit 8025. The visual E2E stays Ory-free via `depends_on: !reset []` on `web` in `compose.e2e.yml` (the dashboard is mock data — no Postgres/Ory boot). Tests-first (`compose.test.ts`: Ory healthchecks + web ordering + the port split + the e2e reset). Boot-verified the full dev stack with `--wait`: kratos/keto/hydra/postgres/mailpit all healthy, `web` started **only after** kratos+keto healthy, the host reaches kratos 4433 + hydra 4444 + web 3000 while keto 4466 is refused (internal-only); torn down. README **Development** refreshed (dropped the stale "Ory…planned" note). typecheck + 144 units green. -- [ ] **One-command bootstrap** (the MVP bar): `docker compose up` brings up web + all Ory services + Postgres with *zero* manual prep. Commit working default Ory configs; auto-run migrations on first boot; auto-generate the JWKS signing key if absent; seed an admin identity + its Keto roles + a demo password (`admin`/`admin`) idempotently. Land an `OPL`/namespace bootstrap so Keto answers checks out of the box. +- [x] **One-command bootstrap** (the MVP bar): `docker compose up` brings up web + all Ory services + Postgres with *zero* manual prep. Commit working default Ory configs; auto-run migrations on first boot; auto-generate the JWKS signing key if absent; seed an admin identity + its Keto roles + a demo password (`admin`/`admin`) idempotently. Land an `OPL`/namespace bootstrap so Keto answers checks out of the box. → `src/bootstrap.ts` + a one-shot `bootstrap` compose service: runs after kratos+keto are healthy (web gates on its `service_completed_successfully`), idempotent so every `up` re-runs cleanly. (1) `ensureJwks` generates the ES256 signing key (reuses `gen-jwks.ts`) only when the committed dev key is absent — tokenizer dir mounted rw so it can land. (2) `seedAdmin` creates `admin@plainpages.local`/`admin` via the Kratos admin API (a re-run's 409 → look up + reuse the id). (3) grants `Role:admin#members@user:` via the Keto write API (PUT, idempotent) — the source of truth the §4 login flow projects into the JWT. Migrations + default Ory configs already auto-run/committed (§3); OPL/namespaces load from `keto.yml` (§3). The password policy is bypassed by the admin API, so `admin`/`admin` is accepted. Tests-first: `bootstrap.test.ts` (payload builders, seed idempotency via mock fetch, generate-if-absent) + `compose.test.ts` (service wiring). Boot-verified the whole chain on the live stack: `docker compose up --wait` seeds with zero prep, Keto `check` → `allowed:true`, login with `admin@plainpages.local`/`admin` issues a session + tokenizes a JWT; re-run → "already present"; moving the committed key → "generated a JWKS signing key". JWT `roles` stays `[]` until §4 wires the Keto→`metadata_admin` projection. typecheck + 151 units green. The first-run banner (login URL + creds) and the prod-secret/SSO exception docs are the next §3 items. - [ ] First-run banner / log line printing the login URL + seeded admin creds, with a clear "change these before production" warning. - [ ] Document the *only* things that can't be auto-generated: third-party **SSO provider** client id/secret (optional — password login works without them) and **production secrets** (real cookie/CSRF secret + signing key, supplied via env, replacing the dev throwaways). Everything else must work from a clean clone. - [ ] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues.