Merge pull request 'A withdrawn consumer keeps its data, in every provider that holds some (hq issue 241)' (#44) from fix/a-withdrawn-consumer-keeps-its-data into main

This commit was merged in pull request #44.
This commit is contained in:
2026-10-04 23:45:16 +00:00
14 changed files with 183 additions and 24 deletions
+10
View File
@@ -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<void> {
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<void> {
+2 -1
View File
@@ -54,7 +54,8 @@ runProvisioner("npm-package-registry", {
},
async remove(p: { as: string }): Promise<void> {
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).
+5
View File
@@ -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<void> {
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { enabled: false });
}
async deleteUser(email: string): Promise<void> {
await this.api("DELETE", `/user/${encodeURIComponent(email)}`);
}
+3 -1
View File
@@ -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).
+3 -8
View File
@@ -47,15 +47,10 @@ runProvisioner("s3-bucket", {
async remove(p: { as: string; derived: Readonly<Record<string, unknown>> }): Promise<void> {
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 });
},
+12
View File
@@ -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<void> {
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<void> {
await this.admin(async (client) => {
const target = client.db(database);
+3 -2
View File
@@ -41,8 +41,9 @@ runProvisioner("mongodb-database", {
},
async remove(p: { as: string }): Promise<void> {
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).
+8
View File
@@ -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<void> {
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<void> {
const dbs = await this.query(
`SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`,
+3 -2
View File
@@ -41,8 +41,9 @@ runProvisioner("mssql-database", {
},
async remove(p: { as: string }): Promise<void> {
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).
+40 -4
View File
@@ -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<void> {
/**
* 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<void> {
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 `<name>_deleted_<date>` 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<string> {
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<string, unknown>).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: `<name>_deleted_<yyyymmdd>`, 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;
}
+6 -2
View File
@@ -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<void> {
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).
+69
View File
@@ -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<string[]> =>
(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);
});
+17
View File
@@ -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 <name>_deleted_<date> 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 };
},
},
];
}
+2 -4
View File
@@ -31,9 +31,7 @@ runProvisioner("analytics", {
},
async remove(p: { as: string }): Promise<void> {
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`);
},
});