From 1fb7ca3d726a1371be4e2bde700a34fa1d32455b Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 5 Oct 2026 00:34:40 +0200 Subject: [PATCH] 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`); }, });