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/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 }; + }, + }, ]; } 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`); }, });