From 190d711a2a8a348a4ba9124033b8840edc1d8b34 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 5 Oct 2026 00:33:12 +0200 Subject: [PATCH 1/2] postgres: a withdrawn consumer keeps its database; retiring renames, never drops (hq issue 241) --- modules/postgres/client.ts | 44 ++++++++++++++-- modules/postgres/provisioner/index.ts | 8 ++- modules/postgres/test/withdraw.test.ts | 69 ++++++++++++++++++++++++++ modules/postgres/tools/index.ts | 17 +++++++ 4 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 modules/postgres/test/withdraw.test.ts diff --git a/modules/postgres/client.ts b/modules/postgres/client.ts index e38284d..cf60ea6 100644 --- a/modules/postgres/client.ts +++ b/modules/postgres/client.ts @@ -145,14 +145,43 @@ export class PostgresClient { } } - /** Drop a database and its owning role, idempotently, after evicting live connections. */ - async dropDatabaseAndRole(database: string, role: string): Promise { + /** + * Withdraw a consumer without destroying anything (novox/hq issue 241): its login can no longer log + * in and its open connections are ended, and its database stays exactly as it was, under its own + * name. A consumer that comes back is given the same database — create sets LOGIN again — which is + * what a provider must do when "no longer asked for" turns out to be a moment's misreading. + */ + async lockRole(role: string): Promise { + const roles = await this.query("SELECT 1 FROM pg_roles WHERE rolname = " + literal(role)); + if (roles.rows.length === 0) return; + await this.query(`ALTER ROLE ${ident(role)} NOLOGIN`); + await this.query( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = " + + literal(role) + " AND pid <> pg_backend_pid()", + ); + } + + /** + * Take a database out of service on purpose: rename it aside to `_deleted_` and lock + * its owner. Never a drop — the data stays on the server under the new name until a person + * removes it by hand. Returns the name it now has. + */ + async retireDatabase(database: string, now: Date = new Date()): Promise { + const found = await this.query( + "SELECT pg_get_userbyid(datdba) AS owner FROM pg_database WHERE datname = " + literal(database), + ); + if (found.rows.length === 0) throw new Error(`no database named ${database}`); + const owner = String((found.rows[0] as Record).owner ?? ""); + const aside = retiredName(database, now); + const taken = await this.query("SELECT 1 FROM pg_database WHERE datname = " + literal(aside)); + if (taken.rows.length > 0) throw new Error(`${aside} already exists; retire it by hand first`); + if (owner && owner !== "postgres") await this.query(`ALTER ROLE ${ident(owner)} NOLOGIN`); await this.query( "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = " + literal(database) + " AND pid <> pg_backend_pid()", ); - await this.query(`DROP DATABASE IF EXISTS ${ident(database)}`); - await this.query(`DROP ROLE IF EXISTS ${ident(role)}`); + await this.query(`ALTER DATABASE ${ident(database)} RENAME TO ${ident(aside)}`); + return aside; } /** List the non-template databases, with size, for the postgres_list_databases tool. */ @@ -301,3 +330,10 @@ function parseCsv(text: string): string[][] { endRecord(); return records; } + +/** The name a retired database is renamed to: `_deleted_`, within postgres's 63 bytes. */ +export function retiredName(database: string, now: Date = new Date()): string { + const stamp = now.toISOString().slice(0, 10).replace(/-/g, ""); + const suffix = `_deleted_${stamp}`; + return database.slice(0, 63 - suffix.length) + suffix; +} diff --git a/modules/postgres/provisioner/index.ts b/modules/postgres/provisioner/index.ts index 6c3916f..a2091a6 100644 --- a/modules/postgres/provisioner/index.ts +++ b/modules/postgres/provisioner/index.ts @@ -41,9 +41,13 @@ runProvisioner("postgres-database", { }); }, + // **Withdrawn, never dropped** (novox/hq issue 241). The login is locked and the database kept + // under its own name: on 2026-10-04 a misread contributions file withdrew every consumer at once, + // and dropping made that a loss of seven databases. Taking a database out of service is a person's + // act — the postgres_retire_database tool — and even that renames rather than drops. async remove(p: { as: string }): Promise { - await postgres.dropDatabaseAndRole(p.as, p.as); - await announce("database.deprovisioned", { database: p.as }); + await postgres.lockRole(p.as); + await announce("database.deprovisioned", { database: p.as, kept: "true" }); }, // Asked every minute by the harness: whether the backend still holds this consumer exactly as // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). diff --git a/modules/postgres/test/withdraw.test.ts b/modules/postgres/test/withdraw.test.ts new file mode 100644 index 0000000..bc97cf3 --- /dev/null +++ b/modules/postgres/test/withdraw.test.ts @@ -0,0 +1,69 @@ +// A withdrawn consumer keeps its database, and retiring one renames it — nothing here ever drops +// (novox/hq issue 241). psql is a fake on PATH that records every statement and answers the lookups +// these acts make; that the server keeps the data is proven against a real server, not here. +// Run against the compiled module (npm test builds first), the way the runtime loads it. + +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { PostgresClient, retiredName } from "../dist/client.js"; + +let dir: string; +let log: string; +const originalPath = process.env.PATH; + +before(async () => { + dir = await mkdtemp(join(tmpdir(), "postgres-withdraw-")); + log = join(dir, "calls.jsonl"); + await writeFile(join(dir, "psql"), `#!/usr/bin/env node +const fs = require("node:fs"); +const args = process.argv.slice(2); +const sql = args[args.indexOf("-c") + 1]; +fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({ sql }) + "\\n"); +if (/FROM pg_roles/.test(sql)) process.stdout.write("?column?\\n1\\n"); +else if (/pg_get_userbyid/.test(sql)) process.stdout.write("owner\\nmesh_anchor_mail\\n"); +else if (/FROM pg_database WHERE datname = '.*_deleted_/.test(sql)) process.stdout.write("?column?\\n"); +else process.stdout.write(""); +`); + await chmod(join(dir, "psql"), 0o755); + process.env.PATH = `${dir}:${originalPath}`; +}); + +after(async () => { + process.env.PATH = originalPath; + await rm(dir, { recursive: true, force: true }); +}); + +const statements = async (): Promise => + (await readFile(log, "utf8")).trim().split("\n").map((l) => (JSON.parse(l) as { sql: string }).sql); + +const client = (): PostgresClient => + new PostgresClient({ host: "127.0.0.1", port: 5432, user: "postgres", password: "admin-secret" }); + +test("withdrawing a consumer locks its login and keeps its database", async () => { + await writeFile(log, ""); + await client().lockRole("mesh_anchor_mail"); + const sql = await statements(); + assert.ok(sql.some((s) => /ALTER ROLE "mesh_anchor_mail" NOLOGIN/.test(s)), sql.join("\n")); + assert.ok(!sql.some((s) => /\bDROP\b/i.test(s)), `a withdrawal dropped something:\n${sql.join("\n")}`); +}); + +test("retiring a database renames it aside and locks its owner, and drops nothing", async () => { + await writeFile(log, ""); + const now = new Date("2026-10-05T09:00:00Z"); + const aside = await client().retireDatabase("mesh_anchor_mail", now); + assert.equal(aside, "mesh_anchor_mail_deleted_20261005"); + const sql = await statements(); + assert.ok(sql.some((s) => /ALTER DATABASE "mesh_anchor_mail" RENAME TO "mesh_anchor_mail_deleted_20261005"/.test(s)), sql.join("\n")); + assert.ok(sql.some((s) => /ALTER ROLE "mesh_anchor_mail" NOLOGIN/.test(s))); + assert.ok(!sql.some((s) => /\bDROP\b/i.test(s)), `retiring dropped something:\n${sql.join("\n")}`); +}); + +test("a retired name fits postgres's 63 bytes", () => { + const long = "x".repeat(70); + const name = retiredName(long, new Date("2026-10-05T00:00:00Z")); + assert.ok(name.length <= 63 && name.endsWith("_deleted_20261005"), name); +}); diff --git a/modules/postgres/tools/index.ts b/modules/postgres/tools/index.ts index a205658..3cdf8fe 100644 --- a/modules/postgres/tools/index.ts +++ b/modules/postgres/tools/index.ts @@ -30,6 +30,23 @@ export function getPostgresTools(postgres: PostgresClient): ToolDefinition[] { return { database, command: result.command, rows: result.rows }; }, }, + { + name: "postgres_retire_database", + description: + "Take one database out of service on purpose: rename it to _deleted_ and lock its owner's login. Nothing is dropped — the data stays on the server under the new name until a person removes it by hand. Repeat the database's name in confirm.", + input: { + database: { type: "string", description: "the database to retire" }, + confirm: { type: "string", description: "the same name again, to say this is meant" }, + }, + run: async (args) => { + const database = String(args.database ?? ""); + if (!database) throw new Error("postgres_retire_database: database is required"); + if (String(args.confirm ?? "") !== database) { + throw new Error("postgres_retire_database: confirm must repeat the database's name"); + } + return { database, renamedTo: await postgres.retireDatabase(database), dropped: false }; + }, + }, ]; } From 1fb7ca3d726a1371be4e2bde700a34fa1d32455b Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 5 Oct 2026 00:34:40 +0200 Subject: [PATCH 2/2] A withdrawn consumer keeps its data, in every provider that holds some (hq issue 241) mssql disables the login, mongodb takes the user's roles, minio revokes the key and keeps the bucket, mailu disables the mailbox, gitea prohibits the login instead of purging the user and their repositories, umami keeps the website. Each provider's create already enables what this locks. --- modules/gitea/client.ts | 10 ++++++++++ modules/gitea/provisioner/index.ts | 3 ++- modules/mailu/client.ts | 5 +++++ modules/mailu/provisioner/index.ts | 4 +++- modules/minio/provisioner/index.ts | 11 +++-------- modules/mongodb/client.ts | 12 ++++++++++++ modules/mongodb/provisioner/index.ts | 5 +++-- modules/mssql/client.ts | 8 ++++++++ modules/mssql/provisioner/index.ts | 5 +++-- modules/umami/provisioner/index.ts | 6 ++---- 10 files changed, 51 insertions(+), 18 deletions(-) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 160a41e..c43e6e8 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -564,6 +564,16 @@ export class GiteaAdmin { GiteaAdmin.fail(`/teams/${found.id}/members/${username}`, member); } + /** Withdraw a user and keep everything they own: login prohibited, which ensureUser undoes. */ + async prohibitLogin(username: string): Promise { + const res = await this.request(`/admin/users/${encodeURIComponent(username)}`, { + method: "PATCH", + body: JSON.stringify({ login_name: username, prohibit_login: true }), + }); + if (res.status === 200 || res.status === 404) return; + GiteaAdmin.fail(`/admin/users/${username}`, res); + } + /** Delete a user, purging what they own. A 404 means the mesh already withdrew them — success, not * an error, so a re-run of remove is safe. */ async deleteUser(username: string): Promise { diff --git a/modules/gitea/provisioner/index.ts b/modules/gitea/provisioner/index.ts index 1f234d1..fdd8b31 100644 --- a/modules/gitea/provisioner/index.ts +++ b/modules/gitea/provisioner/index.ts @@ -54,7 +54,8 @@ runProvisioner("npm-package-registry", { }, async remove(p: { as: string }): Promise { - await gitea.deleteUser(p.as); + // Login prohibited, never deleted (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once): deleting purges every repository the user owns. + await gitea.prohibitLogin(p.as); }, // Asked every minute by the harness: whether the backend still holds this consumer exactly as // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). diff --git a/modules/mailu/client.ts b/modules/mailu/client.ts index 4ec4cdb..f269c92 100644 --- a/modules/mailu/client.ts +++ b/modules/mailu/client.ts @@ -140,6 +140,11 @@ export class MailuClient { await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password, enabled: true }); } + /** Withdraw a mailbox and keep its mail: disabled, which applyProvisioned undoes. */ + async disableUser(email: string): Promise { + await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { enabled: false }); + } + async deleteUser(email: string): Promise { await this.api("DELETE", `/user/${encodeURIComponent(email)}`); } diff --git a/modules/mailu/provisioner/index.ts b/modules/mailu/provisioner/index.ts index bef72c3..191f2ea 100644 --- a/modules/mailu/provisioner/index.ts +++ b/modules/mailu/provisioner/index.ts @@ -75,7 +75,9 @@ runProvisioner("smtp", { // exists, and left otherwise — a mailbox holding mail is the one thing a background loop // must not guess about (this module's own events file says the same). Withdrawal of a // named-account consumer is an operator action until the harness carries values here. - await mailu.deleteUser(`${p.as}@${domain()}`).catch(() => {}); + // Disabled, never deleted (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once): a mailbox holding mail is the one thing a background loop must + // not destroy. applyProvisioned enables it again when the consumer returns. + await mailu.disableUser(`${p.as}@${domain()}`).catch(() => {}); }, // Asked every minute by the harness: whether the backend still holds this consumer exactly as // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). diff --git a/modules/minio/provisioner/index.ts b/modules/minio/provisioner/index.ts index fca498d..89a3f67 100644 --- a/modules/minio/provisioner/index.ts +++ b/modules/minio/provisioner/index.ts @@ -47,15 +47,10 @@ runProvisioner("s3-bucket", { async remove(p: { as: string; derived: Readonly> }): Promise { const bucket = bucketNamed(p.derived); - // Revoking the key is what cuts the consumer's access. The bucket is emptied-then-dropped only if - // empty; a bucket that still holds objects is left for an operator rather than erroring on every - // reconcile tick — access is already gone, and silently deleting a consumer's data would be worse. + // Revoking the key is what cuts the consumer's access, and the bucket is kept, empty or not + // (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once). A bucket is removed by a person, never by this loop. try { await minio.removeAccessKey(p.as); } catch { /* already gone */ } - try { - await minio.removeBucket(bucket); - } catch (err) { - console.error(`[minio] bucket ${bucket} not removed (likely non-empty), access revoked: ${err}`); - } + console.error(`[minio] ${p.as} withdrawn: access key revoked, bucket ${bucket} kept`); await announce("bucket.removed", { bucket, accessKey: p.as }); }, diff --git a/modules/mongodb/client.ts b/modules/mongodb/client.ts index 946de94..dd6fbcc 100644 --- a/modules/mongodb/client.ts +++ b/modules/mongodb/client.ts @@ -125,6 +125,18 @@ export class MongoClient { /** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the * user is removed first so a re-grant of the same login starts clean. */ + /** Withdraw a consumer and keep its database: the user keeps its name and loses every role. */ + async lockUser(database: string, user: string): Promise { + await this.admin(async (client) => { + const target = client.db(database); + try { + await target.command({ updateUser: user, roles: [] }); + } catch (err) { + if (!(err instanceof MongoServerError && err.code === 11)) throw err; // 11: UserNotFound + } + }); + } + async dropDatabaseAndUser(database: string, user: string): Promise { await this.admin(async (client) => { const target = client.db(database); diff --git a/modules/mongodb/provisioner/index.ts b/modules/mongodb/provisioner/index.ts index 3b905a1..663dcd0 100644 --- a/modules/mongodb/provisioner/index.ts +++ b/modules/mongodb/provisioner/index.ts @@ -41,8 +41,9 @@ runProvisioner("mongodb-database", { }, async remove(p: { as: string }): Promise { - await mongo.dropDatabaseAndUser(p.as, p.as); - await announce("database.deprovisioned", { database: p.as }); + // Locked, never dropped (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once). create gives the roles back. + await mongo.lockUser(p.as, p.as); + await announce("database.deprovisioned", { database: p.as, kept: "true" }); }, // Asked every minute by the harness: whether the backend still holds this consumer exactly as // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). diff --git a/modules/mssql/client.ts b/modules/mssql/client.ts index 2fbb208..afa26d1 100644 --- a/modules/mssql/client.ts +++ b/modules/mssql/client.ts @@ -256,6 +256,14 @@ export class MssqlClient { } /** Drop a database and its login, idempotently, after evicting live connections. */ + /** Withdraw a consumer and keep its database: its login is disabled, which create undoes. */ + async disableLogin(login: string): Promise { + const logins = await this.query( + `SELECT 1 AS ok FROM sys.server_principals WHERE name = ${literal(login)}`, + ); + if (logins.length > 0) await this.exec(`ALTER LOGIN ${ident(login)} DISABLE`); + } + async dropDatabaseAndLogin(database: string, login: string): Promise { const dbs = await this.query( `SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`, diff --git a/modules/mssql/provisioner/index.ts b/modules/mssql/provisioner/index.ts index 9ef31aa..917503a 100644 --- a/modules/mssql/provisioner/index.ts +++ b/modules/mssql/provisioner/index.ts @@ -41,8 +41,9 @@ runProvisioner("mssql-database", { }, async remove(p: { as: string }): Promise { - await mssql.dropDatabaseAndLogin(p.as, p.as); - await announce("database.deprovisioned", { database: p.as }); + // Disabled, never dropped (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once). create enables the login again. + await mssql.disableLogin(p.as); + await announce("database.deprovisioned", { database: p.as, kept: "true" }); }, // Asked every minute by the harness: whether the backend still holds this consumer exactly as // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). diff --git a/modules/umami/provisioner/index.ts b/modules/umami/provisioner/index.ts index de13f96..c84b2bf 100644 --- a/modules/umami/provisioner/index.ts +++ b/modules/umami/provisioner/index.ts @@ -31,9 +31,7 @@ runProvisioner("analytics", { }, async remove(p: { as: string }): Promise { - const token = await umami.getToken(); - // Keyed on the mesh-derived login, the one identity the harness carries into removal. - const site = await umami.findWebsite(token, p.as); - if (site) await umami.deleteWebsite(token, site.id); + // The website and its analytics are kept (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once); a person deletes a site, never this loop. + console.error(`[umami] ${p.as} withdrawn: website and its analytics kept`); }, });