Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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}
Expand Down
37 changes: 33 additions & 4 deletions packages/dashboard/__tests__/clickhouse-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined>;

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") {
Expand Down Expand Up @@ -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");
Expand Down
30 changes: 25 additions & 5 deletions packages/dashboard/lib/clickhouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,24 @@ type ClickHouseClient = ReturnType<typeof createClickHouseClient>;
*/
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 {
Expand Down Expand Up @@ -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;
}
30 changes: 30 additions & 0 deletions packages/db/migrations/clickhouse/0012_eraser_user.sql
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 2 additions & 1 deletion packages/db/src/__tests__/ch-migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -80,6 +80,7 @@ describe("ClickHouse migrations", () => {
"0009",
"0010",
"0011",
"0012",
]);
});
});
Expand Down
1 change: 1 addition & 0 deletions packages/db/src/migrate-clickhouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ async function applyUserPasswords(): Promise<void> {
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) {
Expand Down
2 changes: 2 additions & 0 deletions scripts/setup-env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading