diff --git a/.env.example b/.env.example index 66c4f54..a0b4e2e 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,9 @@ CLICKHOUSE_INGEST_PASSWORD= # yavio_ingest user: INSERT only. Appli # — blank so an upgrade cannot reset the user to a published value. CLICKHOUSE_DASHBOARD_PASSWORD= # yavio_dashboard user: SELECT only, row policies. Applied by # `pnpm migrate:clickhouse` — blank for the same reason. +CLICKHOUSE_ERASER_PASSWORD= # yavio_eraser user: ALTER DELETE on default.events ONLY — used + # by the account/workspace/project deletion routes. Cannot read + # what it erases. Applied by `pnpm migrate:clickhouse`. # ─── Database Connection URLs ──────────────────────────────────────────────── # localhost for local dev; Docker Compose overrides these with container hostnames. diff --git a/docker-compose.yml b/docker-compose.yml index 007f6a9..24bbf04 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -68,6 +68,7 @@ services: # migrations run — migration 0007 creates them with a published literal. CLICKHOUSE_INGEST_PASSWORD: ${CLICKHOUSE_INGEST_PASSWORD:-} CLICKHOUSE_DASHBOARD_PASSWORD: ${CLICKHOUSE_DASHBOARD_PASSWORD:-} + CLICKHOUSE_ERASER_PASSWORD: ${CLICKHOUSE_ERASER_PASSWORD:-} # Applied to the yavio_api role by migrate.ts after migrations run. POSTGRES_API_PASSWORD: ${POSTGRES_API_PASSWORD:?POSTGRES_API_PASSWORD is not set. Run ./scripts/setup-env.sh, or add it to .env — it must never fall back to a published default.} networks: @@ -117,6 +118,8 @@ services: # This service narrows the ClickHouse username; when set it uses that # user's own password instead of the default user's. CLICKHOUSE_DASHBOARD_PASSWORD: ${CLICKHOUSE_DASHBOARD_PASSWORD:-} + # Erasure only: ALTER DELETE on default.events and nothing else. + CLICKHOUSE_ERASER_PASSWORD: ${CLICKHOUSE_ERASER_PASSWORD:-} NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} API_KEY_HASH_SECRET: ${API_KEY_HASH_SECRET} diff --git a/packages/dashboard/__tests__/clickhouse-client.test.ts b/packages/dashboard/__tests__/clickhouse-client.test.ts index 85590fe..bb8b48b 100644 --- a/packages/dashboard/__tests__/clickhouse-client.test.ts +++ b/packages/dashboard/__tests__/clickhouse-client.test.ts @@ -11,17 +11,28 @@ vi.mock("@yavio/db/clickhouse", () => ({ }, })); -let saved: string | undefined; +const ENV_KEYS = [ + "CLICKHOUSE_URL", + "CLICKHOUSE_DASHBOARD_PASSWORD", + "CLICKHOUSE_ERASER_PASSWORD", +] as const; +let saved: Record; beforeEach(() => { - saved = process.env.CLICKHOUSE_URL; + // Save ALL of them: a repo .env sets these, which previously turned a real + // assertion into an environment-dependent flake. + saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + for (const k of ENV_KEYS) Reflect.deleteProperty(process.env, k); created.length = 0; vi.resetModules(); }); afterEach(() => { - if (saved === undefined) Reflect.deleteProperty(process.env, "CLICKHOUSE_URL"); - else process.env.CLICKHOUSE_URL = saved; + for (const k of ENV_KEYS) { + const v = saved[k]; + if (v === undefined) Reflect.deleteProperty(process.env, k); + else process.env[k] = v; + } }); async function urlFor(which: "read" | "mutate") { @@ -64,6 +75,24 @@ describe("mutating client — must retain ALTER DELETE rights", () => { expect(url.username).not.toBe("yavio_dashboard"); }); + it("narrows to yavio_eraser when its password is configured", async () => { + // yavio_eraser holds ALTER DELETE on default.events and nothing else + // (CH migration 0012), so erasure no longer needs the superuser. + process.env.CLICKHOUSE_URL = "http://default:pw@clickhouse:8123"; + process.env.CLICKHOUSE_ERASER_PASSWORD = "eraser-secret"; + const url = new URL((await urlFor("mutate")) as string); + expect(url.username).toBe("yavio_eraser"); + expect(url.password).toBe("eraser-secret"); + }); + + it("falls back to the URL user when no eraser password is set", async () => { + // A deployment that has not yet run 0012 must keep erasing rather than + // silently failing — the exact failure this file exists to prevent. + process.env.CLICKHOUSE_URL = "http://default:pw@clickhouse:8123"; + const url = new URL((await urlFor("mutate")) as string); + expect(url.username).toBe("default"); + }); + it("is a different identity from the read-only client", async () => { process.env.CLICKHOUSE_URL = "http://default:pw@clickhouse:8123"; const mod = await import("@/lib/clickhouse"); diff --git a/packages/dashboard/lib/clickhouse.ts b/packages/dashboard/lib/clickhouse.ts index f9e48ce..9164054 100644 --- a/packages/dashboard/lib/clickhouse.ts +++ b/packages/dashboard/lib/clickhouse.ts @@ -8,17 +8,24 @@ type ClickHouseClient = ReturnType; */ const DASHBOARD_CH_USER = "yavio_dashboard"; +/** Holds only ALTER DELETE on default.events — see CH migration 0012. */ +const ERASER_CH_USER = "yavio_eraser"; + let readOnlyClient: ClickHouseClient | null = null; let mutatingClient: ClickHouseClient | null = null; -function narrowedUrl(baseUrl: string | undefined, user: string): string | undefined { +function narrowedUrl( + baseUrl: string | undefined, + user: string, + password?: string, +): string | undefined { if (!baseUrl) return baseUrl; try { const parsed = new URL(baseUrl); parsed.username = user; - // Prefer this user's OWN password when configured; fall back to the URL's - // so deployments still sharing one secret across users keep working. - const own = process.env.CLICKHOUSE_DASHBOARD_PASSWORD; + // Prefer the caller-supplied password, then this user's own, then the + // URL's — so deployments still sharing one secret keep working. + const own = password ?? process.env.CLICKHOUSE_DASHBOARD_PASSWORD; if (own) parsed.password = own; return parsed.toString(); } catch { @@ -74,7 +81,20 @@ export function getClickHouseClient(): ClickHouseClient { */ export function getMutatingClickHouseClient(): ClickHouseClient { if (!mutatingClient) { - mutatingClient = createClickHouseClient(process.env.CLICKHOUSE_URL); + // Narrow to yavio_eraser when it is configured. That user holds exactly + // ALTER DELETE on default.events (CH migration 0012) — enough to erase, + // not enough to read what it erases, and nothing at all elsewhere. + // + // Falls back to the CLICKHOUSE_URL user when unset, so a deployment that + // has not yet run 0012 or set the password keeps erasing rather than + // silently failing — which is the failure mode this whole file exists to + // prevent. + const eraserPassword = process.env.CLICKHOUSE_ERASER_PASSWORD; + mutatingClient = createClickHouseClient( + eraserPassword + ? narrowedUrl(process.env.CLICKHOUSE_URL, ERASER_CH_USER, eraserPassword) + : process.env.CLICKHOUSE_URL, + ); } return mutatingClient; } diff --git a/packages/db/migrations/clickhouse/0012_eraser_user.sql b/packages/db/migrations/clickhouse/0012_eraser_user.sql new file mode 100644 index 0000000..cf31aca --- /dev/null +++ b/packages/db/migrations/clickhouse/0012_eraser_user.sql @@ -0,0 +1,30 @@ +-- Dedicated user for the erasure path, so the dashboard stops needing the +-- ClickHouse `default` superuser. +-- +-- Account, workspace and project deletion run `ALTER TABLE events DELETE` +-- (packages/dashboard/app/api/auth/account/route.ts and the two workspace +-- routes). Until now that went through getMutatingClickHouseClient(), which +-- keeps the user from CLICKHOUSE_URL — i.e. `default`, which is unrestricted. +-- The consequence is that the dashboard process holds a full-rights ClickHouse +-- credential in its environment for its whole lifetime, and the first deletion +-- opens a superuser connection. Any RCE, SSRF-to-localhost or env dump in the +-- Next.js process then yields DDL on the analytics store, up to DROP TABLE. +-- +-- yavio_eraser gets exactly one capability: delete rows from default.events. +-- No SELECT, no INSERT, no DDL, nothing on any other table. It cannot read the +-- data it is allowed to erase. +-- +-- NO PASSWORD IS SET HERE, deliberately. Migration 0007 created its siblings +-- with the literal 'yavio_dev', which is published in this public repository and +-- is exactly the defect the 2026-08-05 work had to unwind. A user with no +-- password cannot authenticate, so this fails closed: the operator sets +-- CLICKHOUSE_ERASER_PASSWORD (scripts/setup-env.sh generates it) and +-- migrate-clickhouse.ts applies it after migrations run, the same way +-- CLICKHOUSE_INGEST_PASSWORD and CLICKHOUSE_DASHBOARD_PASSWORD are applied. + +CREATE USER IF NOT EXISTS yavio_eraser IDENTIFIED WITH no_password; + +-- ALTER DELETE is the privilege ClickHouse checks for `ALTER TABLE ... DELETE` +-- (a lightweight mutation). Granting it alone means this identity can remove +-- rows and do nothing else — notably it cannot SELECT them first. +GRANT ALTER DELETE ON default.events TO yavio_eraser; diff --git a/packages/db/src/__tests__/ch-migrations.test.ts b/packages/db/src/__tests__/ch-migrations.test.ts index d1a5305..57f93d0 100644 --- a/packages/db/src/__tests__/ch-migrations.test.ts +++ b/packages/db/src/__tests__/ch-migrations.test.ts @@ -61,7 +61,7 @@ describe("ClickHouse migrations", () => { expect(await result.json()).toHaveLength(1); }); - it("records all 11 migration versions", async () => { + it("records all 12 migration versions", async () => { const ch = getClient(); const result = await ch.query({ query: "SELECT version FROM schema_migrations ORDER BY version", @@ -80,6 +80,7 @@ describe("ClickHouse migrations", () => { "0009", "0010", "0011", + "0012", ]); }); }); diff --git a/packages/db/src/migrate-clickhouse.ts b/packages/db/src/migrate-clickhouse.ts index 0cf1865..4d873c4 100644 --- a/packages/db/src/migrate-clickhouse.ts +++ b/packages/db/src/migrate-clickhouse.ts @@ -68,6 +68,7 @@ async function applyUserPasswords(): Promise { const users: Array<[user: string, envVar: string]> = [ ["yavio_ingest", "CLICKHOUSE_INGEST_PASSWORD"], ["yavio_dashboard", "CLICKHOUSE_DASHBOARD_PASSWORD"], + ["yavio_eraser", "CLICKHOUSE_ERASER_PASSWORD"], ]; for (const [user, envVar] of users) { diff --git a/scripts/setup-env.sh b/scripts/setup-env.sh index 2e8f96e..c755b4c 100755 --- a/scripts/setup-env.sh +++ b/scripts/setup-env.sh @@ -57,6 +57,7 @@ POSTGRES_APP_PASSWORD=$(generate_db_password) CLICKHOUSE_PASSWORD=$(generate_db_password) CLICKHOUSE_INGEST_PASSWORD=$(generate_db_password) CLICKHOUSE_DASHBOARD_PASSWORD=$(generate_db_password) +CLICKHOUSE_ERASER_PASSWORD=$(generate_db_password) # Replace values in .env. The trailing-comment form in .env.example # (`KEY=value # note`) is intentionally dropped for the secrets: a comment @@ -80,6 +81,7 @@ set_var POSTGRES_APP_PASSWORD "$POSTGRES_APP_PASSWORD" set_var CLICKHOUSE_PASSWORD "$CLICKHOUSE_PASSWORD" set_var CLICKHOUSE_INGEST_PASSWORD "$CLICKHOUSE_INGEST_PASSWORD" set_var CLICKHOUSE_DASHBOARD_PASSWORD "$CLICKHOUSE_DASHBOARD_PASSWORD" +set_var CLICKHOUSE_ERASER_PASSWORD "$CLICKHOUSE_ERASER_PASSWORD" # These two are host-side URLs used by scripts run OUTSIDE Docker (pnpm migrate # reads them via --env-file). They embed a password, so randomising the password